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