@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,666 @@
1
+ /**
2
+ * lib/kpi-sensors.mjs — the SENSOR REGISTRY: capability id → a real measurement.
3
+ *
4
+ * `lib/kpi.measureKpi` has always looked a sensor up in `deps.sensors[capability]`
5
+ * and, finding nothing, returned `{ok:false, reason:"no-implementation"}`. No
6
+ * registry existed anywhere in the repo, so EVERY objective measured to nothing,
7
+ * every gap was `null`, and `goals/admission.admit`'s honesty gate (the latest
8
+ * sample's `source` must be `method` or `human`) was unsatisfiable by
9
+ * construction. The reconciliation loop could not produce a single backlog item,
10
+ * ever. This file is the missing half.
11
+ *
12
+ * Three laws:
13
+ *
14
+ * 1. **A sensor READS. It never writes.** The measuring instrument may not
15
+ * change the thing it measures. Any capability whose curated tool is not
16
+ * `access:"read"` is refused BY NAME (`capability-is-not-a-read`) instead of
17
+ * quietly missing — the derivation in lib/mandate/derive.mjs can and does
18
+ * propose `task_update` and `escalation_raise` as fallback "sensors".
19
+ * 2. **Every number comes back with its evidence.** `{value, rowCount, detail}`
20
+ * where `detail` names the method, the params actually sent and the rows the
21
+ * reduction walked. `measureKpi` stamps `source:"method"` only because a
22
+ * real capability really executed; that is what the honesty gate trusts.
23
+ * 3. **A sensor that cannot measure says so.** It returns `{ok:false, reason}`
24
+ * — never a zero, never a throw. A read that fails, a read that comes back
25
+ * empty when emptiness is not a measurement, a reduction that finds no
26
+ * number: all three are `ok:false` with a distinct reason, all three are
27
+ * logged, and none of them can become a sample. (An honest zero — "there
28
+ * are no open escalations" — is a different thing and is returned as 0 with
29
+ * `rowCount:0` and a `detail` saying so.)
30
+ *
31
+ * Injectable everywhere: `executeImpl`, the clock, the logger and the member-id
32
+ * resolver all arrive on `o`, so tests never touch the network.
33
+ *
34
+ * @module lib/kpi-sensors
35
+ */
36
+
37
+ "use strict";
38
+
39
+ import { existsSync, readFileSync } from "node:fs";
40
+ import { join } from "node:path";
41
+
42
+ import { resolveAgentRoot } from "./agent-root.mjs";
43
+ import { MANIFEST_REL } from "./capability/inventory.mjs";
44
+ import { orgToolDef } from "./org/tool-surface.mjs";
45
+ import { SENSOR_HINTS } from "./mandate/derive.mjs";
46
+
47
+ const MS_HOUR = 3_600_000;
48
+ const MS_DAY = 86_400_000;
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // Return shapes
52
+ // ---------------------------------------------------------------------------
53
+
54
+ /** A measured number, with the rows it came from. */
55
+ function measured(value, rowCount, detail) {
56
+ return { value, rowCount: Number.isFinite(rowCount) ? rowCount : null, detail: detail || null };
57
+ }
58
+
59
+ /** "I could not measure this." Never a zero, never a throw. */
60
+ function unmeasurable(reason, detail) {
61
+ return { ok: false, reason, detail: detail || null };
62
+ }
63
+
64
+ // ---------------------------------------------------------------------------
65
+ // Small pure helpers (exported: the tests drive them directly)
66
+ // ---------------------------------------------------------------------------
67
+
68
+ /** Dot-path read with numeric index support. Returns undefined, never throws. */
69
+ export function pluck(obj, path) {
70
+ if (!path) return obj;
71
+ let cur = obj;
72
+ for (const seg of String(path).split(".")) {
73
+ if (cur == null) return undefined;
74
+ cur = cur[seg];
75
+ }
76
+ return cur;
77
+ }
78
+
79
+ /** First array-valued property of a result object (`{items:[…]}`, `{deals:[…]}`, …). */
80
+ export function firstArray(result) {
81
+ if (Array.isArray(result)) return { key: "$", rows: result };
82
+ if (!result || typeof result !== "object") return null;
83
+ for (const [k, v] of Object.entries(result)) if (Array.isArray(v)) return { key: k, rows: v };
84
+ return null;
85
+ }
86
+
87
+ /** Mean of a numeric array, or null when empty. */
88
+ export function mean(xs) {
89
+ const ns = (xs || []).filter((n) => Number.isFinite(n));
90
+ if (ns.length === 0) return null;
91
+ return ns.reduce((a, b) => a + b, 0) / ns.length;
92
+ }
93
+
94
+ /** Round to 4dp so a KPI series is comparable across runs. */
95
+ function r4(n) {
96
+ return Number.isFinite(n) ? +n.toFixed(4) : n;
97
+ }
98
+
99
+ function ts(v) {
100
+ const t = Date.parse(v || "");
101
+ return Number.isFinite(t) ? t : null;
102
+ }
103
+
104
+ /**
105
+ * Generic numeric reduction over an arbitrary read payload — the escape hatch
106
+ * that lets a new KPI be added with a `sensor.params` change and no code edit.
107
+ *
108
+ * params: { field?, arrayPath?, where?:{key,equals}, sum?, avg?, count?, scale? }
109
+ * @returns {{value:number, rowCount:number|null, detail:object}|{ok:false, reason:string}}
110
+ */
111
+ export function reduceGeneric(result, params = {}) {
112
+ const scale = Number.isFinite(Number(params.scale)) ? Number(params.scale) : 1;
113
+
114
+ if (params.field) {
115
+ const v = Number(pluck(result, params.field));
116
+ if (!Number.isFinite(v)) {
117
+ return unmeasurable("field-not-numeric", { field: params.field, saw: typeof pluck(result, params.field) });
118
+ }
119
+ return measured(r4(v * scale), null, { field: params.field, scale });
120
+ }
121
+
122
+ const picked = params.arrayPath ? pluck(result, params.arrayPath) : null;
123
+ const found = Array.isArray(picked) ? { key: params.arrayPath, rows: picked } : firstArray(result);
124
+ if (!found) {
125
+ return unmeasurable("no-array-to-reduce", { keys: result && typeof result === "object" ? Object.keys(result) : [] });
126
+ }
127
+
128
+ let rows = found.rows;
129
+ if (params.where && params.where.key) {
130
+ rows = rows.filter((row) => String(pluck(row, params.where.key)) === String(params.where.equals));
131
+ }
132
+
133
+ if (params.sum) {
134
+ const ns = rows.map((row) => Number(pluck(row, params.sum))).filter((n) => Number.isFinite(n));
135
+ if (ns.length === 0) {
136
+ return unmeasurable("no-numeric-rows", { arrayPath: found.key, sum: params.sum, rows: rows.length });
137
+ }
138
+ return measured(r4(ns.reduce((a, b) => a + b, 0) * scale), rows.length, { arrayPath: found.key, sum: params.sum, scale });
139
+ }
140
+ if (params.avg) {
141
+ const m = mean(rows.map((row) => Number(pluck(row, params.avg))));
142
+ if (m == null) {
143
+ return unmeasurable("no-numeric-rows", { arrayPath: found.key, avg: params.avg, rows: rows.length });
144
+ }
145
+ return measured(r4(m * scale), rows.length, { arrayPath: found.key, avg: params.avg, scale });
146
+ }
147
+ return measured(rows.length * scale, rows.length, { arrayPath: found.key, mode: "count", scale });
148
+ }
149
+
150
+ // ---------------------------------------------------------------------------
151
+ // The specs — one per genuinely measurable capability
152
+ // ---------------------------------------------------------------------------
153
+
154
+ /**
155
+ * `input(params, ctx)` → the tool input actually sent (evidence records this).
156
+ * `reduce(result, ctx)` → `measured()` or `unmeasurable()`.
157
+ * `needsMemberId` → resolve this seat's member id before reducing.
158
+ *
159
+ * @type {Record<string, {metrics:string[], defaultMetric:string, unit:string, direction:string, input?:Function, reduce:Function, needsMemberId?:boolean}>}
160
+ */
161
+ export const SENSOR_SPECS = Object.freeze({
162
+ // ── board / commitments ────────────────────────────────────────────────
163
+ board_ready: {
164
+ metrics: ["on_time_rate", "open_count", "overdue_count"],
165
+ defaultMetric: "on_time_rate",
166
+ unit: "%",
167
+ direction: "up",
168
+ input: () => ({}),
169
+ reduce(result, ctx) {
170
+ const all = (result && result.items) || [];
171
+ if (all.length === 0) return unmeasurable("no-rows", { read: "board.ready", items: 0 });
172
+ const scope = String(ctx.params.scope || "all");
173
+ const items = scope === "mine" && ctx.memberId ? all.filter((i) => i.assigneeId === ctx.memberId) : all;
174
+ if (scope === "mine" && !ctx.memberId) {
175
+ return unmeasurable("member-id-unresolved", { scope, read: "board.ready" });
176
+ }
177
+ if (items.length === 0) return unmeasurable("no-rows-in-scope", { scope, of: all.length });
178
+ const overdue = items.filter((i) => {
179
+ const due = ts(i.dueAt);
180
+ return due != null && due < ctx.nowMs;
181
+ });
182
+ const metric = String(ctx.params.metric || this.defaultMetric);
183
+ const detail = { read: "board.ready", scope, considered: items.length, overdue: overdue.length, withDueDate: items.filter((i) => ts(i.dueAt) != null).length };
184
+ if (metric === "open_count") return measured(items.length, items.length, detail);
185
+ if (metric === "overdue_count") return measured(overdue.length, items.length, detail);
186
+ // An item with no due date cannot be late — it counts as on time, and the
187
+ // `withDueDate` evidence field is what tells an operator how much of the
188
+ // rate is actually load-bearing.
189
+ return measured(r4(((items.length - overdue.length) / items.length) * 100), items.length, detail);
190
+ },
191
+ },
192
+
193
+ // ── governance / decision latency ──────────────────────────────────────
194
+ decision_list: {
195
+ metrics: ["open_age_days", "decided_latency_days", "open_count"],
196
+ defaultMetric: "open_age_days",
197
+ unit: "days",
198
+ direction: "down",
199
+ input: (params) => (params && params.status ? { status: params.status } : {}),
200
+ reduce(result, ctx) {
201
+ const all = (result && result.decisions) || [];
202
+ if (all.length === 0) return unmeasurable("no-rows", { read: "decision.list", decisions: 0 });
203
+ const open = all.filter((d) => d.status === "proposed" || (!d.decidedAt && d.status !== "superseded"));
204
+ const decided = all.filter((d) => ts(d.decidedAt) != null && ts(d.createdAt) != null);
205
+ const metric = String(ctx.params.metric || this.defaultMetric);
206
+ const detail = { read: "decision.list", total: all.length, open: open.length, decided: decided.length };
207
+
208
+ if (metric === "open_count") return measured(open.length, all.length, detail);
209
+ if (metric === "decided_latency_days") {
210
+ const m = mean(decided.map((d) => (ts(d.decidedAt) - ts(d.createdAt)) / MS_DAY));
211
+ if (m == null) return unmeasurable("no-decided-decisions", detail);
212
+ return measured(r4(m), decided.length, detail);
213
+ }
214
+ // open_age_days: the backlog that is costing the org time RIGHT NOW. Zero
215
+ // undecided decisions is a real measurement of zero (the read succeeded and
216
+ // returned rows) — not an absence.
217
+ if (open.length === 0) return measured(0, all.length, { ...detail, note: "no undecided decisions — an honest zero, not a missing sample" });
218
+ const m = mean(open.map((d) => (ctx.nowMs - (ts(d.createdAt) ?? ctx.nowMs)) / MS_DAY));
219
+ return measured(r4(m), open.length, detail);
220
+ },
221
+ },
222
+
223
+ // ── email responsiveness ───────────────────────────────────────────────
224
+ email_inbox: {
225
+ metrics: ["first_reply_hours", "unread_backlog_count", "oldest_unread_hours"],
226
+ defaultMetric: "first_reply_hours",
227
+ unit: "hours",
228
+ direction: "down",
229
+ input: (params) => {
230
+ const i = {};
231
+ if (params && params.limit) i.limit = Number(params.limit);
232
+ if (params && params.mailboxId) i.mailboxId = String(params.mailboxId);
233
+ return i;
234
+ },
235
+ reduce(result, ctx) {
236
+ const msgs = (result && result.messages) || [];
237
+ if (msgs.length === 0) return unmeasurable("no-rows", { read: "email.inbox", messages: 0 });
238
+ const inbound = msgs.filter((m) => String(m.direction).toUpperCase() === "INBOUND");
239
+ const outbound = msgs.filter((m) => String(m.direction).toUpperCase() === "OUTBOUND");
240
+ const metric = String(ctx.params.metric || this.defaultMetric);
241
+ const detail = { read: "email.inbox", messages: msgs.length, inbound: inbound.length, outbound: outbound.length };
242
+
243
+ if (metric === "unread_backlog_count") {
244
+ return measured(inbound.filter((m) => !m.readAt).length, inbound.length, detail);
245
+ }
246
+ if (metric === "oldest_unread_hours") {
247
+ const ages = inbound.filter((m) => !m.readAt).map((m) => (ctx.nowMs - (ts(m.receivedAt) ?? ctx.nowMs)) / MS_HOUR);
248
+ if (ages.length === 0) return measured(0, inbound.length, { ...detail, note: "nothing unread" });
249
+ return measured(r4(Math.max(...ages)), ages.length, detail);
250
+ }
251
+
252
+ // first_reply_hours: per inbound message, the first outbound in the SAME
253
+ // thread that left after it. Unanswered inbound is reported in the evidence
254
+ // rather than folded into the mean — an unanswered mail has no latency yet,
255
+ // and averaging it in as zero would make silence look like speed.
256
+ if (inbound.length === 0) return unmeasurable("no-inbound-mail", detail);
257
+ const latencies = [];
258
+ let unanswered = 0;
259
+ for (const m of inbound) {
260
+ const at = ts(m.receivedAt) ?? ts(m.createdAt);
261
+ if (at == null) continue;
262
+ const replies = outbound
263
+ .filter((o) => o.threadId === m.threadId)
264
+ .map((o) => ts(o.sentAt) ?? ts(o.createdAt))
265
+ .filter((t) => t != null && t > at);
266
+ if (replies.length === 0) { unanswered += 1; continue; }
267
+ latencies.push((Math.min(...replies) - at) / MS_HOUR);
268
+ }
269
+ const m = mean(latencies);
270
+ if (m == null) return unmeasurable("no-answered-inbound", { ...detail, unanswered });
271
+ return measured(r4(m), latencies.length, { ...detail, answered: latencies.length, unanswered });
272
+ },
273
+ },
274
+
275
+ // ── messaging ──────────────────────────────────────────────────────────
276
+ messaging_channels: {
277
+ metrics: ["active_channel_count"],
278
+ defaultMetric: "active_channel_count",
279
+ unit: "count",
280
+ direction: "up",
281
+ input: () => ({}),
282
+ reduce(result) {
283
+ const chs = (result && result.channels) || [];
284
+ if (chs.length === 0) return unmeasurable("no-rows", { method: "channel.list", channels: 0 });
285
+ const active = chs.filter((c) => !c.archived);
286
+ return measured(active.length, chs.length, { method: "channel.list", total: chs.length, archived: chs.length - active.length });
287
+ },
288
+ },
289
+
290
+ messaging_history: {
291
+ metrics: ["mention_response_rate", "participation_rate", "message_count"],
292
+ defaultMetric: "mention_response_rate",
293
+ unit: "%",
294
+ direction: "up",
295
+ needsMemberId: true,
296
+ input: (params) => {
297
+ const i = { channelId: String((params && params.channelId) || "") };
298
+ if (params && params.limit) i.limit = Number(params.limit);
299
+ return i;
300
+ },
301
+ reduce(result, ctx) {
302
+ if (!ctx.params.channelId) return unmeasurable("missing-param", { required: "channelId" });
303
+ const msgs = (result && result.messages) || [];
304
+ if (msgs.length === 0) return unmeasurable("no-rows", { method: "messaging.history", channelId: ctx.params.channelId });
305
+ const metric = String(ctx.params.metric || this.defaultMetric);
306
+ const mine = msgs.filter((m) => m.authorId === ctx.memberId);
307
+ const detail = { method: "messaging.history", channelId: ctx.params.channelId, messages: msgs.length, mine: mine.length };
308
+
309
+ if (metric === "message_count") return measured(msgs.length, msgs.length, detail);
310
+ if (!ctx.memberId) return unmeasurable("member-id-unresolved", detail);
311
+ if (metric === "participation_rate") {
312
+ return measured(r4((mine.length / msgs.length) * 100), msgs.length, detail);
313
+ }
314
+ // mention_response_rate: of the messages that @-mentioned me, how many were
315
+ // followed by a message of mine within `windowHours`.
316
+ const windowHours = Number.isFinite(Number(ctx.params.windowHours)) ? Number(ctx.params.windowHours) : 24;
317
+ const mentions = msgs.filter((m) => Array.isArray(m.mentions) && m.mentions.includes(ctx.memberId));
318
+ if (mentions.length === 0) return unmeasurable("no-mentions-in-window", { ...detail, windowHours });
319
+ const mineAt = mine.map((m) => ts(m.createdAt)).filter((t) => t != null);
320
+ let answered = 0;
321
+ for (const m of mentions) {
322
+ const at = ts(m.createdAt);
323
+ if (at == null) continue;
324
+ if (mineAt.some((t) => t > at && t - at <= windowHours * MS_HOUR)) answered += 1;
325
+ }
326
+ return measured(r4((answered / mentions.length) * 100), mentions.length, { ...detail, mentions: mentions.length, answered, windowHours });
327
+ },
328
+ },
329
+
330
+ // ── calendar ───────────────────────────────────────────────────────────
331
+ calendar_list: {
332
+ metrics: ["agenda_coverage", "upcoming_count"],
333
+ defaultMetric: "agenda_coverage",
334
+ unit: "%",
335
+ direction: "up",
336
+ input: (params) => {
337
+ const i = {};
338
+ for (const k of ["fromIso", "toIso", "calendarMemberId", "limit"]) {
339
+ if (params && params[k] != null) i[k] = params[k];
340
+ }
341
+ return i;
342
+ },
343
+ reduce(result, ctx) {
344
+ const events = (result && (result.events || result.meetings)) || [];
345
+ if (events.length === 0) return unmeasurable("no-rows", { method: "calendar.list", events: 0 });
346
+ const metric = String(ctx.params.metric || this.defaultMetric);
347
+ if (metric === "upcoming_count") {
348
+ return measured(events.length, events.length, { method: "calendar.list" });
349
+ }
350
+ const withAgenda = events.filter((e) => {
351
+ const a = e.agenda || e.description || e.notes || e.summary;
352
+ return typeof a === "string" && a.trim().length > 0;
353
+ });
354
+ return measured(r4((withAgenda.length / events.length) * 100), events.length, {
355
+ method: "calendar.list", events: events.length, withAgenda: withAgenda.length,
356
+ });
357
+ },
358
+ },
359
+
360
+ // ── pipeline / risk ────────────────────────────────────────────────────
361
+ crm_list_deals: {
362
+ metrics: ["open_pipeline_usd", "open_deal_count"],
363
+ defaultMetric: "open_pipeline_usd",
364
+ unit: "usd",
365
+ direction: "up",
366
+ input: (params) => {
367
+ // NOT a passthrough of `params`: hq's stage filter is a strict enum
368
+ // (INBOUND|QUALIFIED|PROPOSAL|NEGOTIATION|CLOSED) and rejects anything
369
+ // else with BAD_REQUEST. Open-ness is computed here from the stage field.
370
+ const i = {};
371
+ if (params && params.stage && /^(INBOUND|QUALIFIED|PROPOSAL|NEGOTIATION|CLOSED)$/.test(String(params.stage))) {
372
+ i.stage = String(params.stage);
373
+ }
374
+ return i;
375
+ },
376
+ reduce(result, ctx) {
377
+ const deals = (result && result.deals) || [];
378
+ if (deals.length === 0) return unmeasurable("no-rows", { method: "crm.listDeals", deals: 0 });
379
+ const open = deals.filter((d) => String(d.stage).toUpperCase() !== "CLOSED");
380
+ const metric = String(ctx.params.metric || this.defaultMetric);
381
+ const detail = { method: "crm.listDeals", total: deals.length, open: open.length };
382
+ if (metric === "open_deal_count") return measured(open.length, deals.length, detail);
383
+ const cents = open.map((d) => Number(d.valueCents)).filter((n) => Number.isFinite(n));
384
+ if (cents.length === 0) return unmeasurable("no-deal-values", detail);
385
+ return measured(r4(cents.reduce((a, b) => a + b, 0) / 100), open.length, { ...detail, valued: cents.length });
386
+ },
387
+ },
388
+
389
+ crm_list_escalations: {
390
+ metrics: ["open_count"],
391
+ defaultMetric: "open_count",
392
+ unit: "count",
393
+ direction: "down",
394
+ input: () => ({}),
395
+ reduce(result) {
396
+ const rows = (result && result.escalations) || [];
397
+ // Zero escalations is the GOOD value of a direction:"down" count, and the
398
+ // read genuinely answered. This is the one place an empty list is a real
399
+ // measurement rather than an absence — said out loud in the evidence.
400
+ return measured(rows.length, rows.length, {
401
+ method: "crm.listEscalations",
402
+ note: rows.length === 0 ? "no open escalations — a measured zero" : null,
403
+ });
404
+ },
405
+ },
406
+
407
+ // ── generic counts ─────────────────────────────────────────────────────
408
+ directory_list_captures: {
409
+ metrics: ["pending_count"], defaultMetric: "pending_count", unit: "count", direction: "down",
410
+ input: (params) => (params && params.status ? { status: params.status } : {}),
411
+ reduce: (result) => countOf(result, "directory.listCaptures"),
412
+ },
413
+ design_list_templates: {
414
+ metrics: ["template_count"], defaultMetric: "template_count", unit: "count", direction: "up",
415
+ input: () => ({}),
416
+ reduce: (result) => countOf(result, "branding.listTemplates"),
417
+ },
418
+ files_list: {
419
+ metrics: ["file_count"], defaultMetric: "file_count", unit: "count", direction: "up",
420
+ input: (params) => (params && params.folderId ? { folderId: params.folderId } : {}),
421
+ reduce: (result) => countOf(result, "files.list"),
422
+ },
423
+ knowledge_search: {
424
+ metrics: ["hit_count"], defaultMetric: "hit_count", unit: "count", direction: "up",
425
+ input: (params) => ({ query: String((params && params.query) || "") }),
426
+ reduce(result, ctx) {
427
+ if (!ctx.params.query) return unmeasurable("missing-param", { required: "query" });
428
+ return countOf(result, "knowledge.search");
429
+ },
430
+ },
431
+
432
+ // ── generic numeric escape hatches ─────────────────────────────────────
433
+ books_reports: {
434
+ metrics: ["generic"], defaultMetric: "generic", unit: null, direction: "up",
435
+ input: (params) => {
436
+ const i = { report: String((params && params.report) || "") };
437
+ if (params && params.from) i.from = params.from;
438
+ if (params && params.to) i.to = params.to;
439
+ return i;
440
+ },
441
+ reduce(result, ctx) {
442
+ if (!ctx.params.report) {
443
+ return unmeasurable("missing-param", { required: "report", accepts: "pl|balance|cashflow|agedAr|agedAp" });
444
+ }
445
+ return reduceGeneric(result, ctx.params);
446
+ },
447
+ },
448
+ org_read: {
449
+ metrics: ["generic"], defaultMetric: "generic", unit: null, direction: "up",
450
+ input: (params) => {
451
+ const i = { path: String((params && params.path) || "") };
452
+ if (params && params.query) i.query = params.query;
453
+ return i;
454
+ },
455
+ reduce(result, ctx) {
456
+ if (!ctx.params.path) return unmeasurable("missing-param", { required: "path" });
457
+ return reduceGeneric(result, ctx.params);
458
+ },
459
+ },
460
+ });
461
+
462
+ function countOf(result, method) {
463
+ const found = firstArray(result);
464
+ if (!found) return unmeasurable("no-array-to-reduce", { method, keys: result && typeof result === "object" ? Object.keys(result) : [] });
465
+ return measured(found.rows.length, found.rows.length, { method, arrayKey: found.key });
466
+ }
467
+
468
+ // ---------------------------------------------------------------------------
469
+ // Refusals — every capability the derivation can pick gets a NAMED answer
470
+ // ---------------------------------------------------------------------------
471
+
472
+ /** Capabilities lib/mandate/derive.SENSOR_HINTS can bind a metric to. */
473
+ export function hintedCapabilities() {
474
+ const out = new Set();
475
+ for (const h of SENSOR_HINTS) for (const c of h.caps) out.add(c);
476
+ return out;
477
+ }
478
+
479
+ /** Why this capability is not a sensor. Returns null when it IS one. */
480
+ export function refusalFor(capability) {
481
+ if (SENSOR_SPECS[capability]) return null;
482
+ const def = orgToolDef(capability);
483
+ if (!def) return "not a curated org tool — nothing in this SDK can execute it as a measurement";
484
+ if (capability === "approval_wait") {
485
+ return "approval_wait is a long-poll, not an aggregate read — measuring with it would block the daemon for up to 55s per objective";
486
+ }
487
+ if (def.access !== "read") {
488
+ return `"${capability}" is a ${def.access} capability — a sensor may not mutate the org to measure it`;
489
+ }
490
+ return `"${capability}" is a read, but this registry has no reduction that turns its payload into a number`;
491
+ }
492
+
493
+ // ---------------------------------------------------------------------------
494
+ // Reachability
495
+ // ---------------------------------------------------------------------------
496
+
497
+ /**
498
+ * The reachable capability ids from `config/capability-manifest.json`.
499
+ *
500
+ * Returns **undefined** — not an empty Set — when the manifest is missing or
501
+ * unreadable, and logs. `measureKpi` only enforces reachability when
502
+ * `deps.reachable !== undefined`, so an empty Set here would silently refuse
503
+ * every measurement in the fleet the first time a manifest failed to parse.
504
+ *
505
+ * @param {string} agentRoot @param {object} [deps] { log }
506
+ * @returns {Set<string>|undefined}
507
+ */
508
+ export function reachableCapabilities(agentRoot, deps = {}) {
509
+ const log = typeof deps.log === "function" ? deps.log : () => {};
510
+ const p = join(resolveAgentRoot(agentRoot), MANIFEST_REL);
511
+ if (!existsSync(p)) {
512
+ log("warn", `[kpi-sensors] no capability manifest at ${p} — measuring WITHOUT the reachability check (run \`cohort inventory\`)`);
513
+ return undefined;
514
+ }
515
+ try {
516
+ const m = JSON.parse(readFileSync(p, "utf-8"));
517
+ const entries = Array.isArray(m.entries) ? m.entries : [];
518
+ const out = new Set();
519
+ for (const e of entries) {
520
+ if (!e || !e.reachable) continue;
521
+ out.add(String(e.id));
522
+ // org-plane ids are the bare tool name already; tolerate a "plane:" prefix.
523
+ const bare = String(e.id).replace(/^[a-z]+:/, "");
524
+ if (bare) out.add(bare);
525
+ }
526
+ if (out.size === 0) {
527
+ log("warn", `[kpi-sensors] capability manifest has ZERO reachable entries — measuring without the reachability check rather than refusing every objective`);
528
+ return undefined;
529
+ }
530
+ return out;
531
+ } catch (err) {
532
+ log("warn", `[kpi-sensors] capability manifest unreadable (${err && err.message ? err.message : err}) — measuring without the reachability check`);
533
+ return undefined;
534
+ }
535
+ }
536
+
537
+ // ---------------------------------------------------------------------------
538
+ // The registry
539
+ // ---------------------------------------------------------------------------
540
+
541
+ /**
542
+ * Build the `deps.sensors` registry `lib/kpi.measureKpi` looks a capability up in.
543
+ *
544
+ * Every capability the mandate derivation can bind a metric to is present:
545
+ * measurable ones as a real org read, the rest as a refusal that names WHY.
546
+ * `measureKpi` turns a refusal into `{ok:false, reason}` — never a sample.
547
+ *
548
+ * @param {object} o - {
549
+ * agentRoot, executeImpl?, now?, log?, memberId?, resolveMemberIdImpl?, fetchImpl?
550
+ * }
551
+ * @returns {Record<string, (a:{params:object, objective:object}) => Promise<object>>}
552
+ */
553
+ export function buildSensorRegistry(o = {}) {
554
+ const agentRoot = resolveAgentRoot(o.agentRoot);
555
+ const log = typeof o.log === "function" ? o.log : () => {};
556
+ const now = typeof o.now === "function" ? o.now : Date.now;
557
+ let execute = o.executeImpl || null;
558
+
559
+ const exec = async (name, input) => {
560
+ if (!execute) execute = (await import("./org/tool-surface.mjs")).executeOrgTool;
561
+ return execute(name, input, { agentRoot, fetchImpl: o.fetchImpl });
562
+ };
563
+
564
+ // Member id: env first (the daemon already has it), then a memoized directory
565
+ // lookup by slug. Never fatal — a sensor that needs it says `member-id-unresolved`.
566
+ let memberIdCache = o.memberId || null;
567
+ let memberIdTried = !!o.memberId;
568
+ const resolveMemberId = async () => {
569
+ if (memberIdTried) return memberIdCache;
570
+ memberIdTried = true;
571
+ if (typeof o.resolveMemberIdImpl === "function") {
572
+ try { memberIdCache = (await o.resolveMemberIdImpl()) || null; } catch { memberIdCache = null; }
573
+ return memberIdCache;
574
+ }
575
+ const env = o.env || process.env;
576
+ if (env.COHORT_AGENT_ID) { memberIdCache = String(env.COHORT_AGENT_ID); return memberIdCache; }
577
+ const slug = env.COHORT_MEMBER_SLUG || "";
578
+ if (!slug) {
579
+ log("info", "[kpi-sensors] no COHORT_AGENT_ID and no COHORT_MEMBER_SLUG — sensors scoped to 'me' will report member-id-unresolved");
580
+ return null;
581
+ }
582
+ try {
583
+ const r = await exec("org_directory", {});
584
+ const rows = (r && r.ok && ((r.result && r.result.members) || r.result)) || [];
585
+ const hit = Array.isArray(rows) ? rows.find((m) => m && m.slug === slug) : null;
586
+ memberIdCache = (hit && hit.id) || null;
587
+ if (!memberIdCache) log("warn", `[kpi-sensors] member slug "${slug}" not found in the org directory — 'me'-scoped sensors cannot measure`);
588
+ } catch (err) {
589
+ log("warn", `[kpi-sensors] directory lookup for "${slug}" failed: ${err && err.message ? err.message : err}`);
590
+ }
591
+ return memberIdCache;
592
+ };
593
+
594
+ const registry = {};
595
+
596
+ for (const [capability, spec] of Object.entries(SENSOR_SPECS)) {
597
+ registry[capability] = async ({ params = {}, objective } = {}) => {
598
+ const p = params && typeof params === "object" ? params : {};
599
+ const input = typeof spec.input === "function" ? spec.input(p, { objective }) : p;
600
+ let res;
601
+ try {
602
+ res = await exec(capability, input);
603
+ } catch (err) {
604
+ log("error", `[kpi-sensors] ${capability} threw: ${err && err.message ? err.message : err}`);
605
+ return unmeasurable("sensor-threw", { capability, message: String(err && err.message ? err.message : err) });
606
+ }
607
+ if (!res || res.ok !== true) {
608
+ const e = (res && res.error) || {};
609
+ log("warn", `[kpi-sensors] ${capability} read failed (${e.code || "?"}): ${e.message || "no error frame"}`);
610
+ return unmeasurable("read-failed", { capability, input, code: e.code || null, message: e.message || null });
611
+ }
612
+ const ctx = {
613
+ params: p,
614
+ nowMs: now(),
615
+ memberId: spec.needsMemberId || p.scope === "mine" ? await resolveMemberId() : (memberIdCache || null),
616
+ log,
617
+ };
618
+ let out;
619
+ try {
620
+ out = spec.reduce.call(spec, res.result, ctx);
621
+ } catch (err) {
622
+ log("error", `[kpi-sensors] ${capability} reduction threw: ${err && err.message ? err.message : err}`);
623
+ return unmeasurable("reduce-threw", { capability, message: String(err && err.message ? err.message : err) });
624
+ }
625
+ if (!out || out.ok === false) {
626
+ log("warn", `[kpi-sensors] ${capability} could not be reduced to a number: ${(out && out.reason) || "unknown"}`);
627
+ return out || unmeasurable("reduce-empty", { capability });
628
+ }
629
+ return {
630
+ value: out.value,
631
+ rowCount: out.rowCount,
632
+ detail: {
633
+ capability,
634
+ metric: String(p.metric || spec.defaultMetric),
635
+ unit: spec.unit,
636
+ input,
637
+ ...(out.detail || {}),
638
+ },
639
+ };
640
+ };
641
+ }
642
+
643
+ // Everything the derivation can bind but this registry will not measure.
644
+ for (const capability of hintedCapabilities()) {
645
+ if (registry[capability]) continue;
646
+ const why = refusalFor(capability);
647
+ registry[capability] = async () => {
648
+ log("warn", `[kpi-sensors] refusing to measure via "${capability}": ${why}`);
649
+ return unmeasurable("capability-is-not-a-sensor", { capability, why });
650
+ };
651
+ }
652
+
653
+ return registry;
654
+ }
655
+
656
+ export default {
657
+ SENSOR_SPECS,
658
+ buildSensorRegistry,
659
+ reachableCapabilities,
660
+ refusalFor,
661
+ hintedCapabilities,
662
+ reduceGeneric,
663
+ firstArray,
664
+ pluck,
665
+ mean,
666
+ };