@cohortapp/agent-sdk 2.11.14 → 2.12.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 (172) hide show
  1. package/.env.example +37 -22
  2. package/README.md +2 -0
  3. package/bin/maestro.mjs +113 -39
  4. package/bin/maestro.test.mjs +175 -5
  5. package/docs/guides/front-door-session.md +264 -0
  6. package/docs/guides/mac-mini.md +100 -28
  7. package/docs/guides/org-onboarding.md +1 -1
  8. package/docs/guides/setup-wizard.md +9 -5
  9. package/docs/runbooks/cohort-cutover.md +11 -1
  10. package/docs/runbooks/mac-mini-bootstrap.md +38 -63
  11. package/lib/cadence-bus-requeue.test.mjs +83 -0
  12. package/lib/cadence-bus.mjs +43 -7
  13. package/lib/channels/inbox-item.mjs +59 -2
  14. package/lib/cli/board.mjs +285 -0
  15. package/lib/cli/board.test.mjs +227 -0
  16. package/lib/cli/doctor-checks.mjs +441 -0
  17. package/lib/cli/doctor-checks.test.mjs +336 -0
  18. package/lib/cli/global-setup-extras.mjs +410 -0
  19. package/lib/cli/global-setup-extras.test.mjs +367 -0
  20. package/lib/cli/inbox.mjs +304 -0
  21. package/lib/cli/inbox.test.mjs +230 -0
  22. package/lib/cli/session-ack.mjs +63 -0
  23. package/lib/cli/session-ack.test.mjs +63 -0
  24. package/lib/cli/session.mjs +750 -0
  25. package/lib/cli/session.test.mjs +602 -0
  26. package/lib/collective/global-config.mjs +204 -6
  27. package/lib/collective/global-config.test.mjs +140 -0
  28. package/lib/collective/global-skills.mjs +145 -0
  29. package/lib/collective/global-skills.test.mjs +126 -0
  30. package/lib/collective/presence.mjs +4 -3
  31. package/lib/comms/send-gate.mjs +115 -0
  32. package/lib/comms/send-gate.test.mjs +113 -0
  33. package/lib/feature-init.mjs +2 -2
  34. package/lib/identity/persona.mjs +29 -0
  35. package/lib/identity/persona.test.mjs +26 -1
  36. package/lib/mcp/server.test.mjs +9 -4
  37. package/lib/model-router/spawn.test.mjs +21 -0
  38. package/lib/org/board-mine-cache.mjs +99 -0
  39. package/lib/org/board-mine-cache.test.mjs +53 -0
  40. package/lib/org/board.mjs +11 -0
  41. package/lib/org/board.test.mjs +11 -1
  42. package/lib/org/client.mjs +36 -0
  43. package/lib/org/client.test.mjs +46 -0
  44. package/lib/org/inbound/directedness.mjs +18 -2
  45. package/lib/org/inbound/directedness.test.mjs +58 -0
  46. package/lib/org/inbound/index.mjs +8 -1
  47. package/lib/org/inbound/index.test.mjs +22 -0
  48. package/lib/org/mesh-directives.test.mjs +110 -0
  49. package/lib/org/mesh.mjs +61 -1
  50. package/lib/org/protocol.checksum +1 -1
  51. package/lib/org/protocol.mjs +52 -0
  52. package/lib/org/protocol.test.mjs +12 -1
  53. package/lib/org/registry.mjs +3 -2
  54. package/lib/org/tool-surface.mjs +120 -0
  55. package/lib/org/tool-surface.test.mjs +118 -5
  56. package/lib/security/external-content.mjs +1 -1
  57. package/lib/security/external-content.test.mjs +17 -0
  58. package/lib/session/config.mjs +137 -0
  59. package/lib/session/config.test.mjs +92 -0
  60. package/lib/session/feed-core.mjs +229 -0
  61. package/lib/session/feed-core.test.mjs +198 -0
  62. package/lib/session/first-run.mjs +126 -0
  63. package/lib/session/first-run.test.mjs +121 -0
  64. package/lib/session/frontdoor.mjs +266 -0
  65. package/lib/session/frontdoor.test.mjs +205 -0
  66. package/lib/session/handoffs.mjs +295 -0
  67. package/lib/session/handoffs.test.mjs +183 -0
  68. package/lib/session/identity.mjs +220 -0
  69. package/lib/session/identity.test.mjs +180 -0
  70. package/lib/session/inbox-claims.mjs +434 -0
  71. package/lib/session/inbox-claims.test.mjs +286 -0
  72. package/lib/session/launch-args.mjs +161 -0
  73. package/lib/session/launch-args.test.mjs +157 -0
  74. package/lib/session/liveness.mjs +174 -0
  75. package/lib/session/liveness.test.mjs +100 -0
  76. package/lib/session/status-summary.mjs +172 -0
  77. package/lib/session/status-summary.test.mjs +118 -0
  78. package/lib/session-permissions.mjs +39 -3
  79. package/lib/session-permissions.test.mjs +20 -0
  80. package/lib/setup/claude-probe.mjs +161 -24
  81. package/lib/setup/claude-probe.test.mjs +187 -0
  82. package/lib/setup/sections/learning.mjs +2 -1
  83. package/lib/setup/sections/model.mjs +104 -24
  84. package/lib/setup/sections/model.test.mjs +240 -0
  85. package/lib/setup/sections/org.mjs +27 -2
  86. package/lib/setup/sections/org.test.mjs +35 -2
  87. package/lib/setup/sections/verify.mjs +5 -0
  88. package/lib/setup/state.mjs +30 -10
  89. package/lib/setup/state.test.mjs +24 -1
  90. package/lib/singleton.js +11 -3
  91. package/lib/singleton.test.mjs +16 -0
  92. package/lib/subagents/lock.mjs +1 -1
  93. package/lib/telemetry/collect.mjs +270 -6
  94. package/lib/telemetry/collect.test.mjs +196 -1
  95. package/lib/upgrade/global-refresh.mjs +108 -0
  96. package/lib/upgrade/global-refresh.test.mjs +65 -0
  97. package/lib/upgrade/launchd-reconcile.mjs +327 -0
  98. package/lib/upgrade/launchd-reconcile.test.mjs +272 -0
  99. package/lib/upgrade/post-steps.mjs +151 -0
  100. package/lib/upgrade/post-steps.test.mjs +200 -0
  101. package/lib/upgrade/verify.mjs +215 -0
  102. package/lib/upgrade/verify.test.mjs +164 -0
  103. package/lib/voice/outbound.mjs +3 -2
  104. package/lib/voice/post-call-brief.mjs +2 -1
  105. package/lib/voice/session-rotation.mjs +6 -1
  106. package/lib/voice/session-rotation.test.mjs +114 -0
  107. package/package.json +3 -3
  108. package/plugins/maestro-skills/plugin.json +21 -1
  109. package/plugins/maestro-skills/skills/board-work.md +63 -0
  110. package/plugins/maestro-skills/skills/inbound-triage.md +80 -0
  111. package/plugins/maestro-skills/skills/main-session.md +102 -0
  112. package/plugins/maestro-skills/skills/peer-sessions.md +65 -0
  113. package/plugins/maestro-skills/skills/persona-discipline.md +75 -0
  114. package/scaffold/CLAUDE.md +34 -0
  115. package/scripts/ci/check-durable-write-seam.mjs +147 -0
  116. package/scripts/ci/check-durable-write-seam.test.mjs +90 -0
  117. package/scripts/ci/check.mjs +3 -0
  118. package/scripts/collective/hook-runner.mjs +39 -4
  119. package/scripts/collective/hook-runner.test.mjs +85 -2
  120. package/scripts/daemon/agent-daemon-board-mine.test.mjs +96 -0
  121. package/scripts/daemon/agent-daemon-frontdoor.test.mjs +60 -0
  122. package/scripts/daemon/agent-daemon.mjs +141 -10
  123. package/scripts/daemon/agent-daemon.test.mjs +73 -0
  124. package/scripts/daemon/assurance-e2e.test.mjs +141 -6
  125. package/scripts/daemon/assurance.mjs +461 -37
  126. package/scripts/daemon/assurance.test.mjs +408 -43
  127. package/scripts/daemon/cadence-consumer-frontdoor.test.mjs +334 -0
  128. package/scripts/daemon/cadence-consumer.mjs +254 -78
  129. package/scripts/daemon/cadence-handlers.mjs +53 -0
  130. package/scripts/daemon/classifier.mjs +1 -1
  131. package/scripts/daemon/dispatcher-resume.test.mjs +166 -0
  132. package/scripts/daemon/dispatcher.mjs +127 -19
  133. package/scripts/daemon/health.mjs +12 -1
  134. package/scripts/daemon/inbox-deferral-session.test.mjs +49 -0
  135. package/scripts/daemon/inbox-deferral.mjs +6 -0
  136. package/scripts/daemon/lib/self-echo.mjs +201 -0
  137. package/scripts/daemon/lib/self-echo.test.mjs +153 -0
  138. package/scripts/daemon/maestro-daemon.mjs +3 -0
  139. package/scripts/daemon/prompt-builder.mjs +9 -1
  140. package/scripts/daemon/prompt-builder.test.mjs +22 -0
  141. package/scripts/daemon/responder.mjs +61 -41
  142. package/scripts/daemon/sdk-version.mjs +51 -0
  143. package/scripts/daemon/sdk-version.test.mjs +31 -0
  144. package/scripts/hooks/pre-send-audit.sh +97 -4
  145. package/scripts/hooks/pre-send-audit.test.mjs +140 -1
  146. package/scripts/local-triggers/autoupdate.sh +243 -19
  147. package/scripts/local-triggers/autoupdate.test.mjs +488 -0
  148. package/scripts/local-triggers/generate-plists.sh +24 -1
  149. package/scripts/local-triggers/generate-plists.test.mjs +49 -11
  150. package/scripts/org/send-orgmail.first-contact.test.mjs +102 -0
  151. package/scripts/org/send-orgmail.mjs +27 -3
  152. package/scripts/poller/inbox-privilege-injection.test.mjs +167 -0
  153. package/scripts/poller/slack-poller.mjs +13 -1
  154. package/scripts/poller/utils.mjs +46 -1
  155. package/scripts/poller-launchd/install.sh +19 -11
  156. package/scripts/poller-launchd/install.test.mjs +243 -0
  157. package/scripts/poller-launchd/launchd-poller-wrapper.sh +92 -0
  158. package/scripts/poller-launchd/migrate.sh +66 -0
  159. package/scripts/poller-launchd/poller.plist.template +4 -2
  160. package/scripts/session/feed.mjs +237 -0
  161. package/scripts/session/feed.test.mjs +196 -0
  162. package/scripts/session/supervisor-sh.test.mjs +218 -0
  163. package/scripts/session/supervisor.mjs +328 -0
  164. package/scripts/session/supervisor.sh +141 -0
  165. package/scripts/session/supervisor.test.mjs +482 -0
  166. package/scripts/setup/configure-macos.sh +250 -55
  167. package/scripts/setup/configure-macos.test.mjs +306 -0
  168. package/scripts/setup/init-agent.sh +112 -7
  169. package/scripts/setup/init-agent.test.mjs +220 -1
  170. package/scripts/watchdog/memory-watchdog.sh +37 -1
  171. package/scripts/watchdog/memory-watchdog.test.mjs +64 -0
  172. package/scripts/setup/boot-claude-session.sh +0 -94
@@ -0,0 +1,153 @@
1
+ /**
2
+ * self-echo.test.mjs — the sender==self match that three production recurrences
3
+ * (2026-08-25 / 08-27 / 08-30) proved the old inline check could not make.
4
+ *
5
+ * Run: node --test scripts/daemon/lib/self-echo.test.mjs
6
+ *
7
+ * The load-bearing case is the REAL event shape: cohort inbox items carry the
8
+ * display name verbatim (`sender: "Isla Roselli"`), while the old filter
9
+ * compared against `fullName.toLowerCase().replace(/\s+/g, "-")` —
10
+ * "isla-roselli" — so the echo sailed through and the agent acked itself.
11
+ */
12
+
13
+ import { test, describe, beforeEach } from "node:test";
14
+ import assert from "node:assert/strict";
15
+ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from "node:fs";
16
+ import { join } from "node:path";
17
+ import { tmpdir } from "node:os";
18
+
19
+ import { loadSelfIdentity, isSelfSender, normalizeName, logSelfEchoDrop, _resetSelfIdentity } from "./self-echo.mjs";
20
+
21
+ const ROOT = mkdtempSync(join(tmpdir(), "self-echo-test-"));
22
+ mkdirSync(join(ROOT, "config"), { recursive: true });
23
+ writeFileSync(join(ROOT, "config", "agent.json"), JSON.stringify({
24
+ firstName: "Isla",
25
+ fullName: "Isla Roselli",
26
+ memberId: "cmqh0tcml004ihhl6zev6ocb5",
27
+ email: "isla@example.com",
28
+ org: { cohort: { agentId: "A016" } },
29
+ }));
30
+
31
+ beforeEach(() => _resetSelfIdentity());
32
+
33
+ const me = () => loadSelfIdentity(ROOT);
34
+
35
+ describe("normalizeName", () => {
36
+ test("folds case and the separator zoo to one comparable form", () => {
37
+ for (const v of ["Isla Roselli", "isla roselli", "isla-roselli", "Isla_Roselli", "isla.roselli", " Isla Roselli "]) {
38
+ assert.equal(normalizeName(v), "isla roselli", `"${v}" must normalise`);
39
+ }
40
+ });
41
+ });
42
+
43
+ describe("isSelfSender — the shapes real events actually carry", () => {
44
+ test("REGRESSION: the verbatim display name — the exact 25/27/30 Aug echo shape — matches", () => {
45
+ // Field-for-field the 30 Aug production echo (cmtfqjwvv01kype01t002t8jf):
46
+ const echo = {
47
+ id: "cohort-cmtfqjwvv01kype01t002t8jf",
48
+ service: "cohort",
49
+ channel: "dm-a016-a039",
50
+ sender: "Isla Roselli",
51
+ raw_ref: "cohort:dm:cmtfqjwvv01kype01t002t8jf:192025",
52
+ event_kind: "send",
53
+ is_dm: true,
54
+ };
55
+ assert.equal(isSelfSender(echo, me()), true);
56
+ });
57
+
58
+ test("the OLD filter's dashed form and other separator variants also match", () => {
59
+ for (const s of ["isla-roselli", "Isla_Roselli", "isla roselli"]) {
60
+ assert.equal(isSelfSender({ sender: s }, me()), true, `"${s}" is this agent`);
61
+ }
62
+ });
63
+
64
+ test("REGRESSION: the BARE first name alone is NOT self — it collides with every namesake", () => {
65
+ // "Isla" as a display name is any colleague, contact or CC'd person who
66
+ // shares the first name. Matching it dropped their genuine inbound.
67
+ assert.equal(isSelfSender({ sender: "Isla" }, me()), false);
68
+ assert.equal(isSelfSender({ sender: "isla" }, me()), false);
69
+ });
70
+
71
+ test("REGRESSION: a NON-matching id EXONERATES a colliding display name", () => {
72
+ // A namesake — display name "Isla Roselli", but a DIFFERENT member cuid.
73
+ // The id positively identifies the sender as someone else; the name rung
74
+ // must never be reached, so this is NOT self and must not be dropped.
75
+ assert.equal(
76
+ isSelfSender({ sender: "Isla Roselli", sender_id: "cmDIFFERENT00000000000000" }, me()),
77
+ false,
78
+ "an id that resolves to someone else outranks the name collision",
79
+ );
80
+ assert.equal(
81
+ isSelfSender({ sender: "isla-roselli", author_id: "cmDIFFERENT00000000000000" }, me()),
82
+ false,
83
+ );
84
+ });
85
+
86
+ test("REGRESSION: a NON-matching email exonerates a colliding display name too", () => {
87
+ assert.equal(
88
+ isSelfSender({ sender: "Isla Roselli", sender_email: "other.isla@example.com" }, me()),
89
+ false,
90
+ );
91
+ // …while the OWN email still matches regardless of the display name.
92
+ assert.equal(
93
+ isSelfSender({ sender: "totally different name", sender_email: "isla@example.com" }, me()),
94
+ true,
95
+ );
96
+ });
97
+
98
+ test("a member cuid on any id-ish field matches (strongest evidence)", () => {
99
+ for (const item of [
100
+ { sender: "someone", sender_id: "cmqh0tcml004ihhl6zev6ocb5" },
101
+ { sender: "someone", author_id: "CMQH0TCML004IHHL6ZEV6OCB5" },
102
+ { sender: "someone", from: { id: "cmqh0tcml004ihhl6zev6ocb5" } },
103
+ ]) {
104
+ assert.equal(isSelfSender(item, me()), true);
105
+ }
106
+ });
107
+
108
+ test("the org seat slug matches too", () => {
109
+ assert.equal(isSelfSender({ sender: "x", sender_id: "A016" }, me()), true);
110
+ });
111
+
112
+ test("own email matches (gmail/orgmail lanes)", () => {
113
+ assert.equal(isSelfSender({ sender: "Isla via mail", sender_email: "isla@example.com" }, me()), true);
114
+ });
115
+
116
+ test("genuine colleagues NEVER match — no false drops", () => {
117
+ for (const s of ["Jordan Blake", "Candace Wong", "Isla Fisher", "roselli", "isla2", "ceo"]) {
118
+ assert.equal(isSelfSender({ sender: s }, me()), false, `"${s}" must not be dropped`);
119
+ }
120
+ assert.equal(isSelfSender({ sender: "Candace Wong", sender_id: "cmOTHER000000000000000000" }, me()), false);
121
+ });
122
+
123
+ test("an UNCONFIGURED identity matches nothing — a half-set-up repo drops no inbound", () => {
124
+ const bare = mkdtempSync(join(tmpdir(), "self-echo-bare-"));
125
+ try {
126
+ const id = loadSelfIdentity(bare);
127
+ assert.equal(isSelfSender({ sender: "Isla Roselli" }, id), false);
128
+ assert.equal(isSelfSender({ sender: "Agent" }, id), false);
129
+ } finally {
130
+ rmSync(bare, { recursive: true, force: true });
131
+ }
132
+ });
133
+ });
134
+
135
+ describe("logSelfEchoDrop — every drop is auditable", () => {
136
+ test("appends one JSONL row per drop under logs/daemon/", () => {
137
+ const item = { id: "e-1", raw_ref: "cohort:dm:e-1:42", sender: "Isla Roselli", channel: "dm-a016-a039", service: "cohort", event_kind: "send" };
138
+ logSelfEchoDrop(item, { stage: "poll", service: "cohort", agentRoot: ROOT });
139
+ logSelfEchoDrop(item, { stage: "process", service: "cohort", agentRoot: ROOT });
140
+ const rows = readFileSync(join(ROOT, "logs", "daemon", "self-echo-drops.jsonl"), "utf-8")
141
+ .split("\n").filter(Boolean).map((l) => JSON.parse(l));
142
+ assert.equal(rows.length, 2);
143
+ assert.equal(rows[0].stage, "poll");
144
+ assert.equal(rows[1].stage, "process");
145
+ assert.equal(rows[0].raw_ref, "cohort:dm:e-1:42");
146
+ assert.equal(rows[0].sender, "Isla Roselli");
147
+ });
148
+
149
+ test("never throws, even for a junk item", () => {
150
+ assert.doesNotThrow(() => logSelfEchoDrop(null, { agentRoot: ROOT }));
151
+ assert.doesNotThrow(() => logSelfEchoDrop(undefined, { agentRoot: join(ROOT, "no", "such", "deep") }));
152
+ });
153
+ });
@@ -32,6 +32,9 @@ const AGENT_DIR = process.env.AGENT_DIR || resolve(__dirname, "../..");
32
32
 
33
33
  process.env.AGENT_DIR = AGENT_DIR;
34
34
  process.env.AGENT_ROOT = AGENT_DIR;
35
+ // Inherited by every `claude --print` child: scripts/collective/hook-runner.mjs
36
+ // classifies those sessions as "daemon" (presence kind; no identity primer).
37
+ process.env.MAESTRO_DAEMON = "1";
35
38
 
36
39
  // ---------------------------------------------------------------------------
37
40
  // 0. Environment — BEFORE anything reads a credential
@@ -7,7 +7,7 @@ import { readFileSync, readdirSync } from "fs";
7
7
  import { join } from "path";
8
8
  import { createRequire } from "node:module";
9
9
  import { compileContext } from "./context-compiler.mjs";
10
- import { renderPersona } from "../../lib/identity/persona.mjs";
10
+ import { renderPersona, MESSAGE_CRAFT } from "../../lib/identity/persona.mjs";
11
11
  import { wrapExternalContent } from "../../lib/security/external-content.mjs";
12
12
  import { isEnabled as orgEnabled } from "../../lib/org/client.mjs";
13
13
  import { recall as orgRecall } from "../../lib/org/knowledge.mjs";
@@ -876,6 +876,14 @@ export async function buildPrompt(item, classResult, options = {}) {
876
876
  parts.push(SKILLS_GUIDANCE);
877
877
  parts.push("");
878
878
 
879
+ // 1c. Message-craft doctrine — the shared "how you shape an outbound message"
880
+ // rule (distil by default; format only when the message must be large; attach
881
+ // rather than dump genuinely verbose content). Exported from persona.mjs and
882
+ // injected into the responder's quick-reply prompt too, so reactive replies and
883
+ // full sessions write outbound prose the same way.
884
+ parts.push(MESSAGE_CRAFT);
885
+ parts.push("");
886
+
879
887
  // 1a. Holding message warning — TOP OF PROMPT so Claude sees it before action instructions.
880
888
  // This is the most critical instruction in the prompt: prevents double-replies.
881
889
  // We repeat it at section 7a as well, immediately before the action block.
@@ -212,6 +212,28 @@ test("buildPrompt caps the number of injected org facts (bounded)", async () =>
212
212
  });
213
213
  });
214
214
 
215
+ // ---------------------------------------------------------------------------
216
+ // Message-craft doctrine — the shared "how you shape an outbound message" rule
217
+ // (distil by default; format only when the message must be large; attach
218
+ // genuinely verbose content). The SAME constant leads the responder's quick-reply
219
+ // system prompt, so reactive replies and full sessions write outbound prose the
220
+ // same way.
221
+ // ---------------------------------------------------------------------------
222
+
223
+ test("buildPrompt injects the message-craft doctrine (distil / format / attach)", async () => {
224
+ writeOrgConfig(null);
225
+ const prompt = await buildPrompt(ITEM, CLASS, { type: "inbox" });
226
+ assert.match(prompt, /How you shape a message:/);
227
+ assert.match(prompt, /Distil to what matters, by default/);
228
+ assert.match(prompt, /when a message genuinely must be long, FORMAT it/i);
229
+ assert.match(prompt, /do NOT dump it into the chat/);
230
+ // The real attach mechanism, not a vague "share it somewhere".
231
+ assert.match(prompt, /scripts\/pdf-generation\/build-document\.mjs/);
232
+ assert.match(prompt, /scripts\/slack-upload-v2\.py/);
233
+ // The rest of the prompt is unchanged.
234
+ assert.ok(prompt.includes("--- INCOMING MESSAGE ---"));
235
+ });
236
+
215
237
  test("buildPrompt states the acknowledgement was sent when it actually was", async () => {
216
238
  const prompt = await buildPrompt(ITEM, CLASS, {
217
239
  type: "inbox",
@@ -10,14 +10,12 @@
10
10
  * 2. ACKNOWLEDGEMENTS: for complex items that need a full session, an immediate
11
11
  * holding message so the sender knows the work has started.
12
12
  *
13
- * THIS ONE USED TO SPAWN A MODEL TOO, AND THAT WAS THE BUG. A cold
14
- * `claude --print` child under a 60-second cap, on a machine at 95-99%
15
- * memory, to compose two sentences of courtesy: it lost that race 20 times
16
- * in 32, and each loss was caught, logged as `holding_message_error`, and
17
- * told the requester nothing while a 15-45 minute session ran behind a
18
- * typing indicator. It is now composed by assurance.composeAck — a string
19
- * concat and one HTTP call, which cannot time out, cannot be starved of
20
- * memory, and cannot be refused by a spend cap.
13
+ * This one is generated by assurance.generateAck — one bespoke haiku line
14
+ * under a tight (~5s) cap — and on ANY generation failure it sends NOTHING.
15
+ * That is a deliberate inversion (CEO directive 2026-08-30) of the previous
16
+ * canned rotation ("On it." / "Looking now."), which itself replaced a 60s
17
+ * model spawn that lost its race 20 times in 32. The failure budget is now
18
+ * owned by the assurance sweep, not by boilerplate.
21
19
  *
22
20
  * Migrated off `@anthropic-ai/sdk` per CEO directive (Slack DM
23
21
  * D099N1JGKRQ, 2026-04-27 09:38Z + 11:33Z): all agent daemon model
@@ -56,10 +54,18 @@ import { rungById } from "../../lib/execution/route.mjs";
56
54
  // the property the acknowledgement path needed and did not have.
57
55
  import { deliver, deliverWithRetry, resolveSlackChannel } from "./deliver.mjs";
58
56
  import { startTyping, stopTyping } from "./typing-registry.mjs";
59
- // The acknowledgement is now COMPOSED, not generated. See assurance.mjs for why
60
- // (in short: a 60s `claude --print` spawn to write "let me look into it" lost
61
- // its own race 2 times in 3, and lost it silently).
62
- import { composeAck } from "./assurance.mjs";
57
+ // The acknowledgement is GENERATED again — one bespoke haiku line under a
58
+ // ~5s cap — but with the failure mode inverted from the old 60s-spawn era:
59
+ // generation failure now means SILENCE (send nothing), never a canned
60
+ // placeholder. CEO directive 2026-08-30; see assurance.generateAck.
61
+ import { generateAck } from "./assurance.mjs";
62
+ // Shared message-craft doctrine (distil by default; format only when the message
63
+ // must be large; attach genuinely verbose content rather than dumping it). The
64
+ // SAME constant leads the full-session prompt in prompt-builder.mjs, so a reactive
65
+ // quick reply and a full inbox/backlog session shape outbound prose identically.
66
+ // This path never renders the persona block, which is why the doctrine is a
67
+ // separately-exported constant rather than part of voiceRules().
68
+ import { MESSAGE_CRAFT } from "../../lib/identity/persona.mjs";
63
69
 
64
70
  const AGENT_REPO_DIR = process.env.AGENT_DIR || join(new URL(".", import.meta.url).pathname, "../..");
65
71
  const SONNET_MODEL = "claude-sonnet-4-6";
@@ -688,11 +694,10 @@ export function setGenerateResponseForTests(fn) {
688
694
  * Generate an ANSWER. Only an answer.
689
695
  *
690
696
  * This used to take an `isHolding` flag and, when set, spend a model call
691
- * writing an acknowledgement. That branch is gone: acknowledgements are
692
- * composed (assurance.composeAck), and the flag is deliberately not kept "just
693
- * in case", because keeping it is how it comes back. The one thing a model was
694
- * adding to a courtesy sentence was phrasing variety, at a cost of 13-46
695
- * seconds when it worked and total silence when it did not.
697
+ * writing an acknowledgement. That branch stays gone: acknowledgements live in
698
+ * assurance.generateAck (their own model, cap and silence-on-failure rules),
699
+ * and the flag is deliberately not kept "just in case", because keeping it is
700
+ * how it comes back.
696
701
  */
697
702
  async function realGenerateResponse(item, classResult) {
698
703
  const preamble = loadPreamble();
@@ -708,7 +713,9 @@ If it's informational, acknowledge appropriately.
708
713
 
709
714
  Keep responses focused — 1-4 sentences for simple items, up to a short paragraph for more nuanced ones.
710
715
  Match the sender's tone and urgency level.
711
- ${profile ? `\nSender profile:\n${profile}` : ""}`;
716
+ ${profile ? `\nSender profile:\n${profile}` : ""}
717
+
718
+ ${MESSAGE_CRAFT}`;
712
719
 
713
720
  const conversationHistory = await loadConversationHistory(item);
714
721
 
@@ -966,26 +973,29 @@ export async function sendQuickResponse(item, classResult, routed = null) {
966
973
  * Acknowledge an ask that is about to become a long-running session, and return
967
974
  * the text so the session's own prompt can see what the human was already told.
968
975
  *
969
- * THIS NO LONGER CALLS A MODEL, AND THAT IS THE WHOLE POINT.
976
+ * THE ACK IS GENERATED AGAIN — WITH THE FAILURE MODE INVERTED.
970
977
  *
971
- * It used to. `_generateResponse(item, classResult, true)` spawned a cold
972
- * `claude --print` child under a 60-second hard cap to compose two sentences of
973
- * courtesy. On a box at 95-99% memory that spawn lost its race 20 times in 32;
974
- * every loss threw, was caught below, logged as `holding_message_error`, and
975
- * told the requester nothing at all — while a session ran for a median of 14.7
976
- * minutes behind a typing indicator the human reads as "she's replying".
978
+ * History, because this path has now swung twice and the reasons matter:
977
979
  *
978
- * Generation was never needed here. An acknowledgement has a fixed shape, and
979
- * the one genuinely useful variable in it — what the ask is about — was already
980
- * computed by the classifier and sitting in `classResult.summary`. So the text
981
- * is composed (assurance.composeAck) and handed straight to transport. The path
982
- * is now a string concat plus one HTTP call: it cannot time out, cannot be
983
- * starved of memory, and cannot be refused by a spend cap.
980
+ * 1. Originally `_generateResponse(item, classResult, true)` spawned a cold
981
+ * `claude --print` child under a 60-second cap. On a box at 95-99% memory
982
+ * it lost that race 20 times in 32, and every loss was silent — the
983
+ * requester got nothing while a 15-45 minute session ran behind a typing
984
+ * indicator.
985
+ * 2. The fix made the ack a canned rotation (assurance.composeAck: "On it." /
986
+ * "Looking now." / "On it — digging in now.") that could never fail.
987
+ * It also could never sound like anyone. In the 08-2026 self-echo
988
+ * incidents two agents traded those exact canned lines at each other, and
989
+ * the CEO's verdict (2026-08-30) was that a reflex string is worse than
990
+ * no ack at all.
984
991
  *
985
- * Failure is still possible (the network exists), and it is still not fatal —
986
- * but it is no longer FORGOTTEN. The caller opens an obligation before calling
987
- * this, and the assurance sweep retries any acknowledgement still undelivered
988
- * after ASSURANCE_ACK_GRACE_MS.
992
+ * So: assurance.generateAck produces ONE bespoke line from the actual thread
993
+ * via the cheapest model under a tight (~5s, env-tunable) cap, and on ANY
994
+ * generation failure this function sends NOTHING and says so in the log. The
995
+ * durability story is unchanged — the caller opened the obligation before
996
+ * calling this, the assurance sweep retries generation once within
997
+ * ASSURANCE_ACK_GRACE_MS, and the progress/failure notices still guarantee the
998
+ * human is never left in long-term silence.
989
999
  *
990
1000
  * @returns {{ sent: boolean, holdingText: string|null }} result
991
1001
  */
@@ -993,8 +1003,8 @@ export async function sendHoldingMessage(item, classResult) {
993
1003
  const startTime = Date.now();
994
1004
 
995
1005
  try {
996
- const text = composeAck(item, classResult);
997
-
1006
+ // Channel + dedup gates FIRST — they are free, and a model call spent on a
1007
+ // message the dedup registry would refuse anyway is a pure waste.
998
1008
  if (item.service === "slack") {
999
1009
  const channel = resolveSlackChannel(item);
1000
1010
  if (!channel) {
@@ -1011,9 +1021,19 @@ export async function sendHoldingMessage(item, classResult) {
1011
1021
  }
1012
1022
  }
1013
1023
 
1014
- // Bounded retry on the SEND (not on any generation — there isn't any). A
1015
- // courtesy message that gives up on one dropped socket is how silence
1016
- // happens; ~3 seconds of retry in front of a waiting human is proportionate.
1024
+ const text = await generateAck(item, classResult);
1025
+ if (text == null) {
1026
+ // Deliberate silence — never a canned fallback. The obligation the caller
1027
+ // opened stays un-acked; the assurance sweep owns the (bounded) retry.
1028
+ console.warn(`[responder] holding ack generation failed for ${item.sender} — sending nothing (silence over boilerplate, by design)`);
1029
+ logResponse({ type: "holding_ack_generation_failed", sender: item.sender, service: item.service });
1030
+ counters.bump("send.blocked", { stage: "ack_generation", service: item.service || "unknown", kind: "holding" });
1031
+ return { sent: false, holdingText: null, error: "ack_generation_failed", genFailed: true };
1032
+ }
1033
+
1034
+ // Bounded retry on the SEND. A courtesy message that gives up on one
1035
+ // dropped socket is how silence happens; ~3 seconds of retry in front of a
1036
+ // waiting human is proportionate.
1017
1037
  const delivered = await deliverWithRetry(item, text, { kind: "ack" });
1018
1038
  const sendResult = {
1019
1039
  sent: delivered.sent,
@@ -1044,7 +1064,7 @@ export async function sendHoldingMessage(item, classResult) {
1044
1064
  service: item.service,
1045
1065
  duration_ms: duration,
1046
1066
  text_length: text.length,
1047
- generated: false,
1067
+ generated: true,
1048
1068
  attempts: delivered.attempts || 1,
1049
1069
  ...sendResult,
1050
1070
  });
@@ -0,0 +1,51 @@
1
+ /**
2
+ * scripts/daemon/sdk-version.mjs — which SDK version is the code THIS daemon
3
+ * process is running?
4
+ *
5
+ * The daemon runs from the agent dir's COPIED scripts/daemon, not from
6
+ * node_modules. autoupdate installs the next version into node_modules first
7
+ * and merges the files a minute or two later; a daemon that (re)starts inside
8
+ * that window would report the node_modules version while executing the old
9
+ * copies, and `maestro upgrade --verify` would go green on a lie. So the
10
+ * order of trust is the version of the COPIED tree first:
11
+ *
12
+ * 1. .maestro/shipped-manifest.json `sdkVersion` — written by the upgrade
13
+ * that copied the files (2.12+), i.e. the version of scripts/daemon/*;
14
+ * 2. node_modules/@cohortapp/agent-sdk/package.json — a pre-2.12 manifest
15
+ * has no sdkVersion; the window above is then the only inaccuracy left;
16
+ * 3. the package this file itself ships in (a framework checkout).
17
+ *
18
+ * Read once at daemon start, never again (the dashboard must say what the
19
+ * process IS, not what node_modules became meanwhile).
20
+ *
21
+ * @module scripts/daemon/sdk-version
22
+ */
23
+
24
+ "use strict";
25
+
26
+ import { readFileSync } from "node:fs";
27
+ import { join } from "node:path";
28
+
29
+ export const SDK_PACKAGE = "@cohortapp/agent-sdk";
30
+
31
+ /**
32
+ * Pure over an injected reader. `readJson(path)` returns the parsed file or
33
+ * throws / returns null; every source is fail-open.
34
+ * @param {{agentDir:string, ownPackagePath?:string, readJson?:(p:string)=>any}} o
35
+ * @returns {{version:string, source:"manifest"|"node_modules"|"package"|"none"}}
36
+ */
37
+ export function resolveRunningSdkVersion(o = {}) {
38
+ const readJson = o.readJson || ((p) => JSON.parse(readFileSync(p, "utf8")));
39
+ const tryRead = (p) => { try { return readJson(p); } catch { return null; /* absent/corrupt → next source */ } };
40
+ const manifest = tryRead(join(o.agentDir, ".maestro", "shipped-manifest.json"));
41
+ if (manifest && typeof manifest.sdkVersion === "string" && manifest.sdkVersion) return { version: manifest.sdkVersion, source: "manifest" };
42
+ const dep = tryRead(join(o.agentDir, "node_modules", ...SDK_PACKAGE.split("/"), "package.json"));
43
+ if (dep && dep.name === SDK_PACKAGE && typeof dep.version === "string") return { version: dep.version, source: "node_modules" };
44
+ if (o.ownPackagePath) {
45
+ const own = tryRead(o.ownPackagePath);
46
+ if (own && own.name === SDK_PACKAGE && typeof own.version === "string") return { version: own.version, source: "package" };
47
+ }
48
+ return { version: "", source: "none" };
49
+ }
50
+
51
+ export default { resolveRunningSdkVersion, SDK_PACKAGE };
@@ -0,0 +1,31 @@
1
+ /**
2
+ * sdk-version.test.mjs — the daemon's own-version resolution prefers the
3
+ * version of the COPIED tree (the manifest) over node_modules.
4
+ */
5
+
6
+ import { test } from "node:test";
7
+ import assert from "node:assert/strict";
8
+ import { resolveRunningSdkVersion, SDK_PACKAGE } from "./sdk-version.mjs";
9
+
10
+ function reader(files) {
11
+ return (p) => { if (p in files) return files[p]; throw new Error(`ENOENT ${p}`); };
12
+ }
13
+ const A = "/seat/ivy-ai";
14
+ const MANIFEST = `${A}/.maestro/shipped-manifest.json`;
15
+ const DEP = `${A}/node_modules/@cohortapp/agent-sdk/package.json`;
16
+ const OWN = "/sdk/package.json";
17
+
18
+ test("the manifest's sdkVersion wins: node_modules may already hold the NEXT version while the copied scripts are still the old one", () => {
19
+ const r = resolveRunningSdkVersion({ agentDir: A, ownPackagePath: OWN, readJson: reader({
20
+ [MANIFEST]: { version: 1, sdkVersion: "2.11.17" },
21
+ [DEP]: { name: SDK_PACKAGE, version: "2.12.0" },
22
+ }) });
23
+ assert.deepEqual(r, { version: "2.11.17", source: "manifest" });
24
+ });
25
+
26
+ test("a pre-2.12 manifest (no sdkVersion) or none → node_modules; then the package this file ships in; then empty", () => {
27
+ assert.deepEqual(resolveRunningSdkVersion({ agentDir: A, readJson: reader({ [MANIFEST]: { version: 1, files: [] }, [DEP]: { name: SDK_PACKAGE, version: "2.11.15" } }) }), { version: "2.11.15", source: "node_modules" });
28
+ assert.deepEqual(resolveRunningSdkVersion({ agentDir: A, ownPackagePath: OWN, readJson: reader({ [OWN]: { name: SDK_PACKAGE, version: "2.12.0" } }) }), { version: "2.12.0", source: "package" });
29
+ assert.deepEqual(resolveRunningSdkVersion({ agentDir: A, ownPackagePath: OWN, readJson: reader({ [OWN]: { name: "ivy-ai", version: "1.0.0" } }) }), { version: "", source: "none" }, "an agent's own package.json is not the SDK");
30
+ assert.deepEqual(resolveRunningSdkVersion({ agentDir: A, readJson: () => { throw new Error("EACCES"); } }), { version: "", source: "none" }, "fail-open");
31
+ });
@@ -6,10 +6,20 @@
6
6
  # recipient allowlist sanity check, and enforces hourly + daily rate caps.
7
7
  #
8
8
  # Usage: pre-send-audit.sh <channel_type>
9
- # channel_type: slack | slack-draft | gmail | whatsapp
9
+ # channel_type: slack | slack-draft | gmail | whatsapp | cohort
10
+ #
11
+ # `cohort` is the PreToolUse channel `maestro global-setup` wires into
12
+ # ~/.claude/settings.json for EVERY mcp__cohort outbound tool (messaging_send,
13
+ # messaging_send_voice_note, email_send, email_draft_send, org_rpc) — the
14
+ # persona gate below (section a2) is what the front-door session relies on to
15
+ # never expose its own machinery to a human (design spec 2026-09-08 §3.6). The
16
+ # same patterns run in-process in lib/comms/send-gate.mjs (screenPersona) for
17
+ # the lanes no hook fronts: `maestro inbox reply`, the daemon's delivery, the
18
+ # adapters. Keep the two lists in sync.
10
19
  #
11
20
  # Stdin: Claude Code PreToolUse JSON payload:
12
- # { "tool_name": "...", "tool_input": { "text"|"message"|"body": "...",
21
+ # { "tool_name": "...", "tool_input": { "text"|"message"|"body"|"bodyText": "...",
22
+ # "subject": "...", "params": { …an org_rpc body… },
13
23
  # "to"|"recipient"|"channel": "..." }, ... }
14
24
  #
15
25
  # Exit codes:
@@ -33,7 +43,7 @@ deny() {
33
43
  }
34
44
 
35
45
  CHANNEL_TYPE="${1:-}"
36
- [ -n "$CHANNEL_TYPE" ] || deny "Channel type required (slack|slack-draft|gmail|whatsapp)"
46
+ [ -n "$CHANNEL_TYPE" ] || deny "Channel type required (slack|slack-draft|gmail|whatsapp|cohort)"
37
47
 
38
48
  # jq is mandatory — without it we cannot inspect content, so refuse to send.
39
49
  command -v jq >/dev/null 2>&1 || deny "jq not available — cannot screen outbound content (fail closed)"
@@ -50,15 +60,20 @@ fi
50
60
  # Extract the outbound text. Claude Code wraps the tool args under
51
61
  # tool_input; for robustness we also look at the top level. We concatenate
52
62
  # every plausible content field so a banned phrase anywhere is caught.
63
+ # `params` is where org_rpc carries an outbound body; `bodyText`/`subject` are
64
+ # the mail-desk (email_draft_send / email_send) content fields.
53
65
  CONTENT=$(printf '%s' "$TOOL_INPUT" | jq -r '
54
- [ .tool_input // {} , . ]
66
+ [ .tool_input // {} , ( .tool_input.params? | if type=="object" then . else {} end ) , . ]
55
67
  | map(
56
68
  ( .text? // empty ),
57
69
  ( .message? // empty ),
58
70
  ( .body? // empty ),
71
+ ( .bodyText? // empty ),
72
+ ( .subject? // empty ),
59
73
  ( .content? // empty ),
60
74
  ( .blocks? | if type=="array" or type=="object" then tojson else empty end )
61
75
  )
76
+ | map(if type=="string" then . else tojson end)
62
77
  | flatten
63
78
  | map(select(. != null and . != ""))
64
79
  | join("\n")
@@ -110,6 +125,84 @@ if printf '%s' "$CONTENT" | grep -Eiq '(^|[[:cntrl:]])[[:space:]]*(Certainly!|Ab
110
125
  deny "Banned filler opener (Certainly!/Absolutely!/Great question!) — see policies/communication-style.md"
111
126
  fi
112
127
 
128
+ # --- (a2) persona-leak screen ----------------------------------------------
129
+ # The agent is one persona to the humans it talks to; parallel sessions,
130
+ # sub-sessions, subagents and workflows are internal workings. Outbound text
131
+ # may not name them, and may not name Claude Code / a language model. The
132
+ # denial carries the offending phrase so the session rewrites in its own voice
133
+ # ("my team", "a colleague", "one of my analysts").
134
+ #
135
+ # Patterns are case-insensitive extended regexes. "subagent"/"sub-agent" is
136
+ # blocked only when it is followed by said|reported|session — the bare noun in
137
+ # an internal-process sentence is tolerated, the attribution of speech to a
138
+ # machine part is not.
139
+ PERSONA_PATTERNS=(
140
+ 'claude[[:space:]]+code'
141
+ 'claude[[:space:]]+sessions?'
142
+ 'sub-sessions?'
143
+ 'sub-?agents?[[:space:]]+(said|reported|sessions?)'
144
+ 'workflow[[:space:]]+agents?'
145
+ 'as[[:space:]]+an[[:space:]]+ai\b'
146
+ 'language[[:space:]]+model'
147
+ )
148
+ for pat in "${PERSONA_PATTERNS[@]}"; do
149
+ hit=$(printf '%s' "$CONTENT" | grep -Eio -- "$pat" 2>/dev/null | head -n 1) || true
150
+ if [ -n "$hit" ]; then
151
+ deny "Persona leak in outbound message: \"$hit\" — rewrite in your own voice (my team / a colleague), never name the machinery"
152
+ fi
153
+ done
154
+
155
+ # Session names are internal identifiers and must never reach a human — but
156
+ # the rule is BOUNDED, not guessed: `<first>-main` is always internal; any
157
+ # other name is internal only when it is a registered peer
158
+ # ($AGENT_ROOT/state/session/peers.json, written by `maestro session spawn`,
159
+ # an array or {peers:[…]} of {name,…}) or listed in MAESTRO_SESSION_NAMES
160
+ # (comma-separated). A colleague called Marc-Antoine, the slug james-kirkland
161
+ # in a URL or the mailbox ethan-miller@ are ordinary text.
162
+ #
163
+ # The first name resolves, in order, from MAESTRO_AGENT_FIRST,
164
+ # $AGENT_ROOT/config/agent.json (firstName, else the first word of fullName)
165
+ # and the machine pointer ~/.claude/maestro-agent.json (agentName). When none
166
+ # resolves the `<first>-main` check is skipped — the phrase gate above still
167
+ # applies — because a wrong guess would block legitimate text.
168
+ FIRST="${MAESTRO_AGENT_FIRST:-}"
169
+ if [ -z "$FIRST" ]; then
170
+ for cand in "${AGENT_ROOT:-}/config/agent.json" "${AGENT_DIR:-}/config/agent.json" "$HOME/.claude/maestro-agent.json"; do
171
+ [ -f "$cand" ] || continue
172
+ FIRST=$(jq -r '(.firstName // .agentName // .fullName // "") | tostring | split(" ")[0] | ascii_downcase' "$cand" 2>/dev/null) || FIRST=""
173
+ [ -n "$FIRST" ] && [ "$FIRST" != "null" ] && break
174
+ FIRST=""
175
+ done
176
+ fi
177
+ FIRST=$(printf '%s' "$FIRST" | tr '[:upper:]' '[:lower:]' | tr -cd 'a-z0-9-')
178
+
179
+ SESSION_NAMES=()
180
+ [ -n "$FIRST" ] && SESSION_NAMES+=("${FIRST}-main")
181
+ for peers in "${AGENT_ROOT:-}/state/session/peers.json" "${AGENT_DIR:-}/state/session/peers.json"; do
182
+ [ -f "$peers" ] || continue
183
+ # A corrupt registry is an empty one: this must never fail the whole send closed.
184
+ while IFS= read -r pn; do
185
+ [ -n "$pn" ] && SESSION_NAMES+=("$pn")
186
+ done < <(jq -r '(if type=="array" then . elif type=="object" then (.peers // []) else [] end) | .[] | select(type=="object") | (.name // empty) | tostring | ascii_downcase' "$peers" 2>/dev/null || true)
187
+ break
188
+ done
189
+ if [ -n "${MAESTRO_SESSION_NAMES:-}" ]; then
190
+ IFS=',' read -r -a extra_names <<< "$MAESTRO_SESSION_NAMES"
191
+ # bash 3.2 (macOS) + set -u: an empty array expands as "unbound" — use the ${a[@]+"${a[@]}"} idiom.
192
+ for en in ${extra_names[@]+"${extra_names[@]}"}; do
193
+ en=$(printf '%s' "$en" | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]')
194
+ [ -n "$en" ] && SESSION_NAMES+=("$en")
195
+ done
196
+ fi
197
+ for sname in ${SESSION_NAMES[@]+"${SESSION_NAMES[@]}"}; do
198
+ sname_re=$(printf '%s' "$sname" | sed 's/[.[\*^$()+?{|]/\\&/g')
199
+ # Word-bounded on [A-Za-z0-9_-]: "alex-main" hits, "alex-mainland" / "alex-main-street" do not.
200
+ name_hit=$(printf '%s' "$CONTENT" | grep -Eio -- "(^|[^[:alnum:]_-])${sname_re}([^[:alnum:]_-]|$)" 2>/dev/null | head -n 1 | sed -E 's/^[^[:alnum:]_-]//; s/[^[:alnum:]_-]$//') || true
201
+ if [ -n "$name_hit" ]; then
202
+ deny "Persona leak in outbound message: \"$name_hit\" is an internal session name — say \"my team\" or \"a colleague\" instead"
203
+ fi
204
+ done
205
+
113
206
  # --- (b) recipient allowlist sanity check ----------------------------------
114
207
  # Only enforced when a recipient is present AND an allowlist file exists.
115
208
  # Allowlist file: one entry (channel id, email, handle) per line, '#' comments.