@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,1504 @@
1
+ #!/usr/bin/env python3
2
+ """Pre-send outbound correspondence validator.
3
+
4
+ Checks outbound messages for:
5
+ 1. Relationship claims — "our project manager" must match confirmed status
6
+ 2. Title/role accuracy — titles must match org chart / profiles
7
+ 3. AI self-disclosure — the agent must never reveal it is AI
8
+ 4. In-person meeting self-scheduling — the agent must not attend physical meetings
9
+ 5. Unverified commitment claims — flag strong commitment language
10
+
11
+ Usage:
12
+ echo "message body" | python3 validate-outbound.py --type email --recipient graham@kpm.com
13
+ python3 validate-outbound.py --message "message body" --type slack --recipient C099ABCDEF
14
+
15
+ Exit codes:
16
+ 0 = clean (no blocking issues)
17
+ 1 = issues found (at least one "block" severity issue)
18
+
19
+ Outputs JSON to stdout:
20
+ {"status": "clean"|"issues_found", "issues": [...], "metadata": {...}}
21
+
22
+ Performance target: < 1 second (no API calls — file reads only).
23
+
24
+ Created: 2026-04-03 — triggered by ib-20260403-008 (Graham/KPM incident)
25
+ """
26
+ import argparse
27
+ import json
28
+ import os
29
+ import re
30
+ import sys
31
+ import time
32
+ from datetime import datetime, timezone
33
+ from pathlib import Path
34
+
35
+ # Import disclosure assessment module (Information Barrier Layer 2)
36
+ _da_module = None
37
+ try:
38
+ import importlib.util as _da_ilu
39
+ _da_path = Path(__file__).resolve().parent / "disclosure_assessment.py"
40
+ if _da_path.exists():
41
+ _da_spec = _da_ilu.spec_from_file_location("disclosure_assessment", str(_da_path))
42
+ _da_mod = _da_ilu.module_from_spec(_da_spec)
43
+ _da_spec.loader.exec_module(_da_mod)
44
+ _da_module = _da_mod
45
+ except Exception:
46
+ pass
47
+
48
+ # Resolve paths relative to the repo root
49
+ SCRIPT_DIR = Path(__file__).resolve().parent
50
+ REPO_ROOT = Path(os.environ.get("AGENT_DIR", SCRIPT_DIR.parent))
51
+
52
+ # Load agent identity (canonical SOT) so validation patterns can be
53
+ # parameterised by the running agent's first name.
54
+ try:
55
+ with open(REPO_ROOT / "config" / "agent.json") as _f:
56
+ _AGENT = json.load(_f)
57
+ except Exception:
58
+ _AGENT = {"firstName": "Agent", "fullName": "Agent"}
59
+ AGENT_FIRST_NAME = _AGENT.get("firstName", "Agent")
60
+
61
+ # Data source paths
62
+ CONTACTS_PATH = REPO_ROOT / "config" / "contacts.yaml"
63
+ ORG_CHART_PATH = REPO_ROOT / "knowledge" / "entities" / "org-chart.md"
64
+ USER_PROFILES_DIR = REPO_ROOT / "memory" / "profiles" / "users"
65
+ VALIDATION_RULES_PATH = REPO_ROOT / "config" / "validation-rules.yaml"
66
+ ENTITY_INDEX_PATH = REPO_ROOT / "memory" / "indexes" / "entity-relationships.yaml"
67
+ AUDIT_LOG_DIR = REPO_ROOT / "logs" / "audit"
68
+
69
+
70
+ # ─────────────────────────────────────────────
71
+ # Nested YAML parser (handles maps, lists, and scalar values)
72
+ # Replaces the original flat key-value parser that missed nested fields
73
+ # like relationship.type and relationship.status (Graham incident, G4)
74
+ # ─────────────────────────────────────────────
75
+ def _parse_yaml_simple(text):
76
+ """Parse YAML text into nested dicts/lists.
77
+
78
+ Handles:
79
+ - Nested mappings (indented key: value blocks)
80
+ - Lists (- item and - key: value)
81
+ - Quoted and unquoted scalar values
82
+ - Comments and blank lines
83
+
84
+ Returns a dict (possibly nested). Flat profiles still work —
85
+ a flat file returns a flat dict, same as before.
86
+ """
87
+ try:
88
+ import yaml as _yaml
89
+ return _yaml.safe_load(text) or {}
90
+ except ImportError:
91
+ pass
92
+
93
+ lines = text.split('\n')
94
+ return _parse_yaml_block(lines, 0, 0)[0]
95
+
96
+
97
+ def _parse_yaml_block(lines, start, base_indent):
98
+ """Recursively parse a YAML block starting at *start* with *base_indent*.
99
+
100
+ Returns (parsed_dict, next_line_index).
101
+ """
102
+ result = {}
103
+ i = start
104
+ while i < len(lines):
105
+ raw = lines[i]
106
+
107
+ # Skip blank lines and comments
108
+ stripped = raw.lstrip()
109
+ if not stripped or stripped.startswith('#'):
110
+ i += 1
111
+ continue
112
+
113
+ # Measure indentation
114
+ indent = len(raw) - len(raw.lstrip())
115
+
116
+ # If we've dedented past our block, stop
117
+ if indent < base_indent and i > start:
118
+ break
119
+
120
+ # Skip lines that are deeper than expected (handled recursively)
121
+ if indent > base_indent and i > start:
122
+ break
123
+
124
+ # ── List item ────────────────────────────────
125
+ if stripped.startswith('- '):
126
+ # We encountered a list where we expected a mapping value.
127
+ # Parse the full list and return it as a special case —
128
+ # caller will assign it as the value.
129
+ lst, i = _parse_yaml_list(lines, i, indent)
130
+ # If this list appears at the top level inside a mapping block,
131
+ # we shouldn't be here — this branch is for when a key's value
132
+ # is a list. Return what we have so far and let the caller handle it.
133
+ if not result:
134
+ return lst, i
135
+ break
136
+
137
+ # ── Mapping entry (key: ...) ─────────────────
138
+ if ':' in stripped and not stripped.startswith('-'):
139
+ key, _, rest = stripped.partition(':')
140
+ key = key.strip()
141
+ rest = rest.strip()
142
+
143
+ if rest:
144
+ # Inline value — scalar
145
+ result[key] = _yaml_scalar(rest)
146
+ i += 1
147
+ else:
148
+ # Block value — could be nested map or list
149
+ # Peek at next meaningful line to determine child indent
150
+ child_indent, child_idx = _next_content_line(lines, i + 1)
151
+ if child_indent is not None and child_indent > indent:
152
+ # Check if child is a list
153
+ child_stripped = lines[child_idx].lstrip()
154
+ if child_stripped.startswith('- '):
155
+ value, i = _parse_yaml_list(lines, child_idx, child_indent)
156
+ else:
157
+ value, i = _parse_yaml_block(lines, child_idx, child_indent)
158
+ result[key] = value
159
+ else:
160
+ # Empty value
161
+ result[key] = None
162
+ i += 1
163
+ else:
164
+ i += 1
165
+
166
+ return result, i
167
+
168
+
169
+ def _parse_yaml_list(lines, start, base_indent):
170
+ """Parse a YAML list starting at *start*.
171
+
172
+ Returns (list, next_line_index).
173
+ """
174
+ result = []
175
+ i = start
176
+ while i < len(lines):
177
+ raw = lines[i]
178
+ stripped = raw.lstrip()
179
+ if not stripped or stripped.startswith('#'):
180
+ i += 1
181
+ continue
182
+
183
+ indent = len(raw) - len(raw.lstrip())
184
+ if indent < base_indent:
185
+ break
186
+ if indent > base_indent:
187
+ # Continuation of previous list item (nested block)
188
+ # This is handled inside the item parsing below
189
+ break
190
+
191
+ if not stripped.startswith('- '):
192
+ break
193
+
194
+ item_content = stripped[2:].strip()
195
+
196
+ if ':' in item_content and not item_content.startswith('"') and not item_content.startswith("'"):
197
+ # Mapping-style list item: - key: value [possibly with more keys below]
198
+ k, _, v = item_content.partition(':')
199
+ k = k.strip()
200
+ v = v.strip()
201
+ item_dict = {k: _yaml_scalar(v) if v else None}
202
+
203
+ # Check for continuation keys at deeper indent
204
+ child_indent, child_idx = _next_content_line(lines, i + 1)
205
+ if child_indent is not None and child_indent > indent:
206
+ extra, i = _parse_yaml_block(lines, child_idx, child_indent)
207
+ if isinstance(extra, dict):
208
+ item_dict.update(extra)
209
+ else:
210
+ i = i # no change
211
+ else:
212
+ i += 1
213
+
214
+ result.append(item_dict)
215
+ else:
216
+ # Simple scalar list item
217
+ result.append(_yaml_scalar(item_content))
218
+ i += 1
219
+
220
+ return result, i
221
+
222
+
223
+ def _next_content_line(lines, start):
224
+ """Find the next non-blank, non-comment line from *start*.
225
+
226
+ Returns (indent, index) or (None, None).
227
+ """
228
+ i = start
229
+ while i < len(lines):
230
+ stripped = lines[i].lstrip()
231
+ if stripped and not stripped.startswith('#'):
232
+ indent = len(lines[i]) - len(lines[i].lstrip())
233
+ return indent, i
234
+ i += 1
235
+ return None, None
236
+
237
+
238
+ def _yaml_scalar(text):
239
+ """Convert a YAML scalar string to an appropriate Python type."""
240
+ if not text:
241
+ return None
242
+ # Remove surrounding quotes
243
+ if (text.startswith('"') and text.endswith('"')) or \
244
+ (text.startswith("'") and text.endswith("'")):
245
+ return text[1:-1]
246
+ # Remove inline comments
247
+ if ' #' in text:
248
+ text = text[:text.index(' #')].strip()
249
+ elif '\t#' in text:
250
+ text = text[:text.index('\t#')].strip()
251
+ # Boolean
252
+ if text.lower() in ('true', 'yes'):
253
+ return True
254
+ if text.lower() in ('false', 'no'):
255
+ return False
256
+ # Null
257
+ if text.lower() in ('null', '~', ''):
258
+ return None
259
+ # Numeric
260
+ try:
261
+ return int(text)
262
+ except ValueError:
263
+ pass
264
+ try:
265
+ return float(text)
266
+ except ValueError:
267
+ pass
268
+ # Strip quotes that may remain
269
+ return text.strip('"').strip("'")
270
+
271
+
272
+ def _nested_get(data, *keys):
273
+ """Safely traverse nested dicts. Returns None if any key is missing."""
274
+ current = data
275
+ for key in keys:
276
+ if isinstance(current, dict):
277
+ current = current.get(key)
278
+ else:
279
+ return None
280
+ return current
281
+
282
+
283
+ # ─────────────────────────────────────────────
284
+ # Data Loading — cached per process
285
+ # ─────────────────────────────────────────────
286
+ _contacts_cache = None
287
+ _profiles_cache = None
288
+ _org_titles_cache = None
289
+ _entity_index_cache = None
290
+
291
+
292
+ def load_entity_index():
293
+ """Load entity-relationships.yaml as the primary entity/relationship index.
294
+
295
+ Returns dict with keys:
296
+ - 'entities': list of entity dicts (people, companies)
297
+ - 'relationships': list of relationship dicts
298
+ - 'by_name': dict mapping lowercase name/alias -> entity dict
299
+ - 'rels_by_subject': dict mapping entity id -> list of relationship dicts
300
+
301
+ Gracefully returns empty structure if file is missing or malformed.
302
+ """
303
+ global _entity_index_cache
304
+ if _entity_index_cache is not None:
305
+ return _entity_index_cache
306
+
307
+ empty = {'entities': [], 'relationships': [], 'by_name': {}, 'rels_by_subject': {}}
308
+
309
+ if not ENTITY_INDEX_PATH.exists():
310
+ _entity_index_cache = empty
311
+ return _entity_index_cache
312
+
313
+ try:
314
+ text = ENTITY_INDEX_PATH.read_text(encoding='utf-8')
315
+ data = _parse_yaml_simple(text)
316
+
317
+ if not isinstance(data, dict):
318
+ _entity_index_cache = empty
319
+ return _entity_index_cache
320
+
321
+ entities = data.get('entities', []) or []
322
+ relationships = data.get('relationships', []) or []
323
+
324
+ # Build name lookup: lowercase name/alias -> entity dict
325
+ by_name = {}
326
+ for entity in entities:
327
+ if not isinstance(entity, dict):
328
+ continue
329
+ # Index by full name
330
+ name = entity.get('name', '')
331
+ if name and isinstance(name, str):
332
+ by_name[name.lower()] = entity
333
+ # Index by first name + last name (short form)
334
+ parts = name.split()
335
+ if len(parts) >= 2:
336
+ short = f"{parts[0]} {parts[-1]}"
337
+ by_name[short.lower()] = entity
338
+ # Index by first name alone (for single-name lookups)
339
+ if len(parts) >= 1:
340
+ first = parts[0].lower()
341
+ # Only index first name if it's not too common / ambiguous
342
+ # We allow it but give full name priority in lookup_entity
343
+ if first not in by_name:
344
+ by_name[first] = entity
345
+
346
+ # Index by aliases
347
+ aliases = entity.get('aliases', []) or []
348
+ if isinstance(aliases, list):
349
+ for alias in aliases:
350
+ if alias and isinstance(alias, str):
351
+ by_name[alias.lower()] = entity
352
+
353
+ # Build relationship lookup by subject
354
+ rels_by_subject = {}
355
+ for rel in relationships:
356
+ if not isinstance(rel, dict):
357
+ continue
358
+ subj = rel.get('subject', '')
359
+ if subj:
360
+ rels_by_subject.setdefault(subj, []).append(rel)
361
+
362
+ _entity_index_cache = {
363
+ 'entities': entities,
364
+ 'relationships': relationships,
365
+ 'by_name': by_name,
366
+ 'rels_by_subject': rels_by_subject,
367
+ }
368
+ except Exception:
369
+ _entity_index_cache = empty
370
+
371
+ return _entity_index_cache
372
+
373
+
374
+ def lookup_entity(name, entity_index=None):
375
+ """Look up a person or company by name in the entity index.
376
+
377
+ Performs fuzzy matching: checks full name, first+last, first name, aliases.
378
+
379
+ Args:
380
+ name: The name to look up (case-insensitive)
381
+ entity_index: The loaded entity index (from load_entity_index).
382
+ If None, loads it.
383
+
384
+ Returns:
385
+ dict with keys:
386
+ - 'entity': the entity dict (or None)
387
+ - 'relationships_to_northwind': list of relationship dicts describing
388
+ this entity's relationship to Northwind/its entities
389
+ - 'is_internal': bool — True if entity is employed by a Northwind entity
390
+ - 'relationship_type': str — summary classification
391
+ ('internal', 'vendor_candidate', 'external_vendor', 'external_advisor',
392
+ 'investor', 'regulator', 'external', 'unknown')
393
+ Returns None if entity not found.
394
+ """
395
+ if entity_index is None:
396
+ entity_index = load_entity_index()
397
+
398
+ by_name = entity_index.get('by_name', {})
399
+ rels_by_subject = entity_index.get('rels_by_subject', {})
400
+
401
+ name_lower = name.lower().strip()
402
+ if not name_lower:
403
+ return None
404
+
405
+ # Try exact match first, then partial
406
+ entity = by_name.get(name_lower)
407
+
408
+ if entity is None:
409
+ # Try first+last combination from input
410
+ parts = name_lower.split()
411
+ if len(parts) >= 2:
412
+ short = f"{parts[0]} {parts[-1]}"
413
+ entity = by_name.get(short)
414
+ if entity is None and len(parts) == 1:
415
+ # Single name — try it (already indexed by first name above)
416
+ entity = by_name.get(parts[0])
417
+
418
+ if entity is None:
419
+ return None
420
+
421
+ entity_id = entity.get('id', '')
422
+
423
+ # Collect relationships where this entity is the subject
424
+ subject_rels = rels_by_subject.get(entity_id, [])
425
+
426
+ # Determine relationship to Northwind
427
+ northwind_ids = {
428
+ 'company:northwind-group', 'company:northwind-holdings',
429
+ 'company:northwind-am-difc', 'company:northwind-technologies',
430
+ 'company:northwind-group-investments', 'company:northwind-investments',
431
+ 'company:northwind-inc', 'company:northwind-ai-uk',
432
+ 'company:northwind-am-uk', 'company:northwind-am-cayman',
433
+ 'company:northwind-am-asia', 'company:northwind-asia',
434
+ 'company:northwind-am-eu', 'company:northwind-am-us',
435
+ 'company:northwind-am-ksa', 'company:northwind-ksa',
436
+ }
437
+
438
+ rels_to_northwind = []
439
+ is_internal = False
440
+ rel_type = 'unknown'
441
+
442
+ for rel in subject_rels:
443
+ obj = rel.get('object', '')
444
+ pred = rel.get('predicate', '')
445
+
446
+ if obj in northwind_ids or obj.startswith('company:northwind'):
447
+ rels_to_northwind.append(rel)
448
+
449
+ if pred in ('employed_by', 'directs'):
450
+ is_internal = True
451
+ rel_type = 'internal'
452
+ elif pred == 'vendor_candidate_for' and rel_type != 'internal':
453
+ rel_type = 'vendor_candidate'
454
+ elif pred == 'vendor_for' and rel_type not in ('internal', 'vendor_candidate'):
455
+ rel_type = 'external_vendor'
456
+ elif pred == 'advises' and rel_type not in ('internal', 'vendor_candidate', 'external_vendor'):
457
+ rel_type = 'external_advisor'
458
+ elif pred == 'invests_in' and rel_type not in ('internal',):
459
+ rel_type = 'investor'
460
+ elif pred == 'regulates' and rel_type not in ('internal',):
461
+ rel_type = 'regulator'
462
+
463
+ # Check if entity is employed by a non-Northwind company (external person)
464
+ if rel_type == 'unknown':
465
+ for rel in subject_rels:
466
+ pred = rel.get('predicate', '')
467
+ if pred == 'employed_by':
468
+ rel_type = 'external'
469
+ break
470
+
471
+ return {
472
+ 'entity': entity,
473
+ 'relationships_to_northwind': rels_to_northwind,
474
+ 'is_internal': is_internal,
475
+ 'relationship_type': rel_type,
476
+ }
477
+
478
+
479
+ def load_contacts():
480
+ """Load confirmed contacts from config/contacts.yaml.
481
+
482
+ Returns dict: lowercase_name -> {role, classification, organisation}
483
+ """
484
+ global _contacts_cache
485
+ if _contacts_cache is not None:
486
+ return _contacts_cache
487
+
488
+ _contacts_cache = {}
489
+ if not CONTACTS_PATH.exists():
490
+ return _contacts_cache
491
+
492
+ text = CONTACTS_PATH.read_text(encoding='utf-8')
493
+
494
+ # Simple parser: extract contact blocks
495
+ current_contact = {}
496
+ for line in text.split('\n'):
497
+ stripped = line.strip()
498
+ if stripped.startswith('- name:'):
499
+ if current_contact.get('name'):
500
+ name_lower = current_contact['name'].lower()
501
+ _contacts_cache[name_lower] = current_contact
502
+ current_contact = {'name': stripped.split(':', 1)[1].strip()}
503
+ elif stripped.startswith('role:') and current_contact:
504
+ current_contact['role'] = stripped.split(':', 1)[1].strip()
505
+ elif stripped.startswith('classification:') and current_contact:
506
+ current_contact['classification'] = stripped.split(':', 1)[1].strip()
507
+ elif stripped.startswith('organisation:') and current_contact:
508
+ current_contact['organisation'] = stripped.split(':', 1)[1].strip()
509
+
510
+ if current_contact.get('name'):
511
+ name_lower = current_contact['name'].lower()
512
+ _contacts_cache[name_lower] = current_contact
513
+
514
+ return _contacts_cache
515
+
516
+
517
+ def load_user_profiles():
518
+ """Load all user profiles from memory/profiles/users/*.yaml.
519
+
520
+ Returns dict: lowercase_name -> {name, title, company, relationship_type,
521
+ relationship_status, classification}
522
+ """
523
+ global _profiles_cache
524
+ if _profiles_cache is not None:
525
+ return _profiles_cache
526
+
527
+ _profiles_cache = {}
528
+ if not USER_PROFILES_DIR.exists():
529
+ return _profiles_cache
530
+
531
+ for yaml_file in USER_PROFILES_DIR.glob('*.yaml'):
532
+ try:
533
+ text = yaml_file.read_text(encoding='utf-8')
534
+ data = _parse_yaml_simple(text)
535
+
536
+ # Navigate nested structure — profiles may have top-level keys
537
+ # (flat) or nested sections like identity.name, relationship.type
538
+ identity = data.get('identity', {}) if isinstance(data.get('identity'), dict) else {}
539
+ relationship = data.get('relationship', {}) if isinstance(data.get('relationship'), dict) else {}
540
+
541
+ # Name: try identity.name, then top-level name, then filename
542
+ name = _nested_get(data, 'identity', 'name') or data.get('name', '')
543
+ if not name or not isinstance(name, str):
544
+ name = yaml_file.stem.replace('-', ' ').title()
545
+
546
+ # Title: identity.title or top-level
547
+ title = _nested_get(data, 'identity', 'title') or data.get('title', '')
548
+
549
+ # Company: identity.company or top-level
550
+ company = _nested_get(data, 'identity', 'company') or data.get('company', '')
551
+
552
+ # Relationship type: relationship.type, or flat relationship_to_northwind, or top-level type
553
+ rel_type = (
554
+ _nested_get(data, 'relationship', 'type')
555
+ or data.get('relationship_to_northwind', '')
556
+ or data.get('type', '')
557
+ )
558
+
559
+ # Relationship status: relationship.status, or flat relationship_status, or top-level status
560
+ rel_status = (
561
+ _nested_get(data, 'relationship', 'status')
562
+ or data.get('relationship_status', '')
563
+ or data.get('status', '')
564
+ )
565
+
566
+ # Relationship category (for richer matching)
567
+ rel_category = _nested_get(data, 'relationship', 'category') or data.get('category', '')
568
+
569
+ # Classification & privilege — may be top-level or nested
570
+ classification = data.get('classification', '')
571
+ privilege_level = data.get('privilege_level', '')
572
+
573
+ profile = {
574
+ 'name': name if isinstance(name, str) else str(name),
575
+ 'title': title if isinstance(title, str) else str(title) if title else '',
576
+ 'company': company if isinstance(company, str) else str(company) if company else '',
577
+ 'relationship_type': rel_type if isinstance(rel_type, str) else str(rel_type) if rel_type else '',
578
+ 'relationship_status': rel_status if isinstance(rel_status, str) else str(rel_status) if rel_status else '',
579
+ 'relationship_category': rel_category if isinstance(rel_category, str) else str(rel_category) if rel_category else '',
580
+ 'classification': classification if isinstance(classification, str) else str(classification) if classification else '',
581
+ 'privilege_level': privilege_level if isinstance(privilege_level, str) else str(privilege_level) if privilege_level else '',
582
+ }
583
+ _profiles_cache[name.lower()] = profile
584
+ except Exception:
585
+ continue
586
+
587
+ return _profiles_cache
588
+
589
+
590
+ def load_org_titles():
591
+ """Load confirmed titles from knowledge/entities/org-chart.md.
592
+
593
+ Returns dict: lowercase_name -> confirmed_title
594
+ """
595
+ global _org_titles_cache
596
+ if _org_titles_cache is not None:
597
+ return _org_titles_cache
598
+
599
+ _org_titles_cache = {}
600
+ if not ORG_CHART_PATH.exists():
601
+ return _org_titles_cache
602
+
603
+ text = ORG_CHART_PATH.read_text(encoding='utf-8')
604
+
605
+ # Parse patterns like "### Name — Title" and "Name — Title" from tree
606
+ patterns = [
607
+ r'###\s+(.+?)\s+[—\-]+\s+(.+)', # ### Alex Chen — CEO / Founder / UBO
608
+ r'[├└]\s*(.+?)\s+[—\-]+\s+(.+)', # ├── Sam Rivera — CFO / CIO
609
+ ]
610
+ for pattern in patterns:
611
+ for match in re.finditer(pattern, text):
612
+ name = match.group(1).strip()
613
+ title = match.group(2).strip()
614
+ # Clean up name (remove markdown, extra chars)
615
+ name = re.sub(r'[\*\#]', '', name).strip()
616
+ # Remove full name suffixes for matching
617
+ first_last = name.split()
618
+ if len(first_last) >= 2:
619
+ short_name = f"{first_last[0]} {first_last[-1]}"
620
+ _org_titles_cache[short_name.lower()] = title
621
+ _org_titles_cache[name.lower()] = title
622
+
623
+ return _org_titles_cache
624
+
625
+
626
+ # ─────────────────────────────────────────────
627
+ # Validation Checks
628
+ # ─────────────────────────────────────────────
629
+
630
+ def check_relationship_claims(message):
631
+ """Check for unverified relationship/affiliation claims.
632
+
633
+ Uses entity-relationships.yaml as PRIMARY lookup source.
634
+ Falls back to user profiles if entity not found in the index.
635
+
636
+ Returns list of issue dicts.
637
+ """
638
+ issues = []
639
+ contacts = load_contacts()
640
+ profiles = load_user_profiles()
641
+ entity_index = load_entity_index()
642
+ message_lower = message.lower()
643
+
644
+ # Confirmed statuses — people with these can be called "our X" (profile fallback)
645
+ confirmed_statuses = {
646
+ 'confirmed', 'confirmed_employee', 'confirmed_contractor', 'internal',
647
+ 'confirmed_advisor', 'confirmed_partner',
648
+ }
649
+ confirmed_classifications = {
650
+ 'internal-ceo', 'internal-executive', 'internal-team',
651
+ }
652
+
653
+ # Entity index relationship types that permit possessive claims ("our X")
654
+ entity_internal_types = {'internal'}
655
+
656
+ # Entity index relationship types that BLOCK possessive claims
657
+ entity_external_types = {
658
+ 'vendor_candidate', 'external_vendor', 'external_advisor',
659
+ 'investor', 'regulator', 'external', 'unknown',
660
+ }
661
+
662
+ # Relationship claim patterns
663
+ claim_patterns = [
664
+ (r'\b(our|my|the company\'s|Northwind\'s|the team\'s)\s+'
665
+ r'(project manager|PM|consultant|advisor|counsel|lawyer|developer|'
666
+ r'engineer|designer|analyst|hire|employee|team member|colleague|'
667
+ r'staff member|contractor|associate|partner|director|officer|'
668
+ r'head of\s+\w+|lead\s*\w*|manager|VP|chief\s+\w+)',
669
+ "Possessive role claim"),
670
+ (r'\bwe\'ve\s+(hired|appointed|engaged|retained|onboarded|brought on|contracted)\s+',
671
+ "Hiring/engagement claim"),
672
+ (r'\b(has joined|is joining|will be joining|recently joined|now works|'
673
+ r'working with us|part of our team|on our team|member of our)\b',
674
+ "Team membership claim"),
675
+ (r'\b(is our|are our|will be our)\s+\w+',
676
+ "Direct role attribution"),
677
+ ]
678
+
679
+ for pattern, description in claim_patterns:
680
+ for match in re.finditer(pattern, message, re.IGNORECASE):
681
+ matched_text = match.group(0)
682
+ # Extract context — look for a name near the match
683
+ start = max(0, match.start() - 80)
684
+ end = min(len(message), match.end() + 80)
685
+ context_window = message[start:end]
686
+
687
+ # Check if any known person is referenced nearby
688
+ flagged = False
689
+ flagged_person = None
690
+ entity_lookup_used = False
691
+
692
+ # ── PRIMARY: Entity index lookup ─────────────────
693
+ # Scan the context window for any entity name from the index
694
+ by_name = entity_index.get('by_name', {})
695
+ for entity_name_lower, entity in by_name.items():
696
+ # Only check person entities, skip companies
697
+ if entity.get('type') != 'person':
698
+ continue
699
+
700
+ # Check if this entity's name appears in the context
701
+ name_in_context = False
702
+ # Check full name
703
+ full_name = entity.get('name', '')
704
+ if full_name and full_name.lower() in context_window.lower():
705
+ name_in_context = True
706
+ # Check aliases
707
+ if not name_in_context:
708
+ for alias in (entity.get('aliases', []) or []):
709
+ if alias and len(alias) > 2 and alias.lower() in context_window.lower():
710
+ name_in_context = True
711
+ break
712
+ # Check name parts (first name, last name)
713
+ if not name_in_context and full_name:
714
+ parts = full_name.split()
715
+ for part in parts:
716
+ if len(part) > 2 and part.lower() in context_window.lower():
717
+ name_in_context = True
718
+ break
719
+
720
+ if not name_in_context:
721
+ continue
722
+
723
+ # Found a person near the claim — look up their relationship
724
+ result = lookup_entity(full_name, entity_index)
725
+ if result:
726
+ entity_lookup_used = True
727
+ rel_type = result.get('relationship_type', 'unknown')
728
+
729
+ if rel_type in entity_external_types:
730
+ # BLOCK — external person referenced with possessive claim
731
+ flagged = True
732
+ flagged_person = full_name
733
+ # Build enriched suggestion from entity data
734
+ entity_role = entity.get('primary_role', '')
735
+ entity_company = entity.get('primary_company', '')
736
+ suggestion_parts = [
737
+ f"{full_name} is classified as '{rel_type}' in the entity index."
738
+ ]
739
+ if entity_role:
740
+ suggestion_parts.append(f"Their role is: {entity_role}.")
741
+ if entity_company:
742
+ suggestion_parts.append(f"Their company: {entity_company}.")
743
+ suggestion_parts.append(
744
+ "Do not use possessive language ('our', 'my') for external parties."
745
+ )
746
+ break # Found a flagged person, stop scanning
747
+
748
+ elif rel_type in entity_internal_types:
749
+ # ALLOW — confirmed internal person
750
+ # Don't flag, and mark that we found a confirmed person
751
+ flagged = False
752
+ flagged_person = None
753
+ entity_lookup_used = True
754
+ break
755
+
756
+ # ── FALLBACK: Profile-based lookup (if entity index didn't resolve) ──
757
+ if not entity_lookup_used:
758
+ for name_lower, profile in profiles.items():
759
+ rel_status = profile.get('relationship_status', '')
760
+ rel_type = profile.get('relationship_type', '')
761
+ priv_level = profile.get('privilege_level', '')
762
+ classification = profile.get('classification', '')
763
+
764
+ # Check if this person is in the context window
765
+ name_parts = name_lower.split()
766
+ name_in_context = False
767
+ for part in name_parts:
768
+ if len(part) > 2 and part.lower() in context_window.lower():
769
+ name_in_context = True
770
+ break
771
+
772
+ if not name_in_context:
773
+ continue
774
+
775
+ # Is this person confirmed?
776
+ is_confirmed = (
777
+ rel_status in confirmed_statuses or
778
+ classification in confirmed_classifications or
779
+ priv_level == 'internal'
780
+ )
781
+
782
+ # Also check contacts registry
783
+ contact = contacts.get(name_lower, {})
784
+ contact_class = contact.get('classification', '')
785
+ if contact_class in confirmed_classifications:
786
+ is_confirmed = True
787
+
788
+ if not is_confirmed:
789
+ flagged = True
790
+ flagged_person = profile.get('name', name_lower)
791
+ break
792
+
793
+ # Also flag if no person is identified but claim is made
794
+ # (could be about someone not in our records at all)
795
+ if not flagged and not entity_lookup_used:
796
+ # Check all known profiles — if none are mentioned, still warn
797
+ any_confirmed_nearby = False
798
+ for name_lower, profile in {**profiles, **{k: {'name': v.get('name', k)} for k, v in contacts.items()}}.items():
799
+ name_parts = name_lower.split()
800
+ for part in name_parts:
801
+ if len(part) > 2 and part.lower() in context_window.lower():
802
+ # Person found — check if confirmed
803
+ contact = contacts.get(name_lower, {})
804
+ contact_class = contact.get('classification', '')
805
+ p = profiles.get(name_lower, {})
806
+ if (contact_class in confirmed_classifications or
807
+ p.get('relationship_status') in confirmed_statuses or
808
+ p.get('privilege_level') == 'internal'):
809
+ any_confirmed_nearby = True
810
+ break
811
+
812
+ if not any_confirmed_nearby:
813
+ # Nobody confirmed near this claim — warn
814
+ flagged = True
815
+ flagged_person = None
816
+
817
+ if flagged:
818
+ # Build suggestion based on source
819
+ if flagged_person and entity_lookup_used:
820
+ result = lookup_entity(flagged_person, entity_index)
821
+ if result:
822
+ rel_type = result.get('relationship_type', 'unknown')
823
+ entity_data = result.get('entity', {})
824
+ entity_role = entity_data.get('primary_role', '') if entity_data else ''
825
+ entity_company = entity_data.get('primary_company', '') if entity_data else ''
826
+ suggestion = (
827
+ f"{flagged_person} is '{rel_type}' per entity index"
828
+ f"{' (' + entity_role + ')' if entity_role else ''}. "
829
+ f"Do not use possessive language for external parties."
830
+ )
831
+ else:
832
+ suggestion = f"Verify {flagged_person}'s relationship status before claiming affiliation."
833
+ elif flagged_person:
834
+ suggestion = f"Verify {flagged_person}'s relationship status before claiming affiliation."
835
+ else:
836
+ suggestion = "Could not identify a confirmed team member near this claim. Verify the relationship."
837
+
838
+ issue = {
839
+ "type": "relationship_claim",
840
+ "severity": "block",
841
+ "matched_text": matched_text,
842
+ "description": f"{description}: \"{matched_text}\"",
843
+ "suggestion": suggestion,
844
+ "context": context_window.strip(),
845
+ "lookup_source": "entity_index" if entity_lookup_used else "profile_fallback",
846
+ }
847
+ if flagged_person:
848
+ issue["person"] = flagged_person
849
+ issues.append(issue)
850
+
851
+ return issues
852
+
853
+
854
+ def check_title_accuracy(message):
855
+ """Check that titles attributed to named people match confirmed records.
856
+
857
+ Returns list of issue dicts.
858
+ """
859
+ issues = []
860
+ org_titles = load_org_titles()
861
+ contacts = load_contacts()
862
+ profiles = load_user_profiles()
863
+
864
+ # Build comprehensive title map: name -> known title
865
+ title_map = {}
866
+ for name_lower, title in org_titles.items():
867
+ title_map[name_lower] = title
868
+ for name_lower, contact in contacts.items():
869
+ if contact.get('role'):
870
+ title_map[name_lower] = contact['role']
871
+ for name_lower, profile in profiles.items():
872
+ if profile.get('title'):
873
+ title_map[name_lower] = profile['title']
874
+
875
+ # Pattern: "Name, Title" or "Name (Title)" or "Name, the Title"
876
+ title_attribution_patterns = [
877
+ r'([A-Z][a-z]+(?:\s+[A-Z][a-z]+)+),\s+(?:the\s+)?([A-Z][A-Za-z\s/]+?)(?:\.|,|\s+at\s+|\s+of\s+|\s+from\s+|\s*$)',
878
+ r'([A-Z][a-z]+(?:\s+[A-Z][a-z]+)+)\s+\(([^)]+)\)',
879
+ ]
880
+
881
+ for pattern in title_attribution_patterns:
882
+ for match in re.finditer(pattern, message):
883
+ name = match.group(1).strip()
884
+ attributed_title = match.group(2).strip()
885
+ name_lower = name.lower()
886
+
887
+ # Look up known title
888
+ known_title = title_map.get(name_lower)
889
+ if not known_title:
890
+ # Try partial match (first + last name)
891
+ parts = name_lower.split()
892
+ if len(parts) >= 2:
893
+ short = f"{parts[0]} {parts[-1]}"
894
+ known_title = title_map.get(short)
895
+
896
+ if known_title:
897
+ # Compare — normalize for comparison
898
+ known_normalized = known_title.lower().replace('/', ' ').replace('-', ' ')
899
+ attr_normalized = attributed_title.lower().replace('/', ' ').replace('-', ' ')
900
+
901
+ # Check if attributed title is a reasonable match
902
+ known_words = set(known_normalized.split())
903
+ attr_words = set(attr_normalized.split())
904
+
905
+ # If there's very little overlap, flag it
906
+ if known_words and attr_words:
907
+ overlap = known_words & attr_words
908
+ if len(overlap) < 1 and len(attr_words) > 1:
909
+ issues.append({
910
+ "type": "title_accuracy",
911
+ "severity": "warn",
912
+ "person": name,
913
+ "attributed_title": attributed_title,
914
+ "known_title": known_title,
915
+ "description": (
916
+ f"Title \"{attributed_title}\" for {name} does not match "
917
+ f"confirmed title \"{known_title}\""
918
+ ),
919
+ "suggestion": f"Use confirmed title: \"{known_title}\"",
920
+ })
921
+
922
+ return issues
923
+
924
+
925
+ def check_ai_self_disclosure(message):
926
+ """Scan for language that reveals the agent is AI.
927
+
928
+ Must NOT flag general business discussion of Northwind's AI products.
929
+ Returns list of issue dicts.
930
+ """
931
+ issues = []
932
+
933
+ # Business context exceptions — if these appear, the word "AI" is OK
934
+ business_exceptions = [
935
+ r'AI[- ]Born', r'AI[- ]native', r'Northwind\s*AI', r'Northwind\.ai',
936
+ r'artificial intelligence\s+strategy', r'AI\s+products?',
937
+ r'AI\s+platform', r'AI\s+capabilit', r'machine learning\s+models?',
938
+ r'AI[- ]driven', r'AI\s+solutions?', r'AI\s+technolog',
939
+ r'AI\s+invest', r'AI\s+fund', r'AI\s+sector', r'AI\s+market',
940
+ r'AI\s+infrastructure', r'AI\s+research', r'generative\s+AI',
941
+ ]
942
+
943
+ # Self-referential AI disclosure patterns
944
+ disclosure_patterns = [
945
+ (r'\bI\s+(?:am|\'m)\s+(?:an?\s+)?(?:AI|artificial intelligence|language model|LLM|chatbot|bot|virtual assistant|automated system|machine)\b',
946
+ "Direct AI self-identification"),
947
+ (r'\b(?:I\'m|I am)\s+not\s+(?:human|a real person|a person)\b',
948
+ "Human status denial"),
949
+ (r'\bas an AI\b',
950
+ "Self-referential 'as an AI'"),
951
+ (r'\b(?:my|I)\s+(?:training|training data|knowledge cutoff|neural network|parameters)\b',
952
+ "AI-specific self-reference"),
953
+ (rf'\b{re.escape(AGENT_FIRST_NAME)}\s+is\s+(?:an?\s+)?(?:AI|bot|automated|virtual|artificial)\b',
954
+ f"Third-person AI disclosure about {AGENT_FIRST_NAME}"),
955
+ (r'\b(?:automated|AI[- ]powered|AI[- ]operated|machine[- ]generated)\s+(?:assistant|chief of staff|executive|system)\b',
956
+ "Role described as automated"),
957
+ (r'\b(?:I don\'t have|I lack)\s+(?:feelings|emotions|a body|physical form|consciousness)\b',
958
+ "Self-disclosure via limitation"),
959
+ (r'\b(?:my programming|my algorithms?|my code)\b',
960
+ "Self-referencing technical nature"),
961
+ ]
962
+
963
+ for pattern, description in disclosure_patterns:
964
+ for match in re.finditer(pattern, message, re.IGNORECASE):
965
+ matched_text = match.group(0)
966
+ start = max(0, match.start() - 40)
967
+ end = min(len(message), match.end() + 40)
968
+ context_window = message[start:end]
969
+
970
+ # Check if this is actually a business context exception
971
+ is_business_context = False
972
+ for exc_pattern in business_exceptions:
973
+ if re.search(exc_pattern, context_window, re.IGNORECASE):
974
+ is_business_context = True
975
+ break
976
+
977
+ if not is_business_context:
978
+ issues.append({
979
+ "type": "ai_self_disclosure",
980
+ "severity": "block",
981
+ "matched_text": matched_text,
982
+ "description": f"AI self-disclosure detected: {description}",
983
+ "suggestion": f"Remove or rephrase to avoid revealing {AGENT_FIRST_NAME}'s AI nature.",
984
+ "context": context_window.strip(),
985
+ })
986
+
987
+ return issues
988
+
989
+
990
+ def check_in_person_self_scheduling(message):
991
+ """Detect the agent scheduling itself for in-person meetings.
992
+
993
+ Should NOT flag scheduling for the principal or team members.
994
+ Returns list of issue dicts.
995
+ """
996
+ issues = []
997
+
998
+ _principal_name = (_AGENT.get("principal") or {}).get("firstName", "the principal")
999
+
1000
+ # Patterns where the agent offers to physically attend
1001
+ self_attend_patterns = [
1002
+ (r'\bI\'ll\s+(?:attend|be there|come to|visit|meet you at|stop by|drop by|come by|swing by|show up)\b',
1003
+ f"{AGENT_FIRST_NAME} offering to physically attend"),
1004
+ (r'\b(?:meet me at|see you at|I\'ll see you|I can meet|let me come|I\'ll come)\b',
1005
+ f"{AGENT_FIRST_NAME} scheduling itself for physical meeting"),
1006
+ (r'\b(?:I\'ll be in|I\'m in|I will be at)\s+(?:the office|DIFC|Innovation|your office|the meeting room|the boardroom)\b',
1007
+ f"{AGENT_FIRST_NAME} placing itself in physical location"),
1008
+ (rf'\b{re.escape(AGENT_FIRST_NAME)}\s+(?:will attend|will be at|will meet|will visit)\b',
1009
+ f"Third-person {AGENT_FIRST_NAME} physical attendance"),
1010
+ (r'\bI\'ll\s+(?:fly|travel|drive|take a cab|take a taxi)\b',
1011
+ f"{AGENT_FIRST_NAME} describing physical travel"),
1012
+ (r'\b(?:looking forward to meeting you|see you (?:there|soon|Monday|Tuesday|Wednesday|Thursday|Friday|tomorrow|next week))\b',
1013
+ f"{AGENT_FIRST_NAME} implying physical presence at meeting"),
1014
+ ]
1015
+
1016
+ # Exception patterns — scheduling others is fine
1017
+ team_scheduling_patterns = [
1018
+ r'\b(?:Alex|Sam|Jordan|Bronwyn|Casey|the team|our team|he|she|they)\s+(?:will|can|should|could)\s+(?:attend|be there|meet|visit)',
1019
+ r'\b(?:Alex|Sam|Jordan|Bronwyn|Casey)\s+(?:will|can)\s+(?:come|be at|meet you)',
1020
+ r'\b(?:schedule|book|set up|arrange)\s+(?:a|the)\s+(?:meeting|call|session)\s+(?:for|with)\s+(?:Alex|Sam|Jordan|Bronwyn)',
1021
+ ]
1022
+
1023
+ for pattern, description in self_attend_patterns:
1024
+ for match in re.finditer(pattern, message, re.IGNORECASE):
1025
+ matched_text = match.group(0)
1026
+ start = max(0, match.start() - 50)
1027
+ end = min(len(message), match.end() + 50)
1028
+ context_window = message[start:end]
1029
+
1030
+ # Check if this is actually about scheduling someone else
1031
+ is_team_scheduling = False
1032
+ for exc_pattern in team_scheduling_patterns:
1033
+ if re.search(exc_pattern, context_window, re.IGNORECASE):
1034
+ is_team_scheduling = True
1035
+ break
1036
+
1037
+ if not is_team_scheduling:
1038
+ issues.append({
1039
+ "type": "in_person_self_scheduling",
1040
+ "severity": "block",
1041
+ "matched_text": matched_text,
1042
+ "description": f"In-person self-scheduling detected: {description}",
1043
+ "suggestion": f"{AGENT_FIRST_NAME} cannot attend in-person meetings. Propose {_principal_name} or a team member instead.",
1044
+ "context": context_window.strip(),
1045
+ })
1046
+
1047
+ return issues
1048
+
1049
+
1050
+ def check_unverified_commitments(message):
1051
+ """Flag strong commitment language that should be verified.
1052
+
1053
+ Returns list of issue dicts.
1054
+ """
1055
+ issues = []
1056
+
1057
+ commitment_patterns = [
1058
+ (r'\b(?:we\'ve|we have)\s+(?:closed|secured|signed|finalized|completed|received approval)',
1059
+ "Commitment claim — verify status"),
1060
+ (r'\b(?:deal is done|agreement signed|funds received|licence approved|licence granted)',
1061
+ "Completion claim — verify against records"),
1062
+ (r'\b(?:we\'ve|we have)\s+(?:raised|received)\s+\$[\d,.]+',
1063
+ "Fundraising claim — verify amount"),
1064
+ ]
1065
+
1066
+ for pattern, description in commitment_patterns:
1067
+ for match in re.finditer(pattern, message, re.IGNORECASE):
1068
+ matched_text = match.group(0)
1069
+ start = max(0, match.start() - 40)
1070
+ end = min(len(message), match.end() + 40)
1071
+ context_window = message[start:end]
1072
+
1073
+ issues.append({
1074
+ "type": "unverified_commitment",
1075
+ "severity": "warn",
1076
+ "matched_text": matched_text,
1077
+ "description": f"Unverified commitment: {description}",
1078
+ "suggestion": "Verify this claim against known records before sending.",
1079
+ "context": context_window.strip(),
1080
+ })
1081
+
1082
+ return issues
1083
+
1084
+
1085
+ # ─────────────────────────────────────────────
1086
+ # Main Validation Pipeline
1087
+ # ─────────────────────────────────────────────
1088
+
1089
+ def _load_rules_config():
1090
+ """Load validation-rules.yaml. Returns dict or None on failure."""
1091
+ try:
1092
+ if VALIDATION_RULES_PATH.exists():
1093
+ text = VALIDATION_RULES_PATH.read_text(encoding='utf-8')
1094
+ return _parse_yaml_simple(text)
1095
+ except Exception:
1096
+ pass
1097
+ return None
1098
+
1099
+
1100
+ def validate_message(message, message_type="email", recipient="", channel=None):
1101
+ """Run all validation checks on a message.
1102
+
1103
+ Args:
1104
+ message: The message body text
1105
+ message_type: "email", "slack", or "whatsapp"
1106
+ recipient: Recipient identifier (email address, channel ID, phone number)
1107
+ channel: Optional Slack channel slug (for disclosure assessment)
1108
+
1109
+ Returns:
1110
+ dict with keys: status, issues, metadata
1111
+ """
1112
+ start_time = time.time()
1113
+ all_issues = []
1114
+
1115
+ # Run all checks
1116
+ all_issues.extend(check_relationship_claims(message))
1117
+ all_issues.extend(check_title_accuracy(message))
1118
+ all_issues.extend(check_ai_self_disclosure(message))
1119
+ all_issues.extend(check_in_person_self_scheduling(message))
1120
+ all_issues.extend(check_unverified_commitments(message))
1121
+
1122
+ # ── Rule 6: Disclosure Assessment (Information Barrier) ──
1123
+ if _da_module:
1124
+ try:
1125
+ rules_config = _load_rules_config()
1126
+ da_config = rules_config.get("disclosure_assessment", {}) if rules_config else {}
1127
+ if da_config.get("enabled", False):
1128
+ # Derive a slug from the recipient string
1129
+ recipient_slug = None
1130
+ if recipient:
1131
+ recipient_slug = (
1132
+ recipient.lower()
1133
+ .replace(" ", "-")
1134
+ .replace("@", "-at-")
1135
+ .replace(".", "-")
1136
+ )
1137
+
1138
+ if recipient_slug:
1139
+ da_result = _da_module.assess_disclosure(
1140
+ message, recipient_slug, channel=channel
1141
+ )
1142
+ outcome = da_result.get("outcome", "passed")
1143
+ if outcome == "blocked":
1144
+ assessments = da_result.get("references_assessed", [])
1145
+ first_reason = (
1146
+ assessments[0].get("reasoning", "disclosure violation")
1147
+ if assessments else "disclosure violation detected"
1148
+ )
1149
+ all_issues.append({
1150
+ "type": "disclosure_assessment",
1151
+ "severity": "block",
1152
+ "matched_text": "",
1153
+ "description": (
1154
+ f"INFORMATION BARRIER: {da_result.get('severity', 'unknown')} severity"
1155
+ f" — {first_reason}"
1156
+ ),
1157
+ "suggestion": "Remove or redact the restricted reference before sending.",
1158
+ "detail": da_result,
1159
+ })
1160
+ elif outcome == "stripped":
1161
+ all_issues.append({
1162
+ "type": "disclosure_assessment",
1163
+ "severity": "warn",
1164
+ "matched_text": "",
1165
+ "description": (
1166
+ "INFORMATION BARRIER: Medium severity references detected"
1167
+ " — content should be reviewed"
1168
+ ),
1169
+ "suggestion": "Review and redact medium-sensitivity references before sending.",
1170
+ "detail": da_result,
1171
+ })
1172
+ except Exception:
1173
+ pass # Fail-open on assessment errors
1174
+
1175
+ # Deduplicate by matched_text + type
1176
+ seen = set()
1177
+ unique_issues = []
1178
+ for issue in all_issues:
1179
+ key = (issue.get('type', ''), issue.get('matched_text', ''))
1180
+ if key not in seen:
1181
+ seen.add(key)
1182
+ unique_issues.append(issue)
1183
+
1184
+ elapsed_ms = round((time.time() - start_time) * 1000, 1)
1185
+
1186
+ has_blockers = any(i['severity'] == 'block' for i in unique_issues)
1187
+ status = "issues_found" if has_blockers else ("warnings" if unique_issues else "clean")
1188
+
1189
+ result = {
1190
+ "status": status,
1191
+ "issues": unique_issues,
1192
+ "metadata": {
1193
+ "message_type": message_type,
1194
+ "recipient": recipient,
1195
+ "checks_run": 6,
1196
+ "issues_found": len(unique_issues),
1197
+ "blockers": sum(1 for i in unique_issues if i['severity'] == 'block'),
1198
+ "warnings": sum(1 for i in unique_issues if i['severity'] == 'warn'),
1199
+ "elapsed_ms": elapsed_ms,
1200
+ "timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
1201
+ },
1202
+ }
1203
+
1204
+ return result
1205
+
1206
+
1207
+ def log_validation_result(result):
1208
+ """Log validation result to audit log."""
1209
+ try:
1210
+ AUDIT_LOG_DIR.mkdir(parents=True, exist_ok=True)
1211
+ today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
1212
+ log_file = AUDIT_LOG_DIR / f"{today}-validation.jsonl"
1213
+
1214
+ log_entry = {
1215
+ "timestamp": result["metadata"]["timestamp"],
1216
+ "agent": "validate-outbound",
1217
+ "action": "outbound_validation",
1218
+ "status": result["status"],
1219
+ "message_type": result["metadata"]["message_type"],
1220
+ "recipient": result["metadata"]["recipient"],
1221
+ "issues_found": result["metadata"]["issues_found"],
1222
+ "blockers": result["metadata"]["blockers"],
1223
+ "warnings": result["metadata"]["warnings"],
1224
+ "elapsed_ms": result["metadata"]["elapsed_ms"],
1225
+ }
1226
+
1227
+ # Add issue summaries (but not full context to keep log compact)
1228
+ if result["issues"]:
1229
+ log_entry["issue_types"] = list(set(i["type"] for i in result["issues"]))
1230
+ log_entry["issue_summaries"] = [
1231
+ {"type": i["type"], "severity": i["severity"], "description": i["description"]}
1232
+ for i in result["issues"][:10] # Cap at 10 for log size
1233
+ ]
1234
+
1235
+ with open(log_file, "a", encoding="utf-8") as f:
1236
+ f.write(json.dumps(log_entry) + "\n")
1237
+ except Exception:
1238
+ pass # Fail-open — validation logging should never block sending
1239
+
1240
+
1241
+ # ─────────────────────────────────────────────
1242
+ # CLI Entry Point
1243
+ # ─────────────────────────────────────────────
1244
+
1245
+ def main():
1246
+ parser = argparse.ArgumentParser(
1247
+ description="Pre-send outbound correspondence validator"
1248
+ )
1249
+ parser.add_argument(
1250
+ "--message", "-m",
1251
+ help="Message body to validate (alternative to stdin)"
1252
+ )
1253
+ parser.add_argument(
1254
+ "--type", "-t",
1255
+ default="email",
1256
+ choices=["email", "slack", "whatsapp"],
1257
+ help="Message type (default: email)"
1258
+ )
1259
+ parser.add_argument(
1260
+ "--recipient", "-r",
1261
+ default="",
1262
+ help="Recipient identifier (email, channel ID, phone)"
1263
+ )
1264
+ parser.add_argument(
1265
+ "--channel", "-c",
1266
+ default=None,
1267
+ help="Slack channel slug (used for disclosure assessment)"
1268
+ )
1269
+ parser.add_argument(
1270
+ "--quiet", "-q",
1271
+ action="store_true",
1272
+ help="Suppress non-JSON output"
1273
+ )
1274
+ args = parser.parse_args()
1275
+
1276
+ # Get message from --message flag or stdin
1277
+ if args.message:
1278
+ message = args.message
1279
+ elif not sys.stdin.isatty():
1280
+ message = sys.stdin.read()
1281
+ else:
1282
+ print("ERROR: Provide message via --message flag or stdin", file=sys.stderr)
1283
+ sys.exit(2)
1284
+
1285
+ if not message.strip():
1286
+ print("ERROR: Empty message", file=sys.stderr)
1287
+ sys.exit(2)
1288
+
1289
+ # Run validation
1290
+ result = validate_message(message, args.type, args.recipient, channel=args.channel)
1291
+
1292
+ # Log to audit
1293
+ log_validation_result(result)
1294
+
1295
+ # Output JSON result
1296
+ print(json.dumps(result, indent=2))
1297
+
1298
+ # Human-readable summary to stderr (unless --quiet)
1299
+ if not args.quiet:
1300
+ meta = result["metadata"]
1301
+ if result["status"] == "clean":
1302
+ print(
1303
+ f"VALIDATION PASS: {meta['checks_run']} checks, "
1304
+ f"0 issues ({meta['elapsed_ms']}ms)",
1305
+ file=sys.stderr
1306
+ )
1307
+ else:
1308
+ print(
1309
+ f"VALIDATION {'BLOCK' if meta['blockers'] > 0 else 'WARN'}: "
1310
+ f"{meta['blockers']} blockers, {meta['warnings']} warnings "
1311
+ f"({meta['elapsed_ms']}ms)",
1312
+ file=sys.stderr
1313
+ )
1314
+ for issue in result["issues"]:
1315
+ severity_marker = "BLOCK" if issue["severity"] == "block" else "WARN"
1316
+ print(
1317
+ f" [{severity_marker}] {issue['description']}",
1318
+ file=sys.stderr
1319
+ )
1320
+ if issue.get("suggestion"):
1321
+ print(
1322
+ f" -> {issue['suggestion']}",
1323
+ file=sys.stderr
1324
+ )
1325
+
1326
+ # Exit code: 1 if blockers, 0 otherwise
1327
+ sys.exit(1 if result["metadata"]["blockers"] > 0 else 0)
1328
+
1329
+
1330
+ # ─────────────────────────────────────────────
1331
+ # Test Suite
1332
+ # ─────────────────────────────────────────────
1333
+
1334
+ def run_tests():
1335
+ """Run built-in validation tests. Returns True if all pass."""
1336
+ passed = 0
1337
+ failed = 0
1338
+ total = 0
1339
+
1340
+ def assert_test(name, condition, detail=""):
1341
+ nonlocal passed, failed, total
1342
+ total += 1
1343
+ if condition:
1344
+ passed += 1
1345
+ print(f" PASS: {name}", file=sys.stderr)
1346
+ else:
1347
+ failed += 1
1348
+ print(f" FAIL: {name}{' — ' + detail if detail else ''}", file=sys.stderr)
1349
+
1350
+ print("Running validate-outbound.py tests...\n", file=sys.stderr)
1351
+
1352
+ # ── Test 1: Entity index loads without error ──
1353
+ print("[Entity Index Loading]", file=sys.stderr)
1354
+ idx = load_entity_index()
1355
+ assert_test("Entity index loads", isinstance(idx, dict))
1356
+ assert_test("Entity index has entities", len(idx.get('entities', [])) > 0)
1357
+ assert_test("Entity index has relationships", len(idx.get('relationships', [])) > 0)
1358
+ assert_test("Entity index has by_name lookup", len(idx.get('by_name', {})) > 0)
1359
+
1360
+ # ── Test 2: Entity lookup — known internal people ──
1361
+ print("\n[Entity Lookup — Internal]", file=sys.stderr)
1362
+ alex = lookup_entity("Alex Chen", idx)
1363
+ assert_test("Alex found", alex is not None)
1364
+ assert_test("Alex is internal", alex and alex.get('is_internal') is True)
1365
+ assert_test("Alex rel_type is internal", alex and alex.get('relationship_type') == 'internal')
1366
+
1367
+ sam = lookup_entity("Sam", idx)
1368
+ assert_test("Sam found by first name", sam is not None)
1369
+ assert_test("Sam is internal", sam and sam.get('is_internal') is True)
1370
+
1371
+ robin = lookup_entity("Robin Hayes", idx)
1372
+ assert_test("Robin found", robin is not None)
1373
+ assert_test("Robin is internal", robin and robin.get('is_internal') is True)
1374
+
1375
+ # ── Test 3: Entity lookup — known external people ──
1376
+ print("\n[Entity Lookup — External]", file=sys.stderr)
1377
+ graham = lookup_entity("Graham Syder", idx)
1378
+ assert_test("Graham found", graham is not None)
1379
+ assert_test("Graham is NOT internal", graham and graham.get('is_internal') is False)
1380
+ assert_test(
1381
+ "Graham is vendor_candidate",
1382
+ graham and graham.get('relationship_type') == 'vendor_candidate',
1383
+ f"got: {graham.get('relationship_type') if graham else 'None'}"
1384
+ )
1385
+
1386
+ rishel = lookup_entity("Rishel", idx)
1387
+ assert_test("Rishel found by first name", rishel is not None)
1388
+ assert_test("Rishel is NOT internal", rishel and rishel.get('is_internal') is False)
1389
+
1390
+ # ── Test 4: Entity lookup — unknown person ──
1391
+ print("\n[Entity Lookup — Unknown]", file=sys.stderr)
1392
+ unknown = lookup_entity("John Smith", idx)
1393
+ assert_test("Unknown person returns None", unknown is None)
1394
+
1395
+ unknown_empty = lookup_entity("", idx)
1396
+ assert_test("Empty string returns None", unknown_empty is None)
1397
+
1398
+ # ── Test 5: Graham incident — MUST be blocked ──
1399
+ print("\n[Graham Incident — Possessive Claim Block]", file=sys.stderr)
1400
+ graham_msg = "Graham Syder is our project manager for the DIFC fitout."
1401
+ result = validate_message(graham_msg, "email", "internal@example.com")
1402
+ assert_test(
1403
+ "Graham 'our PM' is BLOCKED",
1404
+ result['status'] == 'issues_found' and result['metadata']['blockers'] > 0,
1405
+ f"status={result['status']}, blockers={result['metadata']['blockers']}"
1406
+ )
1407
+ # Verify entity_index was the lookup source
1408
+ rel_issues = [i for i in result['issues'] if i['type'] == 'relationship_claim']
1409
+ entity_sourced = any(i.get('lookup_source') == 'entity_index' for i in rel_issues)
1410
+ assert_test(
1411
+ "Graham block used entity_index as source",
1412
+ entity_sourced,
1413
+ f"sources: {[i.get('lookup_source') for i in rel_issues]}"
1414
+ )
1415
+
1416
+ # Also test variant: "our PM Graham"
1417
+ graham_msg2 = "I've arranged for our PM Graham to review the floor plan."
1418
+ result2 = validate_message(graham_msg2, "slack", "C099ABCDEF")
1419
+ assert_test(
1420
+ "Graham 'our PM Graham' variant is BLOCKED",
1421
+ result2['status'] == 'issues_found' and result2['metadata']['blockers'] > 0,
1422
+ f"status={result2['status']}, blockers={result2['metadata']['blockers']}"
1423
+ )
1424
+
1425
+ # ── Test 6: Internal person — MUST be allowed ──
1426
+ print("\n[Internal Person — Allowed Claims]", file=sys.stderr)
1427
+ sam_msg = "Sam Rivera is our CFO and will be handling the the regulator submission."
1428
+ result3 = validate_message(sam_msg, "email", "external@example.com")
1429
+ rel_issues3 = [i for i in result3['issues'] if i['type'] == 'relationship_claim']
1430
+ assert_test(
1431
+ "Sam 'our CFO' is NOT blocked",
1432
+ len(rel_issues3) == 0,
1433
+ f"found {len(rel_issues3)} relationship issues"
1434
+ )
1435
+
1436
+ # ── Test 7: AI self-disclosure still blocked ──
1437
+ print("\n[AI Self-Disclosure Block]", file=sys.stderr)
1438
+ ai_msg = "I am an AI assistant and I can help with that."
1439
+ result4 = validate_message(ai_msg, "email", "external@example.com")
1440
+ ai_issues = [i for i in result4['issues'] if i['type'] == 'ai_self_disclosure']
1441
+ assert_test("AI self-disclosure blocked", len(ai_issues) > 0)
1442
+
1443
+ ai_msg_robin = "Robin is an AI-operated automated assistant."
1444
+ result4b = validate_message(ai_msg_robin, "email", "external@example.com")
1445
+ ai_issues_b = [i for i in result4b['issues'] if i['type'] == 'ai_self_disclosure']
1446
+ assert_test("Robin third-person AI disclosure blocked", len(ai_issues_b) > 0)
1447
+
1448
+ # ── Test 8: In-person self-scheduling still blocked ──
1449
+ print("\n[In-Person Self-Scheduling Block]", file=sys.stderr)
1450
+ attend_msg = "I'll attend the meeting at the DIFC office tomorrow."
1451
+ result5 = validate_message(attend_msg, "slack", "C099ABCDEF")
1452
+ attend_issues = [i for i in result5['issues'] if i['type'] == 'in_person_self_scheduling']
1453
+ assert_test("In-person self-scheduling blocked", len(attend_issues) > 0)
1454
+
1455
+ # ── Test 9: Clean message passes ──
1456
+ print("\n[Clean Message — No Issues]", file=sys.stderr)
1457
+ clean_msg = "Hi team, the quarterly report is ready for review. Please check your inboxes."
1458
+ result6 = validate_message(clean_msg, "email", "team@example.com")
1459
+ assert_test(
1460
+ "Clean message passes with no blockers",
1461
+ result6['metadata']['blockers'] == 0,
1462
+ f"blockers={result6['metadata']['blockers']}"
1463
+ )
1464
+
1465
+ # ── Test 10: Casey — investor, not internal employee ──
1466
+ print("\n[Casey — Investor, Not Internal]", file=sys.stderr)
1467
+ shayan = lookup_entity("Casey Morgan", idx)
1468
+ assert_test("Casey found", shayan is not None)
1469
+ assert_test(
1470
+ "Casey is NOT internal",
1471
+ shayan and shayan.get('is_internal') is False,
1472
+ f"is_internal={shayan.get('is_internal') if shayan else 'None'}"
1473
+ )
1474
+
1475
+ # ── Test 11: Performance — under 1 second ──
1476
+ print("\n[Performance]", file=sys.stderr)
1477
+ perf_msg = "Graham is our project manager. He has joined the team. We've hired him."
1478
+ import time as _time
1479
+ t0 = _time.time()
1480
+ for _ in range(10):
1481
+ validate_message(perf_msg, "email", "test@test.com")
1482
+ elapsed = (_time.time() - t0) / 10
1483
+ assert_test(
1484
+ f"Average validation < 1s (got {elapsed*1000:.1f}ms)",
1485
+ elapsed < 1.0
1486
+ )
1487
+
1488
+ # ── Summary ──
1489
+ print(f"\n{'='*50}", file=sys.stderr)
1490
+ print(f"Results: {passed}/{total} passed, {failed} failed", file=sys.stderr)
1491
+ if failed > 0:
1492
+ print("TESTS FAILED", file=sys.stderr)
1493
+ return False
1494
+ else:
1495
+ print("ALL TESTS PASSED", file=sys.stderr)
1496
+ return True
1497
+
1498
+
1499
+ if __name__ == "__main__":
1500
+ # Check for --test flag before argparse (to avoid --message requirement)
1501
+ if '--test' in sys.argv:
1502
+ success = run_tests()
1503
+ sys.exit(0 if success else 1)
1504
+ main()