@cohortapp/agent-sdk 2.3.2 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/framework-features.json +30 -0
- package/lib/backlog.mjs +136 -0
- package/lib/cadences.mjs +63 -2
- package/lib/cadences.test.mjs +105 -0
- package/lib/capability/inventory.mjs +542 -0
- package/lib/capability/inventory.test.mjs +232 -0
- package/lib/capability/probe.mjs +255 -0
- package/lib/channels/contract.mjs +37 -1
- package/lib/channels/contract.test.mjs +25 -1
- package/lib/claude-bin.mjs +37 -3
- package/lib/claude-bin.test.mjs +42 -8
- package/lib/execution/disposition.mjs +501 -0
- package/lib/execution/disposition.test.mjs +482 -0
- package/lib/execution/drive.mjs +352 -0
- package/lib/execution/drive.test.mjs +270 -0
- package/lib/execution/effects.mjs +340 -0
- package/lib/execution/effects.test.mjs +193 -0
- package/lib/execution/index.mjs +152 -0
- package/lib/execution/intake.mjs +581 -0
- package/lib/execution/intake.test.mjs +343 -0
- package/lib/execution/journal.mjs +374 -0
- package/lib/execution/journal.test.mjs +261 -0
- package/lib/execution/match.mjs +331 -0
- package/lib/execution/match.test.mjs +235 -0
- package/lib/execution/pipeline.mjs +341 -0
- package/lib/execution/pipeline.test.mjs +389 -0
- package/lib/execution/route.mjs +332 -0
- package/lib/execution/route.test.mjs +186 -0
- package/lib/execution/surface-policy.mjs +446 -0
- package/lib/execution/surface-policy.test.mjs +162 -0
- package/lib/goals/admission.mjs +209 -0
- package/lib/goals/admission.test.mjs +139 -0
- package/lib/goals/classify.mjs +206 -0
- package/lib/goals/classify.test.mjs +109 -0
- package/lib/goals/collaborate.mjs +415 -0
- package/lib/goals/collaborate.test.mjs +324 -0
- package/lib/goals/gaps.mjs +111 -0
- package/lib/goals/gaps.test.mjs +284 -0
- package/lib/goals/loop.mjs +537 -0
- package/lib/goals/loop.test.mjs +719 -0
- package/lib/identity/persona.mjs +247 -0
- package/lib/identity/persona.test.mjs +117 -0
- package/lib/kpi.mjs +469 -0
- package/lib/kpi.test.mjs +244 -0
- package/lib/mandate/audit.mjs +168 -0
- package/lib/mandate/audit.test.mjs +195 -0
- package/lib/mandate/cache.mjs +162 -0
- package/lib/mandate/derive.mjs +317 -0
- package/lib/mandate/derive.test.mjs +224 -0
- package/lib/mandate/model.mjs +352 -0
- package/lib/mandate/model.test.mjs +145 -0
- package/lib/mandate/refresh.mjs +187 -0
- package/lib/mandate/refresh.test.mjs +293 -0
- package/lib/mcp/server.test.mjs +4 -4
- package/lib/org/approvals.mjs +14 -2
- package/lib/org/client.mjs +58 -22
- package/lib/org/client.test.mjs +3 -1
- package/lib/org/inbound/directedness.mjs +720 -0
- package/lib/org/inbound/directedness.test.mjs +543 -0
- package/lib/org/inbound/facts.mjs +501 -0
- package/lib/org/inbound/facts.test.mjs +375 -0
- package/lib/org/inbound/hydrate.mjs +535 -0
- package/lib/org/inbound/hydrate.test.mjs +326 -0
- package/lib/org/inbound/index.mjs +233 -0
- package/lib/org/inbound/index.test.mjs +324 -0
- package/lib/org/inbound/io.mjs +141 -0
- package/lib/org/inbound/project.mjs +201 -0
- package/lib/org/inbound/project.test.mjs +287 -0
- package/lib/org/inbound/surfaces.mjs +257 -0
- package/lib/org/knowledge.mjs +10 -1
- package/lib/org/knowledge.test.mjs +8 -1
- package/lib/org/leases.mjs +5 -0
- package/lib/org/mesh.mjs +17 -2
- package/lib/org/messaging.mjs +40 -4
- package/lib/org/messaging.test.mjs +40 -0
- package/lib/org/param-contract.mjs +694 -0
- package/lib/org/param-contract.test.mjs +451 -0
- package/lib/org/protocol.checksum +1 -1
- package/lib/org/protocol.mjs +8 -0
- package/lib/org/protocol.test.mjs +5 -1
- package/lib/org/push.mjs +1025 -0
- package/lib/org/push.test.mjs +690 -0
- package/lib/org/tool-surface.mjs +138 -38
- package/lib/org/tool-surface.test.mjs +13 -8
- package/lib/org/typing.mjs +341 -0
- package/lib/org/typing.test.mjs +291 -0
- package/lib/plan/compile.mjs +510 -0
- package/lib/plan/compile.test.mjs +286 -0
- package/lib/plan/emit.mjs +256 -0
- package/lib/plan/emit.test.mjs +246 -0
- package/lib/plan/explain.mjs +226 -0
- package/lib/plan/explain.test.mjs +188 -0
- package/lib/plan/schema.mjs +140 -0
- package/lib/resource-governor.mjs +47 -1
- package/lib/resource-governor.test.mjs +21 -1
- package/lib/setup/enroll-from-cohort.mjs +84 -16
- package/lib/setup/enroll-from-cohort.test.mjs +43 -1
- package/lib/setup/sections/identity.mjs +15 -4
- package/lib/setup/sections/identity.test.mjs +94 -0
- package/lib/setup/sections/inventory.mjs +178 -0
- package/lib/setup/sections/inventory.test.mjs +198 -0
- package/lib/setup/sections/mandate.mjs +392 -0
- package/lib/setup/sections/mandate.test.mjs +373 -0
- package/lib/setup/sections/subagents.mjs +427 -0
- package/lib/setup/sections/subagents.test.mjs +429 -0
- package/lib/setup/sections/verify.mjs +121 -0
- package/lib/setup/sections/verify.test.mjs +175 -0
- package/lib/setup/sot.mjs +2 -0
- package/lib/subagents/cli.mjs +463 -0
- package/lib/subagents/cli.test.mjs +389 -0
- package/lib/subagents/client.mjs +373 -0
- package/lib/subagents/client.test.mjs +309 -0
- package/lib/subagents/gap.mjs +268 -0
- package/lib/subagents/gap.test.mjs +234 -0
- package/lib/subagents/lock.mjs +296 -0
- package/lib/subagents/lock.test.mjs +248 -0
- package/lib/subagents/manifest.mjs +224 -0
- package/lib/subagents/manifest.test.mjs +175 -0
- package/lib/subagents/refs.mjs +274 -0
- package/lib/subagents/refs.test.mjs +204 -0
- package/lib/subagents/resolve.mjs +455 -0
- package/lib/subagents/resolve.test.mjs +422 -0
- package/lib/subagents/schema.mjs +467 -0
- package/lib/subagents/schema.test.mjs +306 -0
- package/package.json +8 -3
- package/plugins/maestro-skills/.claude-plugin/marketplace.json +16 -0
- package/policies/ai-disclosure.yaml +42 -2
- package/scaffold/CLAUDE.md +16 -2
- package/schedules/triggers/goal-steward.md +79 -0
- package/scripts/ci/conformance-org-api.mjs +792 -0
- package/scripts/ci/conformance-org-api.test.mjs +417 -0
- package/scripts/daemon/agent-daemon.mjs +36 -4
- package/scripts/daemon/cadence-handlers.mjs +145 -1
- package/scripts/daemon/goal-steward-cadence.test.mjs +243 -0
- package/scripts/daemon/inbox-deferral.mjs +45 -2
- package/scripts/daemon/inbox-deferral.test.mjs +56 -0
- package/scripts/daemon/inbox-wake.mjs +282 -0
- package/scripts/daemon/inbox-wake.test.mjs +199 -0
- package/scripts/daemon/prompt-builder.mjs +41 -1
- package/scripts/daemon/typing-registry.mjs +55 -2
- package/scripts/daemon/typing-registry.test.mjs +25 -0
- package/scripts/local-triggers/generate-plists.test.mjs +5 -5
- package/scripts/setup/gen-subagent-manifest.mjs +95 -0
- package/scripts/setup/gen-subagent-manifest.test.mjs +124 -0
- package/scripts/setup/generate-plan.mjs +108 -0
- package/scripts/setup/init-capability-manifest.mjs +70 -0
- package/scripts/setup/init-skill-marketplace.mjs +155 -0
- package/scripts/setup/init-skill-marketplace.test.mjs +193 -0
|
@@ -0,0 +1,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
|
+
};
|