@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,185 @@
1
+ /**
2
+ * lib/mandate/contract.test.mjs — the SDK half of THE MANDATE BODY CONTRACT.
3
+ *
4
+ * The break this closes: hq emitted `{memberId, objectives, proposed,
5
+ * generatedAt}` while `compilePlan` read `{objectives, budgetCentsPerPeriod,
6
+ * collaborators, reactsTo}`. Three keys were absent, `Number.isFinite(undefined)`
7
+ * was false, and every obligation in the fleet took a hardcoded 500c allowance
8
+ * against a hardcoded 10000c envelope — with no error, no log and no drift row.
9
+ *
10
+ * The invariant under test, stated once: a MISSING required key is an ERROR (the
11
+ * body cannot be budgeted from); a key that is PRESENT AND NULL is a WARNING (hq
12
+ * looked and had nothing). Nothing anywhere may substitute a number for either.
13
+ */
14
+
15
+ "use strict";
16
+
17
+ import { test } from "node:test";
18
+ import assert from "node:assert/strict";
19
+ import { existsSync, readFileSync } from "node:fs";
20
+
21
+ import {
22
+ MANDATE_BODY_KEYS,
23
+ MANDATE_CONTRACT_VERSION,
24
+ MANDATE_REQUIRED_KEYS,
25
+ assertMandateContract,
26
+ bodyOf,
27
+ validateMandateContract,
28
+ } from "./contract.mjs";
29
+
30
+ /** A well-formed, FUNDED body in exactly the shape hq publishes. */
31
+ function fundedBody(over = {}) {
32
+ return {
33
+ contractVersion: 1,
34
+ memberId: "mem_self",
35
+ objectives: [
36
+ { key: "pipeline", state: "active", weight: 3, budgetCentsPerPeriod: 18_750 },
37
+ { key: "inbox", state: "active", weight: 1, budgetCentsPerPeriod: 6_250 },
38
+ ],
39
+ proposed: [],
40
+ budgetCentsPerPeriod: null,
41
+ seatBudgetCents: 25_000,
42
+ budgetPeriod: "monthly",
43
+ budgetSource: "employee.payBasis.meteredBudget",
44
+ collaborators: [{ memberId: "mem_boss", role: "REVIEWER", source: "workforceMember.supervisorId" }],
45
+ reactsTo: [],
46
+ degradations: [],
47
+ generatedAt: "2026-08-11T00:00:00.000Z",
48
+ ...over,
49
+ };
50
+ }
51
+
52
+ test("a funded hq body validates clean and is NOT unmetered", () => {
53
+ const v = validateMandateContract(fundedBody());
54
+ assert.equal(v.ok, true);
55
+ assert.deepEqual(v.missing, []);
56
+ assert.equal(v.contractVersion, MANDATE_CONTRACT_VERSION);
57
+ assert.equal(v.unmetered, false);
58
+ assert.equal(v.errors.length, 0);
59
+ });
60
+
61
+ test("THE ORIGINAL BREAK: the pre-contract body is an ERROR, not a shrug", () => {
62
+ // Byte-for-byte the shape hq used to send.
63
+ const legacy = { memberId: "mem_self", objectives: [], proposed: [], generatedAt: "1970-01-01T00:00:00.000Z" };
64
+ const v = validateMandateContract(legacy);
65
+ assert.equal(v.ok, false);
66
+ for (const k of ["budgetCentsPerPeriod", "seatBudgetCents", "collaborators", "reactsTo", "contractVersion"]) {
67
+ assert.ok(v.missing.includes(k), `missing names ${k}`);
68
+ }
69
+ assert.ok(v.errors.some((e) => /PRE-CONTRACT/.test(e)));
70
+ assert.equal(v.unmetered, true);
71
+ });
72
+
73
+ test("PRESENT-AND-NULL is a warning, MISSING is an error — the distinction is the point", () => {
74
+ const unfunded = validateMandateContract(
75
+ fundedBody({
76
+ seatBudgetCents: null,
77
+ budgetPeriod: null,
78
+ budgetSource: "none",
79
+ objectives: [{ key: "pipeline", state: "active", weight: 1, budgetCentsPerPeriod: null }],
80
+ degradations: [{ field: "seatBudgetCents", reason: "no Employee row is linked to this member" }],
81
+ })
82
+ );
83
+ // Well-formed: the body can be trusted. Just genuinely unfunded.
84
+ assert.equal(unfunded.ok, true);
85
+ assert.deepEqual(unfunded.missing, []);
86
+ assert.equal(unfunded.unmetered, true);
87
+ assert.ok(unfunded.warnings.some((w) => /seatBudgetCents is null/.test(w)));
88
+ // hq's own stated reason is echoed, so one log line explains the null.
89
+ assert.ok(unfunded.warnings.some((w) => /hq degradation — seatBudgetCents/.test(w)));
90
+
91
+ // Delete the key instead of nulling it and the verdict flips to untrustworthy.
92
+ const body = fundedBody();
93
+ delete body.seatBudgetCents;
94
+ const gone = validateMandateContract(body);
95
+ assert.equal(gone.ok, false);
96
+ assert.deepEqual(gone.missing, ["seatBudgetCents"]);
97
+ });
98
+
99
+ test("apportionment that exceeds the envelope is an ERROR, not a rounding note", () => {
100
+ const v = validateMandateContract(
101
+ fundedBody({ objectives: [{ key: "a", budgetCentsPerPeriod: 20_000 }, { key: "b", budgetCentsPerPeriod: 20_000 }] })
102
+ );
103
+ assert.equal(v.ok, false);
104
+ assert.ok(v.errors.some((e) => /apportioned more than it holds/.test(e)));
105
+ });
106
+
107
+ test("junk in the budget fields is rejected rather than coerced", () => {
108
+ for (const bad of ["500", NaN, Infinity, -1, {}]) {
109
+ const v = validateMandateContract(fundedBody({ seatBudgetCents: bad }));
110
+ assert.equal(v.ok, false, `seatBudgetCents=${String(bad)} must be rejected`);
111
+ }
112
+ });
113
+
114
+ test("a non-object body fails closed instead of throwing", () => {
115
+ for (const bad of [null, undefined, 42, "body", []]) {
116
+ const v = validateMandateContract(bad);
117
+ assert.equal(v.ok, false);
118
+ assert.equal(v.unmetered, true);
119
+ }
120
+ });
121
+
122
+ test("a FUTURE contract version warns but still compiles (forward-compat)", () => {
123
+ const v = validateMandateContract(fundedBody({ contractVersion: MANDATE_CONTRACT_VERSION + 1 }));
124
+ assert.equal(v.ok, true);
125
+ assert.ok(v.warnings.some((w) => /upgrade the SDK/.test(w)));
126
+ });
127
+
128
+ test("bodyOf unwraps a cache record and passes a bare body through", () => {
129
+ const body = fundedBody();
130
+ assert.equal(bodyOf({ body, version: 4 }), body);
131
+ assert.equal(bodyOf(body), body);
132
+ assert.equal(bodyOf(null), null);
133
+ // The cache record shape must validate identically to the bare body.
134
+ assert.equal(validateMandateContract({ body, version: 4 }).ok, true);
135
+ });
136
+
137
+ test("assertMandateContract LOGS every error and warning — silence is the bug", () => {
138
+ const lines = [];
139
+ const v = assertMandateContract({ memberId: "m" }, (level, msg) => lines.push([level, msg]), "compilePlan");
140
+ assert.equal(v.ok, false);
141
+ assert.ok(lines.some(([l]) => l === "error"), "the violation is logged at error");
142
+ assert.ok(lines.every(([, m]) => m.includes("[compilePlan]")), "every line is tagged with its call site");
143
+ // It must never throw, whatever it is handed — it runs inside the daemon.
144
+ assert.doesNotThrow(() => assertMandateContract(undefined));
145
+ assert.doesNotThrow(() => assertMandateContract({}, "not a function"));
146
+ });
147
+
148
+ // ---------------------------------------------------------------------------
149
+ // THE CROSS-REPO DRIFT ALARM
150
+ // ---------------------------------------------------------------------------
151
+
152
+ test("the key lists match hq's _contract.ts literal for literal", { skip: hqContractPath() ? false : "hq checkout not present" }, () => {
153
+ // The two repos cannot import each other, so the contract is written down
154
+ // twice — and a shape written down twice drifts unless something compares
155
+ // them. This reads hq's TypeScript source and diffs the literals. It is the
156
+ // single mechanical reason this joint cannot silently reopen.
157
+ const src = readFileSync(hqContractPath(), "utf8");
158
+
159
+ const arrayLiteral = (name) => {
160
+ const m = src.match(new RegExp(`export const ${name} = \\[([^\\]]*)\\]`));
161
+ assert.ok(m, `hq's _contract.ts declares ${name}`);
162
+ return m[1].split(",").map((s) => s.trim().replace(/^["']|["']$/g, "")).filter(Boolean);
163
+ };
164
+
165
+ assert.deepEqual(arrayLiteral("MANDATE_BODY_KEYS"), [...MANDATE_BODY_KEYS], "MANDATE_BODY_KEYS drifted between the repos");
166
+ assert.deepEqual(arrayLiteral("MANDATE_REQUIRED_KEYS"), [...MANDATE_REQUIRED_KEYS], "MANDATE_REQUIRED_KEYS drifted between the repos");
167
+
168
+ const ver = src.match(/export const MANDATE_CONTRACT_VERSION = (\d+)/);
169
+ assert.ok(ver, "hq declares MANDATE_CONTRACT_VERSION");
170
+ assert.equal(Number(ver[1]), MANDATE_CONTRACT_VERSION, "the contract VERSION drifted between the repos");
171
+ });
172
+
173
+ /** hq's contract file, when this checkout sits beside one. */
174
+ function hqContractPath() {
175
+ const p = `${process.env.HOME}/hq/src/server/methods/mandate/_contract.ts`;
176
+ return existsSync(p) ? p : null;
177
+ }
178
+
179
+ test("the required keys are a SUBSET of the body keys (an unreadable key cannot be required)", () => {
180
+ for (const k of MANDATE_REQUIRED_KEYS) {
181
+ assert.ok(MANDATE_BODY_KEYS.includes(k), `${k} is required but not in MANDATE_BODY_KEYS`);
182
+ }
183
+ assert.deepEqual([...MANDATE_BODY_KEYS], [...MANDATE_BODY_KEYS].slice().sort(), "MANDATE_BODY_KEYS must stay sorted");
184
+ assert.deepEqual([...MANDATE_REQUIRED_KEYS], [...MANDATE_REQUIRED_KEYS].slice().sort(), "MANDATE_REQUIRED_KEYS must stay sorted");
185
+ });
@@ -31,6 +31,8 @@
31
31
 
32
32
  import { createHash } from "node:crypto";
33
33
 
34
+ import { MANDATE_CONTRACT_VERSION } from "./contract.mjs";
35
+
34
36
  /** Objective kinds (mirrors hq's ObjectiveKind enum). */
35
37
  export const OBJECTIVE_KINDS = Object.freeze(["PILLAR", "OBJECTIVE", "GOAL"]);
36
38
  /** Metric directions (mirrors hq's MetricDirection enum). */
@@ -46,8 +48,14 @@ export const CADENCES = Object.freeze(["weekly", "monthly", "quarterly"]);
46
48
  * @type {Array<{re:RegExp, caps:string[], params?:object, direction?:string, unit?:string}>}
47
49
  */
48
50
  export const SENSOR_HINTS = Object.freeze([
49
- { re: /(pipeline|deal|bookings|revenue|win rate|arr|mrr)/i, caps: ["crm_list_deals", "crm_next_best_action"], params: { stage: "open" }, unit: "x", direction: "up" },
50
- { re: /(runway|burn|cash|invoice|billing|tax|spend|cost)/i, caps: ["books_reports", "books_invoices"], unit: "months", direction: "up" },
51
+ // `params` here are sent VERBATIM to the capability by lib/kpi-sensors.mjs, so
52
+ // they must be legal at hq. `{stage:"open"}` was not: crm.listDeals takes a
53
+ // strict enum (INBOUND|QUALIFIED|PROPOSAL|NEGOTIATION|CLOSED) and answered every
54
+ // derived pipeline sensor with BAD_REQUEST. Open-ness is a reduction, not a
55
+ // filter — the sensor computes it from the stage field. Likewise books.reports
56
+ // REQUIRES `report`, so a hint that omitted it could never execute.
57
+ { re: /(pipeline|deal|bookings|revenue|win rate|arr|mrr)/i, caps: ["crm_list_deals", "crm_next_best_action"], params: { metric: "open_pipeline_usd" }, unit: "usd", direction: "up" },
58
+ { re: /(runway|burn|cash|invoice|billing|tax|spend|cost)/i, caps: ["books_reports", "books_invoices"], params: { report: "cashflow" }, unit: "months", direction: "up" },
51
59
  { re: /(commitment|action item|follow-?through|closure|backlog|task|execution)/i, caps: ["board_ready", "task_update"], unit: "%", direction: "up" },
52
60
  { re: /(decision|governance|approval)/i, caps: ["decision_list", "approval_wait"], unit: "days", direction: "down" },
53
61
  { re: /(meeting|calendar|cadence|agenda)/i, caps: ["calendar_list", "meetings_recap_file"], unit: "%", direction: "up" },
@@ -228,16 +236,52 @@ export function deriveMandate(input = {}) {
228
236
  }
229
237
 
230
238
  /**
231
- * Wrap the derived tree in the mandate BODY shape the cache + compiler consume.
232
- * @param {object} o - { memberId, objectives, budgetCentsPerPeriod?, collaborators?, reactsTo? }
239
+ * Wrap the derived tree in the CANONICAL mandate body (lib/mandate/contract.mjs
240
+ * — the same shape hq publishes, key for key).
241
+ *
242
+ * `budgetCentsPerPeriod` used to default to 500 here. It does not any more, and
243
+ * that is the point: a LOCALLY DERIVED mandate is the offline fallback, and an
244
+ * offline agent has no way whatsoever to know what it is funded for. Emitting
245
+ * 500 made "I never asked" indistinguishable from "I asked and was told 500".
246
+ * Null says the true thing, and `degradations[]` says why — so the plan comes
247
+ * out unmetered-and-loud instead of budgeted-and-wrong.
248
+ *
249
+ * @param {object} o - { memberId, objectives, proposed?, budgetCentsPerPeriod?,
250
+ * seatBudgetCents?, collaborators?, reactsTo?, generatedAt? }
251
+ * @returns {import("./contract.mjs").MandateBody}
233
252
  */
234
253
  export function toMandateBody(o = {}) {
254
+ const budgetCentsPerPeriod = Number.isFinite(o.budgetCentsPerPeriod) ? o.budgetCentsPerPeriod : null;
255
+ const seatBudgetCents = Number.isFinite(o.seatBudgetCents) ? o.seatBudgetCents : null;
256
+ const degradations = [];
257
+ if (budgetCentsPerPeriod == null) {
258
+ degradations.push({
259
+ field: "budgetCentsPerPeriod",
260
+ reason:
261
+ "derived locally: the agent cannot know its own funding offline, so every " +
262
+ "obligation compiles unmetered until an adopted snapshot arrives from hq",
263
+ });
264
+ }
265
+ if (seatBudgetCents == null) {
266
+ degradations.push({
267
+ field: "seatBudgetCents",
268
+ reason: "derived locally: no seat envelope is knowable offline",
269
+ });
270
+ }
235
271
  return {
272
+ contractVersion: MANDATE_CONTRACT_VERSION,
236
273
  memberId: o.memberId || null,
237
274
  objectives: Array.isArray(o.objectives) ? o.objectives : [],
238
- budgetCentsPerPeriod: Number.isFinite(o.budgetCentsPerPeriod) ? o.budgetCentsPerPeriod : 500,
275
+ proposed: Array.isArray(o.proposed) ? o.proposed : [],
276
+ budgetCentsPerPeriod,
277
+ seatBudgetCents,
278
+ budgetPeriod: seatBudgetCents == null ? null : "monthly",
279
+ budgetSource: "local-derivation",
239
280
  collaborators: Array.isArray(o.collaborators) ? o.collaborators : [],
240
281
  reactsTo: Array.isArray(o.reactsTo) ? o.reactsTo : [],
282
+ degradations,
283
+ // From the data, never the clock — the body is checksummed.
284
+ generatedAt: typeof o.generatedAt === "string" ? o.generatedAt : "1970-01-01T00:00:00.000Z",
241
285
  };
242
286
  }
243
287
 
@@ -194,7 +194,13 @@ test("toMandateBody produces the shape the cache and compiler consume", () => {
194
194
  const body = toMandateBody({ memberId: "mem_self", objectives: r.objectives });
195
195
  assert.equal(body.memberId, "mem_self");
196
196
  assert.ok(Array.isArray(body.objectives));
197
- assert.equal(typeof body.budgetCentsPerPeriod, "number");
197
+ // NOT a number. A locally-derived body is the OFFLINE fallback, and an offline
198
+ // agent cannot know what it is funded for. This assertion used to demand a
199
+ // number and got the hardcoded 500 that made "I never asked" look exactly like
200
+ // "I asked and was told 500". Null is the true answer, and it must be PRESENT.
201
+ assert.ok(Object.prototype.hasOwnProperty.call(body, "budgetCentsPerPeriod"));
202
+ assert.equal(body.budgetCentsPerPeriod, null);
203
+ assert.ok(body.degradations.some((d) => d.field === "budgetCentsPerPeriod"));
198
204
  assert.ok(Array.isArray(body.reactsTo));
199
205
  // And it must round-trip through the validator the loop uses.
200
206
  assert.equal(validateMandateBody(body).ok, true);
@@ -226,7 +226,16 @@ export function collaboratorsOf(objective) {
226
226
  const memberId = typeof c === "string" ? c : c.memberId || c.member_id || c.id;
227
227
  if (!memberId) continue;
228
228
  const role = String((typeof c === "object" && (c.role || c.kind)) || "CONTRIBUTOR").toUpperCase();
229
- out.push({ memberId: String(memberId), role });
229
+ // `source` is hq's own provenance label for the edge and it is NOT
230
+ // decoration: `src/server/methods/mandate/_shared.ts#projectObjective`
231
+ // stamps "objective.adoptedById" on the APPROVER it synthesises from the
232
+ // adopter, and "workforceMember.supervisorId" on the seat-level REVIEWER.
233
+ // Dropping it here erased the only way a consumer could tell a DERIVED
234
+ // governance edge from a deliberately-named per-task reviewer — which is
235
+ // exactly what made `classifyWorkItem` rule 4 fire on every single
236
+ // candidate (see the note there).
237
+ const source = typeof c === "object" && c.source ? String(c.source) : null;
238
+ out.push({ memberId: String(memberId), role, source });
230
239
  }
231
240
  return out;
232
241
  }
@@ -133,12 +133,31 @@ test("buildTree nests children and surfaces orphans as roots rather than droppin
133
133
 
134
134
  test("collaboratorsOf normalises strings, objects and casing", () => {
135
135
  assert.deepEqual(collaboratorsOf({ collaborators: ["m1", { memberId: "m2", role: "reviewer" }, { id: "m3" }, null] }), [
136
- { memberId: "m1", role: "CONTRIBUTOR" },
137
- { memberId: "m2", role: "REVIEWER" },
138
- { memberId: "m3", role: "CONTRIBUTOR" },
136
+ { memberId: "m1", role: "CONTRIBUTOR", source: null },
137
+ { memberId: "m2", role: "REVIEWER", source: null },
138
+ { memberId: "m3", role: "CONTRIBUTOR", source: null },
139
139
  ]);
140
140
  });
141
141
 
142
+ test("collaboratorsOf PRESERVES hq's provenance `source` on the edge", () => {
143
+ // hq's projectObjective stamps this, and goals/classify.mjs is the consumer:
144
+ // an APPROVER derived from `objective.adoptedById` is a standing governance
145
+ // edge, not per-task sign-off. Dropping `source` made every self-directed
146
+ // candidate classify NEEDS_REVIEW.
147
+ assert.deepEqual(
148
+ collaboratorsOf({
149
+ collaborators: [
150
+ { memberId: "boss", role: "APPROVER", source: "objective.adoptedById" },
151
+ { memberId: "peer", role: "REVIEWER" },
152
+ ],
153
+ }),
154
+ [
155
+ { memberId: "boss", role: "APPROVER", source: "objective.adoptedById" },
156
+ { memberId: "peer", role: "REVIEWER", source: null },
157
+ ]
158
+ );
159
+ });
160
+
142
161
  test("badKeys are excluded from adoptedObjectives so a broken node cannot drive work", () => {
143
162
  const body = tree();
144
163
  assert.deepEqual(adoptedObjectives(body, { ownerMemberId: ME, badKeys: ["pipeline"] }), []);
@@ -28,14 +28,28 @@
28
28
  "use strict";
29
29
 
30
30
  import { readCache, writeCache, stalenessTier, checksumOf } from "./cache.mjs";
31
+ import { assertMandateContract } from "./contract.mjs";
31
32
  import { deriveMandate, toMandateBody } from "./derive.mjs";
32
33
  import { validateMandateBody } from "./model.mjs";
33
34
 
34
35
  /** The protocol method that returns a seat's adopted snapshot. */
35
36
  export const GET_METHOD = "mandate.get";
36
37
 
38
+ /**
39
+ * The degradation logger. Defaults to the CONSOLE, not to a no-op.
40
+ *
41
+ * `() => {}` was the default here, which meant any caller that forgot `deps.log`
42
+ * — and `refreshMandate` is called from the daemon, from setup, and from tests —
43
+ * silently discarded every contract violation and every fallback notice. That is
44
+ * the same failure class as an empty `catch {}`: the code is correct, reports
45
+ * honestly, and nobody ever hears it. Fail-open is fine; silent is not.
46
+ */
37
47
  function logOf(deps) {
38
- return deps && typeof deps.log === "function" ? deps.log : () => {};
48
+ if (deps && typeof deps.log === "function") return deps.log;
49
+ return (level, msg) => {
50
+ try { (level === "error" ? console.error : console.warn)(msg); }
51
+ catch { /* logging may never throw into the refresh path */ }
52
+ };
39
53
  }
40
54
 
41
55
  /**
@@ -61,7 +75,27 @@ export async function fetchSnapshot(deps = {}) {
61
75
  }
62
76
  if (frame && frame.ok) {
63
77
  const r = frame.result || {};
64
- return { ok: true, snapshot: { version: Number(r.version) || 0, checksum: r.checksum || null, body: r.body || r }, reason: "fetched" };
78
+ // `mandate.get` answers "this seat has NOTHING adopted" with an ok frame
79
+ // carrying `body: null, version: 0` — deliberately, so a brand-new seat's
80
+ // first beat is not a 404 (hq src/server/methods/mandate/get.ts).
81
+ //
82
+ // `body: r.body || r` read that as a hit and cached the RPC ENVELOPE
83
+ // ({memberId, version, checksum, body:null, publishedAt, publishedById}) as
84
+ // the mandate body, with `source:"server"`. Two consequences, both silent
85
+ // until you look: the body carries zero objectives, and `refreshMandate`
86
+ // then refuses forever to let the local derivation supersede it ("adopted
87
+ // beats proposed") — so the cache is pinned to an empty mandate that no
88
+ // amount of re-derivation can dislodge. Verified live against
89
+ // os.cohortapp.com for member cmqh0tcml004ihhl6zev6ocb5.
90
+ //
91
+ // An unadopted seat is a REPORTABLE ABSENCE, not a fetch. The caller falls
92
+ // through to the local derivation, which is exactly the pre-adoption
93
+ // posture: every objective `state:'proposed'`, no obligations, no backlog.
94
+ const body = r.body;
95
+ if (!body || typeof body !== "object" || Array.isArray(body)) {
96
+ return { ok: false, snapshot: null, reason: "no-adopted-mandate" };
97
+ }
98
+ return { ok: true, snapshot: { version: Number(r.version) || 0, checksum: r.checksum || null, body }, reason: "fetched" };
65
99
  }
66
100
  const code = (frame && frame.error && frame.error.code) || "unknown";
67
101
  const msg = (frame && frame.error && frame.error.message) || "";
@@ -109,13 +143,25 @@ export async function refreshMandate(agentRoot, deps = {}) {
109
143
  { version: got.snapshot.version, checksum: local, body, source: "server" },
110
144
  { now: deps.now ? () => new Date(deps.now()).toISOString() : undefined }
111
145
  );
146
+ // TWO checks, and they answer different questions. `validateMandateBody`
147
+ // asks "is the objective TREE sound?"; `assertMandateContract` asks "does
148
+ // this body carry the keys the compiler reads at all?" — the second is the
149
+ // one that was missing, which is how a body with three absent keys sailed
150
+ // through as valid and the plan then ran on invented money.
151
+ const contract = assertMandateContract(record, log, "mandate.get");
112
152
  const validation = validateMandateBody(record, { ownerMemberId: deps.memberId });
113
153
  for (const e of validation.errors) log("warn", `[mandate] adopted snapshot is invalid: ${e}`);
114
154
  return {
115
155
  ok: true,
116
156
  source: "server",
117
- degraded: false,
118
- reason: "fetched the adopted snapshot",
157
+ // A body whose contract is broken is still cached (adopted objectives
158
+ // beat nothing), but the caller is told it is degraded rather than being
159
+ // handed a clean-looking record it cannot budget from.
160
+ degraded: !contract.ok,
161
+ contract,
162
+ reason: contract.ok
163
+ ? "fetched the adopted snapshot"
164
+ : `fetched the adopted snapshot, but it violates mandate contract v1 (missing: ${contract.missing.join(", ") || "n/a"})`,
119
165
  changed: record.checksum !== prevChecksum,
120
166
  tier: stalenessTier(record, deps.now ? deps.now() : Date.now()),
121
167
  record,
@@ -126,7 +172,9 @@ export async function refreshMandate(agentRoot, deps = {}) {
126
172
  "warn",
127
173
  got.reason === "protocol-missing-mandate-family"
128
174
  ? "[mandate] the vendored protocol carries no `mandate` family — falling back to the local derivation. Re-vendor lib/org/protocol.mjs (by hand: sync-protocol is unsafe while maestro main lacks the crm family) to pick up adopted objectives."
129
- : `[mandate] could not fetch the adopted snapshot (${got.reason}) — falling back to the local derivation`
175
+ : got.reason === "no-adopted-mandate"
176
+ ? "[mandate] hq answered mandate.get with an EMPTY snapshot (version 0, body null): this seat has nothing adopted. Falling back to the local derivation — every objective stays `proposed`, which compiles no obligations and admits no backlog. The goal-steward loop will measure nothing and create nothing until a human adopts objectives for this seat in hq."
177
+ : `[mandate] could not fetch the adopted snapshot (${got.reason}) — falling back to the local derivation`
130
178
  );
131
179
  }
132
180
 
@@ -32,18 +32,39 @@ function cleanup(root) {
32
32
  try { rmSync(root, { recursive: true, force: true }); } catch { /* best effort */ }
33
33
  }
34
34
 
35
+ /**
36
+ * A body in the shape hq ACTUALLY publishes — contract v1, every key the
37
+ * compiler reads present, budgets sourced and apportioned. This fixture used to
38
+ * carry only `{memberId, objectives}`, which is precisely the pre-contract shape
39
+ * that let `budgetCentsPerPeriod` be `undefined` all the way into the compiler.
40
+ * Keep it in sync with hq's `_contract.ts#MANDATE_BODY_KEYS`.
41
+ */
35
42
  const BODY = {
43
+ contractVersion: 1,
36
44
  memberId: "mem_self",
37
45
  objectives: [
38
- { key: "revenue", kind: "PILLAR", state: "active", charterSectionId: "cs_1", adoptedById: "mem_boss" },
46
+ { key: "revenue", kind: "PILLAR", state: "active", charterSectionId: "cs_1", adoptedById: "mem_boss", budgetCentsPerPeriod: null },
39
47
  {
40
48
  key: "pipeline", kind: "OBJECTIVE", parentKey: "revenue", state: "active",
41
49
  metric: "pipeline_coverage_x", target: 3, direction: "up", cadence: "weekly",
42
50
  sensor: { capability: "crm_list_deals", source: "method" }, adoptedById: "mem_boss",
51
+ weight: 1, budgetCentsPerPeriod: 25_000,
43
52
  },
44
53
  ],
54
+ proposed: [],
55
+ budgetCentsPerPeriod: null,
56
+ seatBudgetCents: 25_000,
57
+ budgetPeriod: "monthly",
58
+ budgetSource: "employee.payBasis.meteredBudget",
59
+ collaborators: [{ memberId: "mem_boss", role: "REVIEWER", source: "workforceMember.supervisorId" }],
60
+ reactsTo: [],
61
+ degradations: [],
62
+ generatedAt: "2026-08-11T00:00:00.000Z",
45
63
  };
46
64
 
65
+ /** The shape hq sent BEFORE the contract. Kept to prove it is now caught. */
66
+ const LEGACY_BODY = { memberId: "mem_self", objectives: BODY.objectives, proposed: [], generatedAt: BODY.generatedAt };
67
+
47
68
  const DERIVATION = {
48
69
  charterSections: [{ id: "cs_1", kind: "PILLAR", order: 1, title: "Revenue growth", body: "Own revenue." }],
49
70
  kpiCategories: [{ id: "pipeline", name: "Pipeline coverage", examples: ["pipeline_coverage_x"] }],
@@ -172,6 +193,34 @@ test("the online path caches the adopted snapshot and reports it changed", async
172
193
  } finally { cleanup(root); }
173
194
  });
174
195
 
196
+ test("a PRE-CONTRACT snapshot is cached but reported DEGRADED and LOUD", async () => {
197
+ // The original break: hq sent this exact shape, `refreshMandate` reported
198
+ // `degraded:false, reason:"fetched the adopted snapshot"`, and the compiler
199
+ // then invented 500c for every obligation. Adopted objectives still beat
200
+ // nothing, so it is still cached — but it may never look clean again.
201
+ const root = makeRoot();
202
+ const logs = [];
203
+ try {
204
+ const r = await refreshMandate(root, {
205
+ memberId: "mem_self",
206
+ now: () => NOW,
207
+ log: (level, msg) => logs.push(`${level}: ${msg}`),
208
+ callImpl: async () => ({ ok: true, result: { version: 4, checksum: checksumOf(LEGACY_BODY), body: LEGACY_BODY } }),
209
+ });
210
+ assert.equal(r.ok, true);
211
+ assert.equal(r.source, "server");
212
+ assert.equal(r.degraded, true, "a body the compiler cannot budget from is degraded");
213
+ assert.equal(r.contract.ok, false);
214
+ for (const k of ["budgetCentsPerPeriod", "seatBudgetCents", "collaborators", "reactsTo", "contractVersion"]) {
215
+ assert.ok(r.contract.missing.includes(k), `contract.missing names ${k}`);
216
+ }
217
+ assert.match(r.reason, /violates mandate contract v1/);
218
+ // Still cached — the adopted tree is worth more than nothing.
219
+ assert.equal(readCache(root).source, "server");
220
+ assert.ok(logs.some((l) => l.startsWith("error:")), "the violation is logged, not swallowed");
221
+ } finally { cleanup(root); }
222
+ });
223
+
175
224
  test("SERVER UNREACHABLE, no prior cache: falls back to the local derivation, degraded and LOUD", async () => {
176
225
  const root = makeRoot();
177
226
  try {
@@ -291,3 +340,36 @@ test("a corrupt cache does not block a fresh server refresh", async () => {
291
340
  assert.equal(JSON.parse(readFileSync(cachePath(root), "utf-8")).version, 4);
292
341
  } finally { cleanup(root); }
293
342
  });
343
+
344
+ test("REGRESSION — hq's 'nothing adopted' answer must NOT be cached as a server snapshot", async () => {
345
+ // mandate.get answers an unadopted seat with {version:0, checksum:null,
346
+ // body:null} BY DESIGN (hq src/server/methods/mandate/get.ts). `body: r.body || r`
347
+ // read that as a hit and cached the RPC ENVELOPE as the mandate body with
348
+ // source:"server" — after which "a local derivation never supersedes a server
349
+ // snapshot" pinned the seat to an empty mandate permanently. Verified live
350
+ // against os.cohortapp.com.
351
+ const root = makeRoot();
352
+ try {
353
+ const r = await refreshMandate(root, {
354
+ memberId: "mem_self",
355
+ now: () => NOW,
356
+ log: () => {},
357
+ callImpl: async () => ({
358
+ ok: true,
359
+ result: { memberId: "mem_self", version: 0, checksum: null, body: null, publishedAt: null, publishedById: null },
360
+ }),
361
+ });
362
+ assert.notEqual(r.source, "server", "an empty snapshot is an absence, not a fetch");
363
+ assert.equal(r.source, "local");
364
+ const cached = JSON.parse(readFileSync(cachePath(root), "utf-8"));
365
+ assert.equal(cached.source, "local");
366
+ assert.ok(Array.isArray(cached.body.objectives), "the cached body is a real mandate body, not the RPC envelope");
367
+ assert.equal(cached.body.publishedAt, undefined, "the envelope's own keys never leaked into the body");
368
+ } finally { cleanup(root); }
369
+ });
370
+
371
+ test("fetchSnapshot names the empty-snapshot case distinctly", async () => {
372
+ const got = await fetchSnapshot({ callImpl: async () => ({ ok: true, result: { version: 0, body: null } }) });
373
+ assert.equal(got.ok, false);
374
+ assert.equal(got.reason, "no-adopted-mandate");
375
+ });
@@ -80,6 +80,65 @@ function identityHints(agentRoot, env) {
80
80
  return hints;
81
81
  }
82
82
 
83
+ /**
84
+ * LOCAL probe: is the reactive lane in this agent repo actually wired?
85
+ *
86
+ * An agent repo carries its OWN copy of the framework (`lib/**` and
87
+ * `scripts/daemon/**`), copied in at enrolment and refreshed on upgrade. Those
88
+ * two trees can drift apart, and when they do the failure is completely silent:
89
+ *
90
+ * `scripts/daemon/maestro-daemon.mjs` resolves `./agent-daemon.mjs` from the
91
+ * AGENT's own directory. If that copy predates the execution ladder, then
92
+ * `processItem` never calls `runExecutionLadder`, and `lib/execution/**` —
93
+ * which the same repo may well be carrying, current — is dead code. Every
94
+ * inbound event still gets classified and answered, so the agent LOOKS
95
+ * healthy; what is missing is the entire decision spine: no directedness
96
+ * journal, no obligation binding, no rung, no drift record, nothing written
97
+ * to `state/execution/journal.jsonl`. Observed live on a real seat
98
+ * (2026-08-11): the wide inbound was current to within one ledger row, and
99
+ * the execution journal had not a single row from the daemon itself.
100
+ *
101
+ * Nothing else can catch this. The new build cannot warn about the old one, and
102
+ * the old one does not know the ladder exists — so the check has to be made
103
+ * from `lib/`, which is the half that gets refreshed.
104
+ *
105
+ * Pure + injectable; never throws.
106
+ *
107
+ * @param {string} agentRoot
108
+ * @param {object} [deps] {existsSync, readFileSync} for tests
109
+ * @returns {{level:"ok"|"warn"|"fail", msg:string}|null} null when not applicable
110
+ */
111
+ export function checkReactiveLaneWiring(agentRoot, deps = {}) {
112
+ const _exists = deps.existsSync || existsSync;
113
+ const _read = deps.readFileSync || readFileSync;
114
+ const daemonPath = join(agentRoot, "scripts", "daemon", "agent-daemon.mjs");
115
+ const pipelinePath = join(agentRoot, "lib", "execution", "pipeline.mjs");
116
+ try {
117
+ // Not an agent repo layout (running from the framework checkout, or a
118
+ // partial install) — nothing to say.
119
+ if (!_exists(daemonPath) || !_exists(pipelinePath)) return null;
120
+ const src = String(_read(daemonPath, "utf8"));
121
+ if (src.includes("runExecutionLadder")) {
122
+ return { level: "ok", msg: "Reactive lane: the daemon runs inbound events through the execution ladder" };
123
+ }
124
+ return {
125
+ level: "fail",
126
+ msg:
127
+ "Reactive lane: scripts/daemon/agent-daemon.mjs in THIS repo never calls runExecutionLadder, but " +
128
+ "lib/execution/ is present and current — the ladder is dead code here. Inbound events are still " +
129
+ "classified and answered, but intake/obligation-match/rung/journal DO NOT RUN and " +
130
+ "state/execution/journal.jsonl will stay empty. The daemon copy is stale: re-run the framework " +
131
+ "upgrade so scripts/daemon/ is refreshed alongside lib/.",
132
+ };
133
+ } catch (err) {
134
+ // Fail-open, never silent — a doctor probe must not take doctor down.
135
+ return {
136
+ level: "warn",
137
+ msg: `Reactive lane: could not read ${daemonPath} to check ladder wiring (${err && err.message ? err.message : String(err)})`,
138
+ };
139
+ }
140
+ }
141
+
83
142
  /**
84
143
  * Run the Cohort connectivity probes.
85
144
  * @param {object} o
@@ -87,6 +146,7 @@ function identityHints(agentRoot, env) {
87
146
  * @param {Function} [o.fetchImpl] injectable fetch (tests)
88
147
  * @param {object} [o.env] injectable env (tests; default process.env)
89
148
  * @param {() => number} [o.now] injectable clock for latency (tests)
149
+ * @param {object} [o.fs] injectable {existsSync, readFileSync} (tests)
90
150
  * @returns {Promise<Array<{level:"ok"|"warn"|"fail", msg:string}>>}
91
151
  */
92
152
  export async function checkOrgConnectivity(o = {}) {
@@ -95,6 +155,12 @@ export async function checkOrgConnectivity(o = {}) {
95
155
  const now = typeof o.now === "function" ? o.now : Date.now;
96
156
  const results = [];
97
157
 
158
+ // ── 0. LOCAL: is the decision spine wired in this repo? ──────────────────
159
+ // Runs before the network probes and before the not-enrolled early return:
160
+ // a stale daemon is worth reporting even on an agent that never enrolled.
161
+ const wiring = checkReactiveLaneWiring(agentRoot, o.fs || {});
162
+ if (wiring) results.push(wiring);
163
+
98
164
  // ── 1. config resolution ─────────────────────────────────────────────────
99
165
  const cfg = configFromAgent(loadOrgConfig(agentRoot));
100
166
  const base = cfg.base || (env.COHORT_BASE ? String(env.COHORT_BASE).replace(/\/+$/, "") : "");