@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
@@ -0,0 +1,309 @@
1
+ /**
2
+ * client.test.mjs — the hq half of the registry (§2.1, §4). The contract under
3
+ * test is FAIL-OPEN: nothing in this module may throw, because `apply()` throws
4
+ * are fatal to the whole wizard (lib/setup/runner.mjs:220-227).
5
+ * Run: node --test lib/subagents/client.test.mjs
6
+ */
7
+ "use strict";
8
+
9
+ import { test } from "node:test";
10
+ import assert from "node:assert/strict";
11
+ import { mkdtempSync } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+
15
+ import {
16
+ TIMEOUT_MS,
17
+ createDefinition,
18
+ fetchResolve,
19
+ fetchRoster,
20
+ flushOutbox,
21
+ forkDefinition,
22
+ getDefinition,
23
+ getRead,
24
+ listDefinitions,
25
+ pinDefinition,
26
+ postRpc,
27
+ publishVersion,
28
+ resolveConfig,
29
+ unpinDefinition,
30
+ writeVerb,
31
+ yankDefinition,
32
+ } from "./client.mjs";
33
+ import { latestCached, listOutbox, queueOutbox } from "./lock.mjs";
34
+ import { hashBody, parseAgentMd } from "./schema.mjs";
35
+
36
+ const CFG = { enabled: true, base: "https://hq.example.com", token: "sk-test", orgId: "org_1", memberId: "mem_1" };
37
+ const root = () => mkdtempSync(join(tmpdir(), "subagent-client-"));
38
+
39
+ /** A recording fake fetch. `reply(url, init)` returns {ok,status,body} or throws. */
40
+ function fakeFetch(reply) {
41
+ const calls = [];
42
+ const fn = async (url, init) => {
43
+ calls.push({ url: String(url), method: init && init.method, headers: (init && init.headers) || {}, body: init && init.body ? JSON.parse(init.body) : null });
44
+ const r = typeof reply === "function" ? await reply(String(url), init, calls.length) : reply;
45
+ if (r instanceof Error) throw r;
46
+ return { ok: r.ok !== false, status: r.status || 200, json: async () => r.body };
47
+ };
48
+ fn.calls = calls;
49
+ return fn;
50
+ }
51
+ const okFrame = (result) => ({ ok: true, status: 200, body: { ok: true, result } });
52
+ const errFrame = (status, code, message) => ({ ok: false, status, body: { ok: false, error: { code, message } } });
53
+ /** A fetch that never resolves — exercises the AbortController timeout. */
54
+ const hangingFetch = () => async (_u, init) => new Promise((_res, rej) => {
55
+ if (init && init.signal) init.signal.addEventListener("abort", () => rej(Object.assign(new Error("aborted"), { name: "AbortError" })));
56
+ });
57
+
58
+ // ---------------------------------------------------------------------------
59
+ // config
60
+ // ---------------------------------------------------------------------------
61
+
62
+ test("resolveConfig: env supplies base/token/org; enabled needs BOTH base and token", () => {
63
+ const c = resolveConfig({ cfg: {}, env: { COHORT_BASE: "https://hq.example.com/", COHORT_API_KEY: "sk", COHORT_ORG_ID: "org_9", COHORT_MEMBER_ID: "mem_9" } });
64
+ assert.equal(c.base, "https://hq.example.com", "trailing slash trimmed");
65
+ assert.equal(c.token, "sk");
66
+ assert.equal(c.orgId, "org_9");
67
+ assert.equal(c.memberId, "mem_9");
68
+ assert.equal(c.enabled, true);
69
+
70
+ assert.equal(resolveConfig({ cfg: {}, env: {} }).enabled, false);
71
+ assert.equal(resolveConfig({ cfg: {}, env: { COHORT_BASE: "https://x" } }).enabled, false);
72
+ });
73
+
74
+ test("resolveConfig: not enrolled is a legitimate steady state, not an error", () => {
75
+ // §4 — layer 1 simply does not exist; the section reports status:"skipped".
76
+ const c = resolveConfig({ cfg: {}, env: {} });
77
+ assert.deepEqual(c, { enabled: false, base: "", token: "", orgId: "", memberId: "" });
78
+ });
79
+
80
+ // ---------------------------------------------------------------------------
81
+ // transport
82
+ // ---------------------------------------------------------------------------
83
+
84
+ test("postRpc: POSTs /v1/<method> with bearer, protocol and tenant-pin headers", async () => {
85
+ const f = fakeFetch(okFrame({ hello: "world" }));
86
+ const r = await postRpc("subagent.list", { a: 1 }, { cfg: CFG, fetchImpl: f, idempotencyKey: "k1" });
87
+ assert.deepEqual(r, { ok: true, result: { hello: "world" } });
88
+ const call = f.calls[0];
89
+ assert.equal(call.url, "https://hq.example.com/v1/subagent.list");
90
+ assert.equal(call.method, "POST");
91
+ assert.equal(call.headers.authorization, "Bearer sk-test");
92
+ assert.equal(call.headers["x-org-protocol"], "1");
93
+ assert.equal(call.headers["x-org-id"], "org_1");
94
+ assert.equal(call.headers["x-idempotency-key"], "k1");
95
+ assert.deepEqual(call.body, { a: 1 });
96
+ });
97
+
98
+ test("postRpc: a REJECTION (the server said no) is not degraded", async () => {
99
+ const r = await postRpc("subagent.pin", {}, { cfg: CFG, fetchImpl: fakeFetch(errFrame(403, "FORBIDDEN", "nope")) });
100
+ assert.equal(r.ok, false);
101
+ assert.equal(r.degraded, false, "the distinction that decides outbox-queue vs surface-the-error");
102
+ assert.equal(r.error.code, "FORBIDDEN");
103
+ assert.equal(r.status, 403);
104
+ });
105
+
106
+ test("postRpc: a transport failure (no answer) IS degraded, and never throws", async () => {
107
+ const r = await postRpc("subagent.pin", {}, { cfg: CFG, fetchImpl: fakeFetch(new Error("ECONNREFUSED")) });
108
+ assert.equal(r.ok, false);
109
+ assert.equal(r.degraded, true);
110
+ assert.match(r.error.message, /ECONNREFUSED/);
111
+ });
112
+
113
+ test("postRpc: a hung server aborts on the timeout and reports it as degraded", async () => {
114
+ const r = await postRpc("subagent.list", {}, { cfg: CFG, fetchImpl: hangingFetch(), timeoutMs: 20 });
115
+ assert.equal(r.degraded, true);
116
+ assert.match(r.error.message, /timeout after 20ms/);
117
+ assert.equal(TIMEOUT_MS, 5000, "5 s — a human waiting on a dead server is the worse outcome");
118
+ });
119
+
120
+ test("postRpc: unparseable / non-frame JSON is an error, not a crash", async () => {
121
+ const r = await postRpc("subagent.list", {}, { cfg: CFG, fetchImpl: fakeFetch({ ok: true, status: 200, body: "not-a-frame" }) });
122
+ assert.equal(r.ok, false);
123
+ assert.equal(r.error.code, "HTTP_200");
124
+ });
125
+
126
+ test("postRpc / getRead: no base or token → UNAUTHORIZED + degraded, with NO request made", async () => {
127
+ const f = fakeFetch(okFrame({}));
128
+ const r = await postRpc("subagent.list", {}, { cfg: { base: "", token: "" }, fetchImpl: f });
129
+ assert.equal(r.degraded, true);
130
+ assert.equal(r.error.code, "UNAUTHORIZED");
131
+ assert.equal((await getRead("subagent.roster", { cfg: {}, fetchImpl: f })).error.code, "UNAUTHORIZED");
132
+ assert.equal(f.calls.length, 0);
133
+ });
134
+
135
+ test("getRead: reads return the BARE payload on 200 (not an envelope)", async () => {
136
+ const f = fakeFetch({ ok: true, status: 200, body: { rosterVersion: 4, entries: [] } });
137
+ const r = await getRead("subagent.roster", { cfg: CFG, fetchImpl: f });
138
+ assert.deepEqual(r.payload, { rosterVersion: 4, entries: [] });
139
+ assert.equal(f.calls[0].method, "GET");
140
+ assert.equal("content-type" in f.calls[0].headers, false, "no body, no content-type");
141
+ });
142
+
143
+ // ---------------------------------------------------------------------------
144
+ // reads
145
+ // ---------------------------------------------------------------------------
146
+
147
+ test("fetchRoster: the cheap poll — rosterVersion + bodiless entries", async () => {
148
+ const f = fakeFetch({ ok: true, status: 200, body: { rosterVersion: 11, entries: [{ slug: "alpha", definitionId: "d1", version: 3, contentHash: "h" }] } });
149
+ const r = await fetchRoster({ cfg: CFG, fetchImpl: f });
150
+ assert.equal(r.ok, true);
151
+ assert.equal(r.rosterVersion, 11);
152
+ assert.equal(r.entries[0].slug, "alpha");
153
+ assert.equal(f.calls[0].url, "https://hq.example.com/v1/subagent.roster");
154
+ });
155
+
156
+ test("fetchRoster: a dead server returns an empty, degraded roster — never throws", async () => {
157
+ const r = await fetchRoster({ cfg: CFG, fetchImpl: fakeFetch(new Error("down")) });
158
+ assert.deepEqual([r.ok, r.degraded, r.rosterVersion, r.entries], [false, true, 0, []]);
159
+ });
160
+
161
+ test("fetchResolve: sends the selector params and returns bodies + advice", async () => {
162
+ const f = fakeFetch(okFrame({ rosterVersion: 5, entries: [], rewire: [{ ref: "x", suggest: "y" }], unmatched: ["z"] }));
163
+ const r = await fetchResolve({ cfg: CFG, memberId: "mem_1", function: "executive-operator", altitude: "c-suite", refs: ["ghost"], fetchImpl: f });
164
+ assert.deepEqual(f.calls[0].body, { memberId: "mem_1", function: "executive-operator", altitude: "c-suite", refs: ["ghost"] });
165
+ assert.equal(r.rosterVersion, 5);
166
+ assert.deepEqual(r.unmatched, ["z"]);
167
+ assert.equal(r.rewire[0].suggest, "y");
168
+ });
169
+
170
+ test("fetchResolve: every returned body is cached so the NEXT run needs no network", async () => {
171
+ const agentRoot = root();
172
+ const f = fakeFetch(okFrame({
173
+ rosterVersion: 5,
174
+ entries: [{ slug: "alpha", version: 3, contentHash: "h", frontmatter: { name: "alpha", description: "d", model: "sonnet", tools: ["Read"] }, body: "# alpha\n\ncached body\n" }],
175
+ }));
176
+ await fetchResolve({ cfg: CFG, agentRoot, fetchImpl: f });
177
+ const cached = latestCached(agentRoot, "alpha");
178
+ assert.equal(cached.version, 3);
179
+ // Emitted through the same serializer the resolver parses with — lossless.
180
+ const parsed = parseAgentMd(cached.text);
181
+ assert.equal(parsed.frontmatter.name, "alpha");
182
+ assert.equal(parsed.body, "# alpha\n\ncached body\n");
183
+ });
184
+
185
+ test("fetchResolve: a failure yields an empty degraded result (layer 1 just vanishes)", async () => {
186
+ const r = await fetchResolve({ cfg: CFG, fetchImpl: fakeFetch(new Error("down")) });
187
+ assert.deepEqual([r.ok, r.degraded, r.entries.length], [false, true, 0]);
188
+ });
189
+
190
+ test("listDefinitions / getDefinition: shaped results, fail-open on error", async () => {
191
+ const l = await listDefinitions({ cfg: CFG, fetchImpl: fakeFetch(okFrame({ items: [{ slug: "alpha" }] })) });
192
+ assert.deepEqual(l.items, [{ slug: "alpha" }]);
193
+ const bad = await listDefinitions({ cfg: CFG, fetchImpl: fakeFetch(new Error("x")) });
194
+ assert.deepEqual(bad.items, []);
195
+ const g = await getDefinition("alpha", { cfg: CFG, fetchImpl: fakeFetch(okFrame({ slug: "alpha" })) });
196
+ assert.deepEqual(g.definition, { slug: "alpha" });
197
+ assert.equal((await getDefinition("alpha", { cfg: CFG, fetchImpl: fakeFetch(errFrame(404, "NOT_FOUND", "x")) })).definition, null);
198
+ });
199
+
200
+ // ---------------------------------------------------------------------------
201
+ // writes + the outbox — the distinction that keeps work from being lost
202
+ // ---------------------------------------------------------------------------
203
+
204
+ test("writeVerb: a DEGRADED failure queues to the outbox for `maestro sync`", async () => {
205
+ const agentRoot = root();
206
+ const r = await writeVerb("subagent.create", { slug: "alpha" }, { cfg: CFG, agentRoot, fetchImpl: fakeFetch(new Error("offline")) });
207
+ assert.equal(r.degraded, true);
208
+ assert.ok(r.queued);
209
+ const queued = listOutbox(agentRoot);
210
+ assert.equal(queued.length, 1);
211
+ assert.deepEqual(queued[0].payload, { slug: "alpha" }, "queued verbatim — never re-derived at replay time");
212
+ });
213
+
214
+ test("writeVerb: a REJECTION is NOT queued (it would be rejected forever, invisibly)", async () => {
215
+ const agentRoot = root();
216
+ const r = await writeVerb("subagent.pin", { slug: "alpha" }, { cfg: CFG, agentRoot, fetchImpl: fakeFetch(errFrame(403, "FORBIDDEN", "nope")) });
217
+ assert.equal(r.ok, false);
218
+ assert.equal(r.degraded, false);
219
+ assert.equal(r.queued, undefined);
220
+ assert.deepEqual(listOutbox(agentRoot), []);
221
+ });
222
+
223
+ test("writeVerb: queue:false opts out (used where a caller wants the raw verdict)", async () => {
224
+ const agentRoot = root();
225
+ await writeVerb("subagent.pin", {}, { cfg: CFG, agentRoot, fetchImpl: fakeFetch(new Error("offline")), queue: false });
226
+ assert.deepEqual(listOutbox(agentRoot), []);
227
+ });
228
+
229
+ test("createDefinition: stamps contentHash, strips provenance, sends an idempotency key", async () => {
230
+ const f = fakeFetch(okFrame({ definitionId: "d1", version: 1 }));
231
+ await createDefinition({
232
+ slug: "alpha", title: "Alpha", summary: "s", tags: ["x"], functions: ["f"], altitudes: ["a"],
233
+ frontmatter: { name: "alpha", layer: "sdk", source: "sdk://1/alpha", contentHash: "old" },
234
+ body: "# alpha\n",
235
+ }, { cfg: CFG, fetchImpl: f });
236
+ const sent = f.calls[0].body;
237
+ assert.equal(sent.contentHash, hashBody("# alpha\n"));
238
+ assert.deepEqual(Object.keys(sent.frontmatter), ["name"], "a definition is prose, not a record of one copy of it");
239
+ assert.equal(sent.baseLayer, "none");
240
+ assert.equal(f.calls[0].headers["x-idempotency-key"], "subagent.create:org_1:alpha");
241
+ });
242
+
243
+ test("publishVersion: the idempotency key is (slug, contentHash) — a replay is a no-op", async () => {
244
+ const f = fakeFetch(okFrame({ version: 4 }));
245
+ await publishVersion({ slug: "alpha", definitionId: "d1", frontmatter: { name: "alpha" }, body: "b\n" }, { cfg: CFG, fetchImpl: f });
246
+ assert.equal(f.calls[0].headers["x-idempotency-key"], `subagent.publishVersion:alpha:${hashBody("b\n")}`);
247
+ assert.equal(f.calls[0].body.definitionId, "d1");
248
+ });
249
+
250
+ test("forkDefinition: records baseLayer/baseSlug and a fork generator", async () => {
251
+ const f = fakeFetch(okFrame({ definitionId: "d2", version: 1 }));
252
+ await forkDefinition({ slug: "alpha-mine", baseSlug: "alpha", frontmatter: { name: "alpha-mine" }, body: "b\n" }, { cfg: CFG, fetchImpl: f });
253
+ const sent = f.calls[0].body;
254
+ assert.equal(sent.baseLayer, "sdk");
255
+ assert.equal(sent.baseSlug, "alpha");
256
+ assert.deepEqual(sent.generator, { source: "fork", forkedFrom: "alpha" });
257
+ });
258
+
259
+ test("pin / unpin / yank: send only the identifiers they were given", async () => {
260
+ const f = fakeFetch(okFrame({}));
261
+ await pinDefinition({ slug: "alpha", definitionId: "d1", version: 3 }, { cfg: CFG, fetchImpl: f });
262
+ await unpinDefinition({ slug: "alpha" }, { cfg: CFG, fetchImpl: f });
263
+ await yankDefinition({ slug: "alpha", reason: "superseded" }, { cfg: CFG, fetchImpl: f });
264
+ assert.deepEqual(f.calls.map((c) => c.url.split("/v1/")[1]), ["subagent.pin", "subagent.unpin", "subagent.yank"]);
265
+ assert.equal(f.calls[0].body.version, 3);
266
+ assert.equal(f.calls[0].body.memberId, undefined, "an absent memberId means 'me' — hq fills it from the actor");
267
+ assert.equal(f.calls[2].body.reason, "superseded");
268
+ });
269
+
270
+ // ---------------------------------------------------------------------------
271
+ // replay
272
+ // ---------------------------------------------------------------------------
273
+
274
+ test("flushOutbox: replays oldest-first and removes what landed", async () => {
275
+ const agentRoot = root();
276
+ queueOutbox(agentRoot, "subagent.create", { slug: "alpha" });
277
+ queueOutbox(agentRoot, "subagent.pin", { slug: "alpha" });
278
+ const f = fakeFetch(okFrame({}));
279
+ const r = await flushOutbox({ agentRoot, cfg: CFG, fetchImpl: f });
280
+ assert.deepEqual([r.replayed, r.remaining, r.stopped], [2, 0, false]);
281
+ assert.deepEqual(listOutbox(agentRoot), []);
282
+ });
283
+
284
+ test("flushOutbox: STOPS at the first degraded failure (the server is down again)", async () => {
285
+ const agentRoot = root();
286
+ queueOutbox(agentRoot, "subagent.create", { slug: "a" });
287
+ queueOutbox(agentRoot, "subagent.pin", { slug: "a" });
288
+ const f = fakeFetch((_u, _i, n) => (n === 1 ? okFrame({}) : new Error("down")));
289
+ const r = await flushOutbox({ agentRoot, cfg: CFG, fetchImpl: f });
290
+ assert.deepEqual([r.replayed, r.stopped, r.remaining], [1, true, 1]);
291
+ assert.equal(listOutbox(agentRoot).length, 1, "the undelivered write is still queued");
292
+ });
293
+
294
+ test("flushOutbox: DROPS a rejected entry and reports it (or the outbox never empties)", async () => {
295
+ const agentRoot = root();
296
+ queueOutbox(agentRoot, "subagent.pin", { slug: "a" });
297
+ queueOutbox(agentRoot, "subagent.pin", { slug: "b" });
298
+ const f = fakeFetch((_u, _i, n) => (n === 1 ? errFrame(409, "CONFLICT", "already there") : okFrame({})));
299
+ const r = await flushOutbox({ agentRoot, cfg: CFG, fetchImpl: f });
300
+ assert.equal(r.replayed, 1);
301
+ assert.equal(r.dropped.length, 1);
302
+ assert.equal(r.dropped[0].error.code, "CONFLICT");
303
+ assert.deepEqual(listOutbox(agentRoot), []);
304
+ });
305
+
306
+ test("flushOutbox: an empty outbox is a clean no-op", async () => {
307
+ const r = await flushOutbox({ agentRoot: root(), cfg: CFG, fetchImpl: fakeFetch(okFrame({})) });
308
+ assert.deepEqual(r, { replayed: 0, dropped: [], remaining: 0, stopped: false });
309
+ });
@@ -0,0 +1,268 @@
1
+ /**
2
+ * lib/subagents/gap.mjs — the three-way diff between the roster, the resolved
3
+ * layers, and what the repo actually references (§2.3).
4
+ *
5
+ * Three questions, three sets:
6
+ *
7
+ * missing the capability pack says this agent should exist, and no layer
8
+ * resolves it. → the registry's actual job: adopt/fork/create.
9
+ *
10
+ * unrostered a workflow / team / routing entry references a slug the roster
11
+ * does not contain. Today this is the ONLY check `doctor` does, and
12
+ * it reports it as a bare "missing agent", which is almost always
13
+ * the wrong framing: the reference is usually vestigial, copied in
14
+ * by `create` from maestro's own `workflows/**` (which are the CEO
15
+ * agent's workflows, not this agent's). 15 such refs exist in every
16
+ * freshly-created agent.
17
+ *
18
+ * idle a roster agent that NOTHING references. This check does not exist
19
+ * anywhere today, and it is the one that tells you the capability
20
+ * pack generated prose nobody dispatches to.
21
+ *
22
+ * WHY UNROSTERED IS BIASED TO "DROP". The rewire classifier matches a dangling
23
+ * ref against roster agents using the charter pillars, `orgProfile.skills`, and
24
+ * governance themes. In real configs those are frequently EMPTY — `title:""` and
25
+ * `company:"UNCONFIGURED"` are common in the wild — so the semantic signal is
26
+ * often nil. A classifier with no signal that still confidently proposes a rewire
27
+ * would silently re-point a workflow step at the wrong agent. So: rewire is
28
+ * proposed only on real lexical evidence, and the default verdict is
29
+ * `drop-or-create` with `drop` recommended, because the correct fix for a
30
+ * vestigial step is almost always deleting it.
31
+ *
32
+ * And the section NEVER performs the drop: it records the accepted drop in the
33
+ * lock and prints file:line. Rewriting a workflow changes runtime behaviour; a
34
+ * human owns that diff.
35
+ *
36
+ * Pure — no I/O, no network. Everything is injected. ESM.
37
+ *
38
+ * @module lib/subagents/gap
39
+ */
40
+
41
+ "use strict";
42
+
43
+ import { groupRefs } from "./refs.mjs";
44
+ import { isAcceptedDrop } from "./lock.mjs";
45
+
46
+ /** Words too common to carry any signal in a slug/skill match. */
47
+ const STOPWORDS = new Set([
48
+ "agent", "agents", "the", "and", "for", "with", "ops", "op", "team", "lead",
49
+ "sub", "of", "to", "a", "an", "manager", "operator", "assistant", "specialist",
50
+ ]);
51
+
52
+ /**
53
+ * Split an identifier or a phrase into comparable lowercase tokens.
54
+ * Handles `kebab-case`, `snake_case`, `camelCase` and free text alike, because
55
+ * the three signal sources are all different shapes: slugs are kebab, skills are
56
+ * free text, governance themes are sentences.
57
+ * @param {string} s
58
+ * @returns {string[]}
59
+ */
60
+ export function tokenize(s) {
61
+ return String(s || "")
62
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
63
+ .toLowerCase()
64
+ .split(/[^a-z0-9]+/)
65
+ .filter((t) => t.length > 2 && !STOPWORDS.has(t));
66
+ }
67
+
68
+ /**
69
+ * Build the semantic corpus for one roster agent: its slug, role, purpose/mandate
70
+ * and team, plus any charter pillars / skills / governance themes that mention
71
+ * it. The corpus is what a dangling reference is matched against.
72
+ * @param {object} agent @param {object} [context]
73
+ * @returns {Set<string>}
74
+ */
75
+ export function agentCorpus(agent, context = {}) {
76
+ const parts = [
77
+ agent && agent.id,
78
+ agent && agent.role,
79
+ agent && agent.purpose,
80
+ agent && agent.mandate,
81
+ agent && agent.team,
82
+ ...(Array.isArray(agent && agent.towers) ? agent.towers : []),
83
+ ];
84
+ const tokens = new Set(parts.flatMap((p) => tokenize(p)));
85
+ // Contextual reinforcement: an org skill or charter pillar that shares tokens
86
+ // with this agent strengthens its corpus, which is how "the org actually does
87
+ // compliance" tips a `regulatory-watch` ref toward the compliance agent.
88
+ for (const extra of [...(context.skills || []), ...(context.pillars || []), ...(context.governance || [])]) {
89
+ const et = tokenize(extra);
90
+ if (et.some((t) => tokens.has(t))) for (const t of et) tokens.add(t);
91
+ }
92
+ return tokens;
93
+ }
94
+
95
+ /**
96
+ * Score a dangling reference against a roster agent: the fraction of the
97
+ * REFERENCE's tokens the agent's corpus covers. Reference-normalised (not
98
+ * Jaccard) on purpose — a rich agent corpus should not be penalised for being
99
+ * rich, but a two-token ref must match both tokens to score 1.
100
+ * @param {string} ref @param {Set<string>} corpus
101
+ * @returns {number} 0..1
102
+ */
103
+ export function scoreRef(ref, corpus) {
104
+ const rt = tokenize(ref);
105
+ if (!rt.length) return 0;
106
+ let hit = 0;
107
+ for (const t of rt) if (corpus.has(t)) hit++;
108
+ return hit / rt.length;
109
+ }
110
+
111
+ /**
112
+ * The evidence bar a rewire suggestion must clear. Set high (2/3 of the
113
+ * reference's tokens) precisely because the classifier runs against frequently
114
+ * empty configs — see the module header. Below the bar we do not guess.
115
+ */
116
+ export const REWIRE_THRESHOLD = 0.67;
117
+
118
+ /**
119
+ * Classify one unrostered reference.
120
+ * @param {string} ref
121
+ * @param {{roster:Array<object>, context?:object}} o
122
+ * @returns {{ref:string, action:"rewire"|"drop-or-create", suggest:string|null, score:number, reason:string, recommend:"rewire"|"drop"}}
123
+ */
124
+ export function classifyRef(ref, o = {}) {
125
+ const roster = Array.isArray(o.roster) ? o.roster : [];
126
+ const context = o.context || {};
127
+ let best = { id: null, score: 0 };
128
+ for (const a of roster) {
129
+ const s = scoreRef(ref, agentCorpus(a, context));
130
+ if (s > best.score) best = { id: a.id, score: s };
131
+ }
132
+ if (best.id && best.score >= REWIRE_THRESHOLD) {
133
+ return {
134
+ ref,
135
+ action: "rewire",
136
+ suggest: best.id,
137
+ score: Number(best.score.toFixed(3)),
138
+ recommend: "rewire",
139
+ reason: `"${ref}" overlaps ${Math.round(best.score * 100)}% with the roster agent "${best.id}"`,
140
+ };
141
+ }
142
+ return {
143
+ ref,
144
+ action: "drop-or-create",
145
+ suggest: best.id,
146
+ score: Number(best.score.toFixed(3)),
147
+ recommend: "drop",
148
+ reason: best.id
149
+ ? `no roster agent covers "${ref}" (closest: "${best.id}" at ${Math.round(best.score * 100)}%) — most likely a vestigial step copied by \`create\``
150
+ : `no roster agent resembles "${ref}" — most likely a vestigial step copied by \`create\``,
151
+ };
152
+ }
153
+
154
+ /**
155
+ * Normalise a capability surface into the flat roster the diff works over.
156
+ * `standardAgents ∪ roleAgents` per §2.3.
157
+ * @param {object} surface the resolveCapabilitySurface result
158
+ * @returns {Array<{id:string, role:string, purpose:string, team?:string, tier?:string, source:string}>}
159
+ */
160
+ export function rosterFromSurface(surface) {
161
+ const out = [];
162
+ for (const a of (surface && surface.standardAgents) || []) {
163
+ if (a && a.id) out.push({ ...a, source: "standard" });
164
+ }
165
+ for (const a of (surface && surface.roleAgents) || []) {
166
+ if (a && a.id) out.push({ ...a, source: "role" });
167
+ }
168
+ // Dedupe by id, first wins (standard defaults are authoritative for their ids).
169
+ const seen = new Set();
170
+ return out.filter((a) => (seen.has(a.id) ? false : (seen.add(a.id), true)));
171
+ }
172
+
173
+ /**
174
+ * The three-way diff.
175
+ *
176
+ * @param {object} o
177
+ * @param {Array<object>} o.roster the authoritative roster (rosterFromSurface)
178
+ * @param {object} o.resolved the resolveSubagents result
179
+ * @param {Array<object>} o.refs scanAgentRefs output
180
+ * @param {object} [o.lock] for acceptedDrops
181
+ * @param {object} [o.context] {skills, pillars, governance} semantic signal
182
+ * @returns {{missing:Array, unrostered:Array, idle:Array, localEdits:string[], status:"complete"|"partial"|"unconfigured", summary:string}}
183
+ */
184
+ export function computeGaps(o = {}) {
185
+ const roster = Array.isArray(o.roster) ? o.roster : [];
186
+ const rosterIds = new Set(roster.map((a) => a.id));
187
+ const resolved = o.resolved || { bySlug: new Map(), entries: [] };
188
+ const bySlug = resolved.bySlug instanceof Map ? resolved.bySlug : new Map((resolved.entries || []).map((e) => [e.slug, e]));
189
+ const refs = Array.isArray(o.refs) ? o.refs : [];
190
+ const lock = o.lock || { acceptedDrops: [] };
191
+ const byRef = groupRefs(refs);
192
+
193
+ // 1. missing = roster ∖ resolved-layers
194
+ const missing = roster
195
+ .filter((a) => !bySlug.has(a.id))
196
+ .map((a) => ({
197
+ slug: a.id,
198
+ role: a.role || "",
199
+ purpose: a.purpose || a.mandate || "",
200
+ team: a.team || "",
201
+ source: a.source,
202
+ // What a human can do about it, in the order the prompt offers them.
203
+ actions: ["adopt-sdk", "fork", "create", "defer"],
204
+ }));
205
+
206
+ // 2. unrostered = parsed refs ∖ roster
207
+ const unrostered = [];
208
+ for (const [ref, locations] of byRef) {
209
+ if (rosterIds.has(ref)) continue;
210
+ const cls = classifyRef(ref, { roster, context: o.context });
211
+ unrostered.push({
212
+ ...cls,
213
+ locations,
214
+ resolves: bySlug.has(ref), // referenced, off-roster, but a file DOES exist
215
+ accepted: isAcceptedDrop(lock, ref),
216
+ });
217
+ }
218
+ unrostered.sort((a, b) => b.score - a.score || a.ref.localeCompare(b.ref));
219
+
220
+ // 3. idle = roster agents nothing references
221
+ const idle = roster
222
+ .filter((a) => !byRef.has(a.id))
223
+ .map((a) => ({ slug: a.id, role: a.role || "", team: a.team || "", source: a.source, resolves: bySlug.has(a.id) }));
224
+
225
+ // `complete` demands no missing roster agents AND every drop-or-create verdict
226
+ // already accepted by a human. A rewire suggestion alone does NOT block
227
+ // completion — it is advisory (§5.6) and the operator may legitimately ignore it.
228
+ const openDrops = unrostered.filter((u) => u.action === "drop-or-create" && !u.accepted && !u.resolves);
229
+ const status = missing.length === 0 && openDrops.length === 0 ? "complete" : (bySlug.size ? "partial" : "unconfigured");
230
+
231
+ const summary = status === "complete"
232
+ ? `${bySlug.size} sub-agent${bySlug.size === 1 ? "" : "s"} resolved, roster complete${idle.length ? `, ${idle.length} idle` : ""}`
233
+ : `${missing.length} missing, ${openDrops.length} unresolved reference${openDrops.length === 1 ? "" : "s"}, ${idle.length} idle`;
234
+
235
+ return {
236
+ missing,
237
+ unrostered,
238
+ openDrops,
239
+ idle,
240
+ localEdits: Array.isArray(resolved.localEdits) ? resolved.localEdits : [],
241
+ emptyDirs: Array.isArray(resolved.emptyDirs) ? resolved.emptyDirs : [],
242
+ counts: { roster: roster.length, resolved: bySlug.size, missing: missing.length, unrostered: unrostered.length, idle: idle.length },
243
+ status,
244
+ summary,
245
+ };
246
+ }
247
+
248
+ /**
249
+ * Distil the semantic signal the classifier uses from the agent's own config.
250
+ * Tolerant of every field being absent — which, per the module header, is the
251
+ * common case and is exactly why the classifier is biased to drop.
252
+ * @param {{agentConfig?:object, charter?:object}} o
253
+ * @returns {{skills:string[], pillars:string[], governance:string[]}}
254
+ */
255
+ export function contextFromConfig(o = {}) {
256
+ const cfg = o.agentConfig || {};
257
+ const org = cfg.orgProfile || {};
258
+ const charter = o.charter || cfg.charter || {};
259
+ const skills = Array.isArray(org.skills) ? org.skills.map(String) : [];
260
+ const pillars = [
261
+ ...(Array.isArray(cfg.responsibilities) ? cfg.responsibilities.map(String) : []),
262
+ ...(Array.isArray(charter.sections) ? charter.sections.map((s) => `${(s && s.title) || ""} ${(s && s.body) || ""}`) : []),
263
+ ];
264
+ const governance = Array.isArray(org.governance)
265
+ ? org.governance.map((g) => (g && typeof g === "object" ? `${g.theme || ""} ${g.title || ""} ${g.description || ""}` : String(g)))
266
+ : [];
267
+ return { skills, pillars, governance };
268
+ }