@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
@@ -19,6 +19,7 @@
19
19
  * - check-no-residual-identity: de-Robin'd paths stay persona-agnostic
20
20
  * - check-no-build-artifacts : no tracked .pyc/symlinks reach the tarball
21
21
  * - check-docs-accuracy : README links resolve; counts/agent-refs WARN
22
+ * - check-subagent-frontmatter: every agents/*.md passes the registry validator
22
23
  *
23
24
  * Usage: `node scripts/ci/check.mjs`
24
25
  * exit 0 → all checks passed; exit 1 → one or more failed.
@@ -36,6 +37,7 @@ import { run as runUnresolvedTokens } from "./check-unresolved-tokens.mjs";
36
37
  import { run as runNoResidualIdentity } from "./check-no-residual-identity.mjs";
37
38
  import { run as runNoBuildArtifacts } from "./check-no-build-artifacts.mjs";
38
39
  import { run as runDocsAccuracy } from "./check-docs-accuracy.mjs";
40
+ import { run as runSubagentFrontmatter } from "./check-subagent-frontmatter.mjs";
39
41
 
40
42
  /**
41
43
  * The ordered list of guards this aggregator runs.
@@ -51,6 +53,7 @@ export const CHECKS = [
51
53
  { name: "check-no-residual-identity", run: runNoResidualIdentity },
52
54
  { name: "check-no-build-artifacts", run: runNoBuildArtifacts },
53
55
  { name: "check-docs-accuracy", run: runDocsAccuracy },
56
+ { name: "check-subagent-frontmatter", run: runSubagentFrontmatter },
54
57
  ];
55
58
 
56
59
  /**
@@ -441,6 +441,22 @@ export function buildProbes() {
441
441
  guard: "ghost",
442
442
  why: "the task target 404s before the Escalation row",
443
443
  },
444
+ {
445
+ // The OpenQuestion sibling. Two DISTINCT options are mandatory server-side
446
+ // (one option is a notification, none is prose), and `context` is a STRING
447
+ // — the exact shape the contract row asserts, so the probe tests it rather
448
+ // than restating it. Ghost-guarded on the task target like its sibling.
449
+ method: "escalation.ask",
450
+ params: {
451
+ question: "conformance probe — which option?",
452
+ options: ["probe-option-a", "probe-option-b"],
453
+ context: "raised by the org-api conformance probe",
454
+ taskId: ghostTask,
455
+ },
456
+ writes: false,
457
+ guard: "ghost",
458
+ why: "the task target 404s before any OpenQuestion row is created",
459
+ },
444
460
  {
445
461
  method: "knowledge.invalidate",
446
462
  params: { id: ghostId("epi"), reason: "conformance probe" },
@@ -0,0 +1,341 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * scripts/ci/journey-approval-escalation.mjs — ONE JOURNEY, END TO END, LIVE.
4
+ *
5
+ * ── WHY THIS EXISTS ──
6
+ * Every layer of the inbound → execution stack has unit tests and every one of
7
+ * them passes with fakes on both sides of the joint. That proves the layers, not
8
+ * the seams. This script drives ONE real journey against a REAL org and prints
9
+ * what happened at every hop, so "the units connect" is a thing you can read
10
+ * rather than a thing you assert:
11
+ *
12
+ * hop 1 hq approval.request → an Approval row
13
+ * hop 2 hq ctx.appendEvent → an `approval.requested` frame
14
+ * hop 3 SDK pullWideInbound → the frame off /v1/events
15
+ * hop 4 SDK directedness.resolveDirected → "mine, because <reason>"
16
+ * hop 5 SDK match.matchObligation → the plan obligation it binds to
17
+ * hop 6 SDK route.routeRung → rung + mechanism + gates
18
+ * hop 7 SDK drive → effects.escalate → escalation.ask (an OpenQuestion)
19
+ * hop 8 SDK journal.recordOutcome → state/execution/journal.jsonl
20
+ * hop 9 SDK kpi-sensors → is this lane measurable at all?
21
+ *
22
+ * ── WHAT IS REAL AND WHAT IS NOT ──
23
+ * REAL: the org, the credentials, the `/v1/events` ledger page, the board read,
24
+ * the channel roster, the directedness resolver, the whole execution ladder, the
25
+ * journal write, and the exact JSON body the escalation write would put on the
26
+ * wire.
27
+ *
28
+ * NOT REAL, and deliberately so: the Approval row itself. Creating one means
29
+ * writing to a production org, so the row and its ledger frame are synthesised —
30
+ * the frame BYTE-FOR-BYTE as `hq src/server/methods/approval/request.ts`
31
+ * appends it, the row byte-for-byte as `hq methods/approval/_shared.ts
32
+ * #projectApproval` returns it. Everything downstream of the frame is the
33
+ * shipping code path with nothing stubbed.
34
+ *
35
+ * EVERY SIDE-EFFECTING POST IS BLOCKED. The guard consults the vendored protocol
36
+ * table and refuses anything `sideEffecting:true`, printing the body instead. So
37
+ * this script can be run against production without writing to it — which is the
38
+ * only way it is useful, because a journey you can only run against an empty
39
+ * fixture org is a journey you have not tested.
40
+ *
41
+ * ── USAGE ──
42
+ * AGENT_DIR=/path/to/agent \
43
+ * COHORT_BASE=… COHORT_ORG_ID=… COHORT_AGENT_ID=… COHORT_API_KEY=… \
44
+ * node scripts/ci/journey-approval-escalation.mjs
45
+ *
46
+ * --allow-writes actually send the escalation write (NOT the default)
47
+ * --json machine-readable hop report on stdout
48
+ *
49
+ * Exit 0 when every hop moved; exit 1 naming the first hop that did not.
50
+ */
51
+
52
+ "use strict";
53
+
54
+ import { readFileSync, existsSync } from "node:fs";
55
+ import { join } from "node:path";
56
+
57
+ import { METHODS } from "../../lib/org/protocol.mjs";
58
+ import { loadOrgConfig } from "../../lib/org/client.mjs";
59
+
60
+ const ARGV = new Set(process.argv.slice(2));
61
+ const ALLOW_WRITES = ARGV.has("--allow-writes");
62
+ const AS_JSON = ARGV.has("--json");
63
+
64
+ const AGENT_DIR = process.env.AGENT_DIR || process.env.AGENT_ROOT || process.cwd();
65
+ const BASE = process.env.COHORT_BASE || "";
66
+ const KEY = process.env.COHORT_API_KEY || process.env.COHORT_API_TOKEN || "";
67
+ const ME = process.env.COHORT_AGENT_ID || "";
68
+
69
+ const hops = [];
70
+ const writes = [];
71
+
72
+ function hop(n, name, ok, detail) {
73
+ hops.push({ n, name, ok, detail });
74
+ const mark = ok ? "✓" : "✗";
75
+ if (!AS_JSON) console.log(`\n${mark} HOP ${n} ${name}`);
76
+ if (!AS_JSON && detail) console.log(String(detail).replace(/^/gm, " "));
77
+ return ok;
78
+ }
79
+
80
+ function die(n, name, why) {
81
+ hop(n, name, false, why);
82
+ report();
83
+ process.exit(1);
84
+ }
85
+
86
+ function report() {
87
+ if (AS_JSON) {
88
+ console.log(JSON.stringify({ hops, writes }, null, 2));
89
+ return;
90
+ }
91
+ console.log("\n──────────────────────────────────────────────────────────────");
92
+ for (const h of hops) console.log(` ${h.ok ? "✓" : "✗"} hop ${h.n} ${h.name}`);
93
+ console.log("──────────────────────────────────────────────────────────────");
94
+ }
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // the fetch guard: live reads, blocked writes, one synthesised row
98
+ // ---------------------------------------------------------------------------
99
+
100
+ /**
101
+ * Wrap `globalThis.fetch` so that:
102
+ * - `GET /v1/events` is served live and the synthetic frame is appended;
103
+ * - `approval.get` for the synthetic id returns the synthetic row;
104
+ * - any POST to a `sideEffecting` protocol method is RECORDED, not sent.
105
+ *
106
+ * @param {{seq:number, approvalId:string, frame:object, row:object}} synth
107
+ */
108
+ function installGuard(synth) {
109
+ const real = globalThis.fetch;
110
+ const json = (o) => new Response(JSON.stringify(o), { status: 200, headers: { "content-type": "application/json" } });
111
+
112
+ globalThis.fetch = async (input, init = {}) => {
113
+ const url = String(typeof input === "string" ? input : (input && input.url) || input);
114
+ const method = String(init.method || "GET").toUpperCase();
115
+ const m = url.match(/\/api\/v1\/([^?]+)/);
116
+ const rpc = m ? m[1] : "";
117
+
118
+ if (rpc === "approval.get") {
119
+ const body = init.body ? JSON.parse(init.body) : {};
120
+ if (String(body.id) === synth.approvalId) return json({ ok: true, result: synth.row });
121
+ }
122
+
123
+ if (rpc === "events" && method === "GET") {
124
+ const r = await real(input, init);
125
+ const j = await r.json();
126
+ const events = Array.isArray(j.events) ? j.events : [];
127
+ events.push(synth.frame);
128
+ return json({ events });
129
+ }
130
+
131
+ const d = METHODS[rpc];
132
+ if (method === "POST" && d && d.sideEffecting && !ALLOW_WRITES) {
133
+ const params = init.body ? JSON.parse(init.body) : {};
134
+ writes.push({ method: rpc, params });
135
+ return json({ ok: true, result: { id: `blocked_${writes.length}`, __blocked: true } });
136
+ }
137
+
138
+ if (method === "POST" && d && d.sideEffecting) writes.push({ method: rpc, params: init.body ? JSON.parse(init.body) : {} });
139
+ return real(input, init);
140
+ };
141
+ }
142
+
143
+ // ---------------------------------------------------------------------------
144
+
145
+ async function main() {
146
+ if (!BASE || !KEY || !ME) {
147
+ console.error("set COHORT_BASE, COHORT_API_KEY and COHORT_AGENT_ID");
148
+ process.exit(2);
149
+ }
150
+
151
+ // agent cfg exactly as `cadence-handlers.loadAgentCfg` assembles it
152
+ let cfg = {};
153
+ try {
154
+ const p = join(AGENT_DIR, "config", "agent.json");
155
+ if (existsSync(p)) cfg = JSON.parse(readFileSync(p, "utf8"));
156
+ } catch { /* fail-open */ }
157
+ const orgCfg = loadOrgConfig(AGENT_DIR);
158
+ if (orgCfg && orgCfg.org) cfg.org = orgCfg.org;
159
+ if (!cfg.org || !cfg.org.cohort) die(0, "agent config", "no `org.cohort` in config/org.yaml — nothing to talk to");
160
+
161
+ const auth = { authorization: `Bearer ${KEY}` };
162
+
163
+ // ── the ledger head, so the synthetic frame is the ONLY new row ──────────
164
+ let head = 0;
165
+ {
166
+ let c = 0;
167
+ for (let page = 0; page < 200; page += 1) {
168
+ const r = await fetch(`${BASE}/api/v1/events?cursor=${c}&limit=500`, { headers: auth });
169
+ const j = await r.json();
170
+ const e = Array.isArray(j.events) ? j.events : [];
171
+ if (!e.length) break;
172
+ c = e[e.length - 1].seq;
173
+ if (e.length < 500) break;
174
+ }
175
+ head = c;
176
+ }
177
+
178
+ // ── a REAL board item this seat owns or reviews: the thing the approval blocks
179
+ const { collectTasks } = await import("../../lib/org/inbound/facts.mjs");
180
+ const board = await (await fetch(`${BASE}/api/v1/board.context`, { headers: auth })).json();
181
+ const tasks = collectTasks(board);
182
+ const mine = tasks.filter(
183
+ (t) => String(t.assigneeId || t.assignee || "") === ME || String(t.reviewerId || t.reviewer || "") === ME,
184
+ );
185
+ if (!mine.length) die(0, "fixture", `no live board item names ${ME} as assignee or reviewer — nothing to hang an approval on`);
186
+ const task = mine.find((t) => String(t.reviewerId || "") === ME) || mine[0];
187
+ const requester = String(task.assigneeId || task.assignee || "") || "unknown";
188
+
189
+ // ── hop 1 + 2: the hq write and its chain append, synthesised to hq's shape
190
+ const approvalId = `aprv_journey_${Date.now().toString(36)}`;
191
+ const payloadHash = "0".repeat(64);
192
+ const row = {
193
+ id: approvalId,
194
+ requester,
195
+ approver: null, // hq: NULL until resolved (G5)
196
+ actionClass: "exec",
197
+ payloadHash,
198
+ status: "pending",
199
+ itemId: task.id,
200
+ reason: null,
201
+ subject: { note: "journey harness — synthetic, never written to the org" },
202
+ createdAt: new Date().toISOString(),
203
+ expiresAt: null,
204
+ decidedAt: null,
205
+ };
206
+ const frame = {
207
+ seq: head + 1,
208
+ family: "approval",
209
+ kind: "approval.requested",
210
+ entity_id: approvalId,
211
+ actor: requester,
212
+ at: new Date().toISOString(),
213
+ payload: { requester, actionClass: "exec", payloadHash, itemId: task.id },
214
+ prev_hash: "",
215
+ row_hash: "",
216
+ };
217
+ hop(1, "hq approval.request → Approval row", true,
218
+ `hq src/server/methods/approval/request.ts\n` +
219
+ `approval ${approvalId} blocks task ${task.id} (${task.title})\nrequester ${requester}, approver NULL (G5), status pending`);
220
+ hop(2, "hq ctx.appendEvent → approval.requested", true,
221
+ `family=approval kind=approval.requested seq=${frame.seq}\n${JSON.stringify(frame.payload)}`);
222
+
223
+ installGuard({ seq: frame.seq, approvalId, frame, row });
224
+
225
+ // ── hop 3: delivery ──────────────────────────────────────────────────────
226
+ const { pullWideInbound } = await import("../../lib/org/inbound/index.mjs");
227
+ const seen = [];
228
+ const log = (lvl, msg) => { seen.push(`[${lvl}] ${msg}`); };
229
+ const pulled = await pullWideInbound({ cfg, agentId: ME, cursor: head, log });
230
+ const ok3 = pulled.events.length > 0;
231
+ hop(3, "SDK pullWideInbound → the frame off /v1/events", ok3,
232
+ `scanned=${pulled.stats.scanned} classified=${pulled.stats.classified} directed=${pulled.stats.directed} delivered=${pulled.stats.delivered}\n` +
233
+ `dropped=${JSON.stringify(pulled.stats.dropped)}\ndegraded=${JSON.stringify(pulled.stats.degraded)}\n` +
234
+ seen.join("\n"));
235
+ if (!ok3) die(3, "SDK pullWideInbound", `nothing was delivered — dropped: ${JSON.stringify(pulled.stats.dropped)}`);
236
+
237
+ let roundTrip = "not attempted";
238
+ const ev = pulled.events.find((e) => e.cohort && e.cohort.surface === "approval") || pulled.events[0];
239
+
240
+ // ── hop 4: directedness ─────────────────────────────────────────────────
241
+ hop(4, "directedness → mine, and why", !!(ev.cohort && ev.cohort.reason),
242
+ `surface=${ev.cohort.surface} reason=${ev.cohort.reason} family=${ev.cohort.family} kind=${ev.cohort.event_kind}\n` +
243
+ `subject: ${ev.subject}\ntext: ${ev.text}`);
244
+
245
+ // ── hops 5-8: the execution ladder ──────────────────────────────────────
246
+ process.env.MAESTRO_DAEMON_NO_AUTOSTART = "1";
247
+ const { eventToInboxItem } = await import("../../lib/channels/inbox-item.mjs");
248
+ const daemon = await import("../daemon/agent-daemon.mjs");
249
+
250
+ // THE REAL POLL LANE, not a shortcut past it. `guardMessagingInbound` writes
251
+ // the flattened item to `state/inbox/cohort/*.yaml` and the daemon's scan
252
+ // poller reads it back; anything the writer does not enumerate dies in
253
+ // between. Going straight from MessageEvent to the ladder would hide exactly
254
+ // that class of bug — which is how the chain kind, the directedness reason and
255
+ // the subject id all came to be dropped at this boundary.
256
+ const flat = eventToInboxItem(ev);
257
+ const { writeInboxItem } = await import("../poller/utils.mjs");
258
+ const { parseInboxItemYaml } = await import("../poller/inbox-scan-poller.mjs");
259
+ let item = flat;
260
+ try {
261
+ writeInboxItem("cohort", flat, AGENT_DIR);
262
+ const dir = join(AGENT_DIR, "state", "inbox", "cohort");
263
+ const { readdirSync } = await import("node:fs");
264
+ const file = readdirSync(dir).filter((f) => f.includes(String(flat.id))).pop();
265
+ if (file && typeof parseInboxItemYaml === "function") {
266
+ item = parseInboxItemYaml(readFileSync(join(dir, file), "utf8"));
267
+ roundTrip = `wrote + re-read ${file}`;
268
+ } else {
269
+ roundTrip = `WROTE but could not re-read (parseInboxItemYaml exported? ${typeof parseInboxItemYaml}) — using the in-memory item`;
270
+ }
271
+ } catch (err) {
272
+ roundTrip = `YAML round-trip skipped: ${err.message}`;
273
+ }
274
+ daemon.enrichItem(item, "cohort");
275
+ const gate = await daemon.runExecutionLadder(item, "cohort", item.raw_ref || item.id, "journey", {
276
+ answerItem: async () => ({ ok: true, path: "journey-stub" }),
277
+ });
278
+ hop(3.5, "poll-lane YAML round-trip", !/skipped|could not/.test(roundTrip), roundTrip);
279
+
280
+ const d = gate.decision;
281
+ if (!d) die(5, "execution ladder", "the ladder produced no decision at all");
282
+
283
+ hop(5, "obligation match", true,
284
+ d.obligationKey ? `bound to ${d.obligationKey}` : "UNCOVERED — no compiled REACT obligation matched (config/plan.yaml)");
285
+ hop(6, "rung decision", Number.isFinite(d.rung),
286
+ `disposition=${d.disposition} reason=${d.reason} rung=${d.rung} mechanism=${d.mechanism}\n` +
287
+ `gates=${JSON.stringify(d.gates)}\nwhy:\n` + (d.why || []).map((w) => ` - ${w}`).join("\n"));
288
+
289
+ const escalation = writes.find((w) => w.method === "escalation.ask" || w.method === "escalation.create");
290
+ hop(7, "the action taken", !!escalation,
291
+ escalation
292
+ ? `${escalation.method}\n${JSON.stringify(escalation.params, null, 2)}`
293
+ : `NO escalation write was attempted. writes=${JSON.stringify(writes.map((w) => w.method))}`);
294
+
295
+ // ── hop 8: the journal ──────────────────────────────────────────────────
296
+ const { journalPath } = await import("../../lib/execution/journal.mjs");
297
+ const jp = journalPath(AGENT_DIR);
298
+ let tail = [];
299
+ try {
300
+ tail = readFileSync(jp, "utf8").trim().split("\n").map((l) => JSON.parse(l))
301
+ .filter((r) => String(r.key || "").includes(String(frame.seq)));
302
+ } catch (err) {
303
+ hop(8, "outcome journalled", false, `${jp} unreadable: ${err.message}`);
304
+ }
305
+ hop(8, "outcome journalled", tail.length > 0,
306
+ tail.length ? `${jp}\n` + tail.map((r) => JSON.stringify(r)).join("\n")
307
+ : `no row in ${jp} carries this event's key`);
308
+
309
+ // ── hop 9: the KPI ──────────────────────────────────────────────────────
310
+ // The goal loop measures against the CAPABILITY SENSORS, so the honest check
311
+ // is whether the surface this journey exercised is reachable by one at all.
312
+ let kpiDetail = "";
313
+ let kpiOk = false;
314
+ try {
315
+ const { buildSensorRegistry, reachableCapabilities } = await import("../../lib/kpi-sensors.mjs");
316
+ const reg = buildSensorRegistry({ agentRoot: AGENT_DIR, memberId: ME });
317
+ const reach = reachableCapabilities(AGENT_DIR);
318
+ const names = Object.keys(reg || {});
319
+ const escalationSensors = names.filter((n) => /escal|approv|decision/i.test(n));
320
+ kpiOk = escalationSensors.length > 0;
321
+ kpiDetail =
322
+ `${names.length} sensor(s) registered, ${reach ? reach.size : "n/a"} reachable capabilities\n` +
323
+ (escalationSensors.length
324
+ ? `governance sensors: ${escalationSensors.join(", ")}`
325
+ : "NO sensor measures the escalation/approval/decision lane — this journey moves no KPI");
326
+ } catch (err) {
327
+ kpiDetail = `kpi-sensors unavailable: ${err.message}`;
328
+ }
329
+ hop(9, "KPI — is this lane measurable at all?", kpiOk, kpiDetail +
330
+ "\n(NOTE: this journey moved no KPI number. `escalate` is journalled, not scored — " +
331
+ "the goal loop measures obligations, and this event bound to none.)");
332
+
333
+ report();
334
+ const failed = hops.find((h) => !h.ok);
335
+ process.exit(failed ? 1 : 0);
336
+ }
337
+
338
+ main().catch((err) => {
339
+ console.error("[journey] fatal:", err && err.stack ? err.stack : err);
340
+ process.exit(1);
341
+ });