@cohortapp/agent-sdk 2.3.1 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. package/bin/maestro.mjs +37 -50
  2. package/framework-features.json +30 -0
  3. package/lib/backlog.mjs +136 -0
  4. package/lib/cadences.mjs +63 -2
  5. package/lib/cadences.test.mjs +105 -0
  6. package/lib/capability/inventory.mjs +542 -0
  7. package/lib/capability/inventory.test.mjs +232 -0
  8. package/lib/capability/probe.mjs +255 -0
  9. package/lib/channels/contract.mjs +37 -1
  10. package/lib/channels/contract.test.mjs +25 -1
  11. package/lib/channels/inbox-item.mjs +20 -0
  12. package/lib/claude-bin.mjs +37 -3
  13. package/lib/claude-bin.test.mjs +42 -8
  14. package/lib/execution/disposition.mjs +501 -0
  15. package/lib/execution/disposition.test.mjs +482 -0
  16. package/lib/execution/drive.mjs +352 -0
  17. package/lib/execution/drive.test.mjs +270 -0
  18. package/lib/execution/effects.mjs +340 -0
  19. package/lib/execution/effects.test.mjs +193 -0
  20. package/lib/execution/index.mjs +152 -0
  21. package/lib/execution/intake.mjs +581 -0
  22. package/lib/execution/intake.test.mjs +343 -0
  23. package/lib/execution/journal.mjs +374 -0
  24. package/lib/execution/journal.test.mjs +261 -0
  25. package/lib/execution/match.mjs +331 -0
  26. package/lib/execution/match.test.mjs +235 -0
  27. package/lib/execution/pipeline.mjs +341 -0
  28. package/lib/execution/pipeline.test.mjs +389 -0
  29. package/lib/execution/route.mjs +332 -0
  30. package/lib/execution/route.test.mjs +186 -0
  31. package/lib/execution/surface-policy.mjs +446 -0
  32. package/lib/execution/surface-policy.test.mjs +162 -0
  33. package/lib/goals/admission.mjs +209 -0
  34. package/lib/goals/admission.test.mjs +139 -0
  35. package/lib/goals/classify.mjs +206 -0
  36. package/lib/goals/classify.test.mjs +109 -0
  37. package/lib/goals/collaborate.mjs +415 -0
  38. package/lib/goals/collaborate.test.mjs +324 -0
  39. package/lib/goals/gaps.mjs +111 -0
  40. package/lib/goals/gaps.test.mjs +284 -0
  41. package/lib/goals/loop.mjs +537 -0
  42. package/lib/goals/loop.test.mjs +719 -0
  43. package/lib/identity/persona.mjs +247 -0
  44. package/lib/identity/persona.test.mjs +117 -0
  45. package/lib/kpi.mjs +469 -0
  46. package/lib/kpi.test.mjs +244 -0
  47. package/lib/mandate/audit.mjs +168 -0
  48. package/lib/mandate/audit.test.mjs +195 -0
  49. package/lib/mandate/cache.mjs +162 -0
  50. package/lib/mandate/derive.mjs +317 -0
  51. package/lib/mandate/derive.test.mjs +224 -0
  52. package/lib/mandate/model.mjs +352 -0
  53. package/lib/mandate/model.test.mjs +145 -0
  54. package/lib/mandate/refresh.mjs +187 -0
  55. package/lib/mandate/refresh.test.mjs +293 -0
  56. package/lib/mcp/server.test.mjs +4 -4
  57. package/lib/org/approvals.mjs +14 -2
  58. package/lib/org/client.mjs +79 -25
  59. package/lib/org/client.test.mjs +54 -1
  60. package/lib/org/doctor.mjs +64 -0
  61. package/lib/org/doctor.test.mjs +31 -2
  62. package/lib/org/inbound/directedness.mjs +720 -0
  63. package/lib/org/inbound/directedness.test.mjs +543 -0
  64. package/lib/org/inbound/facts.mjs +501 -0
  65. package/lib/org/inbound/facts.test.mjs +375 -0
  66. package/lib/org/inbound/hydrate.mjs +535 -0
  67. package/lib/org/inbound/hydrate.test.mjs +326 -0
  68. package/lib/org/inbound/index.mjs +233 -0
  69. package/lib/org/inbound/index.test.mjs +324 -0
  70. package/lib/org/inbound/io.mjs +141 -0
  71. package/lib/org/inbound/project.mjs +201 -0
  72. package/lib/org/inbound/project.test.mjs +287 -0
  73. package/lib/org/inbound/surfaces.mjs +257 -0
  74. package/lib/org/knowledge.mjs +10 -1
  75. package/lib/org/knowledge.test.mjs +8 -1
  76. package/lib/org/leases.mjs +5 -0
  77. package/lib/org/mesh.mjs +45 -2
  78. package/lib/org/mesh.test.mjs +55 -0
  79. package/lib/org/messaging.mjs +180 -15
  80. package/lib/org/messaging.test.mjs +117 -0
  81. package/lib/org/param-contract.mjs +694 -0
  82. package/lib/org/param-contract.test.mjs +451 -0
  83. package/lib/org/protocol.checksum +1 -1
  84. package/lib/org/protocol.mjs +8 -0
  85. package/lib/org/protocol.test.mjs +5 -1
  86. package/lib/org/push.mjs +1025 -0
  87. package/lib/org/push.test.mjs +690 -0
  88. package/lib/org/tool-surface.mjs +138 -38
  89. package/lib/org/tool-surface.test.mjs +13 -8
  90. package/lib/org/typing.mjs +341 -0
  91. package/lib/org/typing.test.mjs +291 -0
  92. package/lib/plan/compile.mjs +510 -0
  93. package/lib/plan/compile.test.mjs +286 -0
  94. package/lib/plan/emit.mjs +256 -0
  95. package/lib/plan/emit.test.mjs +246 -0
  96. package/lib/plan/explain.mjs +226 -0
  97. package/lib/plan/explain.test.mjs +188 -0
  98. package/lib/plan/schema.mjs +140 -0
  99. package/lib/resource-governor.mjs +47 -1
  100. package/lib/resource-governor.test.mjs +21 -1
  101. package/lib/setup/enroll-from-cohort.mjs +84 -16
  102. package/lib/setup/enroll-from-cohort.test.mjs +43 -1
  103. package/lib/setup/sections/identity.mjs +15 -4
  104. package/lib/setup/sections/identity.test.mjs +94 -0
  105. package/lib/setup/sections/inventory.mjs +178 -0
  106. package/lib/setup/sections/inventory.test.mjs +198 -0
  107. package/lib/setup/sections/mandate.mjs +392 -0
  108. package/lib/setup/sections/mandate.test.mjs +373 -0
  109. package/lib/setup/sections/subagents.mjs +427 -0
  110. package/lib/setup/sections/subagents.test.mjs +429 -0
  111. package/lib/setup/sections/verify.mjs +121 -0
  112. package/lib/setup/sections/verify.test.mjs +175 -0
  113. package/lib/setup/sot.mjs +2 -0
  114. package/lib/subagents/cli.mjs +463 -0
  115. package/lib/subagents/cli.test.mjs +389 -0
  116. package/lib/subagents/client.mjs +373 -0
  117. package/lib/subagents/client.test.mjs +309 -0
  118. package/lib/subagents/gap.mjs +268 -0
  119. package/lib/subagents/gap.test.mjs +234 -0
  120. package/lib/subagents/lock.mjs +296 -0
  121. package/lib/subagents/lock.test.mjs +248 -0
  122. package/lib/subagents/manifest.mjs +224 -0
  123. package/lib/subagents/manifest.test.mjs +175 -0
  124. package/lib/subagents/refs.mjs +274 -0
  125. package/lib/subagents/refs.test.mjs +204 -0
  126. package/lib/subagents/resolve.mjs +455 -0
  127. package/lib/subagents/resolve.test.mjs +422 -0
  128. package/lib/subagents/schema.mjs +467 -0
  129. package/lib/subagents/schema.test.mjs +306 -0
  130. package/package.json +9 -4
  131. package/plugins/maestro-skills/.claude-plugin/marketplace.json +16 -0
  132. package/policies/ai-disclosure.yaml +42 -2
  133. package/scaffold/CLAUDE.md +16 -2
  134. package/schedules/triggers/goal-steward.md +79 -0
  135. package/scripts/ci/conformance-org-api.mjs +792 -0
  136. package/scripts/ci/conformance-org-api.test.mjs +417 -0
  137. package/scripts/daemon/agent-daemon.mjs +70 -11
  138. package/scripts/daemon/cadence-handlers.mjs +187 -5
  139. package/scripts/daemon/goal-steward-cadence.test.mjs +243 -0
  140. package/scripts/daemon/inbox-deferral.mjs +45 -2
  141. package/scripts/daemon/inbox-deferral.test.mjs +56 -0
  142. package/scripts/daemon/inbox-wake.mjs +282 -0
  143. package/scripts/daemon/inbox-wake.test.mjs +199 -0
  144. package/scripts/daemon/maestro-daemon.mjs +23 -0
  145. package/scripts/daemon/prompt-builder.mjs +41 -1
  146. package/scripts/daemon/responder.mjs +56 -0
  147. package/scripts/daemon/typing-registry.mjs +55 -2
  148. package/scripts/daemon/typing-registry.test.mjs +25 -0
  149. package/scripts/local-triggers/generate-plists.test.mjs +5 -5
  150. package/scripts/poller/inbox-scan-poller.mjs +26 -1
  151. package/scripts/poller/inbox-scan-poller.test.mjs +64 -0
  152. package/scripts/poller/slack-cloud-relay-client.mjs +5 -0
  153. package/scripts/poller/slack-poller.mjs +32 -0
  154. package/scripts/poller/slack-socket-mode.mjs +27 -1
  155. package/scripts/poller/slack-socket-mode.test.mjs +52 -0
  156. package/scripts/poller/utils.mjs +47 -0
  157. package/scripts/setup/gen-subagent-manifest.mjs +95 -0
  158. package/scripts/setup/gen-subagent-manifest.test.mjs +124 -0
  159. package/scripts/setup/generate-plan.mjs +108 -0
  160. package/scripts/setup/init-capability-manifest.mjs +70 -0
  161. package/scripts/setup/init-skill-marketplace.mjs +155 -0
  162. package/scripts/setup/init-skill-marketplace.test.mjs +193 -0
package/lib/kpi.mjs ADDED
@@ -0,0 +1,469 @@
1
+ /**
2
+ * lib/kpi.mjs — measurement, the time series, and the gap.
3
+ *
4
+ * This is the missing half of "the agent has objectives": neither repo has ever
5
+ * held a KPI TIME SERIES, so no agent could tell whether it was improving, flat
6
+ * or sliding — which means no agent could decide whether to do more work, ask a
7
+ * question, or leave the number alone. `gapFor()` is that decision's input.
8
+ *
9
+ * Three rules the rest of the system leans on:
10
+ *
11
+ * 1. **Honesty is a property of the SAMPLE, not of the caller.** A sample's
12
+ * `source` is `method` only when a real capability actually executed and
13
+ * returned a number; `human` when a person supplied it; `llm` when a model
14
+ * guessed. An `llm`-sourced sample is advisory and can NEVER create work
15
+ * (enforced in goals/admission.mjs). This is the Goodhart gate.
16
+ * 2. **An unreachable sensor is a measurement, not an exception.** It returns
17
+ * `{ok:false, reason}` and is logged; it never throws into the daemon and
18
+ * it never quietly records a zero.
19
+ * 3. **Idempotent on `(objectiveKey, window)`.** Re-running the steward inside
20
+ * the same week does not double-count; the same window is overwritten only
21
+ * when a better source arrives (`llm` → `human` → `method`).
22
+ *
23
+ * Injectable everywhere: the clock, the sensor registry, and the ledger path all
24
+ * arrive on `deps`, so tests are hermetic and the network is never touched.
25
+ *
26
+ * @module lib/kpi
27
+ */
28
+
29
+ "use strict";
30
+
31
+ import { existsSync, readFileSync, mkdirSync } from "node:fs";
32
+ import { join, dirname } from "node:path";
33
+
34
+ import { resolveAgentRoot } from "./agent-root.mjs";
35
+ import { appendJsonl } from "./fs-atomic.mjs";
36
+
37
+ export const MEASUREMENTS_REL = join("state", "kpi", "measurements.jsonl");
38
+ export const INTERVENTIONS_REL = join("state", "kpi", "interventions.jsonl");
39
+
40
+ /** Sample sources, weakest → strongest. A stronger source may overwrite a window. */
41
+ export const SOURCE_RANK = Object.freeze({ llm: 0, human: 1, method: 2 });
42
+
43
+ /** Sources a sample must carry to be allowed to CREATE work. */
44
+ export const WORK_CREATING_SOURCES = Object.freeze(["method", "human"]);
45
+
46
+ /** How many periods of no movement counts as "flat". */
47
+ export const FLAT_PERIODS = 2;
48
+
49
+ function nowMs(deps) {
50
+ return deps && typeof deps.now === "function" ? deps.now() : Date.now();
51
+ }
52
+
53
+ function logOf(deps) {
54
+ return deps && typeof deps.log === "function" ? deps.log : () => {};
55
+ }
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // Windows
59
+ // ---------------------------------------------------------------------------
60
+
61
+ /** ISO-8601 week number + ISO week-year for a date. Pure. */
62
+ export function isoWeek(date) {
63
+ const d = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
64
+ // Thursday of the current week determines the ISO year.
65
+ const dayNum = (d.getUTCDay() + 6) % 7;
66
+ d.setUTCDate(d.getUTCDate() - dayNum + 3);
67
+ const isoYear = d.getUTCFullYear();
68
+ const firstThursday = new Date(Date.UTC(isoYear, 0, 4));
69
+ const firstDayNum = (firstThursday.getUTCDay() + 6) % 7;
70
+ firstThursday.setUTCDate(firstThursday.getUTCDate() - firstDayNum + 3);
71
+ const week = 1 + Math.round((d - firstThursday) / (7 * 24 * 3600 * 1000));
72
+ return { isoYear, week };
73
+ }
74
+
75
+ /**
76
+ * The measurement window key for a cadence at a point in time. This is the
77
+ * idempotency key half of `(objectiveKey, window)`.
78
+ * weekly → "2026-W33"
79
+ * monthly → "2026-08"
80
+ * quarterly → "2026-Q3"
81
+ * daily/any → "2026-08-11"
82
+ * @param {string} cadence @param {number|Date} at @returns {string}
83
+ */
84
+ export function windowFor(cadence, at) {
85
+ const d = at instanceof Date ? at : new Date(at || Date.now());
86
+ const y = d.getUTCFullYear();
87
+ switch (String(cadence || "").toLowerCase()) {
88
+ case "weekly": {
89
+ const { isoYear, week } = isoWeek(d);
90
+ return `${isoYear}-W${String(week).padStart(2, "0")}`;
91
+ }
92
+ case "monthly":
93
+ return `${y}-${String(d.getUTCMonth() + 1).padStart(2, "0")}`;
94
+ case "quarterly":
95
+ return `${y}-Q${Math.floor(d.getUTCMonth() / 3) + 1}`;
96
+ default:
97
+ return d.toISOString().slice(0, 10);
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Is this objective due to be measured? True when its current window has no
103
+ * sample yet. An objective with no cadence is never due (and is warned about by
104
+ * `model.validateMandateBody`) — silence there would be the bug.
105
+ * @param {object} objective @param {object[]} series @param {object} [deps] { now }
106
+ * @returns {{due:boolean, window:string|null, reason:string}}
107
+ */
108
+ export function isDue(objective, series, deps) {
109
+ if (!objective || !objective.cadence) {
110
+ return { due: false, window: null, reason: "objective has no measure cadence" };
111
+ }
112
+ const window = windowFor(objective.cadence, nowMs(deps));
113
+ const hit = (series || []).find((s) => s && s.window === window);
114
+ if (hit) return { due: false, window, reason: `already sampled for ${window}` };
115
+ return { due: true, window, reason: `no sample for ${window}` };
116
+ }
117
+
118
+ // ---------------------------------------------------------------------------
119
+ // The ledger
120
+ // ---------------------------------------------------------------------------
121
+
122
+ /** Absolute path of the measurement ledger. */
123
+ export function measurementsPath(agentRoot) {
124
+ return join(resolveAgentRoot(agentRoot), MEASUREMENTS_REL);
125
+ }
126
+
127
+ /** Absolute path of the intervention (learning-signal) ledger. */
128
+ export function interventionsPath(agentRoot) {
129
+ return join(resolveAgentRoot(agentRoot), INTERVENTIONS_REL);
130
+ }
131
+
132
+ function readJsonl(path) {
133
+ if (!existsSync(path)) return [];
134
+ let body;
135
+ try { body = readFileSync(path, "utf-8"); } catch { return []; }
136
+ const out = [];
137
+ for (const line of body.split("\n")) {
138
+ if (!line.trim()) continue;
139
+ try { out.push(JSON.parse(line)); } catch { /* skip malformed */ }
140
+ }
141
+ return out;
142
+ }
143
+
144
+ /**
145
+ * Every sample for an objective, oldest first. When two rows share a window the
146
+ * strongest source wins (a `method` re-measure supersedes an `llm` guess).
147
+ * @param {string} agentRoot @param {string} objectiveKey
148
+ * @returns {object[]}
149
+ */
150
+ export function readSeries(agentRoot, objectiveKey) {
151
+ const rows = readJsonl(measurementsPath(agentRoot)).filter(
152
+ (r) => r && (!objectiveKey || r.objectiveKey === objectiveKey)
153
+ );
154
+ const byWindow = new Map();
155
+ for (const r of rows) {
156
+ const prev = byWindow.get(r.window);
157
+ if (!prev) { byWindow.set(r.window, r); continue; }
158
+ const better =
159
+ (SOURCE_RANK[r.source] ?? -1) > (SOURCE_RANK[prev.source] ?? -1) ||
160
+ ((SOURCE_RANK[r.source] ?? -1) === (SOURCE_RANK[prev.source] ?? -1) &&
161
+ String(r.at || "") >= String(prev.at || ""));
162
+ if (better) byWindow.set(r.window, r);
163
+ }
164
+ return [...byWindow.values()].sort((a, b) => String(a.window).localeCompare(String(b.window)));
165
+ }
166
+
167
+ /**
168
+ * Append a sample. Idempotent on `(objectiveKey, window)`: a duplicate of equal
169
+ * or weaker source is refused (and reported — never silently swallowed).
170
+ *
171
+ * @param {string} agentRoot
172
+ * @param {{objectiveKey:string, value:number, window:string, source:string, evidence?:object}} sample
173
+ * @param {object} [deps] { now, log }
174
+ * @returns {{recorded:boolean, reason:string, sample:object|null}}
175
+ */
176
+ export function recordMeasurement(agentRoot, sample, deps = {}) {
177
+ const key = sample && sample.objectiveKey;
178
+ const value = Number(sample && sample.value);
179
+ if (!key) return { recorded: false, reason: "no objectiveKey", sample: null };
180
+ if (!Number.isFinite(value)) {
181
+ logOf(deps)("warn", `[kpi] refusing a non-numeric sample for "${key}" (value=${sample && sample.value})`);
182
+ return { recorded: false, reason: "non-numeric value", sample: null };
183
+ }
184
+ const source = String((sample && sample.source) || "llm");
185
+ if (!(source in SOURCE_RANK)) {
186
+ logOf(deps)("warn", `[kpi] unknown sample source "${source}" for "${key}" — treated as llm (advisory only)`);
187
+ }
188
+ const window = String((sample && sample.window) || windowFor("daily", nowMs(deps)));
189
+
190
+ const existing = readSeries(agentRoot, key).find((s) => s.window === window);
191
+ if (existing && (SOURCE_RANK[existing.source] ?? -1) >= (SOURCE_RANK[source] ?? -1)) {
192
+ logOf(deps)(
193
+ "info",
194
+ `[kpi] "${key}" already has a ${existing.source} sample for ${window} — refusing a ${source} duplicate (idempotent)`
195
+ );
196
+ return { recorded: false, reason: "duplicate-window", sample: existing };
197
+ }
198
+
199
+ const rec = {
200
+ objectiveKey: key,
201
+ objectiveId: (sample && sample.objectiveId) || null,
202
+ value,
203
+ window,
204
+ at: new Date(nowMs(deps)).toISOString(),
205
+ source: source in SOURCE_RANK ? source : "llm",
206
+ evidence: (sample && sample.evidence) || null,
207
+ };
208
+ try {
209
+ const p = measurementsPath(agentRoot);
210
+ mkdirSync(dirname(p), { recursive: true });
211
+ appendJsonl(p, rec);
212
+ } catch (err) {
213
+ logOf(deps)("error", `[kpi] could not append a measurement for "${key}": ${err && err.message ? err.message : err}`);
214
+ return { recorded: false, reason: "append-failed", sample: null };
215
+ }
216
+ return { recorded: true, reason: existing ? "superseded-weaker-source" : "recorded", sample: rec };
217
+ }
218
+
219
+ /**
220
+ * Record the realized delta of an intervention — the learning signal. Step 4 of
221
+ * the loop feeds the last three of these back into the planner so an
222
+ * intervention that made a number WORSE is down-weighted next time.
223
+ * @param {string} agentRoot @param {object} rec @param {object} [deps]
224
+ * @returns {{recorded:boolean, record:object|null}}
225
+ */
226
+ export function recordIntervention(agentRoot, rec, deps = {}) {
227
+ const row = {
228
+ at: new Date(nowMs(deps)).toISOString(),
229
+ objectiveKey: (rec && rec.objectiveKey) || null,
230
+ taskId: (rec && rec.taskId) || null,
231
+ obligationKey: (rec && rec.obligationKey) || null,
232
+ expectedDelta: Number.isFinite(Number(rec && rec.expectedDelta)) ? Number(rec.expectedDelta) : null,
233
+ realizedDelta: Number.isFinite(Number(rec && rec.realizedDelta)) ? Number(rec.realizedDelta) : null,
234
+ measuredAt: (rec && rec.measuredAt) || null,
235
+ };
236
+ try {
237
+ const p = interventionsPath(agentRoot);
238
+ mkdirSync(dirname(p), { recursive: true });
239
+ appendJsonl(p, row);
240
+ } catch (err) {
241
+ logOf(deps)("error", `[kpi] could not append an intervention: ${err && err.message ? err.message : err}`);
242
+ return { recorded: false, record: null };
243
+ }
244
+ return { recorded: true, record: row };
245
+ }
246
+
247
+ /**
248
+ * The last N interventions against an objective, newest last. Fed to the
249
+ * planning sub-session so it can see what it already tried.
250
+ */
251
+ export function readInterventions(agentRoot, objectiveKey, limit = 3) {
252
+ const rows = readJsonl(interventionsPath(agentRoot)).filter(
253
+ (r) => r && (!objectiveKey || r.objectiveKey === objectiveKey)
254
+ );
255
+ return limit > 0 ? rows.slice(-limit) : rows;
256
+ }
257
+
258
+ // ---------------------------------------------------------------------------
259
+ // Measurement
260
+ // ---------------------------------------------------------------------------
261
+
262
+ /**
263
+ * Execute an objective's sensor.
264
+ *
265
+ * `deps.sensors` is a registry `{ [capabilityId]: async ({params, objective}) =>
266
+ * number | {value, rowCount?, …} }`. `deps.reachable` is the set/array of
267
+ * capability ids the manifest says are actually reachable — an entry that is
268
+ * NOT reachable may never be cited (SPEC §5.1), so it fails here rather than
269
+ * producing a fabricated number.
270
+ *
271
+ * Never throws. Every failure path returns `{ok:false, reason}` AND logs.
272
+ *
273
+ * @param {object} objective
274
+ * @param {object} deps - { sensors, reachable?, now, log }
275
+ * @returns {Promise<{ok:boolean, value:number|null, source:string, evidence:object|null, reason:string}>}
276
+ */
277
+ export async function measureKpi(objective, deps = {}) {
278
+ const key = objective && objective.key;
279
+ const sensor = objective && objective.sensor;
280
+ if (!sensor || !sensor.capability) {
281
+ const reason = "no-sensor";
282
+ logOf(deps)("info", `[kpi] "${key}" has no method sensor (source=${(sensor && sensor.source) || "none"}) — measurement is a human obligation`);
283
+ return { ok: false, value: null, source: (sensor && sensor.source) || "human", evidence: null, reason };
284
+ }
285
+
286
+ const reachable = deps.reachable instanceof Set ? deps.reachable : new Set(deps.reachable || []);
287
+ if (deps.reachable !== undefined && !reachable.has(sensor.capability)) {
288
+ logOf(deps)(
289
+ "warn",
290
+ `[kpi] "${key}" cites capability "${sensor.capability}" which is NOT reachable in the manifest — refusing to measure (this is a drift, not a zero)`
291
+ );
292
+ return { ok: false, value: null, source: "method", evidence: null, reason: "unreachable-capability" };
293
+ }
294
+
295
+ const fn = deps.sensors && deps.sensors[sensor.capability];
296
+ if (typeof fn !== "function") {
297
+ logOf(deps)("warn", `[kpi] no sensor implementation registered for "${sensor.capability}" (objective "${key}")`);
298
+ return { ok: false, value: null, source: "method", evidence: null, reason: "no-implementation" };
299
+ }
300
+
301
+ let raw;
302
+ try {
303
+ raw = await fn({ params: sensor.params || {}, objective });
304
+ } catch (err) {
305
+ logOf(deps)("error", `[kpi] sensor "${sensor.capability}" threw for "${key}": ${err && err.message ? err.message : err}`);
306
+ return { ok: false, value: null, source: "method", evidence: null, reason: "sensor-threw" };
307
+ }
308
+
309
+ const value = Number(raw && typeof raw === "object" ? raw.value : raw);
310
+ if (!Number.isFinite(value)) {
311
+ logOf(deps)("warn", `[kpi] sensor "${sensor.capability}" returned a non-numeric result for "${key}"`);
312
+ return { ok: false, value: null, source: "method", evidence: null, reason: "non-numeric" };
313
+ }
314
+
315
+ return {
316
+ ok: true,
317
+ value,
318
+ source: "method",
319
+ evidence: {
320
+ method: sensor.capability,
321
+ params: sensor.params || {},
322
+ rowCount: raw && typeof raw === "object" && Number.isFinite(Number(raw.rowCount)) ? Number(raw.rowCount) : null,
323
+ sessionId: (raw && typeof raw === "object" && raw.sessionId) || null,
324
+ },
325
+ reason: "measured",
326
+ };
327
+ }
328
+
329
+ // ---------------------------------------------------------------------------
330
+ // The gap
331
+ // ---------------------------------------------------------------------------
332
+
333
+ /** Signed shortfall for a direction. Positive = "we are short of target". */
334
+ function rawGap(direction, value, target, tolerance) {
335
+ switch (String(direction || "up")) {
336
+ case "down":
337
+ return value - target;
338
+ case "hold":
339
+ case "band":
340
+ return Math.max(0, Math.abs(value - target) - Math.max(0, tolerance || 0));
341
+ case "up":
342
+ default:
343
+ return target - value;
344
+ }
345
+ }
346
+
347
+ /**
348
+ * Compute the gap for an objective from its series.
349
+ *
350
+ * @param {object} objective
351
+ * @param {object[]} series - readSeries() output, oldest first
352
+ * @param {object} [deps] { now }
353
+ * @returns {{
354
+ * objectiveKey:string, value:number|null, target:number|null, gap:number|null,
355
+ * normalizedGap:number, withinTolerance:boolean, trend:'improving'|'flat'|'worsening'|'unknown',
356
+ * flatPeriods:number, source:string|null, window:string|null,
357
+ * stalenessPeriods:number|null, stale:boolean, reason:string
358
+ * }}
359
+ */
360
+ export function gapFor(objective, series, deps = {}) {
361
+ const key = (objective && objective.key) || null;
362
+ // `Number(null)` is 0 — an absent target must NOT read as a target of zero.
363
+ const rawTarget = objective ? objective.target : null;
364
+ const target = rawTarget == null || rawTarget === "" ? NaN : Number(rawTarget);
365
+ const tolerance = Number.isFinite(Number(objective && objective.tolerance)) ? Number(objective.tolerance) : 0;
366
+ const direction = (objective && objective.direction) || "up";
367
+ const rows = (series || []).filter((s) => s && Number.isFinite(Number(s.value)));
368
+ const latest = rows[rows.length - 1] || null;
369
+
370
+ const base = {
371
+ objectiveKey: key,
372
+ value: latest ? Number(latest.value) : null,
373
+ target: Number.isFinite(target) ? target : null,
374
+ gap: null,
375
+ normalizedGap: 0,
376
+ withinTolerance: false,
377
+ trend: "unknown",
378
+ flatPeriods: 0,
379
+ source: latest ? latest.source : null,
380
+ window: latest ? latest.window : null,
381
+ stalenessPeriods: null,
382
+ stale: false,
383
+ reason: "",
384
+ };
385
+
386
+ if (!latest) return { ...base, reason: "no samples" };
387
+ if (!Number.isFinite(target)) return { ...base, reason: "objective has no numeric target" };
388
+
389
+ const value = Number(latest.value);
390
+ const gap = rawGap(direction, value, target, tolerance);
391
+ const denom = Math.max(Math.abs(target), 1e-9);
392
+ const normalizedGap = Math.max(0, gap) / denom;
393
+
394
+ // Trend over the gap, not the raw value, so `direction` is honoured.
395
+ const gaps = rows.slice(-(FLAT_PERIODS + 1)).map((r) => rawGap(direction, Number(r.value), target, tolerance));
396
+ let trend = "unknown";
397
+ let flatPeriods = 0;
398
+ if (gaps.length >= 2) {
399
+ const eps = Math.max(Math.abs(target) * 0.01, 1e-9);
400
+ let improving = 0;
401
+ let worsening = 0;
402
+ for (let i = 1; i < gaps.length; i++) {
403
+ const delta = gaps[i] - gaps[i - 1];
404
+ if (delta < -eps) improving += 1;
405
+ else if (delta > eps) worsening += 1;
406
+ else flatPeriods += 1;
407
+ }
408
+ if (improving > worsening) trend = "improving";
409
+ else if (worsening > improving) trend = "worsening";
410
+ else trend = "flat";
411
+ }
412
+
413
+ // Staleness: how many cadence periods since the latest sample.
414
+ let stalenessPeriods = null;
415
+ let stale = false;
416
+ if (objective && objective.cadence && latest.at) {
417
+ const per = periodSeconds(objective.cadence);
418
+ const age = Math.max(0, (nowMs(deps) - Date.parse(latest.at)) / 1000);
419
+ if (Number.isFinite(age) && per > 0) {
420
+ stalenessPeriods = age / per;
421
+ stale = stalenessPeriods >= 2; // 2× cadence with no sample is itself a gap
422
+ }
423
+ }
424
+
425
+ return {
426
+ ...base,
427
+ value,
428
+ target,
429
+ gap: +gap.toFixed(6),
430
+ normalizedGap: +normalizedGap.toFixed(6),
431
+ withinTolerance: gap <= tolerance,
432
+ trend,
433
+ flatPeriods,
434
+ stalenessPeriods,
435
+ stale,
436
+ reason: gap <= tolerance ? "within tolerance" : "gap exceeds tolerance",
437
+ };
438
+ }
439
+
440
+ /** Seconds in one cadence period. */
441
+ export function periodSeconds(cadence) {
442
+ switch (String(cadence || "").toLowerCase()) {
443
+ case "weekly": return 7 * 24 * 3600;
444
+ case "monthly": return 30 * 24 * 3600;
445
+ case "quarterly": return 91 * 24 * 3600;
446
+ case "daily": return 24 * 3600;
447
+ default: return 24 * 3600;
448
+ }
449
+ }
450
+
451
+ export default {
452
+ MEASUREMENTS_REL,
453
+ INTERVENTIONS_REL,
454
+ SOURCE_RANK,
455
+ WORK_CREATING_SOURCES,
456
+ FLAT_PERIODS,
457
+ isoWeek,
458
+ windowFor,
459
+ isDue,
460
+ measurementsPath,
461
+ interventionsPath,
462
+ readSeries,
463
+ recordMeasurement,
464
+ recordIntervention,
465
+ readInterventions,
466
+ measureKpi,
467
+ gapFor,
468
+ periodSeconds,
469
+ };