@cohortapp/agent-sdk 2.3.2 → 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 (148) hide show
  1. package/framework-features.json +30 -0
  2. package/lib/backlog.mjs +136 -0
  3. package/lib/cadences.mjs +63 -2
  4. package/lib/cadences.test.mjs +105 -0
  5. package/lib/capability/inventory.mjs +542 -0
  6. package/lib/capability/inventory.test.mjs +232 -0
  7. package/lib/capability/probe.mjs +255 -0
  8. package/lib/channels/contract.mjs +37 -1
  9. package/lib/channels/contract.test.mjs +25 -1
  10. package/lib/claude-bin.mjs +37 -3
  11. package/lib/claude-bin.test.mjs +42 -8
  12. package/lib/execution/disposition.mjs +501 -0
  13. package/lib/execution/disposition.test.mjs +482 -0
  14. package/lib/execution/drive.mjs +352 -0
  15. package/lib/execution/drive.test.mjs +270 -0
  16. package/lib/execution/effects.mjs +340 -0
  17. package/lib/execution/effects.test.mjs +193 -0
  18. package/lib/execution/index.mjs +152 -0
  19. package/lib/execution/intake.mjs +581 -0
  20. package/lib/execution/intake.test.mjs +343 -0
  21. package/lib/execution/journal.mjs +374 -0
  22. package/lib/execution/journal.test.mjs +261 -0
  23. package/lib/execution/match.mjs +331 -0
  24. package/lib/execution/match.test.mjs +235 -0
  25. package/lib/execution/pipeline.mjs +341 -0
  26. package/lib/execution/pipeline.test.mjs +389 -0
  27. package/lib/execution/route.mjs +332 -0
  28. package/lib/execution/route.test.mjs +186 -0
  29. package/lib/execution/surface-policy.mjs +446 -0
  30. package/lib/execution/surface-policy.test.mjs +162 -0
  31. package/lib/goals/admission.mjs +209 -0
  32. package/lib/goals/admission.test.mjs +139 -0
  33. package/lib/goals/classify.mjs +206 -0
  34. package/lib/goals/classify.test.mjs +109 -0
  35. package/lib/goals/collaborate.mjs +415 -0
  36. package/lib/goals/collaborate.test.mjs +324 -0
  37. package/lib/goals/gaps.mjs +111 -0
  38. package/lib/goals/gaps.test.mjs +284 -0
  39. package/lib/goals/loop.mjs +537 -0
  40. package/lib/goals/loop.test.mjs +719 -0
  41. package/lib/identity/persona.mjs +247 -0
  42. package/lib/identity/persona.test.mjs +117 -0
  43. package/lib/kpi.mjs +469 -0
  44. package/lib/kpi.test.mjs +244 -0
  45. package/lib/mandate/audit.mjs +168 -0
  46. package/lib/mandate/audit.test.mjs +195 -0
  47. package/lib/mandate/cache.mjs +162 -0
  48. package/lib/mandate/derive.mjs +317 -0
  49. package/lib/mandate/derive.test.mjs +224 -0
  50. package/lib/mandate/model.mjs +352 -0
  51. package/lib/mandate/model.test.mjs +145 -0
  52. package/lib/mandate/refresh.mjs +187 -0
  53. package/lib/mandate/refresh.test.mjs +293 -0
  54. package/lib/mcp/server.test.mjs +4 -4
  55. package/lib/org/approvals.mjs +14 -2
  56. package/lib/org/client.mjs +58 -22
  57. package/lib/org/client.test.mjs +3 -1
  58. package/lib/org/inbound/directedness.mjs +720 -0
  59. package/lib/org/inbound/directedness.test.mjs +543 -0
  60. package/lib/org/inbound/facts.mjs +501 -0
  61. package/lib/org/inbound/facts.test.mjs +375 -0
  62. package/lib/org/inbound/hydrate.mjs +535 -0
  63. package/lib/org/inbound/hydrate.test.mjs +326 -0
  64. package/lib/org/inbound/index.mjs +233 -0
  65. package/lib/org/inbound/index.test.mjs +324 -0
  66. package/lib/org/inbound/io.mjs +141 -0
  67. package/lib/org/inbound/project.mjs +201 -0
  68. package/lib/org/inbound/project.test.mjs +287 -0
  69. package/lib/org/inbound/surfaces.mjs +257 -0
  70. package/lib/org/knowledge.mjs +10 -1
  71. package/lib/org/knowledge.test.mjs +8 -1
  72. package/lib/org/leases.mjs +5 -0
  73. package/lib/org/mesh.mjs +17 -2
  74. package/lib/org/messaging.mjs +40 -4
  75. package/lib/org/messaging.test.mjs +40 -0
  76. package/lib/org/param-contract.mjs +694 -0
  77. package/lib/org/param-contract.test.mjs +451 -0
  78. package/lib/org/protocol.checksum +1 -1
  79. package/lib/org/protocol.mjs +8 -0
  80. package/lib/org/protocol.test.mjs +5 -1
  81. package/lib/org/push.mjs +1025 -0
  82. package/lib/org/push.test.mjs +690 -0
  83. package/lib/org/tool-surface.mjs +138 -38
  84. package/lib/org/tool-surface.test.mjs +13 -8
  85. package/lib/org/typing.mjs +341 -0
  86. package/lib/org/typing.test.mjs +291 -0
  87. package/lib/plan/compile.mjs +510 -0
  88. package/lib/plan/compile.test.mjs +286 -0
  89. package/lib/plan/emit.mjs +256 -0
  90. package/lib/plan/emit.test.mjs +246 -0
  91. package/lib/plan/explain.mjs +226 -0
  92. package/lib/plan/explain.test.mjs +188 -0
  93. package/lib/plan/schema.mjs +140 -0
  94. package/lib/resource-governor.mjs +47 -1
  95. package/lib/resource-governor.test.mjs +21 -1
  96. package/lib/setup/enroll-from-cohort.mjs +84 -16
  97. package/lib/setup/enroll-from-cohort.test.mjs +43 -1
  98. package/lib/setup/sections/identity.mjs +15 -4
  99. package/lib/setup/sections/identity.test.mjs +94 -0
  100. package/lib/setup/sections/inventory.mjs +178 -0
  101. package/lib/setup/sections/inventory.test.mjs +198 -0
  102. package/lib/setup/sections/mandate.mjs +392 -0
  103. package/lib/setup/sections/mandate.test.mjs +373 -0
  104. package/lib/setup/sections/subagents.mjs +427 -0
  105. package/lib/setup/sections/subagents.test.mjs +429 -0
  106. package/lib/setup/sections/verify.mjs +121 -0
  107. package/lib/setup/sections/verify.test.mjs +175 -0
  108. package/lib/setup/sot.mjs +2 -0
  109. package/lib/subagents/cli.mjs +463 -0
  110. package/lib/subagents/cli.test.mjs +389 -0
  111. package/lib/subagents/client.mjs +373 -0
  112. package/lib/subagents/client.test.mjs +309 -0
  113. package/lib/subagents/gap.mjs +268 -0
  114. package/lib/subagents/gap.test.mjs +234 -0
  115. package/lib/subagents/lock.mjs +296 -0
  116. package/lib/subagents/lock.test.mjs +248 -0
  117. package/lib/subagents/manifest.mjs +224 -0
  118. package/lib/subagents/manifest.test.mjs +175 -0
  119. package/lib/subagents/refs.mjs +274 -0
  120. package/lib/subagents/refs.test.mjs +204 -0
  121. package/lib/subagents/resolve.mjs +455 -0
  122. package/lib/subagents/resolve.test.mjs +422 -0
  123. package/lib/subagents/schema.mjs +467 -0
  124. package/lib/subagents/schema.test.mjs +306 -0
  125. package/package.json +8 -3
  126. package/plugins/maestro-skills/.claude-plugin/marketplace.json +16 -0
  127. package/policies/ai-disclosure.yaml +42 -2
  128. package/scaffold/CLAUDE.md +16 -2
  129. package/schedules/triggers/goal-steward.md +79 -0
  130. package/scripts/ci/conformance-org-api.mjs +792 -0
  131. package/scripts/ci/conformance-org-api.test.mjs +417 -0
  132. package/scripts/daemon/agent-daemon.mjs +36 -4
  133. package/scripts/daemon/cadence-handlers.mjs +145 -1
  134. package/scripts/daemon/goal-steward-cadence.test.mjs +243 -0
  135. package/scripts/daemon/inbox-deferral.mjs +45 -2
  136. package/scripts/daemon/inbox-deferral.test.mjs +56 -0
  137. package/scripts/daemon/inbox-wake.mjs +282 -0
  138. package/scripts/daemon/inbox-wake.test.mjs +199 -0
  139. package/scripts/daemon/prompt-builder.mjs +41 -1
  140. package/scripts/daemon/typing-registry.mjs +55 -2
  141. package/scripts/daemon/typing-registry.test.mjs +25 -0
  142. package/scripts/local-triggers/generate-plists.test.mjs +5 -5
  143. package/scripts/setup/gen-subagent-manifest.mjs +95 -0
  144. package/scripts/setup/gen-subagent-manifest.test.mjs +124 -0
  145. package/scripts/setup/generate-plan.mjs +108 -0
  146. package/scripts/setup/init-capability-manifest.mjs +70 -0
  147. package/scripts/setup/init-skill-marketplace.mjs +155 -0
  148. package/scripts/setup/init-skill-marketplace.test.mjs +193 -0
@@ -0,0 +1,199 @@
1
+ /**
2
+ * inbox-wake.test.mjs — the daemon's "something landed" wake signal.
3
+ *
4
+ * Exercised against a REAL temp filesystem (fs.watch semantics are the thing
5
+ * under test), plus one injected-watch case that proves the scan floor carries
6
+ * the load when every watcher is dead — the property that keeps a missed event
7
+ * from turning into a silently late agent.
8
+ *
9
+ * Run: node --test scripts/daemon/inbox-wake.test.mjs
10
+ */
11
+
12
+ import { test } from "node:test";
13
+ import assert from "node:assert/strict";
14
+ import { mkdtempSync, mkdirSync, writeFileSync, renameSync, rmSync } from "node:fs";
15
+ import { tmpdir } from "node:os";
16
+ import { join } from "node:path";
17
+
18
+ import {
19
+ waitForInboxWake,
20
+ signalInbox,
21
+ findLiveInboxService,
22
+ isLiveItemName,
23
+ resetInboxWake,
24
+ DEFAULT_SERVICES,
25
+ } from "./inbox-wake.mjs";
26
+
27
+ function makeRoot() {
28
+ const root = mkdtempSync(join(tmpdir(), "inbox-wake-"));
29
+ mkdirSync(join(root, "state", "inbox", "cohort"), { recursive: true });
30
+ return root;
31
+ }
32
+
33
+ function writeItem(root, service, name, body = "id: x\n") {
34
+ writeFileSync(join(root, "state", "inbox", service, name), body);
35
+ }
36
+
37
+ // --- the predicate ----------------------------------------------------------
38
+
39
+ test("isLiveItemName: only un-drained, non-dot files count as work", () => {
40
+ assert.equal(isLiveItemName("2026-08-11-abc.yaml"), true);
41
+ assert.equal(isLiveItemName("2026-08-11-abc.yaml.processed"), false);
42
+ assert.equal(isLiveItemName("2026-08-11-abc.yaml.deferred"), false);
43
+ assert.equal(isLiveItemName("2026-08-11-abc.yaml.processed-bundled"), false);
44
+ assert.equal(isLiveItemName(".DS_Store"), false);
45
+ assert.equal(isLiveItemName(""), false);
46
+ });
47
+
48
+ test("findLiveInboxService: names the service holding work, else null", () => {
49
+ const root = makeRoot();
50
+ try {
51
+ assert.equal(findLiveInboxService({ agentRoot: root }), null);
52
+ writeItem(root, "cohort", "a.yaml.processed");
53
+ assert.equal(findLiveInboxService({ agentRoot: root }), null, "a drained item is not work");
54
+ writeItem(root, "cohort", "b.yaml");
55
+ assert.equal(findLiveInboxService({ agentRoot: root }), "cohort");
56
+ } finally {
57
+ rmSync(root, { recursive: true, force: true });
58
+ }
59
+ });
60
+
61
+ test("the cohort + orgmail dirs are in the default service set (the ones that were missed)", () => {
62
+ assert.ok(DEFAULT_SERVICES.includes("cohort"));
63
+ assert.ok(DEFAULT_SERVICES.includes("orgmail"));
64
+ assert.ok(DEFAULT_SERVICES.includes("telegram"));
65
+ });
66
+
67
+ // --- the wake ---------------------------------------------------------------
68
+
69
+ test("returns immediately when work is already waiting (never sleep on a full inbox)", async () => {
70
+ const root = makeRoot();
71
+ try {
72
+ writeItem(root, "cohort", "a.yaml");
73
+ const r = await waitForInboxWake({ agentRoot: root, timeoutMs: 5_000 });
74
+ assert.equal(r.woke, true);
75
+ assert.equal(r.reason, "pending");
76
+ assert.equal(r.service, "cohort");
77
+ assert.equal(r.waitedMs, 0);
78
+ } finally {
79
+ rmSync(root, { recursive: true, force: true });
80
+ }
81
+ });
82
+
83
+ test("wakes on a file written by another writer, far inside the 60s poll interval", async () => {
84
+ const root = makeRoot();
85
+ try {
86
+ const started = Date.now();
87
+ const waiting = waitForInboxWake({ agentRoot: root, timeoutMs: 8_000 });
88
+ setTimeout(() => writeItem(root, "cohort", "late.yaml"), 50);
89
+ const r = await waiting;
90
+ assert.equal(r.woke, true);
91
+ assert.ok(["watch", "scan"].includes(r.reason), `unexpected reason ${r.reason}`);
92
+ assert.equal(r.service, "cohort");
93
+ assert.ok(Date.now() - started < 3_000, "a wake must not take seconds");
94
+ } finally {
95
+ rmSync(root, { recursive: true, force: true });
96
+ }
97
+ });
98
+
99
+ test("does NOT wake when the only change is an item being drained", async () => {
100
+ const root = makeRoot();
101
+ try {
102
+ writeItem(root, "cohort", "a.yaml");
103
+ // Drain it, then wait: renaming to `.processed` raises watch events for BOTH
104
+ // names, so an event-driven wake would spin here.
105
+ renameSync(
106
+ join(root, "state", "inbox", "cohort", "a.yaml"),
107
+ join(root, "state", "inbox", "cohort", "a.yaml.processed")
108
+ );
109
+ const r = await waitForInboxWake({ agentRoot: root, timeoutMs: 400 });
110
+ assert.equal(r.woke, false);
111
+ assert.equal(r.reason, "timeout");
112
+ } finally {
113
+ rmSync(root, { recursive: true, force: true });
114
+ }
115
+ });
116
+
117
+ test("times out cleanly with an empty inbox (the loop shape is unchanged)", async () => {
118
+ const root = makeRoot();
119
+ try {
120
+ const r = await waitForInboxWake({ agentRoot: root, timeoutMs: 200 });
121
+ assert.deepEqual(
122
+ { woke: r.woke, reason: r.reason, service: r.service },
123
+ { woke: false, reason: "timeout", service: null }
124
+ );
125
+ assert.ok(r.waitedMs >= 150);
126
+ } finally {
127
+ rmSync(root, { recursive: true, force: true });
128
+ }
129
+ });
130
+
131
+ test("signalInbox wakes an in-process waiter (the dispatcher's promote path)", async () => {
132
+ const root = makeRoot();
133
+ resetInboxWake();
134
+ try {
135
+ const waiting = waitForInboxWake({ agentRoot: root, timeoutMs: 5_000, scanMs: 60_000 });
136
+ setTimeout(() => {
137
+ // Simulate promoteDeferred: the file becomes live, then we nudge.
138
+ writeItem(root, "cohort", "promoted.yaml");
139
+ signalInbox("promote");
140
+ }, 20);
141
+ const r = await waiting;
142
+ assert.equal(r.woke, true);
143
+ assert.equal(r.service, "cohort");
144
+ } finally {
145
+ resetInboxWake();
146
+ rmSync(root, { recursive: true, force: true });
147
+ }
148
+ });
149
+
150
+ test("signalInbox with no work does NOT wake (a spurious nudge is free)", async () => {
151
+ const root = makeRoot();
152
+ resetInboxWake();
153
+ try {
154
+ const waiting = waitForInboxWake({ agentRoot: root, timeoutMs: 400, scanMs: 60_000 });
155
+ setTimeout(() => signalInbox("noise"), 20);
156
+ const r = await waiting;
157
+ assert.equal(r.woke, false);
158
+ } finally {
159
+ resetInboxWake();
160
+ rmSync(root, { recursive: true, force: true });
161
+ }
162
+ });
163
+
164
+ test("with EVERY watcher dead the scan floor still wakes it — and says so once", async () => {
165
+ const root = makeRoot();
166
+ const logs = [];
167
+ try {
168
+ const watchImpl = () => {
169
+ throw new Error("EMFILE: watcher limit reached");
170
+ };
171
+ const waiting = waitForInboxWake({
172
+ agentRoot: root,
173
+ timeoutMs: 6_000,
174
+ scanMs: 100,
175
+ watchImpl,
176
+ log: (m) => logs.push(m),
177
+ });
178
+ setTimeout(() => writeItem(root, "cohort", "floor.yaml"), 30);
179
+ const r = await waiting;
180
+ assert.equal(r.woke, true);
181
+ assert.equal(r.reason, "scan", "correctness must not depend on fs.watch");
182
+ assert.equal(logs.length, 1, "a dead watcher is reported — fail-open, never silent");
183
+ assert.match(logs[0], /falling back to the 100ms rescan/);
184
+ } finally {
185
+ resetInboxWake();
186
+ rmSync(root, { recursive: true, force: true });
187
+ }
188
+ });
189
+
190
+ test("a missing service dir is not an error (an un-enrolled agent just times out)", async () => {
191
+ const root = mkdtempSync(join(tmpdir(), "inbox-wake-bare-"));
192
+ try {
193
+ const r = await waitForInboxWake({ agentRoot: root, timeoutMs: 200 });
194
+ assert.equal(r.woke, false);
195
+ assert.equal(r.reason, "timeout");
196
+ } finally {
197
+ rmSync(root, { recursive: true, force: true });
198
+ }
199
+ });
@@ -6,6 +6,7 @@
6
6
  import { readFileSync, readdirSync } from "fs";
7
7
  import { join } from "path";
8
8
  import { compileContext } from "./context-compiler.mjs";
9
+ import { loadPersonaBlock } from "../../lib/identity/persona.mjs";
9
10
  import { wrapExternalContent } from "../../lib/security/external-content.mjs";
10
11
  import { isEnabled as orgEnabled } from "../../lib/org/client.mjs";
11
12
  import { recall as orgRecall } from "../../lib/org/knowledge.mjs";
@@ -118,6 +119,27 @@ async function buildOrgKnowledgeBlock(item, classResult, queueItem) {
118
119
  // Cached identity preamble — extracted from CLAUDE.md on first call
119
120
  let cachedPreamble = null;
120
121
 
122
+ // Cached persona block — rendered from config/agent.json (the SoT, kept in sync
123
+ // with the Cohort server) on first call. This leads every prompt and is
124
+ // AUTHORITATIVE over the CLAUDE.md-scraped preamble that follows it: a scaffolded
125
+ // repo still carries "your identity has not yet been configured" prose in
126
+ // CLAUDE.md long after the org record is populated, and without an explicit
127
+ // precedence rule the model would take that placeholder at face value and fall
128
+ // back to a generic-assistant register.
129
+ let cachedPersona = null;
130
+
131
+ /** Render (once per process) the persona block from config/agent.json. */
132
+ function loadPersona() {
133
+ if (cachedPersona !== null) return cachedPersona;
134
+ cachedPersona = loadPersonaBlock(AGENT_REPO_DIR) || "";
135
+ return cachedPersona;
136
+ }
137
+
138
+ /** For tests: drop the cached persona so a fresh config/agent.json is re-read. */
139
+ export function _resetPersonaCache() {
140
+ cachedPersona = null;
141
+ }
142
+
121
143
  function loadPreamble() {
122
144
  if (cachedPreamble) return cachedPreamble;
123
145
 
@@ -185,7 +207,10 @@ function buildFallbackPreamble() {
185
207
  const principal = a.principal || {};
186
208
  const principalName = principal.fullName || "the principal";
187
209
  const principalTitle = principal.title || "principal";
188
- return `You are ${a.fullName}, ${a.title} to ${principalName}, ${principalTitle} of ${a.company}.
210
+ // "<title> to <principal>" only reads correctly for assistant-shaped titles;
211
+ // for a functional seat ("SVP AI Systems & Agent Platform") it turns the role
212
+ // into a service relationship. State the seat, then the reporting line.
213
+ return `You are ${a.fullName}, ${a.title} at ${a.company}. You report to ${principalName}, ${principalTitle}.
189
214
  You operate as the autonomous executive command layer for the company.
190
215
 
191
216
  ${a.companyDescription || a.company}.
@@ -552,6 +577,21 @@ export async function buildPrompt(item, classResult, options = {}) {
552
577
 
553
578
  const parts = [];
554
579
 
580
+ // 1. Persona block (cached) — rendered from config/agent.json, which the
581
+ // Cohort enrollment pull keeps current. Leads the prompt and outranks the
582
+ // CLAUDE.md preamble below, because a scaffolded CLAUDE.md carries
583
+ // "identity not yet configured" placeholder prose that would otherwise
584
+ // contradict a perfectly good org record.
585
+ const persona = loadPersona();
586
+ if (persona) {
587
+ parts.push(persona);
588
+ parts.push("");
589
+ parts.push(
590
+ "The section above is your identity, resolved from your organisation's record of you. Where anything below conflicts with it, the section above wins.",
591
+ );
592
+ parts.push("");
593
+ }
594
+
555
595
  // 1. Identity preamble (cached)
556
596
  parts.push(preamble);
557
597
  parts.push("");
@@ -19,9 +19,31 @@
19
19
 
20
20
  "use strict";
21
21
 
22
+ import { cohortTypingAdapter } from "../../lib/org/typing.mjs";
23
+
22
24
  /** @type {Map<string, { startTypingHeartbeat:Function, stopTypingHeartbeat:Function }>} */
23
25
  const ADAPTERS = new Map();
24
26
 
27
+ /**
28
+ * Services whose adapter is BUILT IN rather than created by the daemon's
29
+ * channel-bus loop, resolved on first use.
30
+ *
31
+ * `cohort` is the org substrate itself: there is no channel adapter object for
32
+ * it anywhere (the messaging-inbound cadence writes inbox YAML directly), so
33
+ * nothing would ever have called `registerTypingAdapter("cohort", …)` — and an
34
+ * SDK-driven agent, which is now the ONLY responder for its own rooms
35
+ * (hq `llm-responder/sdk-driven.ts` stands hq down for a live daemon), would
36
+ * show no "composing" indicator at all while it worked. The adapter is a plain
37
+ * `messaging.typing` client (`lib/org/typing.mjs`) that no-ops when the agent is
38
+ * not enrolled, so resolving it lazily here costs nothing for an agent that has
39
+ * no org.
40
+ *
41
+ * @type {Record<string, () => object>}
42
+ */
43
+ const BUILTIN_ADAPTERS = {
44
+ cohort: () => cohortTypingAdapter(),
45
+ };
46
+
25
47
  /** Register a channel adapter under its service name (e.g. "telegram"). */
26
48
  export function registerTypingAdapter(service, adapter) {
27
49
  if (!service || !adapter) return;
@@ -29,6 +51,37 @@ export function registerTypingAdapter(service, adapter) {
29
51
  ADAPTERS.set(service, adapter);
30
52
  }
31
53
 
54
+ /**
55
+ * Resolve the adapter for a service: an explicitly registered one wins (so a
56
+ * test or a future real cohort adapter can override), else a built-in.
57
+ * Fail-open: a built-in that cannot be constructed yields null and the caller
58
+ * no-ops, but the reason is logged — a silently missing indicator is exactly
59
+ * the class of bug this module exists to fix.
60
+ */
61
+ function adapterFor(service) {
62
+ if (!service) return null;
63
+ const registered = ADAPTERS.get(service);
64
+ if (registered) return registered;
65
+ const builtin = BUILTIN_ADAPTERS[service];
66
+ if (!builtin) return null;
67
+ try {
68
+ const made = builtin();
69
+ if (!made || typeof made.startTypingHeartbeat !== "function") return null;
70
+ ADAPTERS.set(service, made);
71
+ return made;
72
+ } catch (err) {
73
+ try {
74
+ console.warn(
75
+ `[typing-registry] built-in typing adapter for "${service}" failed to load ` +
76
+ `(${err && err.message}) — sessions will run without a typing indicator`
77
+ );
78
+ } catch {
79
+ /* never throw from logging */
80
+ }
81
+ return null;
82
+ }
83
+ }
84
+
32
85
  export function clearTypingAdapters() {
33
86
  ADAPTERS.clear();
34
87
  }
@@ -57,7 +110,7 @@ export function sourceFromItem(item) {
57
110
  export function startTyping(item) {
58
111
  try {
59
112
  const service = item?.service;
60
- const adapter = service && ADAPTERS.get(service);
113
+ const adapter = adapterFor(service);
61
114
  if (!adapter) return null;
62
115
  const source = sourceFromItem(item);
63
116
  if (!source || !source.chatId) return null;
@@ -71,7 +124,7 @@ export function startTyping(item) {
71
124
  export function stopTyping(item) {
72
125
  try {
73
126
  const service = item?.service;
74
- const adapter = service && ADAPTERS.get(service);
127
+ const adapter = adapterFor(service);
75
128
  if (!adapter) return;
76
129
  const source = sourceFromItem(item);
77
130
  if (!source || !source.chatId) return;
@@ -16,6 +16,7 @@ import {
16
16
  stopTyping,
17
17
  sourceFromItem,
18
18
  } from "./typing-registry.mjs";
19
+ import { resetCohortTypingAdapter } from "../../lib/org/typing.mjs";
19
20
 
20
21
  function fakeAdapter() {
21
22
  return {
@@ -68,6 +69,30 @@ test("sourceFromItem prefers a structured source, else reconstructs from item",
68
69
  assert.equal(group.threadRef, "77");
69
70
  });
70
71
 
72
+ test("cohort resolves the BUILT-IN adapter with nothing registered (the SDK parity gap)", () => {
73
+ clearTypingAdapters();
74
+ resetCohortTypingAdapter();
75
+ // No registerTypingAdapter("cohort", …) anywhere — nothing constructs a
76
+ // cohort channel adapter, which is why the indicator was missing entirely.
77
+ const item = { service: "cohort", channel_id: "ch_1", channel: "#build", thread_id: "" };
78
+ assert.doesNotThrow(() => startTyping(item));
79
+ assert.doesNotThrow(() => stopTyping(item));
80
+ clearTypingAdapters();
81
+ resetCohortTypingAdapter();
82
+ });
83
+
84
+ test("an explicitly registered cohort adapter still wins over the built-in", () => {
85
+ clearTypingAdapters();
86
+ resetCohortTypingAdapter();
87
+ const fake = fakeAdapter();
88
+ registerTypingAdapter("cohort", fake);
89
+ startTyping({ service: "cohort", channel_id: "ch_1", channel: "#build" });
90
+ assert.equal(fake.started.length, 1);
91
+ assert.equal(fake.started[0].chatId, "ch_1");
92
+ clearTypingAdapters();
93
+ resetCohortTypingAdapter();
94
+ });
95
+
71
96
  test("registerTypingAdapter ignores adapters lacking startTypingHeartbeat", () => {
72
97
  clearTypingAdapters();
73
98
  registerTypingAdapter("bad", { foo: 1 });
@@ -107,7 +107,7 @@ function listPlists(agentRoot) {
107
107
  // Tests
108
108
  // ---------------------------------------------------------------------------
109
109
 
110
- test("generator emits 21 standard plists with the agent's first name", async () => {
110
+ test("generator emits 22 standard plists with the agent's first name", async () => {
111
111
  // Standard inventory (cadence-bus v1 + slack-socket-mode v1), with NO
112
112
  // config/.cadence-plists.tsv present (no archetype cadences):
113
113
  // daemon, poll-relay, slack-socket (3 infra), PLUS one trigger plist per
@@ -118,8 +118,8 @@ test("generator emits 21 standard plists with the agent's first name", async ()
118
118
  // economics/scheduling cadences nightly-cost-reconcile, fleet-cost-digest,
119
119
  // org-cost-sync, monitoring-alerts, dynamic-jobs (5), PLUS the SP10
120
120
  // messaging-inbound cadence (1), PLUS the 2026-07 Directory & Design
121
- // stewards directory-hygiene + brand-steward (2). = 3 infra + 18 trigger
122
- // = 21 total.
121
+ // stewards directory-hygiene + brand-steward (2), PLUS the D3 self-directed
122
+ // loop goal-steward (1). = 3 infra + 19 trigger = 22 total.
123
123
  // org-pulse used to be declared standard but was OMITTED by the old hardcoded
124
124
  // list (it never got a plist); deriving from the SoT fixes that drift.
125
125
  // The former weekly-* cadences are still ARCHETYPE-DRIVEN (function × altitude),
@@ -129,7 +129,7 @@ test("generator emits 21 standard plists with the agent's first name", async ()
129
129
  const r = runGenerator(root);
130
130
  assert.equal(r.status, 0, r.stderr);
131
131
  const plists = listPlists(root);
132
- assert.equal(plists.length, 21, `expected 21 standard plists; got ${plists.join(",")}`);
132
+ assert.equal(plists.length, 22, `expected 22 standard plists; got ${plists.join(",")}`);
133
133
  assert.ok(plists.includes("ai.maestro.alice-messaging-inbound.plist"),
134
134
  "messaging-inbound (SP10 standard cadence) must get a SoT-derived plist");
135
135
  for (const id of ["directory-hygiene", "brand-steward"]) {
@@ -200,7 +200,7 @@ test("archetype cadences from config/.cadence-plists.tsv emit extra trigger plis
200
200
  const r = runGenerator(root);
201
201
  assert.equal(r.status, 0, r.stderr);
202
202
  const plists = listPlists(root);
203
- assert.equal(plists.length, 23, `expected 21 standard + 2 archetype; got ${plists.join(",")}`);
203
+ assert.equal(plists.length, 24, `expected 22 standard + 2 archetype; got ${plists.join(",")}`);
204
204
  const dir = join(root, "scripts/local-triggers/plists");
205
205
  const eng = readFileSync(join(dir, "ai.maestro.erin-engineering-health.plist"), "utf-8");
206
206
  assert.match(eng, /<key>Weekday<\/key>\s*<integer>3<\/integer>/); // base64 schedule decoded
@@ -0,0 +1,95 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * gen-subagent-manifest.mjs — generate `agents/manifest.json`, layer 0's index
4
+ * (Sub-Agent Registry v1 §2.2).
5
+ *
6
+ * WHY THIS SCRIPT EXISTS. `agents/` ships in the npm package and is copied
7
+ * verbatim by `maestro create`, then MERGED (never overwritten) by
8
+ * `UPGRADE_PATHS`. That means an SDK fix to a shipped agent.md can never reach a
9
+ * deployed agent — an upgraded file and a hand-edited one look identical on disk.
10
+ * The manifest's per-agent sha256 breaks the tie: a file whose hash matches a
11
+ * manifest sha is a pristine copy and may be re-materialised; anything else is a
12
+ * human's edit and is sacred. That single fact is what lets `create`'s copy list
13
+ * and `UPGRADE_PATHS` stay EXACTLY as they are — no migration, no breaking change.
14
+ *
15
+ * Run at prepublish and commit the result:
16
+ * node scripts/setup/gen-subagent-manifest.mjs
17
+ * node scripts/setup/gen-subagent-manifest.mjs --check # CI: fail on drift
18
+ *
19
+ * `--check` is the CI gate: it rebuilds the manifest in memory and fails when it
20
+ * differs from the committed one, so a shipped agent.md can never change without
21
+ * its sha changing with it (which would silently reclassify every deployed copy
22
+ * as a local edit and freeze it forever).
23
+ *
24
+ * Exit codes: 0 ok · 1 drift (with --check) · 2 an agent.md failed validation.
25
+ *
26
+ * Node builtins only. ESM.
27
+ *
28
+ * @module scripts/setup/gen-subagent-manifest
29
+ */
30
+
31
+ "use strict";
32
+
33
+ import { existsSync, readFileSync } from "node:fs";
34
+ import { dirname, join, resolve } from "node:path";
35
+ import { fileURLToPath } from "node:url";
36
+ import { buildManifest, manifestPath, readManifest, writeManifest } from "../../lib/subagents/manifest.mjs";
37
+
38
+ const HERE = dirname(fileURLToPath(import.meta.url));
39
+ const MAESTRO_ROOT =
40
+ process.env.MAESTRO_ROOT && existsSync(join(process.env.MAESTRO_ROOT, "lib", "subagents", "manifest.mjs"))
41
+ ? process.env.MAESTRO_ROOT
42
+ : resolve(HERE, "..", "..");
43
+
44
+ /** Read the package version so the manifest can name the release it describes. */
45
+ function sdkVersion(root) {
46
+ try { return String(JSON.parse(readFileSync(join(root, "package.json"), "utf8")).version || ""); }
47
+ catch { return ""; }
48
+ }
49
+
50
+ /**
51
+ * Compare two manifests ignoring `generatedAt` — a timestamp differing on every
52
+ * run would make `--check` permanently red and train everyone to ignore it.
53
+ */
54
+ function sameManifest(a, b) {
55
+ const strip = (m) => JSON.stringify({ sdkVersion: m.sdkVersion || "", agents: m.agents || {}, emptyDirs: m.emptyDirs || [] });
56
+ return strip(a || {}) === strip(b || {});
57
+ }
58
+
59
+ export function main(argv = process.argv.slice(2), root = MAESTRO_ROOT) {
60
+ const check = argv.includes("--check");
61
+ const strict = argv.includes("--strict");
62
+ const previous = readManifest(root);
63
+ const { manifest, invalid } = buildManifest({ maestroRoot: root, sdkVersion: sdkVersion(root), previous });
64
+
65
+ for (const bad of invalid) {
66
+ console.error(`[gen-subagent-manifest] agents/${bad.slug}/agent.md is invalid:`);
67
+ for (const e of bad.errors) console.error(` - ${e}`);
68
+ }
69
+
70
+ if (check) {
71
+ if (!existsSync(manifestPath(root))) {
72
+ console.error(`[gen-subagent-manifest] ${manifestPath(root)} does not exist — run this script and commit it.`);
73
+ return 1;
74
+ }
75
+ if (!sameManifest(previous, manifest)) {
76
+ console.error("[gen-subagent-manifest] agents/manifest.json is STALE — an agent.md changed without regenerating it.");
77
+ console.error(" fix: node scripts/setup/gen-subagent-manifest.mjs && git add agents/manifest.json");
78
+ return 1;
79
+ }
80
+ console.log(`[gen-subagent-manifest] up to date (${Object.keys(manifest.agents).length} agents).`);
81
+ return invalid.length && strict ? 2 : 0;
82
+ }
83
+
84
+ const p = writeManifest(root, manifest);
85
+ console.log(`[gen-subagent-manifest] wrote ${p} (${Object.keys(manifest.agents).length} agents, sdk ${manifest.sdkVersion || "?"}).`);
86
+ if (manifest.emptyDirs && manifest.emptyDirs.length) {
87
+ console.warn(`[gen-subagent-manifest] ${manifest.emptyDirs.length} agents/ directory(ies) have no agent.md: ${manifest.emptyDirs.join(", ")}`);
88
+ }
89
+ return invalid.length && strict ? 2 : 0;
90
+ }
91
+
92
+ // Only run when invoked directly (importable for tests).
93
+ if (process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url))) {
94
+ process.exit(main());
95
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * gen-subagent-manifest.test.mjs — the layer-0 manifest generator + its CI gate.
3
+ * Run: node --test scripts/setup/gen-subagent-manifest.test.mjs
4
+ */
5
+ "use strict";
6
+
7
+ import { test } from "node:test";
8
+ import assert from "node:assert/strict";
9
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
10
+ import { tmpdir } from "node:os";
11
+ import { dirname, join } from "node:path";
12
+ import { fileURLToPath } from "node:url";
13
+
14
+ import { main } from "./gen-subagent-manifest.mjs";
15
+ import { buildManifest, manifestPath, readManifest } from "../../lib/subagents/manifest.mjs";
16
+ import { hashFile, stringifyAgentMd } from "../../lib/subagents/schema.mjs";
17
+
18
+ const MAESTRO_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
19
+
20
+ function put(root, slug, body = `# ${slug}\n\nprose\n`, over = {}) {
21
+ const text = stringifyAgentMd({ name: slug, description: `${slug} does things`, model: "sonnet", tools: ["Read"], ...over }, body);
22
+ mkdirSync(join(root, "agents", slug), { recursive: true });
23
+ writeFileSync(join(root, "agents", slug, "agent.md"), text);
24
+ return text;
25
+ }
26
+ function fixture(version = "1.2.3") {
27
+ const root = mkdtempSync(join(tmpdir(), "gen-manifest-"));
28
+ writeFileSync(join(root, "package.json"), JSON.stringify({ version }));
29
+ return root;
30
+ }
31
+ /** Run main() with its console noise captured. */
32
+ function runMain(argv, root) {
33
+ const log = console.log, warn = console.warn, error = console.error;
34
+ const out = [];
35
+ console.log = console.warn = console.error = (...a) => out.push(a.join(" "));
36
+ try { return { code: main(argv, root), out: out.join("\n") }; }
37
+ finally { console.log = log; console.warn = warn; console.error = error; }
38
+ }
39
+
40
+ test("writes agents/manifest.json with the package version and per-agent shas", () => {
41
+ const root = fixture("1.2.3");
42
+ const text = put(root, "alpha");
43
+ const { code } = runMain([], root);
44
+ assert.equal(code, 0);
45
+ assert.equal(existsSync(manifestPath(root)), true);
46
+ const m = readManifest(root);
47
+ assert.equal(m.sdkVersion, "1.2.3");
48
+ assert.equal(m.agents.alpha.sha256, hashFile(text));
49
+ });
50
+
51
+ test("--check passes when the committed manifest is current", () => {
52
+ const root = fixture();
53
+ put(root, "alpha");
54
+ runMain([], root);
55
+ const { code, out } = runMain(["--check"], root);
56
+ assert.equal(code, 0);
57
+ assert.match(out, /up to date \(1 agents\)/);
58
+ });
59
+
60
+ test("--check FAILS when an agent.md changed without regenerating the manifest", () => {
61
+ // Without this gate a shipped agent.md could change while its sha did not,
62
+ // which would silently reclassify every deployed copy as a local edit and
63
+ // freeze it forever.
64
+ const root = fixture();
65
+ put(root, "alpha");
66
+ runMain([], root);
67
+ put(root, "alpha", "# alpha\n\nEDITED UPSTREAM\n");
68
+ const { code, out } = runMain(["--check"], root);
69
+ assert.equal(code, 1);
70
+ assert.match(out, /STALE/);
71
+ assert.match(out, /gen-subagent-manifest\.mjs && git add/);
72
+ });
73
+
74
+ test("--check fails when the manifest has never been generated", () => {
75
+ const root = fixture();
76
+ put(root, "alpha");
77
+ const { code, out } = runMain(["--check"], root);
78
+ assert.equal(code, 1);
79
+ assert.match(out, /does not exist/);
80
+ });
81
+
82
+ test("--check ignores generatedAt (a per-run timestamp must not make CI permanently red)", () => {
83
+ const root = fixture();
84
+ put(root, "alpha");
85
+ runMain([], root);
86
+ const doc = JSON.parse(readFileSync(manifestPath(root), "utf8"));
87
+ doc.generatedAt = "1999-01-01T00:00:00.000Z";
88
+ writeFileSync(manifestPath(root), JSON.stringify(doc, null, 2));
89
+ assert.equal(runMain(["--check"], root).code, 0);
90
+ });
91
+
92
+ test("an invalid agent.md is REPORTED, and only --strict makes it an exit code", () => {
93
+ const root = fixture();
94
+ put(root, "alpha", "# alpha\n\nprose\n", { name: "wrong-name" });
95
+ const loose = runMain([], root);
96
+ assert.equal(loose.code, 0);
97
+ assert.match(loose.out, /agents\/alpha\/agent\.md is invalid/);
98
+ assert.match(loose.out, /must equal the directory name/);
99
+ assert.equal(runMain(["--strict"], root).code, 2);
100
+ });
101
+
102
+ test("empty agents/<slug>/ directories are warned about", () => {
103
+ const root = fixture();
104
+ put(root, "alpha");
105
+ mkdirSync(join(root, "agents", "hollow"), { recursive: true });
106
+ const { out } = runMain([], root);
107
+ assert.match(out, /have no agent\.md: hollow/);
108
+ });
109
+
110
+ test("regenerating carries the previous sha into history", () => {
111
+ const root = fixture();
112
+ const v1 = put(root, "alpha", "v1\n");
113
+ runMain([], root);
114
+ put(root, "alpha", "v2\n");
115
+ runMain([], root);
116
+ assert.deepEqual(readManifest(root).agents.alpha.history, [hashFile(v1)]);
117
+ });
118
+
119
+ test("the REAL SDK tree would pass the gate (no invalid shipped agents)", () => {
120
+ // Nothing is written to disk — buildManifest is pure. This only proves the
121
+ // shipped corpus is generatable, so `--check` can be wired into CI safely.
122
+ const { invalid } = buildManifest({ maestroRoot: MAESTRO_ROOT, sdkVersion: "test" });
123
+ assert.deepEqual(invalid.map((i) => i.slug), []);
124
+ });