@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,694 @@
1
+ /**
2
+ * lib/org/param-contract.mjs — the WIRE PARAM CONTRACT between this SDK and hq.
3
+ *
4
+ * THE BUG CLASS THIS EXISTS TO KILL
5
+ * --------------------------------
6
+ * hq validates every `/v1/<method>` body with a zod schema that does NOT ship to
7
+ * the SDK. Nothing on this side knew hq's param NAMES, so they were re-invented
8
+ * per call site from JSDoc and memory. A wire audit found 18 methods where the
9
+ * name the SDK sent and the name hq reads had diverged. Two failure modes, both
10
+ * silent from the agent's point of view:
11
+ *
12
+ * - hard: hq's schema REQUIRES the canonical name → every call came back
13
+ * BAD_REQUEST. `messaging.send` (SDK sent `clientMsgId`, hq reads
14
+ * `idempotencyId`) meant no agent message from the LLM tool plane had ever
15
+ * landed; `approval.request` (SDK sent `kind`, hq reads `actionClass`) meant
16
+ * the SDK-side governance gate had filed zero approvals; `registry.register`
17
+ * (SDK sent the raw self-entry into a `.strict()` schema) meant no agent had
18
+ * ever entered the org directory while the daemon logged "registered … with
19
+ * the org mesh" on every boot.
20
+ * - soft, and worse: hq's schema is NOT `.strict()`, so zod silently STRIPS the
21
+ * unknown key and the call succeeds having discarded the caller's intent —
22
+ * `board.createTask` accepted `assignee`/`status`/`description` and created
23
+ * unassigned, untriaged, bodyless tasks.
24
+ *
25
+ * THE FIX: ONE table, applied at ONE chokepoint. `client.call()` is the single
26
+ * door every plane goes through — the 2 090 `ui-parity.mjs` wrappers, the curated
27
+ * `tool-surface.mjs` table, the `org_rpc` escape hatch, and every hand-written
28
+ * helper in `lib/org/*.mjs`. `normalizeParams` runs there, so a call site cannot
29
+ * opt out and a newly added wrapper inherits the contract for free.
30
+ *
31
+ * WHAT AN ENTRY DECLARES
32
+ * ----------------------
33
+ * serverAccepts [names] legacy names hq's OWN handler still reads as
34
+ * aliases (`knowledge.append` takes body|text).
35
+ * Documentation + the CI surface guard's
36
+ * allow-list; the normaliser ignores it.
37
+ * alias {legacy: canonical} rename when the canonical key is absent. The
38
+ * legacy key STAYS on the wire by default
39
+ * (harmless: hq's non-strict schemas strip it)
40
+ * so an older server still reads it.
41
+ * strict [names] hq's schema is `.strict()` — it REJECTS
42
+ * unknown keys, so everything outside this list
43
+ * is dropped (and reported).
44
+ * mint [names] business-dedup ids hq requires and the caller
45
+ * cannot be expected to invent (uuid).
46
+ * fold {into, keys} enrichment hq only persists inside a
47
+ * container column (knowledge metadata).
48
+ * unsupported [names] hq accepts-and-IGNORES: the param rides along
49
+ * (an older/newer hq may read it) but is
50
+ * REPORTED so the loss of intent is never
51
+ * silent. This is the honest half of fail-open.
52
+ * enums {name: [values]} hq throws on an out-of-vocabulary value;
53
+ * we lowercase/normalise what we can and report
54
+ * what we cannot.
55
+ * required [names] hq 400s without these (probe + preflight).
56
+ * oneOf [[a,b], …] hq's `.refine()` demands at least one of each
57
+ * group.
58
+ * transform (params) => params last-resort reshape (note→proof, entry→card,
59
+ * rationale→why.reason, expiresInMs→expiresAt).
60
+ *
61
+ * PURE + FAIL-OPEN. Nothing here throws, does I/O, or reaches the network — it is
62
+ * a string-keyed rewrite over a plain object, unit-testable without a server.
63
+ * A method with no entry passes through byte-for-byte.
64
+ *
65
+ * THE LIVE HALF: a table cannot prove itself right — hq's schemas are the only
66
+ * authority. `scripts/ci/conformance-org-api.mjs` drives this same table against
67
+ * a real org and fails on BAD_REQUEST, which is the check that would have caught
68
+ * all 18 rows.
69
+ *
70
+ * Node builtins only. ESM.
71
+ *
72
+ * @module lib/org/param-contract
73
+ */
74
+
75
+ "use strict";
76
+
77
+ import { randomUUID } from "node:crypto";
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // hq vocabularies (mirrored from the hq zod enums — the values hq throws on)
81
+ // ---------------------------------------------------------------------------
82
+
83
+ /** `Task.col` — hq `src/server/validation/task.ts#boardColEnum`. */
84
+ export const BOARD_COLS = [
85
+ "triage",
86
+ "backlog",
87
+ "todo",
88
+ "scheduled",
89
+ "ready",
90
+ "running",
91
+ "review",
92
+ "blocked",
93
+ "done",
94
+ "archived",
95
+ ];
96
+
97
+ /** `Task.priority` — hq `taskPriorityEnum` (P0 highest .. P4 lowest). */
98
+ export const TASK_PRIORITIES = ["P0", "P1", "P2", "P3", "P4"];
99
+
100
+ /** Escalation severity — hq `methods/escalation/_shared.ts#SEVERITIES`. */
101
+ export const ESCALATION_SEVERITIES = ["low", "medium", "high", "critical"];
102
+
103
+ /** `memory.author` memoryClass — hq `methods/memory/_shared.ts#MEMORY_CLASS_VALUES`. */
104
+ export const MEMORY_CLASSES = [
105
+ "framework",
106
+ "constitution",
107
+ "strategy",
108
+ "policy",
109
+ "orggraph",
110
+ "sop",
111
+ "glossary",
112
+ "sources",
113
+ "ledger",
114
+ ];
115
+
116
+ // ---------------------------------------------------------------------------
117
+ // transforms
118
+ // ---------------------------------------------------------------------------
119
+
120
+ /** Is this a plain (non-array, non-null) object? */
121
+ function isObj(v) {
122
+ return !!v && typeof v === "object" && !Array.isArray(v);
123
+ }
124
+
125
+ /** First non-empty string among the candidates, else "". */
126
+ function firstString(...vals) {
127
+ for (const v of vals) {
128
+ if (typeof v === "string" && v.trim()) return v.trim();
129
+ if (typeof v === "number" && Number.isFinite(v)) return String(v);
130
+ }
131
+ return "";
132
+ }
133
+
134
+ /**
135
+ * Project a rich maestro self-entry onto hq's `.strict()` registerSchema
136
+ * ({displayName, archetype, humanSponsor, card}). Everything the server does not
137
+ * model rides under `card` (chain-redacted to a `hasCard` boolean), which is what
138
+ * that field is for. IDEMPOTENT: an already-projected params object (has `card`,
139
+ * has no `id`) is returned unchanged, so applying the contract twice is safe.
140
+ *
141
+ * @param {object} entry
142
+ * @returns {object} registerSchema-shaped params
143
+ */
144
+ export function toRegisterParams(entry) {
145
+ const e = isObj(entry) ? entry : {};
146
+ if (e.card !== undefined && e.id === undefined) return { ...e };
147
+
148
+ const displayName = firstString(e.fullName, e.name, e.displayName, e.id);
149
+ // `archetype` is an object in the maestro entry ({function,altitude,label});
150
+ // hq wants a single string. Prefer the human label, then the function.
151
+ const arch = e.archetype;
152
+ const archetype =
153
+ typeof arch === "string"
154
+ ? arch
155
+ : isObj(arch)
156
+ ? firstString(arch.label, arch.function, arch.altitude)
157
+ : "";
158
+ const humanSponsor = firstString(
159
+ isObj(e.principal) ? e.principal.fullName : "",
160
+ isObj(e.principal) ? e.principal.title : "",
161
+ e.humanSponsor,
162
+ );
163
+
164
+ const params = { card: e };
165
+ if (displayName) params.displayName = String(displayName).slice(0, 200);
166
+ if (archetype) params.archetype = String(archetype).slice(0, 120);
167
+ if (humanSponsor) params.humanSponsor = String(humanSponsor).slice(0, 200);
168
+ return params;
169
+ }
170
+
171
+ /**
172
+ * `board.complete` — hq's schema is `.strict()` with `proof: Record<string,unknown>`.
173
+ * The tool plane offers the model a free-text `note`, which is exactly the shape
174
+ * hq stores under `why.proof` — so wrap it rather than drop it.
175
+ */
176
+ function completeProof(p) {
177
+ const out = { ...p };
178
+ if (out.proof === undefined) {
179
+ const note = firstString(out.note, out.summary, out.body, out.text);
180
+ if (note) out.proof = { note };
181
+ } else if (typeof out.proof === "string") {
182
+ out.proof = { note: out.proof };
183
+ }
184
+ return out;
185
+ }
186
+
187
+ /**
188
+ * `decision.propose` — hq reads the rationale from the `why` provenance block
189
+ * (`why.reason` → `Decision.rationale`), not a top-level `rationale`. A decision
190
+ * proposed with a bare `rationale` was recorded with no rationale at all.
191
+ */
192
+ function decisionWhy(p) {
193
+ const out = { ...p };
194
+ const reason = firstString(out.rationale, out.why?.reason);
195
+ const clause = firstString(out.clause, out.why?.clause);
196
+ if (reason || clause || isObj(out.why)) {
197
+ const why = isObj(out.why) ? { ...out.why } : {};
198
+ if (reason) why.reason = reason;
199
+ if (clause && !why.clause) why.clause = clause;
200
+ if (Object.keys(why).length) out.why = why;
201
+ }
202
+ return out;
203
+ }
204
+
205
+ /**
206
+ * `approval.request` — hq takes an absolute `expiresAt`; the SDK gate speaks in
207
+ * relative `expiresInMs`. Convert rather than lose the expiry.
208
+ */
209
+ function approvalExpiry(p) {
210
+ const out = { ...p };
211
+ if (out.expiresAt === undefined && Number.isFinite(Number(out.expiresInMs))) {
212
+ out.expiresAt = new Date(Date.now() + Number(out.expiresInMs)).toISOString();
213
+ }
214
+ return out;
215
+ }
216
+
217
+ /**
218
+ * `memory.author` — hq requires `{memoryClass (enum), title}`. Callers speak
219
+ * `{content}`; derive a title from its first line so the entry is not rejected,
220
+ * and keep the full text as the summary hq persists.
221
+ */
222
+ function memoryTitle(p) {
223
+ const out = { ...p };
224
+ const content = firstString(out.summary, out.content, out.text, out.body);
225
+ if (content && out.summary === undefined) out.summary = content;
226
+ if (out.title === undefined && content) {
227
+ const firstLine = content.split(/\r?\n/, 1)[0].trim();
228
+ out.title = (firstLine || content).slice(0, 200);
229
+ }
230
+ return out;
231
+ }
232
+
233
+ // ---------------------------------------------------------------------------
234
+ // THE TABLE — one row per method whose hq schema disagrees with a call site,
235
+ // plus every `.strict()` schema an SDK call site reaches.
236
+ // ---------------------------------------------------------------------------
237
+
238
+ /**
239
+ * @type {Record<string, {
240
+ * alias?: Record<string,string>, serverAccepts?: string[], dropAlias?: boolean, strict?: string[],
241
+ * mint?: string[], fold?: {into:string, keys:string[]},
242
+ * unsupported?: string[], enums?: Record<string,string[]>,
243
+ * required?: string[], oneOf?: string[][], transform?: (p:object)=>object,
244
+ * note?: string
245
+ * }>}
246
+ */
247
+ export const PARAM_CONTRACT = {
248
+ // ── messaging ────────────────────────────────────────────────────────────
249
+ "messaging.send": {
250
+ alias: {
251
+ clientMsgId: "idempotencyId",
252
+ msgId: "idempotencyId",
253
+ channel: "channelId",
254
+ threadId: "threadRootId",
255
+ },
256
+ mint: ["idempotencyId"],
257
+ required: ["channelId", "body", "idempotencyId"],
258
+ note: "hq reads idempotencyId (NOT clientMsgId) — sendMessageSchemaV1.",
259
+ },
260
+ "messaging.history": {
261
+ alias: { channel: "channelId", cursor: "before", since: "after" },
262
+ required: ["channelId"],
263
+ // CAUTION for whoever wires a paginating caller: hq's `before`/`after` are
264
+ // MESSAGE IDS, not timestamps — history.ts resolves the anchor with
265
+ // `input.before ?? input.after` and 404s ("cursor message X not found in
266
+ // channel") when it is not a real message id in that channel. `before` loads
267
+ // older (descending), `after` loads newer (ascending).
268
+ //
269
+ // So this alias fixes the NAME, not a type error: passing `since` as an ISO
270
+ // date or epoch now produces a hard NOT_FOUND where it used to be silently
271
+ // dropped. No production caller supplies since/cursor today (fetchHistory's
272
+ // only callers pass channel+limit), so the mapping is dormant — but a
273
+ // timestamp must be resolved to a message id before it goes on the wire.
274
+ note: "pagination anchors are MESSAGE IDS (before=older, after=newer), not timestamps.",
275
+ },
276
+ "messaging.react": { alias: { channel: "channelId" }, required: ["messageId", "emoji"] },
277
+ "messaging.edit": { required: ["messageId", "body"] },
278
+
279
+ // ── calling ──────────────────────────────────────────────────────────────
280
+ "calling.start": {
281
+ alias: {
282
+ channel: "channelId",
283
+ invitees: "participantIds",
284
+ participants: "participantIds",
285
+ memberIds: "participantIds",
286
+ },
287
+ unsupported: ["topic"],
288
+ oneOf: [["channelId", "participantIds"]],
289
+ note: "startCallSchema.refine() demands channelId OR a non-empty participantIds.",
290
+ },
291
+ "calling.join": { required: ["callId"] },
292
+ "calling.invite": { alias: { invitees: "memberIds", participantIds: "memberIds" }, required: ["callId", "memberIds"] },
293
+
294
+ // ── board ────────────────────────────────────────────────────────────────
295
+ "board.complete": {
296
+ strict: ["itemId", "proof"],
297
+ transform: completeProof,
298
+ required: ["itemId"],
299
+ note: "completeSchema is .strict(): a `note` key 400s — it is wrapped as proof.note.",
300
+ },
301
+ "board.claim": { strict: ["itemId"], required: ["itemId"] },
302
+ "board.assign": {
303
+ alias: { assigneeId: "assignee", memberId: "assignee" },
304
+ strict: ["itemId", "assignee"],
305
+ required: ["itemId", "assignee"],
306
+ },
307
+ "board.createTask": {
308
+ alias: {
309
+ description: "detail",
310
+ body: "detail",
311
+ assignee: "assigneeId",
312
+ status: "col",
313
+ column: "col",
314
+ },
315
+ enums: { col: BOARD_COLS, priority: TASK_PRIORITIES },
316
+ unsupported: ["dueAt"],
317
+ required: ["title"],
318
+ note: "createTaskSchema has NO dueAt — set it with board.updateTask after create.",
319
+ },
320
+ "board.updateTask": {
321
+ alias: { description: "detail", body: "detail", status: "col", column: "col" },
322
+ enums: { col: BOARD_COLS, priority: TASK_PRIORITIES },
323
+ unsupported: ["assignee", "assigneeId"],
324
+ required: ["taskId"],
325
+ note: "editTaskSchema has NO assignee field — reassign with board.assignTask.",
326
+ },
327
+ "board.assignTask": {
328
+ alias: { assignee: "assigneeId", memberId: "assigneeId" },
329
+ required: ["taskId", "assigneeId"],
330
+ },
331
+ "board.moveTask": {
332
+ alias: { status: "col", column: "col" },
333
+ enums: { col: BOARD_COLS },
334
+ required: ["taskId", "col"],
335
+ },
336
+ "board.addTaskComment": { alias: { text: "body", comment: "body" }, required: ["taskId", "body"] },
337
+
338
+ // ── decisions ────────────────────────────────────────────────────────────
339
+ "decision.propose": {
340
+ alias: { scope: "tag" },
341
+ transform: decisionWhy,
342
+ required: ["title"],
343
+ note: "rationale lives at why.reason; `scope` is hq's `tag`.",
344
+ },
345
+ "decision.comment": {
346
+ alias: { body: "text", comment: "text" },
347
+ required: ["decisionId", "text"],
348
+ note: 'hq throws "text is required" — `body` was never read.',
349
+ },
350
+ "decision.sign": { required: ["decisionId"] },
351
+
352
+ // ── approvals ────────────────────────────────────────────────────────────
353
+ "approval.request": {
354
+ alias: {
355
+ kind: "actionClass",
356
+ class: "actionClass",
357
+ payload_hash: "payloadHash",
358
+ hash: "payloadHash",
359
+ },
360
+ transform: approvalExpiry,
361
+ unsupported: ["requester", "expiresInMs"],
362
+ required: ["actionClass"],
363
+ oneOf: [["payload", "payloadHash"]],
364
+ note: "actionClass is REQUIRED; requester is derived server-side from the actor.",
365
+ },
366
+ "approval.resolve": { required: ["id", "decision"] },
367
+
368
+ // ── memory / knowledge ───────────────────────────────────────────────────
369
+ "memory.author": {
370
+ alias: { kind: "memoryClass", class: "memoryClass", content: "summary", text: "summary" },
371
+ transform: memoryTitle,
372
+ enums: { memoryClass: MEMORY_CLASSES },
373
+ unsupported: ["tags"],
374
+ required: ["memoryClass", "title"],
375
+ note: "scopes[] is the ACL field — tags are NOT scopes and are not persisted.",
376
+ },
377
+ "knowledge.append": {
378
+ alias: { text: "body" },
379
+ serverAccepts: ["text", "group"],
380
+ fold: { into: "metadata", keys: ["kind", "participants", "links", "refs"] },
381
+ required: ["body"],
382
+ note: "hq persists enrichment only inside `metadata`.",
383
+ },
384
+ "knowledge.replace": {
385
+ alias: { text: "body" },
386
+ serverAccepts: ["text", "group"],
387
+ fold: { into: "metadata", keys: ["kind", "participants", "links", "refs"] },
388
+ required: ["id", "body"],
389
+ },
390
+ "knowledge.rewrite": { alias: { text: "body" }, serverAccepts: ["text"], required: ["id", "body"] },
391
+ "knowledge.invalidate": { required: ["id"] },
392
+ "knowledge.search": {
393
+ alias: { query: "q" },
394
+ serverAccepts: ["query", "group"],
395
+ unsupported: ["kind", "includeInvalid", "lexical"],
396
+ note: "hq lexical-matches over the RLS-floored set; these three filters are not read.",
397
+ },
398
+ "contacts.upsert": {
399
+ unsupported: ["group"],
400
+ required: ["displayName"],
401
+ note: "the ACL group hint is not read by contacts.upsert.",
402
+ },
403
+ "meetings.record": { unsupported: ["group"], required: ["title"] },
404
+
405
+ // ── registry ─────────────────────────────────────────────────────────────
406
+ "registry.register": {
407
+ transform: toRegisterParams,
408
+ strict: ["displayName", "archetype", "humanSponsor", "card"],
409
+ dropAlias: true,
410
+ note: "registerSchema is .strict(): the raw self-entry 400s — it rides under `card`.",
411
+ },
412
+ // (hq also ships registry.update against the same strict schema, but the
413
+ // VENDORED protocol table carries no such method — `call()` would reject it
414
+ // NOT_FOUND before any network I/O — so there is deliberately no entry for it.
415
+ // The GUARD test asserts every contracted method is a real protocol method,
416
+ // which is what caught the phantom entry.)
417
+
418
+ // ── members / escalation ─────────────────────────────────────────────────
419
+ "member.get": {
420
+ alias: { memberId: "slug", id: "slug", member: "slug" },
421
+ required: ["slug"],
422
+ note: "hq resolves a member by SLUG, not id.",
423
+ },
424
+ "escalation.create": {
425
+ alias: { subject: "title", body: "detail", description: "detail" },
426
+ enums: { severity: ESCALATION_SEVERITIES },
427
+ required: ["title"],
428
+ oneOf: [["taskId", "channelId"]],
429
+ note: "hq requires a title AND a target (taskId or channelId).",
430
+ },
431
+
432
+ // ── leases ───────────────────────────────────────────────────────────────
433
+ "lease.claim": {
434
+ alias: { token: "tokenHash" },
435
+ unsupported: ["holder"],
436
+ required: ["scope", "resourceId"],
437
+ note: "the holder is the authenticated actor; hq never reads a client-supplied holder.",
438
+ },
439
+ "lease.release": {
440
+ unsupported: ["leaseToken"],
441
+ required: ["scope", "resourceId"],
442
+ note: "ownership is enforced via actor.id — the token never round-trips.",
443
+ },
444
+ "lease.heartbeat": { unsupported: ["leaseToken"], required: ["scope", "resourceId"] },
445
+ };
446
+
447
+ // ---------------------------------------------------------------------------
448
+ // the normaliser
449
+ // ---------------------------------------------------------------------------
450
+
451
+ /**
452
+ * Does this method carry a wire contract?
453
+ * @param {string} method
454
+ * @returns {boolean}
455
+ */
456
+ export function hasContract(method) {
457
+ return Object.prototype.hasOwnProperty.call(PARAM_CONTRACT, String(method || ""));
458
+ }
459
+
460
+ /**
461
+ * The contract entry for a method (or null).
462
+ * @param {string} method
463
+ * @returns {object|null}
464
+ */
465
+ export function contractFor(method) {
466
+ return hasContract(method) ? PARAM_CONTRACT[String(method)] : null;
467
+ }
468
+
469
+ /**
470
+ * Normalise a params object onto the names hq actually validates.
471
+ *
472
+ * PURE and TOTAL: never throws, never mutates the input, never does I/O. A
473
+ * method with no contract entry is returned as a shallow copy with an empty
474
+ * report, so this is safe to run on every call.
475
+ *
476
+ * @param {string} method full dotted method name (e.g. "messaging.send")
477
+ * @param {object} params the caller's params
478
+ * @param {object} [o] - { mintId?: () => string } (injectable for tests)
479
+ * @returns {{params:object, renamed:string[], minted:string[], folded:string[],
480
+ * dropped:string[], unsupported:string[], missing:string[], notes:string[]}}
481
+ */
482
+ export function normalizeParams(method, params, o = {}) {
483
+ const report = {
484
+ params: isObj(params) ? { ...params } : {},
485
+ renamed: [],
486
+ minted: [],
487
+ folded: [],
488
+ dropped: [],
489
+ unsupported: [],
490
+ missing: [],
491
+ notes: [],
492
+ };
493
+ const c = contractFor(method);
494
+ if (!c) return report;
495
+
496
+ const mintId = typeof o.mintId === "function" ? o.mintId : randomUUID;
497
+ let p = report.params;
498
+
499
+ // (1) transform first — it reshapes on the caller's own vocabulary.
500
+ if (typeof c.transform === "function") {
501
+ try {
502
+ const next = c.transform(p);
503
+ if (isObj(next)) p = next;
504
+ } catch {
505
+ /* fail-open: a transform fault must never lose the call */
506
+ }
507
+ }
508
+
509
+ // (2) alias: legacy → canonical (only when the canonical key is absent, so an
510
+ // explicit canonical value always wins over a legacy one).
511
+ for (const [legacy, canonical] of Object.entries(c.alias || {})) {
512
+ if (p[legacy] === undefined) continue;
513
+ if (p[canonical] === undefined) {
514
+ p[canonical] = p[legacy];
515
+ report.renamed.push(`${legacy}→${canonical}`);
516
+ }
517
+ // The legacy key stays on the wire by default (hq's non-strict schemas strip
518
+ // it; an older server may still read it). `.strict()` methods drop it below.
519
+ if (c.dropAlias) delete p[legacy];
520
+ }
521
+
522
+ // (3) mint the business-dedup ids hq requires.
523
+ for (const key of c.mint || []) {
524
+ if (p[key] === undefined || p[key] === null || p[key] === "") {
525
+ p[key] = mintId();
526
+ report.minted.push(key);
527
+ }
528
+ }
529
+
530
+ // (4) fold enrichment into the container hq persists.
531
+ if (c.fold && Array.isArray(c.fold.keys)) {
532
+ const bag = isObj(p[c.fold.into]) ? { ...p[c.fold.into] } : {};
533
+ let touched = false;
534
+ for (const key of c.fold.keys) {
535
+ if (p[key] === undefined) continue;
536
+ if (bag[key] === undefined) bag[key] = p[key];
537
+ report.folded.push(key);
538
+ touched = true;
539
+ }
540
+ if (touched) p[c.fold.into] = bag;
541
+ }
542
+
543
+ // (5) enum coercion — hq throws on an out-of-vocabulary value. Case-fold and
544
+ // match; report (never drop) a value we cannot map, so the server's own
545
+ // error message stays the authority.
546
+ for (const [key, values] of Object.entries(c.enums || {})) {
547
+ const v = p[key];
548
+ if (typeof v !== "string") continue;
549
+ if (values.includes(v)) continue;
550
+ const hit = values.find((x) => x.toLowerCase() === v.trim().toLowerCase());
551
+ if (hit) {
552
+ p[key] = hit;
553
+ report.renamed.push(`${key}:${v}→${hit}`);
554
+ } else {
555
+ report.notes.push(`${key}="${v}" is not one of ${values.join("|")}`);
556
+ }
557
+ }
558
+
559
+ // (6) strict: hq REJECTS unknown keys — drop everything outside the allow-list.
560
+ if (Array.isArray(c.strict)) {
561
+ const allow = new Set(c.strict);
562
+ for (const key of Object.keys(p)) {
563
+ if (allow.has(key)) continue;
564
+ delete p[key];
565
+ report.dropped.push(key);
566
+ }
567
+ }
568
+
569
+ // (7) report the params hq accepts-and-ignores. They RIDE ALONG (fail-open —
570
+ // an older/newer hq may read them) but the loss of intent is now visible.
571
+ for (const key of c.unsupported || []) {
572
+ if (p[key] !== undefined) report.unsupported.push(key);
573
+ }
574
+
575
+ // (8) preflight: what hq will 400 on. Reported, never enforced — the server is
576
+ // the authority on its own contract and we must not invent refusals.
577
+ report.missing = missingRequired(method, p);
578
+ if (c.note && (report.renamed.length || report.dropped.length || report.unsupported.length)) {
579
+ report.notes.push(c.note);
580
+ }
581
+
582
+ report.params = p;
583
+ return report;
584
+ }
585
+
586
+ /**
587
+ * Which required / one-of params are absent? Pure; used by the conformance probe
588
+ * and by `normalizeParams`'s report. Empty array when the method has no contract.
589
+ *
590
+ * @param {string} method
591
+ * @param {object} params
592
+ * @returns {string[]} human-readable missing-requirement descriptions
593
+ */
594
+ export function missingRequired(method, params) {
595
+ const c = contractFor(method);
596
+ if (!c) return [];
597
+ const p = isObj(params) ? params : {};
598
+ const out = [];
599
+ const present = (k) => {
600
+ const v = p[k];
601
+ if (v === undefined || v === null) return false;
602
+ if (typeof v === "string") return v.trim().length > 0;
603
+ if (Array.isArray(v)) return v.length > 0;
604
+ return true;
605
+ };
606
+ for (const key of c.required || []) if (!present(key)) out.push(key);
607
+ for (const group of c.oneOf || []) {
608
+ if (!group.some(present)) out.push(group.join("|"));
609
+ }
610
+ return out;
611
+ }
612
+
613
+ /**
614
+ * Was anything worth telling a human about? (renames, drops, ignored intent)
615
+ * @param {object} report a `normalizeParams` report
616
+ * @returns {boolean}
617
+ */
618
+ export function reportIsInteresting(report) {
619
+ if (!report) return false;
620
+ return (
621
+ (report.renamed || []).length > 0 ||
622
+ (report.dropped || []).length > 0 ||
623
+ (report.unsupported || []).length > 0 ||
624
+ (report.notes || []).length > 0
625
+ );
626
+ }
627
+
628
+ /**
629
+ * Render a report as one log line. Pure; returns "" when nothing happened.
630
+ * @param {string} method
631
+ * @param {object} report
632
+ * @returns {string}
633
+ */
634
+ export function formatReport(method, report) {
635
+ if (!reportIsInteresting(report)) return "";
636
+ const bits = [];
637
+ if (report.renamed.length) bits.push(`renamed ${report.renamed.join(",")}`);
638
+ if (report.dropped.length) bits.push(`dropped ${report.dropped.join(",")} (hq schema is strict)`);
639
+ if (report.unsupported.length) bits.push(`NOT READ BY hq: ${report.unsupported.join(",")}`);
640
+ if (report.folded.length) bits.push(`folded ${report.folded.join(",")} into metadata`);
641
+ if (report.notes.length) bits.push(report.notes.join("; "));
642
+ return `${method}: ${bits.join(" | ")}`;
643
+ }
644
+
645
+ // ---------------------------------------------------------------------------
646
+ // one-shot logging (never silent, never spam)
647
+ // ---------------------------------------------------------------------------
648
+
649
+ /** Seen (method|signature) pairs — one warning per distinct rewrite per process. */
650
+ const _seen = new Set();
651
+
652
+ /**
653
+ * Log a normalisation report ONCE per distinct (method, rewrite) per process.
654
+ * Silent fail-open has caused every bug in this file's history, so a rewrite is
655
+ * always reported — but a hot loop must not flood the log.
656
+ *
657
+ * @param {string} method
658
+ * @param {object} report
659
+ * @param {Function} [logImpl] injectable sink (defaults to console.warn)
660
+ * @returns {boolean} true when a line was emitted
661
+ */
662
+ export function logReportOnce(method, report, logImpl) {
663
+ const line = formatReport(method, report);
664
+ if (!line) return false;
665
+ if (_seen.has(line)) return false;
666
+ _seen.add(line);
667
+ try {
668
+ (logImpl || console.warn)(`[org-contract] ${line}`);
669
+ } catch {
670
+ /* never throw from logging */
671
+ }
672
+ return true;
673
+ }
674
+
675
+ /** Test seam: forget every one-shot log line. */
676
+ export function _resetLogOnce() {
677
+ _seen.clear();
678
+ }
679
+
680
+ export default {
681
+ PARAM_CONTRACT,
682
+ BOARD_COLS,
683
+ TASK_PRIORITIES,
684
+ ESCALATION_SEVERITIES,
685
+ MEMORY_CLASSES,
686
+ hasContract,
687
+ contractFor,
688
+ normalizeParams,
689
+ missingRequired,
690
+ reportIsInteresting,
691
+ formatReport,
692
+ logReportOnce,
693
+ toRegisterParams,
694
+ };