@cohortapp/agent-sdk 2.3.2 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (148) hide show
  1. package/framework-features.json +30 -0
  2. package/lib/backlog.mjs +136 -0
  3. package/lib/cadences.mjs +63 -2
  4. package/lib/cadences.test.mjs +105 -0
  5. package/lib/capability/inventory.mjs +542 -0
  6. package/lib/capability/inventory.test.mjs +232 -0
  7. package/lib/capability/probe.mjs +255 -0
  8. package/lib/channels/contract.mjs +37 -1
  9. package/lib/channels/contract.test.mjs +25 -1
  10. package/lib/claude-bin.mjs +37 -3
  11. package/lib/claude-bin.test.mjs +42 -8
  12. package/lib/execution/disposition.mjs +501 -0
  13. package/lib/execution/disposition.test.mjs +482 -0
  14. package/lib/execution/drive.mjs +352 -0
  15. package/lib/execution/drive.test.mjs +270 -0
  16. package/lib/execution/effects.mjs +340 -0
  17. package/lib/execution/effects.test.mjs +193 -0
  18. package/lib/execution/index.mjs +152 -0
  19. package/lib/execution/intake.mjs +581 -0
  20. package/lib/execution/intake.test.mjs +343 -0
  21. package/lib/execution/journal.mjs +374 -0
  22. package/lib/execution/journal.test.mjs +261 -0
  23. package/lib/execution/match.mjs +331 -0
  24. package/lib/execution/match.test.mjs +235 -0
  25. package/lib/execution/pipeline.mjs +341 -0
  26. package/lib/execution/pipeline.test.mjs +389 -0
  27. package/lib/execution/route.mjs +332 -0
  28. package/lib/execution/route.test.mjs +186 -0
  29. package/lib/execution/surface-policy.mjs +446 -0
  30. package/lib/execution/surface-policy.test.mjs +162 -0
  31. package/lib/goals/admission.mjs +209 -0
  32. package/lib/goals/admission.test.mjs +139 -0
  33. package/lib/goals/classify.mjs +206 -0
  34. package/lib/goals/classify.test.mjs +109 -0
  35. package/lib/goals/collaborate.mjs +415 -0
  36. package/lib/goals/collaborate.test.mjs +324 -0
  37. package/lib/goals/gaps.mjs +111 -0
  38. package/lib/goals/gaps.test.mjs +284 -0
  39. package/lib/goals/loop.mjs +537 -0
  40. package/lib/goals/loop.test.mjs +719 -0
  41. package/lib/identity/persona.mjs +247 -0
  42. package/lib/identity/persona.test.mjs +117 -0
  43. package/lib/kpi.mjs +469 -0
  44. package/lib/kpi.test.mjs +244 -0
  45. package/lib/mandate/audit.mjs +168 -0
  46. package/lib/mandate/audit.test.mjs +195 -0
  47. package/lib/mandate/cache.mjs +162 -0
  48. package/lib/mandate/derive.mjs +317 -0
  49. package/lib/mandate/derive.test.mjs +224 -0
  50. package/lib/mandate/model.mjs +352 -0
  51. package/lib/mandate/model.test.mjs +145 -0
  52. package/lib/mandate/refresh.mjs +187 -0
  53. package/lib/mandate/refresh.test.mjs +293 -0
  54. package/lib/mcp/server.test.mjs +4 -4
  55. package/lib/org/approvals.mjs +14 -2
  56. package/lib/org/client.mjs +58 -22
  57. package/lib/org/client.test.mjs +3 -1
  58. package/lib/org/inbound/directedness.mjs +720 -0
  59. package/lib/org/inbound/directedness.test.mjs +543 -0
  60. package/lib/org/inbound/facts.mjs +501 -0
  61. package/lib/org/inbound/facts.test.mjs +375 -0
  62. package/lib/org/inbound/hydrate.mjs +535 -0
  63. package/lib/org/inbound/hydrate.test.mjs +326 -0
  64. package/lib/org/inbound/index.mjs +233 -0
  65. package/lib/org/inbound/index.test.mjs +324 -0
  66. package/lib/org/inbound/io.mjs +141 -0
  67. package/lib/org/inbound/project.mjs +201 -0
  68. package/lib/org/inbound/project.test.mjs +287 -0
  69. package/lib/org/inbound/surfaces.mjs +257 -0
  70. package/lib/org/knowledge.mjs +10 -1
  71. package/lib/org/knowledge.test.mjs +8 -1
  72. package/lib/org/leases.mjs +5 -0
  73. package/lib/org/mesh.mjs +17 -2
  74. package/lib/org/messaging.mjs +40 -4
  75. package/lib/org/messaging.test.mjs +40 -0
  76. package/lib/org/param-contract.mjs +694 -0
  77. package/lib/org/param-contract.test.mjs +451 -0
  78. package/lib/org/protocol.checksum +1 -1
  79. package/lib/org/protocol.mjs +8 -0
  80. package/lib/org/protocol.test.mjs +5 -1
  81. package/lib/org/push.mjs +1025 -0
  82. package/lib/org/push.test.mjs +690 -0
  83. package/lib/org/tool-surface.mjs +138 -38
  84. package/lib/org/tool-surface.test.mjs +13 -8
  85. package/lib/org/typing.mjs +341 -0
  86. package/lib/org/typing.test.mjs +291 -0
  87. package/lib/plan/compile.mjs +510 -0
  88. package/lib/plan/compile.test.mjs +286 -0
  89. package/lib/plan/emit.mjs +256 -0
  90. package/lib/plan/emit.test.mjs +246 -0
  91. package/lib/plan/explain.mjs +226 -0
  92. package/lib/plan/explain.test.mjs +188 -0
  93. package/lib/plan/schema.mjs +140 -0
  94. package/lib/resource-governor.mjs +47 -1
  95. package/lib/resource-governor.test.mjs +21 -1
  96. package/lib/setup/enroll-from-cohort.mjs +84 -16
  97. package/lib/setup/enroll-from-cohort.test.mjs +43 -1
  98. package/lib/setup/sections/identity.mjs +15 -4
  99. package/lib/setup/sections/identity.test.mjs +94 -0
  100. package/lib/setup/sections/inventory.mjs +178 -0
  101. package/lib/setup/sections/inventory.test.mjs +198 -0
  102. package/lib/setup/sections/mandate.mjs +392 -0
  103. package/lib/setup/sections/mandate.test.mjs +373 -0
  104. package/lib/setup/sections/subagents.mjs +427 -0
  105. package/lib/setup/sections/subagents.test.mjs +429 -0
  106. package/lib/setup/sections/verify.mjs +121 -0
  107. package/lib/setup/sections/verify.test.mjs +175 -0
  108. package/lib/setup/sot.mjs +2 -0
  109. package/lib/subagents/cli.mjs +463 -0
  110. package/lib/subagents/cli.test.mjs +389 -0
  111. package/lib/subagents/client.mjs +373 -0
  112. package/lib/subagents/client.test.mjs +309 -0
  113. package/lib/subagents/gap.mjs +268 -0
  114. package/lib/subagents/gap.test.mjs +234 -0
  115. package/lib/subagents/lock.mjs +296 -0
  116. package/lib/subagents/lock.test.mjs +248 -0
  117. package/lib/subagents/manifest.mjs +224 -0
  118. package/lib/subagents/manifest.test.mjs +175 -0
  119. package/lib/subagents/refs.mjs +274 -0
  120. package/lib/subagents/refs.test.mjs +204 -0
  121. package/lib/subagents/resolve.mjs +455 -0
  122. package/lib/subagents/resolve.test.mjs +422 -0
  123. package/lib/subagents/schema.mjs +467 -0
  124. package/lib/subagents/schema.test.mjs +306 -0
  125. package/package.json +8 -3
  126. package/plugins/maestro-skills/.claude-plugin/marketplace.json +16 -0
  127. package/policies/ai-disclosure.yaml +42 -2
  128. package/scaffold/CLAUDE.md +16 -2
  129. package/schedules/triggers/goal-steward.md +79 -0
  130. package/scripts/ci/conformance-org-api.mjs +792 -0
  131. package/scripts/ci/conformance-org-api.test.mjs +417 -0
  132. package/scripts/daemon/agent-daemon.mjs +36 -4
  133. package/scripts/daemon/cadence-handlers.mjs +145 -1
  134. package/scripts/daemon/goal-steward-cadence.test.mjs +243 -0
  135. package/scripts/daemon/inbox-deferral.mjs +45 -2
  136. package/scripts/daemon/inbox-deferral.test.mjs +56 -0
  137. package/scripts/daemon/inbox-wake.mjs +282 -0
  138. package/scripts/daemon/inbox-wake.test.mjs +199 -0
  139. package/scripts/daemon/prompt-builder.mjs +41 -1
  140. package/scripts/daemon/typing-registry.mjs +55 -2
  141. package/scripts/daemon/typing-registry.test.mjs +25 -0
  142. package/scripts/local-triggers/generate-plists.test.mjs +5 -5
  143. package/scripts/setup/gen-subagent-manifest.mjs +95 -0
  144. package/scripts/setup/gen-subagent-manifest.test.mjs +124 -0
  145. package/scripts/setup/generate-plan.mjs +108 -0
  146. package/scripts/setup/init-capability-manifest.mjs +70 -0
  147. package/scripts/setup/init-skill-marketplace.mjs +155 -0
  148. package/scripts/setup/init-skill-marketplace.test.mjs +193 -0
@@ -0,0 +1,792 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * conformance-org-api.mjs — the LIVE wire-contract conformance probe.
4
+ *
5
+ * WHY THIS CANNOT BE A UNIT TEST
6
+ * ------------------------------
7
+ * hq validates every `/v1/<method>` body with a zod schema that does NOT ship to
8
+ * the SDK. `lib/org/param-contract.mjs` is this side's best transcription of
9
+ * those schemas — and a transcription cannot prove itself right. The 2026-08
10
+ * audit found 18 methods where the name the SDK sent and the name hq reads had
11
+ * diverged; every one of them passed `npm test` on the day it shipped, because
12
+ * both halves of the mistake lived on this side of the wire.
13
+ *
14
+ * So: drive a REAL org. Call each method with a minimal, well-formed payload and
15
+ * read the server's own verdict.
16
+ *
17
+ * THE DISCRIMINATOR (the whole idea)
18
+ * ----------------------------------
19
+ * A probe against a live org gets errors for perfectly legitimate reasons — the
20
+ * key has no scope for that family, the placeholder id doesn't exist, governance
21
+ * isn't ready. Those are NOT failures. Exactly one class of error is:
22
+ *
23
+ * BAD_REQUEST citing a param name we did not send → hq requires a name we
24
+ * don't know about (FAIL)
25
+ * BAD_REQUEST "Unrecognized key(s): 'x'" for a key
26
+ * we DID send → hq's schema is .strict()
27
+ * and rejects our name (FAIL)
28
+ * BAD_REQUEST citing a param we DID send → a VALUE complaint about
29
+ * our placeholder (warn)
30
+ * FORBIDDEN_SCOPE / NOT_FOUND / CONFLICT /
31
+ * UNAUTHORIZED / GOVERNANCE_NOT_READY / RATE_LIMITED → the schema PASSED; the
32
+ * handler then refused for
33
+ * an unrelated reason (skip)
34
+ * ok:true → pass
35
+ *
36
+ * `classifyFrame` is pure and unit-tested (`conformance-org-api.test.mjs`); the
37
+ * network is just how it gets its input.
38
+ *
39
+ * SAFETY — why this is not a write tool
40
+ * -------------------------------------
41
+ * Validation runs BEFORE the handler touches the database, so a side-effecting
42
+ * method can be probed harmlessly by pointing it at an entity that cannot exist:
43
+ * zod validates (the thing under test), the handler then 404s (the thing we
44
+ * ignore) and nothing is written. Every default probe is either read-scope or
45
+ * gated behind a `probe-<uuid>` id of that kind — marked `writes: false`.
46
+ *
47
+ * Probes that WOULD durably create a row (decision.propose, board.createTask,
48
+ * knowledge.append, memory.author, approval.request, lease.claim,
49
+ * registry.register — the ones with no entity reference to poison) are marked
50
+ * `writes: true` and are SKIPPED unless `--write` is passed. Outbound-content
51
+ * methods are never probed at all: a `messaging.send` probe is safe only because
52
+ * its channel does not exist, and that is asserted, not assumed.
53
+ *
54
+ * USAGE
55
+ * node scripts/ci/conformance-org-api.mjs # read-safe probes
56
+ * node scripts/ci/conformance-org-api.mjs --write # + row-creating probes
57
+ * node scripts/ci/conformance-org-api.mjs --json # machine-readable
58
+ * node scripts/ci/conformance-org-api.mjs --verbose # every verdict
59
+ * node scripts/ci/conformance-org-api.mjs --only=messaging,board
60
+ * node scripts/ci/conformance-org-api.mjs --agent-root=/path/to/agent
61
+ *
62
+ * CREDENTIALS resolve exactly as the runtime does (lib/org/client#configFromAgent):
63
+ * COHORT_BASE / COHORT_API_URL → config base → https://os.cohortapp.com
64
+ * COHORT_API_TOKEN / COHORT_TOKEN / COHORT_API_KEY
65
+ * COHORT_ORG_ID
66
+ * With no credentials the probe SKIPS cleanly (exit 0) so it can sit in a CI job
67
+ * that has no org secret — it is a gate for the environments that do.
68
+ *
69
+ * EXIT CODES: 0 = no drift (or skipped), 1 = drift found, 2 = bad invocation.
70
+ *
71
+ * Node builtins only. ESM.
72
+ *
73
+ * @module scripts/ci/conformance-org-api
74
+ */
75
+
76
+ "use strict";
77
+
78
+ import { randomUUID } from "node:crypto";
79
+ import { dirname, join } from "node:path";
80
+ import { fileURLToPath } from "node:url";
81
+
82
+ import { call, read, loadOrgConfig, configFromAgent } from "../../lib/org/client.mjs";
83
+ import { READS, methodDef } from "../../lib/org/protocol.mjs";
84
+ import { PARAM_CONTRACT, normalizeParams } from "../../lib/org/param-contract.mjs";
85
+
86
+ const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
87
+
88
+ /** Error codes that mean "your params were fine, the request was refused later". */
89
+ export const SCHEMA_PASSED_CODES = new Set([
90
+ "FORBIDDEN_SCOPE",
91
+ "NOT_FOUND",
92
+ "CONFLICT",
93
+ "GOVERNANCE_NOT_READY",
94
+ "RATE_LIMITED",
95
+ "LEASE_HELD",
96
+ "IDEMPOTENT_REPLAY",
97
+ ]);
98
+
99
+ /**
100
+ * Codes that mean "we never actually tested anything".
101
+ *
102
+ * UNAUTHORIZED used to sit in the set above, and it is what hq returns for a bad
103
+ * bearer key on EVERY method and every GET read. So a rotated or revoked
104
+ * COHORT_API_TOKEN made all 36 method probes `skip`, all 12 read probes `pass`,
105
+ * and the run print "No wire-contract drift." and exit 0 — forever, while
106
+ * probing nothing. A gate that greens on a dead credential is worse than no
107
+ * gate, because it is trusted. The no-credential path was already loud; this is
108
+ * the bad-credential path, which was silent and indistinguishable from success.
109
+ */
110
+ export const CREDENTIAL_FAILURE_CODES = new Set(["UNAUTHORIZED"]);
111
+
112
+ /**
113
+ * An id shaped like ours but which CANNOT exist — the safety primitive that makes
114
+ * probing a side-effecting method harmless. The literal "probe" marker is load-
115
+ * bearing: the safety test greps for it, and an operator seeing one in a log
116
+ * knows instantly it came from here.
117
+ * @param {string} [kind]
118
+ * @returns {string}
119
+ */
120
+ export function ghostId(kind = "id") {
121
+ return `probe-${kind}-${randomUUID()}`;
122
+ }
123
+
124
+ /** Does this payload reference a ghost id anywhere? Pure (safety assertion). */
125
+ export function referencesGhost(params) {
126
+ try {
127
+ return JSON.stringify(params || {}).includes("probe-");
128
+ } catch {
129
+ return false;
130
+ }
131
+ }
132
+
133
+ // ---------------------------------------------------------------------------
134
+ // verdict classification (PURE — the unit-tested core)
135
+ // ---------------------------------------------------------------------------
136
+
137
+ /**
138
+ * Pull the param names an hq error message is complaining about.
139
+ *
140
+ * hq produces four shapes:
141
+ * zod via parse(): "channelId: Required" / "why.reason: Required"
142
+ * zod strict: "Unrecognized key(s) in object: 'note', 'stray'"
143
+ * manual guards: "title is required" / "actionClass is required"
144
+ * enum guards: "memoryClass must be one of: framework, …"
145
+ *
146
+ * @param {string} message
147
+ * @returns {{cited:string[], unrecognized:string[]}}
148
+ */
149
+ export function citedParams(message) {
150
+ const msg = String(message || "");
151
+ const cited = new Set();
152
+ const unrecognized = new Set();
153
+
154
+ // zod .strict(): "Unrecognized key(s) in object: 'note', 'stray'"
155
+ const unrec = msg.match(/Unrecognized key\(s\)[^:]*:\s*(.+)$/i);
156
+ if (unrec) {
157
+ for (const m of unrec[1].matchAll(/['"`]([A-Za-z0-9_]+)['"`]/g)) unrecognized.add(m[1]);
158
+ }
159
+
160
+ // zod path prefix: "channelId: Required" / "participantIds.0: Required"
161
+ const path = msg.match(/^([A-Za-z_][A-Za-z0-9_]*)(?:\.[A-Za-z0-9_]+)*\s*:/);
162
+ if (path) cited.add(path[1]);
163
+
164
+ // manual guards: "<name> is required" / "<name> must be …"
165
+ for (const m of msg.matchAll(/\b([A-Za-z_][A-Za-z0-9_]*)\s+(?:is required|must be|must have)/g)) {
166
+ cited.add(m[1]);
167
+ }
168
+
169
+ return { cited: [...cited], unrecognized: [...unrecognized] };
170
+ }
171
+
172
+ /**
173
+ * Classify one probe result. PURE — no I/O, no clock, no randomness.
174
+ *
175
+ * @param {object} probe the probe descriptor ({method, params, …})
176
+ * @param {object} sent the params actually put on the wire (post-contract)
177
+ * @param {object} frame the res frame hq returned
178
+ * @returns {{verdict:"pass"|"fail"|"warn"|"skip", reason:string, detail?:string}}
179
+ */
180
+ export function classifyFrame(probe, sent, frame) {
181
+ const method = probe && probe.method;
182
+ if (!frame || typeof frame !== "object") {
183
+ return { verdict: "warn", reason: "no frame returned (transport)" };
184
+ }
185
+ if (frame.ok) return { verdict: "pass", reason: "accepted" };
186
+
187
+ const code = (frame.error && frame.error.code) || "INTERNAL";
188
+ const message = (frame.error && frame.error.message) || "";
189
+
190
+ // A credential failure proves NOTHING about the wire contract: hq rejects at
191
+ // auth, before any schema runs. It must be its own verdict so the runner can
192
+ // refuse to call the whole run green.
193
+ if (CREDENTIAL_FAILURE_CODES.has(code)) {
194
+ return { verdict: "credential", reason: `${code} — rejected at auth, nothing was tested`, detail: message };
195
+ }
196
+ if (SCHEMA_PASSED_CODES.has(code)) {
197
+ return { verdict: "skip", reason: `${code} — schema accepted, handler declined`, detail: message };
198
+ }
199
+ if (code !== "BAD_REQUEST") {
200
+ return { verdict: "warn", reason: `${code}`, detail: message };
201
+ }
202
+
203
+ // ── BAD_REQUEST: the one code that can mean param-name drift ──────────────
204
+ const sentKeys = new Set(Object.keys(sent || {}));
205
+ const { cited, unrecognized } = citedParams(message);
206
+
207
+ // (a) hq's .strict() schema rejected a key we sent → our name is wrong.
208
+ const rejected = unrecognized.filter((k) => sentKeys.has(k));
209
+ if (rejected.length) {
210
+ return {
211
+ verdict: "fail",
212
+ reason: `hq REJECTS the param name(s) we send: ${rejected.join(", ")}`,
213
+ detail: message,
214
+ };
215
+ }
216
+ if (unrecognized.length) {
217
+ return { verdict: "fail", reason: `hq rejected unknown key(s): ${unrecognized.join(", ")}`, detail: message };
218
+ }
219
+
220
+ // (b) hq demands a name we never sent → we are missing/misnaming a param.
221
+ const unknownToUs = cited.filter((k) => !sentKeys.has(k));
222
+ if (unknownToUs.length) {
223
+ return {
224
+ verdict: "fail",
225
+ reason: `hq requires param(s) we do not send: ${unknownToUs.join(", ")}`,
226
+ detail: message,
227
+ };
228
+ }
229
+
230
+ // (c) hq complained about a param we DID send → a value complaint about the
231
+ // probe's placeholder, not a name mismatch. Not drift.
232
+ if (cited.length) {
233
+ return { verdict: "warn", reason: `value rejected for ${cited.join(", ")} (probe placeholder)`, detail: message };
234
+ }
235
+
236
+ // (d) an unparseable BAD_REQUEST. Refuse to call it a pass — a human looks.
237
+ return { verdict: "warn", reason: "BAD_REQUEST (unparsed)", detail: message };
238
+ }
239
+
240
+ // ---------------------------------------------------------------------------
241
+ // the probe table
242
+ // ---------------------------------------------------------------------------
243
+
244
+ /**
245
+ * A probe is `{method, params, writes, guard, why}`.
246
+ * writes:false → the handler cannot durably write. `guard` says WHY, and the
247
+ * unit test enforces it:
248
+ * "read" — read-scope; nothing to write.
249
+ * "ghost" — every write path is gated behind an id that
250
+ * cannot exist, so validation runs and the
251
+ * handler then 404s. Params MUST carry one.
252
+ * ONLY SOUND when the handler REQUIRES that
253
+ * entity to exist: a create-or-upsert method
254
+ * (knowledge.replace) treats an unknown id as
255
+ * CREATE, so a ghost id guarantees a row
256
+ * instead of preventing one — those are
257
+ * `writes:true`.
258
+ * "audit-only" — appends a read-audit event and nothing else
259
+ * (knowledge.search is side-effecting by
260
+ * design: retrieval is logged).
261
+ * "no-op" — a documented no-op for a resource we do not
262
+ * hold (lease.release).
263
+ * writes:true → running it creates a real row. `--write` only.
264
+ *
265
+ * Every method carrying a PARAM_CONTRACT entry is probed (that is the point);
266
+ * the guard test in `param-contract.test.mjs` asserts the coverage.
267
+ *
268
+ * @returns {object[]}
269
+ */
270
+ export function buildProbes() {
271
+ const ghostChannel = ghostId("chan");
272
+ const ghostTask = ghostId("task");
273
+ const ghostItem = ghostId("item");
274
+ const ghostDecision = ghostId("dec");
275
+ const ghostMember = ghostId("mem");
276
+ const ghostCall = ghostId("call");
277
+ const hash64 = "0".repeat(64);
278
+
279
+ return [
280
+ // ── read-scope: harmless by construction ────────────────────────────────
281
+ { method: "messaging.channels", params: {}, writes: false, guard: "read", why: "roster read" },
282
+ {
283
+ method: "messaging.history",
284
+ params: { channelId: ghostChannel, limit: 1 },
285
+ writes: false,
286
+ guard: "ghost",
287
+ why: "channel does not exist → NOT_FOUND after validation",
288
+ },
289
+ { method: "member.get", params: { slug: ghostId("slug") }, writes: false, guard: "ghost", why: "slug lookup 404s" },
290
+ {
291
+ method: "knowledge.search",
292
+ params: { q: "conformance probe", limit: 1 },
293
+ writes: false,
294
+ guard: "audit-only",
295
+ why: "retrieval is audited by design; it creates no domain row",
296
+ },
297
+ { method: "integration.toolsetVersion", params: {}, writes: false, guard: "read", why: "version read" },
298
+
299
+ // ── side-effecting, but poisoned with a ghost id → validation only ───────
300
+ {
301
+ method: "messaging.send",
302
+ params: { channelId: ghostChannel, body: "conformance probe — never delivered", idempotencyId: randomUUID() },
303
+ writes: false,
304
+ guard: "ghost",
305
+ why: "channel cannot exist: assertChannelAccess 404s before any Message row",
306
+ },
307
+ {
308
+ method: "messaging.react",
309
+ params: { messageId: ghostId("msg"), emoji: "+1" },
310
+ writes: false,
311
+ guard: "ghost",
312
+ why: "message 404s after validation",
313
+ },
314
+ {
315
+ method: "calling.start",
316
+ params: { channelId: ghostChannel },
317
+ writes: false,
318
+ guard: "ghost",
319
+ why: "channel is resolved (and 404s) before the Call row is created",
320
+ },
321
+ { method: "calling.join", params: { callId: ghostCall }, writes: false, guard: "ghost", why: "call 404s" },
322
+ {
323
+ method: "calling.invite",
324
+ params: { callId: ghostCall, memberIds: [ghostMember] },
325
+ writes: false,
326
+ guard: "ghost",
327
+ why: "call 404s after validation",
328
+ },
329
+ {
330
+ method: "messaging.edit",
331
+ params: { messageId: ghostId("msg"), body: "conformance probe" },
332
+ writes: false,
333
+ guard: "ghost",
334
+ why: "message 404s after validation",
335
+ },
336
+ {
337
+ method: "board.claim",
338
+ params: { itemId: ghostItem },
339
+ writes: false,
340
+ guard: "ghost",
341
+ why: "requireItem 404s after validation",
342
+ },
343
+ {
344
+ method: "board.assign",
345
+ params: { itemId: ghostItem, assignee: ghostMember },
346
+ writes: false,
347
+ guard: "ghost",
348
+ why: "STRICT schema; governance-gated, then requireItem 404s",
349
+ },
350
+ {
351
+ method: "approval.resolve",
352
+ params: { id: ghostId("ap"), decision: "reject" },
353
+ writes: false,
354
+ guard: "ghost",
355
+ why: "approval 404s after validation",
356
+ },
357
+ // knowledge.replace is CREATE-or-CAS, NOT a pure CAS: hq's handler treats a
358
+ // fact that does not exist as the CREATE branch and appends `knowledge.created`
359
+ // at version 1 (src/server/methods/knowledge/replace.ts). A ghost id therefore
360
+ // does not make it harmless — it GUARANTEES a durable row, which is the exact
361
+ // opposite of the guard's assumption. This probe was mis-declared `writes:false`
362
+ // and a read-only run created a junk fact in the live org before it was caught.
363
+ //
364
+ // THE RULE the `ghost` guard depends on: it is only sound when the handler
365
+ // REQUIRES the referenced entity to exist. Any create-or-upsert method must be
366
+ // `writes:true`, no matter how unreachable its id looks.
367
+ {
368
+ method: "knowledge.replace",
369
+ params: { id: ghostId("epi"), body: "conformance probe", expectedVersion: 1 },
370
+ writes: true,
371
+ guard: "write",
372
+ why: "create-or-CAS: a nonexistent id CREATES the fact rather than 404ing",
373
+ },
374
+ {
375
+ method: "knowledge.rewrite",
376
+ params: { id: ghostId("epi"), body: "conformance probe" },
377
+ writes: false,
378
+ guard: "ghost",
379
+ why: "owner-only rewrite of a fact that cannot exist → 404",
380
+ },
381
+ {
382
+ method: "lease.heartbeat",
383
+ params: { scope: "work-claim", resourceId: ghostId("res") },
384
+ writes: false,
385
+ guard: "no-op",
386
+ why: "sliding a lease we do not hold changes nothing",
387
+ },
388
+ {
389
+ method: "board.complete",
390
+ params: { itemId: ghostItem, proof: { note: "conformance probe" } },
391
+ writes: false,
392
+ guard: "ghost",
393
+ why: "STRICT schema — the whole point; requireItem then 404s",
394
+ },
395
+ {
396
+ method: "board.updateTask",
397
+ params: { taskId: ghostTask, col: "review", detail: "conformance probe" },
398
+ writes: false,
399
+ guard: "ghost",
400
+ why: "task 404s after validation",
401
+ },
402
+ {
403
+ method: "board.assignTask",
404
+ params: { taskId: ghostTask, assigneeId: ghostMember },
405
+ writes: false,
406
+ guard: "ghost",
407
+ why: "task 404s after validation",
408
+ },
409
+ {
410
+ method: "board.moveTask",
411
+ params: { taskId: ghostTask, col: "todo" },
412
+ writes: false,
413
+ guard: "ghost",
414
+ why: "task 404s after validation",
415
+ },
416
+ {
417
+ method: "board.addTaskComment",
418
+ params: { taskId: ghostTask, body: "conformance probe" },
419
+ writes: false,
420
+ guard: "ghost",
421
+ why: "task 404s after validation",
422
+ },
423
+ {
424
+ method: "decision.comment",
425
+ params: { decisionId: ghostDecision, text: "conformance probe" },
426
+ writes: false,
427
+ guard: "ghost",
428
+ why: 'the "text is required" guard runs BEFORE the decision lookup',
429
+ },
430
+ {
431
+ method: "decision.sign",
432
+ params: { decisionId: ghostDecision },
433
+ writes: false,
434
+ guard: "ghost",
435
+ why: "decision 404s after validation",
436
+ },
437
+ {
438
+ method: "escalation.create",
439
+ params: { title: "conformance probe", severity: "low", taskId: ghostTask },
440
+ writes: false,
441
+ guard: "ghost",
442
+ why: "the task target 404s before the Escalation row",
443
+ },
444
+ {
445
+ method: "knowledge.invalidate",
446
+ params: { id: ghostId("epi"), reason: "conformance probe" },
447
+ writes: false,
448
+ guard: "ghost",
449
+ why: "episode 404s",
450
+ },
451
+ {
452
+ method: "lease.release",
453
+ params: { scope: "work-claim", resourceId: ghostId("res") },
454
+ // NOT a no-op. hq deletes conditionally but appends `lease.released`
455
+ // UNCONDITIONALLY, outside the `if` (methods/lease/release.ts:32) — so a
456
+ // release of a lease we never held still writes a row to the org's
457
+ // hash-linked event chain, with `freed: false`. Agents tail that feed, so
458
+ // a "read-only" CI run produced phantom lease.released events for
459
+ // probe-res-* resources in the live org on every pass. Same defect class
460
+ // as knowledge.replace: the guard described what we WANTED to be true
461
+ // rather than what the handler does.
462
+ writes: true,
463
+ guard: "write",
464
+ why: "appends lease.released unconditionally, even when nothing was held",
465
+ },
466
+
467
+ // ── genuinely creates a row: --write only ───────────────────────────────
468
+ {
469
+ method: "registry.register",
470
+ params: { card: { probe: "conformance", at: new Date().toISOString() } },
471
+ writes: true,
472
+ guard: "write",
473
+ why: "updates the SELF seat and appends a registry event",
474
+ },
475
+ {
476
+ method: "board.createTask",
477
+ params: { title: "[conformance probe] delete me", col: "triage", priority: "P4" },
478
+ writes: true,
479
+ guard: "write",
480
+ why: "creates a real Task",
481
+ },
482
+ {
483
+ method: "decision.propose",
484
+ params: { title: "[conformance probe] delete me", why: { reason: "wire conformance" }, tag: "probe" },
485
+ writes: true,
486
+ guard: "write",
487
+ why: "creates a real Decision",
488
+ },
489
+ {
490
+ method: "memory.author",
491
+ params: { memoryClass: "ledger", title: "[conformance probe] delete me", summary: "wire conformance" },
492
+ writes: true,
493
+ guard: "write",
494
+ why: "creates a real MemoryEntry",
495
+ },
496
+ {
497
+ method: "knowledge.append",
498
+ params: { body: "[conformance probe] wire conformance check", group: "org" },
499
+ writes: true,
500
+ guard: "write",
501
+ why: "appends a real knowledge episode",
502
+ },
503
+ {
504
+ method: "approval.request",
505
+ params: { actionClass: "conformance_probe", payloadHash: hash64, subject: "[conformance probe]" },
506
+ writes: true,
507
+ guard: "write",
508
+ why: "creates a real pending Approval",
509
+ },
510
+ {
511
+ method: "lease.claim",
512
+ params: { scope: "work-claim", resourceId: ghostId("res"), ttlMs: 1000 },
513
+ writes: true,
514
+ guard: "write",
515
+ why: "creates a real (1s) Lease row",
516
+ },
517
+ {
518
+ method: "contacts.upsert",
519
+ params: { kind: "person", displayName: "[conformance probe] delete me" },
520
+ writes: true,
521
+ guard: "write",
522
+ why: "creates a real Contact",
523
+ },
524
+ {
525
+ method: "meetings.record",
526
+ params: { title: "[conformance probe] delete me" },
527
+ writes: true,
528
+ guard: "write",
529
+ why: "creates a real MeetingRecord",
530
+ },
531
+ ];
532
+ }
533
+
534
+ /**
535
+ * Which contracted methods have no probe? Pure — the coverage assertion the
536
+ * unit test enforces so a new contract row cannot ship unprobed.
537
+ * @param {object[]} [probes]
538
+ * @returns {string[]}
539
+ */
540
+ export function uncoveredContractMethods(probes = buildProbes()) {
541
+ const probed = new Set(probes.map((p) => p.method));
542
+ return Object.keys(PARAM_CONTRACT).filter((m) => !probed.has(m));
543
+ }
544
+
545
+ // ---------------------------------------------------------------------------
546
+ // argv
547
+ // ---------------------------------------------------------------------------
548
+
549
+ /**
550
+ * Parse argv. Pure.
551
+ * @param {string[]} argv
552
+ * @returns {{write:boolean, json:boolean, verbose:boolean, only:string[], agentRoot:string|null, help:boolean, bad:string[]}}
553
+ */
554
+ export function parseArgs(argv = []) {
555
+ const out = { write: false, json: false, verbose: false, only: [], agentRoot: null, help: false, bad: [] };
556
+ for (const raw of argv) {
557
+ const a = String(raw);
558
+ if (a === "--write") out.write = true;
559
+ else if (a === "--json") out.json = true;
560
+ else if (a === "--verbose" || a === "-v") out.verbose = true;
561
+ else if (a === "--help" || a === "-h") out.help = true;
562
+ else if (a.startsWith("--only=")) out.only = a.slice(7).split(",").map((s) => s.trim()).filter(Boolean);
563
+ else if (a.startsWith("--agent-root=")) out.agentRoot = a.slice(13);
564
+ else out.bad.push(a);
565
+ }
566
+ return out;
567
+ }
568
+
569
+ /** Does this probe pass the --only family filter? Pure. */
570
+ export function probeSelected(probe, only) {
571
+ if (!only || !only.length) return true;
572
+ const def = methodDef(probe.method);
573
+ const family = (def && def.family) || String(probe.method).split(".")[0];
574
+ return only.includes(family) || only.includes(probe.method);
575
+ }
576
+
577
+ // ---------------------------------------------------------------------------
578
+ // runner
579
+ // ---------------------------------------------------------------------------
580
+
581
+ /**
582
+ * Run the probe suite. Injectable (`callImpl`/`readImpl`) so the runner itself
583
+ * is testable without a network.
584
+ *
585
+ * @param {object} o - { base, token, orgId, write?, only?, callImpl?, readImpl? }
586
+ * @returns {Promise<{results:object[], reads:object[], failures:object[]}>}
587
+ */
588
+ export async function runProbes(o = {}) {
589
+ const callImpl = o.callImpl || call;
590
+ const readImpl = o.readImpl || read;
591
+ const tx = { base: o.base, token: o.token, orgId: o.orgId, logImpl: () => {} };
592
+
593
+ const results = [];
594
+ for (const probe of buildProbes()) {
595
+ if (!probeSelected(probe, o.only)) continue;
596
+ if (probe.writes && !o.write) {
597
+ results.push({ ...probe, verdict: "skip", reason: "row-creating — pass --write to include", sent: probe.params });
598
+ continue;
599
+ }
600
+ // Record what actually goes on the wire (post-contract) so the classifier
601
+ // can tell "hq wants a name we don't send" from "hq disliked our value".
602
+ // What the SHIPPING path actually puts on the wire: client.call applies the
603
+ // contract, so recompute it here rather than guessing from probe.params. The
604
+ // classifier needs the real key set to tell "hq wants a name we never send"
605
+ // apart from "hq disliked our placeholder value".
606
+ const sent = wireBodyFor(probe.method, probe.params);
607
+ let frame;
608
+ try {
609
+ frame = await callImpl(probe.method, probe.params, { ...tx, fetchImpl: o.fetchImpl });
610
+ } catch (err) {
611
+ frame = { ok: false, error: { code: "INTERNAL", message: String((err && err.message) || err) } };
612
+ }
613
+ const verdict = classifyFrame(probe, sent, frame);
614
+ results.push({ ...probe, ...verdict, sent, code: frame && frame.error && frame.error.code });
615
+ }
616
+
617
+ // Every GET read path — a 404/400 here is a routing contract break.
618
+ const reads = [];
619
+ if (!o.only || !o.only.length || o.only.includes("reads")) {
620
+ for (const path of Object.keys(READS)) {
621
+ if (path === "approval.wait") continue; // a 55s long-poll; not a probe
622
+ const r = await readImpl(path, { ...tx, timeoutMs: 8000 });
623
+ const code = r.ok ? null : (r.error && r.error.code) || "INTERNAL";
624
+ // A read rejected at auth is `credential`, never `pass`. Marking it pass
625
+ // is how a dead token turned all 12 GET paths green.
626
+ const verdict = r.ok
627
+ ? "pass"
628
+ : CREDENTIAL_FAILURE_CODES.has(code)
629
+ ? "credential"
630
+ : SCHEMA_PASSED_CODES.has(code)
631
+ ? "pass"
632
+ : code === "BAD_REQUEST"
633
+ ? "fail"
634
+ : "warn";
635
+ reads.push({
636
+ path,
637
+ verdict,
638
+ reason: r.ok ? "200" : `${code}: ${(r.error && r.error.message) || ""}`,
639
+ });
640
+ }
641
+ }
642
+
643
+ const failures = [...results, ...reads].filter((x) => x.verdict === "fail");
644
+ return { results, reads, failures };
645
+ }
646
+
647
+ /**
648
+ * The params `client.call()` actually puts on the wire for this method — the same
649
+ * contract, applied the same way. Fail-open to the raw params.
650
+ * @param {string} method @param {object} params @returns {object}
651
+ */
652
+ export function wireBodyFor(method, params) {
653
+ try {
654
+ return normalizeParams(method, params).params;
655
+ } catch {
656
+ return params || {};
657
+ }
658
+ }
659
+
660
+ // ---------------------------------------------------------------------------
661
+ // cli
662
+ // ---------------------------------------------------------------------------
663
+
664
+ const HELP = `
665
+ conformance-org-api — live wire-contract probe against a real Cohort org.
666
+
667
+ node scripts/ci/conformance-org-api.mjs [options]
668
+
669
+ --write include probes that create a real row (default: skipped)
670
+ --only=fam[,fam] limit to protocol families (e.g. --only=board,messaging)
671
+ or "reads" for the GET surface only
672
+ --json machine-readable report on stdout
673
+ --verbose, -v print every verdict, not just failures
674
+ --agent-root=PATH load config/org.yaml from PATH
675
+ --help, -h this text
676
+
677
+ exit 0 = no drift (or no credentials → skipped), 1 = drift, 2 = bad usage.
678
+ `.trim();
679
+
680
+ /** @returns {Promise<number>} process exit code */
681
+ export async function main(argv = process.argv.slice(2)) {
682
+ const args = parseArgs(argv);
683
+ if (args.help) {
684
+ console.log(HELP);
685
+ return 0;
686
+ }
687
+ if (args.bad.length) {
688
+ console.error(`conformance-org-api: unknown argument(s): ${args.bad.join(", ")}`);
689
+ console.error(HELP);
690
+ return 2;
691
+ }
692
+
693
+ // Resolve credentials exactly as the runtime does.
694
+ const agentRoot = args.agentRoot || process.env.COHORT_AGENT_ROOT || process.env.AGENT_ROOT || REPO_ROOT;
695
+ const cfg = configFromAgent(loadOrgConfig(agentRoot));
696
+ const base =
697
+ (process.env.COHORT_BASE && process.env.COHORT_BASE.replace(/\/+$/, "")) ||
698
+ (process.env.COHORT_API_URL && process.env.COHORT_API_URL.replace(/\/+$/, "")) ||
699
+ cfg.base ||
700
+ "https://os.cohortapp.com";
701
+ const token = cfg.token;
702
+ const orgId = cfg.orgId;
703
+
704
+ if (!token) {
705
+ // A CI job with no org secret must not fail — but it must SAY so. A silent
706
+ // green here would be exactly the failure mode this script exists to end.
707
+ console.log("conformance-org-api: SKIPPED — no org credentials (set COHORT_API_TOKEN).");
708
+ console.log(" This check is a no-op without a live org; it is a gate only where the secret exists.");
709
+ return 0;
710
+ }
711
+
712
+ const uncovered = uncoveredContractMethods();
713
+ const { results, reads, failures } = await runProbes({ base, token, orgId, write: args.write, only: args.only });
714
+
715
+ if (args.json) {
716
+ console.log(JSON.stringify({ base, orgId, write: args.write, uncovered, results, reads, failures }, null, 2));
717
+ return failures.length ? 1 : 0;
718
+ }
719
+
720
+ const icon = { pass: " ok ", fail: " FAIL ", warn: " warn ", skip: " skip " };
721
+ console.log(`conformance-org-api → ${base} (org ${orgId || "?"})${args.write ? " [--write]" : ""}\n`);
722
+ for (const r of results) {
723
+ if (!args.verbose && r.verdict !== "fail") continue;
724
+ console.log(`${icon[r.verdict]} ${r.method.padEnd(28)} ${r.reason}`);
725
+ if (r.detail && r.verdict !== "pass") console.log(` ↳ ${r.detail}`);
726
+ }
727
+ for (const r of reads) {
728
+ if (!args.verbose && r.verdict !== "fail") continue;
729
+ console.log(`${icon[r.verdict]} GET ${r.path.padEnd(24)} ${r.reason}`);
730
+ }
731
+
732
+ const tally = (v) => results.filter((r) => r.verdict === v).length;
733
+ console.log(
734
+ `\n${results.length} method probes: ${tally("pass")} pass, ${tally("skip")} skip, ` +
735
+ `${tally("warn")} warn, ${tally("fail")} FAIL — ${reads.length} read paths.`,
736
+ );
737
+ if (uncovered.length) {
738
+ console.log(`\nNOTE: ${uncovered.length} contracted method(s) have no probe: ${uncovered.join(", ")}`);
739
+ }
740
+ if (failures.length) {
741
+ console.log(`\nWIRE-CONTRACT DRIFT — ${failures.length} method(s) reject the params this SDK sends.`);
742
+ console.log("Fix lib/org/param-contract.mjs (and the call site) so the canonical name goes on the wire.");
743
+ return 1;
744
+ }
745
+
746
+ // A RUN THAT PROVED NOTHING IS NOT A PASS.
747
+ //
748
+ // Every probe rejected at auth means the credential is dead, not that the
749
+ // contract is sound — and the old code called that green and exited 0. A gate
750
+ // is only worth having if it can tell "verified" from "never ran"; this one
751
+ // could not, and would have stayed green forever after a token rotation.
752
+ const credentialRejected = tally("credential");
753
+ if (credentialRejected > 0 && tally("pass") === 0) {
754
+ console.log(
755
+ `\nNOTHING WAS VERIFIED — all ${credentialRejected} probe(s) were rejected at auth ` +
756
+ `(UNAUTHORIZED). The token is missing, expired, revoked or scoped out.\n` +
757
+ `This is NOT a pass: the wire contract was never exercised.`,
758
+ );
759
+ return 2;
760
+ }
761
+ if (tally("pass") === 0) {
762
+ console.log(
763
+ "\nNOTHING WAS VERIFIED — no probe reached a handler. Refusing to report success.",
764
+ );
765
+ return 2;
766
+ }
767
+
768
+ console.log(`\nNo wire-contract drift (${tally("pass")} probe(s) genuinely verified).`);
769
+ return 0;
770
+ }
771
+
772
+ const isMain = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
773
+ if (isMain) {
774
+ main()
775
+ .then((code) => process.exit(code))
776
+ .catch((err) => {
777
+ console.error(`conformance-org-api: ${(err && err.stack) || err}`);
778
+ process.exit(1);
779
+ });
780
+ }
781
+
782
+ export default {
783
+ main,
784
+ classifyFrame,
785
+ citedParams,
786
+ buildProbes,
787
+ parseArgs,
788
+ probeSelected,
789
+ runProbes,
790
+ wireBodyFor,
791
+ uncoveredContractMethods,
792
+ };