@cohortapp/agent-sdk 2.17.0 → 2.18.5

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