@cohortapp/agent-sdk 2.4.1 → 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 (79) 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/sections/mandate.mjs +43 -1
  57. package/lib/subagents/schema.mjs +14 -2
  58. package/lib/subagents/schema.test.mjs +22 -0
  59. package/package.json +1 -1
  60. package/scripts/ci/check-subagent-frontmatter.mjs +139 -0
  61. package/scripts/ci/check-subagent-frontmatter.test.mjs +124 -0
  62. package/scripts/ci/check.mjs +3 -0
  63. package/scripts/ci/conformance-org-api.mjs +16 -0
  64. package/scripts/ci/journey-approval-escalation.mjs +341 -0
  65. package/scripts/daemon/agent-daemon.mjs +582 -28
  66. package/scripts/daemon/cadence-handlers.mjs +273 -17
  67. package/scripts/daemon/cadence-handlers.test.mjs +101 -0
  68. package/scripts/daemon/execution-ladder.test.mjs +430 -0
  69. package/scripts/daemon/goal-steward-cadence.test.mjs +69 -0
  70. package/scripts/daemon/maestro-daemon.mjs +53 -0
  71. package/scripts/daemon/prompt-builder.mjs +47 -0
  72. package/scripts/daemon/responder.mjs +70 -3
  73. package/scripts/poller/imap-client.mjs +20 -1
  74. package/scripts/poller/inbox-scan-poller.mjs +15 -0
  75. package/scripts/poller/utils.mjs +51 -0
  76. package/scripts/setup/generate-capability.mjs +120 -11
  77. package/scripts/setup/generate-capability.test.mjs +134 -0
  78. package/scripts/setup/generate-plan.mjs +6 -1
  79. package/scripts/setup/repair-subagent-frontmatter.mjs +231 -0
@@ -626,12 +626,37 @@ function writeMessagingCursor(agentRoot, cursor) {
626
626
  }
627
627
 
628
628
  /**
629
- * messaging-inbound guard (guarded): poll the Cohort messaging/calling event
630
- * stream for new messages / @mentions / call-invites DIRECTED AT this
631
- * agent (lib/org/messaging.pullInbound), and ROUTE each one into the existing
632
- * daemon inbox pipeline by writing a legacy inbox item under
633
- * state/inbox/cohort/ — so the agent reacts to a Cohort @mention exactly like
634
- * a Slack/Telegram one (classifier → directed-gate → responder → dispatcher).
629
+ * messaging-inbound guard (guarded): poll the Cohort org event ledger for
630
+ * everything DIRECTED AT this agent and ROUTE each one into the existing daemon
631
+ * inbox pipeline by writing a legacy inbox item under state/inbox/cohort/ — so
632
+ * the agent reacts to a Cohort @mention exactly like a Slack/Telegram one
633
+ * (classifier → directed-gate → responder → dispatcher).
634
+ *
635
+ * ── WIDE, NOT messaging+calling (2026-08) ──
636
+ * This used to call `lib/org/messaging.pullInbound`, which pins
637
+ * `family=messaging,calling` on its events URL and recognises exactly two things
638
+ * (a message in a private room I am in, a call in one of my rooms). Every other
639
+ * surface hq emits — board/task, files & doc comments, decisions, escalations,
640
+ * approvals, handoffs, calendar, email — was read off the wire and thrown away,
641
+ * so a task assigned to this agent reached NOBODY: hq's in-process responder
642
+ * stands down for any member whose daemon beat inside 100s (hq 4d762d30) and
643
+ * the daemon was structurally deaf to the family.
644
+ *
645
+ * The live path is now `lib/org/inbound.pullWideInbound`, which returns the SAME
646
+ * `{events, nextCursor}` MessageEvent contract (plus `stats`), so everything
647
+ * below — eventToInboxItem, writeInboxItem, the cursor discipline — is unchanged.
648
+ *
649
+ * ADDRESSING STAYS ACL-SAFE. The org `/v1/events` feed is org-wide and REDACTED
650
+ * by design: a frame carries `mentionCount`, never the mentioned ids. The wide
651
+ * reader therefore never widens a payload; it resolves "is this for me" by
652
+ * JOINING the redacted frame against the agent's OWN entitled reads
653
+ * (`messaging.channels`, `messaging.history.mentions`, `board.context`,
654
+ * `approval.get`, `files.get`, `calendar.list`). A room this seat is not in is
655
+ * never paged, never hydrated, never delivered.
656
+ *
657
+ * Set MESSAGING_INBOUND_WIDE=0 to fall back to the old two-family reader (an
658
+ * operator escape hatch for an hq that is misbehaving on a family) — the
659
+ * fallback is LOGGED, never silent.
635
660
  *
636
661
  * The reactive daemon OWNS inbox dispatch, so the default decision is `inline`
637
662
  * (we route the items but don't escalate — escalating would double-respond, the
@@ -655,7 +680,20 @@ async function guardMessagingInbound({ event, agentRoot, log }, opts = {}) {
655
680
  return { ok: true, decision: "inline", cadence: event.cadence, reason: "org messaging not configured", routed: 0 };
656
681
  }
657
682
 
658
- const pull = opts.pullImpl || (await import("../../lib/org/messaging.mjs")).pullInbound;
683
+ // WIDE by default. The narrow reader is kept reachable as an operator
684
+ // escape hatch, but taking it is a DEGRADATION and is logged as one.
685
+ const wide = process.env.MESSAGING_INBOUND_WIDE !== "0";
686
+ let pull = opts.pullImpl;
687
+ if (!pull) {
688
+ if (wide) {
689
+ pull = (await import("../../lib/org/inbound/index.mjs")).pullWideInbound;
690
+ } else {
691
+ pull = (await import("../../lib/org/messaging.mjs")).pullInbound;
692
+ if (typeof log === "function") {
693
+ log("warn", "[messaging-inbound] MESSAGING_INBOUND_WIDE=0 — using the narrow messaging+calling reader; board, task, calendar, escalation, approval, file and decision events WILL NOT arrive");
694
+ }
695
+ }
696
+ }
659
697
  const eventToInboxItem =
660
698
  opts.toInboxImpl || (await import("../../lib/channels/inbox-item.mjs")).eventToInboxItem;
661
699
  const writeInboxItem = opts.writeImpl || (await import("../poller/utils.mjs")).writeInboxItem;
@@ -671,28 +709,70 @@ async function guardMessagingInbound({ event, agentRoot, log }, opts = {}) {
671
709
  process.env.COHORT_AGENT_ID ||
672
710
  (cfg && cfg.org && cfg.org.cohort && cfg.org.cohort.agentId) ||
673
711
  undefined;
674
- const res = await pull({ cfg, agentId, cursor, fetchImpl: opts.fetchImpl });
712
+ // Display names for prose-mention matching ("Isla, can you…" with no @).
713
+ // The wide reader only ever uses these to match text it was ALREADY entitled
714
+ // to read, so this cannot widen the aperture.
715
+ let myNames = opts.myNames;
716
+ if (myNames == null) {
717
+ try {
718
+ const { readIdentity } = await import("../../lib/org/registry.mjs");
719
+ const id = readIdentity(agentRoot) || {};
720
+ myNames = [id.firstName, id.fullName, id.preferredName].filter(
721
+ (n) => typeof n === "string" && n.trim(),
722
+ );
723
+ } catch { myNames = []; }
724
+ }
725
+
726
+ const res = await pull({
727
+ cfg,
728
+ agentId,
729
+ cursor,
730
+ fetchImpl: opts.fetchImpl,
731
+ myNames,
732
+ // The wide reader logs every degradation through this; without it a failed
733
+ // roster read, a capped page or a dropped hydration is invisible.
734
+ log: typeof log === "function" ? log : undefined,
735
+ });
675
736
  const events = (res && Array.isArray(res.events)) ? res.events : [];
676
737
 
677
738
  let routed = 0;
739
+ const failed = [];
678
740
  for (const ev of events) {
679
741
  try {
680
742
  const item = eventToInboxItem(ev);
681
743
  writeInboxItem("cohort", item, agentRoot);
682
744
  routed += 1;
683
- } catch { /* one bad event must not drop the rest */ }
745
+ } catch (err) {
746
+ // One bad event must not drop the rest — but it must not vanish either.
747
+ // A silent catch here is exactly how six directed DMs went missing.
748
+ failed.push(`${(ev && ev.kind) || "?"}:${(ev && ev.message_id) || "?"} (${err && err.message ? err.message : String(err)})`);
749
+ }
750
+ }
751
+ if (failed.length && typeof log === "function") {
752
+ log("warn", `[messaging-inbound] ${failed.length} directed item(s) could NOT be written to the inbox: ${failed.slice(0, 5).join("; ")}`);
684
753
  }
685
754
 
686
755
  // Advance the cursor AFTER writing (at-least-once; writeInboxItem dedupes).
687
756
  const next = res && res.nextCursor != null ? res.nextCursor : cursor;
688
757
  if (next !== cursor) writeMessagingCursor(agentRoot, next);
689
758
 
690
- if (routed && typeof log === "function") {
691
- log("info", `[messaging-inbound] routed ${routed} Cohort item(s) into the inbox pipeline`);
759
+ // The wide reader hands back what it spent and what it dropped. Surfacing it
760
+ // is the difference between "the agent is quiet" and "the agent is deaf".
761
+ const stats = res && res.stats && typeof res.stats === "object" ? res.stats : null;
762
+ if (typeof log === "function") {
763
+ if (routed) {
764
+ const bySurface = stats && stats.bySurface
765
+ ? Object.entries(stats.bySurface).map(([k, v]) => `${k}×${v}`).join(", ")
766
+ : "";
767
+ log("info", `[messaging-inbound] routed ${routed} Cohort item(s) into the inbox pipeline${bySurface ? ` (${bySurface})` : ""}`);
768
+ }
769
+ if (stats && Array.isArray(stats.degraded) && stats.degraded.length) {
770
+ log("warn", `[messaging-inbound] DEGRADED facts this tick: ${stats.degraded.join(", ")} — directedness for those surfaces failed CLOSED (items may have been dropped)`);
771
+ }
692
772
  }
693
773
 
694
774
  if (process.env.MESSAGING_INBOUND_ESCALATE === "1" && routed > 0) {
695
- return { ok: true, decision: "escalate", cadence: event.cadence, reason: `${routed} directed item(s)`, routed };
775
+ return { ok: true, decision: "escalate", cadence: event.cadence, reason: `${routed} directed item(s)`, routed, stats };
696
776
  }
697
777
  return {
698
778
  ok: true,
@@ -700,10 +780,19 @@ async function guardMessagingInbound({ event, agentRoot, log }, opts = {}) {
700
780
  cadence: event.cadence,
701
781
  reason: routed ? "routed to inbox (reactive-daemon-owns-dispatch)" : "no new directed items",
702
782
  routed,
783
+ wide,
784
+ stats,
703
785
  note: routed ? "Set MESSAGING_INBOUND_ESCALATE=1 to escalate instead of routing to the inbox." : undefined,
704
786
  };
705
787
  } catch (err) {
706
- return { ok: true, decision: "inline", cadence: event.cadence, reason: "messaging-inbound-error", routed: 0, error: err && err.message ? err.message : String(err) };
788
+ // FAIL-OPEN IS FINE; SILENT IS NOT. A throw here means the agent received
789
+ // NOTHING this tick — the guard returning `ok:true` used to make that read
790
+ // as a healthy quiet org in every log and dashboard.
791
+ const message = err && err.message ? err.message : String(err);
792
+ if (typeof log === "function") {
793
+ log("error", `[messaging-inbound] pull FAILED (${message}) — 0 items routed this tick; nothing arrived from ANY surface`);
794
+ }
795
+ return { ok: true, decision: "inline", cadence: event.cadence, reason: "messaging-inbound-error", routed: 0, error: message };
707
796
  }
708
797
  }
709
798
 
@@ -943,17 +1032,69 @@ async function guardBrandSteward({ event, agentRoot, log }, opts = {}) {
943
1032
  * @param {object} ctx - { event, agentRoot, log }
944
1033
  * @param {object} [opts] - injectable seams: { runImpl, refreshImpl, cfg, enforcement, now, fetchImpl }
945
1034
  */
1035
+ /** Keep ONLY the org credential. See the note at step 0 — a resolver's extra
1036
+ * keys (notably `agentRoot`) must never reach a deps bag by accident. */
1037
+ function credsOnly(c) {
1038
+ return c ? { base: c.base || "", token: c.token || "", orgId: c.orgId || "" } : c;
1039
+ }
1040
+
946
1041
  async function guardGoalSteward({ event, agentRoot, log }, opts = {}) {
947
1042
  const cadence = event && event.cadence ? event.cadence : "goal-steward";
948
1043
  const say = typeof log === "function" ? log : () => {};
949
1044
  try {
1045
+ // 0. THE ORG CREDENTIAL. Everything below that talks to hq needs it, and
1046
+ // nothing here used to resolve it.
1047
+ //
1048
+ // `refreshMandate`'s default callImpl is `lib/org/client.call(method,
1049
+ // params, {base, token, orgId})` — and `client.mjs` does NOT fall back to
1050
+ // the environment for `base`. Calling it with `{fetchImpl, log, now}`
1051
+ // (which is what this guard did) produced
1052
+ // `{ok:false, error:{code:"BAD_REQUEST", message:"missing base"}}` WITHOUT
1053
+ // a network round-trip, on every tick, forever. `fetchSnapshot` mapped
1054
+ // that to `call-failed:BAD_REQUEST` and `refreshMandate` fell back to the
1055
+ // local derivation — where every objective is `state:'proposed'`, which
1056
+ // compiles no obligations and admits no backlog.
1057
+ //
1058
+ // Net effect, verified live: the goal-steward cadence had NEVER once read
1059
+ // an adopted mandate out of hq. It could not create a single item, and the
1060
+ // only symptom was one warn line that read like an outage.
1061
+ // NARROWED to the three credential fields ON PURPOSE. `resolveOrgToolConfig`
1062
+ // also returns `agentRoot` (its own resolution, which is NOT necessarily this
1063
+ // cadence's root), and spreading the whole object into `runGoalSteward`
1064
+ // silently re-pointed the loop at a different agent directory — it measured
1065
+ // and would have written under the wrong root. Caught in the live run; never
1066
+ // spread a resolver's output into a deps bag wholesale.
1067
+ let conn = opts.conn === undefined ? undefined : credsOnly(opts.conn);
1068
+ if (conn === undefined) {
1069
+ try {
1070
+ const { resolveOrgToolConfig } = await import("../../lib/org/tool-surface.mjs");
1071
+ conn = credsOnly(resolveOrgToolConfig({ agentRoot, orgConfig: opts.cfg }));
1072
+ } catch (err) {
1073
+ conn = null;
1074
+ say("error", `[goal-steward] could not resolve the org connection (${err && err.message ? err.message : err}) — the mandate cannot be refreshed and no sample can be mirrored to hq this tick`);
1075
+ }
1076
+ }
1077
+ if (conn && (!conn.base || !conn.token)) {
1078
+ say(
1079
+ "warn",
1080
+ `[goal-steward] no org credential for this seat (base=${conn.base ? "set" : "MISSING"} token=${conn.token ? "set" : "MISSING"}) — mandate.get and mandate.sample will both fail; the loop runs entirely on local state`,
1081
+ );
1082
+ }
1083
+ const memberId = opts.memberId || (conn && conn.memberId) || process.env.COHORT_AGENT_ID || null;
1084
+
950
1085
  // 1. Refresh the mandate cache first — a goal loop running off a week-old
951
1086
  // mandate is worse than one that does not run. Fail-open: the loop's own
952
1087
  // staleness ladder is what actually decides what it may do.
953
1088
  if (opts.refreshImpl !== null) {
954
1089
  try {
955
1090
  const refresh = opts.refreshImpl || (await import("../../lib/mandate/refresh.mjs")).refreshMandate;
956
- const r = await refresh(agentRoot, { fetchImpl: opts.fetchImpl, log: say, now: opts.now });
1091
+ const r = await refresh(agentRoot, {
1092
+ ...(conn || {}),
1093
+ memberId,
1094
+ fetchImpl: opts.fetchImpl,
1095
+ log: say,
1096
+ now: opts.now,
1097
+ });
957
1098
  if (r && r.ok === false) {
958
1099
  say("warn", `[goal-steward] mandate refresh failed (${r.reason}) — running on the cached mandate`);
959
1100
  }
@@ -978,8 +1119,98 @@ async function guardGoalSteward({ event, agentRoot, log }, opts = {}) {
978
1119
  }
979
1120
  }
980
1121
 
1122
+ // 2b. THE SENSOR REGISTRY. Without this the loop measures nothing: every
1123
+ // objective's sensor lookup misses, `measureKpi` returns
1124
+ // {ok:false,"no-implementation"}, no sample is recorded, every gap is
1125
+ // null, and admission's honesty gate (source ∈ method|human) is
1126
+ // unsatisfiable by construction — D3 could not create a single item.
1127
+ // `reachableCapabilities` returns undefined (not an empty Set) when the
1128
+ // manifest is missing, so a broken manifest degrades to "measure without
1129
+ // the reachability check" instead of refusing every objective.
1130
+ let sensors = opts.sensors;
1131
+ let reachable = opts.reachable;
1132
+ if (sensors === undefined || reachable === undefined) {
1133
+ try {
1134
+ const mod = await import("../../lib/kpi-sensors.mjs");
1135
+ if (sensors === undefined) {
1136
+ sensors = mod.buildSensorRegistry({ agentRoot, log: say, now: opts.now, fetchImpl: opts.fetchImpl });
1137
+ }
1138
+ if (reachable === undefined) reachable = mod.reachableCapabilities(agentRoot, { log: say });
1139
+ } catch (err) {
1140
+ say(
1141
+ "error",
1142
+ `[goal-steward] could not build the sensor registry (${err && err.message ? err.message : err}) — every objective will report no-implementation and NOTHING will be measured this tick`,
1143
+ );
1144
+ }
1145
+ }
1146
+
1147
+ // 2c. THE RETURN LEG. `lib/goals/loop.mjs` calls `deps.mirrorSample` for
1148
+ // every sample it records, and hq implements the receiving end in full
1149
+ // (`src/server/methods/mandate/sample.ts`: server-decided `source`
1150
+ // honesty gate, `(orgId, objectiveId, window)` idempotency, gap
1151
+ // computation, chain append). `mirrorSample` had ZERO callers in either
1152
+ // repo — so every number this loop measured stayed on one laptop's disk
1153
+ // and hq's Objective.value / KpiSample series were never written by an
1154
+ // agent at all. The loop learned; the org did not.
1155
+ //
1156
+ // `objectiveId` is hq's own row id, carried on the cached snapshot by
1157
+ // `projectObjective`. A locally-DERIVED objective has no id — those are
1158
+ // `state:'proposed'` and never reach here, but the guard is explicit
1159
+ // rather than sending a request hq would reject.
1160
+ //
1161
+ // Best-effort by construction: the loop already wraps this in try/catch
1162
+ // and logs, and the local measurement is written BEFORE the mirror is
1163
+ // attempted, so an unreachable hq loses nothing.
1164
+ let mirrorSample = opts.mirrorSample;
1165
+ if (mirrorSample === undefined) {
1166
+ if (!conn || !conn.base || !conn.token) {
1167
+ mirrorSample = null;
1168
+ say("warn", "[goal-steward] no org credential — measurements will be recorded LOCALLY ONLY and hq's KPI series will not move");
1169
+ } else {
1170
+ const { call } = await import("../../lib/org/client.mjs");
1171
+ mirrorSample = async ({ objective, value, window, source, evidence }) => {
1172
+ if (!objective || !objective.id) {
1173
+ say("info", `[goal-steward] "${objective && objective.key}" has no hq objective id (locally-derived) — sample kept local`);
1174
+ return;
1175
+ }
1176
+ const frame = await call(
1177
+ "mandate.sample",
1178
+ { objectiveId: objective.id, value, window, evidence: evidence || null },
1179
+ { ...conn, fetchImpl: opts.fetchImpl },
1180
+ );
1181
+ if (!frame || !frame.ok) {
1182
+ say(
1183
+ "warn",
1184
+ `[goal-steward] mandate.sample rejected "${objective.key}" @ ${window}: ${(frame && frame.error && frame.error.code) || "unknown"}${frame && frame.error && frame.error.message ? ` — ${frame.error.message}` : ""} — the local series is intact`,
1185
+ );
1186
+ return;
1187
+ }
1188
+ const got = frame.result || {};
1189
+ // hq decides `source` itself and says so when it downgraded us. A
1190
+ // silent downgrade is how a seat keeps reporting fabricated `method`
1191
+ // samples forever without learning its sensor is not wired up.
1192
+ if (got.sourceNote) {
1193
+ say("warn", `[goal-steward] hq downgraded the sample source for "${objective.key}" (asked ${source}, got ${got.source}): ${got.sourceNote}`);
1194
+ } else {
1195
+ say("info", `[goal-steward] mirrored "${objective.key}" = ${value} @ ${window} to hq (source ${got.source || source})`);
1196
+ }
1197
+ };
1198
+ }
1199
+ }
1200
+
981
1201
  const run = opts.runImpl || (await import("../../lib/goals/loop.mjs")).runGoalSteward;
982
- const report = await run({ agentRoot, log: say, enforcement, now: opts.now, ...(opts.runDeps || {}) });
1202
+ const report = await run({
1203
+ agentRoot,
1204
+ log: say,
1205
+ enforcement,
1206
+ now: opts.now,
1207
+ sensors,
1208
+ reachable,
1209
+ memberId,
1210
+ ...(conn || {}),
1211
+ ...(mirrorSample ? { mirrorSample } : {}),
1212
+ ...(opts.runDeps || {}),
1213
+ });
983
1214
 
984
1215
  if (!report || report.ok === false) {
985
1216
  // Not an error for the bus: "no mandate" is the normal pre-adoption state.
@@ -998,6 +1229,7 @@ async function guardGoalSteward({ event, agentRoot, log }, opts = {}) {
998
1229
  const rejected = (report.rejected || []).length;
999
1230
  const escalations = (report.escalations || []).length;
1000
1231
  const drift = (report.drift || []).length;
1232
+ const scored = (report.interventions || []).reduce((n, i) => n + (i.recorded || 0), 0);
1001
1233
 
1002
1234
  if (report.skipped) {
1003
1235
  return { ok: true, decision: "inline", cadence, reason: report.skipped, measured, admitted, drift };
@@ -1005,7 +1237,7 @@ async function guardGoalSteward({ event, agentRoot, log }, opts = {}) {
1005
1237
 
1006
1238
  say(
1007
1239
  "info",
1008
- `[goal-steward] ${enforcement}: measured ${measured}, admitted ${admitted}, rejected ${rejected}, escalations ${escalations}, drift ${drift}`
1240
+ `[goal-steward] ${enforcement}: measured ${measured}, interventions scored ${scored}, admitted ${admitted}, rejected ${rejected}, escalations ${escalations}, drift ${drift}`
1009
1241
  );
1010
1242
 
1011
1243
  // 3. Escalate only when a model would actually add something: an open gap
@@ -1025,7 +1257,7 @@ async function guardGoalSteward({ event, agentRoot, log }, opts = {}) {
1025
1257
  };
1026
1258
  }
1027
1259
 
1028
- return { ok: true, decision: "inline", cadence, measured, admitted, rejected, escalations, drift, enforcement };
1260
+ return { ok: true, decision: "inline", cadence, measured, admitted, rejected, escalations, drift, scored, enforcement };
1029
1261
  } catch (err) {
1030
1262
  // Fail-open but LOUD: the daemon keeps running, the failure is on the record.
1031
1263
  say("error", `[goal-steward] guard threw: ${err && err.message ? err.message : err}`);
@@ -1255,6 +1487,29 @@ function loadConfigRegistry() {
1255
1487
  * availability. The hardcoded CADENCE_REGISTRY wins; the config-driven archetype
1256
1488
  * cadences (config/.cadence-registry.json) fill in the rest.
1257
1489
  */
1490
+ /**
1491
+ * Cadences already announced as unmetered. The warning is worth saying ONCE per
1492
+ * cadence per process — `getCadenceDef` is called on every tick, and a line per
1493
+ * tick is how a real signal becomes log noise nobody reads.
1494
+ */
1495
+ const _unmeteredWarned = new Set();
1496
+
1497
+ /**
1498
+ * A plan-derived cadence arriving with no budget is the LAST hop of the mandate
1499
+ * body joint, and it used to be its quietest: `budgetCents` was simply not set,
1500
+ * the sub-session spawned with no spend cap, and nothing anywhere said so. The
1501
+ * cap genuinely cannot be invented here — but its absence is now stated.
1502
+ */
1503
+ function _warnUnmetered(name, obligationKey) {
1504
+ if (_unmeteredWarned.has(name)) return;
1505
+ _unmeteredWarned.add(name);
1506
+ console.warn(
1507
+ `[cadence] "${name}"${obligationKey ? ` (${obligationKey})` : ""} carries no budgetCents — ` +
1508
+ "hq published no funding for the objective behind it, so this cadence spawns with NO spend cap. " +
1509
+ "Fund the seat (Employee.payBasis.meteredBudget) or accept it as unmetered."
1510
+ );
1511
+ }
1512
+
1258
1513
  export function getCadenceDef(name) {
1259
1514
  if (CADENCE_REGISTRY[name]) return CADENCE_REGISTRY[name];
1260
1515
  const cfg = loadConfigRegistry()[name];
@@ -1268,6 +1523,7 @@ export function getCadenceDef(name) {
1268
1523
  if (Array.isArray(cfg.allowedTools) && cfg.allowedTools.length) def.allowedTools = cfg.allowedTools;
1269
1524
  if (cfg.guardModule) def.guardModule = cfg.guardModule;
1270
1525
  if (Number.isFinite(cfg.budgetCents)) def.budgetCents = cfg.budgetCents;
1526
+ else _warnUnmetered(name, cfg.obligationKey);
1271
1527
  if (cfg.obligationKey) def.obligationKey = cfg.obligationKey;
1272
1528
  return def;
1273
1529
  }
@@ -615,3 +615,104 @@ test("brand-steward is registered guarded and cannot reach the admin foundation
615
615
  }
616
616
  assert.ok(def.allowedTools.includes("design_propose_change"), "proposing IS the lane");
617
617
  });
618
+
619
+ // ---------------------------------------------------------------------------
620
+ // messaging-inbound is WIDE (JOINT 3) — the LIVE default, not an injected stub
621
+ // ---------------------------------------------------------------------------
622
+
623
+ /**
624
+ * The regression this pins: `guardMessagingInbound` used to default to
625
+ * `lib/org/messaging.pullInbound`, whose events URL hardcodes
626
+ * `family=messaging,calling`. Board, calendar, escalation, approval, decision,
627
+ * files and handoff events were read off the wire and discarded, so a task
628
+ * assigned to this agent reached NOBODY — hq stands down for a live daemon
629
+ * (4d762d30) and the daemon was structurally deaf to the family.
630
+ *
631
+ * No `pullImpl` here ON PURPOSE: the point is which reader the REAL default
632
+ * resolves to. Only the network is faked.
633
+ */
634
+ test("messaging-inbound (JOINT 3): the DEFAULT reader is wide — no family filter, and a board assignment arrives", async () => {
635
+ const root = tmpRoot();
636
+ const urls = [];
637
+ const written = [];
638
+ const ME = "member-me";
639
+
640
+ const json = (body) => ({
641
+ ok: true,
642
+ status: 200,
643
+ headers: { get: () => "application/json" },
644
+ text: async () => JSON.stringify(body),
645
+ json: async () => body,
646
+ });
647
+
648
+ const fetchImpl = async (url, init) => {
649
+ const u = String(url);
650
+ urls.push(`${(init && init.method) || "GET"} ${u}`);
651
+ if (u.includes("/events?")) {
652
+ return json({
653
+ events: [
654
+ {
655
+ seq: 501, family: "board", kind: "item.assigned", entity_id: "task-9",
656
+ actor: "member-boss", at: "2026-08-11T09:00:00Z",
657
+ // REDACTED BY DESIGN: no mention list, no recipient list.
658
+ payload: { actor: "member-boss", assignee: ME, assignedBy: "member-boss" },
659
+ },
660
+ ],
661
+ nextCursor: 501,
662
+ });
663
+ }
664
+ if (u.includes("board.context")) {
665
+ return json({ ok: true, result: { tasks: [{ id: "task-9", title: "Ship the transport joint", assigneeId: ME, col: "todo" }] } });
666
+ }
667
+ if (u.includes("messaging.channels")) return json({ ok: true, result: { channels: [] } });
668
+ return json({ ok: true, result: {} });
669
+ };
670
+
671
+ const res = await guardMessagingInbound(
672
+ { event: { cadence: "messaging-inbound" }, agentRoot: root, log: () => {} },
673
+ {
674
+ cfg: { org: { cohort: { enabled: true, base: "https://x.test", token: "t" } } },
675
+ agentId: ME,
676
+ fetchImpl,
677
+ toInboxImpl: (e) => ({ id: e.id, service: "cohort", kind: e.kind, content: e.text }),
678
+ writeImpl: (service, item) => written.push({ service, item }),
679
+ },
680
+ );
681
+
682
+ const eventsUrl = urls.find((u) => u.includes("/events?"));
683
+ assert.ok(eventsUrl, "the ledger was read");
684
+ assert.ok(
685
+ !/family=/.test(eventsUrl),
686
+ `the events read must NOT pin a family — got ${eventsUrl}`,
687
+ );
688
+ assert.equal(res.wide, true, "the wide reader is the default, not an opt-in");
689
+ assert.equal(res.routed, 1, "a board assignment reaches the inbox pipeline");
690
+ assert.equal(written[0].item.kind, "task_assigned", "and carries the routing hint the classifier needs");
691
+ assert.equal(res.stats.bySurface.task_assigned, 1);
692
+ });
693
+
694
+ test("messaging-inbound (JOINT 3): MESSAGING_INBOUND_WIDE=0 falls back to the narrow reader — and SAYS SO", async () => {
695
+ const prev = process.env.MESSAGING_INBOUND_WIDE;
696
+ process.env.MESSAGING_INBOUND_WIDE = "0";
697
+ const logs = [];
698
+ try {
699
+ const res = await guardMessagingInbound(
700
+ { event: { cadence: "messaging-inbound" }, agentRoot: tmpRoot(), log: (lvl, m) => logs.push(`${lvl}:${m}`) },
701
+ {
702
+ cfg: { org: { cohort: { enabled: true, base: "https://x.test", token: "t" } } },
703
+ agentId: "member-me",
704
+ // The narrow reader pins family=messaging,calling on its URL; assert the
705
+ // degradation is announced rather than inferred from an empty inbox.
706
+ fetchImpl: async () => ({ ok: true, status: 200, headers: { get: () => "application/json" }, text: async () => JSON.stringify({ events: [], nextCursor: 0 }), json: async () => ({ events: [], nextCursor: 0 }) }),
707
+ },
708
+ );
709
+ assert.equal(res.wide, false);
710
+ assert.ok(
711
+ logs.some((l) => l.startsWith("warn:") && /WILL NOT arrive/.test(l)),
712
+ `a silent narrowing is the bug; got ${JSON.stringify(logs)}`,
713
+ );
714
+ } finally {
715
+ if (prev === undefined) delete process.env.MESSAGING_INBOUND_WIDE;
716
+ else process.env.MESSAGING_INBOUND_WIDE = prev;
717
+ }
718
+ });