@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,1206 @@
1
+ /**
2
+ * lib/model-router/resolve.mjs — the resolveChain "brain" of the router (v2).
3
+ *
4
+ * This is the policy engine that sits on top of the catalog + failover + ledger
5
+ * substrate (catalog.mjs, taxonomy.mjs, failover.mjs, health.mjs, ledger.mjs).
6
+ * `resolveChain(request, opts)` is a PURE function of its injected snapshot —
7
+ * config, catalog, breaker state, budget band, env, clock — so it makes no
8
+ * network call and (when the snapshot is injected) touches no filesystem. It
9
+ * returns a `RouteDecision` describing which model/harness to run a unit of work
10
+ * on, the env to retarget a session spawn, the spawn args, an estimated cost,
11
+ * the candidates it skipped (with reasons), and a MANDATORY one-line `explain`.
12
+ *
13
+ * Routing happens at task-dispatch time, NEVER mid-session. Cache prefixes are
14
+ * model-scoped; a mid-session switch silently re-bills the entire prefix.
15
+ *
16
+ * resolveChain(req):
17
+ * 0 MAESTRO_ROUTER_FORCE_ANTHROPIC kill switch → Anthropic-only chain
18
+ * 1 ensure req.data_class (deny-default "sensitive") + needs_tool_use (W5)
19
+ * 2 first-match over routing_policy (strict-validated structured features)
20
+ * 3 rule.chain aliases → catalog rows
21
+ * 4 gate each candidate IN ORDER, recording rejections in tried[]:
22
+ * status ▸ harness ▸ capability/grade ▸ context ▸ data_class
23
+ * ▸ credential(auth_env) ▸ breaker(health.isOpen) ▸ budget ladder
24
+ * 5 affinity pin moved to head when it still passes the gates
25
+ * 6 fallback_to_anthropic safety net if everything is rejected
26
+ * 7 build chosen + envForSpawn + spawnArgs + estCostUSD + explain + audit
27
+ *
28
+ * House style: ESM .mjs, Node 20, near-zero-dep; mirrors the DI discipline of
29
+ * lib/resource-governor.mjs / lib/rate-guard.mjs — every external dependency
30
+ * (catalog, breaker, env, clock, budget band) is injectable. NEVER throws on the
31
+ * resolve path: a broken snapshot degrades to the Anthropic safety net.
32
+ *
33
+ * @module lib/model-router/resolve
34
+ */
35
+
36
+ import { loadCatalogCached, lookupModel } from "./catalog.mjs";
37
+ import { estimateCost } from "./ledger.mjs";
38
+ import { keyFor, isOpen as breakerIsOpen } from "./health.mjs";
39
+ import { nextCredential as authNextCredential, available as authAvailable } from "./auth-profiles.mjs";
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // Constants / vocabulary
43
+ // ---------------------------------------------------------------------------
44
+
45
+ const GRADE_RANK = { A: 3, B: 2, C: 1 };
46
+ const DATA_CLASSES = new Set(["public", "internal", "sensitive"]);
47
+ const DATA_CLASS_RANK = { public: 1, internal: 2, sensitive: 3 };
48
+ const HARNESSES = new Set(["session", "direct", "batch"]);
49
+
50
+ // The session-retarget transport: a row whose chosen harness is `session` runs
51
+ // inside `claude` via ANTHROPIC_BASE_URL. Anthropic rows keep the stock CLI/API;
52
+ // third-party rows that expose an `/anthropic` endpoint are "anthropic-native".
53
+ const TRANSPORT = Object.freeze({
54
+ anthropicCli: "anthropic-cli",
55
+ anthropicApi: "anthropic-api",
56
+ anthropicNative: "anthropic-native",
57
+ anthropicMessages: "anthropic-messages",
58
+ openaiCompletions: "openai-completions",
59
+ });
60
+
61
+ // The closed set of routing_policy match keys. A key outside this set is a
62
+ // strict-validation error (W10 — a typo'd safety rule must fail loudly, never
63
+ // silently never-match). All match against STRUCTURED METADATA only — message
64
+ // content never reaches the rule engine (prompt-injection cannot steer T0).
65
+ const VALID_MATCH_KEYS = new Set([
66
+ // v2 features
67
+ "task_class",
68
+ "channel",
69
+ "source",
70
+ "data_class",
71
+ "lane",
72
+ "priority",
73
+ "agent_role",
74
+ "agent_role_in",
75
+ "needs_tool_use",
76
+ "needs_thinking",
77
+ "needs_vision",
78
+ "needs_long_context",
79
+ "needs_parallel_tools",
80
+ "token_estimate_gte",
81
+ "token_estimate_lt",
82
+ "budget_band_gte",
83
+ "pin",
84
+ // glob-friendly alternates handled by matchValue (task_class supports "*")
85
+ ]);
86
+
87
+ // Default per-task-class maxTurns; falls back to a generic ceiling.
88
+ const DEFAULT_MAX_TURNS = 40;
89
+ const TASK_CLASS_MAX_TURNS = Object.freeze({
90
+ "classify.inbox": 1,
91
+ "lookup": 2,
92
+ "enrich": 3,
93
+ "voice.brief": 4,
94
+ "learning.reflect": 6,
95
+ "learning.curate": 6,
96
+ });
97
+
98
+ // ---------------------------------------------------------------------------
99
+ // Small helpers
100
+ // ---------------------------------------------------------------------------
101
+
102
+ function nowMs(opts) {
103
+ return (opts && typeof opts.now === "function" ? opts.now : Date.now)();
104
+ }
105
+
106
+ function envOf(opts) {
107
+ return (opts && opts.env) || process.env || {};
108
+ }
109
+
110
+ function isForceAnthropic(env) {
111
+ const v = env && env.MAESTRO_ROUTER_FORCE_ANTHROPIC;
112
+ return v === "1" || v === "true";
113
+ }
114
+
115
+ /**
116
+ * A short, sortable, ULID-ish id: 10 chars of base32 time + 8 random base32.
117
+ * Not a real ULID (no Crockford spec guarantees) but monotonic-ish and unique
118
+ * enough to join a decision row ↔ ledger row ↔ resume marker.
119
+ */
120
+ const B32 = "0123456789abcdefghjkmnpqrstvwxyz";
121
+ function ulidish(now, rnd = Math.random) {
122
+ let t = Math.floor(now);
123
+ let time = "";
124
+ for (let i = 0; i < 10; i++) {
125
+ time = B32[t % 32] + time;
126
+ t = Math.floor(t / 32);
127
+ }
128
+ let rand = "";
129
+ for (let i = 0; i < 8; i++) rand += B32[Math.floor(rnd() * 32)];
130
+ return time + rand;
131
+ }
132
+
133
+ /** Resolve the merged catalog from opts (injected catalog wins; else load). */
134
+ function resolveCatalog(agentRoot, opts) {
135
+ if (opts && opts.catalog) {
136
+ // Accept either a CatalogResult or its .catalog
137
+ return opts.catalog.byRef ? opts.catalog : opts.catalog.catalog || opts.catalog;
138
+ }
139
+ return loadCatalogCached(agentRoot, opts || {});
140
+ }
141
+
142
+ // ---------------------------------------------------------------------------
143
+ // Policy rule matching (structured metadata only)
144
+ // ---------------------------------------------------------------------------
145
+
146
+ /** Glob match supporting a single trailing/embedded "*" (task_class "lookup.*"). */
147
+ function globMatch(pattern, value) {
148
+ if (typeof pattern !== "string") return pattern === value;
149
+ if (!pattern.includes("*")) return pattern === value;
150
+ const re = new RegExp(
151
+ "^" + pattern.split("*").map((s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join(".*") + "$"
152
+ );
153
+ return typeof value === "string" && re.test(value);
154
+ }
155
+
156
+ function matchesRule(match, req) {
157
+ for (const [key, expected] of Object.entries(match || {})) {
158
+ switch (key) {
159
+ case "token_estimate_gte":
160
+ if ((req.token_estimate ?? 0) < expected) return false;
161
+ break;
162
+ case "token_estimate_lt":
163
+ if ((req.token_estimate ?? 0) >= expected) return false;
164
+ break;
165
+ case "budget_band_gte":
166
+ if ((req.budget_band ?? 0) < expected) return false;
167
+ break;
168
+ case "agent_role_in":
169
+ if (!Array.isArray(expected) || !expected.includes(req.agent_role)) return false;
170
+ break;
171
+ case "task_class":
172
+ case "channel":
173
+ case "source":
174
+ case "lane":
175
+ if (!globMatch(expected, req[key])) return false;
176
+ break;
177
+ case "pin":
178
+ if (Boolean(req.pin) !== Boolean(expected)) return false;
179
+ break;
180
+ default:
181
+ if (req[key] !== expected) return false;
182
+ }
183
+ }
184
+ return true;
185
+ }
186
+
187
+ /** First-match over routing_policy. Returns { rule, index } or the default. */
188
+ function pickRule(policy, req) {
189
+ const rules = Array.isArray(policy) ? policy : [];
190
+ for (let i = 0; i < rules.length; i++) {
191
+ const rule = rules[i];
192
+ if (!rule) continue;
193
+ if (rule.default) return { rule, index: i };
194
+ if (matchesRule(rule.match || {}, req)) return { rule, index: i };
195
+ }
196
+ // No rule matched and no explicit default — synthesize one.
197
+ return { rule: { default: true, synthesized: true }, index: -1 };
198
+ }
199
+
200
+ // ---------------------------------------------------------------------------
201
+ // Alias resolution → catalog refs
202
+ // ---------------------------------------------------------------------------
203
+
204
+ /**
205
+ * Resolve a chain of aliases/refs to concrete catalog refs, preserving order.
206
+ * A "rules" member (the caller's deterministic fallback as a chain member) is
207
+ * passed through as a sentinel ref so failover.mjs / the caller can treat it as
208
+ * a terminal candidate. Unknown aliases collapse to null (recorded by caller).
209
+ */
210
+ function resolveChainRefs(chainSpec, aliases) {
211
+ const out = [];
212
+ for (const member of Array.isArray(chainSpec) ? chainSpec : []) {
213
+ if (member === "rules") {
214
+ out.push({ ref: "rules", isRules: true, alias: "rules" });
215
+ continue;
216
+ }
217
+ if (typeof member !== "string") continue;
218
+ // A member may be an alias name OR a literal "provider/model" ref.
219
+ const ref = aliases && Object.prototype.hasOwnProperty.call(aliases, member)
220
+ ? aliases[member]
221
+ : member;
222
+ out.push({ ref, isRules: false, alias: member === ref ? null : member });
223
+ }
224
+ return out;
225
+ }
226
+
227
+ // ---------------------------------------------------------------------------
228
+ // Budget ladder (degrade, never cliff) — operates on the alias list
229
+ // ---------------------------------------------------------------------------
230
+
231
+ const DEGRADE_ORDER = ["frontier", "default", "fast", "cheap"];
232
+
233
+ /**
234
+ * Apply the budget ladder to a resolved chain of alias-tagged refs. Returns a
235
+ * possibly-reordered/extended ref list + a note for the explain string. Pins
236
+ * and critical priority are exempt below band 100.
237
+ */
238
+ function applyBudgetLadder(refs, req, aliases, band) {
239
+ if (!band || band < 75) return { refs, note: null };
240
+ const exempt = req.pin || req.priority === "critical";
241
+ if (exempt) return { refs, note: `band ${band} (exempt: ${req.pin ? "pin" : "critical"})` };
242
+
243
+ // Map known aliases to their degrade index; downgrade one tier at >=75.
244
+ const cheapest = (name) => {
245
+ const i = DEGRADE_ORDER.indexOf(name);
246
+ return i === -1 ? name : DEGRADE_ORDER[Math.min(DEGRADE_ORDER.length - 1, i + 1)];
247
+ };
248
+
249
+ if (band >= 90) {
250
+ // cheap/fast only: drop frontier/default aliases from the head, keep cheap/fast.
251
+ const kept = refs.filter((r) => r.alias !== "frontier" && r.alias !== "default");
252
+ const head = kept.length ? kept : refs; // never end up with an empty chain
253
+ return { refs: head, note: `band ${band} downgraded to cheap/fast lane` };
254
+ }
255
+
256
+ // band >= 75: downgrade one tier where the alias is a known degrade rung.
257
+ let changed = false;
258
+ const next = refs.map((r) => {
259
+ if (r.alias && DEGRADE_ORDER.includes(r.alias)) {
260
+ const downAlias = cheapest(r.alias);
261
+ if (downAlias !== r.alias && aliases[downAlias]) {
262
+ changed = true;
263
+ return { ref: aliases[downAlias], isRules: false, alias: downAlias };
264
+ }
265
+ }
266
+ return r;
267
+ });
268
+ return { refs: next, note: changed ? `band ${band} downgraded one tier` : `band ${band}` };
269
+ }
270
+
271
+ // ---------------------------------------------------------------------------
272
+ // Per-candidate gating
273
+ // ---------------------------------------------------------------------------
274
+
275
+ /**
276
+ * Resolve the credential a row needs, honoring an optional pooled auth-profile
277
+ * set (OpenClaw auth-profile rotation). ADDITIVE + FAIL-OPEN:
278
+ * - No pool wired (ctx.authProfiles absent) OR provider not pooled (<=1 key) →
279
+ * fall back to the single env var exactly as before (token = env[auth_env]).
280
+ * - Pooled (>1 key) → pick the next non-cooled credential round-robin; if every
281
+ * key is cooling, return { token:null, poolExhausted:true } so the gate skips
282
+ * this backend with `missing_credential` (the pool looks dry — failover moves
283
+ * on, the per-key cooldowns self-heal independently).
284
+ *
285
+ * Returns { token: string|null, pooled: boolean, fingerprint: string|null,
286
+ * poolExhausted: boolean }.
287
+ */
288
+ function resolveRowCredential(row, ctx) {
289
+ const authEnv = row.auth_env;
290
+ if (!authEnv) return { token: null, pooled: false, fingerprint: null, poolExhausted: false };
291
+
292
+ const profiles = ctx && ctx.authProfiles;
293
+ const provider = row.provider;
294
+ const pooled =
295
+ profiles && typeof profiles.isPooled === "function" && profiles.isPooled(provider);
296
+
297
+ // Single-key (or no pool): preserve today's behavior exactly.
298
+ if (!pooled) {
299
+ const v = ctx.env[authEnv];
300
+ const token = typeof v === "string" && v.trim() !== "" ? v : null;
301
+ return { token, pooled: false, fingerprint: null, poolExhausted: false };
302
+ }
303
+
304
+ // Pooled: pick the next non-cooled credential. The pick advances the cursor
305
+ // (persisted under the ledger lock) so repeated resolves rotate.
306
+ const pick = authNextCredential(provider, {
307
+ profiles,
308
+ now: ctx.now,
309
+ deps: ctx.authProfileDeps,
310
+ });
311
+ if (pick && pick.key) {
312
+ return { token: pick.key, pooled: true, fingerprint: pick.fingerprint, poolExhausted: false };
313
+ }
314
+ // Every key in the pool is cooling — the backend is effectively credential-less
315
+ // for now. Surface as missing_credential so the gate skips it.
316
+ return { token: null, pooled: true, fingerprint: null, poolExhausted: true };
317
+ }
318
+
319
+ /**
320
+ * Run a candidate row through the ordered gate. Returns { ok:true } or
321
+ * { ok:false, reason } where reason is one of the tried[] reason strings.
322
+ */
323
+ function gateCandidate(row, req, ctx) {
324
+ // 1. status — only `available` rows are routable.
325
+ if (row.status !== "available") {
326
+ return { ok: false, reason: `status_${row.status}` };
327
+ }
328
+
329
+ // 2. harness — the requested harness must be offered by the row.
330
+ const harness = ctx.harness;
331
+ if (harness && row.harness && row.harness[harness] !== true) {
332
+ return { ok: false, reason: `harness_unavailable:${harness}` };
333
+ }
334
+
335
+ // 3. capability + tool-reliability grade.
336
+ // A session that needs tool use requires grade A (§6.6) — grade B/C rows
337
+ // are structurally unreachable for sessions.
338
+ if (req.needs_tool_use && harness === "session") {
339
+ const rank = row.tool_reliability ? GRADE_RANK[row.tool_reliability] : 0;
340
+ if (rank < GRADE_RANK.A) {
341
+ return { ok: false, reason: "grade_too_low" };
342
+ }
343
+ }
344
+ // Explicit grade floor from the rule (minGrade).
345
+ if (ctx.minGrade) {
346
+ const rank = row.tool_reliability ? GRADE_RANK[row.tool_reliability] : 0;
347
+ if (rank < (GRADE_RANK[ctx.minGrade] || 0)) {
348
+ return { ok: false, reason: "grade_too_low" };
349
+ }
350
+ }
351
+
352
+ // 4. context — token_estimate must fit the row's effective runtime cap.
353
+ if (req.token_estimate != null && row.context_tokens != null) {
354
+ if (req.token_estimate > row.context_tokens) {
355
+ return { ok: false, reason: "context_overflow" };
356
+ }
357
+ }
358
+
359
+ // 5. data_class — deny-by-default. The provider's allowed_data_classes
360
+ // (config backends ∩ overlay max) must include the request's data_class.
361
+ const allowed = ctx.allowedDataClasses[row.provider];
362
+ if (allowed && !allowed.has(req.data_class)) {
363
+ return { ok: false, reason: "data_class_denied" };
364
+ }
365
+ // Even without a backends entry, anything but Anthropic is deny-by-default for
366
+ // non-public classes unless the config explicitly allows it.
367
+ if (!allowed && row.provider !== "anthropic" && req.data_class !== "public") {
368
+ return { ok: false, reason: "data_class_denied" };
369
+ }
370
+
371
+ // 6. credential — auth_env must be present & non-empty (key-absence-as-
372
+ // enforcement, §7.1). EXCEPTION: an Anthropic SESSION candidate rides the
373
+ // stock `claude` CLI / keychain-OAuth (or a Console key the spawner owns),
374
+ // so it never needs ANTHROPIC_API_KEY injected — the v1 resolver treated
375
+ // anthropic-cli the same way. Direct-api Anthropic and ALL third-party rows
376
+ // require their credential or they are structurally unreachable.
377
+ const sessionAnthropic = row.provider === "anthropic" && harness === "session";
378
+ if (row.auth_env && !sessionAnthropic) {
379
+ const cred = resolveRowCredential(row, ctx);
380
+ if (!cred.token) {
381
+ return { ok: false, reason: "missing_credential" };
382
+ }
383
+ }
384
+
385
+ // 7. breaker — the per-backend AND per-backend:model breaker must be closed.
386
+ const backendKey = keyFor(row.provider);
387
+ const modelKey = keyFor(row.provider, modelClassOf(row));
388
+ if (ctx.isOpen(backendKey).open) return { ok: false, reason: "breaker_open" };
389
+ if (ctx.isOpen(modelKey).open) return { ok: false, reason: "breaker_open" };
390
+
391
+ return { ok: true };
392
+ }
393
+
394
+ /**
395
+ * The model-class segment for the breaker key. We use the bare id; callers that
396
+ * want coarser bucketing (sonnet/haiku) can override via catalog, but the id is
397
+ * the stable, collision-free default.
398
+ */
399
+ function modelClassOf(row) {
400
+ return row.id;
401
+ }
402
+
403
+ // ---------------------------------------------------------------------------
404
+ // envForSpawn / transport / wire derivation
405
+ // ---------------------------------------------------------------------------
406
+
407
+ /**
408
+ * Build the env to retarget a `claude` SESSION spawn onto a row's backend.
409
+ * - Anthropic rows: no retarget (stock CLI/OAuth or Console key the caller owns).
410
+ * - Third-party rows with an /anthropic endpoint: ANTHROPIC_BASE_URL retarget +
411
+ * ANTHROPIC_AUTH_TOKEN from auth_env + ANTHROPIC_API_KEY="" (so Claude Code
412
+ * does NOT fall back to keychain OAuth) + ANTHROPIC_MODEL.
413
+ * Returns {} for non-session harnesses (direct-api builds its own request).
414
+ */
415
+ function buildEnvForSpawn(row, harness, env, config, credCtx) {
416
+ if (harness !== "session") return {};
417
+ if (row.provider === "anthropic") return {};
418
+
419
+ const baseUrl = row.endpoints && row.endpoints.anthropic;
420
+ if (!baseUrl) return {}; // not session-capable on the anthropic wire; caller falls back
421
+
422
+ const out = {
423
+ ANTHROPIC_BASE_URL: baseUrl,
424
+ ANTHROPIC_API_KEY: "", // explicit empty string — NOT unset (W: keychain fallthrough)
425
+ ANTHROPIC_MODEL: row.id,
426
+ ANTHROPIC_SMALL_FAST_MODEL: row.id,
427
+ };
428
+ // Auth token: a pooled auth-profile set picks the next non-cooled credential
429
+ // (additive — a single-key setup falls through to env[auth_env] exactly as
430
+ // before). credCtx carries { authProfiles, authProfileDeps, now, env }.
431
+ if (row.auth_env) {
432
+ const cred = credCtx ? resolveRowCredential(row, credCtx) : null;
433
+ if (cred && cred.pooled && cred.token) {
434
+ out.ANTHROPIC_AUTH_TOKEN = cred.token;
435
+ } else if (typeof env[row.auth_env] === "string") {
436
+ out.ANTHROPIC_AUTH_TOKEN = env[row.auth_env];
437
+ }
438
+ }
439
+ if (!config || config.strip_attribution_header !== false) {
440
+ out.CLAUDE_CODE_ATTRIBUTION_HEADER = "0";
441
+ }
442
+ if (!config || config.disable_experimental_betas !== false) {
443
+ out.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS = "1";
444
+ }
445
+ return out;
446
+ }
447
+
448
+ function transportFor(row, harness) {
449
+ if (harness === "session") {
450
+ if (row.provider === "anthropic") return TRANSPORT.anthropicApi;
451
+ return TRANSPORT.anthropicNative;
452
+ }
453
+ // direct-api / batch
454
+ if (row.provider === "anthropic") return TRANSPORT.anthropicMessages;
455
+ return TRANSPORT.openaiCompletions;
456
+ }
457
+
458
+ function wireFor(row, harness) {
459
+ // The wire dialect the direct-api path speaks. Session spawns ride `claude`,
460
+ // so the wire is informational there.
461
+ if (row.provider === "anthropic") return "anthropic";
462
+ // A row routed on the anthropic-native session transport still speaks the
463
+ // anthropic wire; a direct-api third-party call uses the openai wire.
464
+ if (harness === "session") return "anthropic";
465
+ return "openai";
466
+ }
467
+
468
+ function modelFlagFor(row) {
469
+ // For Anthropic the CLI accepts the shorthand; for others Claude Code passes
470
+ // ANTHROPIC_MODEL through, so the flag is the concrete id either way.
471
+ return row.id;
472
+ }
473
+
474
+ function maxTurnsFor(taskClass) {
475
+ if (!taskClass) return DEFAULT_MAX_TURNS;
476
+ if (TASK_CLASS_MAX_TURNS[taskClass] != null) return TASK_CLASS_MAX_TURNS[taskClass];
477
+ // Prefix match (e.g. "lookup.gmail" → "lookup").
478
+ const head = String(taskClass).split(".")[0];
479
+ if (TASK_CLASS_MAX_TURNS[head] != null) return TASK_CLASS_MAX_TURNS[head];
480
+ return DEFAULT_MAX_TURNS;
481
+ }
482
+
483
+ // ---------------------------------------------------------------------------
484
+ // data_class resolution from config + overlay
485
+ // ---------------------------------------------------------------------------
486
+
487
+ /**
488
+ * Build provider -> Set(allowed data classes), intersecting config backends with
489
+ * the overlay's allowed_data_classes_max (tighten-only).
490
+ */
491
+ function buildAllowedDataClasses(config, overlay) {
492
+ const out = {};
493
+ const backends = (config && config.backends) || {};
494
+ for (const [name, b] of Object.entries(backends)) {
495
+ const adc = Array.isArray(b && b.allowed_data_classes) ? b.allowed_data_classes : null;
496
+ if (adc) out[name] = new Set(adc.filter((c) => DATA_CLASSES.has(c)));
497
+ }
498
+ // Anthropic defaults to all classes if unspecified.
499
+ if (!out.anthropic) out.anthropic = new Set(["public", "internal", "sensitive"]);
500
+
501
+ const max = overlay && overlay.constraints && overlay.constraints.allowed_data_classes_max;
502
+ if (max && typeof max === "object") {
503
+ for (const [name, list] of Object.entries(max)) {
504
+ if (!Array.isArray(list)) continue;
505
+ const cap = new Set(list.filter((c) => DATA_CLASSES.has(c)));
506
+ out[name] = out[name] ? intersect(out[name], cap) : cap;
507
+ }
508
+ }
509
+ return out;
510
+ }
511
+
512
+ function intersect(a, b) {
513
+ const out = new Set();
514
+ for (const x of a) if (b.has(x)) out.add(x);
515
+ return out;
516
+ }
517
+
518
+ // ---------------------------------------------------------------------------
519
+ // resolveChain — the public entry point
520
+ // ---------------------------------------------------------------------------
521
+
522
+ /**
523
+ * @typedef {object} RouteDecision
524
+ * @property {string} decision_id
525
+ * @property {Array<{ref,harness,transport}>} chain
526
+ * @property {{provider,model,harness,transport,wire,catalogRow}} chosen
527
+ * @property {object} envForSpawn
528
+ * @property {{modelFlag,maxTurns,effort,maxBudgetUsd,agentsJson,bare}} spawnArgs
529
+ * @property {string} lane
530
+ * @property {string} cacheAffinityKey
531
+ * @property {string} cache_ttl
532
+ * @property {number|null} estCostUSD
533
+ * @property {Array<{ref,ok:false,reason}>} tried
534
+ * @property {string} explain MANDATORY one-line human string
535
+ * @property {{rule,fallback_reason,config_source,overlay_version,catalog_provenance}} audit
536
+ */
537
+
538
+ /**
539
+ * Resolve a RouteRequest to a RouteDecision. NEVER throws — a broken snapshot
540
+ * degrades to an Anthropic safety-net decision (or, under the kill switch, an
541
+ * Anthropic-only chain).
542
+ *
543
+ * @param {object} request RouteRequest (§4.3)
544
+ * @param {object} [opts]
545
+ * @param {string} [opts.agentRoot]
546
+ * @param {object} [opts.config] normalised routing config (v2)
547
+ * @param {object} [opts.catalog] CatalogResult (injected; else loaded)
548
+ * @param {object} [opts.overlay] org policy overlay (already verified)
549
+ * @param {object} [opts.env] env for credential + kill-switch checks
550
+ * @param {(key:string)=>{open:boolean,until,reason,strikes}} [opts.isOpen]
551
+ * breaker probe (defaults to health.isOpen)
552
+ * @param {()=>number} [opts.now]
553
+ * @param {(n)=>number} [opts.rng]
554
+ * @returns {RouteDecision}
555
+ */
556
+ export function resolveChain(request = {}, opts = {}) {
557
+ const env = envOf(opts);
558
+ const now = nowMs(opts);
559
+ const agentRoot = opts.agentRoot;
560
+ const config = opts.config || null;
561
+ const overlay = opts.overlay || (config && config.overlay) || null;
562
+
563
+ // Breaker probe: injectable; defaults to the on-disk health.mjs breaker.
564
+ const isOpen = typeof opts.isOpen === "function"
565
+ ? (key) => opts.isOpen(key, now)
566
+ : (key) => breakerIsOpen(key, now, opts.breakerDeps || { agentRoot });
567
+
568
+ // 1. Normalise the request (deny-default data_class; W5 needs_tool_use).
569
+ const req = normaliseRequest(request, config);
570
+
571
+ const catalogResult = safe(() => resolveCatalog(agentRoot, opts), null);
572
+ const catalog = catalogResult && (catalogResult.byRef ? catalogResult : catalogResult.catalog)
573
+ ? catalogResult
574
+ : null;
575
+
576
+ const tried = [];
577
+ const audit = {
578
+ rule: null,
579
+ fallback_reason: null,
580
+ config_source: (config && config.source_path) || (catalogResult && catalogResult.sources && catalogResult.sources.join("+")) || null,
581
+ overlay_version: (overlay && overlay.version) || null,
582
+ catalog_provenance: catalogProvenanceTag(catalog),
583
+ };
584
+
585
+ // 0. Kill switch — force an Anthropic-only chain regardless of policy.
586
+ if (isForceAnthropic(env)) {
587
+ return forceAnthropicDecision(catalog, req, { now, opts, audit, reason: "kill_switch" });
588
+ }
589
+
590
+ // No config at all → inert v2 path: an Anthropic safety-net decision so the
591
+ // caller always has a usable chosen (matching the "collapse to today" rule).
592
+ if (!config || (config.schema_version !== 2 && !Array.isArray(config.routing_policy))) {
593
+ return forceAnthropicDecision(catalog, req, { now, opts, audit, reason: "no_v2_config" });
594
+ }
595
+
596
+ const aliases = (config.aliases && typeof config.aliases === "object") ? config.aliases : {};
597
+ const allowedDataClasses = buildAllowedDataClasses(config, overlay);
598
+
599
+ // 2. First-match rule.
600
+ const { rule, index } = pickRule(config.routing_policy, req);
601
+ audit.rule = rule.default ? (rule.synthesized ? "<synthesized-default>" : "<default>") : (rule.match ? JSON.stringify(rule.match) : `rule[${index}]`);
602
+
603
+ // Rule-level overrides.
604
+ const ruleHarness = rule.harness || req.harness_hint || (req.lane === "batch" ? "batch" : "session");
605
+ const harness = HARNESSES.has(ruleHarness) ? ruleHarness : "session";
606
+ // A rule may opt out of tool use explicitly (classify/lookup are tool-less).
607
+ if (rule.needs_tool_use === false) req.needs_tool_use = false;
608
+ const minGrade = rule.minGrade || null;
609
+ const lane = req.lane || (harness === "batch" ? "batch" : "realtime");
610
+
611
+ // 3. Alias chain → refs, then budget ladder.
612
+ let refs = resolveChainRefs(rule.chain, aliases);
613
+ const ladder = applyBudgetLadder(refs, req, aliases, req.budget_band);
614
+ refs = ladder.refs;
615
+
616
+ // 4. Gate each candidate; first survivor is chosen.
617
+ const ctx = {
618
+ env,
619
+ harness,
620
+ minGrade,
621
+ allowedDataClasses,
622
+ isOpen,
623
+ // Auth-profile rotation context (additive; absent ⇒ single-key behavior).
624
+ authProfiles: opts.authProfiles || null,
625
+ authProfileDeps: opts.authProfileDeps || (agentRoot ? { agentRoot } : undefined),
626
+ now,
627
+ };
628
+
629
+ let chosenRow = null;
630
+ let chosenHarness = harness;
631
+ const chainOut = [];
632
+ const explainSkips = [];
633
+
634
+ for (const member of refs) {
635
+ if (member.isRules) {
636
+ // The deterministic-fallback sentinel: a terminal candidate the caller
637
+ // owns. It always "passes" (no model gate) but is never the chosen row
638
+ // unless nothing else survived — record it on the chain and continue.
639
+ chainOut.push({ ref: "rules", harness: "direct", transport: "rules" });
640
+ if (!chosenRow) {
641
+ // Keep walking; a real model later in the chain is preferred. We only
642
+ // fall to "rules" if no model survives (handled after the loop).
643
+ }
644
+ continue;
645
+ }
646
+ const row = catalog ? lookupModel(catalog, member.ref) : null;
647
+ if (!row) {
648
+ tried.push({ ref: member.ref, ok: false, reason: "unknown_ref" });
649
+ explainSkips.push(`${member.ref} (unknown_ref)`);
650
+ continue;
651
+ }
652
+ const gate = gateCandidate(row, req, ctx);
653
+ if (!gate.ok) {
654
+ tried.push({ ref: member.ref, ok: false, reason: gate.reason });
655
+ explainSkips.push(`${member.ref} (${gate.reason})`);
656
+ continue;
657
+ }
658
+ // Survivor. Record it on the visible chain.
659
+ chainOut.push({
660
+ ref: row.ref,
661
+ harness: chosenHarness,
662
+ transport: transportFor(row, chosenHarness),
663
+ });
664
+ if (!chosenRow) {
665
+ chosenRow = row;
666
+ // Continue building the rest of the chain (the failover tail) from the
667
+ // remaining members so spawnRouted has somewhere to go.
668
+ }
669
+ }
670
+
671
+ // 4b. If no model survived but the chain had a "rules" sentinel, the caller's
672
+ // deterministic fallback is the answer (direct, no model).
673
+ const hasRules = refs.some((m) => m.isRules);
674
+
675
+ // 5. Affinity pin — if a live pin for the session_key passes the gates, move
676
+ // it to the head; a pin that fails the gates emits pin_overridden.
677
+ let affinityNote = null;
678
+ if (req.pin && req.pin.ref && catalog) {
679
+ const pinRow = lookupModel(catalog, req.pin.ref);
680
+ if (pinRow) {
681
+ const g = gateCandidate(pinRow, req, ctx);
682
+ if (g.ok) {
683
+ chosenRow = pinRow;
684
+ chosenHarness = harness;
685
+ // Move/insert the pin at the chain head.
686
+ const head = { ref: pinRow.ref, harness, transport: transportFor(pinRow, harness) };
687
+ const rest = chainOut.filter((c) => c.ref !== pinRow.ref);
688
+ chainOut.length = 0;
689
+ chainOut.push(head, ...rest);
690
+ affinityNote = `pin ${pinRow.ref}`;
691
+ } else {
692
+ tried.push({ ref: pinRow.ref, ok: false, reason: `pin_overridden:${g.reason}` });
693
+ affinityNote = `pin_overridden (${g.reason})`;
694
+ }
695
+ }
696
+ }
697
+
698
+ // 6. Fallback to the Anthropic safety net when nothing survived.
699
+ if (!chosenRow) {
700
+ if (hasRules) {
701
+ return rulesDecision(req, { now, opts, audit, tried, chainOut, lane, explainSkips });
702
+ }
703
+ if (config.fallback_to_anthropic !== false) {
704
+ audit.fallback_reason = "no_compatible_candidate";
705
+ return forceAnthropicDecision(catalog, req, {
706
+ now, opts, audit, tried, reason: "no_compatible_candidate", harness, lane,
707
+ explainSkips,
708
+ });
709
+ }
710
+ // No fallback declared — return an explicit no-route decision.
711
+ return noRouteDecision(req, { now, opts, audit, tried, explainSkips, lane });
712
+ }
713
+
714
+ // 7. Build the decision.
715
+ return buildDecision({
716
+ catalog,
717
+ row: chosenRow,
718
+ harness: chosenHarness,
719
+ req,
720
+ config,
721
+ env,
722
+ now,
723
+ opts,
724
+ rule,
725
+ audit,
726
+ tried,
727
+ chain: chainOut,
728
+ lane,
729
+ ladderNote: ladder.note,
730
+ affinityNote,
731
+ explainSkips,
732
+ credCtx: ctx,
733
+ });
734
+ }
735
+
736
+ // ---------------------------------------------------------------------------
737
+ // Request normalisation
738
+ // ---------------------------------------------------------------------------
739
+
740
+ function normaliseRequest(request, config) {
741
+ const req = { ...request };
742
+ // data_class deny-default.
743
+ if (!DATA_CLASSES.has(req.data_class)) {
744
+ const dflt = (config && config.defaults && config.defaults.data_class) || "sensitive";
745
+ req.data_class = DATA_CLASSES.has(dflt) ? dflt : "sensitive";
746
+ }
747
+ // needs_tool_use default-true for SESSION work (W5), unless explicitly set.
748
+ const sessionDefaultTools =
749
+ !config || !config.defaults || config.defaults.needs_tool_use_for_sessions !== false;
750
+ if (req.needs_tool_use === undefined) {
751
+ const willBeSession = (req.harness_hint || (req.lane === "batch" ? "batch" : "session")) === "session";
752
+ req.needs_tool_use = willBeSession && sessionDefaultTools;
753
+ }
754
+ return req;
755
+ }
756
+
757
+ // ---------------------------------------------------------------------------
758
+ // Decision builders
759
+ // ---------------------------------------------------------------------------
760
+
761
+ function buildDecision({ catalog, row, harness, req, config, env, now, opts, rule, audit, tried, chain, lane, ladderNote, affinityNote, explainSkips, credCtx }) {
762
+ const decision_id = ulidish(now, opts.rng);
763
+ const transport = transportFor(row, harness);
764
+ const envForSpawn = buildEnvForSpawn(row, harness, env, config, credCtx);
765
+
766
+ const cache_ttl =
767
+ (config && config.defaults && config.defaults.cache_ttl) ||
768
+ (lane === "realtime" ? "1h" : "1h");
769
+
770
+ const cacheAffinityKey = req.session_key
771
+ ? `${req.session_key}:${row.ref}`
772
+ : `${req.task_class || "task"}:${row.ref}`;
773
+
774
+ // Estimate cost (budget projection: volatile rows at steady-state).
775
+ const tokenIn = req.token_estimate ?? 0;
776
+ const tokenOut = req.token_estimate_out ?? Math.floor(tokenIn * 0.4);
777
+ const est = estimateCost(catalog, {
778
+ ref: row.ref,
779
+ inputTokens: tokenIn,
780
+ outputTokens: tokenOut,
781
+ cacheReadTokens: req.cache_read_estimate ?? 0,
782
+ budget: true,
783
+ });
784
+
785
+ const spawnArgs = {
786
+ modelFlag: modelFlagFor(row),
787
+ maxTurns: maxTurnsFor(req.task_class),
788
+ effort: rule && rule.effort ? rule.effort : null,
789
+ maxBudgetUsd: rule && rule.max_budget_usd != null ? rule.max_budget_usd : null,
790
+ agentsJson: rule && rule.agents_json ? rule.agents_json : null,
791
+ bare: true,
792
+ };
793
+
794
+ const explain = buildExplain({
795
+ req, rule, row, harness, ladderNote, affinityNote, explainSkips, est,
796
+ });
797
+
798
+ return {
799
+ decision_id,
800
+ chain,
801
+ chosen: {
802
+ provider: row.provider,
803
+ model: row.id,
804
+ harness,
805
+ transport,
806
+ wire: wireFor(row, harness),
807
+ catalogRow: row,
808
+ },
809
+ envForSpawn,
810
+ spawnArgs,
811
+ lane,
812
+ cacheAffinityKey,
813
+ cache_ttl,
814
+ estCostUSD: est.usd,
815
+ tried,
816
+ explain,
817
+ audit,
818
+ };
819
+ }
820
+
821
+ /**
822
+ * The Anthropic-only / safety-net decision. Used by the kill switch, the
823
+ * no-v2-config inert path, and the "nothing survived the gates" fallback. Picks
824
+ * the best available Anthropic row for the request's needs.
825
+ */
826
+ function forceAnthropicDecision(catalog, req, { now, opts, audit, tried = [], reason, harness = "session", lane = "realtime", explainSkips = [] }) {
827
+ const row = pickAnthropicRow(catalog, req);
828
+ const decision_id = ulidish(now, opts && opts.rng);
829
+ const env = envOf(opts);
830
+
831
+ if (!row) {
832
+ // No catalog at all — degrade to a synthetic chosen so the caller still has
833
+ // a usable model flag (stock `claude` defaults to its account model).
834
+ const synthetic = {
835
+ provider: "anthropic",
836
+ id: req.needs_thinking || req.priority === "critical" ? "claude-opus-4-8" : "claude-sonnet-4-6",
837
+ ref: "anthropic/claude-sonnet-4-6",
838
+ status: "available",
839
+ auth_env: null,
840
+ endpoints: {},
841
+ cost: {},
842
+ cost_provenance: {},
843
+ compat: {},
844
+ harness: { session: true, direct: true, batch: true },
845
+ tool_reliability: "A",
846
+ context_tokens: null,
847
+ };
848
+ return synthDecision(synthetic, req, { now, opts, audit, tried, reason, harness, lane, explainSkips, env, catalog });
849
+ }
850
+ return synthDecision(row, req, { now, opts, audit, tried, reason, harness, lane, explainSkips, env, catalog });
851
+ }
852
+
853
+ function synthDecision(row, req, { now, opts, audit, tried, reason, harness, lane, explainSkips, env, catalog }) {
854
+ const decision_id = ulidish(now, opts && opts.rng);
855
+ const transport = transportFor(row, harness);
856
+ const est = catalog ? estimateCost(catalog, {
857
+ ref: row.ref,
858
+ inputTokens: req.token_estimate ?? 0,
859
+ outputTokens: req.token_estimate_out ?? Math.floor((req.token_estimate ?? 0) * 0.4),
860
+ budget: true,
861
+ }) : { usd: null };
862
+ audit.fallback_reason = audit.fallback_reason || reason;
863
+
864
+ const explainBase =
865
+ reason === "kill_switch"
866
+ ? "kill switch (MAESTRO_ROUTER_FORCE_ANTHROPIC) → Anthropic-only"
867
+ : reason === "no_v2_config"
868
+ ? "no v2 routing config → Anthropic safety net"
869
+ : `${req.task_class || "task"} → Anthropic safety net (${reason})`;
870
+ const skipNote = explainSkips && explainSkips.length ? `; skipped ${explainSkips.join(", ")}` : "";
871
+
872
+ return {
873
+ decision_id,
874
+ chain: [{ ref: row.ref, harness, transport }],
875
+ chosen: {
876
+ provider: "anthropic",
877
+ model: row.id,
878
+ harness,
879
+ transport,
880
+ wire: "anthropic",
881
+ catalogRow: row,
882
+ },
883
+ envForSpawn: {}, // Anthropic session: stock CLI/Console key, no retarget
884
+ spawnArgs: {
885
+ modelFlag: row.id,
886
+ maxTurns: maxTurnsFor(req.task_class),
887
+ effort: null,
888
+ maxBudgetUsd: null,
889
+ agentsJson: null,
890
+ bare: true,
891
+ },
892
+ lane,
893
+ cacheAffinityKey: req.session_key ? `${req.session_key}:${row.ref}` : `${req.task_class || "task"}:${row.ref}`,
894
+ cache_ttl: "1h",
895
+ estCostUSD: est.usd,
896
+ tried,
897
+ explain: `${explainBase}: ${row.ref}${skipNote}`,
898
+ audit,
899
+ };
900
+ }
901
+
902
+ /** The deterministic-fallback ("rules") decision — no model, caller-owned. */
903
+ function rulesDecision(req, { now, opts, audit, tried, chainOut, lane, explainSkips }) {
904
+ const decision_id = ulidish(now, opts && opts.rng);
905
+ audit.fallback_reason = audit.fallback_reason || "rules_fallback";
906
+ const skipNote = explainSkips && explainSkips.length ? ` (skipped ${explainSkips.join(", ")})` : "";
907
+ return {
908
+ decision_id,
909
+ chain: chainOut,
910
+ chosen: {
911
+ provider: "rules",
912
+ model: "rules",
913
+ harness: "direct",
914
+ transport: "rules",
915
+ wire: "none",
916
+ catalogRow: null,
917
+ },
918
+ envForSpawn: {},
919
+ spawnArgs: { modelFlag: null, maxTurns: 1, effort: null, maxBudgetUsd: null, agentsJson: null, bare: true },
920
+ lane,
921
+ cacheAffinityKey: null,
922
+ cache_ttl: "0",
923
+ estCostUSD: 0,
924
+ tried,
925
+ explain: `${req.task_class || "task"} → deterministic rules fallback${skipNote}`,
926
+ audit,
927
+ };
928
+ }
929
+
930
+ /** No-route decision when no model survives and fallback_to_anthropic is off. */
931
+ function noRouteDecision(req, { now, opts, audit, tried, explainSkips, lane }) {
932
+ const decision_id = ulidish(now, opts && opts.rng);
933
+ audit.fallback_reason = "no_route";
934
+ return {
935
+ decision_id,
936
+ chain: [],
937
+ chosen: null,
938
+ envForSpawn: {},
939
+ spawnArgs: { modelFlag: null, maxTurns: 0, effort: null, maxBudgetUsd: null, agentsJson: null, bare: true },
940
+ lane,
941
+ cacheAffinityKey: null,
942
+ cache_ttl: "0",
943
+ estCostUSD: null,
944
+ tried,
945
+ explain: `${req.task_class || "task"} → NO ROUTE (no compatible candidate, fallback disabled); skipped ${(explainSkips || []).join(", ")}`,
946
+ audit,
947
+ };
948
+ }
949
+
950
+ /**
951
+ * Pick the best available Anthropic row from the catalog for a request:
952
+ * frontier (opus) for thinking/critical, else the default workhorse (sonnet),
953
+ * else any available anthropic row.
954
+ */
955
+ function pickAnthropicRow(catalog, req) {
956
+ if (!catalog) return null;
957
+ const wantFrontier = req.needs_thinking || req.priority === "critical" || req.agent_role === "regulatory";
958
+ const order = wantFrontier
959
+ ? ["anthropic/claude-opus-4-8", "anthropic/claude-sonnet-4-6", "anthropic/claude-haiku-4-5"]
960
+ : ["anthropic/claude-sonnet-4-6", "anthropic/claude-haiku-4-5", "anthropic/claude-opus-4-8"];
961
+ for (const ref of order) {
962
+ const row = lookupModel(catalog, ref);
963
+ if (row && row.status === "available") {
964
+ // Context gate: if the request is too big for this row, try the next.
965
+ if (req.token_estimate != null && row.context_tokens != null && req.token_estimate > row.context_tokens) continue;
966
+ return row;
967
+ }
968
+ }
969
+ // Any available anthropic row.
970
+ const models = (catalog.models || (catalog.catalog && catalog.catalog.models)) || [];
971
+ return models.find((r) => r.provider === "anthropic" && r.status === "available") || null;
972
+ }
973
+
974
+ // ---------------------------------------------------------------------------
975
+ // Explain string (mandatory, one line)
976
+ // ---------------------------------------------------------------------------
977
+
978
+ function buildExplain({ req, rule, row, harness, ladderNote, affinityNote, explainSkips, est }) {
979
+ const parts = [];
980
+ parts.push(`${req.task_class || req.source || "task"} →`);
981
+ const ruleLabel = rule.default
982
+ ? (rule.synthesized ? "synthesized-default" : "default")
983
+ : (rule.match ? `rule ${shortMatch(rule.match)}` : "rule");
984
+ parts.push(ruleLabel);
985
+ if (ladderNote) parts.push(`[${ladderNote}]`);
986
+ if (affinityNote) parts.push(`[${affinityNote}]`);
987
+ if (explainSkips && explainSkips.length) {
988
+ parts.push(`skipped ${explainSkips.join(", ")} →`);
989
+ } else {
990
+ parts.push("→");
991
+ }
992
+ parts.push(`${row.provider}/${row.id} (${harness})`);
993
+ if (est && est.usd != null) parts.push(`~$${est.usd}`);
994
+ return parts.join(" ");
995
+ }
996
+
997
+ function shortMatch(match) {
998
+ const keys = Object.keys(match);
999
+ if (!keys.length) return "{}";
1000
+ const k = keys[0];
1001
+ const v = match[k];
1002
+ return `${k}=${Array.isArray(v) ? `[${v.length}]` : v}${keys.length > 1 ? "…" : ""}`;
1003
+ }
1004
+
1005
+ // ---------------------------------------------------------------------------
1006
+ // Catalog provenance tag (for the audit + doctor staleness ladder)
1007
+ // ---------------------------------------------------------------------------
1008
+
1009
+ function catalogProvenanceTag(catalog) {
1010
+ if (!catalog) return null;
1011
+ const models = (catalog.models || (catalog.catalog && catalog.catalog.models)) || [];
1012
+ let oldest = null;
1013
+ let anyVolatile = false;
1014
+ for (const r of models) {
1015
+ const p = r.cost_provenance || {};
1016
+ if (p.volatile) anyVolatile = true;
1017
+ if (p.fetched && (oldest == null || p.fetched < oldest)) oldest = p.fetched;
1018
+ }
1019
+ return { oldestFetched: oldest, anyVolatile };
1020
+ }
1021
+
1022
+ function safe(fn, dflt) {
1023
+ try {
1024
+ return fn();
1025
+ } catch {
1026
+ return dflt;
1027
+ }
1028
+ }
1029
+
1030
+ // ---------------------------------------------------------------------------
1031
+ // validateRoutingConfig — strict v2 config validation (W10/W8/G14)
1032
+ // ---------------------------------------------------------------------------
1033
+
1034
+ /**
1035
+ * Validate a v2 routing config against the catalog (+ optional overlay). Returns
1036
+ * `{ errors:[], warnings:[] }`. NEVER throws. Surfaces:
1037
+ * - unknown match keys (capability typos)
1038
+ * - unknown aliases referenced in a chain
1039
+ * - chain refs not present in the catalog
1040
+ * - auth_env unset for a referenced (non-anthropic) provider (warning — the
1041
+ * key may live in .env which we don't load here)
1042
+ * - attempts to widen tool_reliability or add a provider via the overlay
1043
+ * - invalid harness / data_class / budget bands
1044
+ *
1045
+ * @param {object} config normalised v2 routing config
1046
+ * @param {object} [deps]
1047
+ * @param {object} [deps.catalog] CatalogResult (or .catalog)
1048
+ * @param {object} [deps.overlay]
1049
+ * @param {object} [deps.env] env for the auth_env presence check
1050
+ * @returns {{errors:Array<{where:string,error:string}>, warnings:Array<{where:string,warning:string}>}}
1051
+ */
1052
+ export function validateRoutingConfig(config, deps = {}) {
1053
+ const errors = [];
1054
+ const warnings = [];
1055
+ const env = deps.env || process.env || {};
1056
+ const catalog = deps.catalog && (deps.catalog.byRef ? deps.catalog : deps.catalog.catalog || deps.catalog);
1057
+ const overlay = deps.overlay || null;
1058
+
1059
+ if (!config || typeof config !== "object") {
1060
+ errors.push({ where: "config", error: "config is empty or not an object" });
1061
+ return { errors, warnings };
1062
+ }
1063
+
1064
+ // Only v2 configs are strict-validated here; v1 configs flow through the v1
1065
+ // loader's own normalisation.
1066
+ if (config.schema_version !== 2) {
1067
+ return { errors, warnings };
1068
+ }
1069
+
1070
+ const aliases = (config.aliases && typeof config.aliases === "object") ? config.aliases : {};
1071
+
1072
+ // Aliases must resolve to a catalog ref (or be a literal ref).
1073
+ for (const [name, ref] of Object.entries(aliases)) {
1074
+ if (typeof ref !== "string") {
1075
+ errors.push({ where: `aliases.${name}`, error: "alias target must be a string ref" });
1076
+ continue;
1077
+ }
1078
+ if (catalog && !lookupModel(catalog, ref)) {
1079
+ errors.push({ where: `aliases.${name}`, error: `alias points at unknown catalog row "${ref}"` });
1080
+ }
1081
+ }
1082
+
1083
+ // backends data-class sanity.
1084
+ for (const [name, b] of Object.entries(config.backends || {})) {
1085
+ if (b && b.allowed_data_classes) {
1086
+ if (!Array.isArray(b.allowed_data_classes)) {
1087
+ errors.push({ where: `backends.${name}`, error: "allowed_data_classes must be an array" });
1088
+ } else {
1089
+ for (const c of b.allowed_data_classes) {
1090
+ if (!DATA_CLASSES.has(c)) errors.push({ where: `backends.${name}`, error: `unknown data_class "${c}"` });
1091
+ }
1092
+ }
1093
+ }
1094
+ }
1095
+
1096
+ // routing_policy rules.
1097
+ const rules = Array.isArray(config.routing_policy) ? config.routing_policy : [];
1098
+ if (!rules.length) {
1099
+ warnings.push({ where: "routing_policy", warning: "no routing rules — every request falls to the Anthropic safety net" });
1100
+ }
1101
+ let sawDefault = false;
1102
+ rules.forEach((rule, i) => {
1103
+ if (!rule || typeof rule !== "object") {
1104
+ errors.push({ where: `routing_policy[${i}]`, error: "rule is not an object" });
1105
+ return;
1106
+ }
1107
+ if (rule.default) sawDefault = true;
1108
+ // match keys.
1109
+ if (rule.match && typeof rule.match === "object") {
1110
+ for (const key of Object.keys(rule.match)) {
1111
+ if (!VALID_MATCH_KEYS.has(key)) {
1112
+ errors.push({ where: `routing_policy[${i}].match`, error: `unknown match key "${key}" (typo?)` });
1113
+ }
1114
+ }
1115
+ }
1116
+ // harness.
1117
+ if (rule.harness !== undefined && !HARNESSES.has(rule.harness)) {
1118
+ errors.push({ where: `routing_policy[${i}]`, error: `unknown harness "${rule.harness}"` });
1119
+ }
1120
+ // minGrade.
1121
+ if (rule.minGrade !== undefined && !GRADE_RANK[rule.minGrade]) {
1122
+ errors.push({ where: `routing_policy[${i}]`, error: `unknown minGrade "${rule.minGrade}"` });
1123
+ }
1124
+ // chain refs.
1125
+ const chain = rule.chain;
1126
+ if (!rule.default && !Array.isArray(chain)) {
1127
+ errors.push({ where: `routing_policy[${i}]`, error: "non-default rule has no chain[]" });
1128
+ }
1129
+ if (Array.isArray(chain)) {
1130
+ for (const member of chain) {
1131
+ if (member === "rules") continue;
1132
+ if (typeof member !== "string") {
1133
+ errors.push({ where: `routing_policy[${i}].chain`, error: "chain member must be a string alias/ref" });
1134
+ continue;
1135
+ }
1136
+ const ref = Object.prototype.hasOwnProperty.call(aliases, member) ? aliases[member] : member;
1137
+ if (!Object.prototype.hasOwnProperty.call(aliases, member) && !member.includes("/")) {
1138
+ errors.push({ where: `routing_policy[${i}].chain`, error: `"${member}" is neither a known alias nor a "provider/model" ref` });
1139
+ continue;
1140
+ }
1141
+ if (catalog && !lookupModel(catalog, ref)) {
1142
+ errors.push({ where: `routing_policy[${i}].chain`, error: `chain ref "${ref}" not in catalog` });
1143
+ } else if (catalog) {
1144
+ // Credential presence (warning only — keys often live in .env, which
1145
+ // doctor/the daemon load at spawn time). Skip Anthropic: a session
1146
+ // candidate rides keychain OAuth, so a missing ANTHROPIC_API_KEY is
1147
+ // not a routing blocker the way a missing third-party key is.
1148
+ const row = lookupModel(catalog, ref);
1149
+ if (
1150
+ row && row.auth_env && row.provider !== "anthropic" &&
1151
+ (typeof env[row.auth_env] !== "string" || env[row.auth_env].trim() === "")
1152
+ ) {
1153
+ warnings.push({ where: `routing_policy[${i}].chain`, warning: `${ref} needs ${row.auth_env} — unset in this env (key-absence enforcement will skip it)` });
1154
+ }
1155
+ }
1156
+ }
1157
+ }
1158
+ });
1159
+ if (rules.length && !sawDefault) {
1160
+ warnings.push({ where: "routing_policy", warning: "no default rule — unmatched requests fall to the Anthropic safety net" });
1161
+ }
1162
+
1163
+ // budget_ladder bands.
1164
+ if (config.budget_ladder && typeof config.budget_ladder === "object") {
1165
+ for (const band of Object.keys(config.budget_ladder)) {
1166
+ if (!/^\d+$/.test(band)) {
1167
+ warnings.push({ where: "budget_ladder", warning: `band key "${band}" is not numeric` });
1168
+ }
1169
+ }
1170
+ }
1171
+
1172
+ // Overlay tighten-only widening attempts (provider add / grade raise) are
1173
+ // caught by catalog.mjs at merge; surface a duplicate guard here against the
1174
+ // CONFIG (an agent config cannot widen what the overlay tightened).
1175
+ if (overlay && overlay.constraints) {
1176
+ const allowed = Array.isArray(overlay.constraints.backends_allowed)
1177
+ ? new Set(overlay.constraints.backends_allowed)
1178
+ : null;
1179
+ if (allowed) {
1180
+ for (const name of Object.keys(config.backends || {})) {
1181
+ if (!allowed.has(name) && name !== "anthropic") {
1182
+ errors.push({ where: `backends.${name}`, error: `backend "${name}" is not in the org overlay's backends_allowed (tighten-only — cannot add)` });
1183
+ }
1184
+ }
1185
+ }
1186
+ }
1187
+
1188
+ return { errors, warnings };
1189
+ }
1190
+
1191
+ export const _internals = {
1192
+ matchesRule,
1193
+ pickRule,
1194
+ resolveChainRefs,
1195
+ applyBudgetLadder,
1196
+ gateCandidate,
1197
+ buildEnvForSpawn,
1198
+ buildAllowedDataClasses,
1199
+ resolveRowCredential,
1200
+ ulidish,
1201
+ pickAnthropicRow,
1202
+ GRADE_RANK,
1203
+ VALID_MATCH_KEYS,
1204
+ };
1205
+
1206
+ export default { resolveChain, validateRoutingConfig };