@jr2/orchestrator 0.6.0 → 0.7.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 (301) hide show
  1. package/console/components/app.ts +55 -15
  2. package/console/components/bottom-drawer.ts +259 -0
  3. package/console/components/drawer.ts +14 -6
  4. package/console/components/fleet.ts +36 -13
  5. package/console/components/machine-pane.ts +61 -5
  6. package/console/components/nav.ts +54 -8
  7. package/console/conversation.ts +161 -0
  8. package/console/login.ts +123 -0
  9. package/console/main.ts +600 -60
  10. package/console/store.ts +611 -64
  11. package/console/style.css +231 -0
  12. package/docs/agents-block.md +2 -2
  13. package/docs/concepts/agents-and-turns.md +86 -9
  14. package/docs/concepts/architecture.md +41 -28
  15. package/docs/concepts/callers-and-hooks.md +205 -0
  16. package/docs/concepts/charts.md +7 -5
  17. package/docs/concepts/composition.md +10 -1
  18. package/docs/concepts/converge.md +4 -2
  19. package/docs/concepts/durable-runs.md +43 -16
  20. package/docs/concepts/effects.md +50 -22
  21. package/docs/concepts/gates.md +95 -25
  22. package/docs/concepts/machines-and-workflows.md +5 -4
  23. package/docs/concepts/pools-and-sources.md +187 -11
  24. package/docs/concepts/secrets-and-trust.md +73 -33
  25. package/docs/concepts/the-menu.md +11 -0
  26. package/docs/concepts/workspaces.md +6 -5
  27. package/docs/get-started/a-model-decides.md +14 -13
  28. package/docs/get-started/an-agent-in-a-sandbox.md +43 -34
  29. package/docs/get-started/connect-a-model.md +1 -1
  30. package/docs/get-started/create-an-instance.md +17 -14
  31. package/docs/guides/add-tools-to-the-sandbox.md +55 -11
  32. package/docs/guides/answer-a-gate-from-a-forge.md +304 -0
  33. package/docs/guides/answer-a-gate.md +74 -37
  34. package/docs/guides/bind-open-parts.md +24 -8
  35. package/docs/guides/call-an-mcp-tool-from-a-machine.md +572 -0
  36. package/docs/guides/cancel-a-run.md +26 -16
  37. package/docs/guides/fix-a-failed-converge.md +18 -6
  38. package/docs/guides/give-a-caller-a-scope.md +203 -0
  39. package/docs/guides/give-an-agent-mcp-tools.md +422 -0
  40. package/docs/guides/iterate-on-a-machine-on-kind.md +10 -9
  41. package/docs/guides/let-an-agent-write-a-chart.md +9 -6
  42. package/docs/guides/look-inside-a-sandbox.md +101 -31
  43. package/docs/guides/open-the-console.md +63 -14
  44. package/docs/guides/read-a-conversation.md +225 -0
  45. package/docs/guides/run-a-chart-by-name.md +17 -16
  46. package/docs/guides/run-one-worker-per-item.md +15 -9
  47. package/docs/guides/set-an-agents-access-and-dials.md +2 -0
  48. package/docs/guides/start-a-run-for-each-alert.md +290 -0
  49. package/docs/guides/start-a-run-from-a-run.md +18 -16
  50. package/docs/guides/start-a-run.md +24 -18
  51. package/docs/guides/steer-an-agent.md +332 -0
  52. package/docs/guides/use-a-hosted-provider.md +1 -1
  53. package/docs/guides/watch-a-run.md +127 -121
  54. package/docs/guides/write-a-hook-kind.md +238 -0
  55. package/docs/guides/write-a-tool-as-an-mcp-server.md +351 -0
  56. package/docs/guides/write-a-workflow.md +17 -7
  57. package/docs/llms.txt +81 -15
  58. package/docs/reference/authoring-surface/agent.md +1 -1
  59. package/docs/reference/authoring-surface/answer.md +1 -1
  60. package/docs/reference/authoring-surface/arg.md +1 -1
  61. package/docs/reference/authoring-surface/basic.md +1 -1
  62. package/docs/reference/authoring-surface/bearer.md +1 -1
  63. package/docs/reference/authoring-surface/branchname.md +1 -1
  64. package/docs/reference/authoring-surface/carry.md +23 -0
  65. package/docs/reference/authoring-surface/chartschema.md +1 -1
  66. package/docs/reference/authoring-surface/chartschemafor.md +1 -1
  67. package/docs/reference/authoring-surface/command.md +1 -1
  68. package/docs/reference/authoring-surface/customize.md +4 -1
  69. package/docs/reference/authoring-surface/defineconfig.md +1 -1
  70. package/docs/reference/authoring-surface/defineevent.md +1 -1
  71. package/docs/reference/authoring-surface/dispatch.md +5 -4
  72. package/docs/reference/authoring-surface/fail.md +1 -1
  73. package/docs/reference/authoring-surface/file.md +1 -1
  74. package/docs/reference/authoring-surface/frompick.md +1 -1
  75. package/docs/reference/authoring-surface/gate.md +118 -0
  76. package/docs/reference/authoring-surface/group-agents.md +15 -0
  77. package/docs/reference/authoring-surface/group-charts.md +16 -0
  78. package/docs/reference/authoring-surface/group-composition.md +25 -0
  79. package/docs/reference/authoring-surface/group-config.md +17 -0
  80. package/docs/reference/authoring-surface/group-effects.md +48 -0
  81. package/docs/reference/authoring-surface/group-failure.md +13 -0
  82. package/docs/reference/authoring-surface/group-gates.md +16 -0
  83. package/docs/reference/authoring-surface/group-machine-and-events.md +15 -0
  84. package/docs/reference/authoring-surface/group-steering.md +12 -0
  85. package/docs/reference/authoring-surface/group-types-these-signatures-name.md +137 -0
  86. package/docs/reference/authoring-surface/group-workspaces-and-repos.md +15 -0
  87. package/docs/reference/authoring-surface/http.md +1 -1
  88. package/docs/reference/authoring-surface/isfailure.md +1 -1
  89. package/docs/reference/authoring-surface/isready.md +1 -1
  90. package/docs/reference/authoring-surface/jr2setup.md +13 -3
  91. package/docs/reference/authoring-surface/mcp.md +107 -0
  92. package/docs/reference/authoring-surface/open.md +5 -3
  93. package/docs/reference/authoring-surface/openbody.md +1 -1
  94. package/docs/reference/authoring-surface/paramsof.md +1 -1
  95. package/docs/reference/authoring-surface/pool.md +77 -9
  96. package/docs/reference/authoring-surface/push.md +1 -1
  97. package/docs/reference/authoring-surface/readyof.md +1 -1
  98. package/docs/reference/authoring-surface/reread.md +19 -0
  99. package/docs/reference/authoring-surface/secret.md +1 -1
  100. package/docs/reference/authoring-surface/sentby.md +1 -1
  101. package/docs/reference/authoring-surface/skip.md +22 -0
  102. package/docs/reference/authoring-surface/source.md +56 -1
  103. package/docs/reference/authoring-surface/steer.md +93 -0
  104. package/docs/reference/authoring-surface/text.md +1 -1
  105. package/docs/reference/authoring-surface/type-agentdefinition.md +14 -1
  106. package/docs/reference/authoring-surface/type-agentmcpserver.md +54 -0
  107. package/docs/reference/authoring-surface/type-anyframepath.md +1 -1
  108. package/docs/reference/authoring-surface/type-anyworktreepath.md +1 -1
  109. package/docs/reference/authoring-surface/type-binding.md +1 -1
  110. package/docs/reference/authoring-surface/type-caller.md +40 -0
  111. package/docs/reference/authoring-surface/type-callerrecord.md +32 -0
  112. package/docs/reference/authoring-surface/type-carry.md +19 -0
  113. package/docs/reference/authoring-surface/type-chainlink.md +3 -3
  114. package/docs/reference/authoring-surface/type-commandoutput.md +1 -1
  115. package/docs/reference/authoring-surface/type-customize.md +16 -1
  116. package/docs/reference/authoring-surface/type-delivered.md +17 -0
  117. package/docs/reference/authoring-surface/type-deliveredevent.md +18 -0
  118. package/docs/reference/authoring-surface/type-dispatchdefinition.md +4 -3
  119. package/docs/reference/authoring-surface/type-dispatchoutput.md +1 -1
  120. package/docs/reference/authoring-surface/type-effectbinding.md +12 -2
  121. package/docs/reference/authoring-surface/type-framepath.md +1 -1
  122. package/docs/reference/authoring-surface/type-gatebinding.md +15 -0
  123. package/docs/reference/authoring-surface/type-gatehooks.md +12 -0
  124. package/docs/reference/authoring-surface/type-gatelook.md +37 -0
  125. package/docs/reference/authoring-surface/type-gateoutcome.md +32 -0
  126. package/docs/reference/authoring-surface/type-gatereply.md +19 -0
  127. package/docs/reference/authoring-surface/type-gateslot.md +21 -0
  128. package/docs/reference/authoring-surface/type-gateslotinput.md +13 -0
  129. package/docs/reference/authoring-surface/type-gateslotparts.md +18 -0
  130. package/docs/reference/authoring-surface/type-gateslots.md +12 -0
  131. package/docs/reference/authoring-surface/type-gatetypesat.md +13 -0
  132. package/docs/reference/authoring-surface/type-hostinjectedinput.md +1 -1
  133. package/docs/reference/authoring-surface/type-httpoutput.md +1 -1
  134. package/docs/reference/authoring-surface/type-jr2config.md +37 -2
  135. package/docs/reference/authoring-surface/type-lateroutcome.md +1 -1
  136. package/docs/reference/authoring-surface/type-lends.md +5 -1
  137. package/docs/reference/authoring-surface/type-lookoutput.md +14 -0
  138. package/docs/reference/authoring-surface/type-mcpcalloutput.md +78 -0
  139. package/docs/reference/authoring-surface/type-mcpnotreturned.md +42 -0
  140. package/docs/reference/authoring-surface/type-mcpoptions.md +47 -0
  141. package/docs/reference/authoring-surface/type-mcpoutput.md +50 -0
  142. package/docs/reference/authoring-surface/type-mcpreadoutput.md +53 -0
  143. package/docs/reference/authoring-surface/type-mcpremoteserver.md +50 -0
  144. package/docs/reference/authoring-surface/type-mcpserver.md +41 -0
  145. package/docs/reference/authoring-surface/type-mcpstdioserver.md +42 -0
  146. package/docs/reference/authoring-surface/type-namedsecret.md +4 -4
  147. package/docs/reference/authoring-surface/type-openbodydeclaration.md +1 -1
  148. package/docs/reference/authoring-surface/type-openbodyoutput.md +1 -1
  149. package/docs/reference/authoring-surface/type-poolnextinput.md +1 -1
  150. package/docs/reference/authoring-surface/type-poolspec.md +11 -6
  151. package/docs/reference/authoring-surface/type-pushoutput.md +1 -1
  152. package/docs/reference/authoring-surface/type-pushsourcespec.md +38 -0
  153. package/docs/reference/authoring-surface/type-readsourcespec.md +47 -0
  154. package/docs/reference/authoring-surface/type-reposlot.md +1 -1
  155. package/docs/reference/authoring-surface/type-reread.md +18 -0
  156. package/docs/reference/authoring-surface/type-runsconfig.md +41 -0
  157. package/docs/reference/authoring-surface/type-sender.md +26 -1
  158. package/docs/reference/authoring-surface/type-sendercallerfield.md +14 -0
  159. package/docs/reference/authoring-surface/type-size.md +1 -1
  160. package/docs/reference/authoring-surface/type-skip.md +19 -0
  161. package/docs/reference/authoring-surface/type-sourcehooks.md +23 -0
  162. package/docs/reference/authoring-surface/type-sourcein.md +25 -0
  163. package/docs/reference/authoring-surface/type-sourcereply.md +18 -0
  164. package/docs/reference/authoring-surface/type-sourcespec.md +16 -7
  165. package/docs/reference/authoring-surface/type-sourceverdict.md +49 -0
  166. package/docs/reference/authoring-surface/type-standingpoolmachine.md +12 -0
  167. package/docs/reference/authoring-surface/type-standingpoolspec.md +51 -0
  168. package/docs/reference/authoring-surface/type-steerargs.md +20 -0
  169. package/docs/reference/authoring-surface/type-thinkinglevel.md +1 -1
  170. package/docs/reference/authoring-surface/type-usercontainer.md +1 -1
  171. package/docs/reference/authoring-surface/type-workspaced.md +1 -1
  172. package/docs/reference/authoring-surface/type-worktreefound.md +3 -3
  173. package/docs/reference/authoring-surface/type-worktreeinput.md +1 -1
  174. package/docs/reference/authoring-surface/type-worktreeoptions.md +1 -1
  175. package/docs/reference/authoring-surface/type-worktreeoutput.md +1 -1
  176. package/docs/reference/authoring-surface/type-worktreepath.md +1 -1
  177. package/docs/reference/authoring-surface/type-worktreeready.md +1 -1
  178. package/docs/reference/authoring-surface/type-worktreerefused.md +4 -4
  179. package/docs/reference/authoring-surface/workspace.md +1 -1
  180. package/docs/reference/authoring-surface/worktree.md +1 -1
  181. package/docs/reference/authoring-surface.md +88 -167
  182. package/docs/reference/cli.md +413 -74
  183. package/docs/reference/config.md +235 -2
  184. package/docs/reference/decisions/0002-duplex-streaming-actor.md +71 -11
  185. package/docs/reference/decisions/0005-sandbox-pod-composition.md +14 -10
  186. package/docs/reference/decisions/0006-agent-control-surface.md +4 -4
  187. package/docs/reference/decisions/0007-durable-machine-state.md +1 -1
  188. package/docs/reference/decisions/0009-cli-and-instance-interface.md +150 -49
  189. package/docs/reference/decisions/0010-bdd-acceptance-tests.md +5 -3
  190. package/docs/reference/decisions/0011-workflow-defined-events.md +256 -12
  191. package/docs/reference/decisions/0012-workspace-wrapper-machine.md +9 -2
  192. package/docs/reference/decisions/0013-the-agent-reaches-its-machine-through-a-container-it-cannot-read.md +20 -14
  193. package/docs/reference/decisions/0014-observation-is-open-run-state-is-not.md +53 -27
  194. package/docs/reference/decisions/0015-authoring-surface-absorbs-the-mechanism.md +18 -14
  195. package/docs/reference/decisions/0017-pool-over-a-source-port.md +226 -12
  196. package/docs/reference/decisions/0018-instance-agents-are-definitions-jr2-assembles-the-harness.md +15 -7
  197. package/docs/reference/decisions/0019-one-converging-command-against-the-current-context.md +32 -18
  198. package/docs/reference/decisions/0021-workspace-continuity-is-a-lease-that-answers-back.md +14 -1
  199. package/docs/reference/decisions/0022-observation-is-a-level-triggered-feed.md +4 -4
  200. package/docs/reference/decisions/0023-the-harness-prints-the-conversation.md +116 -34
  201. package/docs/reference/decisions/0024-an-agents-turn-ends-with-the-state-that-asked-for-it.md +38 -37
  202. package/docs/reference/decisions/0025-cancel-ends-a-run-stop-parks-it.md +10 -5
  203. package/docs/reference/decisions/0027-the-harness-is-jr2s-own-server-flue-retires-the-wire-stays.md +7 -6
  204. package/docs/reference/decisions/0028-what-an-agent-may-do-to-the-workspace-is-part-of-its-definition.md +23 -7
  205. package/docs/reference/decisions/0029-a-menu-is-fixed-for-a-conversation-and-a-turn-is-told-its-allowed-picks.md +18 -10
  206. package/docs/reference/decisions/0030-a-snapshot-names-the-machine-it-was-written-under.md +3 -2
  207. package/docs/reference/decisions/0031-menu-only-agents-run-on-the-instance-harness.md +23 -18
  208. package/docs/reference/decisions/0032-the-console-unlocks-with-a-callers-credential.md +101 -0
  209. package/docs/reference/decisions/0033-a-machine-declares-the-input-that-starts-it.md +4 -3
  210. package/docs/reference/decisions/0034-the-console-renders-with-preact-and-the-server-erases-the-types.md +6 -6
  211. package/docs/reference/decisions/0037-an-instance-builds-its-sandbox-images-jr2-injects-the-harness.md +5 -4
  212. package/docs/reference/decisions/0049-a-machine-carries-its-parts-and-composes-by-invoke.md +19 -19
  213. package/docs/reference/decisions/0050-what-code-names-config-declares-what-the-cli-names-discovery-finds.md +6 -1
  214. package/docs/reference/decisions/0054-the-kit-ships-machines-and-a-shipped-machine-leaves-its-parts-open.md +55 -40
  215. package/docs/reference/decisions/0055-a-release-is-a-pushed-tag-and-the-publish-guard-is-the-credential.md +14 -13
  216. package/docs/reference/decisions/0057-a-turn-is-its-frame-its-dials-and-whether-it-continues.md +7 -0
  217. package/docs/reference/decisions/0058-a-harness-answers-the-orchestrator-alone.md +13 -7
  218. package/docs/reference/decisions/0059-a-harness-holds-stand-ins-and-the-custodian-holds-the-keys.md +38 -30
  219. package/docs/reference/decisions/0062-jr2-briefs-the-agent-on-its-seat-never-on-its-task.md +64 -17
  220. package/docs/reference/decisions/0063-the-orchestrator-watches-the-cluster.md +2 -1
  221. package/docs/reference/decisions/0064-a-workspace-waits-for-capacity.md +2 -2
  222. package/docs/reference/decisions/0065-a-persist-survives-a-crash-and-history-is-kept-for-a-set-time.md +109 -0
  223. package/docs/reference/decisions/0066-a-model-tier-runs-a-real-model-and-a-change-to-what-it-reads-runs-the-tier.md +51 -23
  224. package/docs/reference/decisions/0067-an-effect-is-an-actor-slot-and-runs-at-most-once-unless-marked-idempotent.md +94 -54
  225. package/docs/reference/decisions/0068-an-effector-holds-an-effects-secrets-and-signs-its-wire.md +101 -47
  226. package/docs/reference/decisions/0069-the-orchestrator-is-a-kit-image-and-an-instance-publishes-its-assembly.md +32 -9
  227. package/docs/reference/decisions/0070-a-machine-lends-its-parts-to-a-chart.md +17 -13
  228. package/docs/reference/decisions/0071-a-run-starts-a-run-through-a-dispatch-effect.md +56 -30
  229. package/docs/reference/decisions/0072-the-docs-ship-inside-the-kit-and-an-instances-agents-md-indexes-them.md +35 -25
  230. package/docs/reference/decisions/0075-a-machine-cuts-a-worktree-and-the-workspace-only-clones.md +4 -3
  231. package/docs/reference/decisions/0076-an-agents-control-credential-lives-only-where-no-workspace-code-runs.md +37 -10
  232. package/docs/reference/decisions/0078-a-caller-comes-from-one-authenticator-seam-and-acts-within-its-scopes.md +285 -0
  233. package/docs/reference/decisions/0079-an-agent-holds-mcp-tools-a-machine-calls-them-as-an-effect-and-the-machine-is-the-approval.md +198 -0
  234. package/docs/reference/decisions.md +6 -4
  235. package/docs/reference/glossary.md +96 -42
  236. package/docs/reference/hooks.md +1077 -0
  237. package/docs/reference/http-api.md +976 -149
  238. package/docs/why.md +2 -1
  239. package/package.json +10 -3
  240. package/src/actor.ts +36 -10
  241. package/src/agent.ts +50 -0
  242. package/src/assembly-checks.ts +530 -4
  243. package/src/callers.ts +245 -0
  244. package/src/chart.ts +52 -7
  245. package/src/config.ts +550 -1
  246. package/src/conversations.ts +453 -0
  247. package/src/custodian.ts +8 -2
  248. package/src/customize.ts +143 -4
  249. package/src/dispatch-effect.ts +142 -26
  250. package/src/durability.ts +2 -0
  251. package/src/duration.ts +20 -0
  252. package/src/effect-checks.ts +2 -1
  253. package/src/effect.ts +60 -7
  254. package/src/effector-wire.ts +272 -27
  255. package/src/gate-brand.ts +16 -0
  256. package/src/gate.ts +634 -48
  257. package/src/harness-client.ts +10 -4
  258. package/src/held-secrets.ts +72 -23
  259. package/src/holds.ts +2 -2
  260. package/src/hook-kinds/discord.ts +92 -0
  261. package/src/hook-kinds/door.ts +107 -0
  262. package/src/hook-kinds/github.ts +89 -0
  263. package/src/hook-kinds/gitlab.ts +72 -0
  264. package/src/hook-kinds/hook.ts +7 -0
  265. package/src/hook-kinds/index.ts +465 -0
  266. package/src/hook-kinds/slack.ts +151 -0
  267. package/src/hook-kinds/webhook.ts +113 -0
  268. package/src/hooks.ts +506 -0
  269. package/src/http-effect.ts +76 -38
  270. package/src/http.ts +889 -94
  271. package/src/index.ts +16 -5
  272. package/src/instance.ts +196 -26
  273. package/src/internal.ts +22 -3
  274. package/src/machine-doc.ts +26 -1
  275. package/src/mcp-effect.ts +424 -0
  276. package/src/mount-digest.ts +41 -7
  277. package/src/names.ts +64 -2
  278. package/src/notices.ts +138 -10
  279. package/src/open-body.ts +25 -11
  280. package/src/open-effect.ts +17 -4
  281. package/src/open.ts +4 -2
  282. package/src/parts.ts +87 -9
  283. package/src/party.ts +2 -1
  284. package/src/pick.ts +2 -2
  285. package/src/pool.ts +1142 -163
  286. package/src/registration.ts +203 -13
  287. package/src/run-host.ts +1509 -171
  288. package/src/run-list.ts +191 -0
  289. package/src/sandbox-kube.ts +53 -5
  290. package/src/secret-placement.ts +239 -33
  291. package/src/server.ts +53 -4
  292. package/src/setup.ts +72 -32
  293. package/src/snapshot-store.ts +565 -48
  294. package/src/steer.ts +111 -0
  295. package/src/stub-harness.ts +6 -2
  296. package/src/tokens.ts +263 -22
  297. package/src/wire.ts +17 -0
  298. package/src/workspace.ts +50 -6
  299. package/src/worktree.ts +3 -3
  300. package/docs/reference/decisions/0032-the-console-unlocks-with-the-instance-token.md +0 -61
  301. package/docs/reference/decisions/0065-a-persist-survives-a-crash-and-history-is-a-week.md +0 -65
package/src/config.ts CHANGED
@@ -20,8 +20,11 @@ import { readFile } from "node:fs/promises";
20
20
  import { join } from "node:path";
21
21
  import { fileURLToPath, pathToFileURL } from "node:url";
22
22
  import { z } from "zod";
23
+ import type { Hook } from "./hook-kinds/index.ts";
24
+ import { hookParts, isHook, kindsById } from "./hook-kinds/door.ts";
23
25
  import { repoIdentity } from "./repo-identity.ts";
24
26
  import { assertSize, type Size } from "./size.ts";
27
+ import { periodMs } from "./duration.ts";
25
28
 
26
29
  // ------------------------------------------------------------------------------------------------
27
30
  // `git.credentials` (ADR-0051): how the cluster authenticates to a Repo, and the fence.
@@ -360,6 +363,68 @@ export type SecretEntry = {
360
363
  paths?: readonly string[];
361
364
  };
362
365
 
366
+ // One MCP server of the Instance (ADR-0079), by the author's name. A url and a secret are facts of
367
+ // the Instance, never of an Agent's definition, so a packaged Machine names a server and the
368
+ // Instance declares it. The name is the author's: two servers of one service with two secrets are
369
+ // two names.
370
+ /**
371
+ * A remote MCP server, which the Harness reaches over Streamable HTTP. An Agent that names it in its
372
+ * `mcp` list gets the tools that its `tools` allowlist names.
373
+ *
374
+ * A server with a `secret` needs an `https://` url, because the Custodian puts the value on TLS
375
+ * only: `mcp.<name>.url must be an https:// URL`. A server with no secret can use `http://`. The
376
+ * Harness sends `Authorization: Bearer <Stand-in>`, and the Custodian of each pod whose Agents name
377
+ * the server puts the value in its place. A `secret` that the `secrets` list does not hold is
378
+ * refused when the config loads: `<file> at mcp.<name>.secret: "<NAME>" is not an entry of secrets`.
379
+ * Past `timeout`, the Harness cancels the call, and the model reads jr2's text.
380
+ *
381
+ * @example
382
+ * ```ts
383
+ * import { defineConfig } from "@jr2/orchestrator";
384
+ *
385
+ * export default defineConfig({
386
+ * secrets: [{ name: "SLACK_ACME_TOKEN", value: process.env.SLACK_ACME_TOKEN }],
387
+ * mcp: { "slack-acme": { url: "https://mcp.slack.example/mcp", secret: "SLACK_ACME_TOKEN" } },
388
+ * });
389
+ * ```
390
+ */
391
+ export type McpRemoteServer = {
392
+ /** Its Streamable HTTP endpoint: `https://` when it has a `secret`. */
393
+ url: string;
394
+ /** A name of the `secrets` list: its bearer token. With no `hosts`, that entry is bound to the
395
+ * url's host, in `authorization`. Default: none. */
396
+ secret?: string;
397
+ /** How long one call can run, such as `"90s"`. Default `"60s"`. */
398
+ timeout?: `${number}s` | `${number}m` | `${number}h`;
399
+ };
400
+
401
+ /**
402
+ * A stdio MCP server: a program of the Sandbox Image that the Harness starts for each Agent
403
+ * conversation, through jr2's spawner, inside the Agent's confinement. It is Workspace code: its env
404
+ * holds only the Stand-ins that the `workspace()` grants, so it has no `secret` of its own. A
405
+ * `"none"` Agent cannot name one, because its Turns run on the Instance Harness, where no Workspace
406
+ * code runs.
407
+ *
408
+ * @example
409
+ * ```ts
410
+ * import { defineConfig } from "@jr2/orchestrator";
411
+ *
412
+ * export default defineConfig({ mcp: { notes: { command: "notes-mcp", args: ["--root", "/work"] } } });
413
+ * ```
414
+ */
415
+ export type McpStdioServer = {
416
+ /** A program of the Sandbox Image. */
417
+ command: string;
418
+ /** Its arguments. */
419
+ args?: readonly string[];
420
+ /** How long one call can run, such as `"90s"`. Default `"60s"`. */
421
+ timeout?: `${number}s` | `${number}m` | `${number}h`;
422
+ };
423
+
424
+ /** One MCP server of the Instance: a {@link McpRemoteServer} (`url`) or a {@link McpStdioServer}
425
+ * (`command`). */
426
+ export type McpServer = McpRemoteServer | McpStdioServer;
427
+
363
428
  // The agent-runtime section (ADR-0018): what the stock Harness image consumes alongside the Agent
364
429
  // definition each Turn hands it (ADR-0049). Moved out of `sandbox` deliberately — `sandbox` is pod
365
430
  // transport (it still CARRIES this env to the Harness container), but model concerns are Harness
@@ -489,6 +554,184 @@ export type PriorityClasses = {
489
554
  sandbox?: string;
490
555
  };
491
556
 
557
+ /**
558
+ * The Workflows that one scope of a Caller opens (ADR-0078): `"all"` for every Workflow, or a list
559
+ * of Workflow names. A Chart wrapper's name in the list also opens each named Chart of that wrapper
560
+ * (`plan` opens `plan/weekly`), because a named Chart runs its wrapper's Machine. A named Chart's
561
+ * own name (`plan/weekly`) opens that Chart alone.
562
+ */
563
+ export type ScopeGrant = "all" | readonly string[];
564
+
565
+ /**
566
+ * What a Caller may do: five scopes, each one for the Workflows that it names (ADR-0078). A scope
567
+ * that is absent opens nothing. Which scope opens which route is in the HTTP API Reference.
568
+ *
569
+ * A Caller's scope does not limit what its run dispatches: a Caller with `start` on `triage` starts
570
+ * a run whose Machine can dispatch `deploy`. The Machine is the author's, and it is the boundary of
571
+ * what its run does.
572
+ */
573
+ export type Scopes = {
574
+ /** Start a run. */
575
+ start?: ScopeGrant;
576
+ /** Answer a Gate, and read a run's open Gates without its context. A list item
577
+ * `{ workflow, gates }` opens only the Gates that it names, by invoke id (see {@link GateGrant}). */
578
+ answer?: AnswerGrant;
579
+ /** Cancel a run. */
580
+ cancel?: ScopeGrant;
581
+ /** Read a run's state: its status, its context and its feed. */
582
+ read?: ScopeGrant;
583
+ /** Send a Steering message to an Agent of a run. */
584
+ steer?: ScopeGrant;
585
+ };
586
+
587
+ /**
588
+ * The Gates that the `answer` scope opens (ADR-0078): `"all"`, or a list in which each item is a
589
+ * Workflow name, which opens every Gate of that Workflow, or a {@link GateGrant}, which opens only
590
+ * the Gates that it names: `["triage", { workflow: "deploy", gates: ["approve"] }]`.
591
+ */
592
+ export type AnswerGrant = "all" | readonly (string | GateGrant)[];
593
+
594
+ /**
595
+ * Some Gates of one Workflow, in the `answer` scope (ADR-0078). A Gate's name is its invoke id: the
596
+ * static name that it has in the Machine that invokes it, and the id that its events carry as their
597
+ * sender (ADR-0011). That is the invoke `id` that the author wrote, or else the key path of its
598
+ * gated state below the root of that Machine (`body.humanReview`). One name opens every Gate of the
599
+ * Workflow with that id, in each child of a Pool too.
600
+ *
601
+ * `jr2 up` refuses an item for a Workflow that the Assembly does not have, a name that no Machine
602
+ * of the Workflow has, and a name that Gates in more than one Machine of the Workflow have. A Chart
603
+ * wrapper's item (`{ workflow: "plan", gates }`) opens those Gates in each Chart of `plan` too, and
604
+ * `jr2 up` checks its names against the wrapper's Machines and its Chart files. A runtime Chart is
605
+ * not in the Assembly, so `jr2 up` cannot check its Gates: a name that no Machine and no Chart file
606
+ * of the wrapper has, and an item for a runtime Chart (`plan/weekly` with no Chart file), are
607
+ * warnings, not refusals.
608
+ */
609
+ export type GateGrant = {
610
+ /** The Workflow, as a bare name in the list names it. */
611
+ workflow: string;
612
+ /** The Gates' names. */
613
+ gates: readonly string[];
614
+ };
615
+
616
+ /** The name of one scope of a Caller (ADR-0078). */
617
+ export type Scope = keyof Scopes;
618
+
619
+ /** Every scope, in the order a reader meets them. */
620
+ export const SCOPES: readonly Scope[] = ["start", "answer", "cancel", "read", "steer"];
621
+
622
+ /**
623
+ * One named Caller token (ADR-0078). `jr2 up` makes its value once, in a Secret of its own, and
624
+ * gives the Orchestrator only the sha-256 of the value. `jr2 caller token <name>` prints the value,
625
+ * and `jr2 caller rotate <name>` makes a new one.
626
+ */
627
+ export type CallerToken = {
628
+ /** What the token may do. See {@link Scopes}. */
629
+ may: Scopes;
630
+ };
631
+
632
+ /**
633
+ * The Callers of an Instance that do not hold the Instance token (ADR-0078). The CLI on a kube
634
+ * context is the Caller `token/instance`, which needs no entry. When the config loads, a bad entry
635
+ * is refused: `<file> at callers<path>: <problem>`, followed by the shape of the block.
636
+ */
637
+ export type CallersConfig = {
638
+ // The name is part of a Secret's name (`jr2 up`), so it is a DNS label. `instance` is the
639
+ // Instance token's Caller name, so no named token takes it.
640
+ /** The named Caller tokens, by name. A name is lowercase letters, digits and `-`, at most 40
641
+ * characters, from a letter to a letter or digit. The config load refuses `instance`, the
642
+ * Instance token's name. A name that the config no longer declares stops working at the next
643
+ * `jr2 up`. */
644
+ tokens?: Readonly<Record<string, CallerToken>>;
645
+ /** The identity providers whose JWTs are Callers. See {@link JwtIssuer}. Two entries with one
646
+ * `issuer` are refused. */
647
+ issuers?: readonly JwtIssuer[];
648
+ };
649
+
650
+ /**
651
+ * One identity provider whose JWTs are Callers (ADR-0078). For each bearer that is a JWT with this
652
+ * `issuer` in its `iss` claim, jr2 checks the signature against the issuer's published keys, and the
653
+ * issuer, the audience and the expiry. A JWT that fails a check is 401.
654
+ *
655
+ * The Caller's name is the `sub` claim, and the name that jr2 shows is the `email` claim; `claims`
656
+ * names other claims. Its scopes come from the values of one claim, a list of groups or roles,
657
+ * through `groups`: each value that `groups` names adds its scopes. A JWT that gets no scope is 403
658
+ * on every route that needs a scope. jr2 never treats it as read-only.
659
+ *
660
+ * The Orchestrator fetches the keys from the issuer: this is its one call out of the Instance, and
661
+ * it spends no secret (ADR-0068). It keeps the keys for 10 minutes, and fetches them again at once
662
+ * for a JWT whose key it does not know, at most once each 30 seconds, so a key that the issuer
663
+ * rotated works with no restart.
664
+ */
665
+ export type JwtIssuer = {
666
+ /** The issuer's URL, exactly as the `iss` claim gives it, such as `https://dex.example.com`. */
667
+ issuer: string;
668
+ /** The audience that a JWT must name in its `aud` claim: for a login, the client id. */
669
+ audience: string;
670
+ /** The URL of the issuer's public keys (a JWKS). Default: the `jwks_uri` of the issuer's
671
+ * `/.well-known/openid-configuration`. */
672
+ jwks?: string;
673
+ /** The claims to read. Default: `{ name: "sub", shown: "email", groups: "groups" }`. */
674
+ claims?: {
675
+ /** The claim that is the Caller's name. Use a claim that the provider never reassigns. */
676
+ name?: string;
677
+ /** The claim that is the name that jr2 shows. */
678
+ shown?: string;
679
+ /** The claim that holds the list of groups or roles that `groups` maps to scopes. */
680
+ groups?: string;
681
+ };
682
+ /** The scopes that each value of the groups claim gives, such as
683
+ * `{ "jr2-ops": { start: "all", answer: "all" } }`. A JWT with more than one value gets the scopes
684
+ * of each, joined. A value that is not here gives nothing. */
685
+ groups: Readonly<Record<string, Scopes>>;
686
+ /** The Console offers a login against this issuer (ADR-0032): OIDC, the authorization code flow
687
+ * with PKCE, as a public client, with `audience` as the client id. Register the client as public
688
+ * (no secret), with the Console's root (`https://<console>/`) as its redirect URI. The Console
689
+ * reads the issuer and the client id before the login, from the open route
690
+ * `GET /console/config`: both are public values. A login needs the Console on https or on
691
+ * localhost, and the issuer must let the Console's origin call its discovery document and its
692
+ * token endpoint (CORS). Default: `false`, because an issuer for machines (a CI provider's
693
+ * tokens) has no login page. */
694
+ login?: boolean;
695
+ };
696
+
697
+ /**
698
+ * How long jr2 keeps an ended run, and the volume that keeps it (ADR-0065). When the config loads,
699
+ * a bad field is refused: `<file> at runs.<field>: <problem>`.
700
+ *
701
+ * @example
702
+ * ```ts
703
+ * import { defineConfig } from "@jr2/orchestrator";
704
+ *
705
+ * export default defineConfig({ runs: { retain: "90d", volume: "20Gi" } });
706
+ * ```
707
+ */
708
+ export type RunsConfig = {
709
+ /** How long jr2 keeps an ended run: its row, its conversations and the hook log. A number and
710
+ * `m`, `h`, `d` or `w`, such as `"90d"`. At least `"1h"`, with no upper limit. A live run is
711
+ * never removed. Default: `"30d"`. */
712
+ retain?: `${number}${"m" | "h" | "d" | "w"}`;
713
+ /** The size of the Orchestrator's state volume claim: a number and a unit, such as `"5Gi"` or
714
+ * `"500Mi"` (a number with no unit is refused). Default: `"5Gi"` for a
715
+ * new claim; unset, `jr2 up` keeps the size of a claim that exists. A larger size grows the
716
+ * claim where its StorageClass allows expansion, else `jr2 up` refuses it. A smaller size is
717
+ * refused: Kubernetes never makes a claim smaller. */
718
+ volume?: string;
719
+ };
720
+
721
+ /** `runs.retain` when the config sets none (ADR-0065). */
722
+ export const RUNS_RETAIN_DEFAULT = "30d";
723
+ /** The shortest `runs.retain` (ADR-0065). */
724
+ export const RUNS_RETAIN_MIN_MS = 3_600_000;
725
+ /** The size of a new state volume claim when the config sets no `runs.volume` (ADR-0065). */
726
+ export const STATE_VOLUME_DEFAULT = "5Gi";
727
+
728
+ /** How long an ended run is kept, in milliseconds: `runs.retain`, or the default. The config load
729
+ * refuses a bad value, so here a bad one reads as the default. */
730
+ export function retainMs(runs: RunsConfig | undefined): number {
731
+ const ms = runs?.retain === undefined ? undefined : periodMs(runs.retain);
732
+ return ms !== undefined && ms >= RUNS_RETAIN_MIN_MS ? ms : periodMs(RUNS_RETAIN_DEFAULT)!;
733
+ }
734
+
492
735
  /**
493
736
  * The shape of `jr2.config.ts`: the deployment facts of an Instance, which a Machine cannot carry.
494
737
  * It holds the Instance's name, its secrets, how the cluster authenticates to Repos, what the
@@ -498,7 +741,7 @@ export type PriorityClasses = {
498
741
  * Every field is optional, and an Instance can run with no `jr2.config.ts`. Write the file as
499
742
  * `export default defineConfig({ … })`. When the config loads, a file with no default export is
500
743
  * refused: `` <file> has no default export (use `export default defineConfig({…})`) ``. The load also
501
- * checks the shape of `git.credentials`, `secrets`, `harness.catalog` and `priorityClasses`. It
744
+ * checks the shape of `git.credentials`, `secrets`, `harness.catalog`, `priorityClasses` and `mcp`. It
502
745
  * checks that `sandbox` is an object, and checks its `resources`. Each refusal names the field.
503
746
  * `jr2 up` checks the rest.
504
747
  *
@@ -554,6 +797,37 @@ export type JR2Config = {
554
797
  /** Existing PriorityClasses to use in place of the two that `jr2 up` creates. See
555
798
  * {@link PriorityClasses}. */
556
799
  priorityClasses?: PriorityClasses;
800
+ // See `CallersConfig` (ADR-0078).
801
+ /** The Callers that act on the Instance with a credential of their own, and what each may do.
802
+ * Default: none; only the Instance token acts. See {@link CallersConfig}. */
803
+ callers?: CallersConfig;
804
+ // An allowlist, never a pattern (ADR-0078): the Console needs no entry.
805
+ /** The origins from which a browser app may call the Instance's HTTP API, such as
806
+ * `https://ops.example.com`. Each entry is an origin alone: a scheme, a host and an optional port,
807
+ * with no path. Default: none. The Console is served from the Orchestrator's own origin, so it
808
+ * needs no entry. The config load refuses an entry that is not an origin:
809
+ * `<file> at cors[<i>]: <entry> is not an origin — scheme://host[:port], with no path`. */
810
+ cors?: readonly string[];
811
+ // A Hook is a Caller with `answer` alone (ADR-0078); its door is `POST /hooks/:name` (ADR-0011).
812
+ /** The Hooks of the Instance, by name: each one is a door, `POST /hooks/<name>`, for one outside
813
+ * service. A Hook's value comes from its kind's constructor, which the config imports:
814
+ * `github({ secret: secret("GH_HOOK") })`, `gitlab({ secret: secret("GL_HOOK"), may })`,
815
+ * `discord({ publicKey: "…" })`. `may.answer` names the Workflows whose Gates the Hook may answer,
816
+ * every Workflow by default. A Hook never starts a run. A name has the rule of a Caller token's
817
+ * name. When the config loads, a bad entry is refused: `<file> at hooks.<name>: <problem>`.
818
+ * Default: none. */
819
+ hooks?: Readonly<Record<string, Hook>>;
820
+ // Reach for tools (ADR-0079): a name of the author's, so an Agent's definition and an Effect name
821
+ // a server without its url or its secret.
822
+ /** The MCP servers, by a name of yours: `[a-z][a-z0-9-]*`, not `jr2`. A remote server has a
823
+ * `url` (`https://` with a secret), a `secret` (a name of the `secrets` list; with no `hosts`, it
824
+ * is bound to the url's host) and a `timeout` (default `"60s"`). A stdio server has a `command`
825
+ * of the Sandbox Image, its `args` and a `timeout`. An Agent names a server in its `mcp` list.
826
+ * Default: none. See {@link McpServer}. */
827
+ mcp?: Readonly<Record<string, McpServer>>;
828
+ // The history (ADR-0065): how long an ended run is kept, and the volume that keeps it.
829
+ /** How long ended runs are kept, and the volume size. Default: `{ retain: "30d", volume: "5Gi" }`. */
830
+ runs?: RunsConfig;
557
831
  // Deployment-varying — resolve from env (ADR-0019).
558
832
  /** The image registry prefix to which `jr2 up` pushes the images that it builds, such as
559
833
  * `registry.example.com/team`. It changes between deployments, so read it from `process.env`
@@ -650,9 +924,284 @@ export async function loadConfig(dir: string): Promise<JR2Config | undefined> {
650
924
  if (mod.default.harness !== undefined) checkHarness(mod.default.harness, file);
651
925
  if (mod.default.sandbox !== undefined) checkSandbox(mod.default.sandbox, file);
652
926
  if (mod.default.priorityClasses !== undefined) checkPriorityClasses(mod.default.priorityClasses, file);
927
+ if (mod.default.callers !== undefined) checkCallers(mod.default.callers, file);
928
+ if (mod.default.cors !== undefined) checkCors(mod.default.cors, file);
929
+ if (mod.default.hooks !== undefined) checkHooks(mod.default.hooks, file);
930
+ if (mod.default.mcp !== undefined) checkMcp(mod.default.mcp, mod.default.secrets ?? [], file);
931
+ if (mod.default.runs !== undefined) checkRuns(mod.default.runs, file);
653
932
  return mod.default;
654
933
  }
655
934
 
935
+ const RUNS_HINT = 'runs is { retain?, volume? }, such as { retain: "30d", volume: "5Gi" } (ADR-0065)';
936
+ const RETAIN_FORM = /^\d+(\.\d+)?[mhdw]$/;
937
+ // A unit is required: a bare number is bytes to Kubernetes, so "5" for "5Gi" is a claim of 5 bytes.
938
+ const VOLUME_FORM = /^\d+(\.\d+)?(Ki|Mi|Gi|Ti|Pi|Ei|k|M|G|T|P|E)$/;
939
+
940
+ /** `runs`' SHAPE (ADR-0065): `retain` a duration of at least 1h, `volume` a storage quantity. */
941
+ export function checkRuns(runs: unknown, where: string): void {
942
+ if (typeof runs !== "object" || runs === null || Array.isArray(runs)) {
943
+ throw new Error(`${where} at runs: expected an object — ${RUNS_HINT}`);
944
+ }
945
+ for (const key of Object.keys(runs)) {
946
+ if (key !== "retain" && key !== "volume") throw new Error(`${where} at runs.${key}: not a field — ${RUNS_HINT}`);
947
+ }
948
+ const { retain, volume } = runs as { retain?: unknown; volume?: unknown };
949
+ if (retain !== undefined) {
950
+ const ms = typeof retain === "string" && RETAIN_FORM.test(retain) ? periodMs(retain) : undefined;
951
+ if (ms === undefined) {
952
+ throw new Error(
953
+ `${where} at runs.retain: ${JSON.stringify(retain)} is not a duration — a number and m, h, d or w, such as "30d" (ADR-0065)`,
954
+ );
955
+ }
956
+ if (ms < RUNS_RETAIN_MIN_MS) {
957
+ throw new Error(
958
+ `${where} at runs.retain: ${JSON.stringify(retain)} is shorter than 1h — jr2 keeps an ended run for at least 1h (ADR-0065)`,
959
+ );
960
+ }
961
+ }
962
+ if (
963
+ volume !== undefined &&
964
+ (typeof volume !== "string" || !VOLUME_FORM.test(volume) || !(Number.parseFloat(volume) > 0))
965
+ ) {
966
+ throw new Error(
967
+ `${where} at runs.volume: ${JSON.stringify(volume)} is not a storage quantity — a number and a unit, such as "5Gi" or "500Mi" (ADR-0065)`,
968
+ );
969
+ }
970
+ }
971
+
972
+ /** A list item of a scope other than `answer`: a Workflow name. An item that names Gates is refused
973
+ * with the reason: only `answer` acts on one Gate, and the other scopes act on a whole run. */
974
+ const workflowItem = (scope: Scope) =>
975
+ z.unknown().superRefine((item, ctx) => {
976
+ if (typeof item === "string" && item !== "") return;
977
+ ctx.addIssue({
978
+ code: "custom",
979
+ message:
980
+ typeof item === "object" && item !== null && "gates" in item
981
+ ? `only "answer" can name single Gates; "${scope}" names whole Workflows, because it acts on a whole run`
982
+ : "a Workflow name is a string that is not empty",
983
+ });
984
+ });
985
+ const ScopeGrantShape = (scope: Scope) => z.union([z.literal("all"), z.array(workflowItem(scope)).min(1)]);
986
+ const GateGrantShape = z
987
+ .object({
988
+ workflow: z.string().min(1, "a Workflow name is a string that is not empty"),
989
+ gates: z
990
+ .array(z.string().min(1, "a Gate name is a string that is not empty"))
991
+ .min(1, "gates names at least one Gate"),
992
+ })
993
+ .strict();
994
+ const AnswerGrantShape = z.union([z.literal("all"), z.array(z.union([z.string().min(1), GateGrantShape])).min(1)]);
995
+ const ScopesShape = z
996
+ .object({
997
+ start: ScopeGrantShape("start").optional(),
998
+ answer: AnswerGrantShape.optional(),
999
+ cancel: ScopeGrantShape("cancel").optional(),
1000
+ read: ScopeGrantShape("read").optional(),
1001
+ steer: ScopeGrantShape("steer").optional(),
1002
+ })
1003
+ .strict();
1004
+ const CallersShape = z
1005
+ .object({
1006
+ tokens: z
1007
+ .record(
1008
+ z
1009
+ .string()
1010
+ .regex(
1011
+ /^[a-z]([a-z0-9-]{0,38}[a-z0-9])?$/,
1012
+ "a token name is lowercase letters, digits and -, at most 40, starts with a letter and ends with a letter or digit",
1013
+ )
1014
+ .refine((name) => name !== "instance", "the name instance is the Instance token's"),
1015
+ z.object({ may: ScopesShape }).strict(),
1016
+ )
1017
+ .optional(),
1018
+ issuers: z
1019
+ .array(
1020
+ z
1021
+ .object({
1022
+ issuer: z.string().refine(isHttpUrl, "the issuer is an http or https URL"),
1023
+ audience: z.string().min(1),
1024
+ jwks: z.string().refine(isHttpUrl, "jwks is an http or https URL").optional(),
1025
+ claims: z
1026
+ .object({
1027
+ name: z.string().min(1).optional(),
1028
+ shown: z.string().min(1).optional(),
1029
+ groups: z.string().min(1).optional(),
1030
+ })
1031
+ .strict()
1032
+ .optional(),
1033
+ groups: z.record(z.string().min(1), ScopesShape),
1034
+ login: z.boolean().optional(),
1035
+ })
1036
+ .strict(),
1037
+ )
1038
+ .refine(
1039
+ (list) => new Set(list.map((i) => i.issuer)).size === list.length,
1040
+ "two issuers have one issuer URL; each issuer is one entry",
1041
+ )
1042
+ .optional(),
1043
+ })
1044
+ .strict();
1045
+
1046
+ /** Whether `text` is an http or https URL. */
1047
+ function isHttpUrl(text: string): boolean {
1048
+ try {
1049
+ const url = new URL(text);
1050
+ return url.protocol === "http:" || url.protocol === "https:";
1051
+ } catch {
1052
+ return false;
1053
+ }
1054
+ }
1055
+
1056
+ /** `callers`' SHAPE (ADR-0078), the `checkGit` style: a bad entry names its path. */
1057
+ function checkCallers(callers: unknown, where: string): void {
1058
+ const parsed = CallersShape.safeParse(callers);
1059
+ if (parsed.success) return;
1060
+ const issue = parsed.error.issues[0];
1061
+ const at = ["", ...(issue?.path ?? [])].map(String).join(".");
1062
+ // A bad token NAME is an issue about the key, whose own issues say why.
1063
+ const inner = issue?.code === "invalid_key" ? issue.issues[0]?.message : undefined;
1064
+ throw new Error(`${where} at callers${at}: ${inner ?? issue?.message ?? "invalid"} — ${CALLERS_HINT}`);
1065
+ }
1066
+
1067
+ const CALLERS_HINT =
1068
+ "callers is { tokens?: { <name>: { may: <scopes> } }, issuers?: [{ issuer, audience, jwks?, " +
1069
+ "claims?: { name?, shown?, groups? }, groups: { <value>: <scopes> }, login? }] }, where <scopes> is " +
1070
+ '{ start?, answer?, cancel?, read?, steer? }, each scope "all" or a list of Workflow names, and an answer ' +
1071
+ "item may be { workflow, gates: [<Gate name>] } (ADR-0078)";
1072
+
1073
+ /** The name of a Hook (ADR-0078): the last segment of `POST /hooks/:name`, the rule of a Caller
1074
+ * token's name. */
1075
+ export const HOOK_NAME = /^[a-z]([a-z0-9-]{0,38}[a-z0-9])?$/;
1076
+
1077
+ /** `hooks`' SHAPE (ADR-0078): each entry is a Hook that a kind's constructor made, under a name of
1078
+ * the Caller name's form, and one kind id is one kind (`kindsById`). */
1079
+ function checkHooks(hooks: unknown, where: string): void {
1080
+ if (typeof hooks !== "object" || hooks === null || Array.isArray(hooks)) {
1081
+ throw new Error(`${where} at hooks: expected { <name>: <Hook> }, such as { forge: github({ secret }) } (ADR-0078)`);
1082
+ }
1083
+ const kinds = [];
1084
+ for (const [name, hook] of Object.entries(hooks)) {
1085
+ if (!HOOK_NAME.test(name)) {
1086
+ throw new Error(
1087
+ `${where} at hooks.${name}: a Hook's name is lowercase letters, digits and -, at most 40, starts with a ` +
1088
+ "letter and ends with a letter or digit (ADR-0078)",
1089
+ );
1090
+ }
1091
+ if (!isHook(hook)) {
1092
+ throw new Error(
1093
+ `${where} at hooks.${name}: not a Hook — make it with its kind, such as github({ secret: secret("GH_HOOK") }) (ADR-0078)`,
1094
+ );
1095
+ }
1096
+ kinds.push(hookParts(hook).kind);
1097
+ }
1098
+ try {
1099
+ kindsById(kinds);
1100
+ } catch (err) {
1101
+ throw new Error(`${where} at hooks: ${err instanceof Error ? err.message : String(err)} (ADR-0078)`);
1102
+ }
1103
+ }
1104
+
1105
+ /** `cors`' SHAPE (ADR-0078): a list of origins, each one exactly what a browser sends as `Origin`. */
1106
+ function checkCors(cors: unknown, where: string): void {
1107
+ if (!Array.isArray(cors)) throw new Error(`${where} at cors: expected an array of origins (ADR-0078)`);
1108
+ for (const [i, entry] of cors.entries()) {
1109
+ if (typeof entry !== "string" || !isOrigin(entry)) {
1110
+ throw new Error(
1111
+ `${where} at cors[${i}]: ${JSON.stringify(entry)} is not an origin — scheme://host[:port], with no path (ADR-0078)`,
1112
+ );
1113
+ }
1114
+ }
1115
+ }
1116
+
1117
+ /** Whether `text` is an origin as a browser sends it: `URL` gives back the same text as its origin. */
1118
+ function isOrigin(text: string): boolean {
1119
+ try {
1120
+ const url = new URL(text);
1121
+ return (url.protocol === "http:" || url.protocol === "https:") && url.origin === text;
1122
+ } catch {
1123
+ return false;
1124
+ }
1125
+ }
1126
+
1127
+ /** An MCP server's name (ADR-0079): the `<server>` of `mcp__<server>__<tool>`. It has no `_`, so
1128
+ * the prefix of a tool name is never ambiguous. */
1129
+ export const MCP_SERVER_NAME = /^[a-z][a-z0-9-]*$/;
1130
+
1131
+ /** The default `timeout` of one MCP call (ADR-0079). */
1132
+ export const MCP_DEFAULT_TIMEOUT_MS = 60_000;
1133
+
1134
+ /** One MCP server's `timeout` in milliseconds, or undefined when it is not a duration of 1 ms or
1135
+ * more (a shorter one rounds to 0 ms, which no Harness can wait). */
1136
+ export function mcpTimeoutMs(timeout: string | undefined): number | undefined {
1137
+ if (timeout === undefined) return MCP_DEFAULT_TIMEOUT_MS;
1138
+ const m = /^(\d+(?:\.\d+)?)(s|m|h)$/.exec(timeout);
1139
+ const n = m ? Number(m[1]) : NaN;
1140
+ if (!(n > 0)) return undefined;
1141
+ const ms = Math.round(m![2] === "h" ? n * 3_600_000 : m![2] === "m" ? n * 60_000 : n * 1000);
1142
+ return ms >= 1 ? ms : undefined;
1143
+ }
1144
+
1145
+ /** Is this MCP server a stdio one (`command`), not a remote one (`url`)? */
1146
+ export function isStdioServer(server: McpServer): server is McpStdioServer {
1147
+ return "command" in server;
1148
+ }
1149
+
1150
+ const McpTimeoutShape = z
1151
+ .string()
1152
+ .refine((t) => mcpTimeoutMs(t) !== undefined, 'a timeout is a positive duration, such as "90s", "5m" or "1h"');
1153
+ const McpServerShape = z.union([
1154
+ z.object({ url: z.string(), secret: z.string().min(1).optional(), timeout: McpTimeoutShape.optional() }).strict(),
1155
+ z
1156
+ .object({
1157
+ command: z.string().min(1),
1158
+ args: z.array(z.string()).optional(),
1159
+ timeout: McpTimeoutShape.optional(),
1160
+ })
1161
+ .strict(),
1162
+ ]);
1163
+
1164
+ const MCP_HINT =
1165
+ "an entry is <name>: { url, secret?, timeout? } for a remote server, or <name>: { command, args?, timeout? } for a stdio server (ADR-0079)";
1166
+
1167
+ /** `mcp`'s SHAPE (ADR-0079), the `checkGit` style: a bad entry names its path. The url is not
1168
+ * checked here: in-cluster it reads `.env`, which is absent, so `jr2 up` checks it host-side. */
1169
+ function checkMcp(mcp: unknown, secrets: readonly SecretEntry[], where: string): void {
1170
+ if (typeof mcp !== "object" || mcp === null || Array.isArray(mcp)) {
1171
+ throw new Error(`${where} at mcp: expected an object keyed by server name — ${MCP_HINT}`);
1172
+ }
1173
+ for (const [name, server] of Object.entries(mcp)) {
1174
+ if (name === "jr2") {
1175
+ throw new Error(
1176
+ `${where} at mcp.jr2: the server name jr2 is reserved — the Menu's tools are named mcp__jr2__<event>; ` +
1177
+ "give the server another name (ADR-0079)",
1178
+ );
1179
+ }
1180
+ if (!MCP_SERVER_NAME.test(name)) {
1181
+ throw new Error(
1182
+ `${where} at mcp.${name}: a server name is lowercase letters, digits and -, and starts with a letter ` +
1183
+ "([a-z][a-z0-9-]*) — it is the <server> of the tool names mcp__<server>__<tool> (ADR-0079)",
1184
+ );
1185
+ }
1186
+ const parsed = McpServerShape.safeParse(server);
1187
+ if (!parsed.success) {
1188
+ // A union's issue says only "invalid input": name the branch the entry meant.
1189
+ const meant = typeof server === "object" && server !== null && "command" in server ? 1 : 0;
1190
+ const issues = parsed.error.issues[0];
1191
+ const inner = issues?.code === "invalid_union" ? (issues.errors[meant]?.[0] ?? issues.errors[0]?.[0]) : issues;
1192
+ const at = ["", ...(inner?.path ?? [])].map(String).join(".");
1193
+ throw new Error(`${where} at mcp.${name}${at}: ${inner?.message ?? "invalid"} — ${MCP_HINT}`);
1194
+ }
1195
+ const secret = (server as { secret?: string }).secret;
1196
+ if (secret !== undefined && !secrets.some((s) => s.name === secret)) {
1197
+ throw new Error(
1198
+ `${where} at mcp.${name}.secret: "${secret}" is not an entry of secrets — add it: ` +
1199
+ `secrets: [{ name: "${secret}", value: process.env.${secret} }] (ADR-0079)`,
1200
+ );
1201
+ }
1202
+ }
1203
+ }
1204
+
656
1205
  const NonEmpty = z.string().min(1);
657
1206
  const Credential = z
658
1207
  .object({