@cohortapp/agent-sdk 2.11.15 → 2.13.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 (280) hide show
  1. package/.env.example +37 -22
  2. package/README.md +2 -0
  3. package/bin/maestro.mjs +117 -39
  4. package/bin/maestro.test.mjs +175 -5
  5. package/docs/guides/front-door-session.md +313 -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/design.mjs +185 -0
  17. package/lib/cli/design.test.mjs +270 -0
  18. package/lib/cli/doctor-checks.mjs +441 -0
  19. package/lib/cli/doctor-checks.test.mjs +336 -0
  20. package/lib/cli/global-setup-extras.mjs +454 -0
  21. package/lib/cli/global-setup-extras.test.mjs +462 -0
  22. package/lib/cli/inbox.mjs +304 -0
  23. package/lib/cli/inbox.test.mjs +230 -0
  24. package/lib/cli/session-ack.mjs +63 -0
  25. package/lib/cli/session-ack.test.mjs +63 -0
  26. package/lib/cli/session.mjs +760 -0
  27. package/lib/cli/session.test.mjs +613 -0
  28. package/lib/collective/global-config.mjs +209 -6
  29. package/lib/collective/global-config.test.mjs +145 -0
  30. package/lib/collective/global-skills.mjs +145 -0
  31. package/lib/collective/global-skills.test.mjs +126 -0
  32. package/lib/collective/presence.mjs +4 -3
  33. package/lib/collective/vendor-skills.mjs +305 -0
  34. package/lib/collective/vendor-skills.test.mjs +306 -0
  35. package/lib/comms/send-gate.mjs +115 -0
  36. package/lib/comms/send-gate.test.mjs +113 -0
  37. package/lib/design/design-md.mjs +793 -0
  38. package/lib/design/design-md.test.mjs +318 -0
  39. package/lib/design/fixtures/DESIGN.golden.md +238 -0
  40. package/lib/design/fixtures/PRODUCT.golden.md +67 -0
  41. package/lib/design/fixtures/foundation.json +133 -0
  42. package/lib/design/refresh-gate.mjs +154 -0
  43. package/lib/design/refresh-gate.test.mjs +144 -0
  44. package/lib/design/write.mjs +275 -0
  45. package/lib/design/write.test.mjs +241 -0
  46. package/lib/feature-init.mjs +2 -2
  47. package/lib/mcp/server.test.mjs +9 -4
  48. package/lib/model-router/spawn.test.mjs +21 -0
  49. package/lib/org/board-mine-cache.mjs +99 -0
  50. package/lib/org/board-mine-cache.test.mjs +53 -0
  51. package/lib/org/board.mjs +11 -0
  52. package/lib/org/board.test.mjs +11 -1
  53. package/lib/org/client.mjs +36 -0
  54. package/lib/org/client.test.mjs +46 -0
  55. package/lib/org/inbound/directedness.mjs +18 -2
  56. package/lib/org/inbound/directedness.test.mjs +58 -0
  57. package/lib/org/inbound/index.mjs +8 -1
  58. package/lib/org/inbound/index.test.mjs +22 -0
  59. package/lib/org/mesh-directives.test.mjs +110 -0
  60. package/lib/org/mesh.mjs +61 -1
  61. package/lib/org/protocol.checksum +1 -1
  62. package/lib/org/protocol.mjs +52 -0
  63. package/lib/org/protocol.test.mjs +12 -1
  64. package/lib/org/registry.mjs +3 -2
  65. package/lib/org/tool-surface.mjs +120 -0
  66. package/lib/org/tool-surface.test.mjs +118 -5
  67. package/lib/prompts/parallelism.mjs +79 -0
  68. package/lib/prompts/parallelism.test.mjs +177 -0
  69. package/lib/security/external-content.mjs +1 -1
  70. package/lib/security/external-content.test.mjs +17 -0
  71. package/lib/session/config.mjs +137 -0
  72. package/lib/session/config.test.mjs +92 -0
  73. package/lib/session/feed-core.mjs +229 -0
  74. package/lib/session/feed-core.test.mjs +198 -0
  75. package/lib/session/first-run.mjs +126 -0
  76. package/lib/session/first-run.test.mjs +121 -0
  77. package/lib/session/frontdoor.mjs +266 -0
  78. package/lib/session/frontdoor.test.mjs +205 -0
  79. package/lib/session/handoffs.mjs +295 -0
  80. package/lib/session/handoffs.test.mjs +183 -0
  81. package/lib/session/identity.mjs +220 -0
  82. package/lib/session/identity.test.mjs +180 -0
  83. package/lib/session/inbox-claims.mjs +434 -0
  84. package/lib/session/inbox-claims.test.mjs +286 -0
  85. package/lib/session/launch-args.mjs +161 -0
  86. package/lib/session/launch-args.test.mjs +157 -0
  87. package/lib/session/liveness.mjs +174 -0
  88. package/lib/session/liveness.test.mjs +100 -0
  89. package/lib/session/status-summary.mjs +172 -0
  90. package/lib/session/status-summary.test.mjs +118 -0
  91. package/lib/session-permissions.mjs +39 -3
  92. package/lib/session-permissions.test.mjs +20 -0
  93. package/lib/setup/claude-probe.mjs +161 -24
  94. package/lib/setup/claude-probe.test.mjs +187 -0
  95. package/lib/setup/sections/learning.mjs +2 -1
  96. package/lib/setup/sections/model.mjs +104 -24
  97. package/lib/setup/sections/model.test.mjs +240 -0
  98. package/lib/setup/sections/org.mjs +27 -2
  99. package/lib/setup/sections/org.test.mjs +35 -2
  100. package/lib/setup/sections/verify.mjs +5 -0
  101. package/lib/setup/state.mjs +30 -10
  102. package/lib/setup/state.test.mjs +24 -1
  103. package/lib/singleton.js +11 -3
  104. package/lib/singleton.test.mjs +16 -0
  105. package/lib/subagents/lock.mjs +1 -1
  106. package/lib/telemetry/collect.mjs +270 -6
  107. package/lib/telemetry/collect.test.mjs +196 -1
  108. package/lib/upgrade/global-refresh.mjs +108 -0
  109. package/lib/upgrade/global-refresh.test.mjs +65 -0
  110. package/lib/upgrade/launchd-reconcile.mjs +327 -0
  111. package/lib/upgrade/launchd-reconcile.test.mjs +272 -0
  112. package/lib/upgrade/post-steps.mjs +151 -0
  113. package/lib/upgrade/post-steps.test.mjs +200 -0
  114. package/lib/upgrade/verify.mjs +215 -0
  115. package/lib/upgrade/verify.test.mjs +164 -0
  116. package/lib/voice/outbound.mjs +3 -2
  117. package/lib/voice/post-call-brief.mjs +2 -1
  118. package/lib/voice/session-rotation.mjs +6 -1
  119. package/lib/voice/session-rotation.test.mjs +114 -0
  120. package/package.json +3 -3
  121. package/plugins/maestro-skills/plugin.json +25 -1
  122. package/plugins/maestro-skills/skills/board-work.md +63 -0
  123. package/plugins/maestro-skills/skills/cohort-design.md +153 -0
  124. package/plugins/maestro-skills/skills/inbound-triage.md +80 -0
  125. package/plugins/maestro-skills/skills/main-session.md +102 -0
  126. package/plugins/maestro-skills/skills/peer-sessions.md +65 -0
  127. package/plugins/maestro-skills/skills/persona-discipline.md +75 -0
  128. package/plugins/maestro-skills/vendor/emilkowalski/LICENSE +21 -0
  129. package/plugins/maestro-skills/vendor/emilkowalski/UPSTREAM.json +70 -0
  130. package/plugins/maestro-skills/vendor/emilkowalski/skills/animate/RECIPES.md +324 -0
  131. package/plugins/maestro-skills/vendor/emilkowalski/skills/animate/SKILL.md +199 -0
  132. package/plugins/maestro-skills/vendor/emilkowalski/skills/animation-vocabulary/SKILL.md +173 -0
  133. package/plugins/maestro-skills/vendor/emilkowalski/skills/apple-design/SKILL.md +282 -0
  134. package/plugins/maestro-skills/vendor/emilkowalski/skills/emil-design-eng/SKILL.md +674 -0
  135. package/plugins/maestro-skills/vendor/emilkowalski/skills/find-animation-opportunities/SKILL.md +132 -0
  136. package/plugins/maestro-skills/vendor/emilkowalski/skills/improve-animations/AUDIT.md +115 -0
  137. package/plugins/maestro-skills/vendor/emilkowalski/skills/improve-animations/PLAN-TEMPLATE.md +73 -0
  138. package/plugins/maestro-skills/vendor/emilkowalski/skills/improve-animations/SKILL.md +101 -0
  139. package/plugins/maestro-skills/vendor/emilkowalski/skills/prototype/PICKER.md +197 -0
  140. package/plugins/maestro-skills/vendor/emilkowalski/skills/prototype/SKILL.md +90 -0
  141. package/plugins/maestro-skills/vendor/emilkowalski/skills/review-animations/SKILL.md +112 -0
  142. package/plugins/maestro-skills/vendor/emilkowalski/skills/review-animations/STANDARDS.md +187 -0
  143. package/plugins/maestro-skills/vendor/impeccable/LICENSE +191 -0
  144. package/plugins/maestro-skills/vendor/impeccable/NOTICE.md +11 -0
  145. package/plugins/maestro-skills/vendor/impeccable/SKILL.md +86 -0
  146. package/plugins/maestro-skills/vendor/impeccable/UPSTREAM.json +201 -0
  147. package/plugins/maestro-skills/vendor/impeccable/agents/impeccable-asset-producer.md +42 -0
  148. package/plugins/maestro-skills/vendor/impeccable/agents/impeccable-documenter.md +29 -0
  149. package/plugins/maestro-skills/vendor/impeccable/agents/impeccable-finish-reviewer.md +43 -0
  150. package/plugins/maestro-skills/vendor/impeccable/agents/impeccable-manual-edit-applier.md +97 -0
  151. package/plugins/maestro-skills/vendor/impeccable/reference/adapt.md +312 -0
  152. package/plugins/maestro-skills/vendor/impeccable/reference/adapt.native.md +58 -0
  153. package/plugins/maestro-skills/vendor/impeccable/reference/android.md +46 -0
  154. package/plugins/maestro-skills/vendor/impeccable/reference/animate.md +89 -0
  155. package/plugins/maestro-skills/vendor/impeccable/reference/audit.md +136 -0
  156. package/plugins/maestro-skills/vendor/impeccable/reference/audit.native.md +139 -0
  157. package/plugins/maestro-skills/vendor/impeccable/reference/bolder.md +33 -0
  158. package/plugins/maestro-skills/vendor/impeccable/reference/clarify.md +94 -0
  159. package/plugins/maestro-skills/vendor/impeccable/reference/colorize.md +86 -0
  160. package/plugins/maestro-skills/vendor/impeccable/reference/craft-floor.md +44 -0
  161. package/plugins/maestro-skills/vendor/impeccable/reference/craft.md +5 -0
  162. package/plugins/maestro-skills/vendor/impeccable/reference/critique.md +806 -0
  163. package/plugins/maestro-skills/vendor/impeccable/reference/degraded/asset-producer.md +37 -0
  164. package/plugins/maestro-skills/vendor/impeccable/reference/degraded/documenter.md +24 -0
  165. package/plugins/maestro-skills/vendor/impeccable/reference/degraded/finish-reviewer.md +38 -0
  166. package/plugins/maestro-skills/vendor/impeccable/reference/degraded/manual-edit-applier.md +92 -0
  167. package/plugins/maestro-skills/vendor/impeccable/reference/delight.md +70 -0
  168. package/plugins/maestro-skills/vendor/impeccable/reference/distill.md +111 -0
  169. package/plugins/maestro-skills/vendor/impeccable/reference/doctor.md +54 -0
  170. package/plugins/maestro-skills/vendor/impeccable/reference/document.md +416 -0
  171. package/plugins/maestro-skills/vendor/impeccable/reference/extract.md +69 -0
  172. package/plugins/maestro-skills/vendor/impeccable/reference/harden.md +336 -0
  173. package/plugins/maestro-skills/vendor/impeccable/reference/hooks.md +111 -0
  174. package/plugins/maestro-skills/vendor/impeccable/reference/init.md +131 -0
  175. package/plugins/maestro-skills/vendor/impeccable/reference/ios.md +51 -0
  176. package/plugins/maestro-skills/vendor/impeccable/reference/layout.md +84 -0
  177. package/plugins/maestro-skills/vendor/impeccable/reference/live-setup.md +104 -0
  178. package/plugins/maestro-skills/vendor/impeccable/reference/live.md +325 -0
  179. package/plugins/maestro-skills/vendor/impeccable/reference/new-work.md +147 -0
  180. package/plugins/maestro-skills/vendor/impeccable/reference/onboard.md +234 -0
  181. package/plugins/maestro-skills/vendor/impeccable/reference/operate.md +61 -0
  182. package/plugins/maestro-skills/vendor/impeccable/reference/optimize.md +258 -0
  183. package/plugins/maestro-skills/vendor/impeccable/reference/overdrive.md +127 -0
  184. package/plugins/maestro-skills/vendor/impeccable/reference/polish.md +105 -0
  185. package/plugins/maestro-skills/vendor/impeccable/reference/quieter.md +99 -0
  186. package/plugins/maestro-skills/vendor/impeccable/reference/routing.md +24 -0
  187. package/plugins/maestro-skills/vendor/impeccable/reference/shape.md +59 -0
  188. package/plugins/maestro-skills/vendor/impeccable/reference/typeset.md +80 -0
  189. package/plugins/maestro-skills/vendor/impeccable/reference/visualize.md +46 -0
  190. package/plugins/maestro-skills/vendor/taste-skill/LICENSE +21 -0
  191. package/plugins/maestro-skills/vendor/taste-skill/UPSTREAM.json +37 -0
  192. package/plugins/maestro-skills/vendor/taste-skill/skills/minimalist-skill/SKILL.md +85 -0
  193. package/plugins/maestro-skills/vendor/taste-skill/skills/redesign-skill/SKILL.md +178 -0
  194. package/plugins/maestro-skills/vendor/taste-skill/skills/soft-skill/SKILL.md +98 -0
  195. package/plugins/maestro-skills/vendor/taste-skill/skills/taste-skill/SKILL.md +1206 -0
  196. package/plugins/maestro-skills/vendor/unlazy/LICENSE +21 -0
  197. package/plugins/maestro-skills/vendor/unlazy/SECURITY.md +72 -0
  198. package/plugins/maestro-skills/vendor/unlazy/SKILL.md +104 -0
  199. package/plugins/maestro-skills/vendor/unlazy/UPSTREAM.json +94 -0
  200. package/plugins/maestro-skills/vendor/unlazy/references/dispatch.md +82 -0
  201. package/plugins/maestro-skills/vendor/unlazy/references/gates.md +149 -0
  202. package/plugins/maestro-skills/vendor/unlazy/references/method.md +49 -0
  203. package/plugins/maestro-skills/vendor/unlazy/references/orchestration.md +107 -0
  204. package/plugins/maestro-skills/vendor/unlazy/references/parallel.md +133 -0
  205. package/plugins/maestro-skills/vendor/unlazy/references/token-economy.md +48 -0
  206. package/plugins/maestro-skills/vendor/unlazy/scripts/dispatch-check.mjs +139 -0
  207. package/plugins/maestro-skills/vendor/unlazy/scripts/gate-check.mjs +960 -0
  208. package/plugins/maestro-skills/vendor/unlazy/scripts/gate-lint.mjs +245 -0
  209. package/plugins/maestro-skills/vendor/unlazy/scripts/lib/check-supervisor.mjs +46 -0
  210. package/plugins/maestro-skills/vendor/unlazy/scripts/lib/dispatch.mjs +293 -0
  211. package/plugins/maestro-skills/vendor/unlazy/scripts/lib/gates.mjs +953 -0
  212. package/plugins/maestro-skills/vendor/unlazy/scripts/lib/process-tree.mjs +161 -0
  213. package/plugins/maestro-skills/vendor/unlazy/scripts/lib/regex-worker.mjs +9 -0
  214. package/plugins/maestro-skills/vendor/unlazy/templates/PLAN.md +116 -0
  215. package/plugins/maestro-skills/vendor/unlazy/templates/gates-leaf.md +51 -0
  216. package/plugins/maestro-skills/vendor/unlazy/templates/gates-node.md +51 -0
  217. package/scaffold/CLAUDE.md +24 -0
  218. package/scripts/ci/check-durable-write-seam.mjs +147 -0
  219. package/scripts/ci/check-durable-write-seam.test.mjs +90 -0
  220. package/scripts/ci/check-skill-packs.mjs +388 -0
  221. package/scripts/ci/check-skill-packs.test.mjs +495 -0
  222. package/scripts/ci/check.mjs +6 -0
  223. package/scripts/collective/hook-runner.mjs +39 -4
  224. package/scripts/collective/hook-runner.test.mjs +85 -2
  225. package/scripts/daemon/agent-daemon-board-mine.test.mjs +96 -0
  226. package/scripts/daemon/agent-daemon-design.test.mjs +238 -0
  227. package/scripts/daemon/agent-daemon-frontdoor.test.mjs +60 -0
  228. package/scripts/daemon/agent-daemon.mjs +249 -10
  229. package/scripts/daemon/agent-daemon.test.mjs +73 -0
  230. package/scripts/daemon/assurance-e2e.test.mjs +141 -6
  231. package/scripts/daemon/assurance.mjs +461 -37
  232. package/scripts/daemon/assurance.test.mjs +408 -43
  233. package/scripts/daemon/cadence-consumer-frontdoor.test.mjs +393 -0
  234. package/scripts/daemon/cadence-consumer.mjs +289 -89
  235. package/scripts/daemon/cadence-handlers.mjs +53 -0
  236. package/scripts/daemon/classifier.mjs +1 -1
  237. package/scripts/daemon/dispatcher-resume.test.mjs +166 -0
  238. package/scripts/daemon/dispatcher.mjs +127 -19
  239. package/scripts/daemon/health.mjs +12 -1
  240. package/scripts/daemon/inbox-deferral-session.test.mjs +49 -0
  241. package/scripts/daemon/inbox-deferral.mjs +6 -0
  242. package/scripts/daemon/lib/self-echo.mjs +201 -0
  243. package/scripts/daemon/lib/self-echo.test.mjs +153 -0
  244. package/scripts/daemon/maestro-daemon.mjs +3 -0
  245. package/scripts/daemon/prompt-builder.mjs +19 -3
  246. package/scripts/daemon/responder.mjs +51 -40
  247. package/scripts/daemon/sdk-version.mjs +51 -0
  248. package/scripts/daemon/sdk-version.test.mjs +31 -0
  249. package/scripts/hooks/pre-send-audit.sh +97 -4
  250. package/scripts/hooks/pre-send-audit.test.mjs +140 -1
  251. package/scripts/local-triggers/autoupdate.sh +243 -19
  252. package/scripts/local-triggers/autoupdate.test.mjs +518 -0
  253. package/scripts/local-triggers/generate-plists.sh +24 -1
  254. package/scripts/local-triggers/generate-plists.test.mjs +49 -11
  255. package/scripts/org/send-orgmail.first-contact.test.mjs +102 -0
  256. package/scripts/org/send-orgmail.mjs +27 -3
  257. package/scripts/poller/inbox-privilege-injection.test.mjs +167 -0
  258. package/scripts/poller/slack-poller.mjs +13 -1
  259. package/scripts/poller/utils.mjs +46 -1
  260. package/scripts/poller-launchd/install.sh +19 -11
  261. package/scripts/poller-launchd/install.test.mjs +243 -0
  262. package/scripts/poller-launchd/launchd-poller-wrapper.sh +92 -0
  263. package/scripts/poller-launchd/migrate.sh +66 -0
  264. package/scripts/poller-launchd/poller.plist.template +4 -2
  265. package/scripts/session/feed.mjs +237 -0
  266. package/scripts/session/feed.test.mjs +196 -0
  267. package/scripts/session/supervisor-sh.test.mjs +218 -0
  268. package/scripts/session/supervisor.mjs +328 -0
  269. package/scripts/session/supervisor.sh +141 -0
  270. package/scripts/session/supervisor.test.mjs +482 -0
  271. package/scripts/setup/configure-macos.sh +250 -55
  272. package/scripts/setup/configure-macos.test.mjs +306 -0
  273. package/scripts/setup/init-agent.sh +112 -7
  274. package/scripts/setup/init-agent.test.mjs +220 -1
  275. package/scripts/vendor/skill-packs.mjs +354 -0
  276. package/scripts/vendor/sync-skill-packs.mjs +242 -0
  277. package/scripts/vendor/sync-skill-packs.test.mjs +103 -0
  278. package/scripts/watchdog/memory-watchdog.sh +37 -1
  279. package/scripts/watchdog/memory-watchdog.test.mjs +64 -0
  280. 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
@@ -12,6 +12,7 @@ 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";
14
14
  import { outcomeSourceShareable } from "./session-outcomes.mjs";
15
+ import { withParallelism } from "../../lib/prompts/parallelism.mjs";
15
16
 
16
17
  const AGENT_REPO_DIR = process.env.AGENT_DIR || join(new URL(".", import.meta.url).pathname, "../..");
17
18
 
@@ -852,16 +853,23 @@ export async function buildPrompt(item, classResult, options = {}) {
852
853
  const parts = [];
853
854
 
854
855
  // 1. Persona block (cached) — rendered from config/agent.json, which the
855
- // Cohort enrollment pull keeps current. Leads the prompt and outranks the
856
+ // Cohort enrollment pull keeps current. Leads the BODY and outranks the
856
857
  // CLAUDE.md preamble below, because a scaffolded CLAUDE.md carries
857
858
  // "identity not yet configured" placeholder prose that would otherwise
858
859
  // contradict a perfectly good org record.
860
+ //
861
+ // THE PRECEDENCE SENTENCE NAMES ITSELF, NOT A POSITION. It used to say "the
862
+ // section above … anything below", which was true only while the persona was
863
+ // the first thing in the prompt. The parallelism directive is prepended at
864
+ // the end of this function (§10) and now leads, so "above" had two candidate
865
+ // referents and the directive sat outside the rule entirely — above it, not
866
+ // below. A self-naming sentence stays true wherever the block ends up.
859
867
  const persona = loadPersona();
860
868
  if (persona) {
861
869
  parts.push(persona);
862
870
  parts.push("");
863
871
  parts.push(
864
- "The section above is your identity, resolved from your organisation's record of you. Where anything below conflicts with it, the section above wins.",
872
+ "This section is your identity, resolved from your organisation's record of you. Where anything else in this prompt conflicts with it, this section wins.",
865
873
  );
866
874
  parts.push("");
867
875
  }
@@ -1025,5 +1033,13 @@ export async function buildPrompt(item, classResult, options = {}) {
1025
1033
  // 9. Audit instruction
1026
1034
  parts.push(`Log all actions taken to logs/audit/${date}-actions.jsonl as a JSON line with: timestamp, action_type, target, result, session context.`);
1027
1035
 
1028
- return parts.join("\n");
1036
+ // 10. The parallelism directive (WP-M7 mechanic 5). Every prompt this builder
1037
+ // produces is handed to a sub-session that may plan and dispatch work of its
1038
+ // own, and nothing else in the prompt ever says that fanning out is allowed —
1039
+ // which is why sessions handed six independent tasks ran them one at a time.
1040
+ // It LEADS the prompt because it governs how the session plans, before it has
1041
+ // read a word of the item. `withParallelism` is idempotent, so a prompt that
1042
+ // already carries it (composed by a caller, or handed on to `session spawn`,
1043
+ // which applies the same seam) is returned with exactly one copy.
1044
+ return withParallelism(parts.join("\n"));
1029
1045
  }
@@ -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,11 @@ 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";
63
62
  // Shared message-craft doctrine (distil by default; format only when the message
64
63
  // must be large; attach genuinely verbose content rather than dumping it). The
65
64
  // SAME constant leads the full-session prompt in prompt-builder.mjs, so a reactive
@@ -695,11 +694,10 @@ export function setGenerateResponseForTests(fn) {
695
694
  * Generate an ANSWER. Only an answer.
696
695
  *
697
696
  * This used to take an `isHolding` flag and, when set, spend a model call
698
- * writing an acknowledgement. That branch is gone: acknowledgements are
699
- * composed (assurance.composeAck), and the flag is deliberately not kept "just
700
- * in case", because keeping it is how it comes back. The one thing a model was
701
- * adding to a courtesy sentence was phrasing variety, at a cost of 13-46
702
- * 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.
703
701
  */
704
702
  async function realGenerateResponse(item, classResult) {
705
703
  const preamble = loadPreamble();
@@ -975,26 +973,29 @@ export async function sendQuickResponse(item, classResult, routed = null) {
975
973
  * Acknowledge an ask that is about to become a long-running session, and return
976
974
  * the text so the session's own prompt can see what the human was already told.
977
975
  *
978
- * THIS NO LONGER CALLS A MODEL, AND THAT IS THE WHOLE POINT.
976
+ * THE ACK IS GENERATED AGAIN — WITH THE FAILURE MODE INVERTED.
979
977
  *
980
- * It used to. `_generateResponse(item, classResult, true)` spawned a cold
981
- * `claude --print` child under a 60-second hard cap to compose two sentences of
982
- * courtesy. On a box at 95-99% memory that spawn lost its race 20 times in 32;
983
- * every loss threw, was caught below, logged as `holding_message_error`, and
984
- * told the requester nothing at all — while a session ran for a median of 14.7
985
- * 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:
986
979
  *
987
- * Generation was never needed here. An acknowledgement has a fixed shape, and
988
- * the one genuinely useful variable in it — what the ask is about — was already
989
- * computed by the classifier and sitting in `classResult.summary`. So the text
990
- * is composed (assurance.composeAck) and handed straight to transport. The path
991
- * is now a string concat plus one HTTP call: it cannot time out, cannot be
992
- * 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.
993
991
  *
994
- * Failure is still possible (the network exists), and it is still not fatal —
995
- * but it is no longer FORGOTTEN. The caller opens an obligation before calling
996
- * this, and the assurance sweep retries any acknowledgement still undelivered
997
- * 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.
998
999
  *
999
1000
  * @returns {{ sent: boolean, holdingText: string|null }} result
1000
1001
  */
@@ -1002,8 +1003,8 @@ export async function sendHoldingMessage(item, classResult) {
1002
1003
  const startTime = Date.now();
1003
1004
 
1004
1005
  try {
1005
- const text = composeAck(item, classResult);
1006
-
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.
1007
1008
  if (item.service === "slack") {
1008
1009
  const channel = resolveSlackChannel(item);
1009
1010
  if (!channel) {
@@ -1020,9 +1021,19 @@ export async function sendHoldingMessage(item, classResult) {
1020
1021
  }
1021
1022
  }
1022
1023
 
1023
- // Bounded retry on the SEND (not on any generation — there isn't any). A
1024
- // courtesy message that gives up on one dropped socket is how silence
1025
- // 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.
1026
1037
  const delivered = await deliverWithRetry(item, text, { kind: "ack" });
1027
1038
  const sendResult = {
1028
1039
  sent: delivered.sent,
@@ -1053,7 +1064,7 @@ export async function sendHoldingMessage(item, classResult) {
1053
1064
  service: item.service,
1054
1065
  duration_ms: duration,
1055
1066
  text_length: text.length,
1056
- generated: false,
1067
+ generated: true,
1057
1068
  attempts: delivered.attempts || 1,
1058
1069
  ...sendResult,
1059
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.