@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
@@ -47,6 +47,12 @@
47
47
  * any) on stdout. Any unexpected internal error for an EXTERNAL recipient also
48
48
  * exits 1 (fail closed); for an internal recipient it exits 0 (fail open).
49
49
  *
50
+ * Hook form (the Claude Code PreToolUse gate, via scripts/hooks/pre-send-audit.sh)
51
+ * ---------------------------------------------------------------------------
52
+ * <PreToolUse JSON> | node lib/comms/send-gate.mjs --pre-send-hook <channel>
53
+ * exit 0 → allow (audit line on stdout); exit 2 → BLOCK (reason on stderr).
54
+ * Never exit 1: Claude Code does not block on it. See runPreSendHook.
55
+ *
50
56
  * @module lib/comms/send-gate
51
57
  */
52
58
 
@@ -55,8 +61,12 @@
55
61
  import {
56
62
  readFileSync as nodeReadFileSync,
57
63
  existsSync as nodeExistsSync,
64
+ statSync as nodeStatSync,
65
+ mkdirSync as nodeMkdirSync,
66
+ writeSync as nodeWriteSync,
58
67
  } from "node:fs";
59
68
  import { join } from "node:path";
69
+ import { writeFileAtomic, appendJsonl } from "../fs-atomic.mjs";
60
70
 
61
71
  /* ─────────────────────────── policy file locations ───────────────────────── */
62
72
 
@@ -117,6 +127,51 @@ const PERSONA_FILES = Object.freeze({
117
127
  peers: "state/session/peers.json",
118
128
  });
119
129
 
130
+ /* ──────────────────────── session-scaffold markers ───────────────────────── */
131
+ // A raw model turn must never reach a person. On 5 and 20 September 2026, bodies
132
+ // carrying `<user_message>`, a closing `</thinking>` and the responder's
133
+ // `[auto-reply: "…"]` preamble were posted verbatim into live Cohort channels —
134
+ // in the worst instance a triage conclusion that no reply was warranted,
135
+ // published AS the reply. Root cause was scripts/daemon/responder.mjs: BOTH the
136
+ // success path and the JSON-parse fallback resolve `cliResult.text`, and nothing
137
+ // sat between that and the wire (#64 closes the fallback). Patching the fallback
138
+ // closes one mouth; this closes the throat.
139
+ //
140
+ // Scope: the SCAFFOLD ONLY — wrappers a harness emits around a turn. Deliberately
141
+ // not a general "looks like XML" test, because org bodies legitimately carry angle
142
+ // brackets (diffs, HTML fragments, Slack's `<@U…>` mention syntax, `NAV < 1bp`).
143
+ //
144
+ // Matching is case-insensitive over the body with INLINE code spans REMOVED
145
+ // (see stripCodeSpans). A marker inside inline backticks is a citation, not a
146
+ // leak, and the incident thread that diagnosed this defect quoted every one of
147
+ // these in backticks. Without that carve-out the first thing this rule blocks
148
+ // is its own post-mortem, and the second is somebody switching the gate off to
149
+ // send it. Fenced (```) blocks are NOT exempt — a fenced block is also how a
150
+ // seat pastes a templated message or quotes a draft for review, so carving it
151
+ // out would reopen the exact leak this rule exists to stop.
152
+ //
153
+ // ONE list, shared by both screening paths in this module (`screenOutbound` for
154
+ // the in-process lanes, `screenHookPayload` for the PreToolUse hook). The ported
155
+ // hook rules are duplicated as HOOK_* constants because they owe shell parity;
156
+ // this rule is new, owes no parity, and must not be duplicated.
157
+ export const SCAFFOLD_MARKERS = Object.freeze([
158
+ "<user_message>",
159
+ "</user_message>",
160
+ "<thinking>",
161
+ "</thinking>",
162
+ "<system-reminder>",
163
+ "</system-reminder>",
164
+ "<function_calls>",
165
+ "</function_calls>",
166
+ "<function_results>",
167
+ "</function_results>",
168
+ "<invoke name=",
169
+ "</invoke>",
170
+ "[auto-reply:",
171
+ "<<<UNTRUSTED_",
172
+ "<END_UNTRUSTED_",
173
+ ]);
174
+
120
175
  /* ────────────────────────── recipient classification ─────────────────────── */
121
176
 
122
177
  /**
@@ -365,6 +420,62 @@ function personaContext(fs, agentRoot, { agentFirst, sessionNames }) {
365
420
  return { first, sessionNames: names };
366
421
  }
367
422
 
423
+ /**
424
+ * Remove INLINE code spans only, so a deliberately quoted marker (`` `<thinking>` ``)
425
+ * is not read as a leaked one. Replaced with a space rather than deleted, so
426
+ * stripping can never splice two halves of a marker together across the removed
427
+ * span (`` `<user_` `` followed by `` `message>` `` must not become
428
+ * `<user_message>`).
429
+ *
430
+ * Deliberately does NOT strip fenced (```) blocks: a fenced block isn't
431
+ * obviously a citation of the leak bug — it's also how a seat pastes a
432
+ * templated message or quotes a draft for review — so exempting it from the
433
+ * screen would carve out exactly the failure this rule exists to stop. Quote a
434
+ * marker deliberately with inline backticks; a marker inside a fenced block
435
+ * still blocks (pre-merge review on #66, Ravi Patel).
436
+ *
437
+ * Bounded to a 1-2 backtick run (single line, no fence). The run must not be
438
+ * adjacent to a further backtick on either side, so this can never partially
439
+ * consume a ``` fence delimiter (matching 2 of its 3 backticks and leaving a
440
+ * dangling one) — inside a fence, no width starts or closes a match, so the
441
+ * fence content is left untouched for screenScaffold to see.
442
+ *
443
+ * @param {string} text
444
+ * @returns {string}
445
+ */
446
+ export function stripCodeSpans(text) {
447
+ return String(text || "").replace(/(?<!`)(`{1,2})(?!`)[^\n`]*?(?<!`)\1(?!`)/g, " ");
448
+ }
449
+
450
+ /**
451
+ * (a0) Session-scaffold screen. Pure; no IO. Returns a block reason naming the
452
+ * marker, or null.
453
+ *
454
+ * This blocks for INTERNAL recipients too, and that is the point: every recorded
455
+ * leak went to an internal Cohort channel, and those channels have human members.
456
+ * There is no recipient class for which publishing a raw model turn is
457
+ * acceptable — so unlike the policy-file layers this screen has no fail posture
458
+ * to resolve. It reads no file, so it cannot fail open.
459
+ *
460
+ * @param {string} text
461
+ * @returns {string|null}
462
+ */
463
+ export function screenScaffold(text) {
464
+ const t = typeof text === "string" ? text : "";
465
+ if (!t) return null;
466
+ const lc = stripCodeSpans(t).toLowerCase();
467
+ for (const marker of SCAFFOLD_MARKERS) {
468
+ if (lc.includes(marker.toLowerCase())) {
469
+ return (
470
+ `session-scaffold marker "${marker}" in the outbound body — a raw model turn ` +
471
+ `must never reach a person; send the reply you meant to write. ` +
472
+ `(To quote a marker deliberately, put it in backticks.)`
473
+ );
474
+ }
475
+ }
476
+ return null;
477
+ }
478
+
368
479
  /**
369
480
  * (b) AI-disclosure screen. Resolves the active posture for the recipient's
370
481
  * jurisdiction from ai-disclosure.yaml and enforces two things:
@@ -586,6 +697,14 @@ export async function screenOutbound({
586
697
  // file is unreadable we still apply the embedded constant list (the SoT
587
698
  // is mirrored here), so a missing style file does NOT open the AI-tell
588
699
  // gate; it only loses the ability to refresh the list.
700
+ // (a0) Session scaffold — FIRST, ahead of every other layer. A dumped model
701
+ // turn very often also trips the banned-phrase list, and if that fires
702
+ // first the operator reads "banned AI-tell phrase" and goes off to fix
703
+ // the wording of a message that should never have had a body at all.
704
+ // The most severe diagnosis has to be the one that gets reported.
705
+ const scaffoldReason = screenScaffold(text);
706
+ if (scaffoldReason) return blocked(scaffoldReason);
707
+
589
708
  const bannedReason = screenBannedPhrases(text);
590
709
  if (bannedReason) return blocked(bannedReason);
591
710
 
@@ -660,6 +779,443 @@ export async function screenOutbound({
660
779
  }
661
780
  }
662
781
 
782
+ /* ──────────────────── pre-send audit hook (ported from shell) ─────────────── */
783
+ //
784
+ // scripts/hooks/pre-send-audit.sh used to carry these rules in bash + jq + grep;
785
+ // it is now a thin wrapper that runs `node lib/comms/send-gate.mjs
786
+ // --pre-send-hook <channel>`. The rules live here so the Claude Code PreToolUse
787
+ // hook and a native engine gate (lib/engine, W2) share one implementation — no
788
+ // window in which outbound sends are gated by a copy that drifted.
789
+ //
790
+ // The port reproduces the shell verdicts, reasons and side effects exactly
791
+ // over the hook's test corpus (scripts/hooks/pre-send-audit.parity.test.mjs
792
+ // runs every case against the frozen shell copy and this module). Where bash/jq
793
+ // semantics were an accident rather than a rule, the port is deliberately no
794
+ // LESS strict, and says so inline: a multi-document JSON payload is malformed,
795
+ // whitespace inside a phrase includes non-ASCII spaces, a recipient with an
796
+ // embedded newline can never match an allowlist line.
797
+ //
798
+ // Split into pure verdict functions (screenHookPayload, applyHookRateCaps, and
799
+ // the extract/resolve helpers under them) and one effect edge (runPreSendHook)
800
+ // that reads identity/allowlist/counter files, writes the counter and the send
801
+ // log, and takes `now` as a parameter.
802
+
803
+ /** Banned substrings in the hook's order: the in-process list plus "Great question!" anywhere. */
804
+ export const HOOK_BANNED_SUBSTRINGS = Object.freeze([
805
+ ...BANNED_SUBSTRINGS.slice(0, 8),
806
+ "Great question!",
807
+ ...BANNED_SUBSTRINGS.slice(8),
808
+ ]);
809
+
810
+ /**
811
+ * The persona patterns, compiled the way the hook matched them: grep is
812
+ * line-bounded, so whitespace inside a phrase never spans a newline.
813
+ */
814
+ export const HOOK_PERSONA_PATTERNS = Object.freeze(
815
+ PERSONA_PATTERNS.map((re) => new RegExp(re.source.replace(/\\s/g, "[^\\S\\n]"), re.flags)),
816
+ );
817
+
818
+ /** Filler openers at the start of any line, or after a control char (grep's [[:cntrl:]]). */
819
+ const HOOK_OPENER_RE = /(^|[\x00-\x1f\x7f])[^\S\n]*(certainly!|absolutely!|great question!)/im;
820
+
821
+ const HOOK_CHANNEL_REQUIRED = "Channel type required (slack|slack-draft|gmail|whatsapp|cohort)";
822
+
823
+ function isPlainObject(v) {
824
+ return v !== null && typeof v === "object" && !Array.isArray(v);
825
+ }
826
+
827
+ /** jq's `//`: only null and false (and a missing key) are "absent". */
828
+ function jqPresent(v) {
829
+ return v !== undefined && v !== null && v !== false;
830
+ }
831
+
832
+ /** jq `tostring` / `tojson` for the values a payload can carry. */
833
+ function jqString(v) {
834
+ return typeof v === "string" ? v : JSON.stringify(v);
835
+ }
836
+
837
+ /** `tr '[:upper:]' '[:lower:]'` / jq `ascii_downcase`: ASCII letters only. */
838
+ function asciiLower(s) {
839
+ return String(s).replace(/[A-Z]/g, (c) => c.toLowerCase());
840
+ }
841
+
842
+ /** Command substitution drops NUL bytes and strips trailing newlines. */
843
+ function shellCaptured(s) {
844
+ return String(s).replace(/\0/g, "").replace(/\n+$/, "");
845
+ }
846
+
847
+ /**
848
+ * The outbound text of a PreToolUse payload, as the hook concatenated it: from
849
+ * `tool_input`, then `tool_input.params` (org_rpc), then the top level, the
850
+ * fields text/message/body/bodyText/subject/content (non-strings as JSON) and
851
+ * `blocks` as JSON, empties dropped, joined by newlines. Pure.
852
+ *
853
+ * @param {unknown} payload parsed JSON
854
+ * @returns {{ok:true, text:string}|{ok:false}} ok:false where jq errored (a non-object payload)
855
+ */
856
+ export function extractHookContent(payload) {
857
+ if (!isPlainObject(payload)) return { ok: false };
858
+ const ti = payload.tool_input;
859
+ const sources = [jqPresent(ti) ? ti : {}, isPlainObject(ti) && isPlainObject(ti.params) ? ti.params : {}, payload];
860
+ const parts = [];
861
+ for (const src of sources) {
862
+ if (!isPlainObject(src)) continue; // `.text?` on a non-object yields nothing
863
+ for (const k of ["text", "message", "body", "bodyText", "subject", "content"]) {
864
+ if (jqPresent(src[k])) parts.push(jqString(src[k]));
865
+ }
866
+ const b = src.blocks;
867
+ if (b !== null && typeof b === "object") parts.push(JSON.stringify(b));
868
+ }
869
+ return { ok: true, text: shellCaptured(parts.filter((p) => p !== "").join("\n")) };
870
+ }
871
+
872
+ /**
873
+ * The recipient the allowlist is checked against: tool_input.to / recipient /
874
+ * channel / email / thread_ts, then top-level to / recipient; non-strings as
875
+ * JSON; "" when none. Pure.
876
+ * @param {object} payload parsed JSON object
877
+ * @returns {string}
878
+ */
879
+ export function extractHookRecipient(payload) {
880
+ const ti = payload && payload.tool_input;
881
+ const t = jqPresent(ti) ? ti : {};
882
+ const pick = (o, k) => (isPlainObject(o) ? o[k] : undefined);
883
+ const chain = [pick(t, "to"), pick(t, "recipient"), pick(t, "channel"), pick(t, "email"), pick(t, "thread_ts"), pick(payload, "to"), pick(payload, "recipient")];
884
+ const v = chain.find(jqPresent);
885
+ return v === undefined ? "" : shellCaptured(jqString(v));
886
+ }
887
+
888
+ /**
889
+ * The agent's first name for the `<first>-main` check, resolved as the hook
890
+ * did: MAESTRO_AGENT_FIRST, else the first candidate file (AGENT_ROOT and
891
+ * AGENT_DIR config/agent.json, ~/.claude/maestro-agent.json) whose
892
+ * firstName / agentName / fullName yields a first word; lower-cased and
893
+ * reduced to [a-z0-9-]. Pure over the file TEXTS (null = not a readable file).
894
+ *
895
+ * @param {{envFirst?:string, candidates:Array<string|null>}} o
896
+ * @returns {string} "" when none resolves (the name check is then skipped)
897
+ */
898
+ export function hookFirstName({ envFirst, candidates = [] }) {
899
+ let first = typeof envFirst === "string" ? envFirst : "";
900
+ if (!first) {
901
+ for (const text of candidates) {
902
+ if (text == null) continue;
903
+ let raw = "";
904
+ try {
905
+ const doc = JSON.parse(text);
906
+ if (isPlainObject(doc)) {
907
+ const v = [doc.firstName, doc.agentName, doc.fullName].find(jqPresent);
908
+ const s = v === undefined ? "" : jqString(v);
909
+ // jq: `"" | split(" ")[0]` is null and ascii_downcase then errors → "".
910
+ raw = s === "" ? "" : asciiLower(s.split(" ")[0]);
911
+ }
912
+ } catch { raw = ""; /* unparseable agent file: jq failed, try the next candidate */ }
913
+ raw = shellCaptured(raw);
914
+ if (raw && raw !== "null") { first = raw; break; }
915
+ }
916
+ }
917
+ return asciiLower(first).replace(/[^a-z0-9-]/g, "");
918
+ }
919
+
920
+ /**
921
+ * Internal session names: `<first>-main`, the registered peers (peers.json as
922
+ * an array or {peers:[…]}), then MAESTRO_SESSION_NAMES (comma-separated, first
923
+ * line). A corrupt registry is an empty one. Pure over the registry TEXT.
924
+ *
925
+ * @param {{first?:string, peersText?:string|null, envNames?:string}} o
926
+ * @returns {string[]}
927
+ */
928
+ export function hookSessionNames({ first, peersText, envNames }) {
929
+ const names = [];
930
+ if (first) names.push(`${first}-main`);
931
+ if (peersText != null) {
932
+ let doc;
933
+ try { doc = JSON.parse(peersText); } catch { doc = undefined; /* corrupt registry = empty */ }
934
+ let list = [];
935
+ if (Array.isArray(doc)) list = doc;
936
+ else if (isPlainObject(doc)) list = jqPresent(doc.peers) ? doc.peers : [];
937
+ const items = Array.isArray(list) ? list : isPlainObject(list) ? Object.values(list) : [];
938
+ for (const it of items) {
939
+ if (!isPlainObject(it) || !jqPresent(it.name)) continue;
940
+ // `while IFS= read -r pn`: one name per output line, whitespace KEPT
941
+ // (IFS is empty, so read trims nothing); empty lines are skipped.
942
+ for (const line of asciiLower(jqString(it.name)).split("\n")) {
943
+ if (line) names.push(line);
944
+ }
945
+ }
946
+ }
947
+ if (typeof envNames === "string" && envNames) {
948
+ for (const part of envNames.split("\n")[0].split(",")) {
949
+ const n = asciiLower(part.replace(/[ \t\n\v\f\r]/g, ""));
950
+ if (n) names.push(n);
951
+ }
952
+ }
953
+ return names;
954
+ }
955
+
956
+ /** Word-bounded (on [A-Za-z0-9_-]) session-name hit, trimmed of its boundary chars. */
957
+ function hookSessionNameHit(text, name) {
958
+ const re = new RegExp(`(^|[^A-Za-z0-9_\\n-])${escapeRe(name)}([^A-Za-z0-9_\\n-]|$)`, "im");
959
+ const m = re.exec(text);
960
+ if (!m) return null;
961
+ return m[0].replace(/^[^A-Za-z0-9_-]/, "").replace(/[^A-Za-z0-9_-]$/, "");
962
+ }
963
+
964
+ /**
965
+ * Is `recipient` a line of the allowlist (surrounding whitespace and a
966
+ * trailing `# comment` allowed, case-sensitive)? Pure.
967
+ * @param {string} recipient non-empty
968
+ * @param {string} allowlistText
969
+ */
970
+ export function hookAllowlistMatch(recipient, allowlistText) {
971
+ const re = new RegExp(`^[ \\t\\v\\f\\r]*${escapeRe(recipient)}[ \\t\\v\\f\\r]*(#[^\\n]*)?$`);
972
+ return String(allowlistText).split("\n").some((line) => re.test(line));
973
+ }
974
+
975
+ /**
976
+ * Phase 1 of the hook verdict: payload shape, banned phrases, openers, persona
977
+ * patterns, internal session names, recipient allowlist. Pure.
978
+ *
979
+ * @param {object} o
980
+ * @param {string} o.channel the hook's channel argument
981
+ * @param {string} o.stdin the raw PreToolUse payload
982
+ * @param {string[]} [o.sessionNames] from hookSessionNames
983
+ * @param {null|{path:string, text:string|null}} [o.allowlist] null = not enforced; text null = unreadable
984
+ * @returns {{allow:true, recipient:string}|{allow:false, reason:string}}
985
+ */
986
+ export function screenHookPayload({ channel, stdin, sessionNames = [], allowlist = null }) {
987
+ const deny = (reason) => ({ allow: false, reason });
988
+ if (!channel) return deny(HOOK_CHANNEL_REQUIRED);
989
+ const raw = shellCaptured(stdin == null ? "" : stdin);
990
+ if (!raw) return deny("Empty hook payload — nothing to audit (fail closed)");
991
+ let payload;
992
+ // Stricter than `jq -e .`: a stream of several JSON documents is malformed.
993
+ try { payload = JSON.parse(raw); } catch { return deny("Malformed hook payload (not valid JSON) — fail closed"); }
994
+ if (payload === null || payload === false) return deny("Malformed hook payload (not valid JSON) — fail closed");
995
+ const content = extractHookContent(payload);
996
+ if (!content.ok) return deny("Failed to parse outbound content (fail closed)");
997
+ const text = content.text;
998
+ const recipient = extractHookRecipient(payload);
999
+
1000
+ // (a0) Session scaffold — the same shared rule the in-process gate applies,
1001
+ // ahead of the ported rules for the same reason it runs first there. This is
1002
+ // NOT a ported rule: the frozen shell had no counterpart, so it is outside the
1003
+ // parity contract by construction (pre-send-audit.parity.test.mjs says so).
1004
+ const scaffoldReason = screenScaffold(text);
1005
+ if (scaffoldReason) return deny(`Session-scaffold marker in outbound message: ${scaffoldReason}`);
1006
+
1007
+ const lc = text.toLowerCase();
1008
+ for (const phrase of HOOK_BANNED_SUBSTRINGS) {
1009
+ if (lc.includes(phrase.toLowerCase())) {
1010
+ return deny(`Banned AI-tell phrase in outbound message: "${phrase}" (see policies/communication-style.md)`);
1011
+ }
1012
+ }
1013
+ if (HOOK_OPENER_RE.test(text)) {
1014
+ return deny("Banned filler opener (Certainly!/Absolutely!/Great question!) — see policies/communication-style.md");
1015
+ }
1016
+ for (const re of HOOK_PERSONA_PATTERNS) {
1017
+ const m = re.exec(text);
1018
+ if (m) return deny(`Persona leak in outbound message: "${m[0]}" — rewrite in your own voice (my team / a colleague), never name the machinery`);
1019
+ }
1020
+ for (const name of sessionNames) {
1021
+ const hit = hookSessionNameHit(text, name);
1022
+ if (hit) return deny(`Persona leak in outbound message: "${hit}" is an internal session name — say "my team" or "a colleague" instead`);
1023
+ }
1024
+ if (recipient && allowlist) {
1025
+ if (allowlist.text == null || !hookAllowlistMatch(recipient, allowlist.text)) {
1026
+ return deny(`Recipient "${recipient}" not on send allowlist (${allowlist.path})`);
1027
+ }
1028
+ }
1029
+ return { allow: true, recipient };
1030
+ }
1031
+
1032
+ /** bash `[ n -ge cap ]` integer syntax; anything else makes the test fail (no cap). */
1033
+ function bashInt(v) {
1034
+ const s = String(v);
1035
+ return /^[ \t\n\v\f\r]*[+-]?[0-9]+[ \t\n\v\f\r]*$/.test(s) ? parseInt(s.trim(), 10) : null;
1036
+ }
1037
+
1038
+ /**
1039
+ * Phase 2: the hourly + daily send caps over the counter file
1040
+ * (`date<TAB>hour<TAB>hour_count<TAB>day_count`). Pure: `today`/`hour` are the
1041
+ * caller's local-clock strings.
1042
+ *
1043
+ * @param {object} o
1044
+ * @param {string|null} o.counterText counter file contents, null when absent
1045
+ * @param {string} o.today YYYY-MM-DD (local)
1046
+ * @param {string} o.hour HH (local)
1047
+ * @param {string} o.perHourCap raw cap (MAESTRO_SEND_PER_HOUR, default "3000")
1048
+ * @param {string} o.perDayCap raw cap (MAESTRO_SEND_PER_DAY, default "20000")
1049
+ * @returns {{allow:false, reason:string}|{allow:true, counterLine:string, hourCount:number, dayCount:number}}
1050
+ */
1051
+ export function applyHookRateCaps({ counterText, today, hour, perHourCap, perDayCap }) {
1052
+ let prevDate = "";
1053
+ let prevHour = "";
1054
+ let hourCount = "0";
1055
+ let dayCount = "0";
1056
+ if (counterText != null) {
1057
+ // `IFS=$'\t' read -r a b c d`: first line, runs of tabs are one separator,
1058
+ // the last variable takes the rest of the line.
1059
+ let rest = String(counterText).split("\n")[0].replace(/^\t+/, "").replace(/\t+$/, "");
1060
+ const take = () => {
1061
+ const i = rest.indexOf("\t");
1062
+ if (i < 0) { const v = rest; rest = ""; return v; }
1063
+ const v = rest.slice(0, i);
1064
+ rest = rest.slice(i).replace(/^\t+/, "");
1065
+ return v;
1066
+ };
1067
+ prevDate = take();
1068
+ prevHour = take();
1069
+ hourCount = take();
1070
+ dayCount = rest;
1071
+ }
1072
+ let hc = /^[0-9]+$/.test(hourCount) ? parseInt(hourCount, 10) : 0;
1073
+ let dc = /^[0-9]+$/.test(dayCount) ? parseInt(dayCount, 10) : 0;
1074
+ if (prevDate !== today) { dc = 0; hc = 0; } else if (prevHour !== hour) { hc = 0; }
1075
+
1076
+ const dayCap = bashInt(perDayCap);
1077
+ const hourCap = bashInt(perHourCap);
1078
+ if (dayCap !== null && dc >= dayCap) return { allow: false, reason: `Daily send limit (${perDayCap}) reached. Sends resume tomorrow.` };
1079
+ if (hourCap !== null && hc >= hourCap) return { allow: false, reason: `Hourly send limit (${perHourCap}) reached. Sends resume next hour.` };
1080
+ hc += 1;
1081
+ dc += 1;
1082
+ return { allow: true, counterLine: `${today}\t${hour}\t${hc}\t${dc}\n`, hourCount: hc, dayCount: dc };
1083
+ }
1084
+
1085
+ const pad2 = (n) => String(n).padStart(2, "0");
1086
+
1087
+ /**
1088
+ * The effect edge of the pre-send hook: resolve identity, peers, allowlist and
1089
+ * counter from disk and env, run the two pure phases, commit the counter and
1090
+ * append the send log. Synchronous; never throws for an expected failure —
1091
+ * every denial is `{code:2}` with the reason on stderr, exactly as the shell.
1092
+ *
1093
+ * @param {object} o
1094
+ * @param {string} o.channel
1095
+ * @param {string} o.stdin
1096
+ * @param {object} [o.env=process.env]
1097
+ * @param {Date} [o.now=new Date()]
1098
+ * @param {object} [o.fs] injectable {readFileSync, statSync, mkdirSync, writeFileAtomic, appendJsonl}
1099
+ * @returns {{code:0|2, stdout:string, stderr:string}}
1100
+ */
1101
+ export function runPreSendHook(o = {}) {
1102
+ const prepared = preparePreSendHook(o);
1103
+ return prepared.ok ? prepared.commit() : prepared.result;
1104
+ }
1105
+
1106
+ /**
1107
+ * The pre-send hook in two steps, for a caller that must settle other checks
1108
+ * between the verdict and the send (the Cohort Engine's tool guard): `prepare`
1109
+ * screens the payload and checks the caps WITHOUT writing anything; `commit`
1110
+ * re-reads the counter, re-applies the caps, writes the counter and appends
1111
+ * the send log — exactly what `runPreSendHook` does after the screen. Calling
1112
+ * `commit` only for the send that actually goes out keeps refused calls off
1113
+ * the hourly and daily caps.
1114
+ *
1115
+ * @param {Parameters<typeof runPreSendHook>[0]} o
1116
+ * @returns {{ok:false, result:{code:2, stdout:string, stderr:string}} | {ok:true, commit:() => {code:0|2, stdout:string, stderr:string}}}
1117
+ */
1118
+ export function preparePreSendHook({ channel, stdin, env = process.env, now = new Date(), fs: injected = {} } = {}) {
1119
+ const fsx = {
1120
+ readFileSync: injected.readFileSync || nodeReadFileSync,
1121
+ statSync: injected.statSync || nodeStatSync,
1122
+ mkdirSync: injected.mkdirSync || nodeMkdirSync,
1123
+ writeFileAtomic: injected.writeFileAtomic || writeFileAtomic,
1124
+ appendJsonl: injected.appendJsonl || appendJsonl,
1125
+ };
1126
+ const deny = (reason) => ({ code: 2, stdout: "", stderr: `BLOCKED: ${reason}\n` });
1127
+ const e = env || {};
1128
+ const isFile = (p) => { try { return fsx.statSync(p).isFile(); } catch { return false; } };
1129
+ const readIfFile = (p) => {
1130
+ if (!isFile(p)) return null;
1131
+ try { return fsx.readFileSync(p, "utf-8"); } catch { return null; /* unreadable: jq/grep would have failed on it */ }
1132
+ };
1133
+ // `${VAR:-}`: an unset or empty variable is the empty string, so the path is
1134
+ // built as the shell built it (e.g. "/config/agent.json" when AGENT_ROOT is unset).
1135
+ const v = (k) => (typeof e[k] === "string" ? e[k] : "");
1136
+
1137
+ const refuse = (reason) => ({ ok: false, result: deny(reason) });
1138
+ if (!channel) return refuse(HOOK_CHANNEL_REQUIRED);
1139
+
1140
+ const first = hookFirstName({
1141
+ envFirst: v("MAESTRO_AGENT_FIRST"),
1142
+ candidates: [`${v("AGENT_ROOT")}/config/agent.json`, `${v("AGENT_DIR")}/config/agent.json`, `${v("HOME")}/.claude/maestro-agent.json`].map(readIfFile),
1143
+ });
1144
+ let peersText = null;
1145
+ for (const p of [`${v("AGENT_ROOT")}/state/session/peers.json`, `${v("AGENT_DIR")}/state/session/peers.json`]) {
1146
+ if (!isFile(p)) continue;
1147
+ peersText = readIfFile(p);
1148
+ break;
1149
+ }
1150
+ const sessionNames = hookSessionNames({ first, peersText, envNames: v("MAESTRO_SESSION_NAMES") });
1151
+ const allowPath = v("MAESTRO_SEND_ALLOWLIST");
1152
+ const allowlist = allowPath && isFile(allowPath) ? { path: allowPath, text: readIfFile(allowPath) } : null;
1153
+
1154
+ const screened = screenHookPayload({ channel, stdin, sessionNames, allowlist });
1155
+ if (!screened.allow) return refuse(screened.reason);
1156
+
1157
+ const runtimeDir = `${v("MAESTRO_RUNTIME_DIR") || `${v("HOME")}/.maestro/runtime`}/audit`;
1158
+ const counterFile = `${runtimeDir}/send-counter.tsv`;
1159
+ const today = `${now.getFullYear()}-${pad2(now.getMonth() + 1)}-${pad2(now.getDate())}`;
1160
+ const hour = pad2(now.getHours());
1161
+ const timestamp = now.toISOString().replace(/\.\d{3}Z$/, "Z");
1162
+ const perHourCap = v("MAESTRO_SEND_PER_HOUR") || "3000";
1163
+ const perDayCap = v("MAESTRO_SEND_PER_DAY") || "20000";
1164
+
1165
+ // A send the caps already refuse is refused here, before anything is written.
1166
+ const preview = applyHookRateCaps({ counterText: readIfFile(counterFile), today, hour, perHourCap, perDayCap });
1167
+ if (!preview.allow) return refuse(preview.reason);
1168
+
1169
+ return {
1170
+ ok: true,
1171
+ commit() {
1172
+ try {
1173
+ fsx.mkdirSync(runtimeDir, { recursive: true });
1174
+ } catch {
1175
+ return deny(`Cannot create audit runtime dir ${runtimeDir} (fail closed)`);
1176
+ }
1177
+ const caps = applyHookRateCaps({ counterText: readIfFile(counterFile), today, hour, perHourCap, perDayCap });
1178
+ if (!caps.allow) return deny(caps.reason);
1179
+ try {
1180
+ fsx.writeFileAtomic(counterFile, caps.counterLine);
1181
+ } catch {
1182
+ return deny("Cannot write send counter (fail closed)");
1183
+ }
1184
+ // Best effort, as before: the send is already counted.
1185
+ fsx.appendJsonl(`${runtimeDir}/${today}-sends.jsonl`, { timestamp, channel, recipient: screened.recipient, status: "allowed" });
1186
+
1187
+ return {
1188
+ code: 0,
1189
+ stdout: `Audit: ${channel} send screened and logged at ${timestamp} (hour: ${caps.hourCount}/${perHourCap}, day: ${caps.dayCount}/${perDayCap})\n`,
1190
+ stderr: "",
1191
+ };
1192
+ },
1193
+ };
1194
+ }
1195
+
1196
+ /**
1197
+ * CLI form of the hook (`--pre-send-hook <channel>`, payload on stdin). Every
1198
+ * outcome is exit 0 or 2: Claude Code does not block on exit 1, so an internal
1199
+ * error here is a denial, never a pass. Output goes through writeSync so a
1200
+ * piped stdout/stderr is flushed before the process ends.
1201
+ */
1202
+ function mainPreSendHook(argv) {
1203
+ let res;
1204
+ try {
1205
+ const channel = argv[1] || "";
1206
+ res = channel
1207
+ ? runPreSendHook({ channel, stdin: readStdin(), env: process.env, now: new Date() })
1208
+ : { code: 2, stdout: "", stderr: `BLOCKED: ${HOOK_CHANNEL_REQUIRED}\n` };
1209
+ } catch (err) {
1210
+ res = { code: 2, stdout: "", stderr: `BLOCKED: pre-send gate internal error — ${err && err.message ? err.message : String(err)} (fail closed)\n` };
1211
+ }
1212
+ try {
1213
+ if (res.stdout) nodeWriteSync(1, res.stdout);
1214
+ if (res.stderr) nodeWriteSync(2, res.stderr);
1215
+ } catch { /* an unwritable stdio cannot change the verdict carried by the exit code */ }
1216
+ process.exitCode = res.code;
1217
+ }
1218
+
663
1219
  /* ─────────────────────────────────── CLI ─────────────────────────────────── */
664
1220
 
665
1221
  /**
@@ -742,5 +1298,7 @@ async function main(argv) {
742
1298
  const invokedDirectly =
743
1299
  process.argv[1] && process.argv[1].endsWith("send-gate.mjs");
744
1300
  if (invokedDirectly) {
745
- main(process.argv.slice(2));
1301
+ const cliArgs = process.argv.slice(2);
1302
+ if (cliArgs[0] === "--pre-send-hook") mainPreSendHook(cliArgs);
1303
+ else main(cliArgs);
746
1304
  }