@cohortapp/agent-sdk 2.17.0 → 2.18.5

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 (531) 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-slots.mjs +234 -0
  23. package/lib/collective/capture.mjs +8 -6
  24. package/lib/collective/config.mjs +2 -0
  25. package/lib/collective/global-config.mjs +63 -1
  26. package/lib/collective/loop-guard.mjs +155 -0
  27. package/lib/collective/presence.mjs +142 -5
  28. package/lib/comms/send-gate.mjs +559 -1
  29. package/lib/diagnostics/alerts.mjs +49 -0
  30. package/lib/diagnostics/cadence-output-freshness.mjs +288 -0
  31. package/lib/engine/agents/definitions.mjs +343 -0
  32. package/lib/engine/agents/persist.mjs +275 -0
  33. package/lib/engine/agents/runtime.mjs +748 -0
  34. package/lib/engine/agents/usage.mjs +95 -0
  35. package/lib/engine/auth-status.mjs +139 -0
  36. package/lib/engine/budget.mjs +194 -0
  37. package/lib/engine/cli.mjs +1204 -0
  38. package/lib/engine/commands/index.mjs +269 -0
  39. package/lib/engine/context/budget.mjs +219 -0
  40. package/lib/engine/context/cache.mjs +125 -0
  41. package/lib/engine/context/child-env.mjs +215 -0
  42. package/lib/engine/context/compaction.mjs +342 -0
  43. package/lib/engine/context/images.mjs +90 -0
  44. package/lib/engine/context/instructions.mjs +327 -0
  45. package/lib/engine/context/lazy-instructions.mjs +169 -0
  46. package/lib/engine/context/manager.mjs +182 -0
  47. package/lib/engine/context/real-path.mjs +91 -0
  48. package/lib/engine/context/secret-values.mjs +163 -0
  49. package/lib/engine/context/settings.mjs +274 -0
  50. package/lib/engine/context/stream-input.mjs +159 -0
  51. package/lib/engine/guard.mjs +152 -0
  52. package/lib/engine/hooks.mjs +713 -0
  53. package/lib/engine/loop.mjs +560 -0
  54. package/lib/engine/mcp/client.mjs +254 -0
  55. package/lib/engine/mcp/config.mjs +301 -0
  56. package/lib/engine/mcp/http.mjs +201 -0
  57. package/lib/engine/mcp/index.mjs +146 -0
  58. package/lib/engine/mcp/jsonrpc.mjs +147 -0
  59. package/lib/engine/mcp/naming.mjs +66 -0
  60. package/lib/engine/mcp/resources.mjs +89 -0
  61. package/lib/engine/mcp/results.mjs +133 -0
  62. package/lib/engine/mcp/stdio.mjs +137 -0
  63. package/lib/engine/mcp/supervisor.mjs +116 -0
  64. package/lib/engine/messages.mjs +104 -0
  65. package/lib/engine/output/json.mjs +164 -0
  66. package/lib/engine/output/stream-json.mjs +266 -0
  67. package/lib/engine/permissions.mjs +845 -0
  68. package/lib/engine/process-identity.mjs +164 -0
  69. package/lib/engine/process-tree.mjs +551 -0
  70. package/lib/engine/prompt.mjs +60 -0
  71. package/lib/engine/session/store.mjs +299 -0
  72. package/lib/engine/session-runtime/args.mjs +97 -0
  73. package/lib/engine/session-runtime/host.mjs +143 -0
  74. package/lib/engine/session-runtime/inbox.mjs +122 -0
  75. package/lib/engine/session-runtime/notifications.mjs +129 -0
  76. package/lib/engine/session-runtime/registry.mjs +328 -0
  77. package/lib/engine/session-runtime/runner.mjs +344 -0
  78. package/lib/engine/session-runtime/socket.mjs +212 -0
  79. package/lib/engine/session-runtime/wakeup.mjs +115 -0
  80. package/lib/engine/skills/index.mjs +321 -0
  81. package/lib/engine/tools/bash-background.mjs +533 -0
  82. package/lib/engine/tools/bash.mjs +216 -0
  83. package/lib/engine/tools/edit.mjs +97 -0
  84. package/lib/engine/tools/glob.mjs +81 -0
  85. package/lib/engine/tools/grep.mjs +224 -0
  86. package/lib/engine/tools/index.mjs +84 -0
  87. package/lib/engine/tools/list-agents.mjs +32 -0
  88. package/lib/engine/tools/ls.mjs +127 -0
  89. package/lib/engine/tools/monitor.mjs +82 -0
  90. package/lib/engine/tools/notebook-edit.mjs +218 -0
  91. package/lib/engine/tools/read.mjs +103 -0
  92. package/lib/engine/tools/schedule-wakeup.mjs +45 -0
  93. package/lib/engine/tools/schema.mjs +144 -0
  94. package/lib/engine/tools/send-message.mjs +77 -0
  95. package/lib/engine/tools/session.mjs +70 -0
  96. package/lib/engine/tools/todo.mjs +144 -0
  97. package/lib/engine/tools/toolsearch.mjs +217 -0
  98. package/lib/engine/tools/walk.mjs +193 -0
  99. package/lib/engine/tools/web-switch.mjs +31 -0
  100. package/lib/engine/tools/webfetch-html.mjs +387 -0
  101. package/lib/engine/tools/webfetch-net.mjs +340 -0
  102. package/lib/engine/tools/webfetch.mjs +198 -0
  103. package/lib/engine/tools/websearch.mjs +91 -0
  104. package/lib/engine/tools/workflow.mjs +95 -0
  105. package/lib/engine/tools/write.mjs +76 -0
  106. package/lib/engine/tui/line-editor.mjs +137 -0
  107. package/lib/engine/tui/render.mjs +86 -0
  108. package/lib/engine/tui/tui.mjs +274 -0
  109. package/lib/engine/wire/anthropic-messages.mjs +263 -0
  110. package/lib/engine/wire/effort.mjs +36 -0
  111. package/lib/engine/wire/errors.mjs +496 -0
  112. package/lib/engine/wire/http.mjs +441 -0
  113. package/lib/engine/wire/index.mjs +76 -0
  114. package/lib/engine/wire/openai-chat.mjs +332 -0
  115. package/lib/engine/wire/prompt-cache.mjs +79 -0
  116. package/lib/engine/wire/search.mjs +140 -0
  117. package/lib/engine/wire/sse.mjs +114 -0
  118. package/lib/engine/wire/stall.mjs +349 -0
  119. package/lib/engine/wire/token-provider.mjs +175 -0
  120. package/lib/engine/wire/usage.mjs +192 -0
  121. package/lib/engine/workflow/host.mjs +524 -0
  122. package/lib/engine/workflow/journal.mjs +188 -0
  123. package/lib/engine/workflow/json-schema.mjs +171 -0
  124. package/lib/engine/workflow/meta.mjs +329 -0
  125. package/lib/engine/workflow/notifications.mjs +52 -0
  126. package/lib/engine/workflow/runtime.mjs +447 -0
  127. package/lib/engine/workflow/sandbox.mjs +534 -0
  128. package/lib/engine/workflow/worker.mjs +141 -0
  129. package/lib/engine/workflow/worktree.mjs +74 -0
  130. package/lib/execution/disposition.mjs +1 -1
  131. package/lib/execution/intake.mjs +10 -0
  132. package/lib/execution/surface-policy.mjs +15 -0
  133. package/lib/learning/curator.mjs +8 -6
  134. package/lib/learning/reflect.mjs +8 -6
  135. package/lib/model-router/catalog/cohort.yaml +137 -0
  136. package/lib/model-router/catalog.mjs +118 -1
  137. package/lib/model-router/failover.mjs +67 -16
  138. package/lib/model-router/llm-task.mjs +39 -3
  139. package/lib/model-router/resolve.mjs +89 -3
  140. package/lib/model-router/spawn.mjs +46 -47
  141. package/lib/model-router/taxonomy.mjs +126 -4
  142. package/lib/org/cost-sync.mjs +141 -11
  143. package/lib/org/inbound/broadcast.mjs +289 -0
  144. package/lib/org/inbound/collective.mjs +375 -0
  145. package/lib/org/inbound/directedness.mjs +96 -8
  146. package/lib/org/inbound/facts.mjs +78 -2
  147. package/lib/org/inbound/project.mjs +22 -0
  148. package/lib/org/inbound/surfaces.mjs +14 -0
  149. package/lib/org/llm-token.mjs +879 -0
  150. package/lib/org/mesh.mjs +61 -0
  151. package/lib/org/messaging.mjs +3 -1
  152. package/lib/org/protocol.checksum +1 -1
  153. package/lib/org/protocol.mjs +15 -0
  154. package/lib/org/quota.mjs +520 -0
  155. package/lib/org/tool-surface.mjs +104 -16
  156. package/lib/org/ui-parity.mjs +16 -1
  157. package/lib/org/work-ledger.mjs +37 -6
  158. package/lib/rate-guard.mjs +114 -1
  159. package/lib/resource-governor.mjs +41 -6
  160. package/lib/runtime/adapter.mjs +833 -0
  161. package/lib/runtime/child-env.mjs +191 -0
  162. package/lib/runtime/legacy-shell-guard.mjs +97 -0
  163. package/lib/runtime/seat-engine.mjs +162 -0
  164. package/lib/session/ask-ledger.mjs +271 -0
  165. package/lib/session/current-work.mjs +676 -0
  166. package/lib/session/feed-core.mjs +40 -3
  167. package/lib/session/launch-args.mjs +56 -4
  168. package/lib/session/status-summary.mjs +26 -9
  169. package/lib/session/upgrade-notice.mjs +42 -0
  170. package/lib/setup/claude-probe.mjs +117 -13
  171. package/lib/setup/enrich.mjs +13 -10
  172. package/lib/setup/sections/model.mjs +39 -13
  173. package/lib/telemetry/collect.mjs +229 -11
  174. package/lib/upgrade/ignored-drift.mjs +105 -0
  175. package/lib/voice/post-call-brief.mjs +30 -17
  176. package/package.json +13 -3
  177. package/plugins/maestro-skills/skills/board-work.md +5 -0
  178. package/plugins/maestro-skills/skills/inbound-triage.md +56 -15
  179. package/plugins/maestro-skills/skills/main-session.md +18 -7
  180. package/scaffold/config/collective.yaml +7 -0
  181. package/scripts/ci/check-durable-write-seam.mjs +3 -1
  182. package/scripts/ci/check-tarball-fidelity.mjs +126 -2
  183. package/scripts/ci/run-tests.mjs +47 -19
  184. package/scripts/cohort-llm/api-key-helper.mjs +92 -0
  185. package/scripts/collective/hook-runner.mjs +142 -19
  186. package/scripts/continuous-monitor.sh +13 -0
  187. package/scripts/cost/track-claude-usage.mjs +15 -0
  188. package/scripts/daemon/agent-daemon.mjs +408 -20
  189. package/scripts/daemon/assurance.mjs +48 -12
  190. package/scripts/daemon/cadence-consumer.mjs +218 -68
  191. package/scripts/daemon/cadence-handlers.mjs +73 -4
  192. package/scripts/daemon/classifier.mjs +75 -26
  193. package/scripts/daemon/context-compiler.mjs +51 -37
  194. package/scripts/daemon/deliver.mjs +30 -1
  195. package/scripts/daemon/dispatcher.mjs +595 -149
  196. package/scripts/daemon/health.mjs +14 -1
  197. package/scripts/daemon/maestro-daemon.mjs +11 -0
  198. package/scripts/daemon/prompt-builder.mjs +24 -0
  199. package/scripts/daemon/responder.mjs +246 -79
  200. package/scripts/daemon/sdk-version.mjs +98 -16
  201. package/scripts/eval/probe-gateway.mjs +635 -0
  202. package/scripts/eval/replay/extract.mjs +270 -0
  203. package/scripts/eval/replay/grade.mjs +260 -0
  204. package/scripts/eval/replay/lib/config.mjs +50 -0
  205. package/scripts/eval/replay/lib/effects.mjs +65 -0
  206. package/scripts/eval/replay/lib/fixture.mjs +188 -0
  207. package/scripts/eval/replay/lib/judge.mjs +72 -0
  208. package/scripts/eval/replay/lib/redact.mjs +136 -0
  209. package/scripts/eval/replay/lib/sandbox.mjs +170 -0
  210. package/scripts/eval/replay/lib/schema-check.mjs +63 -0
  211. package/scripts/eval/replay/lib/transcript.mjs +76 -0
  212. package/scripts/eval/replay/mcp-replay-stub.mjs +101 -0
  213. package/scripts/eval/replay/report.mjs +185 -0
  214. package/scripts/eval/replay/run.mjs +404 -0
  215. package/scripts/fleet/rollout.mjs +1151 -0
  216. package/scripts/hooks/pre-send-audit.sh +36 -245
  217. package/scripts/hooks/pre-write-yaml-validate.mjs +275 -0
  218. package/scripts/hooks/validate-state-yaml.sh +190 -0
  219. package/scripts/huddle/huddle-llm.mjs +361 -0
  220. package/scripts/huddle/huddle-server.mjs +46 -121
  221. package/scripts/local-triggers/autoupdate.sh +465 -81
  222. package/scripts/local-triggers/run-trigger.sh +13 -0
  223. package/scripts/maintenance/pin-integrity.mjs +364 -0
  224. package/scripts/poll-slack-events.sh +41 -9
  225. package/scripts/poller/slack-socket-mode.mjs +28 -3
  226. package/scripts/session/supervisor.mjs +80 -13
  227. package/scripts/spawn-session.sh +13 -0
  228. package/bin/maestro.test.mjs +0 -1574
  229. package/lib/action-executor.test.mjs +0 -871
  230. package/lib/archetype.test.mjs +0 -132
  231. package/lib/assurance/plan-note.test.mjs +0 -234
  232. package/lib/assurance/room-budget.test.mjs +0 -486
  233. package/lib/assurance/tier.test.mjs +0 -174
  234. package/lib/autonomy.test.mjs +0 -66
  235. package/lib/backlog.test.mjs +0 -302
  236. package/lib/backup/policy.test.mjs +0 -305
  237. package/lib/budget-escalate.test.mjs +0 -232
  238. package/lib/budget-guard.envelope.test.mjs +0 -476
  239. package/lib/budget-guard.test.mjs +0 -427
  240. package/lib/cadence-bus-requeue.test.mjs +0 -83
  241. package/lib/cadence-bus-schedule.test.mjs +0 -194
  242. package/lib/cadence-bus.test.mjs +0 -720
  243. package/lib/cadences.test.mjs +0 -230
  244. package/lib/capability/inventory.test.mjs +0 -232
  245. package/lib/capability.test.mjs +0 -78
  246. package/lib/channels/base-adapter.test.mjs +0 -590
  247. package/lib/channels/channels.test.mjs +0 -371
  248. package/lib/channels/contract.test.mjs +0 -162
  249. package/lib/channels/inbox-item.test.mjs +0 -368
  250. package/lib/channels/orgmail/adapter.test.mjs +0 -448
  251. package/lib/channels/pairing.test.mjs +0 -270
  252. package/lib/channels/repeat-suppressor.test.mjs +0 -134
  253. package/lib/channels/slack-adapter.test.mjs +0 -212
  254. package/lib/channels/telegram-adapter.test.mjs +0 -306
  255. package/lib/channels/voice/adapter.test.mjs +0 -278
  256. package/lib/channels/whatsapp/adapter-baileys.test.mjs +0 -359
  257. package/lib/channels/whatsapp/baileys-typing.test.mjs +0 -154
  258. package/lib/charter.test.mjs +0 -89
  259. package/lib/claude-bin.test.mjs +0 -131
  260. package/lib/cli/board.test.mjs +0 -227
  261. package/lib/cli/design.test.mjs +0 -270
  262. package/lib/cli/doctor-checks.test.mjs +0 -336
  263. package/lib/cli/global-setup-extras.test.mjs +0 -462
  264. package/lib/cli/inbox.test.mjs +0 -230
  265. package/lib/cli/session-ack.test.mjs +0 -63
  266. package/lib/cli/session.test.mjs +0 -613
  267. package/lib/collective/capture.test.mjs +0 -121
  268. package/lib/collective/cards.test.mjs +0 -114
  269. package/lib/collective/config.test.mjs +0 -123
  270. package/lib/collective/global-config.test.mjs +0 -220
  271. package/lib/collective/global-skills.test.mjs +0 -126
  272. package/lib/collective/presence.test.mjs +0 -95
  273. package/lib/collective/recall.test.mjs +0 -116
  274. package/lib/collective/vendor-skills.test.mjs +0 -306
  275. package/lib/comms/send-gate.test.mjs +0 -770
  276. package/lib/comms.test.mjs +0 -41
  277. package/lib/context/budget.test.mjs +0 -252
  278. package/lib/context/history-scope.test.mjs +0 -79
  279. package/lib/cost/ledger-row.test.mjs +0 -183
  280. package/lib/design/design-md.test.mjs +0 -318
  281. package/lib/design/fixtures/DESIGN.golden.md +0 -238
  282. package/lib/design/fixtures/PRODUCT.golden.md +0 -67
  283. package/lib/design/fixtures/foundation.json +0 -133
  284. package/lib/design/refresh-gate.test.mjs +0 -144
  285. package/lib/design/write.test.mjs +0 -241
  286. package/lib/diagnostics/alerts.test.mjs +0 -318
  287. package/lib/diagnostics/backup-freshness.test.mjs +0 -185
  288. package/lib/diagnostics/counters.test.mjs +0 -206
  289. package/lib/diagnostics/events.test.mjs +0 -290
  290. package/lib/diagnostics/otel.test.mjs +0 -196
  291. package/lib/diagnostics/trace.test.mjs +0 -251
  292. package/lib/env-compat.test.mjs +0 -104
  293. package/lib/execution/disposition.test.mjs +0 -553
  294. package/lib/execution/drive.test.mjs +0 -270
  295. package/lib/execution/effects.test.mjs +0 -344
  296. package/lib/execution/intake.test.mjs +0 -389
  297. package/lib/execution/journal.test.mjs +0 -261
  298. package/lib/execution/match.test.mjs +0 -235
  299. package/lib/execution/pipeline.test.mjs +0 -392
  300. package/lib/execution/route.test.mjs +0 -186
  301. package/lib/execution/surface-policy.test.mjs +0 -162
  302. package/lib/fs-atomic.test.mjs +0 -72
  303. package/lib/fs-ownership.test.mjs +0 -158
  304. package/lib/goals/admission.test.mjs +0 -164
  305. package/lib/goals/classify.test.mjs +0 -167
  306. package/lib/goals/collaborate.test.mjs +0 -336
  307. package/lib/goals/gaps.test.mjs +0 -284
  308. package/lib/goals/loop.test.mjs +0 -845
  309. package/lib/hooks/bus.test.mjs +0 -387
  310. package/lib/identity/persona.test.mjs +0 -142
  311. package/lib/kpi-sensors.test.mjs +0 -278
  312. package/lib/kpi.test.mjs +0 -244
  313. package/lib/learning/config.test.mjs +0 -75
  314. package/lib/learning/counters.test.mjs +0 -69
  315. package/lib/learning/curator-consolidate.test.mjs +0 -238
  316. package/lib/learning/curator.test.mjs +0 -106
  317. package/lib/learning/reflect.test.mjs +0 -0
  318. package/lib/learning/session-index.test.mjs +0 -125
  319. package/lib/learning/skill-writer.test.mjs +0 -210
  320. package/lib/mandate/audit.test.mjs +0 -195
  321. package/lib/mandate/contract.test.mjs +0 -185
  322. package/lib/mandate/derive.test.mjs +0 -274
  323. package/lib/mandate/model.test.mjs +0 -164
  324. package/lib/mandate/refresh.test.mjs +0 -389
  325. package/lib/mcp/server.test.mjs +0 -426
  326. package/lib/model-router/auth-profiles.test.mjs +0 -580
  327. package/lib/model-router/catalog.test.mjs +0 -385
  328. package/lib/model-router/economics.test.mjs +0 -438
  329. package/lib/model-router/failover.test.mjs +0 -439
  330. package/lib/model-router/health.test.mjs +0 -338
  331. package/lib/model-router/integration-coverage.test.mjs +0 -831
  332. package/lib/model-router/integration.test.mjs +0 -564
  333. package/lib/model-router/ledger.test.mjs +0 -415
  334. package/lib/model-router/llm-task.test.mjs +0 -392
  335. package/lib/model-router/org-credentials.test.mjs +0 -265
  336. package/lib/model-router/pricing-refresh.test.mjs +0 -286
  337. package/lib/model-router/reconcile.test.mjs +0 -316
  338. package/lib/model-router/repair.test.mjs +0 -180
  339. package/lib/model-router/spawn.test.mjs +0 -446
  340. package/lib/model-router/taxonomy.test.mjs +0 -410
  341. package/lib/model-router.test.mjs +0 -1207
  342. package/lib/org/activity.test.mjs +0 -134
  343. package/lib/org/approvals.test.mjs +0 -216
  344. package/lib/org/awareness.test.mjs +0 -159
  345. package/lib/org/board-mine-cache.test.mjs +0 -53
  346. package/lib/org/board.test.mjs +0 -187
  347. package/lib/org/bootstrap-context.test.mjs +0 -153
  348. package/lib/org/client.test.mjs +0 -1206
  349. package/lib/org/cohort-client.test.mjs +0 -126
  350. package/lib/org/cost-sync.test.mjs +0 -153
  351. package/lib/org/doctor.test.mjs +0 -346
  352. package/lib/org/engagement-ledger.test.mjs +0 -112
  353. package/lib/org/engagement.test.mjs +0 -739
  354. package/lib/org/handoff.test.mjs +0 -269
  355. package/lib/org/inbound/directedness.test.mjs +0 -668
  356. package/lib/org/inbound/facts.test.mjs +0 -471
  357. package/lib/org/inbound/hydrate.test.mjs +0 -908
  358. package/lib/org/inbound/index.test.mjs +0 -429
  359. package/lib/org/inbound/project.test.mjs +0 -287
  360. package/lib/org/integration-tools.test.mjs +0 -160
  361. package/lib/org/keys.test.mjs +0 -92
  362. package/lib/org/knowledge.test.mjs +0 -326
  363. package/lib/org/leases.test.mjs +0 -235
  364. package/lib/org/mesh-directives.test.mjs +0 -110
  365. package/lib/org/mesh-integration.test.mjs +0 -127
  366. package/lib/org/mesh.test.mjs +0 -400
  367. package/lib/org/messaging.test.mjs +0 -471
  368. package/lib/org/param-contract.test.mjs +0 -477
  369. package/lib/org/policy.test.mjs +0 -237
  370. package/lib/org/protocol.checksum.test.mjs +0 -90
  371. package/lib/org/protocol.test.mjs +0 -323
  372. package/lib/org/push.test.mjs +0 -792
  373. package/lib/org/registry.test.mjs +0 -100
  374. package/lib/org/resource-tools.test.mjs +0 -361
  375. package/lib/org/tool-access.test.mjs +0 -144
  376. package/lib/org/tool-surface-integration.test.mjs +0 -120
  377. package/lib/org/tool-surface.test.mjs +0 -1268
  378. package/lib/org/typing.test.mjs +0 -291
  379. package/lib/org/ui-parity.test.mjs +0 -560
  380. package/lib/org/verify.test.mjs +0 -194
  381. package/lib/org/work-ledger.test.mjs +0 -273
  382. package/lib/plan/adoption-e2e.test.mjs +0 -366
  383. package/lib/plan/budget-enforcement.test.mjs +0 -400
  384. package/lib/plan/compile.test.mjs +0 -382
  385. package/lib/plan/emit.test.mjs +0 -269
  386. package/lib/plan/explain.test.mjs +0 -188
  387. package/lib/prompts/parallelism.test.mjs +0 -177
  388. package/lib/rag/rag.test.mjs +0 -505
  389. package/lib/rate-guard.test.mjs +0 -272
  390. package/lib/reactive-gate.test.mjs +0 -57
  391. package/lib/render.test.mjs +0 -68
  392. package/lib/resource-governor.test.mjs +0 -488
  393. package/lib/scheduling/dynamic-jobs.test.mjs +0 -344
  394. package/lib/scheduling/jitter.test.mjs +0 -140
  395. package/lib/secrets/broker.test.mjs +0 -280
  396. package/lib/secrets/providers.test.mjs +0 -274
  397. package/lib/security/audit-engine.test.mjs +0 -424
  398. package/lib/security/coerce-args.test.mjs +0 -281
  399. package/lib/security/dangerous-tools.test.mjs +0 -68
  400. package/lib/security/external-content.test.mjs +0 -84
  401. package/lib/security/redact.test.mjs +0 -441
  402. package/lib/security/secret-equal.test.mjs +0 -55
  403. package/lib/session/config.test.mjs +0 -92
  404. package/lib/session/feed-core.test.mjs +0 -198
  405. package/lib/session/first-run.test.mjs +0 -121
  406. package/lib/session/frontdoor.test.mjs +0 -205
  407. package/lib/session/handoffs.test.mjs +0 -183
  408. package/lib/session/identity.test.mjs +0 -180
  409. package/lib/session/inbox-claims.test.mjs +0 -286
  410. package/lib/session/launch-args.test.mjs +0 -157
  411. package/lib/session/liveness.test.mjs +0 -100
  412. package/lib/session/status-summary.test.mjs +0 -118
  413. package/lib/session-permissions.test.mjs +0 -120
  414. package/lib/setup/claude-probe.test.mjs +0 -187
  415. package/lib/setup/completeness.test.mjs +0 -110
  416. package/lib/setup/context-pack.test.mjs +0 -89
  417. package/lib/setup/enrich.test.mjs +0 -115
  418. package/lib/setup/enroll-from-cohort.test.mjs +0 -300
  419. package/lib/setup/integration.test.mjs +0 -162
  420. package/lib/setup/io.test.mjs +0 -77
  421. package/lib/setup/runner.test.mjs +0 -132
  422. package/lib/setup/sections/identity.test.mjs +0 -234
  423. package/lib/setup/sections/inventory.test.mjs +0 -198
  424. package/lib/setup/sections/learning.test.mjs +0 -81
  425. package/lib/setup/sections/mandate.test.mjs +0 -388
  426. package/lib/setup/sections/messaging.test.mjs +0 -127
  427. package/lib/setup/sections/model.test.mjs +0 -240
  428. package/lib/setup/sections/org.test.mjs +0 -346
  429. package/lib/setup/sections/orgmail.test.mjs +0 -118
  430. package/lib/setup/sections/recovery.test.mjs +0 -98
  431. package/lib/setup/sections/subagents.test.mjs +0 -429
  432. package/lib/setup/sections/verify.test.mjs +0 -175
  433. package/lib/setup/sot.test.mjs +0 -81
  434. package/lib/setup/state.test.mjs +0 -115
  435. package/lib/singleton.test.mjs +0 -151
  436. package/lib/subagents/cli.test.mjs +0 -389
  437. package/lib/subagents/client.test.mjs +0 -309
  438. package/lib/subagents/gap.test.mjs +0 -234
  439. package/lib/subagents/lock.test.mjs +0 -248
  440. package/lib/subagents/manifest.test.mjs +0 -175
  441. package/lib/subagents/refs.test.mjs +0 -204
  442. package/lib/subagents/resolve.test.mjs +0 -422
  443. package/lib/subagents/schema.test.mjs +0 -328
  444. package/lib/telemetry/alerts.test.mjs +0 -109
  445. package/lib/telemetry/collect.test.mjs +0 -1274
  446. package/lib/tool-definitions-integration.test.mjs +0 -83
  447. package/lib/tool-definitions.test.mjs +0 -437
  448. package/lib/upgrade/global-refresh.test.mjs +0 -65
  449. package/lib/upgrade/launchd-reconcile.test.mjs +0 -272
  450. package/lib/upgrade/post-steps.test.mjs +0 -200
  451. package/lib/upgrade/verify.test.mjs +0 -164
  452. package/lib/util/fetch-timeout.test.mjs +0 -202
  453. package/lib/util/reconnect.test.mjs +0 -369
  454. package/lib/util/unhandled.test.mjs +0 -216
  455. package/lib/voice/outbound.test.mjs +0 -69
  456. package/lib/voice/session-rotation.test.mjs +0 -114
  457. package/lib/voice/stt.test.mjs +0 -226
  458. package/lib/voice/voice.test.mjs +0 -990
  459. package/scripts/cadence/enqueue-cadence-tick.test.mjs +0 -187
  460. package/scripts/ci/check-docs-accuracy.test.mjs +0 -409
  461. package/scripts/ci/check-durable-write-seam.test.mjs +0 -90
  462. package/scripts/ci/check-no-build-artifacts.test.mjs +0 -71
  463. package/scripts/ci/check-no-residual-identity.test.mjs +0 -202
  464. package/scripts/ci/check-skill-packs.test.mjs +0 -495
  465. package/scripts/ci/check-subagent-frontmatter.test.mjs +0 -124
  466. package/scripts/ci/check.test.mjs +0 -194
  467. package/scripts/ci/conformance-org-api.test.mjs +0 -425
  468. package/scripts/cloud-relay/voice/relay-identity.test.mjs +0 -96
  469. package/scripts/collective/hook-runner.test.mjs +0 -173
  470. package/scripts/cost/fleet-digest.test.mjs +0 -207
  471. package/scripts/cost/track-claude-usage-pricing.test.mjs +0 -183
  472. package/scripts/cost/track-claude-usage.test.mjs +0 -148
  473. package/scripts/daemon/agent-daemon-board-mine.test.mjs +0 -96
  474. package/scripts/daemon/agent-daemon-design.test.mjs +0 -238
  475. package/scripts/daemon/agent-daemon-frontdoor.test.mjs +0 -60
  476. package/scripts/daemon/agent-daemon.test.mjs +0 -995
  477. package/scripts/daemon/assurance-e2e.test.mjs +0 -613
  478. package/scripts/daemon/assurance.test.mjs +0 -1791
  479. package/scripts/daemon/board-mirror.test.mjs +0 -165
  480. package/scripts/daemon/cadence-consumer-frontdoor.test.mjs +0 -393
  481. package/scripts/daemon/cadence-consumer-governance.test.mjs +0 -276
  482. package/scripts/daemon/cadence-consumer.test.mjs +0 -776
  483. package/scripts/daemon/cadence-handlers.test.mjs +0 -837
  484. package/scripts/daemon/classifier-identity.test.mjs +0 -137
  485. package/scripts/daemon/classifier.test.mjs +0 -266
  486. package/scripts/daemon/classify-kind.test.mjs +0 -40
  487. package/scripts/daemon/context-compiler.test.mjs +0 -406
  488. package/scripts/daemon/deliver.test.mjs +0 -564
  489. package/scripts/daemon/dispatcher-cooldown.test.mjs +0 -122
  490. package/scripts/daemon/dispatcher-governance.test.mjs +0 -1013
  491. package/scripts/daemon/dispatcher-resume.test.mjs +0 -166
  492. package/scripts/daemon/dispatcher-session-continuity.test.mjs +0 -365
  493. package/scripts/daemon/execution-ladder.test.mjs +0 -470
  494. package/scripts/daemon/goal-steward-cadence.test.mjs +0 -312
  495. package/scripts/daemon/inbox-deferral-session.test.mjs +0 -49
  496. package/scripts/daemon/inbox-deferral.test.mjs +0 -336
  497. package/scripts/daemon/inbox-wake.test.mjs +0 -199
  498. package/scripts/daemon/integration.test.mjs +0 -149
  499. package/scripts/daemon/lib/self-echo.test.mjs +0 -153
  500. package/scripts/daemon/lib/session-router.test.mjs +0 -554
  501. package/scripts/daemon/prompt-builder-preamble.test.mjs +0 -210
  502. package/scripts/daemon/prompt-builder.test.mjs +0 -556
  503. package/scripts/daemon/responder-cost.test.mjs +0 -68
  504. package/scripts/daemon/responder-history.test.mjs +0 -221
  505. package/scripts/daemon/sdk-version.test.mjs +0 -31
  506. package/scripts/daemon/session-lock.test.mjs +0 -252
  507. package/scripts/daemon/session-outcomes.test.mjs +0 -533
  508. package/scripts/daemon/typing-registry.test.mjs +0 -102
  509. package/scripts/hooks/pre-send-audit.test.mjs +0 -354
  510. package/scripts/huddle/huddle-prompt.test.mjs +0 -176
  511. package/scripts/local-triggers/autoupdate.test.mjs +0 -518
  512. package/scripts/local-triggers/generate-plists.test.mjs +0 -456
  513. package/scripts/media-generation/brand-clause.test.mjs +0 -135
  514. package/scripts/org/send-orgmail.first-contact.test.mjs +0 -102
  515. package/scripts/poller/inbox-privilege-injection.test.mjs +0 -167
  516. package/scripts/poller/inbox-scan-poller.test.mjs +0 -295
  517. package/scripts/poller/lib/cloud-relay-dedup.test.mjs +0 -133
  518. package/scripts/poller/slack-socket-mode.test.mjs +0 -805
  519. package/scripts/poller-launchd/install.test.mjs +0 -243
  520. package/scripts/restore-from-backup.test.mjs +0 -181
  521. package/scripts/session/feed.test.mjs +0 -196
  522. package/scripts/session/supervisor-sh.test.mjs +0 -218
  523. package/scripts/session/supervisor.test.mjs +0 -482
  524. package/scripts/setup/configure-macos.test.mjs +0 -306
  525. package/scripts/setup/gen-subagent-manifest.test.mjs +0 -124
  526. package/scripts/setup/generate-agent-package-json.test.mjs +0 -143
  527. package/scripts/setup/generate-capability.test.mjs +0 -134
  528. package/scripts/setup/init-agent.test.mjs +0 -370
  529. package/scripts/setup/init-skill-marketplace.test.mjs +0 -193
  530. package/scripts/vendor/sync-skill-packs.test.mjs +0 -103
  531. 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
- });