@cohortapp/agent-sdk 2.4.0 → 2.5.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 (81) hide show
  1. package/bin/maestro.mjs +9 -0
  2. package/lib/backlog.mjs +35 -0
  3. package/lib/backlog.test.mjs +36 -0
  4. package/lib/channels/contract.mjs +1 -0
  5. package/lib/channels/contract.test.mjs +2 -1
  6. package/lib/channels/inbox-item.mjs +54 -0
  7. package/lib/comms/send-gate.mjs +56 -1
  8. package/lib/comms/send-gate.test.mjs +56 -0
  9. package/lib/execution/disposition.mjs +62 -2
  10. package/lib/execution/disposition.test.mjs +54 -0
  11. package/lib/execution/drive.mjs +1 -1
  12. package/lib/execution/effects.mjs +282 -24
  13. package/lib/execution/effects.test.mjs +112 -0
  14. package/lib/execution/index.mjs +1 -0
  15. package/lib/execution/intake.mjs +43 -9
  16. package/lib/execution/intake.test.mjs +46 -0
  17. package/lib/execution/pipeline.mjs +5 -0
  18. package/lib/execution/surface-policy.mjs +80 -30
  19. package/lib/goals/classify.mjs +49 -5
  20. package/lib/goals/classify.test.mjs +58 -0
  21. package/lib/goals/collaborate.mjs +131 -17
  22. package/lib/goals/collaborate.test.mjs +16 -4
  23. package/lib/goals/loop.mjs +160 -9
  24. package/lib/goals/loop.test.mjs +129 -3
  25. package/lib/kpi-sensors.mjs +666 -0
  26. package/lib/kpi-sensors.test.mjs +275 -0
  27. package/lib/kpi.mjs +23 -0
  28. package/lib/mandate/audit.mjs +3 -0
  29. package/lib/mandate/contract.mjs +277 -0
  30. package/lib/mandate/contract.test.mjs +185 -0
  31. package/lib/mandate/derive.mjs +49 -5
  32. package/lib/mandate/derive.test.mjs +7 -1
  33. package/lib/mandate/model.mjs +10 -1
  34. package/lib/mandate/model.test.mjs +22 -3
  35. package/lib/mandate/refresh.mjs +53 -5
  36. package/lib/mandate/refresh.test.mjs +83 -1
  37. package/lib/org/doctor.mjs +66 -0
  38. package/lib/org/doctor.test.mjs +73 -1
  39. package/lib/org/inbound/directedness.mjs +119 -1
  40. package/lib/org/inbound/directedness.test.mjs +67 -0
  41. package/lib/org/inbound/facts.mjs +132 -9
  42. package/lib/org/inbound/facts.test.mjs +96 -0
  43. package/lib/org/inbound/hydrate.mjs +40 -0
  44. package/lib/org/inbound/index.test.mjs +83 -0
  45. package/lib/org/inbound/project.mjs +8 -0
  46. package/lib/org/inbound/surfaces.mjs +20 -0
  47. package/lib/org/param-contract.mjs +16 -2
  48. package/lib/org/protocol.checksum +1 -1
  49. package/lib/org/protocol.mjs +214 -2
  50. package/lib/org/protocol.test.mjs +11 -2
  51. package/lib/org/push.mjs +213 -49
  52. package/lib/org/push.test.mjs +112 -10
  53. package/lib/plan/compile.mjs +85 -8
  54. package/lib/plan/compile.test.mjs +82 -0
  55. package/lib/plan/emit.test.mjs +6 -1
  56. package/lib/setup/enroll-from-cohort.mjs +22 -2
  57. package/lib/setup/enroll-from-cohort.test.mjs +25 -0
  58. package/lib/setup/sections/mandate.mjs +43 -1
  59. package/lib/subagents/schema.mjs +14 -2
  60. package/lib/subagents/schema.test.mjs +22 -0
  61. package/package.json +1 -1
  62. package/scripts/ci/check-subagent-frontmatter.mjs +139 -0
  63. package/scripts/ci/check-subagent-frontmatter.test.mjs +124 -0
  64. package/scripts/ci/check.mjs +3 -0
  65. package/scripts/ci/conformance-org-api.mjs +16 -0
  66. package/scripts/ci/journey-approval-escalation.mjs +341 -0
  67. package/scripts/daemon/agent-daemon.mjs +582 -28
  68. package/scripts/daemon/cadence-handlers.mjs +273 -17
  69. package/scripts/daemon/cadence-handlers.test.mjs +101 -0
  70. package/scripts/daemon/execution-ladder.test.mjs +430 -0
  71. package/scripts/daemon/goal-steward-cadence.test.mjs +69 -0
  72. package/scripts/daemon/maestro-daemon.mjs +53 -0
  73. package/scripts/daemon/prompt-builder.mjs +47 -0
  74. package/scripts/daemon/responder.mjs +70 -3
  75. package/scripts/poller/imap-client.mjs +20 -1
  76. package/scripts/poller/inbox-scan-poller.mjs +15 -0
  77. package/scripts/poller/utils.mjs +51 -0
  78. package/scripts/setup/generate-capability.mjs +120 -11
  79. package/scripts/setup/generate-capability.test.mjs +134 -0
  80. package/scripts/setup/generate-plan.mjs +6 -1
  81. package/scripts/setup/repair-subagent-frontmatter.mjs +231 -0
@@ -0,0 +1,275 @@
1
+ /**
2
+ * lib/kpi-sensors.test.mjs — the sensor registry.
3
+ *
4
+ * The load-bearing tests are the NEGATIVES: a sensor that cannot measure must
5
+ * produce an honest `{ok:false, reason}` that `measureKpi` refuses to turn into
6
+ * a sample, and that `admit`'s honesty gate therefore refuses to turn into work.
7
+ * A silent zero here would let an agent invent its own justification for
8
+ * anything, which is the whole reason the honesty gate exists.
9
+ */
10
+
11
+ "use strict";
12
+
13
+ import { test } from "node:test";
14
+ import assert from "node:assert/strict";
15
+ import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from "node:fs";
16
+ import { tmpdir } from "node:os";
17
+ import { join } from "node:path";
18
+
19
+ import {
20
+ SENSOR_SPECS,
21
+ buildSensorRegistry,
22
+ reachableCapabilities,
23
+ refusalFor,
24
+ hintedCapabilities,
25
+ reduceGeneric,
26
+ firstArray,
27
+ pluck,
28
+ } from "./kpi-sensors.mjs";
29
+ import { measureKpi, recordMeasurement, readSeries } from "./kpi.mjs";
30
+ import { admit } from "./goals/admission.mjs";
31
+
32
+ const NOW = Date.parse("2026-08-11T12:00:00.000Z");
33
+ const now = () => NOW;
34
+
35
+ function root() {
36
+ const d = mkdtempSync(join(tmpdir(), "kpi-sensors-"));
37
+ mkdirSync(join(d, "config"), { recursive: true });
38
+ return d;
39
+ }
40
+
41
+ /** A fake org: capability → error-or-result frame. */
42
+ function fakeExec(table, calls = []) {
43
+ return async (name, input) => {
44
+ calls.push({ name, input });
45
+ const hit = table[name];
46
+ if (hit === undefined) return { ok: false, error: { code: "NOT_FOUND", message: `no fake for ${name}` } };
47
+ if (typeof hit === "function") return hit(input);
48
+ return { ok: true, result: hit };
49
+ };
50
+ }
51
+
52
+ const OBJ = (capability, params = {}) => ({
53
+ key: "k", kind: "GOAL", state: "active", metric: "m", direction: "up",
54
+ target: 90, tolerance: 2, cadence: "weekly",
55
+ sensor: { capability, params, source: "method" },
56
+ });
57
+
58
+ // ---------------------------------------------------------------------------
59
+ // Reductions
60
+ // ---------------------------------------------------------------------------
61
+
62
+ test("board_ready measures an on-time rate and shows what it walked", async () => {
63
+ const calls = [];
64
+ const sensors = buildSensorRegistry({
65
+ agentRoot: root(), now,
66
+ executeImpl: fakeExec({ board_ready: { items: [
67
+ { id: "a", dueAt: "2026-06-01T00:00:00Z" }, // overdue
68
+ { id: "b", dueAt: "2026-12-01T00:00:00Z" }, // not yet
69
+ { id: "c", dueAt: null }, // cannot be late
70
+ { id: "d", dueAt: null },
71
+ ] } }, calls),
72
+ });
73
+ const m = await measureKpi(OBJ("board_ready", { metric: "on_time_rate" }), { sensors, now });
74
+ assert.equal(m.ok, true);
75
+ assert.equal(m.value, 75);
76
+ assert.equal(m.source, "method");
77
+ assert.equal(m.evidence.rowCount, 4);
78
+ assert.equal(m.evidence.detail.overdue, 1);
79
+ assert.equal(m.evidence.detail.withDueDate, 2);
80
+ assert.equal(calls[0].name, "board_ready");
81
+ });
82
+
83
+ test("decision_list distinguishes an honest zero from an absence", async () => {
84
+ const sensors = (table) => buildSensorRegistry({ agentRoot: root(), now, executeImpl: fakeExec(table) });
85
+
86
+ // Rows exist, none undecided → a REAL zero-day backlog.
87
+ const zero = await measureKpi(OBJ("decision_list"), {
88
+ sensors: sensors({ decision_list: { decisions: [{ id: "1", status: "adopted", createdAt: "2026-08-01T00:00:00Z", decidedAt: "2026-08-02T00:00:00Z" }] } }),
89
+ now,
90
+ });
91
+ assert.equal(zero.ok, true);
92
+ assert.equal(zero.value, 0);
93
+ assert.match(zero.evidence.detail.note, /honest zero/);
94
+
95
+ // No rows at all → NOT a zero.
96
+ const none = await measureKpi(OBJ("decision_list"), { sensors: sensors({ decision_list: { decisions: [] } }), now });
97
+ assert.equal(none.ok, false);
98
+ assert.equal(none.value, null);
99
+ assert.equal(none.reason, "no-rows");
100
+ });
101
+
102
+ test("email_inbox pairs a reply to its inbound message and never averages silence in as speed", async () => {
103
+ const sensors = buildSensorRegistry({
104
+ agentRoot: root(), now,
105
+ executeImpl: fakeExec({ email_inbox: { messages: [
106
+ { id: "1", threadId: "t1", direction: "INBOUND", receivedAt: "2026-08-11T00:00:00Z" },
107
+ { id: "2", threadId: "t1", direction: "OUTBOUND", sentAt: "2026-08-11T02:00:00Z" },
108
+ { id: "3", threadId: "t2", direction: "INBOUND", receivedAt: "2026-08-10T00:00:00Z" }, // never answered
109
+ ] } }),
110
+ });
111
+ const m = await measureKpi(OBJ("email_inbox"), { sensors, now });
112
+ assert.equal(m.ok, true);
113
+ assert.equal(m.value, 2); // 2h, not 1h — the unanswered one is excluded
114
+ assert.equal(m.evidence.detail.answered, 1);
115
+ assert.equal(m.evidence.detail.unanswered, 1);
116
+ });
117
+
118
+ test("crm_list_deals never sends hq an illegal stage filter", async () => {
119
+ const calls = [];
120
+ const sensors = buildSensorRegistry({
121
+ agentRoot: root(), now,
122
+ executeImpl: fakeExec({ crm_list_deals: { deals: [
123
+ { id: "1", stage: "INBOUND", valueCents: 100_000_000 },
124
+ { id: "2", stage: "CLOSED", valueCents: 900_000_000 },
125
+ ] } }, calls),
126
+ });
127
+ // "open" is not in hq's enum — it must not reach the wire.
128
+ const m = await measureKpi(OBJ("crm_list_deals", { stage: "open" }), { sensors, now });
129
+ assert.deepEqual(calls[0].input, {});
130
+ assert.equal(m.value, 1_000_000); // the CLOSED deal is excluded
131
+ assert.equal(m.evidence.detail.open, 1);
132
+ });
133
+
134
+ test("reduceGeneric is the escape hatch: filter an array, sum a field", () => {
135
+ const r = reduceGeneric(
136
+ { byAgent: [{ agent: "A016", usd: 4384.87 }, { agent: "A017", usd: 3455.27 }] },
137
+ { arrayPath: "byAgent", where: { key: "agent", equals: "A016" }, sum: "usd" },
138
+ );
139
+ assert.equal(r.value, 4384.87);
140
+ assert.equal(r.rowCount, 1);
141
+ assert.equal(pluck({ a: { b: [1, 2] } }, "a.b.1"), 2);
142
+ assert.equal(firstArray({ x: 1, deals: [1, 2, 3] }).key, "deals");
143
+ });
144
+
145
+ // ---------------------------------------------------------------------------
146
+ // THE NEGATIVES
147
+ // ---------------------------------------------------------------------------
148
+
149
+ test("NEGATIVE: an unregistered capability yields no-implementation and NO sample", async () => {
150
+ const dir = root();
151
+ const sensors = buildSensorRegistry({ agentRoot: dir, now, executeImpl: fakeExec({}) });
152
+ const o = OBJ("messaging_send");
153
+ const m = await measureKpi(o, { sensors, now });
154
+
155
+ assert.equal(m.ok, false);
156
+ assert.equal(m.reason, "no-implementation");
157
+ assert.equal(m.value, null);
158
+
159
+ // The loop only records when m.ok — assert the ledger stays empty either way.
160
+ assert.equal(readSeries(dir, o.key).length, 0);
161
+ });
162
+
163
+ test("NEGATIVE: a WRITE capability is refused by name, not silently missing", async () => {
164
+ const sensors = buildSensorRegistry({ agentRoot: root(), now, executeImpl: fakeExec({}) });
165
+ assert.equal(typeof sensors.task_update, "function", "the derivation can pick it, so it must have an answer");
166
+ const m = await measureKpi(OBJ("task_update"), { sensors, now });
167
+ assert.equal(m.ok, false);
168
+ assert.equal(m.reason, "capability-is-not-a-sensor");
169
+ assert.match(m.evidence.detail.why, /may not mutate the org/);
170
+ assert.match(refusalFor("escalation_raise"), /may not mutate the org/);
171
+ assert.match(refusalFor("approval_wait"), /long-poll/);
172
+ assert.equal(refusalFor("board_ready"), null);
173
+ });
174
+
175
+ test("NEGATIVE: a failed org read is a read-failure, never a zero", async () => {
176
+ const dir = root();
177
+ const sensors = buildSensorRegistry({
178
+ agentRoot: dir, now,
179
+ executeImpl: fakeExec({ board_ready: () => ({ ok: false, error: { code: "UNAUTHORIZED", message: "no org credential" } }) }),
180
+ });
181
+ const m = await measureKpi(OBJ("board_ready"), { sensors, now });
182
+ assert.equal(m.ok, false);
183
+ assert.equal(m.reason, "read-failed");
184
+ assert.equal(m.value, null);
185
+ assert.equal(m.evidence.detail.code, "UNAUTHORIZED");
186
+ });
187
+
188
+ test("NEGATIVE: a sensor that throws is caught and reported, not propagated", async () => {
189
+ const sensors = buildSensorRegistry({
190
+ agentRoot: root(), now,
191
+ executeImpl: async () => { throw new Error("socket hang up"); },
192
+ });
193
+ const m = await measureKpi(OBJ("board_ready"), { sensors, now });
194
+ assert.equal(m.ok, false);
195
+ assert.equal(m.reason, "sensor-threw");
196
+ });
197
+
198
+ test("NEGATIVE: an unmeasurable objective cannot reach the admission honesty gate", async () => {
199
+ const dir = root();
200
+ const sensors = buildSensorRegistry({ agentRoot: dir, now, executeImpl: fakeExec({ board_ready: { items: [] } }) });
201
+ const o = OBJ("board_ready");
202
+
203
+ const m = await measureKpi(o, { sensors, now });
204
+ assert.equal(m.ok, false, "empty board → no measurement");
205
+
206
+ // Exactly what the loop does: record ONLY when ok.
207
+ if (m.ok) recordMeasurement(dir, { objectiveKey: o.key, value: m.value, window: "2026-W33", source: m.source }, { now });
208
+ const series = readSeries(dir, o.key);
209
+ assert.equal(series.length, 0);
210
+
211
+ const verdict = admit(
212
+ { title: "do something", advances: [o.key], why: { reason: "kpi-gap" } },
213
+ { objective: o, gap: { gap: 15, withinTolerance: false, trend: "unknown" }, latestSample: series[series.length - 1] || null, openItems: [], staleness: { outcome: "full" } },
214
+ );
215
+ assert.equal(verdict.admitted, false);
216
+ assert.equal(verdict.gate, "honesty");
217
+ rmSync(dir, { recursive: true, force: true });
218
+ });
219
+
220
+ test("a MEASURED sample satisfies the honesty gate — the joint, end to end", async () => {
221
+ const dir = root();
222
+ const sensors = buildSensorRegistry({
223
+ agentRoot: dir, now,
224
+ executeImpl: fakeExec({ board_ready: { items: [{ id: "a", dueAt: "2026-06-01T00:00:00Z" }, { id: "b", dueAt: null }, { id: "c", dueAt: null }, { id: "d", dueAt: null }] } }),
225
+ });
226
+ const o = OBJ("board_ready");
227
+ const m = await measureKpi(o, { sensors, now });
228
+ assert.equal(m.ok, true);
229
+ recordMeasurement(dir, { objectiveKey: o.key, value: m.value, window: "2026-W33", source: m.source, evidence: m.evidence }, { now });
230
+
231
+ const series = readSeries(dir, o.key);
232
+ const verdict = admit(
233
+ { title: "raise the on-time rate", advances: [o.key], why: { reason: "kpi-gap" }, estCents: 50 },
234
+ { objective: o, gap: { gap: 15, withinTolerance: false, trend: "unknown" }, latestSample: series[series.length - 1], openItems: [], remainingCents: 500, staleness: { outcome: "full" } },
235
+ );
236
+ assert.equal(verdict.admitted, true, verdict.reason);
237
+ assert.equal(series[0].evidence.method, "board_ready");
238
+ assert.equal(series[0].evidence.rowCount, 4);
239
+ rmSync(dir, { recursive: true, force: true });
240
+ });
241
+
242
+ // ---------------------------------------------------------------------------
243
+ // Reachability — the fail-open that must not become fail-closed
244
+ // ---------------------------------------------------------------------------
245
+
246
+ test("a missing or empty manifest returns undefined, not an empty Set", () => {
247
+ const dir = root();
248
+ const logs = [];
249
+ const log = (l, m) => logs.push(`${l}:${m}`);
250
+ assert.equal(reachableCapabilities(dir, { log }), undefined);
251
+ assert.ok(logs.some((l) => /no capability manifest/.test(l)), "the degradation is logged");
252
+
253
+ writeFileSync(join(dir, "config", "capability-manifest.json"), JSON.stringify({ entries: [] }));
254
+ assert.equal(reachableCapabilities(dir, { log }), undefined, "zero reachable entries must not refuse every objective");
255
+
256
+ writeFileSync(join(dir, "config", "capability-manifest.json"), "{not json");
257
+ assert.equal(reachableCapabilities(dir, { log }), undefined);
258
+
259
+ writeFileSync(join(dir, "config", "capability-manifest.json"), JSON.stringify({ entries: [{ id: "board_ready", reachable: true }, { id: "task_update", reachable: false }] }));
260
+ const set = reachableCapabilities(dir, { log });
261
+ assert.ok(set.has("board_ready"));
262
+ assert.ok(!set.has("task_update"));
263
+ rmSync(dir, { recursive: true, force: true });
264
+ });
265
+
266
+ test("every capability the mandate derivation can bind has an answer in the registry", () => {
267
+ const sensors = buildSensorRegistry({ agentRoot: root(), now, executeImpl: fakeExec({}) });
268
+ for (const cap of hintedCapabilities()) {
269
+ assert.equal(typeof sensors[cap], "function", `${cap} is hinted by derive.SENSOR_HINTS but has no registry entry`);
270
+ }
271
+ // …and every spec is a READ.
272
+ for (const cap of Object.keys(SENSOR_SPECS)) {
273
+ assert.equal(refusalFor(cap), null, `${cap} has a spec so it must not be refused`);
274
+ }
275
+ });
package/lib/kpi.mjs CHANGED
@@ -232,6 +232,12 @@ export function recordIntervention(agentRoot, rec, deps = {}) {
232
232
  expectedDelta: Number.isFinite(Number(rec && rec.expectedDelta)) ? Number(rec.expectedDelta) : null,
233
233
  realizedDelta: Number.isFinite(Number(rec && rec.realizedDelta)) ? Number(rec.realizedDelta) : null,
234
234
  measuredAt: (rec && rec.measuredAt) || null,
235
+ // The measurement window the realized delta was scored in, and how it was
236
+ // derived. `(taskId, window)` is the idempotency key the loop dedupes on —
237
+ // without it a second steward pass in the same week double-scores the same
238
+ // intervention and the planner's "this failed before" count drifts up on its own.
239
+ window: (rec && rec.window) || null,
240
+ basis: (rec && rec.basis) || null,
235
241
  };
236
242
  try {
237
243
  const p = interventionsPath(agentRoot);
@@ -306,6 +312,20 @@ export async function measureKpi(objective, deps = {}) {
306
312
  return { ok: false, value: null, source: "method", evidence: null, reason: "sensor-threw" };
307
313
  }
308
314
 
315
+ // A sensor that CANNOT measure says so in its own words. Collapsing that into
316
+ // the generic "non-numeric" below would throw away the only diagnostic the
317
+ // operator has — "no-rows" (the org genuinely has nothing), "read-failed"
318
+ // (the org said no), "capability-is-not-a-sensor" (the mandate cited a write
319
+ // tool) and "missing-param" are four different repairs, not one.
320
+ if (raw && typeof raw === "object" && raw.ok === false) {
321
+ const reason = String(raw.reason || "sensor-unavailable");
322
+ logOf(deps)(
323
+ "warn",
324
+ `[kpi] sensor "${sensor.capability}" could not measure "${key}": ${reason}${raw.detail ? ` ${JSON.stringify(raw.detail)}` : ""}`
325
+ );
326
+ return { ok: false, value: null, source: "method", evidence: { method: sensor.capability, params: sensor.params || {}, detail: raw.detail || null }, reason };
327
+ }
328
+
309
329
  const value = Number(raw && typeof raw === "object" ? raw.value : raw);
310
330
  if (!Number.isFinite(value)) {
311
331
  logOf(deps)("warn", `[kpi] sensor "${sensor.capability}" returned a non-numeric result for "${key}"`);
@@ -321,6 +341,9 @@ export async function measureKpi(objective, deps = {}) {
321
341
  params: sensor.params || {},
322
342
  rowCount: raw && typeof raw === "object" && Number.isFinite(Number(raw.rowCount)) ? Number(raw.rowCount) : null,
323
343
  sessionId: (raw && typeof raw === "object" && raw.sessionId) || null,
344
+ // What the reduction actually walked (metric, scope, rows considered).
345
+ // Without it "value: 75" is unauditable a week later.
346
+ detail: (raw && typeof raw === "object" && raw.detail) || null,
324
347
  },
325
348
  reason: "measured",
326
349
  };
@@ -41,6 +41,9 @@ export const AUDIT_REL = join("state", "mandate", "audit.jsonl");
41
41
  export const DECISIONS = Object.freeze([
42
42
  "measured",
43
43
  "measure_failed",
44
+ // The learning signal: an in-flight intervention scored against the sample
45
+ // that just landed (lib/goals/loop.journalRealizedDeltas → kpi.recordIntervention).
46
+ "intervention_scored",
44
47
  "gap",
45
48
  "candidate_admitted",
46
49
  "candidate_rejected",
@@ -0,0 +1,277 @@
1
+ /**
2
+ * lib/mandate/contract.mjs — THE MANDATE BODY CONTRACT (agent half).
3
+ *
4
+ * ONE shape, written down twice: here as a JSDoc typedef + runtime validator,
5
+ * and in hq as TypeScript at `src/server/methods/mandate/_contract.ts`. The two
6
+ * files carry the SAME `MANDATE_CONTRACT_VERSION` and the SAME key lists, and
7
+ * each repo's tests pin those literals — so a divergence is a one-line diff
8
+ * instead of an invisible `undefined`.
9
+ *
10
+ * ── WHY THIS FILE EXISTS ──
11
+ * The two repos disagreed on this body and NOTHING errored. hq emitted
12
+ * `{memberId, objectives, proposed, generatedAt}`; `compilePlan` read
13
+ * `{objectives, budgetCentsPerPeriod, collaborators, reactsTo}`. Three of the
14
+ * four keys were simply absent, so `Number.isFinite(mandate.budgetCentsPerPeriod)`
15
+ * was false on every server body and EVERY obligation took a hardcoded 500c
16
+ * allowance against a hardcoded 10000c envelope. No error frame, no log line,
17
+ * no drift row — the plan ran on fictional money and looked perfectly healthy.
18
+ *
19
+ * The rule: a MISSING required key is a contract ERROR (the body cannot be
20
+ * trusted). A key that is PRESENT AND NULL is a warning (hq looked and had
21
+ * nothing — the obligation is genuinely unmetered, and hq says why in
22
+ * `degradations[]`). Those two used to be indistinguishable. They no longer are,
23
+ * and NOTHING in this codebase may substitute a number for either of them.
24
+ *
25
+ * PURE: no fs, no network, no clock. ESM.
26
+ *
27
+ * @module lib/mandate/contract
28
+ */
29
+
30
+ "use strict";
31
+
32
+ /** Bump when a key is added, removed or re-typed. Mirrored in hq. */
33
+ export const MANDATE_CONTRACT_VERSION = 1;
34
+
35
+ /**
36
+ * The EXACT top-level key set of a v1 body, sorted. Pinned by `contract.test.mjs`
37
+ * here and by hq's `shared.test.ts` — the two assertions ARE the drift alarm, so
38
+ * keep the literals identical.
39
+ */
40
+ export const MANDATE_BODY_KEYS = Object.freeze([
41
+ "budgetCentsPerPeriod",
42
+ "budgetPeriod",
43
+ "budgetSource",
44
+ "collaborators",
45
+ "contractVersion",
46
+ "degradations",
47
+ "generatedAt",
48
+ "memberId",
49
+ "objectives",
50
+ "proposed",
51
+ "reactsTo",
52
+ "seatBudgetCents",
53
+ ]);
54
+
55
+ /**
56
+ * Keys `compilePlan` READS. A missing one is what made the compiler invent a
57
+ * number, so a missing one is a hard error — never a warning, never a default.
58
+ */
59
+ export const MANDATE_REQUIRED_KEYS = Object.freeze([
60
+ "budgetCentsPerPeriod",
61
+ "collaborators",
62
+ "contractVersion",
63
+ "memberId",
64
+ "objectives",
65
+ "reactsTo",
66
+ "seatBudgetCents",
67
+ ]);
68
+
69
+ /**
70
+ * @typedef {"DOER"|"CONTRIBUTOR"|"REVIEWER"|"APPROVER"|"INFORMED"} CollaboratorRole
71
+ *
72
+ * @typedef {object} MandateCollaborator
73
+ * @property {string} memberId
74
+ * @property {CollaboratorRole} role
75
+ * @property {string} source the column the edge came out of (provenance)
76
+ *
77
+ * @typedef {object} MandateDegradation
78
+ * @property {string} field dotted path of the null/empty field
79
+ * @property {string} reason why, in one sentence a human can act on
80
+ *
81
+ * @typedef {object} ObjectiveWire
82
+ * @property {string} id
83
+ * @property {string|null} key
84
+ * @property {string} kind PILLAR|OBJECTIVE|GOAL
85
+ * @property {string} state proposed|active|at_risk|met|missed|retired
86
+ * @property {string} text
87
+ * @property {string|null} memberId
88
+ * @property {string|null} parentId
89
+ * @property {string|null} parentKey the SDK's tree walk joins on KEYS
90
+ * @property {string|null} charterSectionId
91
+ * @property {string|null} workstreamId
92
+ * @property {string|null} metric
93
+ * @property {string|null} unit
94
+ * @property {string} direction
95
+ * @property {number|null} baseline
96
+ * @property {number|null} target
97
+ * @property {number|null} value
98
+ * @property {number|null} tolerance
99
+ * @property {number} weight
100
+ * @property {number} progress
101
+ * @property {string|null} cadence
102
+ * @property {string|null} dueAt
103
+ * @property {object|null} sensor
104
+ * @property {number|null} budgetCentsPerPeriod this node's share of the seat
105
+ * envelope, apportioned by weight
106
+ * @property {MandateCollaborator[]} collaborators
107
+ * @property {number} version
108
+ * @property {string|null} proposedById
109
+ * @property {string|null} adoptedById
110
+ * @property {string|null} adoptedAt
111
+ * @property {string} createdAt
112
+ * @property {string} updatedAt
113
+ *
114
+ * @typedef {object} MandateBody
115
+ * @property {number} contractVersion
116
+ * @property {string} memberId
117
+ * @property {ObjectiveWire[]} objectives adopted — the only compilable nodes
118
+ * @property {ObjectiveWire[]} proposed carried for display; never compiled
119
+ * @property {number|null} budgetCentsPerPeriod default allowance for ONE
120
+ * obligation with no objective behind it. ALWAYS null from hq v1 — hq holds no
121
+ * such column. Present so "unmetered, and hq said so" is distinguishable from
122
+ * "key missing, so I made a number up".
123
+ * @property {number|null} seatBudgetCents the seat's whole envelope per period
124
+ * @property {"monthly"|null} budgetPeriod
125
+ * @property {string} budgetSource provenance of the two figures above
126
+ * @property {MandateCollaborator[]} collaborators seat-level edges
127
+ * @property {object[]} reactsTo ALWAYS [] from hq v1 (no store)
128
+ * @property {MandateDegradation[]} degradations every null above, named
129
+ * @property {string} generatedAt newest row's updatedAt; never a clock
130
+ */
131
+
132
+ /** Pull the body out of a cache record (`{body}`) or accept a bare body. */
133
+ export function bodyOf(source) {
134
+ if (!source || typeof source !== "object") return null;
135
+ if (source.body && typeof source.body === "object" && !Array.isArray(source.body)) {
136
+ return source.body;
137
+ }
138
+ return source;
139
+ }
140
+
141
+ /**
142
+ * Validate a mandate body against the contract. Never throws.
143
+ *
144
+ * @param {object} source a cache record (`{body}`) or a bare body
145
+ * @returns {{
146
+ * ok:boolean, contractVersion:number|null, missing:string[],
147
+ * errors:string[], warnings:string[], unmetered:boolean
148
+ * }}
149
+ * `ok:false` means DO NOT TRUST THE NUMBERS in this body. `unmetered:true`
150
+ * means the body is well-formed and hq truthfully has no budget for it.
151
+ */
152
+ export function validateMandateContract(source) {
153
+ const errors = [];
154
+ const warnings = [];
155
+ const missing = [];
156
+ const body = bodyOf(source);
157
+
158
+ if (!body || typeof body !== "object" || Array.isArray(body)) {
159
+ return {
160
+ ok: false,
161
+ contractVersion: null,
162
+ missing: [...MANDATE_REQUIRED_KEYS],
163
+ errors: ["mandate body is not an object"],
164
+ warnings: [],
165
+ unmetered: true,
166
+ };
167
+ }
168
+
169
+ for (const k of MANDATE_REQUIRED_KEYS) {
170
+ if (!Object.prototype.hasOwnProperty.call(body, k)) missing.push(k);
171
+ }
172
+ if (missing.length) {
173
+ errors.push(
174
+ `mandate body is missing required key(s): ${missing.join(", ")}. ` +
175
+ "This is a PRE-CONTRACT body (hq older than mandate contract v1, or a " +
176
+ "hand-written fixture). Its budgets cannot be trusted and NOTHING may " +
177
+ "substitute a default for them."
178
+ );
179
+ }
180
+
181
+ const version = Number.isFinite(body.contractVersion) ? body.contractVersion : null;
182
+ if (version == null) {
183
+ if (!missing.includes("contractVersion")) {
184
+ errors.push(`contractVersion must be a number (got ${JSON.stringify(body.contractVersion)})`);
185
+ }
186
+ } else if (version > MANDATE_CONTRACT_VERSION) {
187
+ // Forward-compat: a newer server is not an error, but the extra keys are
188
+ // unknown to this compiler and must be announced rather than dropped mute.
189
+ warnings.push(
190
+ `mandate body is contract v${version} but this SDK understands v${MANDATE_CONTRACT_VERSION} — ` +
191
+ "unknown keys are ignored; upgrade the SDK"
192
+ );
193
+ }
194
+
195
+ if (body.memberId != null && typeof body.memberId !== "string") {
196
+ errors.push("memberId must be a string or null");
197
+ }
198
+ for (const k of ["objectives", "proposed", "collaborators", "reactsTo", "degradations"]) {
199
+ if (Object.prototype.hasOwnProperty.call(body, k) && !Array.isArray(body[k])) {
200
+ errors.push(`${k} must be an array (got ${typeof body[k]})`);
201
+ }
202
+ }
203
+ for (const k of ["budgetCentsPerPeriod", "seatBudgetCents"]) {
204
+ if (!Object.prototype.hasOwnProperty.call(body, k)) continue;
205
+ const v = body[k];
206
+ if (v !== null && !Number.isFinite(v)) {
207
+ errors.push(`${k} must be a finite number or null (got ${JSON.stringify(v)})`);
208
+ } else if (v !== null && v < 0) {
209
+ errors.push(`${k} must not be negative (got ${v})`);
210
+ }
211
+ }
212
+
213
+ const seat = Number.isFinite(body.seatBudgetCents) ? body.seatBudgetCents : null;
214
+ const perOb = Number.isFinite(body.budgetCentsPerPeriod) ? body.budgetCentsPerPeriod : null;
215
+ const objectiveBudgets = (Array.isArray(body.objectives) ? body.objectives : [])
216
+ .map((o) => (o && Number.isFinite(o.budgetCentsPerPeriod) ? o.budgetCentsPerPeriod : null))
217
+ .filter((v) => v != null);
218
+ const unmetered = seat == null && perOb == null && objectiveBudgets.length === 0;
219
+
220
+ if (seat == null && !missing.includes("seatBudgetCents")) {
221
+ warnings.push(
222
+ "seatBudgetCents is null — the seat has no funded envelope, so no budget " +
223
+ "ceiling can be enforced. hq's reason is in degradations[]."
224
+ );
225
+ }
226
+ if (perOb == null && !missing.includes("budgetCentsPerPeriod")) {
227
+ warnings.push(
228
+ "budgetCentsPerPeriod is null — obligations with no objective behind them " +
229
+ "(standard/archetype cadences, REACTs) compile UNMETERED. They are not " +
230
+ "worth 500c; they are worth an unknown amount."
231
+ );
232
+ }
233
+ if (seat != null && objectiveBudgets.length) {
234
+ const sum = objectiveBudgets.reduce((a, b) => a + b, 0);
235
+ if (sum > seat) {
236
+ errors.push(
237
+ `the objective budgets sum to ${sum}c but the seat envelope is ${seat}c — ` +
238
+ "hq apportioned more than it holds"
239
+ );
240
+ }
241
+ }
242
+
243
+ // hq states its absences on the wire; echo them so one log line explains the
244
+ // nulls above instead of leaving the operator to guess.
245
+ for (const d of Array.isArray(body.degradations) ? body.degradations : []) {
246
+ if (d && d.field && d.reason) warnings.push(`hq degradation — ${d.field}: ${d.reason}`);
247
+ }
248
+
249
+ return { ok: errors.length === 0, contractVersion: version, missing, errors, warnings, unmetered };
250
+ }
251
+
252
+ /**
253
+ * Validate and LOG. The one call site every consumer should use: the failure
254
+ * mode this contract exists to kill is a quiet one, so the loud path is the
255
+ * default path.
256
+ *
257
+ * @param {object} source
258
+ * @param {(level:string,msg:string)=>void} [log]
259
+ * @param {string} [where] a tag for the log line, e.g. "compilePlan"
260
+ * @returns ReturnType<typeof validateMandateContract>
261
+ */
262
+ export function assertMandateContract(source, log, where = "mandate") {
263
+ const v = validateMandateContract(source);
264
+ const emit = typeof log === "function" ? log : () => {};
265
+ for (const e of v.errors) emit("error", `[${where}] mandate contract violation: ${e}`);
266
+ for (const w of v.warnings) emit("warn", `[${where}] ${w}`);
267
+ return v;
268
+ }
269
+
270
+ export default {
271
+ MANDATE_CONTRACT_VERSION,
272
+ MANDATE_BODY_KEYS,
273
+ MANDATE_REQUIRED_KEYS,
274
+ bodyOf,
275
+ validateMandateContract,
276
+ assertMandateContract,
277
+ };