@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
@@ -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
@@ -57,6 +57,19 @@ export function parseInboxItemYaml(body) {
57
57
  const m = body.match(new RegExp(`^\\s{2}${key}:\\s*(\\S+)`, "m"));
58
58
  return /true/i.test((m || [])[1] || "");
59
59
  };
60
+ // TRI-STATE. The confidentiality fields must distinguish "absent" from
61
+ // "false": `is_private: false` is a positive assertion that the source is
62
+ // public, and the daemon's fail-closed outcome gate
63
+ // (scripts/daemon/session-outcomes.mjs) admits on it. Decoding a missing line
64
+ // as `false` — which `bool()` does — would forge that assertion for every
65
+ // item ever written, which is exactly the leak the gate exists to prevent.
66
+ const triBool = (key) => {
67
+ const m = body.match(new RegExp(`^${key}:\\s*(\\S+)`, "m"));
68
+ if (!m) return undefined;
69
+ if (/^true$/i.test(m[1])) return true;
70
+ if (/^false$/i.test(m[1])) return false;
71
+ return undefined;
72
+ };
60
73
 
61
74
  // Block scalar `content: |` — capture the indented block.
62
75
  const blockScalar = (key) => {
@@ -66,7 +79,11 @@ export function parseInboxItemYaml(body) {
66
79
  return m[1].replace(/^ {2}/gm, "").replace(/\n+$/, "");
67
80
  };
68
81
 
69
- return {
82
+ const channelType = scalar("channel_type");
83
+ const isPrivate = triBool("is_private");
84
+ const isDm = triBool("is_dm");
85
+
86
+ const item = {
70
87
  id: scalar("id"),
71
88
  service: scalar("service"),
72
89
  channel: scalar("channel"),
@@ -88,6 +105,14 @@ export function parseInboxItemYaml(body) {
88
105
  },
89
106
  raw_ref: scalar("raw_ref"),
90
107
  };
108
+
109
+ // Only attached when the writer actually knew (see triBool) — an item with no
110
+ // privacy signal must stay signal-less so the outcome gate fails closed.
111
+ if (channelType) item.channel_type = channelType;
112
+ if (isPrivate !== undefined) item.is_private = isPrivate;
113
+ if (isDm !== undefined) item.is_dm = isDm;
114
+
115
+ return item;
91
116
  }
92
117
 
93
118
  /**
@@ -16,6 +16,7 @@ import { join } from "node:path";
16
16
  import { parseInboxItemYaml, scanInbox } from "./inbox-scan-poller.mjs";
17
17
  import { markDispatched, markInboxProcessed, writeInboxItem } from "./utils.mjs";
18
18
  import { eventToInboxItem } from "../../lib/channels/inbox-item.mjs";
19
+ import { outcomeSourceShareable } from "../daemon/session-outcomes.mjs";
19
20
 
20
21
  async function makeRoot() {
21
22
  const p = join(tmpdir(), `inbox-scan-test-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
@@ -229,3 +230,66 @@ raw_ref: "${item.raw_ref || ""}"
229
230
  if (item.kind) yaml += `kind: "${item.kind}"\n`;
230
231
  return yaml;
231
232
  }
233
+
234
+ // ---------------------------------------------------------------------------
235
+ // CONFIDENTIALITY — the privacy signal must survive the YAML round-trip
236
+ // ---------------------------------------------------------------------------
237
+ //
238
+ // The daemon's outcome gate (scripts/daemon/session-outcomes.mjs) is fail-closed
239
+ // and admits a turn's commitments as ORG-VISIBLE only on a POSITIVE public
240
+ // signal, because Slack's conversation-ID prefix is not a reliable type
241
+ // indicator — private channels carry `C…` ids too. That signal is minted at
242
+ // ingest and reaches the daemon only through this writer/reader pair, so the
243
+ // round-trip is part of the security boundary and is tested as such.
244
+
245
+ test("privacy signals survive writeInboxItem → scanInbox, and drive the outcome gate", async () => {
246
+ const root = await makeRoot();
247
+ try {
248
+ const base = {
249
+ service: "slack",
250
+ sender: "casey",
251
+ sender_privilege: "unknown",
252
+ timestamp: "2026-06-09T10:00:00.000Z",
253
+ content: "hello",
254
+ thread_id: "",
255
+ is_reply: false,
256
+ priority_signals: { from_ceo: false, tagged_urgent: false, contains_deadline: false, mentions_agent: true },
257
+ };
258
+ writeInboxItem("slack", {
259
+ ...base, id: "pub-1", channel: "general", channel_id: "C0GENERAL",
260
+ channel_type: "channel", is_private: false, is_dm: false,
261
+ subject: "#general", raw_ref: "slack:C0GENERAL:1",
262
+ }, root);
263
+ // A PRIVATE channel that Slack minted with a `C…` id — the case the old
264
+ // prefix test admitted, and the leak this whole chain exists to stop.
265
+ writeInboxItem("slack", {
266
+ ...base, id: "priv-1", channel: "leadership", channel_id: "C0LEADERSHIP",
267
+ channel_type: "group", is_private: true, is_dm: false,
268
+ subject: "#leadership", raw_ref: "slack:C0LEADERSHIP:2",
269
+ }, root);
270
+ // An item minted before/without any privacy resolution.
271
+ writeInboxItem("slack", {
272
+ ...base, id: "unk-1", channel: "mystery", channel_id: "C0MYSTERY",
273
+ subject: "#mystery", raw_ref: "slack:C0MYSTERY:3",
274
+ }, root);
275
+
276
+ const byId = Object.fromEntries(
277
+ scanInbox("slack", { agentRoot: root }).items.map((i) => [i.id, i])
278
+ );
279
+ assert.equal(Object.keys(byId).length, 3);
280
+
281
+ assert.equal(byId["pub-1"].channel_type, "channel");
282
+ assert.equal(byId["pub-1"].is_private, false, "an explicit false must survive as false, not vanish");
283
+ assert.equal(byId["priv-1"].channel_type, "group");
284
+ assert.equal(byId["priv-1"].is_private, true);
285
+ assert.equal(
286
+ "is_private" in byId["unk-1"], false,
287
+ "an unresolved item must carry NO privacy field — a defaulted `false` would forge a public verdict"
288
+ );
289
+ assert.equal("channel_type" in byId["unk-1"], false);
290
+
291
+ assert.deepEqual(outcomeSourceShareable(byId["pub-1"]), { shareable: true, reason: "public-channel" });
292
+ assert.deepEqual(outcomeSourceShareable(byId["priv-1"]), { shareable: false, reason: "private-channel" });
293
+ assert.deepEqual(outcomeSourceShareable(byId["unk-1"]), { shareable: false, reason: "privacy-unknown" });
294
+ } finally { await rmRoot(root); }
295
+ });
@@ -306,6 +306,11 @@ function handleWebhook(webhook, identity, peerSlackIds) {
306
306
  const payload = {
307
307
  ...webhook,
308
308
  channel_id: event.channel || "",
309
+ // CONFIDENTIALITY: carry Slack's conversation type through to the reader
310
+ // (slack-poller's events-JSON branch). The id prefix is not a reliable type
311
+ // indicator, so this is the only signal that lets the daemon's outcome gate
312
+ // establish a source as public. Omitted when Slack didn't send one.
313
+ ...(event.channel_type ? { channel_type: event.channel_type } : {}),
309
314
  received_at: new Date().toISOString(),
310
315
  source: "cloud-relay",
311
316
  };
@@ -12,6 +12,9 @@ import {
12
12
  AGENT_REPO_DIR,
13
13
  extractSlackAttachments,
14
14
  downloadSlackAttachment,
15
+ // One shared decoder for Slack's `channel_type` → the confidentiality fields
16
+ // (channel_type / is_private / is_dm) every ingest path stamps on an item.
17
+ slackChannelPrivacy,
15
18
  } from "./utils.mjs";
16
19
 
17
20
  import {
@@ -82,6 +85,12 @@ async function getMonitoredChannels() {
82
85
  id: c.id,
83
86
  name: c.name || c.id,
84
87
  priority: CHANNEL_PRIORITY_OVERRIDES[c.name] || "normal",
88
+ // CONFIDENTIALITY. `types` includes private_channel, and a private
89
+ // channel can carry a `C…` id — the prefix proves nothing. Keep Slack's
90
+ // own verdict so items minted from this channel can carry it downstream
91
+ // to the daemon's fail-closed outcome gate. Only a real boolean counts;
92
+ // anything else stays undefined (unknown ⇒ not established as public).
93
+ is_private: typeof c.is_private === "boolean" ? c.is_private : undefined,
85
94
  }));
86
95
  channelsCachedAt = Date.now();
87
96
  return cachedChannels;
@@ -318,6 +327,9 @@ export async function pollSlack() {
318
327
  },
319
328
  raw_ref: rawRef,
320
329
  channel_id: im.id,
330
+ channel_type: "im",
331
+ is_private: true,
332
+ is_dm: true,
321
333
  });
322
334
  }
323
335
  }
@@ -411,6 +423,12 @@ export async function pollSlack() {
411
423
  thread_id: msg.thread_ts || "",
412
424
  thread_context: channelThreadContext,
413
425
  is_reply: !!msg.thread_ts,
426
+ // Slack's own privacy verdict (see getMonitoredChannels); undefined
427
+ // when unknown so the downstream gate stays fail-closed.
428
+ is_private: channel.is_private,
429
+ channel_type: typeof channel.is_private === "boolean"
430
+ ? (channel.is_private ? "group" : "channel")
431
+ : undefined,
414
432
  attachments: attachments.length > 0 ? attachments : undefined,
415
433
  priority_signals: {
416
434
  from_ceo: isCeo,
@@ -538,6 +556,9 @@ export async function pollSlack() {
538
556
  mentions_agent: true,
539
557
  },
540
558
  raw_ref: `slack:${im.id}:${msg.ts}`,
559
+ channel_type: "im",
560
+ is_private: true,
561
+ is_dm: true,
541
562
  });
542
563
  }
543
564
 
@@ -722,6 +743,13 @@ export async function pollSlack() {
722
743
  mentions_agent: isDmThread || (loadAgent().firstName && new RegExp(loadAgent().firstName, "i").test(replyText)),
723
744
  },
724
745
  raw_ref: `slack:${channelId}:${reply.ts}`,
746
+ // Same privacy verdict as the parent conversation: an im is private
747
+ // by construction, a channel carries whatever Slack said it is.
748
+ ...(isDmThread
749
+ ? { channel_type: "im", is_private: true, is_dm: true }
750
+ : (chanInfo && typeof chanInfo.is_private === "boolean"
751
+ ? { channel_type: chanInfo.is_private ? "group" : "channel", is_private: chanInfo.is_private }
752
+ : {})),
725
753
  });
726
754
  }
727
755
  } catch (err) {
@@ -824,6 +852,10 @@ export async function pollSlack() {
824
852
  mentions_agent: loadAgent().firstName ? new RegExp(loadAgent().firstName, "i").test(content) : false,
825
853
  },
826
854
  raw_ref: eventRef,
855
+ // Slack's conversation type off the delivered envelope — the only
856
+ // dependable privacy signal (the id prefix is not one). Absent on
857
+ // older payloads, in which case we assert nothing.
858
+ ...slackChannelPrivacy(data.channel_type || evt.channel_type),
827
859
  _inbox_file: file,
828
860
  });
829
861
 
@@ -37,7 +37,7 @@ import { existsSync, readFileSync, mkdirSync, appendFileSync } from "node:fs";
37
37
  import { dirname, join, resolve } from "node:path";
38
38
  import { fileURLToPath } from "node:url";
39
39
 
40
- import { writeInboxItem, resolvePrivilege, resolveName, AGENT_REPO_DIR } from "./utils.mjs";
40
+ import { writeInboxItem, resolvePrivilege, resolveName, slackChannelPrivacy, AGENT_REPO_DIR } from "./utils.mjs";
41
41
  import { handleSlashCommand, postSlashResponse } from "./lib/slash-command-handlers.mjs";
42
42
 
43
43
  /**
@@ -276,6 +276,20 @@ export function shouldKeepEvent(envelope, { ownSlackId, peerSlackIds, reactions,
276
276
  // Translation — Slack envelope → poller-compatible inbox item
277
277
  // ---------------------------------------------------------------------------
278
278
 
279
+ /**
280
+ * CONFIDENTIALITY SIGNAL. Slack's conversation-ID prefix is NOT a reliable type
281
+ * indicator — a private channel can carry a `C…` id, and only `D` for a DM is
282
+ * dependable. The dependable signal is the event envelope's own `channel_type`,
283
+ * which this listener used to drop on the floor. We now carry it onto the inbox
284
+ * item, together with the booleans it implies, because the daemon's outcome gate
285
+ * (scripts/daemon/session-outcomes.mjs `outcomeSourceShareable`) requires a
286
+ * POSITIVE public signal before a turn's commitments may become org-visible.
287
+ *
288
+ * Re-exported from ./utils.mjs, the one place the decoding is defined (the
289
+ * Web-API poller stamps the same fields).
290
+ */
291
+ export { slackChannelPrivacy } from "./utils.mjs";
292
+
279
293
  /**
280
294
  * Convert a Slack Socket Mode envelope to the same inbox-item shape that
281
295
  * `slack-poller.mjs` produces. Pure function; all I/O happens at the caller.
@@ -307,6 +321,9 @@ export function eventToInboxItem(envelope, { ownSlackId, principalSlackId, agent
307
321
  const channelLabel = isDm ? `dm/${senderName}` : channelId;
308
322
  const verb = event.type === "reaction_added" ? "added" : "removed";
309
323
  const targetText = typeof threadContext === "string" ? threadContext : "";
324
+ // A reaction envelope carries no `channel_type`; only the `D` prefix is
325
+ // dependable, so that is all we assert (and only in the private direction).
326
+ const reactionPrivacy = isDm ? { channel_type: "im", is_private: true, is_dm: true } : {};
310
327
  return {
311
328
  id: `${targetTs.replace(".", "-")}-reaction-${reactor}`,
312
329
  service: "slack",
@@ -329,6 +346,7 @@ export function eventToInboxItem(envelope, { ownSlackId, principalSlackId, agent
329
346
  raw_ref: `slack:${channelId}:${targetTs}:reaction`,
330
347
  source: "socket-mode",
331
348
  kind: "reaction",
349
+ ...reactionPrivacy,
332
350
  };
333
351
  }
334
352
 
@@ -338,6 +356,12 @@ export function eventToInboxItem(envelope, { ownSlackId, principalSlackId, agent
338
356
  const channelId = event.channel || "";
339
357
  const threadTs = event.thread_ts || "";
340
358
  const isReply = !!threadTs && threadTs !== ts;
359
+ // Slack's own conversation type, when the envelope carries one. See
360
+ // `slackChannelPrivacy` — this, not the id prefix, is the privacy signal.
361
+ const privacy = slackChannelPrivacy(event.channel_type);
362
+ // Routing/labelling stays on the historical `D`-prefix test (an `im` always
363
+ // has a `D` id anyway, and promoting an `mpim` to "DM" would change who the
364
+ // agent auto-replies to). `privacy` only informs the confidentiality fields.
341
365
  const isDm = typeof channelId === "string" && channelId.startsWith("D");
342
366
 
343
367
  // Friendly labels. We can't resolve a channel name without an API call
@@ -383,6 +407,8 @@ export function eventToInboxItem(envelope, { ownSlackId, principalSlackId, agent
383
407
  },
384
408
  raw_ref: `slack:${channelId}:${ts}`,
385
409
  source: "socket-mode",
410
+ // channel_type / is_private / is_dm — only when Slack actually told us.
411
+ ...privacy,
386
412
  };
387
413
  if (isCc) item.kind = "channel_cc";
388
414
  return item;
@@ -279,6 +279,58 @@ test("eventToInboxItem: thread reply preserves thread_id + is_reply", () => {
279
279
  assert.equal(item.thread_id, "1735689500.000100");
280
280
  });
281
281
 
282
+ test("eventToInboxItem: carries Slack's channel_type as the privacy signal", () => {
283
+ // CONFIDENTIALITY. The listener used to drop `event.channel_type` entirely and
284
+ // derive privacy from the id prefix alone — but Slack mints `C…` ids for
285
+ // PRIVATE channels too, so downstream (the daemon's fail-closed outcome gate)
286
+ // had nothing dependable to admit on. Every kept event now carries the type,
287
+ // and the booleans it implies, verbatim off the envelope.
288
+ const mk = (channel, channelType) => eventToInboxItem(
289
+ makeEnvelope({
290
+ event: {
291
+ user: HUMAN_SLACK_ID,
292
+ channel,
293
+ ...(channelType === undefined ? {} : { channel_type: channelType }),
294
+ text: `<@${AGENT_SLACK_ID}> noted`,
295
+ ts: "1735689700.002000",
296
+ },
297
+ }),
298
+ { ownSlackId: AGENT_SLACK_ID, principalSlackId: PRINCIPAL_SLACK_ID, agentFirstName: "Ravi" }
299
+ );
300
+
301
+ const pub = mk("C0CHANNEL1", "channel");
302
+ assert.equal(pub.channel_type, "channel");
303
+ assert.equal(pub.is_private, false);
304
+ assert.equal(pub.is_dm, false);
305
+
306
+ // A private channel WITH a public-looking `C…` id — the leak vector.
307
+ const priv = mk("C0PRIVATE1", "group");
308
+ assert.equal(priv.channel_type, "group");
309
+ assert.equal(priv.is_private, true);
310
+
311
+ const mpim = mk("C0MPIM0001", "mpim");
312
+ assert.equal(mpim.channel_type, "mpim");
313
+ assert.equal(mpim.is_private, true);
314
+ assert.equal(mpim.is_dm, true);
315
+ assert.equal(mpim.channel, "C0MPIM0001", "an mpim is still routed as a channel, not promoted to a DM");
316
+
317
+ const im = mk("DABCDEF01", "im");
318
+ assert.equal(im.channel_type, "im");
319
+ assert.equal(im.is_private, true);
320
+ assert.equal(im.is_dm, true);
321
+
322
+ // No type on the envelope ⇒ assert NOTHING (a defaulted `is_private:false`
323
+ // would forge a public verdict the gate then admits on).
324
+ const unknown = mk("C0CHANNEL1", undefined);
325
+ assert.equal("channel_type" in unknown, false);
326
+ assert.equal("is_private" in unknown, false);
327
+ assert.equal("is_dm" in unknown, false);
328
+
329
+ // An unrecognised type is likewise not decoded into a verdict.
330
+ const weird = mk("C0CHANNEL1", "external_shared");
331
+ assert.equal("is_private" in weird, false);
332
+ });
333
+
282
334
  test("eventToInboxItem: first-name mention (no @) still flips mentions_agent", () => {
283
335
  // "Hey Ravi, can you …" in a channel — no explicit @-mention but the
284
336
  // agent's first name appears. The poller uses this same heuristic.
@@ -10,6 +10,35 @@ export const AGENT_REPO_DIR = process.env.AGENT_DIR || join(__dirname, "../..");
10
10
  export const SOPHIE_AI_DIR = AGENT_REPO_DIR;
11
11
  export const ATTACHMENTS_DIR = join(AGENT_REPO_DIR, "state", "inbox", "attachments");
12
12
 
13
+ /**
14
+ * CONFIDENTIALITY SIGNAL — decode Slack's conversation type into the fields an
15
+ * inbox item carries. Every Slack ingest path (Socket Mode, the Web-API poller,
16
+ * the cloud relay) routes through this one decoder so the meaning of
17
+ * `channel_type` / `is_private` / `is_dm` is defined in exactly one place.
18
+ *
19
+ * WHY IT EXISTS: Slack's conversation-ID prefix is NOT a reliable type
20
+ * indicator — a private channel can and does carry a `C…` id; only `D` for a DM
21
+ * is dependable. The dependable signal is `channel_type` on the event envelope
22
+ * ("channel" public | "group" private | "im" DM | "mpim" group DM), so that is
23
+ * what we decode and carry to the daemon's fail-closed outcome gate
24
+ * (scripts/daemon/session-outcomes.mjs `outcomeSourceShareable`).
25
+ *
26
+ * Returns `{}` for anything unrecognised or missing: asserting nothing keeps
27
+ * that gate closed, which is the safe direction.
28
+ *
29
+ * @param {unknown} channelType raw Slack `channel_type`
30
+ * @returns {{channel_type?:string, is_private?:boolean, is_dm?:boolean}}
31
+ */
32
+ export function slackChannelPrivacy(channelType) {
33
+ switch (String(channelType || "").trim().toLowerCase()) {
34
+ case "channel": return { channel_type: "channel", is_private: false, is_dm: false };
35
+ case "group": return { channel_type: "group", is_private: true, is_dm: false };
36
+ case "im": return { channel_type: "im", is_private: true, is_dm: true };
37
+ case "mpim": return { channel_type: "mpim", is_private: true, is_dm: true };
38
+ default: return {};
39
+ }
40
+ }
41
+
13
42
  export function writeInboxItem(service, item, agentRoot = AGENT_REPO_DIR) {
14
43
  const dir = join(agentRoot, "state", "inbox", service);
15
44
  mkdirSync(dir, { recursive: true });
@@ -159,6 +188,24 @@ raw_ref: "${item.raw_ref || ""}"
159
188
  yaml += `channel_id: "${item.channel_id}"\n`;
160
189
  }
161
190
 
191
+ // CONFIDENTIALITY SIGNALS. Slack's conversation-ID prefix is not a reliable
192
+ // type indicator (a private channel can carry a `C…` id), so downstream
193
+ // consumers — notably the daemon's fail-closed outcome gate,
194
+ // scripts/daemon/session-outcomes.mjs `outcomeSourceShareable` — need the
195
+ // conversation type/privacy the ingest layer read off the event itself.
196
+ // Written ONLY when actually known: an absent line must stay absent, because
197
+ // `is_private: false` is a positive "this is public" assertion and the reader
198
+ // (inbox-scan-poller.mjs `parseInboxItemYaml`) decodes it as a tri-state.
199
+ if (item.channel_type) {
200
+ yaml += `channel_type: "${item.channel_type}"\n`;
201
+ }
202
+ if (typeof item.is_private === "boolean") {
203
+ yaml += `is_private: ${item.is_private}\n`;
204
+ }
205
+ if (typeof item.is_dm === "boolean") {
206
+ yaml += `is_dm: ${item.is_dm}\n`;
207
+ }
208
+
162
209
  // Add event_type if present (e.g., message_changed, reaction)
163
210
  if (item.event_type) {
164
211
  yaml += `event_type: "${item.event_type}"\n`;
@@ -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
+ }