@cohortapp/agent-sdk 2.3.0

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 (731) hide show
  1. package/.claude/commands/init-agent.md +104 -0
  2. package/.claude/commands/init-maestro.md +1187 -0
  3. package/.claude/settings.json +161 -0
  4. package/.env.example +216 -0
  5. package/README.md +632 -0
  6. package/agents/browser-operator/agent.md +52 -0
  7. package/agents/calendar-ops/agent.md +50 -0
  8. package/agents/communications/agent.md +96 -0
  9. package/agents/decision-log/agent.md +65 -0
  10. package/agents/desktop-operator/agent.md +59 -0
  11. package/agents/gmail-operator/agent.md +62 -0
  12. package/agents/inbound-dispatcher/agent.md +66 -0
  13. package/agents/inbox-processor/agent.md +39 -0
  14. package/agents/pmo-execution/agent.md +60 -0
  15. package/agents/session-spawner/agent.md +64 -0
  16. package/agents/slack-operator/agent.md +60 -0
  17. package/agents/whatsapp-operator/agent.md +60 -0
  18. package/agents/workflow-automation/agent.md +61 -0
  19. package/archetypes/altitudes/c-suite.yaml +58 -0
  20. package/archetypes/altitudes/founder.yaml +68 -0
  21. package/archetypes/altitudes/senior-manager.yaml +63 -0
  22. package/archetypes/altitudes/svp.yaml +60 -0
  23. package/archetypes/altitudes/vp.yaml +50 -0
  24. package/archetypes/archetype.schema.json +77 -0
  25. package/archetypes/base.yaml +47 -0
  26. package/archetypes/capabilities/commercial-leader.yaml +159 -0
  27. package/archetypes/capabilities/compliance-officer.yaml +159 -0
  28. package/archetypes/capabilities/executive-operator.yaml +169 -0
  29. package/archetypes/capabilities/finance-leader.yaml +162 -0
  30. package/archetypes/capabilities/operations-leader.yaml +154 -0
  31. package/archetypes/capabilities/product-leader.yaml +148 -0
  32. package/archetypes/capabilities/technical-leader.yaml +146 -0
  33. package/archetypes/functions/commercial-leader.yaml +62 -0
  34. package/archetypes/functions/compliance-officer.yaml +64 -0
  35. package/archetypes/functions/executive-operator.yaml +70 -0
  36. package/archetypes/functions/finance-leader.yaml +70 -0
  37. package/archetypes/functions/operations-leader.yaml +62 -0
  38. package/archetypes/functions/product-leader.yaml +61 -0
  39. package/archetypes/functions/technical-leader.yaml +57 -0
  40. package/bin/cohort-mcp.mjs +81 -0
  41. package/bin/maestro.mjs +3516 -0
  42. package/bin/maestro.test.mjs +1015 -0
  43. package/desktop-control/README.md +56 -0
  44. package/desktop-control/app-profiles/gmail.yaml +120 -0
  45. package/desktop-control/app-profiles/slack.yaml +315 -0
  46. package/desktop-control/app-profiles/whatsapp.yaml +107 -0
  47. package/docs/architecture/agent-topology.md +2239 -0
  48. package/docs/architecture/archetype-agent-factory.md +110 -0
  49. package/docs/architecture/collective-memory-and-org-mesh.md +115 -0
  50. package/docs/architecture/continuous-monitoring.md +221 -0
  51. package/docs/architecture/mcp-capability-map.md +585 -0
  52. package/docs/architecture/system-architecture.md +1272 -0
  53. package/docs/company-context/README.md +40 -0
  54. package/docs/guides/agent-persona-setup.md +600 -0
  55. package/docs/guides/agents-observe-setup.md +64 -0
  56. package/docs/guides/billing-console-keys.md +88 -0
  57. package/docs/guides/ccxray-diagnostics.md +65 -0
  58. package/docs/guides/channel-bus.md +127 -0
  59. package/docs/guides/claude-mem-setup.md +79 -0
  60. package/docs/guides/claude-pace-setup.md +56 -0
  61. package/docs/guides/claudraband-sessions.md +98 -0
  62. package/docs/guides/clawteam-swarm.md +116 -0
  63. package/docs/guides/code-review-graph-setup.md +86 -0
  64. package/docs/guides/email-setup.md +431 -0
  65. package/docs/guides/mac-mini.md +119 -0
  66. package/docs/guides/media-generation-setup.md +349 -0
  67. package/docs/guides/model-routing.md +162 -0
  68. package/docs/guides/observability-otel.md +265 -0
  69. package/docs/guides/org-onboarding.md +132 -0
  70. package/docs/guides/outbound-governance-setup.md +437 -0
  71. package/docs/guides/pdf-generation-setup.md +315 -0
  72. package/docs/guides/poller-daemon-setup.md +563 -0
  73. package/docs/guides/rag-context-setup.md +459 -0
  74. package/docs/guides/self-optimization-pattern.md +82 -0
  75. package/docs/guides/setup-wizard.md +178 -0
  76. package/docs/guides/slack-setup.md +350 -0
  77. package/docs/guides/telegram-setup.md +227 -0
  78. package/docs/guides/twilio-subaccounts-setup.md +223 -0
  79. package/docs/guides/verification.md +128 -0
  80. package/docs/guides/voice-mode.md +188 -0
  81. package/docs/guides/voice-sms-setup.md +698 -0
  82. package/docs/guides/webhook-relay-setup.md +349 -0
  83. package/docs/guides/whatsapp-setup.md +288 -0
  84. package/docs/prompts/board-pack-cover-template.md +36 -0
  85. package/docs/prompts/decision-recommendation-template.md +88 -0
  86. package/docs/prompts/followup-message-template.md +141 -0
  87. package/docs/prompts/investor-letter-template.md +52 -0
  88. package/docs/prompts/morning-brief-template.md +82 -0
  89. package/docs/prompts/presentation-template.md +58 -0
  90. package/docs/prompts/weekly-strategic-memo-template.md +104 -0
  91. package/docs/research/hallucinated-tool-output-investigation.md +151 -0
  92. package/docs/runbooks/backup-restore.md +205 -0
  93. package/docs/runbooks/cohort-cutover.md +129 -0
  94. package/docs/runbooks/fleet-operations.md +200 -0
  95. package/docs/runbooks/incident-response.md +226 -0
  96. package/docs/runbooks/mac-mini-bootstrap.md +431 -0
  97. package/docs/runbooks/perpetual-operations.md +509 -0
  98. package/docs/runbooks/recovery-and-failover.md +260 -0
  99. package/framework-features.json +267 -0
  100. package/ingest/README.md +87 -0
  101. package/lib/action-executor.js +689 -0
  102. package/lib/action-executor.test.mjs +871 -0
  103. package/lib/agent-root.mjs +37 -0
  104. package/lib/archetype.mjs +236 -0
  105. package/lib/archetype.test.mjs +132 -0
  106. package/lib/autonomy.mjs +114 -0
  107. package/lib/autonomy.test.mjs +66 -0
  108. package/lib/backlog.mjs +358 -0
  109. package/lib/backlog.test.mjs +266 -0
  110. package/lib/budget-guard.mjs +279 -0
  111. package/lib/budget-guard.test.mjs +291 -0
  112. package/lib/cadence-bus-schedule.test.mjs +194 -0
  113. package/lib/cadence-bus.mjs +1120 -0
  114. package/lib/cadence-bus.test.mjs +720 -0
  115. package/lib/cadences.mjs +205 -0
  116. package/lib/cadences.test.mjs +125 -0
  117. package/lib/capability.mjs +154 -0
  118. package/lib/capability.test.mjs +78 -0
  119. package/lib/channels/base-adapter.mjs +719 -0
  120. package/lib/channels/base-adapter.test.mjs +590 -0
  121. package/lib/channels/channel.mjs +128 -0
  122. package/lib/channels/channels.test.mjs +371 -0
  123. package/lib/channels/contract.mjs +215 -0
  124. package/lib/channels/contract.test.mjs +137 -0
  125. package/lib/channels/conversation-resolver.mjs +95 -0
  126. package/lib/channels/gmail/adapter.mjs +87 -0
  127. package/lib/channels/inbox-item.mjs +255 -0
  128. package/lib/channels/inbox-item.test.mjs +335 -0
  129. package/lib/channels/index.mjs +94 -0
  130. package/lib/channels/orgmail/adapter.mjs +353 -0
  131. package/lib/channels/orgmail/adapter.test.mjs +311 -0
  132. package/lib/channels/pairing.mjs +363 -0
  133. package/lib/channels/pairing.test.mjs +270 -0
  134. package/lib/channels/registry.mjs +164 -0
  135. package/lib/channels/slack/adapter.mjs +317 -0
  136. package/lib/channels/slack-adapter.test.mjs +212 -0
  137. package/lib/channels/sms/adapter.mjs +43 -0
  138. package/lib/channels/telegram/adapter.mjs +432 -0
  139. package/lib/channels/telegram-adapter.test.mjs +306 -0
  140. package/lib/channels/voice/adapter.mjs +301 -0
  141. package/lib/channels/voice/adapter.test.mjs +278 -0
  142. package/lib/channels/whatsapp/adapter-baileys.mjs +587 -0
  143. package/lib/channels/whatsapp/adapter-baileys.test.mjs +359 -0
  144. package/lib/channels/whatsapp/adapter-twilio.mjs +65 -0
  145. package/lib/channels/whatsapp/baileys-typing.test.mjs +154 -0
  146. package/lib/charter.mjs +256 -0
  147. package/lib/charter.test.mjs +89 -0
  148. package/lib/claude-bin.mjs +134 -0
  149. package/lib/claude-bin.test.mjs +75 -0
  150. package/lib/collective/capture.mjs +185 -0
  151. package/lib/collective/capture.test.mjs +121 -0
  152. package/lib/collective/cards.mjs +201 -0
  153. package/lib/collective/cards.test.mjs +114 -0
  154. package/lib/collective/config.mjs +186 -0
  155. package/lib/collective/config.test.mjs +123 -0
  156. package/lib/collective/global-config.mjs +113 -0
  157. package/lib/collective/global-config.test.mjs +75 -0
  158. package/lib/collective/presence.mjs +201 -0
  159. package/lib/collective/presence.test.mjs +95 -0
  160. package/lib/collective/recall.mjs +215 -0
  161. package/lib/collective/recall.test.mjs +116 -0
  162. package/lib/comms/send-gate.mjs +554 -0
  163. package/lib/comms/send-gate.test.mjs +577 -0
  164. package/lib/comms.mjs +67 -0
  165. package/lib/comms.test.mjs +41 -0
  166. package/lib/diagnostics/alerts.mjs +424 -0
  167. package/lib/diagnostics/alerts.test.mjs +318 -0
  168. package/lib/diagnostics/backup-freshness.mjs +188 -0
  169. package/lib/diagnostics/backup-freshness.test.mjs +185 -0
  170. package/lib/diagnostics/counters.mjs +269 -0
  171. package/lib/diagnostics/counters.test.mjs +206 -0
  172. package/lib/diagnostics/events.mjs +188 -0
  173. package/lib/diagnostics/events.test.mjs +290 -0
  174. package/lib/diagnostics/otel.mjs +237 -0
  175. package/lib/diagnostics/otel.test.mjs +196 -0
  176. package/lib/diagnostics/trace.mjs +216 -0
  177. package/lib/diagnostics/trace.test.mjs +251 -0
  178. package/lib/env-compat.mjs +74 -0
  179. package/lib/env-compat.test.mjs +104 -0
  180. package/lib/feature-init.mjs +331 -0
  181. package/lib/fs-atomic.mjs +112 -0
  182. package/lib/fs-atomic.test.mjs +72 -0
  183. package/lib/fs-ownership.mjs +111 -0
  184. package/lib/fs-ownership.test.mjs +158 -0
  185. package/lib/hooks/bus.mjs +347 -0
  186. package/lib/hooks/bus.test.mjs +387 -0
  187. package/lib/index.js +16 -0
  188. package/lib/learning/config.mjs +106 -0
  189. package/lib/learning/config.test.mjs +75 -0
  190. package/lib/learning/counters.mjs +156 -0
  191. package/lib/learning/counters.test.mjs +69 -0
  192. package/lib/learning/curator-consolidate.test.mjs +238 -0
  193. package/lib/learning/curator.mjs +453 -0
  194. package/lib/learning/curator.test.mjs +106 -0
  195. package/lib/learning/log.mjs +40 -0
  196. package/lib/learning/reflect.mjs +534 -0
  197. package/lib/learning/reflect.test.mjs +0 -0
  198. package/lib/learning/session-index.mjs +352 -0
  199. package/lib/learning/session-index.test.mjs +125 -0
  200. package/lib/learning/skill-writer.mjs +474 -0
  201. package/lib/learning/skill-writer.test.mjs +210 -0
  202. package/lib/mcp/server.mjs +328 -0
  203. package/lib/mcp/server.test.mjs +400 -0
  204. package/lib/model-router/auth-profiles.mjs +758 -0
  205. package/lib/model-router/auth-profiles.test.mjs +580 -0
  206. package/lib/model-router/catalog/anthropic.yaml +153 -0
  207. package/lib/model-router/catalog/deepseek.yaml +86 -0
  208. package/lib/model-router/catalog/moonshot.yaml +81 -0
  209. package/lib/model-router/catalog/qwen.yaml +114 -0
  210. package/lib/model-router/catalog.mjs +925 -0
  211. package/lib/model-router/catalog.test.mjs +385 -0
  212. package/lib/model-router/economics.mjs +564 -0
  213. package/lib/model-router/economics.test.mjs +344 -0
  214. package/lib/model-router/failover.mjs +298 -0
  215. package/lib/model-router/failover.test.mjs +439 -0
  216. package/lib/model-router/health.mjs +453 -0
  217. package/lib/model-router/health.test.mjs +338 -0
  218. package/lib/model-router/integration-coverage.test.mjs +829 -0
  219. package/lib/model-router/integration.test.mjs +564 -0
  220. package/lib/model-router/ledger.mjs +402 -0
  221. package/lib/model-router/ledger.test.mjs +382 -0
  222. package/lib/model-router/llm-task.mjs +515 -0
  223. package/lib/model-router/llm-task.test.mjs +392 -0
  224. package/lib/model-router/org-credentials.mjs +260 -0
  225. package/lib/model-router/org-credentials.test.mjs +265 -0
  226. package/lib/model-router/pricing-refresh.mjs +463 -0
  227. package/lib/model-router/pricing-refresh.test.mjs +286 -0
  228. package/lib/model-router/reconcile.mjs +429 -0
  229. package/lib/model-router/reconcile.test.mjs +316 -0
  230. package/lib/model-router/repair.mjs +471 -0
  231. package/lib/model-router/repair.test.mjs +180 -0
  232. package/lib/model-router/resolve.mjs +1206 -0
  233. package/lib/model-router/spawn.mjs +497 -0
  234. package/lib/model-router/spawn.test.mjs +425 -0
  235. package/lib/model-router/taxonomy.mjs +893 -0
  236. package/lib/model-router/taxonomy.test.mjs +410 -0
  237. package/lib/model-router.mjs +677 -0
  238. package/lib/model-router.test.mjs +907 -0
  239. package/lib/org/activity.mjs +211 -0
  240. package/lib/org/activity.test.mjs +134 -0
  241. package/lib/org/approvals.mjs +448 -0
  242. package/lib/org/approvals.test.mjs +216 -0
  243. package/lib/org/awareness.mjs +222 -0
  244. package/lib/org/awareness.test.mjs +159 -0
  245. package/lib/org/board.mjs +229 -0
  246. package/lib/org/board.test.mjs +177 -0
  247. package/lib/org/bootstrap-context.mjs +169 -0
  248. package/lib/org/bootstrap-context.test.mjs +153 -0
  249. package/lib/org/client.mjs +1628 -0
  250. package/lib/org/client.test.mjs +1107 -0
  251. package/lib/org/cohort-client.mjs +67 -0
  252. package/lib/org/cohort-client.test.mjs +126 -0
  253. package/lib/org/cost-sync.mjs +227 -0
  254. package/lib/org/cost-sync.test.mjs +153 -0
  255. package/lib/org/doctor.mjs +212 -0
  256. package/lib/org/doctor.test.mjs +212 -0
  257. package/lib/org/handoff.mjs +293 -0
  258. package/lib/org/handoff.test.mjs +269 -0
  259. package/lib/org/integration-tools.mjs +182 -0
  260. package/lib/org/integration-tools.test.mjs +160 -0
  261. package/lib/org/keys.mjs +131 -0
  262. package/lib/org/keys.test.mjs +92 -0
  263. package/lib/org/knowledge.mjs +463 -0
  264. package/lib/org/knowledge.test.mjs +319 -0
  265. package/lib/org/leases.mjs +335 -0
  266. package/lib/org/leases.test.mjs +235 -0
  267. package/lib/org/mesh-integration.test.mjs +127 -0
  268. package/lib/org/mesh.mjs +459 -0
  269. package/lib/org/mesh.test.mjs +345 -0
  270. package/lib/org/messaging.mjs +503 -0
  271. package/lib/org/messaging.test.mjs +238 -0
  272. package/lib/org/policy.mjs +345 -0
  273. package/lib/org/policy.test.mjs +237 -0
  274. package/lib/org/protocol.checksum +1 -0
  275. package/lib/org/protocol.checksum.test.mjs +90 -0
  276. package/lib/org/protocol.mjs +967 -0
  277. package/lib/org/protocol.test.mjs +264 -0
  278. package/lib/org/registry.mjs +194 -0
  279. package/lib/org/registry.test.mjs +100 -0
  280. package/lib/org/tool-surface-integration.test.mjs +120 -0
  281. package/lib/org/tool-surface.mjs +2535 -0
  282. package/lib/org/tool-surface.test.mjs +589 -0
  283. package/lib/org/ui-parity.mjs +3236 -0
  284. package/lib/org/ui-parity.test.mjs +348 -0
  285. package/lib/org/verify.mjs +176 -0
  286. package/lib/org/verify.test.mjs +194 -0
  287. package/lib/rag/embed.mjs +188 -0
  288. package/lib/rag/indexer.mjs +425 -0
  289. package/lib/rag/rag.test.mjs +505 -0
  290. package/lib/rag/search.mjs +475 -0
  291. package/lib/rate-guard.mjs +246 -0
  292. package/lib/rate-guard.test.mjs +201 -0
  293. package/lib/render.mjs +112 -0
  294. package/lib/render.test.mjs +68 -0
  295. package/lib/resource-governor.mjs +297 -0
  296. package/lib/resource-governor.test.mjs +262 -0
  297. package/lib/scheduling/dynamic-jobs.mjs +675 -0
  298. package/lib/scheduling/dynamic-jobs.test.mjs +344 -0
  299. package/lib/scheduling/jitter.mjs +0 -0
  300. package/lib/scheduling/jitter.test.mjs +140 -0
  301. package/lib/secrets/broker.mjs +315 -0
  302. package/lib/secrets/broker.test.mjs +280 -0
  303. package/lib/secrets/providers.mjs +461 -0
  304. package/lib/secrets/providers.test.mjs +274 -0
  305. package/lib/security/audit-engine.mjs +684 -0
  306. package/lib/security/audit-engine.test.mjs +389 -0
  307. package/lib/security/coerce-args.mjs +552 -0
  308. package/lib/security/coerce-args.test.mjs +281 -0
  309. package/lib/security/dangerous-tools.mjs +97 -0
  310. package/lib/security/dangerous-tools.test.mjs +68 -0
  311. package/lib/security/external-content.mjs +145 -0
  312. package/lib/security/external-content.test.mjs +67 -0
  313. package/lib/security/redact.mjs +592 -0
  314. package/lib/security/redact.test.mjs +441 -0
  315. package/lib/security/secret-equal.mjs +73 -0
  316. package/lib/security/secret-equal.test.mjs +55 -0
  317. package/lib/session-permissions.mjs +101 -0
  318. package/lib/session-permissions.test.mjs +100 -0
  319. package/lib/setup/claude-probe.mjs +74 -0
  320. package/lib/setup/completeness.mjs +175 -0
  321. package/lib/setup/completeness.test.mjs +110 -0
  322. package/lib/setup/context-pack.mjs +173 -0
  323. package/lib/setup/context-pack.test.mjs +89 -0
  324. package/lib/setup/enrich.mjs +277 -0
  325. package/lib/setup/enrich.test.mjs +115 -0
  326. package/lib/setup/enroll-from-cohort.mjs +441 -0
  327. package/lib/setup/enroll-from-cohort.test.mjs +233 -0
  328. package/lib/setup/integration.test.mjs +162 -0
  329. package/lib/setup/io.mjs +360 -0
  330. package/lib/setup/io.test.mjs +77 -0
  331. package/lib/setup/run-generator.mjs +81 -0
  332. package/lib/setup/runner.mjs +244 -0
  333. package/lib/setup/runner.test.mjs +132 -0
  334. package/lib/setup/sections/comms.mjs +173 -0
  335. package/lib/setup/sections/company.mjs +120 -0
  336. package/lib/setup/sections/enrich.mjs +138 -0
  337. package/lib/setup/sections/identity.mjs +182 -0
  338. package/lib/setup/sections/identity.test.mjs +140 -0
  339. package/lib/setup/sections/learning.mjs +153 -0
  340. package/lib/setup/sections/learning.test.mjs +81 -0
  341. package/lib/setup/sections/messaging.mjs +219 -0
  342. package/lib/setup/sections/messaging.test.mjs +127 -0
  343. package/lib/setup/sections/model.mjs +102 -0
  344. package/lib/setup/sections/operating-model.mjs +78 -0
  345. package/lib/setup/sections/org.mjs +475 -0
  346. package/lib/setup/sections/org.test.mjs +313 -0
  347. package/lib/setup/sections/orgmail.mjs +173 -0
  348. package/lib/setup/sections/orgmail.test.mjs +118 -0
  349. package/lib/setup/sections/recovery.mjs +159 -0
  350. package/lib/setup/sections/recovery.test.mjs +98 -0
  351. package/lib/setup/sections/tools.mjs +132 -0
  352. package/lib/setup/sections/verify.mjs +97 -0
  353. package/lib/setup/sot.mjs +205 -0
  354. package/lib/setup/sot.test.mjs +81 -0
  355. package/lib/setup/state.mjs +151 -0
  356. package/lib/setup/state.test.mjs +92 -0
  357. package/lib/singleton.js +229 -0
  358. package/lib/singleton.test.mjs +135 -0
  359. package/lib/telemetry/alerts.mjs +216 -0
  360. package/lib/telemetry/alerts.test.mjs +109 -0
  361. package/lib/telemetry/collect.mjs +512 -0
  362. package/lib/telemetry/collect.test.mjs +202 -0
  363. package/lib/tool-definitions-integration.test.mjs +83 -0
  364. package/lib/tool-definitions.js +738 -0
  365. package/lib/tool-definitions.test.mjs +437 -0
  366. package/lib/util/fetch-timeout.mjs +136 -0
  367. package/lib/util/fetch-timeout.test.mjs +202 -0
  368. package/lib/util/reconnect.mjs +343 -0
  369. package/lib/util/reconnect.test.mjs +369 -0
  370. package/lib/util/unhandled.mjs +205 -0
  371. package/lib/util/unhandled.test.mjs +216 -0
  372. package/lib/voice/context-loader.mjs +466 -0
  373. package/lib/voice/index.mjs +100 -0
  374. package/lib/voice/openai-realtime.mjs +510 -0
  375. package/lib/voice/outbound.mjs +542 -0
  376. package/lib/voice/outbound.test.mjs +69 -0
  377. package/lib/voice/post-call-brief.mjs +428 -0
  378. package/lib/voice/provider.mjs +52 -0
  379. package/lib/voice/session-rotation.mjs +257 -0
  380. package/lib/voice/stt.mjs +161 -0
  381. package/lib/voice/stt.test.mjs +226 -0
  382. package/lib/voice/tool-bridge.mjs +370 -0
  383. package/lib/voice/tts.mjs +104 -0
  384. package/lib/voice/twilio-sip-bridge.mjs +288 -0
  385. package/lib/voice/voice.test.mjs +990 -0
  386. package/mcp/README.md +80 -0
  387. package/package.json +151 -0
  388. package/plugins/maestro-skills/plugin.json +139 -0
  389. package/plugins/maestro-skills/skills/agents-observe.md +110 -0
  390. package/plugins/maestro-skills/skills/board-deck.md +68 -0
  391. package/plugins/maestro-skills/skills/books-close.md +77 -0
  392. package/plugins/maestro-skills/skills/brand-steward.md +121 -0
  393. package/plugins/maestro-skills/skills/calendar-plan.md +57 -0
  394. package/plugins/maestro-skills/skills/call-working-sessions.md +124 -0
  395. package/plugins/maestro-skills/skills/ccxray-diagnostics.md +91 -0
  396. package/plugins/maestro-skills/skills/claude-pace.md +61 -0
  397. package/plugins/maestro-skills/skills/code-review-graph.md +99 -0
  398. package/plugins/maestro-skills/skills/crm-pipeline.md +65 -0
  399. package/plugins/maestro-skills/skills/decision-brief.md +89 -0
  400. package/plugins/maestro-skills/skills/directory-hygiene.md +125 -0
  401. package/plugins/maestro-skills/skills/draft-comms.md +84 -0
  402. package/plugins/maestro-skills/skills/evening-wrap.md +53 -0
  403. package/plugins/maestro-skills/skills/files-find.md +65 -0
  404. package/plugins/maestro-skills/skills/generative-ui.md +228 -0
  405. package/plugins/maestro-skills/skills/hiring-triage.md +74 -0
  406. package/plugins/maestro-skills/skills/inbox-triage.md +61 -0
  407. package/plugins/maestro-skills/skills/mail-triage.md +86 -0
  408. package/plugins/maestro-skills/skills/morning-brief.md +54 -0
  409. package/plugins/maestro-skills/skills/native-artifacts.md +157 -0
  410. package/plugins/maestro-skills/skills/org-board.md +133 -0
  411. package/plugins/maestro-skills/skills/org-credential.md +68 -0
  412. package/plugins/maestro-skills/skills/org-recall.md +81 -0
  413. package/plugins/maestro-skills/skills/pipeline-review.md +76 -0
  414. package/plugins/maestro-skills/skills/regulatory-status.md +81 -0
  415. package/plugins/maestro-skills/skills/router-why.md +78 -0
  416. package/plugins/maestro-skills/skills/schedule-meeting.md +91 -0
  417. package/plugins/maestro-skills/skills/session-search.md +71 -0
  418. package/plugins/maestro-skills/skills/set-reminder.md +93 -0
  419. package/plugins/maestro-skills/skills/slack-followup.md +64 -0
  420. package/plugins/maestro-skills/skills/team-activity.md +86 -0
  421. package/plugins/maestro-skills/skills/weekly-memo.md +70 -0
  422. package/policies/action-classification.yaml +114 -0
  423. package/policies/ai-disclosure.yaml +294 -0
  424. package/policies/communication-style.md +139 -0
  425. package/policies/information-barriers.yaml +118 -0
  426. package/policies/prompt-injection-defence.yaml +138 -0
  427. package/public/assets/icon-dark.png +0 -0
  428. package/public/assets/icon-dark.svg +9 -0
  429. package/public/assets/icon-light.svg +9 -0
  430. package/public/assets/logo-dark.svg +15 -0
  431. package/public/assets/logo-light.svg +15 -0
  432. package/scaffold/.mcp.json +7 -0
  433. package/scaffold/CLAUDE.md +368 -0
  434. package/scaffold/config/agent.json +55 -0
  435. package/scaffold/config/agent.ts +76 -0
  436. package/scaffold/config/agent.ts.example +89 -0
  437. package/scaffold/config/alerts.yaml +23 -0
  438. package/scaffold/config/allowlist.yaml.example +25 -0
  439. package/scaffold/config/caller-id-map.yaml +46 -0
  440. package/scaffold/config/collective.yaml +49 -0
  441. package/scaffold/config/company.json +20 -0
  442. package/scaffold/config/known-agents.json +6 -0
  443. package/scaffold/config/learning.yaml +55 -0
  444. package/scaffold/config/model-routing.yaml.example +104 -0
  445. package/scaffold/config/org.yaml +25 -0
  446. package/scaffold/config/orgmail.yaml.example +19 -0
  447. package/scaffold/config/recovery.yaml +72 -0
  448. package/scaffold/config/secrets.yaml +27 -0
  449. package/scaffold/config/slack.yaml.example +35 -0
  450. package/scaffold/config/telegram.yaml.example +38 -0
  451. package/scaffold/config/voice.yaml.example +89 -0
  452. package/scaffold/config/whatsapp.yaml.example +39 -0
  453. package/schedules/README.md +49 -0
  454. package/schedules/triggers/backlog-executor.md +102 -0
  455. package/schedules/triggers/brand-steward.md +72 -0
  456. package/schedules/triggers/daily-evening-wrap.md +159 -0
  457. package/schedules/triggers/daily-midday-sweep.md +58 -0
  458. package/schedules/triggers/daily-morning-brief.md +55 -0
  459. package/schedules/triggers/directory-hygiene.md +81 -0
  460. package/schedules/triggers/dynamic-jobs.md +40 -0
  461. package/schedules/triggers/inbox-processor.md +115 -0
  462. package/schedules/triggers/meeting-action-capture.md +60 -0
  463. package/schedules/triggers/meeting-prep.md +69 -0
  464. package/schedules/triggers/messaging-inbound.md +50 -0
  465. package/schedules/triggers/org-pulse.md +24 -0
  466. package/schedules/triggers/quarterly-self-assessment.md +54 -0
  467. package/schedules/triggers/weekly-engineering-health.md +37 -0
  468. package/schedules/triggers/weekly-execution.md +65 -0
  469. package/schedules/triggers/weekly-hiring.md +53 -0
  470. package/schedules/triggers/weekly-priorities.md +38 -0
  471. package/schedules/triggers/weekly-strategic-memo.md +124 -0
  472. package/scripts/archive-email.sh +55 -0
  473. package/scripts/cadence/cadence-status.mjs +36 -0
  474. package/scripts/cadence/enqueue-cadence-tick.mjs +174 -0
  475. package/scripts/cadence/enqueue-cadence-tick.test.mjs +187 -0
  476. package/scripts/cadence/launchd-cadence-wrapper.sh +85 -0
  477. package/scripts/cadence/launchd-cloud-relay-wrapper.sh +95 -0
  478. package/scripts/cadence/launchd-socket-mode-wrapper.sh +95 -0
  479. package/scripts/ci/check-docs-accuracy.mjs +493 -0
  480. package/scripts/ci/check-docs-accuracy.test.mjs +409 -0
  481. package/scripts/ci/check-exports-exist.mjs +140 -0
  482. package/scripts/ci/check-files-exist.mjs +107 -0
  483. package/scripts/ci/check-no-build-artifacts.mjs +111 -0
  484. package/scripts/ci/check-no-build-artifacts.test.mjs +71 -0
  485. package/scripts/ci/check-no-confidential.mjs +198 -0
  486. package/scripts/ci/check-no-conflict-markers.mjs +169 -0
  487. package/scripts/ci/check-no-residual-identity.mjs +163 -0
  488. package/scripts/ci/check-no-residual-identity.test.mjs +89 -0
  489. package/scripts/ci/check-tarball-fidelity.mjs +205 -0
  490. package/scripts/ci/check-unresolved-tokens.mjs +83 -0
  491. package/scripts/ci/check.mjs +109 -0
  492. package/scripts/ci/check.test.mjs +194 -0
  493. package/scripts/ci/run-coverage.mjs +82 -0
  494. package/scripts/ci/run-tests.mjs +71 -0
  495. package/scripts/cloud-relay/README.md +59 -0
  496. package/scripts/cloud-relay/index.mjs +233 -0
  497. package/scripts/cloud-relay/package.json +15 -0
  498. package/scripts/cloud-relay/railway.json +13 -0
  499. package/scripts/cloud-relay/voice/README.md +94 -0
  500. package/scripts/cloud-relay/voice/package-lock.json +39 -0
  501. package/scripts/cloud-relay/voice/package.json +16 -0
  502. package/scripts/cloud-relay/voice/railway.json +13 -0
  503. package/scripts/cloud-relay/voice/server.mjs +532 -0
  504. package/scripts/collective/hook-runner.mjs +211 -0
  505. package/scripts/collective/hook-runner.test.mjs +90 -0
  506. package/scripts/collective/org-pulse.mjs +72 -0
  507. package/scripts/collective/org-sync.mjs +61 -0
  508. package/scripts/collective/recall.mjs +45 -0
  509. package/scripts/collective/who.mjs +30 -0
  510. package/scripts/comms-monitor.sh +288 -0
  511. package/scripts/configure-whatsapp-sandbox.sh +201 -0
  512. package/scripts/continuous-monitor.sh +91 -0
  513. package/scripts/cost/fleet-digest.mjs +407 -0
  514. package/scripts/cost/fleet-digest.test.mjs +207 -0
  515. package/scripts/cost/track-claude-usage.mjs +169 -0
  516. package/scripts/daemon/agent-daemon.mjs +989 -0
  517. package/scripts/daemon/agent-daemon.test.mjs +525 -0
  518. package/scripts/daemon/cadence-consumer-governance.test.mjs +220 -0
  519. package/scripts/daemon/cadence-consumer.mjs +1080 -0
  520. package/scripts/daemon/cadence-consumer.test.mjs +770 -0
  521. package/scripts/daemon/cadence-handlers.mjs +1121 -0
  522. package/scripts/daemon/cadence-handlers.test.mjs +617 -0
  523. package/scripts/daemon/classifier.mjs +704 -0
  524. package/scripts/daemon/classifier.test.mjs +238 -0
  525. package/scripts/daemon/classify-kind.mjs +54 -0
  526. package/scripts/daemon/classify-kind.test.mjs +40 -0
  527. package/scripts/daemon/context-compiler.mjs +605 -0
  528. package/scripts/daemon/context-compiler.test.mjs +300 -0
  529. package/scripts/daemon/dispatcher-cooldown.test.mjs +122 -0
  530. package/scripts/daemon/dispatcher-governance.test.mjs +886 -0
  531. package/scripts/daemon/dispatcher.mjs +1516 -0
  532. package/scripts/daemon/health.mjs +72 -0
  533. package/scripts/daemon/inbox-deferral.mjs +210 -0
  534. package/scripts/daemon/inbox-deferral.test.mjs +242 -0
  535. package/scripts/daemon/integration.test.mjs +149 -0
  536. package/scripts/daemon/launchd-wrapper-generic.sh +96 -0
  537. package/scripts/daemon/launchd-wrapper-slack-events.sh +37 -0
  538. package/scripts/daemon/launchd-wrapper.sh +91 -0
  539. package/scripts/daemon/lib/session-router.mjs +274 -0
  540. package/scripts/daemon/lib/session-router.test.mjs +295 -0
  541. package/scripts/daemon/maestro-daemon.mjs +275 -0
  542. package/scripts/daemon/prompt-builder.mjs +685 -0
  543. package/scripts/daemon/prompt-builder.test.mjs +213 -0
  544. package/scripts/daemon/responder.mjs +854 -0
  545. package/scripts/daemon/session-lock.mjs +721 -0
  546. package/scripts/daemon/session-lock.test.mjs +252 -0
  547. package/scripts/daemon/session-outcomes.mjs +640 -0
  548. package/scripts/daemon/session-outcomes.test.mjs +533 -0
  549. package/scripts/daemon/typing-registry.mjs +90 -0
  550. package/scripts/daemon/typing-registry.test.mjs +77 -0
  551. package/scripts/daemon/voice-webhook-server.mjs +804 -0
  552. package/scripts/decisions/capture-decision.mjs +116 -0
  553. package/scripts/disclosure_assessment.py +873 -0
  554. package/scripts/disclosure_boundaries.py +562 -0
  555. package/scripts/email-signature-principal.html +52 -0
  556. package/scripts/email-signature.html +60 -0
  557. package/scripts/email_quote_thread.py +167 -0
  558. package/scripts/email_thread_dedup.py +362 -0
  559. package/scripts/emergency-stop.sh +81 -0
  560. package/scripts/healthcheck.sh +116 -0
  561. package/scripts/hooks/block-mcp-cohort-send.sh +15 -0
  562. package/scripts/hooks/block-mcp-slack-send.sh +7 -0
  563. package/scripts/hooks/post-action-log.sh +126 -0
  564. package/scripts/hooks/pre-send-audit.sh +174 -0
  565. package/scripts/hooks/pre-send-audit.test.mjs +215 -0
  566. package/scripts/hooks/session-end-log.sh +27 -0
  567. package/scripts/hooks/session-start-banner.sh +115 -0
  568. package/scripts/huddle/audio-bridge.mjs +664 -0
  569. package/scripts/huddle/boot-slack-cdp.sh +102 -0
  570. package/scripts/huddle/huddle-controller.mjs +942 -0
  571. package/scripts/huddle/huddle-server.mjs +1229 -0
  572. package/scripts/huddle/launch-slack.sh +232 -0
  573. package/scripts/huddle/openai-realtime-bridge.mjs +462 -0
  574. package/scripts/huddle/package-lock.json +62 -0
  575. package/scripts/huddle/package.json +22 -0
  576. package/scripts/huddle/setup-audio.sh +239 -0
  577. package/scripts/huddle/start-call.mjs +318 -0
  578. package/scripts/huddle/test-pipeline.mjs +263 -0
  579. package/scripts/learning/consolidate-skills.mjs +72 -0
  580. package/scripts/learning/session-search.mjs +125 -0
  581. package/scripts/llm_email_dedup.py +442 -0
  582. package/scripts/local-triggers/generate-plists.sh +432 -0
  583. package/scripts/local-triggers/generate-plists.test.mjs +413 -0
  584. package/scripts/local-triggers/install-all.sh +49 -0
  585. package/scripts/local-triggers/plists/.gitkeep +0 -0
  586. package/scripts/local-triggers/run-trigger.sh +63 -0
  587. package/scripts/local-triggers/templates/rag-reindex.plist.template +47 -0
  588. package/scripts/local-triggers/templates/voice-relay-poller.plist.template +54 -0
  589. package/scripts/local-triggers/templates/voice-tunnel.plist.template +55 -0
  590. package/scripts/local-triggers/templates/voice-webhook.plist.template +51 -0
  591. package/scripts/maintenance/backup-to-cloud.sh +124 -0
  592. package/scripts/maintenance/health-check.sh +377 -0
  593. package/scripts/media-generation/README.md +105 -0
  594. package/scripts/media-generation/gemini-image-client.mjs +173 -0
  595. package/scripts/media-generation/generate-assets.mjs +289 -0
  596. package/scripts/media-generation/veo-video-client.mjs +219 -0
  597. package/scripts/org/send-orgmail.mjs +227 -0
  598. package/scripts/outbound-dedup-cleanup.sh +43 -0
  599. package/scripts/outbound-dedup.sh +477 -0
  600. package/scripts/outbound_dedup.py +115 -0
  601. package/scripts/parse-voice-transcript.mjs +481 -0
  602. package/scripts/pdf-generation/README.md +63 -0
  603. package/scripts/pdf-generation/build-document.mjs +247 -0
  604. package/scripts/pdf-generation/templates/board-pack.latex +136 -0
  605. package/scripts/pdf-generation/templates/corporate-letter.latex +126 -0
  606. package/scripts/pdf-generation/templates/memo.latex +114 -0
  607. package/scripts/poll-slack-events.sh +35 -0
  608. package/scripts/poller/calendar-poller.mjs +12 -0
  609. package/scripts/poller/gmail-poller.mjs +192 -0
  610. package/scripts/poller/imap-client.mjs +289 -0
  611. package/scripts/poller/inbox-scan-poller.mjs +156 -0
  612. package/scripts/poller/inbox-scan-poller.test.mjs +231 -0
  613. package/scripts/poller/index.mjs +73 -0
  614. package/scripts/poller/intra-session-check.mjs +285 -0
  615. package/scripts/poller/lib/cloud-relay-dedup.mjs +88 -0
  616. package/scripts/poller/lib/cloud-relay-dedup.test.mjs +133 -0
  617. package/scripts/poller/lib/slash-command-handlers.mjs +177 -0
  618. package/scripts/poller/secondary-gmail-poller.mjs +132 -0
  619. package/scripts/poller/slack-cloud-relay-client.mjs +368 -0
  620. package/scripts/poller/slack-poller.mjs +854 -0
  621. package/scripts/poller/slack-socket-mode.mjs +917 -0
  622. package/scripts/poller/slack-socket-mode.test.mjs +753 -0
  623. package/scripts/poller/trigger.mjs +75 -0
  624. package/scripts/poller/utils.mjs +371 -0
  625. package/scripts/poller/voice-cloud-relay-client.mjs +179 -0
  626. package/scripts/poller/voice-poller.mjs +236 -0
  627. package/scripts/poller-launchd/install.sh +66 -0
  628. package/scripts/poller-launchd/poller.plist.template +40 -0
  629. package/scripts/poller-launchd/whatsapp-handler.plist.template +39 -0
  630. package/scripts/post-interaction-indexer.py +1598 -0
  631. package/scripts/pre-draft-context.py +994 -0
  632. package/scripts/pre_draft_lookup.py +258 -0
  633. package/scripts/rag/build-index.mjs +47 -0
  634. package/scripts/rag/ingest.mjs +111 -0
  635. package/scripts/rag/search.mjs +119 -0
  636. package/scripts/rag-indexer.py +629 -0
  637. package/scripts/restore-from-backup.sh +248 -0
  638. package/scripts/restore-from-backup.test.mjs +178 -0
  639. package/scripts/resume-operations.sh +80 -0
  640. package/scripts/search-secondary-inbox.py +181 -0
  641. package/scripts/secondary-inbox-poller.py +437 -0
  642. package/scripts/self-optimization/compute-metrics.py +398 -0
  643. package/scripts/send-email-as-principal.py +369 -0
  644. package/scripts/send-email-threaded.py +392 -0
  645. package/scripts/send-email-with-attachment.py +377 -0
  646. package/scripts/send-email.sh +131 -0
  647. package/scripts/send-sms.sh +175 -0
  648. package/scripts/send-whatsapp.sh +292 -0
  649. package/scripts/session-start.sh +106 -0
  650. package/scripts/setup/boot-claude-session.sh +94 -0
  651. package/scripts/setup/configure-macos.sh +674 -0
  652. package/scripts/setup/configure-twilio-sip-trunk.mjs +207 -0
  653. package/scripts/setup/configure-voice-tunnel.mjs +182 -0
  654. package/scripts/setup/generate-agent-env.mjs +92 -0
  655. package/scripts/setup/generate-agent-package-json.mjs +222 -0
  656. package/scripts/setup/generate-agent-package-json.test.mjs +143 -0
  657. package/scripts/setup/generate-autonomy.mjs +60 -0
  658. package/scripts/setup/generate-backlog.mjs +101 -0
  659. package/scripts/setup/generate-cadences.mjs +92 -0
  660. package/scripts/setup/generate-capability.mjs +162 -0
  661. package/scripts/setup/generate-charter.mjs +76 -0
  662. package/scripts/setup/generate-comms.mjs +59 -0
  663. package/scripts/setup/generate-company.mjs +92 -0
  664. package/scripts/setup/init-agent.sh +547 -0
  665. package/scripts/setup/init-agent.test.mjs +151 -0
  666. package/scripts/setup/init-archetype.mjs +74 -0
  667. package/scripts/setup/init-backup.mjs +54 -0
  668. package/scripts/setup/init-cadence-bus.mjs +60 -0
  669. package/scripts/setup/init-channel-bus.mjs +46 -0
  670. package/scripts/setup/init-cost-tracking.mjs +45 -0
  671. package/scripts/setup/init-decision-capture.mjs +66 -0
  672. package/scripts/setup/init-known-agents.mjs +57 -0
  673. package/scripts/setup/init-learning.mjs +70 -0
  674. package/scripts/setup/init-memory-executive.mjs +45 -0
  675. package/scripts/setup/init-model-router.mjs +124 -0
  676. package/scripts/setup/init-rag.mjs +174 -0
  677. package/scripts/setup/init-session-router.mjs +38 -0
  678. package/scripts/setup/init-slack-socket-mode.mjs +260 -0
  679. package/scripts/setup/init-telegram.mjs +165 -0
  680. package/scripts/setup/init-voice-realtime.mjs +204 -0
  681. package/scripts/setup/init-whatsapp-baileys.mjs +77 -0
  682. package/scripts/setup/install-dev-tools.sh +150 -0
  683. package/scripts/setup/lib/install-plist.mjs +95 -0
  684. package/scripts/setup/migrate-agent-to-sot.mjs +192 -0
  685. package/scripts/setup/render-environment-yaml.mjs +133 -0
  686. package/scripts/slack-events-ctl.sh +177 -0
  687. package/scripts/slack-events-server.mjs +1045 -0
  688. package/scripts/slack-react.mjs +89 -0
  689. package/scripts/slack-responded.sh +232 -0
  690. package/scripts/slack-send.sh +287 -0
  691. package/scripts/slack-typing.mjs +196 -0
  692. package/scripts/slack-upload-v2.py +95 -0
  693. package/scripts/sms-handler.mjs +450 -0
  694. package/scripts/spawn-session.sh +120 -0
  695. package/scripts/sync-protocol.mjs +217 -0
  696. package/scripts/system-verify.sh +184 -0
  697. package/scripts/test-email-thread-dedup.py +239 -0
  698. package/scripts/test-information-barriers.py +484 -0
  699. package/scripts/test-llm-email-dedup.py +251 -0
  700. package/scripts/test-pre-draft-integration.py +203 -0
  701. package/scripts/test-rag-phase2.sh +442 -0
  702. package/scripts/test-rag-search.sh +251 -0
  703. package/scripts/test-voice-parser.mjs +316 -0
  704. package/scripts/user-context-search.py +659 -0
  705. package/scripts/validate_outbound.py +1504 -0
  706. package/scripts/watchdog/ai.maestro.memory-watchdog.plist +41 -0
  707. package/scripts/watchdog/force-reboot.sh +157 -0
  708. package/scripts/watchdog/memory-watchdog.sh +473 -0
  709. package/scripts/whatsapp-handler.mjs +538 -0
  710. package/teams/desktop-operations.yaml +34 -0
  711. package/teams/executive-office.yaml +27 -0
  712. package/teams/legal-and-regulatory.yaml +24 -0
  713. package/teams/platform-and-engineering.yaml +23 -0
  714. package/teams/strategy-and-growth.yaml +29 -0
  715. package/workflows/continuous/backlog-executor.yaml +141 -0
  716. package/workflows/continuous/inbound-monitor.yaml +168 -0
  717. package/workflows/daily/applicant-triage.yaml +197 -0
  718. package/workflows/daily/comms-triage.yaml +80 -0
  719. package/workflows/daily/evening-wrap.yaml +105 -0
  720. package/workflows/daily/morning-brief.yaml +164 -0
  721. package/workflows/daily/slack-followup-sweep.yaml +87 -0
  722. package/workflows/event-driven/README.md +50 -0
  723. package/workflows/event-driven/agent-failure-investigation.yaml +137 -0
  724. package/workflows/event-driven/pr-review.yaml +107 -0
  725. package/workflows/monthly/board-readiness.yaml +76 -0
  726. package/workflows/quarterly/strategic-scenario-analysis.yaml +85 -0
  727. package/workflows/session-protocol.md +171 -0
  728. package/workflows/weekly/engineering-health.yaml +154 -0
  729. package/workflows/weekly/hiring-review.yaml +169 -0
  730. package/workflows/weekly/rollup-pipeline-review.yaml +76 -0
  731. package/workflows/weekly/strategic-memo.yaml +79 -0
@@ -0,0 +1,873 @@
1
+ #!/usr/bin/env python3
2
+ """Post-Draft Disclosure Assessment Engine — Layer 2 hard gate.
3
+
4
+ Analyses a draft message against recipient information boundaries and
5
+ provenance data to determine whether it is safe to send.
6
+
7
+ Usage (CLI):
8
+ echo "Let's discuss the PartnerCo operations schedule" | \\
9
+ python3 scripts/disclosure_assessment.py --recipient casey-morgan
10
+
11
+ python3 scripts/disclosure_assessment.py \\
12
+ --recipient casey-morgan --channel strategic-initiative \\
13
+ --message "The JVA negotiation is progressing well"
14
+
15
+ Exit codes:
16
+ 0 = passed or warn (safe to send, possibly with a warning)
17
+ 1 = stripped (send but strip / redact the flagged content)
18
+ 2 = blocked (do not send)
19
+
20
+ Output: JSON to stdout.
21
+
22
+ Created: 2026-04-05 — Task 7, Layer 2 disclosure assessment system
23
+ """
24
+
25
+ import argparse
26
+ import hashlib
27
+ import json
28
+ import os
29
+ import sys
30
+ import time
31
+ import uuid
32
+ from datetime import datetime, timezone, timedelta
33
+ from pathlib import Path
34
+
35
+ # ─── Paths ────────────────────────────────────────────────────────────────────
36
+ SCRIPT_DIR = Path(__file__).resolve().parent
37
+ REPO_ROOT = SCRIPT_DIR.parent
38
+ USER_PROFILES_DIR = REPO_ROOT / "memory" / "profiles" / "users"
39
+ CHANNEL_PROFILES_DIR = REPO_ROOT / "memory" / "profiles" / "channels"
40
+ PROVENANCE_INDEX = REPO_ROOT / "memory" / "provenance" / "index.jsonl"
41
+ AUDIT_LOG_DIR = REPO_ROOT / "logs" / "audit"
42
+
43
+ # ─── Constants ────────────────────────────────────────────────────────────────
44
+ SEVERITY_ORDER = {"low": 0, "medium": 1, "high": 2, "critical": 3}
45
+ SEVERITY_ACTIONS = {
46
+ "critical": "block_and_escalate",
47
+ "high": "block_and_flag",
48
+ "medium": "strip_and_send",
49
+ "low": "warn_and_send",
50
+ }
51
+
52
+ # Words that provide no discriminating signal when extracted from matter/source_id
53
+ _COMMON_WORDS = frozenset({
54
+ "2024", "2025", "2026", "2027",
55
+ "meeting", "email", "slack", "call", "chat",
56
+ "the", "and", "for", "with", "from", "into", "onto",
57
+ "a", "an", "of", "in", "on", "at", "by", "to",
58
+ "review", "update", "notes", "summary", "discussion",
59
+ })
60
+ _MIN_KEYWORD_LEN = 3
61
+
62
+
63
+ # ─── YAML Parser ──────────────────────────────────────────────────────────────
64
+ # Reuse the same minimal parser pattern from disclosure_boundaries.py.
65
+ # Try to import it; fall back to a local copy so there is no hard dependency.
66
+
67
+ try:
68
+ from disclosure_boundaries import (
69
+ _parse_yaml,
70
+ load_profile as _load_profile_from_boundaries,
71
+ load_recent_provenance as _load_provenance_from_boundaries,
72
+ )
73
+ _have_boundaries_module = True
74
+ except ImportError:
75
+ _have_boundaries_module = False
76
+
77
+
78
+ def _parse_yaml(text: str) -> dict:
79
+ """Parse YAML text into a Python dict. PyYAML if available, else custom."""
80
+ try:
81
+ import yaml as _yaml
82
+ return _yaml.safe_load(text) or {}
83
+ except ImportError:
84
+ pass
85
+
86
+ lines = text.split("\n")
87
+ result, _ = _parse_block(lines, 0, 0)
88
+ return result if isinstance(result, dict) else {}
89
+
90
+
91
+ def _next_content(lines, start):
92
+ i = start
93
+ while i < len(lines):
94
+ s = lines[i].lstrip()
95
+ if s and not s.startswith("#"):
96
+ return len(lines[i]) - len(lines[i].lstrip()), i
97
+ i += 1
98
+ return None, None
99
+
100
+
101
+ def _parse_block(lines, start, base_indent):
102
+ result = {}
103
+ i = start
104
+ while i < len(lines):
105
+ raw = lines[i]
106
+ stripped = raw.lstrip()
107
+ if not stripped or stripped.startswith("#"):
108
+ i += 1
109
+ continue
110
+ indent = len(raw) - len(raw.lstrip())
111
+ if indent < base_indent and i > start:
112
+ break
113
+ if indent > base_indent and i > start:
114
+ break
115
+ if stripped.startswith("- "):
116
+ if not result:
117
+ lst, i = _parse_list(lines, i, indent)
118
+ return lst, i
119
+ break
120
+ if ":" in stripped and not stripped.startswith("-"):
121
+ colon_pos = stripped.index(":")
122
+ key = stripped[:colon_pos].strip()
123
+ rest = stripped[colon_pos + 1:].strip()
124
+ if rest.startswith("["):
125
+ result[key] = _parse_inline_list(rest)
126
+ i += 1
127
+ continue
128
+ if rest in (">", "|", ">-", "|-", ">+", "|+"):
129
+ value, i = _parse_block_scalar(lines, i + 1, indent)
130
+ result[key] = value
131
+ continue
132
+ if rest:
133
+ result[key] = _yaml_scalar(rest)
134
+ i += 1
135
+ else:
136
+ child_indent, child_idx = _next_content(lines, i + 1)
137
+ if child_indent is not None and child_indent > indent:
138
+ child_stripped = lines[child_idx].lstrip()
139
+ if child_stripped.startswith("- "):
140
+ value, i = _parse_list(lines, child_idx, child_indent)
141
+ elif child_stripped.startswith("["):
142
+ value = _parse_inline_list(child_stripped)
143
+ i = child_idx + 1
144
+ else:
145
+ value, i = _parse_block(lines, child_idx, child_indent)
146
+ result[key] = value
147
+ else:
148
+ result[key] = None
149
+ i += 1
150
+ else:
151
+ i += 1
152
+ return result, i
153
+
154
+
155
+ def _parse_list(lines, start, base_indent):
156
+ result = []
157
+ i = start
158
+ while i < len(lines):
159
+ raw = lines[i]
160
+ stripped = raw.lstrip()
161
+ if not stripped or stripped.startswith("#"):
162
+ i += 1
163
+ continue
164
+ indent = len(raw) - len(raw.lstrip())
165
+ if indent < base_indent:
166
+ break
167
+ if indent > base_indent:
168
+ break
169
+ if not stripped.startswith("- "):
170
+ break
171
+ item_content = stripped[2:].strip()
172
+ if item_content.startswith("["):
173
+ result.append(_parse_inline_list(item_content))
174
+ i += 1
175
+ continue
176
+ if item_content in (">", "|", ">-", "|-", ">+", "|+"):
177
+ value, i = _parse_block_scalar(lines, i + 1, indent)
178
+ result.append(value)
179
+ continue
180
+ if ":" in item_content and not item_content.startswith('"') and not item_content.startswith("'"):
181
+ colon_pos = item_content.index(":")
182
+ k = item_content[:colon_pos].strip()
183
+ v = item_content[colon_pos + 1:].strip()
184
+ item_dict = {k: _parse_inline_list(v) if v.startswith("[") else (_yaml_scalar(v) if v else None)}
185
+ child_indent, child_idx = _next_content(lines, i + 1)
186
+ if child_indent is not None and child_indent > indent:
187
+ child_stripped = lines[child_idx].lstrip()
188
+ if child_stripped.startswith("- "):
189
+ i += 1
190
+ else:
191
+ extra, i = _parse_block(lines, child_idx, child_indent)
192
+ if isinstance(extra, dict):
193
+ item_dict.update(extra)
194
+ else:
195
+ i += 1
196
+ result.append(item_dict)
197
+ else:
198
+ result.append(_yaml_scalar(item_content))
199
+ i += 1
200
+ return result, i
201
+
202
+
203
+ def _parse_block_scalar(lines, start, owner_indent):
204
+ parts = []
205
+ i = start
206
+ while i < len(lines):
207
+ raw = lines[i]
208
+ stripped = raw.lstrip()
209
+ if not stripped:
210
+ i += 1
211
+ continue
212
+ indent = len(raw) - len(raw.lstrip())
213
+ if indent <= owner_indent:
214
+ break
215
+ parts.append(stripped)
216
+ i += 1
217
+ return " ".join(parts), i
218
+
219
+
220
+ def _parse_inline_list(text: str):
221
+ text = text.strip()
222
+ if not (text.startswith("[") and text.endswith("]")):
223
+ return [_yaml_scalar(text)] if text else []
224
+ inner = text[1:-1].strip()
225
+ if not inner:
226
+ return []
227
+ items = []
228
+ current = ""
229
+ in_quote = None
230
+ for ch in inner:
231
+ if ch in ('"', "'") and in_quote is None:
232
+ in_quote = ch
233
+ elif ch == in_quote:
234
+ in_quote = None
235
+ elif ch == "," and in_quote is None:
236
+ items.append(_yaml_scalar(current.strip()))
237
+ current = ""
238
+ continue
239
+ current += ch
240
+ if current.strip():
241
+ items.append(_yaml_scalar(current.strip()))
242
+ return items
243
+
244
+
245
+ def _yaml_scalar(text: str):
246
+ if text is None:
247
+ return None
248
+ text = text.strip()
249
+ if not text:
250
+ return None
251
+ if (text.startswith('"') and text.endswith('"')) or \
252
+ (text.startswith("'") and text.endswith("'")):
253
+ return text[1:-1]
254
+ for sep in (" #", "\t#"):
255
+ if sep in text:
256
+ text = text[:text.index(sep)].strip()
257
+ if text.lower() in ("true", "yes"):
258
+ return True
259
+ if text.lower() in ("false", "no"):
260
+ return False
261
+ if text.lower() in ("null", "~"):
262
+ return None
263
+ try:
264
+ return int(text)
265
+ except ValueError:
266
+ pass
267
+ try:
268
+ return float(text)
269
+ except ValueError:
270
+ pass
271
+ return text
272
+
273
+
274
+ # ─── Profile Loading ──────────────────────────────────────────────────────────
275
+
276
+ def _load_profile(slug: str) -> dict | None:
277
+ """Load a user profile YAML. Returns None if not found."""
278
+ path = USER_PROFILES_DIR / f"{slug}.yaml"
279
+ if not path.exists():
280
+ return None
281
+ return _parse_yaml(path.read_text(encoding="utf-8"))
282
+
283
+
284
+ def _load_channel_members(channel: str) -> list[str]:
285
+ """Resolve channel slug/id → member slug list.
286
+
287
+ Accepts a Slack channel ID (C...) or a profile name slug.
288
+ Returns list of member slugs, empty list if channel not found.
289
+ """
290
+ if not channel:
291
+ return []
292
+
293
+ # Search all channel profile files
294
+ for profile_path in CHANNEL_PROFILES_DIR.glob("*.yaml"):
295
+ try:
296
+ data = _parse_yaml(profile_path.read_text(encoding="utf-8"))
297
+ except Exception:
298
+ continue
299
+ slack_id = str(data.get("slack_id") or "")
300
+ name = str(data.get("name") or profile_path.stem)
301
+ if channel in (slack_id, name, profile_path.stem):
302
+ members = data.get("members") or []
303
+ return [str(m) for m in members if m]
304
+
305
+ return []
306
+
307
+
308
+ def _load_provenance_recent(hours: int = 48) -> list[dict]:
309
+ """Load provenance records from the last *hours* hours."""
310
+ if not PROVENANCE_INDEX.exists():
311
+ return []
312
+
313
+ cutoff = datetime.now(tz=timezone.utc) - timedelta(hours=hours)
314
+ records = []
315
+
316
+ with PROVENANCE_INDEX.open(encoding="utf-8") as fh:
317
+ for line in fh:
318
+ line = line.strip()
319
+ if not line:
320
+ continue
321
+ try:
322
+ rec = json.loads(line)
323
+ except json.JSONDecodeError:
324
+ continue
325
+
326
+ ts_raw = rec.get("timestamp", "")
327
+ try:
328
+ ts_str = ts_raw.replace("Z", "+00:00")
329
+ ts = datetime.fromisoformat(ts_str)
330
+ if ts.tzinfo is None:
331
+ ts = ts.replace(tzinfo=timezone.utc)
332
+ except (ValueError, AttributeError):
333
+ records.append(rec)
334
+ continue
335
+
336
+ if ts >= cutoff:
337
+ records.append(rec)
338
+
339
+ return records
340
+
341
+
342
+ def _log_assessment(
343
+ *,
344
+ message_id: str,
345
+ channel: str | None,
346
+ recipients: list[str],
347
+ restricted_recipients: list[str],
348
+ references_assessed: list[dict],
349
+ outcome: str,
350
+ severity: str | None,
351
+ action: str,
352
+ draft_hash: str,
353
+ assessment_duration_ms: float,
354
+ ) -> None:
355
+ """Append one line to the dated disclosure-assessments audit log."""
356
+ AUDIT_LOG_DIR.mkdir(parents=True, exist_ok=True)
357
+ date_str = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
358
+ log_path = AUDIT_LOG_DIR / f"{date_str}-disclosure-assessments.jsonl"
359
+
360
+ record = {
361
+ "timestamp": datetime.now(tz=timezone.utc).isoformat(),
362
+ "message_id": message_id,
363
+ "channel": channel,
364
+ "recipients": recipients,
365
+ "restricted_recipients": restricted_recipients,
366
+ "references_assessed": references_assessed,
367
+ "outcome": outcome,
368
+ "severity": severity,
369
+ "action": action,
370
+ "draft_hash": draft_hash,
371
+ "assessment_duration_ms": round(assessment_duration_ms, 2),
372
+ }
373
+
374
+ with log_path.open("a", encoding="utf-8") as fh:
375
+ fh.write(json.dumps(record) + "\n")
376
+
377
+
378
+ # ─── Reference Extraction ─────────────────────────────────────────────────────
379
+
380
+ def _extract_keywords(text: str) -> set[str]:
381
+ """Extract discriminating keywords from a provenance matter/source_id."""
382
+ if not text:
383
+ return set()
384
+ # Split on hyphens, underscores, slashes, and whitespace
385
+ parts = [p.strip().lower() for p in __import__("re").split(r"[-_/\s]+", text)]
386
+ return {
387
+ p for p in parts
388
+ if p and len(p) >= _MIN_KEYWORD_LEN and p not in _COMMON_WORDS
389
+ }
390
+
391
+
392
+ def _extract_matter_references(draft: str, provenance_records: list[dict]) -> list[dict]:
393
+ """Find provenance records whose keywords appear in the draft text.
394
+
395
+ Returns a list of match dicts:
396
+ {
397
+ "record": <provenance record dict>,
398
+ "matched_keywords": [str, ...],
399
+ "match_confidence": "high" | "medium",
400
+ }
401
+ """
402
+ if not draft.strip():
403
+ return []
404
+
405
+ draft_lower = draft.lower()
406
+ matches = []
407
+
408
+ for rec in provenance_records:
409
+ matter = str(rec.get("matter") or "")
410
+ source_id = str(rec.get("source_id") or "")
411
+
412
+ keywords = _extract_keywords(matter) | _extract_keywords(source_id)
413
+ if not keywords:
414
+ continue
415
+
416
+ matched = [kw for kw in keywords if kw in draft_lower]
417
+ if not matched:
418
+ continue
419
+
420
+ confidence = "high" if len(matched) >= 2 else "medium"
421
+ matches.append({
422
+ "record": rec,
423
+ "matched_keywords": matched,
424
+ "match_confidence": confidence,
425
+ })
426
+
427
+ return matches
428
+
429
+
430
+ # ─── Per-Reference Assessment ─────────────────────────────────────────────────
431
+
432
+ def _assess_reference(
433
+ ref_match: dict,
434
+ recipient_slug: str,
435
+ boundaries: dict,
436
+ ) -> dict:
437
+ """Assess a single matched provenance reference against a recipient's boundaries.
438
+
439
+ Returns:
440
+ {
441
+ "source_id": str,
442
+ "matter": str,
443
+ "match_confidence": str,
444
+ "matched_keywords": list,
445
+ "checks": [{"check": str, "result": str, "severity": str | None, "reason": str}],
446
+ "highest_severity": str | None,
447
+ }
448
+ """
449
+ rec = ref_match["record"]
450
+ match_confidence = ref_match["match_confidence"]
451
+ matched_keywords = ref_match["matched_keywords"]
452
+
453
+ source_id = str(rec.get("source_id") or "unknown")
454
+ matter = str(rec.get("matter") or "")
455
+ sensitivity_signals = [str(s).lower() for s in (rec.get("sensitivity_signals") or [])]
456
+ participants = [str(p).lower() for p in (rec.get("participants") or [])]
457
+ context_domain = str(rec.get("context_domain") or "").lower()
458
+
459
+ conflicts = boundaries.get("conflicts") or []
460
+ excluded_domains = [str(d).lower() for d in (boundaries.get("excluded_domains") or [])]
461
+
462
+ # Build conflict matter set
463
+ conflict_matters = set()
464
+ for c in conflicts:
465
+ if isinstance(c, dict):
466
+ m = c.get("matter")
467
+ if m:
468
+ conflict_matters.add(str(m).lower())
469
+ elif isinstance(c, str):
470
+ conflict_matters.add(c.lower())
471
+
472
+ recipient_lower = recipient_slug.lower()
473
+ checks = []
474
+ highest_severity = None
475
+
476
+ # ── 1. Privilege check ──────────────────────────────────────────────────
477
+ has_privilege = any(s in ("legal_privilege", "negotiation_position") for s in sensitivity_signals)
478
+ if has_privilege:
479
+ priv_signals = [s for s in sensitivity_signals if s in ("legal_privilege", "negotiation_position")]
480
+ checks.append({
481
+ "check": "privilege",
482
+ "result": "FAIL",
483
+ "severity": "critical",
484
+ "reason": f"Provenance carries privileged sensitivity signals: {', '.join(priv_signals)}",
485
+ })
486
+ highest_severity = _escalate_severity(highest_severity, "critical")
487
+ else:
488
+ checks.append({
489
+ "check": "privilege",
490
+ "result": "PASS",
491
+ "severity": None,
492
+ "reason": "No privilege signals detected",
493
+ })
494
+
495
+ # ── 2. Conflict check ───────────────────────────────────────────────────
496
+ matter_lower = matter.lower()
497
+ is_conflict_matter = bool(matter_lower and matter_lower in conflict_matters)
498
+ if is_conflict_matter:
499
+ # Critical if also has privilege, otherwise high
500
+ conflict_sev = "critical" if has_privilege else "high"
501
+ # Find nature note
502
+ nature = ""
503
+ for c in conflicts:
504
+ if isinstance(c, dict) and str(c.get("matter", "")).lower() == matter_lower:
505
+ nature = c.get("nature", "")
506
+ break
507
+ checks.append({
508
+ "check": "conflict",
509
+ "result": "FAIL",
510
+ "severity": conflict_sev,
511
+ "reason": f"Matter '{matter}' is a declared conflict for this recipient"
512
+ + (f" — {nature}" if nature else ""),
513
+ })
514
+ highest_severity = _escalate_severity(highest_severity, conflict_sev)
515
+ else:
516
+ checks.append({
517
+ "check": "conflict",
518
+ "result": "PASS",
519
+ "severity": None,
520
+ "reason": "No declared conflict on this matter",
521
+ })
522
+
523
+ # ── 3. Participant check ────────────────────────────────────────────────
524
+ is_participant = recipient_lower in participants
525
+ if is_participant:
526
+ checks.append({
527
+ "check": "participant",
528
+ "result": "PASS",
529
+ "severity": None,
530
+ "reason": f"Recipient is listed as a participant in this provenance record",
531
+ })
532
+ # Participant check passing does NOT affect highest_severity
533
+ else:
534
+ checks.append({
535
+ "check": "participant",
536
+ "result": "FAIL",
537
+ "severity": "medium",
538
+ "reason": f"Recipient '{recipient_slug}' is not a participant in provenance record '{source_id}'",
539
+ })
540
+ highest_severity = _escalate_severity(highest_severity, "medium")
541
+
542
+ # ── 4. Domain check ─────────────────────────────────────────────────────
543
+ domain_excluded = bool(context_domain and context_domain in excluded_domains)
544
+ if domain_excluded:
545
+ # High if also not a participant, medium if alone
546
+ domain_sev = "high" if not is_participant else "medium"
547
+ checks.append({
548
+ "check": "domain",
549
+ "result": "FAIL",
550
+ "severity": domain_sev,
551
+ "reason": f"Context domain '{context_domain}' is in recipient's excluded domains",
552
+ })
553
+ highest_severity = _escalate_severity(highest_severity, domain_sev)
554
+ else:
555
+ checks.append({
556
+ "check": "domain",
557
+ "result": "PASS",
558
+ "severity": None,
559
+ "reason": "Context domain not in excluded domains"
560
+ if context_domain
561
+ else "No context domain on provenance record",
562
+ })
563
+
564
+ # ── 5. Necessity check (contextual — LLM layer) ─────────────────────────
565
+ checks.append({
566
+ "check": "necessity",
567
+ "result": "REVIEW",
568
+ "severity": None,
569
+ "reason": "Contextual necessity judgment deferred to LLM layer",
570
+ })
571
+
572
+ # ── Low confidence escalation ───────────────────────────────────────────
573
+ if match_confidence == "medium" and highest_severity is not None:
574
+ before = highest_severity
575
+ escalated = _escalate_one_level(highest_severity)
576
+ highest_severity = escalated
577
+ # Annotate in checks
578
+ checks.append({
579
+ "check": "confidence_escalation",
580
+ "result": "ESCALATED",
581
+ "severity": escalated,
582
+ "reason": f"Match confidence is 'medium' — severity escalated from '{before}' to '{escalated}'",
583
+ })
584
+
585
+ return {
586
+ "source_id": source_id,
587
+ "matter": matter,
588
+ "match_confidence": match_confidence,
589
+ "matched_keywords": matched_keywords,
590
+ "checks": checks,
591
+ "highest_severity": highest_severity,
592
+ }
593
+
594
+
595
+ def _escalate_severity(current: str | None, candidate: str) -> str:
596
+ """Return whichever severity is higher."""
597
+ if current is None:
598
+ return candidate
599
+ return candidate if SEVERITY_ORDER.get(candidate, -1) > SEVERITY_ORDER.get(current, -1) else current
600
+
601
+
602
+ def _escalate_one_level(severity: str) -> str:
603
+ """Escalate a severity by one step (low→medium, medium→high, high→critical)."""
604
+ order = ["low", "medium", "high", "critical"]
605
+ idx = order.index(severity) if severity in order else -1
606
+ if idx < 0 or idx >= len(order) - 1:
607
+ return severity
608
+ return order[idx + 1]
609
+
610
+
611
+ def _severity_to_outcome(severity: str | None) -> str:
612
+ """Map highest severity to overall outcome."""
613
+ if severity is None:
614
+ return "passed"
615
+ level = SEVERITY_ORDER.get(severity, -1)
616
+ if level >= SEVERITY_ORDER["high"]:
617
+ return "blocked"
618
+ if level == SEVERITY_ORDER["medium"]:
619
+ return "stripped"
620
+ return "passed"
621
+
622
+
623
+ # ─── Main Assessment ──────────────────────────────────────────────────────────
624
+
625
+ def assess_disclosure(
626
+ draft: str,
627
+ recipient_slug: str,
628
+ channel: str | None = None,
629
+ message_id: str | None = None,
630
+ ) -> dict:
631
+ """Assess a draft message against recipient disclosure boundaries.
632
+
633
+ Args:
634
+ draft: Full text of the draft message.
635
+ recipient_slug: Slug of the primary recipient (e.g. 'casey-morgan').
636
+ channel: Optional Slack channel slug or ID. When provided the
637
+ channel's full member list is resolved and each restricted
638
+ member is assessed independently. The most restrictive
639
+ outcome wins.
640
+ message_id: Optional caller-supplied message identifier. Auto-
641
+ generated (UUID4) if not provided.
642
+
643
+ Returns a dict with keys:
644
+ outcome "passed" | "stripped" | "blocked"
645
+ severity None | "low" | "medium" | "high" | "critical"
646
+ action str describing the recommended action
647
+ references_assessed list of per-reference assessment dicts
648
+ assessment_duration_ms float
649
+ """
650
+ t_start = time.monotonic()
651
+
652
+ if message_id is None:
653
+ message_id = str(uuid.uuid4())
654
+
655
+ # ── Determine full recipient list ────────────────────────────────────────
656
+ all_recipients: list[str] = [recipient_slug]
657
+ if channel:
658
+ members = _load_channel_members(channel)
659
+ for m in members:
660
+ if m not in all_recipients:
661
+ all_recipients.append(m)
662
+
663
+ # ── Separate unrestricted vs restricted recipients ───────────────────────
664
+ restricted_recipients: list[str] = []
665
+ unprofiled_recipients: list[str] = []
666
+
667
+ for slug in all_recipients:
668
+ profile = _load_profile(slug)
669
+ if profile is None:
670
+ unprofiled_recipients.append(slug)
671
+ restricted_recipients.append(slug)
672
+ print(
673
+ f"[disclosure_assessment] WARNING: No profile for recipient '{slug}' "
674
+ "— treating as maximally restricted.",
675
+ file=sys.stderr,
676
+ )
677
+ continue
678
+
679
+ boundaries = profile.get("information_boundaries") or {}
680
+ domains_of_interest = boundaries.get("domains_of_interest") or []
681
+
682
+ # Unrestricted: ["*"]
683
+ if domains_of_interest == ["*"]:
684
+ continue
685
+
686
+ restricted_recipients.append(slug)
687
+
688
+ # ── Short-circuit: no restricted recipients ──────────────────────────────
689
+ if not restricted_recipients:
690
+ duration_ms = (time.monotonic() - t_start) * 1000
691
+ _log_assessment(
692
+ message_id=message_id,
693
+ channel=channel,
694
+ recipients=all_recipients,
695
+ restricted_recipients=[],
696
+ references_assessed=[],
697
+ outcome="passed",
698
+ severity=None,
699
+ action="no_restricted_recipients",
700
+ draft_hash=hashlib.sha256(draft.encode()).hexdigest()[:16],
701
+ assessment_duration_ms=duration_ms,
702
+ )
703
+ return {
704
+ "outcome": "passed",
705
+ "severity": None,
706
+ "action": "no_restricted_recipients",
707
+ "references_assessed": [],
708
+ "assessment_duration_ms": round(duration_ms, 2),
709
+ }
710
+
711
+ # ── Short-circuit: empty draft ───────────────────────────────────────────
712
+ if not draft.strip():
713
+ duration_ms = (time.monotonic() - t_start) * 1000
714
+ _log_assessment(
715
+ message_id=message_id,
716
+ channel=channel,
717
+ recipients=all_recipients,
718
+ restricted_recipients=restricted_recipients,
719
+ references_assessed=[],
720
+ outcome="passed",
721
+ severity=None,
722
+ action="empty_draft",
723
+ draft_hash="",
724
+ assessment_duration_ms=duration_ms,
725
+ )
726
+ return {
727
+ "outcome": "passed",
728
+ "severity": None,
729
+ "action": "empty_draft",
730
+ "references_assessed": [],
731
+ "assessment_duration_ms": round(duration_ms, 2),
732
+ }
733
+
734
+ # ── Load provenance records ──────────────────────────────────────────────
735
+ provenance_records = _load_provenance_recent(hours=48)
736
+
737
+ # ── Extract draft references against provenance ──────────────────────────
738
+ ref_matches = _extract_matter_references(draft, provenance_records)
739
+
740
+ # ── Assess each reference against each restricted recipient ──────────────
741
+ all_ref_assessments: list[dict] = []
742
+ overall_severity: str | None = None
743
+
744
+ for ref_match in ref_matches:
745
+ # Per-recipient assessments for this reference
746
+ per_recipient: list[dict] = []
747
+
748
+ for slug in restricted_recipients:
749
+ if slug in unprofiled_recipients:
750
+ # Maximally restricted — any provenance match → block
751
+ per_recipient.append({
752
+ "recipient": slug,
753
+ "highest_severity": "critical",
754
+ "checks": [{
755
+ "check": "unprofiled_recipient",
756
+ "result": "FAIL",
757
+ "severity": "critical",
758
+ "reason": f"No profile exists for recipient '{slug}' — maximally restricted",
759
+ }],
760
+ })
761
+ overall_severity = _escalate_severity(overall_severity, "critical")
762
+ continue
763
+
764
+ profile = _load_profile(slug)
765
+ boundaries = (profile or {}).get("information_boundaries") or {}
766
+ ref_result = _assess_reference(ref_match, slug, boundaries)
767
+ ref_result["recipient"] = slug
768
+ per_recipient.append(ref_result)
769
+
770
+ if ref_result["highest_severity"] is not None:
771
+ overall_severity = _escalate_severity(overall_severity, ref_result["highest_severity"])
772
+
773
+ # Collapse per-recipient into a single reference assessment entry
774
+ combined_severity = None
775
+ for pr in per_recipient:
776
+ sev = pr.get("highest_severity")
777
+ if sev is not None:
778
+ combined_severity = _escalate_severity(combined_severity, sev)
779
+
780
+ all_ref_assessments.append({
781
+ "source_id": ref_match["record"].get("source_id", "unknown"),
782
+ "matter": ref_match["record"].get("matter", ""),
783
+ "match_confidence": ref_match["match_confidence"],
784
+ "matched_keywords": ref_match["matched_keywords"],
785
+ "per_recipient": per_recipient,
786
+ "combined_severity": combined_severity,
787
+ })
788
+
789
+ # ── Determine final outcome ──────────────────────────────────────────────
790
+ outcome = _severity_to_outcome(overall_severity)
791
+ action = SEVERITY_ACTIONS.get(overall_severity, "send") if overall_severity else "send"
792
+
793
+ duration_ms = (time.monotonic() - t_start) * 1000
794
+
795
+ # ── Audit log ────────────────────────────────────────────────────────────
796
+ draft_hash = hashlib.sha256(draft.encode()).hexdigest()[:16]
797
+ _log_assessment(
798
+ message_id=message_id,
799
+ channel=channel,
800
+ recipients=all_recipients,
801
+ restricted_recipients=restricted_recipients,
802
+ references_assessed=all_ref_assessments,
803
+ outcome=outcome,
804
+ severity=overall_severity,
805
+ action=action,
806
+ draft_hash=draft_hash,
807
+ assessment_duration_ms=duration_ms,
808
+ )
809
+
810
+ return {
811
+ "outcome": outcome,
812
+ "severity": overall_severity,
813
+ "action": action,
814
+ "references_assessed": all_ref_assessments,
815
+ "assessment_duration_ms": round(duration_ms, 2),
816
+ }
817
+
818
+
819
+ # ─── CLI ─────────────────────────────────────────────────────────────────────
820
+
821
+ def main():
822
+ parser = argparse.ArgumentParser(
823
+ description="Post-draft disclosure assessment engine (Layer 2).",
824
+ )
825
+ parser.add_argument(
826
+ "--recipient",
827
+ required=True,
828
+ help="Recipient slug (e.g. casey-morgan)",
829
+ )
830
+ parser.add_argument(
831
+ "--channel",
832
+ default=None,
833
+ help="Optional Slack channel slug or ID to resolve full member list",
834
+ )
835
+ parser.add_argument(
836
+ "--message",
837
+ default=None,
838
+ help="Draft message text (reads from stdin if not provided)",
839
+ )
840
+ parser.add_argument(
841
+ "--message-id",
842
+ default=None,
843
+ dest="message_id",
844
+ help="Optional message ID for audit tracing",
845
+ )
846
+ args = parser.parse_args()
847
+
848
+ # Read draft
849
+ if args.message is not None:
850
+ draft = args.message
851
+ else:
852
+ draft = sys.stdin.read()
853
+
854
+ result = assess_disclosure(
855
+ draft=draft,
856
+ recipient_slug=args.recipient,
857
+ channel=args.channel,
858
+ message_id=args.message_id,
859
+ )
860
+
861
+ print(json.dumps(result, indent=2))
862
+
863
+ outcome = result.get("outcome", "passed")
864
+ if outcome == "blocked":
865
+ sys.exit(2)
866
+ elif outcome == "stripped":
867
+ sys.exit(1)
868
+ else:
869
+ sys.exit(0)
870
+
871
+
872
+ if __name__ == "__main__":
873
+ main()