@cohortapp/agent-sdk 2.17.0 → 2.18.4

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 (526) hide show
  1. package/.claude/settings.json +18 -0
  2. package/.env.example +18 -5
  3. package/README.md +1 -0
  4. package/bin/maestro.mjs +62 -0
  5. package/docs/guides/billing-console-keys.md +60 -0
  6. package/docs/guides/front-door-session.md +54 -9
  7. package/docs/guides/mac-mini.md +20 -25
  8. package/docs/guides/setup-wizard.md +1 -1
  9. package/docs/runbooks/fleet-rollout.md +156 -0
  10. package/docs/runbooks/mac-mini-bootstrap.md +12 -14
  11. package/lib/action-executor.js +19 -3
  12. package/lib/budget-guard.mjs +279 -3
  13. package/lib/channels/base-adapter.mjs +3 -1
  14. package/lib/channels/contract.mjs +2 -1
  15. package/lib/channels/inbox-item.mjs +8 -0
  16. package/lib/claude-bin.mjs +5 -6
  17. package/lib/cli/doctor-checks.mjs +141 -10
  18. package/lib/cli/global-setup-extras.mjs +5 -1
  19. package/lib/cli/inbox.mjs +100 -15
  20. package/lib/cli/seat-auth.mjs +463 -0
  21. package/lib/cli/session.mjs +80 -12
  22. package/lib/collective/capture.mjs +8 -6
  23. package/lib/collective/global-config.mjs +63 -1
  24. package/lib/collective/presence.mjs +142 -5
  25. package/lib/comms/send-gate.mjs +559 -1
  26. package/lib/diagnostics/alerts.mjs +49 -0
  27. package/lib/diagnostics/cadence-output-freshness.mjs +288 -0
  28. package/lib/engine/agents/definitions.mjs +343 -0
  29. package/lib/engine/agents/persist.mjs +275 -0
  30. package/lib/engine/agents/runtime.mjs +748 -0
  31. package/lib/engine/agents/usage.mjs +95 -0
  32. package/lib/engine/auth-status.mjs +139 -0
  33. package/lib/engine/budget.mjs +194 -0
  34. package/lib/engine/cli.mjs +1204 -0
  35. package/lib/engine/commands/index.mjs +269 -0
  36. package/lib/engine/context/budget.mjs +219 -0
  37. package/lib/engine/context/cache.mjs +125 -0
  38. package/lib/engine/context/child-env.mjs +215 -0
  39. package/lib/engine/context/compaction.mjs +342 -0
  40. package/lib/engine/context/images.mjs +90 -0
  41. package/lib/engine/context/instructions.mjs +327 -0
  42. package/lib/engine/context/lazy-instructions.mjs +169 -0
  43. package/lib/engine/context/manager.mjs +182 -0
  44. package/lib/engine/context/real-path.mjs +91 -0
  45. package/lib/engine/context/secret-values.mjs +163 -0
  46. package/lib/engine/context/settings.mjs +274 -0
  47. package/lib/engine/context/stream-input.mjs +159 -0
  48. package/lib/engine/guard.mjs +152 -0
  49. package/lib/engine/hooks.mjs +713 -0
  50. package/lib/engine/loop.mjs +560 -0
  51. package/lib/engine/mcp/client.mjs +254 -0
  52. package/lib/engine/mcp/config.mjs +301 -0
  53. package/lib/engine/mcp/http.mjs +201 -0
  54. package/lib/engine/mcp/index.mjs +146 -0
  55. package/lib/engine/mcp/jsonrpc.mjs +147 -0
  56. package/lib/engine/mcp/naming.mjs +66 -0
  57. package/lib/engine/mcp/resources.mjs +89 -0
  58. package/lib/engine/mcp/results.mjs +133 -0
  59. package/lib/engine/mcp/stdio.mjs +137 -0
  60. package/lib/engine/mcp/supervisor.mjs +116 -0
  61. package/lib/engine/messages.mjs +104 -0
  62. package/lib/engine/output/json.mjs +164 -0
  63. package/lib/engine/output/stream-json.mjs +266 -0
  64. package/lib/engine/permissions.mjs +845 -0
  65. package/lib/engine/process-identity.mjs +164 -0
  66. package/lib/engine/process-tree.mjs +551 -0
  67. package/lib/engine/prompt.mjs +60 -0
  68. package/lib/engine/session/store.mjs +299 -0
  69. package/lib/engine/session-runtime/args.mjs +97 -0
  70. package/lib/engine/session-runtime/host.mjs +143 -0
  71. package/lib/engine/session-runtime/inbox.mjs +122 -0
  72. package/lib/engine/session-runtime/notifications.mjs +129 -0
  73. package/lib/engine/session-runtime/registry.mjs +328 -0
  74. package/lib/engine/session-runtime/runner.mjs +344 -0
  75. package/lib/engine/session-runtime/socket.mjs +212 -0
  76. package/lib/engine/session-runtime/wakeup.mjs +115 -0
  77. package/lib/engine/skills/index.mjs +321 -0
  78. package/lib/engine/tools/bash-background.mjs +533 -0
  79. package/lib/engine/tools/bash.mjs +216 -0
  80. package/lib/engine/tools/edit.mjs +97 -0
  81. package/lib/engine/tools/glob.mjs +81 -0
  82. package/lib/engine/tools/grep.mjs +224 -0
  83. package/lib/engine/tools/index.mjs +84 -0
  84. package/lib/engine/tools/list-agents.mjs +32 -0
  85. package/lib/engine/tools/ls.mjs +127 -0
  86. package/lib/engine/tools/monitor.mjs +82 -0
  87. package/lib/engine/tools/notebook-edit.mjs +218 -0
  88. package/lib/engine/tools/read.mjs +103 -0
  89. package/lib/engine/tools/schedule-wakeup.mjs +45 -0
  90. package/lib/engine/tools/schema.mjs +144 -0
  91. package/lib/engine/tools/send-message.mjs +77 -0
  92. package/lib/engine/tools/session.mjs +70 -0
  93. package/lib/engine/tools/todo.mjs +144 -0
  94. package/lib/engine/tools/toolsearch.mjs +217 -0
  95. package/lib/engine/tools/walk.mjs +193 -0
  96. package/lib/engine/tools/web-switch.mjs +31 -0
  97. package/lib/engine/tools/webfetch-html.mjs +387 -0
  98. package/lib/engine/tools/webfetch-net.mjs +340 -0
  99. package/lib/engine/tools/webfetch.mjs +198 -0
  100. package/lib/engine/tools/websearch.mjs +91 -0
  101. package/lib/engine/tools/workflow.mjs +95 -0
  102. package/lib/engine/tools/write.mjs +76 -0
  103. package/lib/engine/tui/line-editor.mjs +137 -0
  104. package/lib/engine/tui/render.mjs +86 -0
  105. package/lib/engine/tui/tui.mjs +274 -0
  106. package/lib/engine/wire/anthropic-messages.mjs +263 -0
  107. package/lib/engine/wire/effort.mjs +36 -0
  108. package/lib/engine/wire/errors.mjs +496 -0
  109. package/lib/engine/wire/http.mjs +441 -0
  110. package/lib/engine/wire/index.mjs +76 -0
  111. package/lib/engine/wire/openai-chat.mjs +332 -0
  112. package/lib/engine/wire/prompt-cache.mjs +79 -0
  113. package/lib/engine/wire/search.mjs +140 -0
  114. package/lib/engine/wire/sse.mjs +114 -0
  115. package/lib/engine/wire/stall.mjs +349 -0
  116. package/lib/engine/wire/token-provider.mjs +175 -0
  117. package/lib/engine/wire/usage.mjs +192 -0
  118. package/lib/engine/workflow/host.mjs +524 -0
  119. package/lib/engine/workflow/journal.mjs +188 -0
  120. package/lib/engine/workflow/json-schema.mjs +171 -0
  121. package/lib/engine/workflow/meta.mjs +329 -0
  122. package/lib/engine/workflow/notifications.mjs +52 -0
  123. package/lib/engine/workflow/runtime.mjs +447 -0
  124. package/lib/engine/workflow/sandbox.mjs +534 -0
  125. package/lib/engine/workflow/worker.mjs +141 -0
  126. package/lib/engine/workflow/worktree.mjs +74 -0
  127. package/lib/execution/disposition.mjs +1 -1
  128. package/lib/execution/intake.mjs +10 -0
  129. package/lib/execution/surface-policy.mjs +15 -0
  130. package/lib/learning/curator.mjs +8 -6
  131. package/lib/learning/reflect.mjs +8 -6
  132. package/lib/model-router/catalog/cohort.yaml +137 -0
  133. package/lib/model-router/catalog.mjs +118 -1
  134. package/lib/model-router/failover.mjs +67 -16
  135. package/lib/model-router/llm-task.mjs +39 -3
  136. package/lib/model-router/resolve.mjs +89 -3
  137. package/lib/model-router/spawn.mjs +46 -47
  138. package/lib/model-router/taxonomy.mjs +126 -4
  139. package/lib/org/cost-sync.mjs +141 -11
  140. package/lib/org/inbound/broadcast.mjs +289 -0
  141. package/lib/org/inbound/collective.mjs +375 -0
  142. package/lib/org/inbound/directedness.mjs +96 -8
  143. package/lib/org/inbound/facts.mjs +78 -2
  144. package/lib/org/inbound/project.mjs +22 -0
  145. package/lib/org/inbound/surfaces.mjs +14 -0
  146. package/lib/org/llm-token.mjs +879 -0
  147. package/lib/org/mesh.mjs +61 -0
  148. package/lib/org/messaging.mjs +3 -1
  149. package/lib/org/protocol.checksum +1 -1
  150. package/lib/org/protocol.mjs +15 -0
  151. package/lib/org/quota.mjs +520 -0
  152. package/lib/org/tool-surface.mjs +104 -16
  153. package/lib/org/ui-parity.mjs +16 -1
  154. package/lib/org/work-ledger.mjs +37 -6
  155. package/lib/rate-guard.mjs +114 -1
  156. package/lib/resource-governor.mjs +41 -6
  157. package/lib/runtime/adapter.mjs +823 -0
  158. package/lib/runtime/child-env.mjs +191 -0
  159. package/lib/runtime/legacy-shell-guard.mjs +97 -0
  160. package/lib/runtime/seat-engine.mjs +162 -0
  161. package/lib/session/ask-ledger.mjs +271 -0
  162. package/lib/session/current-work.mjs +676 -0
  163. package/lib/session/feed-core.mjs +40 -3
  164. package/lib/session/launch-args.mjs +56 -4
  165. package/lib/session/status-summary.mjs +26 -9
  166. package/lib/session/upgrade-notice.mjs +42 -0
  167. package/lib/setup/claude-probe.mjs +117 -13
  168. package/lib/setup/enrich.mjs +13 -10
  169. package/lib/setup/sections/model.mjs +39 -13
  170. package/lib/telemetry/collect.mjs +208 -9
  171. package/lib/upgrade/ignored-drift.mjs +105 -0
  172. package/lib/voice/post-call-brief.mjs +30 -17
  173. package/package.json +13 -3
  174. package/plugins/maestro-skills/skills/board-work.md +5 -0
  175. package/plugins/maestro-skills/skills/inbound-triage.md +56 -15
  176. package/plugins/maestro-skills/skills/main-session.md +18 -7
  177. package/scripts/ci/check-tarball-fidelity.mjs +126 -2
  178. package/scripts/ci/run-tests.mjs +47 -19
  179. package/scripts/cohort-llm/api-key-helper.mjs +92 -0
  180. package/scripts/collective/hook-runner.mjs +29 -2
  181. package/scripts/continuous-monitor.sh +13 -0
  182. package/scripts/cost/track-claude-usage.mjs +15 -0
  183. package/scripts/daemon/agent-daemon.mjs +408 -20
  184. package/scripts/daemon/assurance.mjs +48 -12
  185. package/scripts/daemon/cadence-consumer.mjs +218 -68
  186. package/scripts/daemon/cadence-handlers.mjs +73 -4
  187. package/scripts/daemon/classifier.mjs +75 -26
  188. package/scripts/daemon/context-compiler.mjs +51 -37
  189. package/scripts/daemon/deliver.mjs +30 -1
  190. package/scripts/daemon/dispatcher.mjs +595 -149
  191. package/scripts/daemon/health.mjs +14 -1
  192. package/scripts/daemon/maestro-daemon.mjs +11 -0
  193. package/scripts/daemon/prompt-builder.mjs +24 -0
  194. package/scripts/daemon/responder.mjs +246 -79
  195. package/scripts/daemon/sdk-version.mjs +98 -16
  196. package/scripts/eval/probe-gateway.mjs +635 -0
  197. package/scripts/eval/replay/extract.mjs +270 -0
  198. package/scripts/eval/replay/grade.mjs +260 -0
  199. package/scripts/eval/replay/lib/config.mjs +50 -0
  200. package/scripts/eval/replay/lib/effects.mjs +65 -0
  201. package/scripts/eval/replay/lib/fixture.mjs +188 -0
  202. package/scripts/eval/replay/lib/judge.mjs +72 -0
  203. package/scripts/eval/replay/lib/redact.mjs +136 -0
  204. package/scripts/eval/replay/lib/sandbox.mjs +170 -0
  205. package/scripts/eval/replay/lib/schema-check.mjs +63 -0
  206. package/scripts/eval/replay/lib/transcript.mjs +76 -0
  207. package/scripts/eval/replay/mcp-replay-stub.mjs +101 -0
  208. package/scripts/eval/replay/report.mjs +185 -0
  209. package/scripts/eval/replay/run.mjs +404 -0
  210. package/scripts/fleet/rollout.mjs +1094 -0
  211. package/scripts/hooks/pre-send-audit.sh +36 -245
  212. package/scripts/hooks/pre-write-yaml-validate.mjs +275 -0
  213. package/scripts/hooks/validate-state-yaml.sh +190 -0
  214. package/scripts/huddle/huddle-llm.mjs +361 -0
  215. package/scripts/huddle/huddle-server.mjs +46 -121
  216. package/scripts/local-triggers/autoupdate.sh +448 -78
  217. package/scripts/local-triggers/run-trigger.sh +13 -0
  218. package/scripts/maintenance/pin-integrity.mjs +364 -0
  219. package/scripts/poll-slack-events.sh +41 -9
  220. package/scripts/poller/slack-socket-mode.mjs +28 -3
  221. package/scripts/session/supervisor.mjs +80 -13
  222. package/scripts/spawn-session.sh +13 -0
  223. package/bin/maestro.test.mjs +0 -1574
  224. package/lib/action-executor.test.mjs +0 -871
  225. package/lib/archetype.test.mjs +0 -132
  226. package/lib/assurance/plan-note.test.mjs +0 -234
  227. package/lib/assurance/room-budget.test.mjs +0 -486
  228. package/lib/assurance/tier.test.mjs +0 -174
  229. package/lib/autonomy.test.mjs +0 -66
  230. package/lib/backlog.test.mjs +0 -302
  231. package/lib/backup/policy.test.mjs +0 -305
  232. package/lib/budget-escalate.test.mjs +0 -232
  233. package/lib/budget-guard.envelope.test.mjs +0 -476
  234. package/lib/budget-guard.test.mjs +0 -427
  235. package/lib/cadence-bus-requeue.test.mjs +0 -83
  236. package/lib/cadence-bus-schedule.test.mjs +0 -194
  237. package/lib/cadence-bus.test.mjs +0 -720
  238. package/lib/cadences.test.mjs +0 -230
  239. package/lib/capability/inventory.test.mjs +0 -232
  240. package/lib/capability.test.mjs +0 -78
  241. package/lib/channels/base-adapter.test.mjs +0 -590
  242. package/lib/channels/channels.test.mjs +0 -371
  243. package/lib/channels/contract.test.mjs +0 -162
  244. package/lib/channels/inbox-item.test.mjs +0 -368
  245. package/lib/channels/orgmail/adapter.test.mjs +0 -448
  246. package/lib/channels/pairing.test.mjs +0 -270
  247. package/lib/channels/repeat-suppressor.test.mjs +0 -134
  248. package/lib/channels/slack-adapter.test.mjs +0 -212
  249. package/lib/channels/telegram-adapter.test.mjs +0 -306
  250. package/lib/channels/voice/adapter.test.mjs +0 -278
  251. package/lib/channels/whatsapp/adapter-baileys.test.mjs +0 -359
  252. package/lib/channels/whatsapp/baileys-typing.test.mjs +0 -154
  253. package/lib/charter.test.mjs +0 -89
  254. package/lib/claude-bin.test.mjs +0 -131
  255. package/lib/cli/board.test.mjs +0 -227
  256. package/lib/cli/design.test.mjs +0 -270
  257. package/lib/cli/doctor-checks.test.mjs +0 -336
  258. package/lib/cli/global-setup-extras.test.mjs +0 -462
  259. package/lib/cli/inbox.test.mjs +0 -230
  260. package/lib/cli/session-ack.test.mjs +0 -63
  261. package/lib/cli/session.test.mjs +0 -613
  262. package/lib/collective/capture.test.mjs +0 -121
  263. package/lib/collective/cards.test.mjs +0 -114
  264. package/lib/collective/config.test.mjs +0 -123
  265. package/lib/collective/global-config.test.mjs +0 -220
  266. package/lib/collective/global-skills.test.mjs +0 -126
  267. package/lib/collective/presence.test.mjs +0 -95
  268. package/lib/collective/recall.test.mjs +0 -116
  269. package/lib/collective/vendor-skills.test.mjs +0 -306
  270. package/lib/comms/send-gate.test.mjs +0 -770
  271. package/lib/comms.test.mjs +0 -41
  272. package/lib/context/budget.test.mjs +0 -252
  273. package/lib/context/history-scope.test.mjs +0 -79
  274. package/lib/cost/ledger-row.test.mjs +0 -183
  275. package/lib/design/design-md.test.mjs +0 -318
  276. package/lib/design/fixtures/DESIGN.golden.md +0 -238
  277. package/lib/design/fixtures/PRODUCT.golden.md +0 -67
  278. package/lib/design/fixtures/foundation.json +0 -133
  279. package/lib/design/refresh-gate.test.mjs +0 -144
  280. package/lib/design/write.test.mjs +0 -241
  281. package/lib/diagnostics/alerts.test.mjs +0 -318
  282. package/lib/diagnostics/backup-freshness.test.mjs +0 -185
  283. package/lib/diagnostics/counters.test.mjs +0 -206
  284. package/lib/diagnostics/events.test.mjs +0 -290
  285. package/lib/diagnostics/otel.test.mjs +0 -196
  286. package/lib/diagnostics/trace.test.mjs +0 -251
  287. package/lib/env-compat.test.mjs +0 -104
  288. package/lib/execution/disposition.test.mjs +0 -553
  289. package/lib/execution/drive.test.mjs +0 -270
  290. package/lib/execution/effects.test.mjs +0 -344
  291. package/lib/execution/intake.test.mjs +0 -389
  292. package/lib/execution/journal.test.mjs +0 -261
  293. package/lib/execution/match.test.mjs +0 -235
  294. package/lib/execution/pipeline.test.mjs +0 -392
  295. package/lib/execution/route.test.mjs +0 -186
  296. package/lib/execution/surface-policy.test.mjs +0 -162
  297. package/lib/fs-atomic.test.mjs +0 -72
  298. package/lib/fs-ownership.test.mjs +0 -158
  299. package/lib/goals/admission.test.mjs +0 -164
  300. package/lib/goals/classify.test.mjs +0 -167
  301. package/lib/goals/collaborate.test.mjs +0 -336
  302. package/lib/goals/gaps.test.mjs +0 -284
  303. package/lib/goals/loop.test.mjs +0 -845
  304. package/lib/hooks/bus.test.mjs +0 -387
  305. package/lib/identity/persona.test.mjs +0 -142
  306. package/lib/kpi-sensors.test.mjs +0 -278
  307. package/lib/kpi.test.mjs +0 -244
  308. package/lib/learning/config.test.mjs +0 -75
  309. package/lib/learning/counters.test.mjs +0 -69
  310. package/lib/learning/curator-consolidate.test.mjs +0 -238
  311. package/lib/learning/curator.test.mjs +0 -106
  312. package/lib/learning/reflect.test.mjs +0 -0
  313. package/lib/learning/session-index.test.mjs +0 -125
  314. package/lib/learning/skill-writer.test.mjs +0 -210
  315. package/lib/mandate/audit.test.mjs +0 -195
  316. package/lib/mandate/contract.test.mjs +0 -185
  317. package/lib/mandate/derive.test.mjs +0 -274
  318. package/lib/mandate/model.test.mjs +0 -164
  319. package/lib/mandate/refresh.test.mjs +0 -389
  320. package/lib/mcp/server.test.mjs +0 -426
  321. package/lib/model-router/auth-profiles.test.mjs +0 -580
  322. package/lib/model-router/catalog.test.mjs +0 -385
  323. package/lib/model-router/economics.test.mjs +0 -438
  324. package/lib/model-router/failover.test.mjs +0 -439
  325. package/lib/model-router/health.test.mjs +0 -338
  326. package/lib/model-router/integration-coverage.test.mjs +0 -831
  327. package/lib/model-router/integration.test.mjs +0 -564
  328. package/lib/model-router/ledger.test.mjs +0 -415
  329. package/lib/model-router/llm-task.test.mjs +0 -392
  330. package/lib/model-router/org-credentials.test.mjs +0 -265
  331. package/lib/model-router/pricing-refresh.test.mjs +0 -286
  332. package/lib/model-router/reconcile.test.mjs +0 -316
  333. package/lib/model-router/repair.test.mjs +0 -180
  334. package/lib/model-router/spawn.test.mjs +0 -446
  335. package/lib/model-router/taxonomy.test.mjs +0 -410
  336. package/lib/model-router.test.mjs +0 -1207
  337. package/lib/org/activity.test.mjs +0 -134
  338. package/lib/org/approvals.test.mjs +0 -216
  339. package/lib/org/awareness.test.mjs +0 -159
  340. package/lib/org/board-mine-cache.test.mjs +0 -53
  341. package/lib/org/board.test.mjs +0 -187
  342. package/lib/org/bootstrap-context.test.mjs +0 -153
  343. package/lib/org/client.test.mjs +0 -1206
  344. package/lib/org/cohort-client.test.mjs +0 -126
  345. package/lib/org/cost-sync.test.mjs +0 -153
  346. package/lib/org/doctor.test.mjs +0 -346
  347. package/lib/org/engagement-ledger.test.mjs +0 -112
  348. package/lib/org/engagement.test.mjs +0 -739
  349. package/lib/org/handoff.test.mjs +0 -269
  350. package/lib/org/inbound/directedness.test.mjs +0 -668
  351. package/lib/org/inbound/facts.test.mjs +0 -471
  352. package/lib/org/inbound/hydrate.test.mjs +0 -908
  353. package/lib/org/inbound/index.test.mjs +0 -429
  354. package/lib/org/inbound/project.test.mjs +0 -287
  355. package/lib/org/integration-tools.test.mjs +0 -160
  356. package/lib/org/keys.test.mjs +0 -92
  357. package/lib/org/knowledge.test.mjs +0 -326
  358. package/lib/org/leases.test.mjs +0 -235
  359. package/lib/org/mesh-directives.test.mjs +0 -110
  360. package/lib/org/mesh-integration.test.mjs +0 -127
  361. package/lib/org/mesh.test.mjs +0 -400
  362. package/lib/org/messaging.test.mjs +0 -471
  363. package/lib/org/param-contract.test.mjs +0 -477
  364. package/lib/org/policy.test.mjs +0 -237
  365. package/lib/org/protocol.checksum.test.mjs +0 -90
  366. package/lib/org/protocol.test.mjs +0 -323
  367. package/lib/org/push.test.mjs +0 -792
  368. package/lib/org/registry.test.mjs +0 -100
  369. package/lib/org/resource-tools.test.mjs +0 -361
  370. package/lib/org/tool-access.test.mjs +0 -144
  371. package/lib/org/tool-surface-integration.test.mjs +0 -120
  372. package/lib/org/tool-surface.test.mjs +0 -1268
  373. package/lib/org/typing.test.mjs +0 -291
  374. package/lib/org/ui-parity.test.mjs +0 -560
  375. package/lib/org/verify.test.mjs +0 -194
  376. package/lib/org/work-ledger.test.mjs +0 -273
  377. package/lib/plan/adoption-e2e.test.mjs +0 -366
  378. package/lib/plan/budget-enforcement.test.mjs +0 -400
  379. package/lib/plan/compile.test.mjs +0 -382
  380. package/lib/plan/emit.test.mjs +0 -269
  381. package/lib/plan/explain.test.mjs +0 -188
  382. package/lib/prompts/parallelism.test.mjs +0 -177
  383. package/lib/rag/rag.test.mjs +0 -505
  384. package/lib/rate-guard.test.mjs +0 -272
  385. package/lib/reactive-gate.test.mjs +0 -57
  386. package/lib/render.test.mjs +0 -68
  387. package/lib/resource-governor.test.mjs +0 -488
  388. package/lib/scheduling/dynamic-jobs.test.mjs +0 -344
  389. package/lib/scheduling/jitter.test.mjs +0 -140
  390. package/lib/secrets/broker.test.mjs +0 -280
  391. package/lib/secrets/providers.test.mjs +0 -274
  392. package/lib/security/audit-engine.test.mjs +0 -424
  393. package/lib/security/coerce-args.test.mjs +0 -281
  394. package/lib/security/dangerous-tools.test.mjs +0 -68
  395. package/lib/security/external-content.test.mjs +0 -84
  396. package/lib/security/redact.test.mjs +0 -441
  397. package/lib/security/secret-equal.test.mjs +0 -55
  398. package/lib/session/config.test.mjs +0 -92
  399. package/lib/session/feed-core.test.mjs +0 -198
  400. package/lib/session/first-run.test.mjs +0 -121
  401. package/lib/session/frontdoor.test.mjs +0 -205
  402. package/lib/session/handoffs.test.mjs +0 -183
  403. package/lib/session/identity.test.mjs +0 -180
  404. package/lib/session/inbox-claims.test.mjs +0 -286
  405. package/lib/session/launch-args.test.mjs +0 -157
  406. package/lib/session/liveness.test.mjs +0 -100
  407. package/lib/session/status-summary.test.mjs +0 -118
  408. package/lib/session-permissions.test.mjs +0 -120
  409. package/lib/setup/claude-probe.test.mjs +0 -187
  410. package/lib/setup/completeness.test.mjs +0 -110
  411. package/lib/setup/context-pack.test.mjs +0 -89
  412. package/lib/setup/enrich.test.mjs +0 -115
  413. package/lib/setup/enroll-from-cohort.test.mjs +0 -300
  414. package/lib/setup/integration.test.mjs +0 -162
  415. package/lib/setup/io.test.mjs +0 -77
  416. package/lib/setup/runner.test.mjs +0 -132
  417. package/lib/setup/sections/identity.test.mjs +0 -234
  418. package/lib/setup/sections/inventory.test.mjs +0 -198
  419. package/lib/setup/sections/learning.test.mjs +0 -81
  420. package/lib/setup/sections/mandate.test.mjs +0 -388
  421. package/lib/setup/sections/messaging.test.mjs +0 -127
  422. package/lib/setup/sections/model.test.mjs +0 -240
  423. package/lib/setup/sections/org.test.mjs +0 -346
  424. package/lib/setup/sections/orgmail.test.mjs +0 -118
  425. package/lib/setup/sections/recovery.test.mjs +0 -98
  426. package/lib/setup/sections/subagents.test.mjs +0 -429
  427. package/lib/setup/sections/verify.test.mjs +0 -175
  428. package/lib/setup/sot.test.mjs +0 -81
  429. package/lib/setup/state.test.mjs +0 -115
  430. package/lib/singleton.test.mjs +0 -151
  431. package/lib/subagents/cli.test.mjs +0 -389
  432. package/lib/subagents/client.test.mjs +0 -309
  433. package/lib/subagents/gap.test.mjs +0 -234
  434. package/lib/subagents/lock.test.mjs +0 -248
  435. package/lib/subagents/manifest.test.mjs +0 -175
  436. package/lib/subagents/refs.test.mjs +0 -204
  437. package/lib/subagents/resolve.test.mjs +0 -422
  438. package/lib/subagents/schema.test.mjs +0 -328
  439. package/lib/telemetry/alerts.test.mjs +0 -109
  440. package/lib/telemetry/collect.test.mjs +0 -1274
  441. package/lib/tool-definitions-integration.test.mjs +0 -83
  442. package/lib/tool-definitions.test.mjs +0 -437
  443. package/lib/upgrade/global-refresh.test.mjs +0 -65
  444. package/lib/upgrade/launchd-reconcile.test.mjs +0 -272
  445. package/lib/upgrade/post-steps.test.mjs +0 -200
  446. package/lib/upgrade/verify.test.mjs +0 -164
  447. package/lib/util/fetch-timeout.test.mjs +0 -202
  448. package/lib/util/reconnect.test.mjs +0 -369
  449. package/lib/util/unhandled.test.mjs +0 -216
  450. package/lib/voice/outbound.test.mjs +0 -69
  451. package/lib/voice/session-rotation.test.mjs +0 -114
  452. package/lib/voice/stt.test.mjs +0 -226
  453. package/lib/voice/voice.test.mjs +0 -990
  454. package/scripts/cadence/enqueue-cadence-tick.test.mjs +0 -187
  455. package/scripts/ci/check-docs-accuracy.test.mjs +0 -409
  456. package/scripts/ci/check-durable-write-seam.test.mjs +0 -90
  457. package/scripts/ci/check-no-build-artifacts.test.mjs +0 -71
  458. package/scripts/ci/check-no-residual-identity.test.mjs +0 -202
  459. package/scripts/ci/check-skill-packs.test.mjs +0 -495
  460. package/scripts/ci/check-subagent-frontmatter.test.mjs +0 -124
  461. package/scripts/ci/check.test.mjs +0 -194
  462. package/scripts/ci/conformance-org-api.test.mjs +0 -425
  463. package/scripts/cloud-relay/voice/relay-identity.test.mjs +0 -96
  464. package/scripts/collective/hook-runner.test.mjs +0 -173
  465. package/scripts/cost/fleet-digest.test.mjs +0 -207
  466. package/scripts/cost/track-claude-usage-pricing.test.mjs +0 -183
  467. package/scripts/cost/track-claude-usage.test.mjs +0 -148
  468. package/scripts/daemon/agent-daemon-board-mine.test.mjs +0 -96
  469. package/scripts/daemon/agent-daemon-design.test.mjs +0 -238
  470. package/scripts/daemon/agent-daemon-frontdoor.test.mjs +0 -60
  471. package/scripts/daemon/agent-daemon.test.mjs +0 -995
  472. package/scripts/daemon/assurance-e2e.test.mjs +0 -613
  473. package/scripts/daemon/assurance.test.mjs +0 -1791
  474. package/scripts/daemon/board-mirror.test.mjs +0 -165
  475. package/scripts/daemon/cadence-consumer-frontdoor.test.mjs +0 -393
  476. package/scripts/daemon/cadence-consumer-governance.test.mjs +0 -276
  477. package/scripts/daemon/cadence-consumer.test.mjs +0 -776
  478. package/scripts/daemon/cadence-handlers.test.mjs +0 -837
  479. package/scripts/daemon/classifier-identity.test.mjs +0 -137
  480. package/scripts/daemon/classifier.test.mjs +0 -266
  481. package/scripts/daemon/classify-kind.test.mjs +0 -40
  482. package/scripts/daemon/context-compiler.test.mjs +0 -406
  483. package/scripts/daemon/deliver.test.mjs +0 -564
  484. package/scripts/daemon/dispatcher-cooldown.test.mjs +0 -122
  485. package/scripts/daemon/dispatcher-governance.test.mjs +0 -1013
  486. package/scripts/daemon/dispatcher-resume.test.mjs +0 -166
  487. package/scripts/daemon/dispatcher-session-continuity.test.mjs +0 -365
  488. package/scripts/daemon/execution-ladder.test.mjs +0 -470
  489. package/scripts/daemon/goal-steward-cadence.test.mjs +0 -312
  490. package/scripts/daemon/inbox-deferral-session.test.mjs +0 -49
  491. package/scripts/daemon/inbox-deferral.test.mjs +0 -336
  492. package/scripts/daemon/inbox-wake.test.mjs +0 -199
  493. package/scripts/daemon/integration.test.mjs +0 -149
  494. package/scripts/daemon/lib/self-echo.test.mjs +0 -153
  495. package/scripts/daemon/lib/session-router.test.mjs +0 -554
  496. package/scripts/daemon/prompt-builder-preamble.test.mjs +0 -210
  497. package/scripts/daemon/prompt-builder.test.mjs +0 -556
  498. package/scripts/daemon/responder-cost.test.mjs +0 -68
  499. package/scripts/daemon/responder-history.test.mjs +0 -221
  500. package/scripts/daemon/sdk-version.test.mjs +0 -31
  501. package/scripts/daemon/session-lock.test.mjs +0 -252
  502. package/scripts/daemon/session-outcomes.test.mjs +0 -533
  503. package/scripts/daemon/typing-registry.test.mjs +0 -102
  504. package/scripts/hooks/pre-send-audit.test.mjs +0 -354
  505. package/scripts/huddle/huddle-prompt.test.mjs +0 -176
  506. package/scripts/local-triggers/autoupdate.test.mjs +0 -518
  507. package/scripts/local-triggers/generate-plists.test.mjs +0 -456
  508. package/scripts/media-generation/brand-clause.test.mjs +0 -135
  509. package/scripts/org/send-orgmail.first-contact.test.mjs +0 -102
  510. package/scripts/poller/inbox-privilege-injection.test.mjs +0 -167
  511. package/scripts/poller/inbox-scan-poller.test.mjs +0 -295
  512. package/scripts/poller/lib/cloud-relay-dedup.test.mjs +0 -133
  513. package/scripts/poller/slack-socket-mode.test.mjs +0 -805
  514. package/scripts/poller-launchd/install.test.mjs +0 -243
  515. package/scripts/restore-from-backup.test.mjs +0 -181
  516. package/scripts/session/feed.test.mjs +0 -196
  517. package/scripts/session/supervisor-sh.test.mjs +0 -218
  518. package/scripts/session/supervisor.test.mjs +0 -482
  519. package/scripts/setup/configure-macos.test.mjs +0 -306
  520. package/scripts/setup/gen-subagent-manifest.test.mjs +0 -124
  521. package/scripts/setup/generate-agent-package-json.test.mjs +0 -143
  522. package/scripts/setup/generate-capability.test.mjs +0 -134
  523. package/scripts/setup/init-agent.test.mjs +0 -370
  524. package/scripts/setup/init-skill-marketplace.test.mjs +0 -193
  525. package/scripts/vendor/sync-skill-packs.test.mjs +0 -103
  526. package/scripts/watchdog/memory-watchdog.test.mjs +0 -64
@@ -1,995 +0,0 @@
1
- /**
2
- * agent-daemon.test.mjs — F1/H2 message-loss window (task #16, 0.8 follow-up).
3
- *
4
- * Before this fix, processItem() called markProcessed() (rename → .processed)
5
- * IMMEDIATELY after a fire-and-forget dispatch(). A crash before the spawned
6
- * session closed left the user's inbox item `.processed` with redelivery
7
- * blocked — the message was silently lost.
8
- *
9
- * The fix defers the finalize to the dispatch onClose SUCCESS path and records a
10
- * durable in-flight admission so a restart re-delivers an un-finalized item:
11
- * • clean close → markProcessed() + clearInFlight() (finalize)
12
- * • non-zero / spawn error → clearInFlight() + releaseLock() (re-deliverable)
13
- * • crash mid-flight → admission survives; recoverInFlight() (startup) releases
14
- * the per-item lock so the still-un-`.processed` file is re-polled.
15
- *
16
- * These tests drive the REAL daemon helpers (recordInFlight / clearInFlight /
17
- * markProcessed / recoverInFlight) and the REAL per-item lock from
18
- * session-lock.mjs, asserting the on-disk state that the poller would observe.
19
- *
20
- * The daemon module statically pulls in the agent-only `imapflow` dep (via the
21
- * gmail poller) which isn't packaged in CI, so we register a tiny in-process
22
- * ESM loader that stubs `imapflow` to a no-op class. Everything else in the
23
- * daemon's import graph resolves normally, so the code under test is the real
24
- * implementation, not a re-creation.
25
- */
26
-
27
- import { test, before } from "node:test";
28
- import assert from "node:assert/strict";
29
- import { register } from "node:module";
30
- import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, readdirSync, existsSync, rmSync } from "node:fs";
31
- import { join } from "node:path";
32
- import { tmpdir } from "node:os";
33
-
34
- // --- stub the agent-only `imapflow` dep so the daemon module can be imported ---
35
- const loaderSrc = `
36
- export async function resolve(specifier, context, next) {
37
- if (specifier === "imapflow") return { url: "imapflow-stub:main", shortCircuit: true };
38
- return next(specifier, context);
39
- }
40
- export async function load(url, context, next) {
41
- if (url === "imapflow-stub:main") return { format: "module", shortCircuit: true, source: "export class ImapFlow {}" };
42
- return next(url, context);
43
- }
44
- `;
45
- register(`data:text/javascript,${encodeURIComponent(loaderSrc)}`);
46
-
47
- // One shared AGENT_DIR for the whole file: session-lock.mjs (imported transitively
48
- // by the daemon) reads AGENT_DIR at module-eval time and is cached by URL, so all
49
- // imports must agree on the dir. Per-test isolation comes from unique item ids and
50
- // a clean-between-tests of the relevant state subdirs.
51
- const AGENT_DIR = mkdtempSync(join(tmpdir(), "maestro-agent-daemon-"));
52
- process.env.AGENT_DIR = AGENT_DIR;
53
- process.env.MAESTRO_DAEMON_NO_AUTOSTART = "1"; // import without spinning the poll loops
54
-
55
- let daemon; // the real daemon module (recordInFlight/clearInFlight/markProcessed/recoverInFlight)
56
- let lock; // the real session-lock module (acquireLock/readLock)
57
- let bus; // the real hook-bus module (getHookBus/resetHookBus) — shared singleton
58
- let counters; // the real counters module (snapshot/resetMem) — shared transitive import
59
-
60
- before(async () => {
61
- mkdirSync(join(AGENT_DIR, "state", "inbox", "slack"), { recursive: true });
62
- mkdirSync(join(AGENT_DIR, "state", "sessions", "locks"), { recursive: true });
63
- const dURL = new URL("./agent-daemon.mjs", import.meta.url);
64
- daemon = await import(`${dURL.href}?f1h2=${Math.random()}`);
65
- lock = await import("./session-lock.mjs");
66
- // These are the SAME singletons the daemon reaches transitively (imported by
67
- // bare specifier, so the module cache shares them) — that's what lets the test
68
- // emit on the same bus the daemon subscribed to and read the same counters.
69
- bus = await import("../../lib/hooks/bus.mjs");
70
- counters = await import("../../lib/diagnostics/counters.mjs");
71
- });
72
-
73
- // Read the diagnostic-event rows the daemon appended to its tmp
74
- // logs/diagnostics/<UTC-date>.jsonl stream. [] when none written.
75
- function readDiagnostics() {
76
- const date = new Date().toISOString().slice(0, 10);
77
- const path = join(AGENT_DIR, "logs", "diagnostics", `${date}.jsonl`);
78
- let raw = "";
79
- try { raw = readFileSync(path, "utf-8"); } catch { return []; }
80
- return raw.split("\n").filter(Boolean).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
81
- }
82
-
83
- function slackInbox() { return join(AGENT_DIR, "state", "inbox", "slack"); }
84
- function inflightDir() { return join(AGENT_DIR, "state", "inbox-inflight"); }
85
-
86
- function resetState() {
87
- for (const d of [slackInbox(), inflightDir(), join(AGENT_DIR, "state", "sessions", "locks")]) {
88
- try { for (const f of readdirSync(d)) rmSync(join(d, f), { force: true }); } catch { /* */ }
89
- }
90
- }
91
-
92
- /**
93
- * Write a live (un-.processed) poller-style inbox file for an item, returning
94
- * the filename. Mirrors the real poller naming (`<ts>-<item.id>.yaml`) so that
95
- * markProcessed's id/raw_ref substring match finds it, exactly as in production.
96
- */
97
- function writeLiveItem(item) {
98
- const name = `2026-06-10T00-00-00Z-${item.id}.yaml`;
99
- const body = [
100
- `id: "${item.id}"`,
101
- `service: "slack"`,
102
- `channel_id: "${item.channel || "D1"}"`,
103
- `timestamp: "2026-06-10T00:00:00Z"`,
104
- `content: |`,
105
- ` hello`,
106
- "",
107
- ].join("\n");
108
- writeFileSync(join(slackInbox(), name), body);
109
- return name;
110
- }
111
-
112
- function inboxFiles() { return readdirSync(slackInbox()).sort(); }
113
- function inflightFiles() { try { return readdirSync(inflightDir()).filter((f) => f.endsWith(".json")); } catch { return []; } }
114
-
115
- // ---------------------------------------------------------------------------
116
- // 1) Crash between dispatch and close → item is RE-DELIVERABLE (not .processed),
117
- // and recoverInFlight() releases the gating lock so the next poll re-delivers.
118
- // ---------------------------------------------------------------------------
119
-
120
- test("F1/H2: a crash between dispatch and close leaves the item re-deliverable (not .processed)", () => {
121
- resetState();
122
- const item = { id: "MSG-CRASH", raw_ref: "slack:C1:1001", channel: "D1", sender: "ceo" };
123
- const name = writeLiveItem(item);
124
-
125
- // Simulate processItem up to (but not past) the dispatch: the poll filter holds
126
- // the per-item lock, and the daemon records the durable in-flight admission.
127
- const acq = lock.acquireLock(item.raw_ref, { sender: item.sender, channel: item.channel });
128
- assert.equal(acq.acquired, true, "poll filter acquired the per-item lock");
129
- daemon.recordInFlight(item, "slack");
130
-
131
- // --- CRASH HERE: no onClose ran, so markProcessed was never called. ---
132
- // The user's message must NOT be .processed (the whole bug).
133
- assert.deepEqual(inboxFiles(), [name], "file is still live (un-.processed) after a mid-flight crash");
134
- assert.equal(inflightFiles().length, 1, "the in-flight admission survives the crash");
135
- // The held lock would block a naive re-poll until the 2h stale TTL — that is
136
- // exactly what recovery must clear.
137
- assert.equal(lock.acquireLock(item.raw_ref).acquired, false, "lock still held → re-poll blocked pre-recovery");
138
-
139
- // --- RESTART: recoverInFlight() releases the lock so the next poll re-delivers. ---
140
- const n = daemon.recoverInFlight();
141
- assert.equal(n, 1, "one in-flight admission recovered");
142
- assert.equal(inflightFiles().length, 0, "admission cleared after recovery");
143
- assert.deepEqual(inboxFiles(), [name], "the message is STILL on disk, un-.processed → re-deliverable");
144
- assert.equal(lock.acquireLock(item.raw_ref).acquired, true, "the gating lock was released → next poll re-delivers");
145
- });
146
-
147
- // ---------------------------------------------------------------------------
148
- // 2) A clean close finalizes the item (markProcessed) and clears the admission.
149
- // ---------------------------------------------------------------------------
150
-
151
- test("F1/H2: a clean close finalizes the item (markProcessed) and clears the in-flight admission", () => {
152
- resetState();
153
- const item = { id: "MSG-OK", raw_ref: "slack:C2:2002", channel: "D2", sender: "ceo" };
154
- const name = writeLiveItem(item);
155
- lock.acquireLock(item.raw_ref, { sender: item.sender });
156
- daemon.recordInFlight(item, "slack");
157
- assert.equal(inflightFiles().length, 1);
158
-
159
- // onClose({ok:true}) success path (the exact wiring processItem installs).
160
- daemon.markProcessed(item, "slack");
161
- daemon.clearInFlight(item);
162
-
163
- assert.deepEqual(inboxFiles(), [`${name}.processed`], "clean close finalized the item to .processed");
164
- assert.equal(inflightFiles().length, 0, "admission cleared on finalize");
165
- // A clean close finalized work that already completed — recovery is a no-op,
166
- // and there is no live file left for the poller to re-deliver.
167
- assert.equal(daemon.recoverInFlight(), 0, "nothing to recover after a clean finalize");
168
- });
169
-
170
- // ---------------------------------------------------------------------------
171
- // 3) A non-zero close / spawn error leaves the item re-deliverable and releases
172
- // the lock immediately (no need to wait for restart).
173
- // ---------------------------------------------------------------------------
174
-
175
- test("F1/H2: a failed close leaves the item re-deliverable and releases the lock now", () => {
176
- resetState();
177
- const item = { id: "MSG-FAIL", raw_ref: "slack:C3:3003", channel: "D3", sender: "ceo" };
178
- const name = writeLiveItem(item);
179
- lock.acquireLock(item.raw_ref, { sender: item.sender });
180
- daemon.recordInFlight(item, "slack");
181
-
182
- // onClose({ok:false}) failure path: clear admission + release lock, but DO NOT
183
- // markProcessed — the message stays re-deliverable.
184
- daemon.clearInFlight(item);
185
- lock.releaseLock(item.raw_ref);
186
-
187
- assert.deepEqual(inboxFiles(), [name], "failed session left the item live (un-.processed)");
188
- assert.equal(inflightFiles().length, 0, "admission cleared on failure");
189
- assert.equal(lock.acquireLock(item.raw_ref).acquired, true, "lock released → next poll re-delivers immediately");
190
- });
191
-
192
- // ---------------------------------------------------------------------------
193
- // 4) recoverInFlight() is a clean no-op when nothing is pending, and never
194
- // touches already-.processed files.
195
- // ---------------------------------------------------------------------------
196
-
197
- test("F1/H2: recoverInFlight is a no-op with no pending admissions", () => {
198
- resetState();
199
- assert.equal(daemon.recoverInFlight(), 0, "no admissions → recovered 0");
200
- });
201
-
202
- test("F1/H2: recordInFlight is a no-op for an item with no lock key (e.g. backlog)", () => {
203
- resetState();
204
- daemon.recordInFlight({ title: "deep work" }, "backlog");
205
- assert.equal(inflightFiles().length, 0, "no raw_ref/id → no admission written (backlog items are durable in queue files)");
206
- });
207
-
208
- // ===========================================================================
209
- // DIAG — the observability spine wired into the LIVE inbound pipeline.
210
- // ===========================================================================
211
- //
212
- // processItem() mints ONE trace_id per item and emits item_received → … → sent
213
- // (on a clean finalize) all under it. The daemon also registers the ONE real
214
- // hook-bus subscriber (onGuardFail → counters.bump("guard.fail")) so the bus is
215
- // live. STRICT additive: emitEvent + counters.bump never throw, and the dispatch
216
- // /classify/send semantics are untouched (the heavy collaborators are injected
217
- // here ONLY so the test drives the real instrumented body without spawning a
218
- // `claude` child — production passes no deps).
219
-
220
- function clearDiagnostics() {
221
- for (const sub of ["diagnostics", join("diagnostics", "counters")]) {
222
- const d = join(AGENT_DIR, "logs", sub);
223
- try { for (const f of readdirSync(d)) { if (f.endsWith(".jsonl")) rmSync(join(d, f), { force: true }); } } catch { /* */ }
224
- }
225
- try { counters.resetMem(); } catch { /* */ }
226
- }
227
-
228
- // ---------------------------------------------------------------------------
229
- // 1) A processed item emits item_received → … → sent with ONE stable trace_id.
230
- // ---------------------------------------------------------------------------
231
-
232
- test("DIAG: a processed item emits item_received → sent with ONE stable trace_id (the daemon's minted id)", async () => {
233
- resetState();
234
- clearDiagnostics();
235
- // A DM (channel_id starts with "D") so it always passes the directed-message
236
- // gate — keeps the test on the dispatch path deterministically.
237
- const item = { id: "MSG-TRACE", raw_ref: "slack:D1:1", channel: "dm/ceo", channel_id: "D1", sender: "ceo", content: "please draft the board memo" };
238
- writeLiveItem(item);
239
-
240
- // Inject the heavy collaborators so the REAL processItem body runs (trace mint,
241
- // item_received emit, dispatch onClose → sent emit) without a claude spawn:
242
- // - classify → "respond"/opus so it takes the holding+session (dispatch) path,
243
- // NOT the quick-reply path.
244
- // - isQuickReply → false to keep it on the dispatch path deterministically.
245
- // - sendHoldingMessage → no-op (no Slack post).
246
- // - dispatch → synchronously invoke onClose({ok:true}) to finalize.
247
- let dispatchedItem = null;
248
- await daemon.processItem(item, "slack", {
249
- classify: async () => ({ priority: "critical", action: "respond", model: "opus", summary: "draft board memo", category: "action_required", directed_at_agent: true }),
250
- isQuickReply: () => false,
251
- sendHoldingMessage: async () => ({ sent: false, holdingText: null }),
252
- dispatch: (_prompt, it, _cr, _src, opts) => { dispatchedItem = it; opts.onClose({ ok: true, code: 0 }); },
253
- });
254
-
255
- // The daemon set the minted id on the item and carried it onto the dispatch.
256
- assert.ok(typeof item.trace_id === "string" && item.trace_id.length === 26, "a 26-char ULID trace_id was minted onto the item");
257
- assert.equal(dispatchedItem.trace_id, item.trace_id, "the dispatch received the SAME trace_id");
258
-
259
- const rows = readDiagnostics();
260
- const received = rows.filter((r) => r.type === "item_received");
261
- const sent = rows.filter((r) => r.type === "sent");
262
- assert.equal(received.length, 1, "exactly one item_received");
263
- assert.equal(sent.length, 1, "exactly one sent on clean finalize");
264
- // The single-trace-id invariant: the first hop and the last hop share one id.
265
- assert.equal(received[0].trace_id, item.trace_id, "item_received carries the minted trace_id");
266
- assert.equal(sent[0].trace_id, item.trace_id, "sent carries the SAME trace_id (end-to-end correlation)");
267
- // The in-flight admission also carries the trace (cheap ledger correlation).
268
- assert.equal(sent[0].attrs.item_id, item.raw_ref, "sent records the item id");
269
- });
270
-
271
- test("DIAG: a failed (ok:false) finalize emits error (not sent), still under the one trace_id", async () => {
272
- resetState();
273
- clearDiagnostics();
274
- const item = { id: "MSG-TRACE-FAIL", raw_ref: "slack:D2:1", channel: "dm/ceo", channel_id: "D2", sender: "ceo", content: "do the thing" };
275
- writeLiveItem(item);
276
-
277
- await daemon.processItem(item, "slack", {
278
- classify: async () => ({ priority: "high", action: "respond", model: "opus", summary: "x", category: "action_required", directed_at_agent: true }),
279
- isQuickReply: () => false,
280
- sendHoldingMessage: async () => ({ sent: false, holdingText: null }),
281
- dispatch: (_p, _it, _cr, _s, opts) => opts.onClose({ ok: false, code: 1 }),
282
- });
283
-
284
- const rows = readDiagnostics();
285
- assert.equal(rows.filter((r) => r.type === "item_received").length, 1, "item_received emitted");
286
- assert.equal(rows.filter((r) => r.type === "sent").length, 0, "a failed finalize must NOT emit sent");
287
- const err = rows.filter((r) => r.type === "error");
288
- assert.equal(err.length, 1, "a failed finalize emits an error hop");
289
- assert.equal(err[0].trace_id, item.trace_id, "the error hop is under the same interaction trace_id");
290
- });
291
-
292
- // ---------------------------------------------------------------------------
293
- // 2) A send-gate block bumps the counter (responder's real instrumented path).
294
- // ---------------------------------------------------------------------------
295
-
296
- test("DIAG: a send-gate block bumps the send.blocked counter (responder validation path)", async () => {
297
- clearDiagnostics();
298
- const responder = await import("./responder.mjs");
299
- // Force the generator to return text the REAL validateQuickReply blocks (AI
300
- // self-disclosure) — no claude spawn, no Slack post. The block path must bump.
301
- const restore = responder.setGenerateResponseForTests(async () => "Hi — I am an AI assistant, happy to help.");
302
- try {
303
- const before = (counters.snapshot()["send.blocked"]) || 0;
304
- const res = await responder.sendQuickResponse(
305
- { sender: "ceo", service: "slack", channel: "D1", content: "hello" },
306
- { priority: "normal", action: "respond", model: "sonnet", summary: "hi" }
307
- );
308
- assert.equal(res.sent, false, "the AI-disclosure reply was blocked, not sent");
309
- assert.equal(res.blocked, true);
310
- const after = (counters.snapshot()["send.blocked"]) || 0;
311
- assert.equal(after, before + 1, "the send-gate block bumped send.blocked by exactly one (durable JSONL)");
312
- } finally { restore(); }
313
- });
314
-
315
- // ---------------------------------------------------------------------------
316
- // 3) The daemon's ONE real onGuardFail subscriber fires (the bus is live).
317
- // ---------------------------------------------------------------------------
318
-
319
- test("DIAG: registerHookSubscribers wires a real onGuardFail subscriber that bumps guard.fail", async () => {
320
- clearDiagnostics();
321
- // The daemon registers exactly one onGuardFail subscriber. It's idempotent, so
322
- // calling it here (the test driver) is safe even though main() also would.
323
- assert.equal(daemon.registerHookSubscribers(), true, "subscriber registered");
324
- const hb = bus.getHookBus();
325
- assert.ok(hb.count("onGuardFail") >= 1, "the bus has a live onGuardFail subscriber (not dead)");
326
-
327
- const before = (counters.snapshot()["guard.fail"]) || 0;
328
- // Emit a guard-fail (as the responder's send-gate fail-open path does). The
329
- // real subscriber must fire and bump the durable counter.
330
- const decision = await hb.emit("onGuardFail", { guard: "send_gate", reason: "policy unreadable", failed_open: true });
331
- assert.equal(decision.cancelled, false, "onGuardFail is observe-only (never cancels)");
332
- assert.equal(decision.errors, 0, "the subscriber did not throw");
333
-
334
- const after = (counters.snapshot()["guard.fail"]) || 0;
335
- assert.equal(after, before + 1, "the onGuardFail subscriber bumped guard.fail by exactly one");
336
- });
337
-
338
- test("DIAG: the responder's send-gate fail-open emits onGuardFail onto the live bus → guard.fail bumps", async () => {
339
- clearDiagnostics();
340
- daemon.registerHookSubscribers(); // ensure the real subscriber is live
341
- const responder = await import("./responder.mjs");
342
- // Generate benign text (passes validation), but force the Slack send-gate
343
- // screen to throw a NON-block infra error so the responder fails OPEN and
344
- // emits onGuardFail. We can't reach sendSlackMessage without a token, so we
345
- // assert the wiring via the bus directly using the same emit the responder
346
- // makes — the prior test proves the subscriber; here we prove a real producer
347
- // shape lands on the same singleton bus the daemon subscribed to.
348
- const hb = bus.getHookBus();
349
- const before = (counters.snapshot()["guard.fail"]) || 0;
350
- await hb.emit("onGuardFail", { guard: "send_gate", channel: "slack", recipient: "D1", reason: "gate module unreadable", failed_open: true });
351
- const after = (counters.snapshot()["guard.fail"]) || 0;
352
- assert.equal(after, before + 1, "a send-gate fail-open shape bumps guard.fail through the live subscriber");
353
- // Tidy: drop the test reference so it doesn't leak the import.
354
- void responder;
355
- });
356
-
357
- // ---------------------------------------------------------------------------
358
- // Post-session org-memory write-back (SP4) — after a clean turn, a bounded
359
- // distillation is authored to the shared store via remember(). Fail-open when
360
- // the org integration is disabled; never throws.
361
- // ---------------------------------------------------------------------------
362
-
363
- const ENABLED_ORG_CFG = { org: { cohort: { enabled: true, base: "https://org.example" } } };
364
-
365
- test("MEMORY: sessionDistillation produces a bounded one-line record", () => {
366
- const line = daemon.sessionDistillation(
367
- { sender: "Alice Smith", subject: "Q2 renewal" },
368
- { action: "respond", summary: "Acme renewal terms and next steps" },
369
- { agent: { firstName: "Jordan" } }
370
- );
371
- assert.ok(line, "should produce a distillation");
372
- assert.ok(line.includes("Jordan"), "names the agent");
373
- assert.ok(line.includes("respond"), "names the action");
374
- assert.ok(line.includes("Alice Smith"), "names the sender");
375
- assert.ok(line.includes("Acme renewal"), "carries the topic");
376
- assert.ok(line.length <= 401, `bounded length, got ${line.length}`);
377
- });
378
-
379
- test("MEMORY: sessionDistillation returns null when there is nothing to remember", () => {
380
- const line = daemon.sessionDistillation({}, {}, { agent: { firstName: "Jordan" } });
381
- assert.equal(line, null, "no sender + no topic ⇒ null");
382
- });
383
-
384
- test("MEMORY: writeBackSession authors to the store when the org integration is enabled", async () => {
385
- const calls = [];
386
- const res = await daemon.writeBackSession(
387
- { sender: "Alice Smith", subject: "Q2 renewal" },
388
- { action: "respond", summary: "Acme renewal terms" },
389
- {
390
- cfg: ENABLED_ORG_CFG,
391
- agent: { firstName: "Jordan" },
392
- sessionId: "sess-xyz",
393
- rememberImpl: async (text, opts) => { calls.push({ text, opts }); return { ok: true, mode: "append", id: "k1" }; },
394
- }
395
- );
396
- assert.equal(res.ok, true, "write reported ok");
397
- assert.equal(calls.length, 1, "remember() called exactly once");
398
- assert.ok(calls[0].text.includes("Jordan"), "authored the distillation text");
399
- assert.equal(calls[0].opts.cfg, ENABLED_ORG_CFG, "passed the org cfg through");
400
- assert.equal(calls[0].opts.provenance.sessionId, "sess-xyz", "carried session provenance");
401
- });
402
-
403
- test("MEMORY: writeBackSession is a fail-open no-op when the org integration is disabled", async () => {
404
- let called = false;
405
- const res = await daemon.writeBackSession(
406
- { sender: "Alice Smith" },
407
- { action: "respond", summary: "x" },
408
- { cfg: {}, rememberImpl: async () => { called = true; return { ok: true }; } }
409
- );
410
- assert.equal(res.skipped, true, "skipped when disabled");
411
- assert.equal(called, false, "remember() not called when disabled");
412
- });
413
-
414
- test("MEMORY: writeBackSession never throws even if remember() rejects", async () => {
415
- const res = await daemon.writeBackSession(
416
- { sender: "Alice Smith", subject: "s" },
417
- { action: "respond", summary: "y" },
418
- { cfg: ENABLED_ORG_CFG, rememberImpl: async () => { throw new Error("network down"); } }
419
- );
420
- assert.equal(res.ok, false, "fail-open result");
421
- assert.equal(res.skipped, true, "reported skipped on throw");
422
- });
423
-
424
- // ---------------------------------------------------------------------------
425
- // Post-session OUTCOME DISCIPLINE — the daemon's parity with hq's meeting chair.
426
- // A turn that committed to next steps must not leave them in a session log; the
427
- // close path lands them through the org protocol. OFF unless the flag is set.
428
- // The parsing/landing itself is covered by session-outcomes.test.mjs — these
429
- // tests assert the DAEMON's wiring: identity + config injection, and fail-open.
430
- // ---------------------------------------------------------------------------
431
-
432
- test("OUTCOMES: the daemon hook lands a turn's commitments through the org protocol", async () => {
433
- const created = [];
434
- const remembered = [];
435
- const res = await daemon.sweepOutcomesForSession({
436
- sessionId: "s-daemon-1",
437
- resultText: "Handled it.\n\nOUTCOMES:\n- Send the revised deck to Alice — due: Monday\n",
438
- enabled: true,
439
- cfg: ENABLED_ORG_CFG,
440
- agent: { firstName: "Robin" },
441
- item: {
442
- service: "slack",
443
- channel: "general",
444
- channel_id: "C0GENERAL",
445
- // The PUBLIC signal. A `C…` id alone proves nothing — Slack mints them
446
- // for private channels too — so the gate needs the conversation type.
447
- channel_type: "channel",
448
- sender: "Alice Smith",
449
- subject: "Deck review",
450
- },
451
- classResult: { action: "respond", summary: "deck review" },
452
- createItemImpl: async (item) => { created.push(item); return { ok: true }; },
453
- rememberImpl: async (text, opts) => { remembered.push({ text, opts }); return { ok: true }; },
454
- });
455
- assert.equal(res.created, 1, "the commitment became a board item");
456
- assert.equal(created[0].title, "Send the revised deck to Alice");
457
- assert.ok(created[0].detail.includes("Robin"), "the daemon supplied the agent identity");
458
- assert.equal(remembered.length, 1, "and one knowledge episode records the set");
459
- });
460
-
461
- test("OUTCOMES: a private DM's commitments never leave the DM", async () => {
462
- // CONFIDENTIALITY. A landed outcome is an ORG-VISIBLE artifact: board.create
463
- // with no workstreamId is channel-less, and the episode's knowledge.append
464
- // carries no group (so hq resolves it to the `org` group). Landing a DM's
465
- // commitments would republish a one-to-one conversation — including its topic
466
- // line — to every member of the org. The gate is fail-closed.
467
- let touched = false;
468
- const res = await daemon.sweepOutcomesForSession({
469
- sessionId: "s-daemon-dm",
470
- resultText: "OUTCOMES:\n- Send Alice the confidential term sheet — due: Monday\n",
471
- enabled: true,
472
- cfg: ENABLED_ORG_CFG,
473
- agent: { firstName: "Robin" },
474
- item: {
475
- service: "slack",
476
- channel: "dm/alice-smith",
477
- channel_id: "D01ALICE",
478
- is_dm: true,
479
- sender: "Alice Smith",
480
- subject: "DM from Alice Smith",
481
- },
482
- classResult: { action: "respond", summary: "confidential term sheet" },
483
- createItemImpl: async () => { touched = true; return { ok: true }; },
484
- rememberImpl: async () => { touched = true; return { ok: true }; },
485
- });
486
- assert.equal(res.created, 0);
487
- assert.equal(res.reason, "source-dm");
488
- assert.equal(touched, false, "no board row and no episode for a DM turn");
489
- });
490
-
491
- test("OUTCOMES: the daemon hook is OFF unless DAEMON_SESSION_OUTCOMES=1", async () => {
492
- let touched = false;
493
- const prior = process.env.DAEMON_SESSION_OUTCOMES;
494
- try {
495
- delete process.env.DAEMON_SESSION_OUTCOMES;
496
- const res = await daemon.sweepOutcomesForSession({
497
- sessionId: "s-daemon-2",
498
- resultText: "OUTCOMES:\n- Do the thing",
499
- cfg: ENABLED_ORG_CFG,
500
- agent: { firstName: "Robin" },
501
- createItemImpl: async () => { touched = true; return { ok: true }; },
502
- rememberImpl: async () => { touched = true; return { ok: true }; },
503
- });
504
- assert.equal(res.skipped, true);
505
- assert.equal(res.reason, "disabled");
506
- assert.equal(touched, false, "nothing is written while the flag is off");
507
- } finally {
508
- if (prior === undefined) delete process.env.DAEMON_SESSION_OUTCOMES;
509
- else process.env.DAEMON_SESSION_OUTCOMES = prior;
510
- }
511
- });
512
-
513
- test("OUTCOMES: the daemon hook never throws into the dispatch path", async () => {
514
- const res = await daemon.sweepOutcomesForSession({
515
- sessionId: "s-daemon-3",
516
- stdout: "{{{ not json",
517
- enabled: true,
518
- cfg: ENABLED_ORG_CFG,
519
- agent: { firstName: "Robin" },
520
- createItemImpl: async () => { throw new Error("network down"); },
521
- rememberImpl: async () => { throw new Error("network down"); },
522
- });
523
- assert.ok(res && typeof res.created === "number", "benign result, no throw");
524
- assert.equal(res.created, 0);
525
- });
526
-
527
- // ===========================================================================
528
- // ELECTION — the multi-AI storm fix. An UNDIRECTED message in a cohort channel
529
- // defers the should-I-respond decision to hq's electResponder; this seat
530
- // answers IFF elected, and a failed/empty election FAILS SAFE (stays silent).
531
- // A DIRECT address (@mention / DM) skips the election entirely. And a holding
532
- // ack is never posted when the reply session cannot run (claude unavailable).
533
- //
534
- // These drive the REAL answerItem() with the hq election call + the claude
535
- // liveness probe stubbed via deps — the same injection seam the DIAG tests use.
536
- // ===========================================================================
537
-
538
- // An org config that configFromAgent() reads: enabled, with this seat's own
539
- // member slug so the verdict can tell "elected me" from "elected someone else".
540
- const ELECTION_ORG_CFG = {
541
- org: { cohort: { enabled: true, base: "https://hq.example", orgId: "org_x", agentId: "robin" } },
542
- };
543
-
544
- // A cohort CHANNEL item (not a DM): service+channel_id give cohortChannelId,
545
- // raw_ref's third segment gives cohortMessageId. Content is deliberately generic
546
- // so isDirectedAtAgent() is false and the election path is taken.
547
- function cohortChannelItem(overrides = {}) {
548
- return {
549
- id: overrides.id || "MSG-EL",
550
- raw_ref: overrides.raw_ref || "cohort:message:msg-el:0",
551
- service: "cohort",
552
- channel: "channel/roadmap",
553
- channel_id: overrides.channel_id || "chan-el",
554
- is_dm: false,
555
- sender: "alice",
556
- content: overrides.content || "what should our Q3 priorities be?",
557
- };
558
- }
559
-
560
- const ELECTED_CLASS = {
561
- priority: "high", action: "respond", model: "opus",
562
- summary: "roadmap discussion", category: "action_required", directed_at_agent: false,
563
- };
564
-
565
- test("ELECTION: the elected seat responds (dispatches) and the hq call carried {messageId, channelId}", async () => {
566
- resetState();
567
- delete process.env.COHORT_AGENT_ID; // let the cfg's agentId be the seat's slug
568
- const cls = await import("./classifier.mjs");
569
- cls._resetAgentRegistry();
570
-
571
- const item = cohortChannelItem({ id: "MSG-EL-YES", raw_ref: "cohort:message:msg-yes:0", channel_id: "chan-yes" });
572
- const electionCalls = [];
573
- let dispatched = false;
574
-
575
- const res = await daemon.answerItem(item, "cohort", item.raw_ref, "trace-el-yes", {
576
- classify: async () => ({ ...ELECTED_CLASS, summary: "elected-yes topic" }),
577
- isQuickReply: () => false,
578
- sendHoldingMessage: async () => ({ sent: false, holdingText: null }),
579
- dispatch: (_p, _it, _cr, _s, opts) => { dispatched = true; opts.onClose({ ok: true, code: 0 }); },
580
- claudeAvailable: () => true,
581
- orgCfg: ELECTION_ORG_CFG,
582
- electResponder: async (params) => {
583
- electionCalls.push(params);
584
- return { ok: true, result: { election: "group", mode: "single", responders: [{ slug: "robin", dimension: "mandate" }], deferredToHuman: false } };
585
- },
586
- });
587
-
588
- assert.equal(electionCalls.length, 1, "the election was consulted exactly once");
589
- assert.deepEqual(electionCalls[0], { messageId: "msg-yes", channelId: "chan-yes" }, "hq got the message + channel identity");
590
- assert.equal(dispatched, true, "the elected seat proceeds to dispatch a reply session");
591
- assert.equal(res.path, "session", "answerItem took the session path");
592
- });
593
-
594
- test("ELECTION: a NON-elected seat stays silent on the same message (no dispatch)", async () => {
595
- resetState();
596
- delete process.env.COHORT_AGENT_ID;
597
- const cls = await import("./classifier.mjs");
598
- cls._resetAgentRegistry();
599
-
600
- const item = cohortChannelItem({ id: "MSG-EL-NO", raw_ref: "cohort:message:msg-no:0", channel_id: "chan-no" });
601
- const electionCalls = [];
602
- let dispatched = false;
603
- let acked = false;
604
-
605
- const res = await daemon.answerItem(item, "cohort", item.raw_ref, "trace-el-no", {
606
- classify: async () => ({ ...ELECTED_CLASS, summary: "not-elected topic" }),
607
- isQuickReply: () => false,
608
- sendHoldingMessage: async () => { acked = true; return { sent: true, holdingText: "on it" }; },
609
- dispatch: (_p, _it, _cr, _s, opts) => { dispatched = true; opts.onClose({ ok: true, code: 0 }); },
610
- claudeAvailable: () => true,
611
- orgCfg: ELECTION_ORG_CFG,
612
- // hq elected a DIFFERENT seat — this one is not in the responders list.
613
- electResponder: async (params) => {
614
- electionCalls.push(params);
615
- return { ok: true, result: { election: "group", mode: "single", responders: [{ slug: "kai", dimension: "mandate" }], deferredToHuman: false } };
616
- },
617
- });
618
-
619
- assert.equal(electionCalls.length, 1, "the election was consulted");
620
- assert.equal(dispatched, false, "a non-elected seat must NOT dispatch");
621
- assert.equal(acked, false, "a non-elected seat must NOT post a holding ack");
622
- assert.equal(res.path, "filtered", "answerItem filtered the item");
623
- assert.equal(res.reason, "not_elected", "…because it was not elected");
624
- });
625
-
626
- test("ELECTION: an election ERROR fails safe — the seat stays silent (never respond-to-all)", async () => {
627
- resetState();
628
- delete process.env.COHORT_AGENT_ID;
629
- const cls = await import("./classifier.mjs");
630
- cls._resetAgentRegistry();
631
-
632
- const item = cohortChannelItem({ id: "MSG-EL-ERR", raw_ref: "cohort:message:msg-err:0", channel_id: "chan-err" });
633
- let dispatched = false;
634
-
635
- const res = await daemon.answerItem(item, "cohort", item.raw_ref, "trace-el-err", {
636
- classify: async () => ({ ...ELECTED_CLASS, summary: "election-error topic" }),
637
- isQuickReply: () => false,
638
- sendHoldingMessage: async () => ({ sent: false, holdingText: null }),
639
- dispatch: (_p, _it, _cr, _s, opts) => { dispatched = true; opts.onClose({ ok: true, code: 0 }); },
640
- claudeAvailable: () => true,
641
- orgCfg: ELECTION_ORG_CFG,
642
- // hq is unreachable / throwing — the INVERSION: this must resolve to silence.
643
- electResponder: async () => { throw new Error("hq unreachable"); },
644
- });
645
-
646
- assert.equal(dispatched, false, "an unreachable election must NOT fall back to responding");
647
- assert.equal(res.path, "filtered", "answerItem filtered the item");
648
- assert.equal(res.reason, "not_elected", "election failure ⇒ treated as not-elected (silent)");
649
- });
650
-
651
- test("ELECTION: an @mention/named-address responds WITHOUT calling electResponder (fast path)", async () => {
652
- resetState();
653
- delete process.env.COHORT_AGENT_ID;
654
- // Give this seat a distinctive name and point isDirectedAtAgent at it.
655
- mkdirSync(join(AGENT_DIR, "config"), { recursive: true });
656
- writeFileSync(join(AGENT_DIR, "config", "agent.json"), JSON.stringify({ firstName: "Zephyr", slackMemberId: "" }));
657
- const cls = await import("./classifier.mjs");
658
- cls._resetAgentRegistry();
659
-
660
- const item = cohortChannelItem({
661
- id: "MSG-EL-MENTION", raw_ref: "cohort:message:msg-mention:0", channel_id: "chan-mention",
662
- content: "hey zephyr, can you own the Q3 rollout?",
663
- });
664
- const electionCalls = [];
665
- let dispatched = false;
666
-
667
- const res = await daemon.answerItem(item, "cohort", item.raw_ref, "trace-el-mention", {
668
- classify: async () => ({ ...ELECTED_CLASS, summary: "mention topic", directed_at_agent: true }),
669
- isQuickReply: () => false,
670
- sendHoldingMessage: async () => ({ sent: false, holdingText: null }),
671
- dispatch: (_p, _it, _cr, _s, opts) => { dispatched = true; opts.onClose({ ok: true, code: 0 }); },
672
- claudeAvailable: () => true,
673
- orgCfg: ELECTION_ORG_CFG,
674
- electResponder: async (params) => { electionCalls.push(params); return { ok: true, result: { responders: [] } }; },
675
- });
676
-
677
- assert.equal(electionCalls.length, 0, "a directly-addressed message does NOT hold an election");
678
- assert.equal(dispatched, true, "the addressed seat answers on its local fast path");
679
- assert.equal(res.path, "session", "answerItem took the session path");
680
-
681
- // Tidy: drop the identity so later tests re-read a nameless seat.
682
- rmSync(join(AGENT_DIR, "config", "agent.json"), { force: true });
683
- cls._resetAgentRegistry();
684
- });
685
-
686
- test("FAIL-SAFE: an ask the reply session cannot answer is never promised one (claude unavailable)", async () => {
687
- resetState();
688
- // A slack DM — always directed, so this isolates the ack gate from the election.
689
- // is_dm is normally set by enrichItem; answerItem is called directly here, so
690
- // set it on the item as the poller/enrichment would.
691
- // Distinct channel + subject per run so the request-claim from one run does not
692
- // deny the other (the claim key is recipient + subject + action_type).
693
- //
694
- // WHAT THIS PINS SINCE 2026-09-12. Nothing is said at open time on any path
695
- // any more — `openAndAcknowledge` writes the debt and returns — so the old
696
- // control ("with claude available a holding ack IS attempted") no longer
697
- // describes the system. The fail-safe itself is unchanged and is what this
698
- // asserts: when the CLI that would answer cannot start, the debt is opened
699
- // with the interim FORBIDDEN on the record, so no sweep in any later tick or
700
- // process can post a promise the seat cannot keep.
701
- const assurance = await import("./assurance.mjs");
702
- const mkItem = (tag) => ({ id: `MSG-ACK-${tag}`, raw_ref: `slack:DACK${tag}:1`, service: "slack", channel: "dm/ceo", channel_id: `DACK${tag}0001`, is_dm: true, sender: "ceo", content: "please draft the memo" });
703
- const baseDeps = (spy, claudeUp, tag) => ({
704
- classify: async () => ({ priority: "critical", action: "respond", model: "opus", summary: `draft memo ${tag}`, category: "action_required", directed_at_agent: true }),
705
- isQuickReply: () => false,
706
- sendHoldingMessage: async () => { spy.calls++; return { sent: true, holdingText: "on it" }; },
707
- dispatch: (_p, _it, _cr, _s, opts) => { spy.dispatched = true; opts.onClose({ ok: true, code: 0 }); },
708
- claudeAvailable: () => claudeUp,
709
- });
710
-
711
- // CONTROL: claude available ⇒ the debt is opened and an interim is PERMITTED
712
- // later, by the sweep, after ACK_AFTER_MS and inside the room's budget.
713
- const up = { calls: 0, dispatched: false };
714
- await daemon.answerItem(mkItem("UP"), "slack", "slack:DACKUP:1", "trace-ack-up", baseDeps(up, true, "UP"));
715
- assert.equal(up.calls, 0, "nothing is said at open time on ANY path — the interim belongs to the sweep");
716
- assert.equal(up.dispatched, true, "control: the session is dispatched");
717
- const upRec = assurance.readObligation("slack:DACKUP:1");
718
- assert.ok(upRec, "the debt exists");
719
- assert.notEqual(upRec.interimForbidden, true, "and an interim is permitted for it");
720
-
721
- // TREATMENT: claude unavailable ⇒ the interim is forbidden on the record, but
722
- // the session still runs (the assurance sweep owns the outcome; the human
723
- // just isn't promised a reply the seat cannot produce).
724
- const down = { calls: 0, dispatched: false };
725
- const res = await daemon.answerItem(mkItem("DN"), "slack", "slack:DACKDN:1", "trace-ack-down", baseDeps(down, false, "DN"));
726
- assert.equal(down.calls, 0, "with claude unavailable, no interim is ever posted for this ask");
727
- assert.equal(down.dispatched, true, "the session is still dispatched");
728
- assert.equal(res.path, "session", "answerItem took the session path");
729
- const downRec = assurance.readObligation("slack:DACKDN:1");
730
- assert.ok(downRec, "the debt is opened all the same — an ask we cannot promise is exactly the one an operator must see");
731
- assert.equal(downRec.interimForbidden, true, "durable on the record, because the sweep runs in another tick");
732
- });
733
-
734
- // ===========================================================================
735
- // is_dm END-TO-END — the Cohort channel_kind is the authoritative signal.
736
- // ===========================================================================
737
-
738
- test("enrichItem: Cohort channel_kind decides is_dm (DM yes, GROUP_DM no)", () => {
739
- // A 1:1 DM skips the election gate; a GROUP_DM is multi-participant and must
740
- // go through it. Cohort ids are cuids, so the kind is the only reliable signal.
741
- const dm = daemon.enrichItem({ channel: "channel/x", channel_id: "cuid_abc", channel_kind: "DM" }, "cohort");
742
- assert.equal(dm.is_dm, true, "channel_kind DM → is_dm true (skips election)");
743
- const group = daemon.enrichItem({ channel: "channel/x", channel_id: "cuid_abc", channel_kind: "GROUP_DM" }, "cohort");
744
- assert.equal(group.is_dm, false, "channel_kind GROUP_DM → is_dm false (goes through election)");
745
- const pub = daemon.enrichItem({ channel: "channel/x", channel_id: "cuid_abc", channel_kind: "public" }, "cohort");
746
- assert.equal(pub.is_dm, false, "any other explicit kind → not a 1:1 DM");
747
- // No kind → fall back to the Slack-style id/label prefix heuristics.
748
- assert.equal(daemon.enrichItem({ channel: "dm/ceo", channel_id: "D123" }, "slack").is_dm, true);
749
- assert.equal(daemon.enrichItem({ channel: "channel/roadmap", channel_id: "C123" }, "slack").is_dm, false);
750
- });
751
-
752
- test("enrichItem: a WIDE-path Cohort 1:1 DM (is_dm:true, NO channel_kind, cuid id) stays is_dm through the whole chain", async () => {
753
- // REGRESSION. The DEFAULT inbound reader is lib/org/inbound.pullWideInbound,
754
- // whose projection (lib/org/inbound/project.mjs toMessageEvent) marks a 1:1 DM
755
- // with is_dm:true + channel_type:"dm" but does NOT emit channel_kind (only the
756
- // non-default MESSAGING_INBOUND_WIDE=0 narrow reader sets channel_kind). Cohort
757
- // channel ids are cuids, so the Slack-style "dm/"/"D…" prefix heuristics never
758
- // fire on them. Before the fix, enrichItem derived is_dm ONLY from an (absent)
759
- // channel_kind or those (never-matching) heuristics and CLOBBERED the true
760
- // is_dm to false — routing a genuine 1:1 DM through the group election gate,
761
- // which then suppressed it. This drives the real chain end to end:
762
- // toMessageEvent → eventToInboxItem → writeInboxItem → parseInboxItemYaml → enrichItem
763
- // and asserts is_dm survives as true.
764
- const { toMessageEvent } = await import("../../lib/org/inbound/project.mjs");
765
- const { classifyEvent } = await import("../../lib/org/inbound/directedness.mjs");
766
- const { eventToInboxItem } = await import("../../lib/channels/inbox-item.mjs");
767
- const { writeInboxItem } = await import("../poller/utils.mjs");
768
- const { parseInboxItemYaml } = await import("../poller/inbox-scan-poller.mjs");
769
-
770
- const CUID = "clw9x7k4a0000abcd1234efgh"; // a real cuid: no "D…"/"dm/" prefix to key off
771
- const cand = classifyEvent({
772
- seq: 501, at: "2026-08-25T09:00:00.000Z", actor: "M-them",
773
- family: "messaging", kind: "send", entity_id: "m501",
774
- payload: { actor: "M-them", channelId: CUID, channelKind: "DM" },
775
- });
776
- const facts = { channels: new Map([[CUID, { id: CUID, kind: "DM", name: "them" }]]) };
777
- const mev = toMessageEvent({
778
- candidate: cand,
779
- verdict: { surface: "dm", reason: "dm", directed: true },
780
- hydrated: { ok: true, text: "ping", from: { id: "M-them", name: "Casey" }, channelId: CUID, channelLabel: "them" },
781
- me: "M-me",
782
- facts,
783
- });
784
-
785
- // The premise the regression rests on: the wide projection marks the DM but
786
- // does NOT emit channel_kind.
787
- assert.equal(mev.is_dm, true, "wide-path DM projects is_dm:true");
788
- assert.equal(mev.channel_type, "dm");
789
- assert.equal("channel_kind" in mev, false, "the DEFAULT wide reader emits NO channel_kind");
790
-
791
- // eventToInboxItem carries is_dm through but (correctly) attaches no channel_kind.
792
- const item = eventToInboxItem(mev);
793
- assert.equal(item.is_dm, true);
794
- assert.equal("channel_kind" in item, false);
795
-
796
- // writeInboxItem → parseInboxItemYaml: the on-disk round-trip the poller does.
797
- writeInboxItem("cohort", item, AGENT_DIR);
798
- const dir = join(AGENT_DIR, "state", "inbox", "cohort");
799
- const file = readdirSync(dir).find((f) => f.includes(item.id) && f.endsWith(".yaml"));
800
- assert.ok(file, "the inbox item was written to disk");
801
- const parsed = parseInboxItemYaml(readFileSync(join(dir, file), "utf8"));
802
- assert.equal(parsed.is_dm, true, "is_dm survives the YAML round-trip");
803
- assert.equal("channel_kind" in parsed, false, "and still no channel_kind on the wide path");
804
-
805
- // enrichItem is the last hop before the election gate. It MUST honor the
806
- // pre-existing boolean is_dm when channel_kind is absent, not clobber it.
807
- const enriched = daemon.enrichItem(parsed, "cohort");
808
- assert.equal(enriched.is_dm, true, "the wide-path 1:1 DM stays is_dm — it must SKIP the group election gate");
809
- });
810
-
811
- // ===========================================================================
812
- // NO FALLTHROUGH ON PERMANENT FAILURE — a permanent quick-reply send failure
813
- // (FORBIDDEN_SCOPE) must escalate, not spawn an autonomous session that would
814
- // post UNRELATED work into a channel the seat may not write to.
815
- // ===========================================================================
816
-
817
- test("NO FALLTHROUGH: a PERMANENT quick-reply failure escalates instead of spawning a session", async () => {
818
- resetState();
819
- const item = { id: "MSG-PERM", raw_ref: "slack:DPERM:1", service: "slack", channel: "dm/ceo-perm", channel_id: "DPERM0001", is_dm: true, sender: "ceo", subject: "forbidden send", content: "please post this" };
820
- let dispatched = false;
821
- const res = await daemon.answerItem(item, "slack", "MSG-PERM", "trace-perm", {
822
- classify: async () => ({ priority: "high", action: "respond", model: "haiku", summary: "quick answer", category: "action_required", directed_at_agent: true }),
823
- isQuickReply: () => true,
824
- sendQuickResponse: async () => ({ sent: false, permanent: true, code: "FORBIDDEN_SCOPE", error: "FORBIDDEN_SCOPE: blocked by send-gate" }),
825
- dispatch: () => { dispatched = true; },
826
- claudeAvailable: () => true,
827
- });
828
- assert.equal(dispatched, false, "a permanent send failure must NOT spawn an autonomous session");
829
- assert.equal(res.path, "quick_reply_permanent");
830
- assert.match(res.reason, /FORBIDDEN_SCOPE/);
831
- const naDir = join(AGENT_DIR, "state", "obligations", "needs-attention");
832
- assert.ok(existsSync(naDir) && readdirSync(naDir).length > 0, "a durable needs-attention escalation was written for an operator");
833
- });
834
-
835
- test("NO FALLTHROUGH: a TRANSIENT quick-reply failure still falls through to a full session", async () => {
836
- resetState();
837
- const item = { id: "MSG-TRAN", raw_ref: "slack:DTRAN:1", service: "slack", channel: "dm/ceo-tran", channel_id: "DTRAN0001", is_dm: true, sender: "ceo", subject: "transient send", content: "please post this" };
838
- let dispatched = false;
839
- const res = await daemon.answerItem(item, "slack", "MSG-TRAN", "trace-tran", {
840
- classify: async () => ({ priority: "high", action: "respond", model: "haiku", summary: "quick answer", category: "action_required", directed_at_agent: true }),
841
- isQuickReply: () => true,
842
- sendQuickResponse: async () => ({ sent: false, error: "hq 503 blip" }), // transient: NO permanent flag
843
- sendHoldingMessage: async () => ({ sent: true, holdingText: "on it" }),
844
- dispatch: (_p, _it, _cr, _s, opts) => { dispatched = true; opts.onClose({ ok: true, code: 0 }); },
845
- claudeAvailable: () => true,
846
- });
847
- assert.equal(dispatched, true, "a transient failure falls through to the full session, as before");
848
- assert.equal(res.path, "session");
849
- });
850
-
851
- test("REACTIVE PRIORITY: a quick reply yields the subscription (evicts backlog) BEFORE it generates", async () => {
852
- resetState();
853
- const item = { id: "MSG-YIELD", raw_ref: "slack:DYIELD:1", service: "slack", channel: "dm/ceo", channel_id: "DYIELD001", is_dm: true, sender: "ceo", subject: "quick q", content: "you around?" };
854
- const order = [];
855
- const res = await daemon.answerItem(item, "slack", "MSG-YIELD", "trace-yield", {
856
- classify: async () => ({ priority: "high", action: "respond", model: "haiku", summary: "quick answer", category: "action_required", directed_at_agent: true }),
857
- isQuickReply: () => true,
858
- yieldBacklogForReactive: () => { order.push("yield"); return 2; },
859
- sendQuickResponse: async () => { order.push("send"); return { sent: true }; },
860
- claudeAvailable: () => true,
861
- });
862
- assert.equal(res.path, "quick_reply");
863
- assert.deepEqual(order, ["yield", "send"], "backlog is yielded the subscription BEFORE the reply's claude call, not after");
864
- });
865
-
866
- test("resolveSelfMemberId reads the member cuid from the res frame's `result` (own-echo keystone)", async () => {
867
- daemon._resetSelfMemberId();
868
- const pk = process.env.COHORT_API_KEY, po = process.env.COHORT_ORG_ID, pa = process.env.COHORT_AGENT_ID;
869
- process.env.COHORT_API_KEY = "nlk_test";
870
- process.env.COHORT_ORG_ID = "org_test";
871
- process.env.COHORT_AGENT_ID = "A016";
872
- try {
873
- let calls = 0;
874
- // fetchSelfProfile returns the authed call() res frame: the member is under
875
- // `result`, and the org key is passed as apiKey. (Both were wrong in 2.11.7.)
876
- const mid = await daemon.resolveSelfMemberId({
877
- fetchSelfProfileImpl: async (o) => {
878
- calls++;
879
- assert.equal(o.agentId, "A016", "must pass the slug so /members/<slug> resolves");
880
- return { ok: true, result: { memberId: "cmSELFcuid", slug: o.agentId } };
881
- },
882
- });
883
- assert.equal(mid, "cmSELFcuid");
884
- // cached: a second call must NOT refetch
885
- const again = await daemon.resolveSelfMemberId({
886
- fetchSelfProfileImpl: async () => { throw new Error("must not refetch a cached self id"); },
887
- });
888
- assert.equal(again, "cmSELFcuid");
889
- assert.equal(calls, 1);
890
- } finally {
891
- daemon._resetSelfMemberId();
892
- if (pk === undefined) delete process.env.COHORT_API_KEY; else process.env.COHORT_API_KEY = pk;
893
- if (po === undefined) delete process.env.COHORT_ORG_ID; else process.env.COHORT_ORG_ID = po;
894
- if (pa === undefined) delete process.env.COHORT_AGENT_ID; else process.env.COHORT_AGENT_ID = pa;
895
- }
896
- });
897
-
898
- test("fastPathClassify: a short directed question skips the classifier; work-requests do not", async () => {
899
- const fp = (content, isDm = true, extra = {}) => daemon.fastPathClassify({ content, ...extra }, isDm);
900
- // Plain short questions → fast-path (quick reply, high priority, sonnet).
901
- const q = fp("what OS is your machine on?");
902
- assert.equal(q && q.fast_path, true);
903
- assert.equal(q.answerable, true);
904
- assert.equal(q.priority, "high");
905
- assert.equal(q.action, "respond");
906
- assert.ok(daemon.fastPathClassify({ content: "hey, you around?" }, true));
907
- assert.ok(daemon.fastPathClassify({ content: "thanks!" }, true));
908
- // Work-requests → NOT fast-pathed (fall through to full classify → session).
909
- assert.equal(fp("can you analyze the Q3 report and send me the findings?"), null);
910
- assert.equal(fp("please review the PR and fix the failing tests"), null);
911
- assert.equal(fp("build a dashboard for the sales pipeline"), null);
912
- // Non-questions, long messages, group, and non-DM → NOT fast-pathed.
913
- assert.equal(fp("here are the notes from the meeting for your records"), null);
914
- assert.equal(fp("x".repeat(300)), null);
915
- assert.equal(fp("what OS?", false), null); // not a DM
916
- assert.equal(fp(""), null);
917
- // Attachments → full path.
918
- assert.equal(daemon.fastPathClassify({ content: "what's this?", has_attachments: true }, true), null);
919
- // Kill switch (deps.disableFastPath) forces the full classify path.
920
- assert.ok(daemon.fastPathClassify({ content: "what OS?" }, true));
921
- assert.equal(daemon.fastPathClassify({ content: "what OS?" }, true, { disableFastPath: true }), null);
922
- });
923
-
924
- // ===========================================================================
925
- // SELF-ECHO — the agent's own outbound, re-ingested by a poll lane, must be
926
- // dropped BEFORE it can be classified, elected on, acked or answered. Recurred
927
- // live 2026-08-25 / 08-27 / 08-30: the old filter normalised the agent's full
928
- // name to "isla-roselli" while real events carry "Isla Roselli", so the agent
929
- // said "Looking now." to itself (and, in shared DMs, to another agent's echo).
930
- // ===========================================================================
931
-
932
- test("SELF-ECHO: an echoed own message is dropped at the processItem choke point — file retired, drop audited", async () => {
933
- resetState();
934
- const selfEcho = await import("./lib/self-echo.mjs");
935
- const { writeFileSync: wf } = await import("node:fs");
936
- mkdirSync(join(AGENT_DIR, "config"), { recursive: true });
937
- wf(join(AGENT_DIR, "config", "agent.json"), JSON.stringify({
938
- firstName: "Isla", fullName: "Isla Roselli", memberId: "cmqh0tcml004ihhl6zev6ocb5",
939
- }));
940
- selfEcho._resetSelfIdentity();
941
- try {
942
- // The REAL echo shape: display name verbatim, spaces and all.
943
- const item = { id: "MSG-SELF", raw_ref: "slack:DSELF:1", channel: "dm/candace", channel_id: "DSELF0001", sender: "Isla Roselli", content: "Both answers — and the second one settles it." };
944
- const name = writeLiveItem(item);
945
-
946
- let pipelineTouched = null;
947
- const res = await daemon.processItem(item, "slack", {
948
- classify: async () => { pipelineTouched = "classify"; return { priority: "high", action: "respond", model: "opus", summary: "x", category: "action_required", directed_at_agent: true }; },
949
- isQuickReply: () => { pipelineTouched = "isQuickReply"; return false; },
950
- sendQuickResponse: async () => { pipelineTouched = "quick_reply"; return { sent: true }; },
951
- sendHoldingMessage: async () => { pipelineTouched = "holding_ack"; return { sent: true, holdingText: "x" }; },
952
- dispatch: () => { pipelineTouched = "dispatch"; },
953
- claudeAvailable: () => true,
954
- });
955
-
956
- assert.equal(res && res.path, "filtered", "the echo is filtered, not answered");
957
- assert.equal(res && res.reason, "self_echo");
958
- assert.equal(pipelineTouched, null, "no classify, no ack, no reply, no session — the echo never enters the pipeline");
959
- assert.deepEqual(inboxFiles(), [`${name}.processed`], "the echo's inbox file is RETIRED — an un-retired echo would be re-scanned and re-dropped every tick forever");
960
- const auditPath = join(AGENT_DIR, "logs", "daemon", "self-echo-drops.jsonl");
961
- assert.ok(existsSync(auditPath), "every drop leaves an audit row");
962
- assert.match(readFileSync(auditPath, "utf-8"), /slack:DSELF:1/, "the audit row names the dropped event");
963
- } finally {
964
- rmSync(join(AGENT_DIR, "config", "agent.json"), { force: true });
965
- selfEcho._resetSelfIdentity();
966
- }
967
- });
968
-
969
- test("SELF-ECHO: a genuine colleague's message is untouched by the guard", async () => {
970
- resetState();
971
- const selfEcho = await import("./lib/self-echo.mjs");
972
- const { writeFileSync: wf } = await import("node:fs");
973
- mkdirSync(join(AGENT_DIR, "config"), { recursive: true });
974
- wf(join(AGENT_DIR, "config", "agent.json"), JSON.stringify({
975
- firstName: "Isla", fullName: "Isla Roselli", memberId: "cmqh0tcml004ihhl6zev6ocb5",
976
- }));
977
- selfEcho._resetSelfIdentity();
978
- try {
979
- const item = { id: "MSG-COLLEAGUE", raw_ref: "slack:DCOLL:1", channel: "dm/jordan", channel_id: "DCOLL0001", is_dm: true, sender: "Jordan Blake", content: "please draft the board memo" };
980
- writeLiveItem(item);
981
- let dispatched = false;
982
- const res = await daemon.answerItem(item, "slack", "slack:DCOLL:1", "trace-coll", {
983
- classify: async () => ({ priority: "critical", action: "respond", model: "opus", summary: "draft board memo", category: "action_required", directed_at_agent: true }),
984
- isQuickReply: () => false,
985
- sendHoldingMessage: async () => ({ sent: true, holdingText: "Starting on the board memo now." }),
986
- dispatch: (_p, _it, _cr, _s, opts) => { dispatched = true; opts.onClose({ ok: true, code: 0 }); },
987
- claudeAvailable: () => true,
988
- });
989
- assert.equal(dispatched, true, "a real colleague's ask still dispatches");
990
- assert.equal(res.path, "session");
991
- } finally {
992
- rmSync(join(AGENT_DIR, "config", "agent.json"), { force: true });
993
- selfEcho._resetSelfIdentity();
994
- }
995
- });