@coreplane/switchboard 1.241.0 → 1.243.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 (157) hide show
  1. package/dist/assets/config/config.example.yaml +45 -13
  2. package/dist/assets/deploy/cloudflare-memory/worker.ts +86 -29
  3. package/dist/assets/deploy/cloudflare-resident/worker.ts +298 -24
  4. package/dist/assets/deploy/cloudflare-sandbox/worker.ts +38 -5
  5. package/dist/assets/package-lock.json +3 -3
  6. package/dist/assets/package.json +1 -1
  7. package/dist/assets/project.json +5 -5
  8. package/dist/assets/source.json +3 -3
  9. package/dist/assets/src/agents/registry.ts +61 -48
  10. package/dist/assets/src/config/profile.ts +68 -3
  11. package/dist/assets/src/core/authz/actor.ts +12 -9
  12. package/dist/assets/src/core/authz/authorize.ts +17 -4
  13. package/dist/assets/src/core/authz/policy.ts +4 -0
  14. package/dist/assets/src/core/authz/types.ts +12 -0
  15. package/dist/assets/src/core/budgets.ts +313 -0
  16. package/dist/assets/src/core/coordinator/contract.ts +3 -1
  17. package/dist/assets/src/core/coordinator/driver.ts +0 -10
  18. package/dist/assets/src/core/costs.ts +21 -80
  19. package/dist/assets/src/core/harness/scope.ts +21 -0
  20. package/dist/assets/src/core/modelPricing.ts +212 -0
  21. package/dist/assets/src/core/prDescriptionTypes.ts +29 -24
  22. package/dist/assets/src/core/reviewVerdict.ts +7 -0
  23. package/dist/assets/src/core/runEvents.ts +48 -5
  24. package/dist/assets/src/core/runFriction.ts +2 -1
  25. package/dist/assets/src/core/runLedger/types.ts +2 -0
  26. package/dist/assets/src/core/runRecord.ts +51 -0
  27. package/dist/assets/src/core/runUsage.ts +158 -47
  28. package/dist/assets/src/core/schedules.ts +3 -0
  29. package/dist/assets/src/core/ship/contract.ts +4 -2
  30. package/dist/assets/src/core/ship/coordinator.ts +101 -84
  31. package/dist/assets/src/core/ship/handoff.ts +9 -0
  32. package/dist/assets/src/execution/bashTimeout.ts +8 -5
  33. package/dist/assets/src/execution/residentAutoRebuild.ts +4 -2
  34. package/dist/assets/src/execution/residentHead.ts +23 -10
  35. package/dist/assets/src/execution/residentInfraStreak.ts +89 -0
  36. package/dist/assets/src/execution/residentRefresh.ts +43 -4
  37. package/dist/assets/src/execution/residentSteps.ts +1 -0
  38. package/dist/assets/src/execution/sandboxErrors.ts +6 -0
  39. package/dist/assets/src/execution/seedPlan.ts +17 -0
  40. package/dist/assets/web/dist/.vite/manifest.json +443 -446
  41. package/dist/assets/web/dist/assets/{AppShell-DEy5jRuS.js → AppShell-IDMGG6yi.js} +1 -1
  42. package/dist/assets/web/dist/assets/CostsPage-CwXOmkeQ.js +2 -0
  43. package/dist/assets/web/dist/assets/{DeliveryPage-BGpPr5xr.js → DeliveryPage-n1tz5I_v.js} +1 -1
  44. package/dist/assets/web/dist/assets/HomePage-PRxjQiGG.js +2 -0
  45. package/dist/assets/web/dist/assets/{NotFoundPage-DtmQM2gD.js → NotFoundPage-C97EhJcQ.js} +1 -1
  46. package/dist/assets/web/dist/assets/PendingTurnRow-BT9RhFZ7.js +1 -0
  47. package/dist/assets/web/dist/assets/{ResidentDetailPage-C-48fxat.js → ResidentDetailPage-DACalNVF.js} +1 -1
  48. package/dist/assets/web/dist/assets/ResidentsIndexPage-SHPdu6uZ.js +1 -0
  49. package/dist/assets/web/dist/assets/RunFoldRow-0SdOmOr5.js +1 -0
  50. package/dist/assets/web/dist/assets/RunRoutePage-BaFS2p8I.js +9 -0
  51. package/dist/assets/web/dist/assets/RunsIndexPage-DYI-iALj.js +1 -0
  52. package/dist/assets/web/dist/assets/{RunsTabs-CtNGNnhN.js → RunsTabs-BUfdk0lH.js} +1 -1
  53. package/dist/assets/web/dist/assets/ScheduledPage-DJ8HiCPt.js +1 -0
  54. package/dist/assets/web/dist/assets/SettingsPage-DLiN5IgY.js +1 -0
  55. package/dist/assets/web/dist/assets/{StatusDot-DPWE1JmO.js → StatusDot-Dw0T1M-P.js} +1 -1
  56. package/dist/assets/web/dist/assets/{Tooltip-DKhSRH9t.js → Tooltip-BbLuIAiS.js} +1 -1
  57. package/dist/assets/web/dist/assets/UnitRoutePage-DicUG96U.js +1 -0
  58. package/dist/assets/web/dist/assets/{angular-html-DgSK1qvr.js → angular-html-oBNfPJR0.js} +1 -1
  59. package/dist/assets/web/dist/assets/{angular-ts-D3gNdiSG.js → angular-ts-BvNwsyWA.js} +1 -1
  60. package/dist/assets/web/dist/assets/{apl-CkHCYM8I.js → apl-CNUdRlYf.js} +1 -1
  61. package/dist/assets/web/dist/assets/{astro-DLm45axt.js → astro-Zb0NriSe.js} +1 -1
  62. package/dist/assets/web/dist/assets/{blade-BKa-VE-c.js → blade-qPRVheqq.js} +1 -1
  63. package/dist/assets/web/dist/assets/{c-C8NCNWai.js → c-D8Awx4YO.js} +1 -1
  64. package/dist/assets/web/dist/assets/{chapel-DqDQ7IKH.js → chapel-Bt72Mhsx.js} +1 -1
  65. package/dist/assets/web/dist/assets/{cobol-j4vCD6AJ.js → cobol-BOBacexg.js} +1 -1
  66. package/dist/assets/web/dist/assets/{coffee-C6oDVH-0.js → coffee-E4u0liHW.js} +1 -1
  67. package/dist/assets/web/dist/assets/{cpp-Bd3A1Rtc.js → cpp-q2sLNlul.js} +1 -1
  68. package/dist/assets/web/dist/assets/{crystal-CUOgOw0d.js → crystal-DyWqUnlb.js} +1 -1
  69. package/dist/assets/web/dist/assets/{css-RYljyv7G.js → css-CQY0hFsD.js} +1 -1
  70. package/dist/assets/web/dist/assets/{dist-BnJXKHCY.js → dist-twkFmSUY.js} +2 -2
  71. package/dist/assets/web/dist/assets/durationTone-BobbycC-.js +1 -0
  72. package/dist/assets/web/dist/assets/{edge-BJhNdQDc.js → edge-C1MwhJkX.js} +1 -1
  73. package/dist/assets/web/dist/assets/{elixir-g1AWOAfT.js → elixir-Bb3YbHfn.js} +1 -1
  74. package/dist/assets/web/dist/assets/{elm-CER5e4Dz.js → elm-DAN9IGQw.js} +1 -1
  75. package/dist/assets/web/dist/assets/{erb-D5aOmMQf.js → erb-BEB8Xlsj.js} +1 -1
  76. package/dist/assets/web/dist/assets/format-BldUwl_R.js +1 -0
  77. package/dist/assets/web/dist/assets/{git-rebase-D6rfV8jp.js → git-rebase-tqpjRfxO.js} +1 -1
  78. package/dist/assets/web/dist/assets/{glimmer-js-BvDH3-mC.js → glimmer-js-Ccbo65zR.js} +1 -1
  79. package/dist/assets/web/dist/assets/{glimmer-ts-DChtNs9V.js → glimmer-ts-B6WBVMpE.js} +1 -1
  80. package/dist/assets/web/dist/assets/{glsl-BHlsWrxf.js → glsl-tRec3Fcu.js} +1 -1
  81. package/dist/assets/web/dist/assets/{graphql-BsaBHb53.js → graphql-P8kbxT4F.js} +1 -1
  82. package/dist/assets/web/dist/assets/{hack-0lbDosCd.js → hack-H9Zhkagy.js} +1 -1
  83. package/dist/assets/web/dist/assets/{haml-BKtco2vr.js → haml-DtEnpn7Z.js} +1 -1
  84. package/dist/assets/web/dist/assets/{handlebars-BE_cj04z.js → handlebars-DkgPfoAz.js} +1 -1
  85. package/dist/assets/web/dist/assets/{html-BNcr8EVd.js → html-D30RXpIs.js} +1 -1
  86. package/dist/assets/web/dist/assets/{html-derivative-BsJt2Kei.js → html-derivative-DwozLrEx.js} +1 -1
  87. package/dist/assets/web/dist/assets/{http-BR5P8ER_.js → http-DXuzBAPm.js} +1 -1
  88. package/dist/assets/web/dist/assets/{hurl-CtHalpWS.js → hurl-d1UUJIt_.js} +1 -1
  89. package/dist/assets/web/dist/assets/indexRow-B_s5tKyq.js +1 -0
  90. package/dist/assets/web/dist/assets/{java-BXVdhN11.js → java-DL0gWf34.js} +1 -1
  91. package/dist/assets/web/dist/assets/{javascript-CyoShgNQ.js → javascript-Cl7vavnS.js} +1 -1
  92. package/dist/assets/web/dist/assets/{jinja-BQYZzHex.js → jinja-CfQOWMX9.js} +1 -1
  93. package/dist/assets/web/dist/assets/{jison-DqrUa1Eq.js → jison-KdYirlqm.js} +1 -1
  94. package/dist/assets/web/dist/assets/{json-CQiTF9Jj.js → json-C9cDQ-Qj.js} +1 -1
  95. package/dist/assets/web/dist/assets/{jsx-Y16MkCYQ.js → jsx-CRx5NItd.js} +1 -1
  96. package/dist/assets/web/dist/assets/{julia-zn16YONe.js → julia-CmsQsZQl.js} +1 -1
  97. package/dist/assets/web/dist/assets/{just-DcVeN_MN.js → just-CsM3Q8TE.js} +1 -1
  98. package/dist/assets/web/dist/assets/{latex-BNNoF-WP.js → latex-BXCh5YRX.js} +1 -1
  99. package/dist/assets/web/dist/assets/{liquid-DAi6qHzn.js → liquid-BF2vwK8p.js} +1 -1
  100. package/dist/assets/web/dist/assets/{lua-BswS0axw.js → lua-DcMBATrl.js} +1 -1
  101. package/dist/assets/web/dist/assets/main-B4kEF3Sg.css +1 -0
  102. package/dist/assets/web/dist/assets/{main-B3is4sk6.js → main-d-w-tIKt.js} +2 -2
  103. package/dist/assets/web/dist/assets/{marko-C-4hkmQZ.js → marko-88MndKvG.js} +1 -1
  104. package/dist/assets/web/dist/assets/{mdc-0ZaHUkS7.js → mdc-DJ4kVAd8.js} +1 -1
  105. package/dist/assets/web/dist/assets/{nginx-DBDg5tOR.js → nginx-CP6mRgtV.js} +1 -1
  106. package/dist/assets/web/dist/assets/{nim-BVMz49Yw.js → nim-QQfj3fpF.js} +1 -1
  107. package/dist/assets/web/dist/assets/{org-1hO_K6ya.js → org-Bke3eBzc.js} +1 -1
  108. package/dist/assets/web/dist/assets/{perl-CVRQLvVk.js → perl-Bkh0N7Kz.js} +1 -1
  109. package/dist/assets/web/dist/assets/{php-K9nCrB9n.js → php-Dnv1Piya.js} +1 -1
  110. package/dist/assets/web/dist/assets/{pug-CTxOcmO3.js → pug-D6peFFI7.js} +1 -1
  111. package/dist/assets/web/dist/assets/{qml-CmCkfG5q.js → qml-D8PEs-C-.js} +1 -1
  112. package/dist/assets/web/dist/assets/{r-pP47Xn1X.js → r-B1EL9b_j.js} +1 -1
  113. package/dist/assets/web/dist/assets/{razor-BpW6r3tC.js → razor-BsMTIh2b.js} +1 -1
  114. package/dist/assets/web/dist/assets/{regexp-CINgdY4N.js → regexp-HhvC8spD.js} +1 -1
  115. package/dist/assets/web/dist/assets/{rst-BZIUEq2Q.js → rst-D5paAxpg.js} +1 -1
  116. package/dist/assets/web/dist/assets/{ruby-nzAOxz6r.js → ruby-BrQwhLrl.js} +1 -1
  117. package/dist/assets/web/dist/assets/{sas-QIS1bFth.js → sas-afot2B1o.js} +1 -1
  118. package/dist/assets/web/dist/assets/{scss-BuhBBVNt.js → scss-Efm-mwuG.js} +1 -1
  119. package/dist/assets/web/dist/assets/{shellscript-CQc1vXbk.js → shellscript-BK0Vv5fT.js} +1 -1
  120. package/dist/assets/web/dist/assets/{shellsession-CoubCAUv.js → shellsession-BGqlMC7N.js} +1 -1
  121. package/dist/assets/web/dist/assets/{soy-CPRzlder.js → soy-M0b4UwGM.js} +1 -1
  122. package/dist/assets/web/dist/assets/{sql-W9krb8-9.js → sql-sxe6IE9j.js} +1 -1
  123. package/dist/assets/web/dist/assets/sseReplay-C9m_EB8J.js +9 -0
  124. package/dist/assets/web/dist/assets/{stata-CuISJEC0.js → stata-nPF_ddLP.js} +1 -1
  125. package/dist/assets/web/dist/assets/{surrealql-CSet7584.js → surrealql-D_GrC6u7.js} +1 -1
  126. package/dist/assets/web/dist/assets/{svelte-ChXTSYwK.js → svelte-DwL1AtNP.js} +1 -1
  127. package/dist/assets/web/dist/assets/{templ-Bm55v62k.js → templ-7s7LTDkc.js} +1 -1
  128. package/dist/assets/web/dist/assets/{tex-CICMX8Gj.js → tex-Bx-5fMxe.js} +1 -1
  129. package/dist/assets/web/dist/assets/{ts-tags-CA1UzWyB.js → ts-tags-DUMJnke_.js} +1 -1
  130. package/dist/assets/web/dist/assets/{tsx-Dy04HNbv.js → tsx-BN8biPDe.js} +1 -1
  131. package/dist/assets/web/dist/assets/{twig-BBHsVnVD.js → twig-8nIu84TN.js} +1 -1
  132. package/dist/assets/web/dist/assets/{typescript-Dvc-wVBT.js → typescript-BooSPq_S.js} +1 -1
  133. package/dist/assets/web/dist/assets/{typst-Dvlqx3_q.js → typst-CTBiBsem.js} +1 -1
  134. package/dist/assets/web/dist/assets/{vue-zWi1MNU2.js → vue-C6Ft4Lea.js} +1 -1
  135. package/dist/assets/web/dist/assets/{vue-html-D4YxT3An.js → vue-html-Ba36dD5D.js} +1 -1
  136. package/dist/assets/web/dist/assets/{vue-vine-0YE7uQlH.js → vue-vine-NkFexVo2.js} +1 -1
  137. package/dist/assets/web/dist/assets/{xml-gm-iksZB.js → xml-C_THnHXZ.js} +1 -1
  138. package/dist/assets/web/dist/assets/{xsl-D_W5BcrY.js → xsl-D8G5xqjY.js} +1 -1
  139. package/dist/assets/web/dist/assets/{yaml-CHmZ21wZ.js → yaml-jAMIJzge.js} +1 -1
  140. package/dist/cli.js +4121 -1902
  141. package/package.json +1 -1
  142. package/dist/assets/web/dist/assets/CostsPage-BuHD-Bv7.js +0 -2
  143. package/dist/assets/web/dist/assets/HomePage-dKBvWh-E.js +0 -2
  144. package/dist/assets/web/dist/assets/PendingTurnRow-CrEAWx5b.js +0 -1
  145. package/dist/assets/web/dist/assets/ResidentsIndexPage-CBBIjPOa.js +0 -1
  146. package/dist/assets/web/dist/assets/RunFoldRow-B6vxjcBI.js +0 -1
  147. package/dist/assets/web/dist/assets/RunRoutePage-CQjd3Cz-.js +0 -6
  148. package/dist/assets/web/dist/assets/RunsIndexPage-Cy9G6D0p.js +0 -1
  149. package/dist/assets/web/dist/assets/ScheduledPage-Ci1Ygqnc.js +0 -1
  150. package/dist/assets/web/dist/assets/SettingsPage-DxmJvAmT.js +0 -1
  151. package/dist/assets/web/dist/assets/SlackMark-VDNs7Vjh.js +0 -1
  152. package/dist/assets/web/dist/assets/UnitRoutePage-DEN5bSQf.js +0 -1
  153. package/dist/assets/web/dist/assets/durationTone-DXG-3R7_.js +0 -1
  154. package/dist/assets/web/dist/assets/indexRow-BD1VT8o8.js +0 -1
  155. package/dist/assets/web/dist/assets/localIso-L06jV29p.js +0 -1
  156. package/dist/assets/web/dist/assets/main-DP_zSemY.css +0 -1
  157. package/dist/assets/web/dist/assets/sseReplay-yji8a1wM.js +0 -9
@@ -168,11 +168,11 @@
168
168
  "when": "Part of `check:consistency`."
169
169
  },
170
170
  "clock:gen": {
171
- "does": "Regenerates the clock-read allowlist (`src/core/trace/clockAllowlist.json`) from the tree — empty since the ratchet reached zero; a result that is not `{}` names a new direct read.",
171
+ "does": "Regenerates both clock allowlists from the tree: wall-clock reads (empty) and duration literals outside `src/core/budgets.ts`.",
172
172
  "when": "Part of `fix`."
173
173
  },
174
174
  "clock:check": {
175
- "does": "No production file reads the wall clock directly: the allowlist is empty and the tree agrees.",
175
+ "does": "No production file reads the wall clock directly or gained a duration literal outside `src/core/budgets.ts`; both allowlists match the tree.",
176
176
  "when": "Part of `check:consistency`."
177
177
  },
178
178
  "docs:changed": {
@@ -204,15 +204,15 @@
204
204
  "when": "After a `web/` change."
205
205
  },
206
206
  "screenshots:gen": {
207
- "does": "Renders the dashboard's screenshots from the fixture preview, both themes, and records their inputs' hashes in `docs/public/screenshots/manifest.json`.",
207
+ "does": "Renders the dashboard screenshots whose inputs changed, both themes, recording each surface's input hashes in `docs/public/screenshots/manifest/` (`--force`: all).",
208
208
  "when": "After a `web/` or fixture change, once `screenshots:check` names it; needs `npx playwright-core install chromium`, so it is not part of `fix`."
209
209
  },
210
210
  "screenshots:check": {
211
- "does": "The dashboard's source and fixtures still hash to what the screenshots were rendered from — no browser.",
211
+ "does": "Each surface's inputs still hash to what its screenshots were rendered from — no browser.",
212
212
  "when": "Part of `check:consistency`."
213
213
  },
214
214
  "load": {
215
- "does": "Load harness: `-- history|resident|sandbox|e2e|cards|provider|pi|route`.",
215
+ "does": "Load harness: `-- history|resident|sandbox|e2e|cards|provider|pi|route|door`.",
216
216
  "when": "Capacity receipts (docs/reference/specs/load-harness.md)."
217
217
  }
218
218
  }
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.241.0",
3
- "commit": "645b2a6d20ac39178acdb7c412a6a72f6013771c",
4
- "builtAt": "2026-09-17T01:34:41.433Z"
2
+ "version": "1.243.0",
3
+ "commit": "abfb780fe3a276f303d13d597ad7c549ba8f49c0",
4
+ "builtAt": "2026-09-17T07:58:14.271Z"
5
5
  }
@@ -1,6 +1,7 @@
1
1
  // Agent definitions. An agent is a system prompt + toolset + machine class + wall-clock budget.
2
2
  import type { Effort } from "../effort.js";
3
3
  import { BASH_TIMEOUT_MAX_MS } from "../execution/bashTimeout.js";
4
+ import { ASKS, RUNAWAY_TURNS_PER_MINUTE, runawayTurnCap, type LoopPreset } from "../core/budgets.js";
4
5
  import { CONTRACT_HEADING, CONTRACT_SECTION_HEADINGS, PR_TITLE_GUARD } from "../core/ship/contract.js";
5
6
  // Which model runs it is resolved separately by the config layers, so any
6
7
  // agent can run on any configured provider/model.
@@ -41,24 +42,16 @@ export function machineNeedsRepo(machine: MachineClass): boolean {
41
42
  export const IDENTITIES = ["none", "read", "write"] as const;
42
43
  export type Identity = (typeof IDENTITIES)[number];
43
44
 
44
- /** The pace that marks a run as looping rather than working: a model turn
45
- * every ten seconds, sustained for the whole wall clock. A busy run takes
46
- * 20–40 s a turn (a model think plus a tool call), so a run that averages six
47
- * a minute from start to end is re-issuing calls, not making progress — and
48
- * its turn cap ends it before the wall clock would, with a write-up that
49
- * says so (docs/reference/specs/harness-pi.md item 15). */
50
- export const RUNAWAY_TURNS_PER_MINUTE = 6;
51
-
52
- /** The turn cap a wall clock implies: `maxMinutes × RUNAWAY_TURNS_PER_MINUTE`.
53
- * Every preset that runs the loop derives its `maxTurns` from this, so the
54
- * cap is never a number a good run reaches — the minutes are the budget. */
55
- export function runawayTurnCap(maxMinutes: number): number {
56
- return maxMinutes * RUNAWAY_TURNS_PER_MINUTE;
57
- }
45
+ /** The wall clocks live in `src/core/budgets.ts` (docs/decisions/0046): a
46
+ * preset's ask, the turn cap derived from it and every allowance are rows
47
+ * there, and this registry reads them. Re-exported for the readers that
48
+ * learned them here. */
49
+ export { RUNAWAY_TURNS_PER_MINUTE, runawayTurnCap };
58
50
 
59
- /** A loop-running preset's budget as one fact: the wall clock, and the runaway
60
- * guard derived from it. */
61
- function loopBudget(maxMinutes: number): Pick<AgentDef, "maxMinutes" | "maxTurns"> {
51
+ /** A loop-running preset's budget as one fact read from the module: the wall
52
+ * clock it asks for, and the runaway guard derived from it. */
53
+ function loopBudget(preset: LoopPreset): Pick<AgentDef, "maxMinutes" | "maxTurns"> {
54
+ const maxMinutes = ASKS[preset];
62
55
  return { maxMinutes, maxTurns: runawayTurnCap(maxMinutes) };
63
56
  }
64
57
 
@@ -150,15 +143,18 @@ export function statusCardRule(examples = '"Implement the fix", "Run the test su
150
143
  );
151
144
  }
152
145
 
153
- const PR_DESCRIPTION_TEMPLATE = `PR description — submit it with the submit_pr_description tool for EVERY PR (this is the default, not something to wait to be asked for). Switchboard renders the GitHub body from the object you submit, so never author PR-body markdown yourself. Before submitting, judge your title with the ${PR_TITLE_GUARD} gate — \`npm run check:pr-title -- "<title>"\` — and submit only a title it accepts; the same gate refuses the PR in CI. Content contract per field (each renders as its own section): prose is unwrapped — no hard line breaks inside a paragraph. Always hyperlink the triggering issue/request. Never fabricate validation — state exactly what you ran and the real result. Keep each field concise, not padded.
154
- EVERY PR includes one that already exists when you push — opened by a person, by dependabot, or by an earlier run. After EVERY push to such a PR: read its current title and body (\`github_issue_get\` with the PR number works for pull requests; \`gh pr view\` where gh exists), judge them against the change as it now stands at the pushed head, and submit the object that describes the PR as it is NOW — carry forward what the existing body says that is still true (a dependency bump's release notes belong in whatWhy), add what you changed, and anchor the Tour at the new head. Switchboard replaces the PR's title and body with your rendering. A description that describes an earlier state of its branch is a bug; "it is someone else's PR" is never a reason to leave it.
146
+ const PR_DESCRIPTION_TEMPLATE = `PR description — submit it with the submit_pr_description tool for EVERY PR (this is the default, not something to wait to be asked for). Switchboard renders the GitHub body from the object you submit, so never author PR-body markdown yourself. Before submitting, judge your title with the ${PR_TITLE_GUARD} gate — \`npm run check:pr-title -- "<title>"\` — and submit only a title it accepts; the same gate refuses the PR in CI. The body is a fixed-size MAP for the reader with everything for agents collapsed under it; every field is capped in visible characters (a link's URL is not counted) and the tool refuses an object over a cap naming the field and the count — cut and resubmit. Prose is unwrapped — no hard line breaks inside a paragraph. Always hyperlink the triggering issue/request. Never fabricate validation — state exactly what you ran and the real result. BEFORE authoring the pointers, load the \`pr-description\` skill with use_skill — it defines how to choose at most seven pointers, the mechanical anchor rules and what goes below the fold; follow it for every PR.
147
+ EVERY PR includes one that already exists when you push — opened by a person, by dependabot, or by an earlier run. After EVERY push to such a PR: read its current title and body (\`github_issue_get\` with the PR number works for pull requests; \`gh pr view\` where gh exists), judge them against the change as it now stands at the pushed head, and submit the object that describes the PR as it is NOW — carry forward what the existing body says that is still true (a dependency bump's release notes belong in why), add what you changed, and anchor the pointers at the new head. Switchboard replaces the PR's title and body with your rendering. A description that describes an earlier state of its branch is a bug; "it is someone else's PR" is never a reason to leave it.
155
148
  - **title**: the PR title — one line naming the change, specific enough to pick out of a PR list.
156
- - **TL;DR** (\`tldr\`, rendered first): two sentences for a naive reader with zero context — what this PR does and why it matters.
157
- - **What & why** (\`whatWhy\`): the change and its motivation, linked to the triggering issue/request.
158
- - **Tour** (\`tour\` + \`remaining\`): the guided walkthrough of the change, replacing any prose list of changes — ordered steps of { title, description, optional lookFor, anchor }, each anchor a { path, from, to } line range at your pushed head; every touched file no step covers goes in \`remaining\` as { path, note }. BEFORE authoring the Tour steps, load the \`pr-tour\` skill with use_skill — it defines the reader-first step shape, the anchor rules, and the Remaining-changes catch-all. Follow it for every PR; if a later push changes what the steps point at, resubmit the description with corrected anchors.
159
- - **Decisions** (\`decisions\`): non-obvious choices as { title, rationale } — alternatives considered and rejected, trade-offs.
160
- - **Risks & implications** (\`risks\`): what could break, the blast radius, and any migration/rollout/compatibility concerns (or "none" — and why).
161
- - **Validation** (\`validation\`): what you tested and the actual results as { criterion, proof } rows (commands run, pass/fail), plus how the reviewer can verify it themselves; the optional summary line carries the overall result.`;
149
+ - **TL;DR** (\`tldr\`, rendered first, ≤300): two sentences for a reader with zero context — what this PR does and why it matters.
150
+ - **Why** (\`why\`, ≤400): the problem and the motivation, with the triggering issue/request, the record and the stack position hyperlinked. Why, never what: the diff shows what.
151
+ - **Where to look** (\`pointers\`, 1 to 7): the files a reviewer would open first, in reading order, each { label ≤60, text ≤160, optional risk ≤100, anchor } with the anchor a { path, from, to } line range at your pushed head, rendered as a link (never embedded code). One pointer per idea, never per file; when the change has more ideas than seven, keep the seven whose mistake would cost most.
152
+ - **Feedback wanted** (\`feedbackWanted\`, ≤200): the one or two things you want the reviewer's judgement on.
153
+ - **Risk** (\`risk\`, ≤300): what breaks if this is wrong, the blast radius, the rollback; over 400 changed lines, say so and name the split you considered.
154
+ - **Verified** (\`verified\`, ≤200): one line for a person — which suites ran and passed, what is still human-gated.
155
+ - **Decisions** (\`decisions\`, 0 to 10, collapsed): non-obvious choices as { title, rationale ≤400 } — the alternative rejected and the fact that decided it.
156
+ - **Validation** (\`validation\`, 1 to 30 criteria, collapsed): what you tested and the actual results as { criterion ≤200, proof ≤300 } rows (test ids, commands run, pass/fail), plus how the reviewer can verify it.
157
+ - **For agents** (\`agentNotes\`, optional, ≤2000, collapsed): what a reviewing agent needs that a person does not — the rebase you did, generated files to skip, the command that reproduces the bug.`;
162
158
 
163
159
  // Both coding prompts carry this verbatim: coding runs hold a write-scoped
164
160
  // token where a merge is one command away, so the boundary is spelled out the
@@ -223,6 +219,14 @@ export const FENCED_CONTENT_RULE =
223
219
  const NOTEPAD = `YOUR NOTES AND YOUR REACH BACK. This thread's conversation outlives your context window and this run: every turn — yours, the person's, every tool call and its output, from this run and the runs before it in this thread — is kept in a log you can search with the \`recall\` tool (words → the matching turns with their numbers; a turn number → that turn whole). When something you need is no longer in front of you, recall it instead of redoing the work or guessing.
224
220
  Keep notes with the \`notes\` tool: one short document, replaced whole each time, at most 8 KiB — decisions and their reasons, the names of things you found (files, tests, commits, the head your tests were green at), what is not yet proven. They are the one thing sure to survive a compaction and to reach the next run in this thread: they ride your system prompt at its start and come back to you right after a compaction. A person reads them too, on the run's page, so write them as a document and never as one paragraph: Markdown, a \`##\` heading per section — \`Done\`, \`In progress\`, \`Next\`, \`Facts\` (names, ids, heads, the reasons behind decisions), leaving out a section with nothing in it — one bullet per item, one line per bullet, no prose walls. Write them when you decide something worth keeping, not only at the end.`;
225
221
 
222
+ // Every coding prompt carries this verbatim (docs/reference/specs/agent-coding.md
223
+ // item 13): the order of checks and the push. Three plan children died at their
224
+ // budget in one evening with finished work unpushed because each ran the
225
+ // project's most expensive checks first; the rule is the runner's to hold, not
226
+ // a line every requester remembers to paste. Stack-agnostic on purpose — the
227
+ // classes are by duration, the project's own scripts and CI say which is which.
228
+ export const CHECKS_BY_COST = `CHECKS BY COST — push before the expensive ones. Every check you might run has a cost class: seconds (a formatter or a linter on the files you touched, one test file, a docs, link or spec check, the typecheck of one package) or minutes (the whole test suite, a build, a dependency install, an end-to-end or full verification). Know a command's class before you run it — from the project's own scripts and CI configuration, from how long it took last time, or by the class above when you have nothing better. Prove each change with the cheapest check that can prove it, matched to the change's scope: a documentation change gets the documentation checks, one module gets its own tests, a shared type gets the typecheck. As soon as the change exists and those checks pass, commit and push — the pushed branch is the deliverable, and an unpushed tree does not survive the run's end. Only then run the expensive checks, once, and fix forward with further commits and pushes. Never start an operation whose expected duration does not fit the time you have left minus what a commit, a push and the description need: push what there is and say plainly what is unverified instead. The description's validation names exactly what ran; what did not run is CI's to gate, and you say so.`;
229
+
226
230
  const CODING_SYSTEM = `You are Switchboard's coding agent, operating from a Slack request.
227
231
 
228
232
  You work inside a dedicated workspace directory with bash, read_file, and write_file tools. ${SANDBOX_TOOLCHAIN}
@@ -237,10 +241,13 @@ Workflow for shipping a PR:
237
241
  1. Clone the repo into the workspace if it's not already there (use gh or git; both are authenticated on this host). Orient with a few BATCHED commands (tree + the relevant files in one call), not file-by-file exploration.
238
242
  2. Create a branch with a descriptive name.
239
243
  3. Implement the change. Match the surrounding code's style and conventions.
240
- 4. Run the project's tests/linters if they exist and are quick enough to run.
241
- 5. Commit with a clear message and push the branch.
242
- 6. Call the submit_pr_description tool with the typed description object (content contract below) — every time, bringing forward the context you gained while implementing. Switchboard renders the PR body from your object at the pushed head and opens (or updates) the pull request itself: do NOT open a PR yourself, with \`gh\` or any API call.
243
- 7. Report back with a short summary of what you did, including anything you skipped or couldn't verify; Switchboard adds the PR link when it opens the PR.
244
+ 4. Prove the change with the cheapest checks that can (CHECKS BY COST below): the linter and the tests nearest the files you touched, the documentation checks for a documentation change.
245
+ 5. Commit with a clear message and push the branch — before any full suite, build or full verification.
246
+ 6. Then, if the budget allows, run the project's expensive checks once and fix forward with further commits and pushes.
247
+ 7. Call the submit_pr_description tool with the typed description object (content contract below) — every time, bringing forward the context you gained while implementing. Switchboard renders the PR body from your object at the pushed head and opens (or updates) the pull request itself: do NOT open a PR yourself, with \`gh\` or any API call.
248
+ 8. Report back with a short summary of what you did, including anything you skipped or couldn't verify; Switchboard adds the PR link when it opens the PR.
249
+
250
+ ${CHECKS_BY_COST}
244
251
 
245
252
  ${NEVER_MERGE}
246
253
 
@@ -279,11 +286,14 @@ Environment notes:
279
286
  Workflow for shipping a change:
280
287
  1. Create a branch with a descriptive name off the bound branch.
281
288
  2. Implement the change. Match the surrounding code's style and conventions.
282
- 3. Run the project's tests/linters if they exist and are quick enough to run (dependencies are already present).
283
- 4. Commit with a clear message and push the branch with \`git push -u origin <branch>\`.
284
- 5. Call the \`diff_digest\` tool to get a distilled summary of your change — per-file churn, totals, and risky-file flags. It is a distilled summary, not the raw diff: use it to shape the description you submit next — which files the Tour must walk, what belongs in risks.
285
- 6. Call the submit_pr_description tool with the typed description object (content contract below) — every time. Switchboard renders the PR body from your object at the pushed head and opens (or updates) the pull request itself: do NOT open a PR yourself, with any API call.
286
- 7. Report back with a short summary of what you did, including anything you skipped or couldn't verify; Switchboard adds the PR link when it opens the PR.
289
+ 3. Prove the change with the cheapest checks that can (CHECKS BY COST below): the linter and the tests nearest the files you touched, the documentation checks for a documentation change (dependencies are already present).
290
+ 4. Commit with a clear message and push the branch with \`git push -u origin <branch>\` — before any full suite, build or full verification.
291
+ 5. Then, if the budget allows, run the project's expensive checks once and fix forward with further commits and pushes.
292
+ 6. Call the \`diff_digest\` tool to get a distilled summary of your change — per-file churn, totals, and risky-file flags. It is a distilled summary, not the raw diff: use it to shape the description you submit next — which files the Tour must walk, what belongs in risks.
293
+ 7. Call the submit_pr_description tool with the typed description object (content contract below) — every time. Switchboard renders the PR body from your object at the pushed head and opens (or updates) the pull request itself: do NOT open a PR yourself, with any API call.
294
+ 8. Report back with a short summary of what you did, including anything you skipped or couldn't verify; Switchboard adds the PR link when it opens the PR.
295
+
296
+ ${CHECKS_BY_COST}
287
297
 
288
298
  ${NEVER_MERGE}
289
299
 
@@ -318,11 +328,14 @@ THE REPOSITORY IS ALREADY CLONED at \`/workspace/checkout\` — seeded from the
318
328
  Workflow for shipping a change:
319
329
  1. Create a branch with a descriptive name off the current branch.
320
330
  2. Implement the change. Match the surrounding code's style and conventions.
321
- 3. Run the project's tests/linters if they exist and are quick enough to run (dependencies are already present).
322
- 4. Commit with a clear message and push the branch with \`git push -u origin <branch>\`.
323
- 5. Call the \`diff_digest\` tool to get a distilled summary of your change — per-file churn, totals, and risky-file flags. It is a distilled summary, not the raw diff: use it to shape the description you submit next — which files the Tour must walk, what belongs in risks.
324
- 6. Call the submit_pr_description tool with the typed description object (content contract below) — every time. Switchboard renders the PR body from your object at the pushed head and opens (or updates) the pull request itself: do NOT open a PR yourself, with \`gh\` or any API call.
325
- 7. Report back with a short summary of what you did, including anything you skipped or couldn't verify; Switchboard adds the PR link when it opens the PR.
331
+ 3. Prove the change with the cheapest checks that can (CHECKS BY COST below): the linter and the tests nearest the files you touched, the documentation checks for a documentation change (dependencies are already present).
332
+ 4. Commit with a clear message and push the branch with \`git push -u origin <branch>\` — before any full suite, build or full verification.
333
+ 5. Then, if the budget allows, run the project's expensive checks once and fix forward with further commits and pushes.
334
+ 6. Call the \`diff_digest\` tool to get a distilled summary of your change — per-file churn, totals, and risky-file flags. It is a distilled summary, not the raw diff: use it to shape the description you submit next — which files the Tour must walk, what belongs in risks.
335
+ 7. Call the submit_pr_description tool with the typed description object (content contract below) — every time. Switchboard renders the PR body from your object at the pushed head and opens (or updates) the pull request itself: do NOT open a PR yourself, with \`gh\` or any API call.
336
+ 8. Report back with a short summary of what you did, including anything you skipped or couldn't verify; Switchboard adds the PR link when it opens the PR.
337
+
338
+ ${CHECKS_BY_COST}
326
339
 
327
340
  ${NEVER_MERGE}
328
341
 
@@ -504,7 +517,7 @@ Your tools work without a workspace: the GitHub tools — \`github_repos\` (the
504
517
 
505
518
  ${statusCardRule('"Read the issue and its thread", "Post the comment"')} A one-step answer needs no checklist; post one when the request has steps the person would wait on.
506
519
 
507
- You cannot run commands, clone repositories, edit code, or review pull requests, and you cannot search the web. Other Switchboard agents can: for code changes or PRs tell the user to re-send with \`agent:coding\`; for a PR review, \`agent:review\`; for a web-research question, \`agent:research\` (e.g. "\`agent:coding fix the failing login test in acme/api\`", "\`agent:research compare X and Y\`"). Delete an issue only when the user explicitly asked to delete it (closing is an update).`;
520
+ You cannot run commands, clone repositories, edit code, or review pull requests, and you cannot search the web. Other Switchboard agents can: for a code change or a pull request tell the user to re-send with \`agent:ship\` (it makes the change, opens the PR and loops review); for a PR review, \`agent:review\`; for a web-research question, \`agent:research\` (e.g. "\`agent:ship in acme/api: fix the failing login test\`", "\`agent:research compare X and Y\`"). Delete an issue only when the user explicitly asked to delete it (closing is an update).`;
508
521
 
509
522
  // The explore agent (docs/reference/specs/agent-explore.md): a long, read-only
510
523
  // investigation — "run our CI locally and validate the claims", "how long does
@@ -527,7 +540,7 @@ THE DELIVERABLE IS A CLAIM TABLE. Turn the request into the claims it makes or a
527
540
 
528
541
  TIME. Your budget is up to two hours — less when a boundary or the request's \`budget:\` directive clipped it, which the runtime-config block above says — and the wrap-up warning tells you when to stop starting new checks. A single command is capped at ${BASH_TIMEOUT_MAX_MS / 60_000} minutes (pass the bash tool's \`timeoutMs\`, up to ${BASH_TIMEOUT_MAX_MS} ms, for a long one). A job that needs longer — a full suite, a build, a pipeline run — is started detached and polled across tool calls: \`setsid -f sh -c '<command> > /tmp/job.log 2>&1; echo $? > /tmp/job.exit'\`, then \`tail -n 40 /tmp/job.log\` and \`cat /tmp/job.exit\` on later calls (a plain background job dies with the command that started it; a \`setsid -f\` job outlives it). Batch commands into few tool calls; never explore file by file.
529
542
 
530
- READ-ONLY: NEVER open a pull request, and never commit or push — no branch, no \`gh pr create\`, no PR or issue write of any kind. You hold a read credential and your job is to find out, not to change. If the investigation shows a change is needed, say exactly what and where in your write-up and point the user at \`agent:coding\`.
543
+ READ-ONLY: NEVER open a pull request, and never commit or push — no branch, no \`gh pr create\`, no PR or issue write of any kind. You hold a read credential and your job is to find out, not to change. If the investigation shows a change is needed, say exactly what and where in your write-up and point the user at \`agent:ship\` (it makes the change, opens the PR and loops review).
531
544
 
532
545
  You cannot attach or post files: your whole answer is text. Never say a file is attached or below — name its path in the workspace and describe it (what it shows, its size) instead; a person who needs the file itself asks \`agent:coding\`, which can attach.
533
546
 
@@ -621,7 +634,7 @@ const WORK_PRESETS = {
621
634
  machine: "none",
622
635
  identity: "none",
623
636
  maxTokens: 16000,
624
- ...loopBudget(5),
637
+ ...loopBudget("general"),
625
638
  },
626
639
  coding: {
627
640
  name: "coding",
@@ -631,7 +644,7 @@ const WORK_PRESETS = {
631
644
  seededSystem: CODING_SYSTEM_SEEDED,
632
645
  toolset: "full",
633
646
  maxTokens: 64000,
634
- ...loopBudget(45),
647
+ ...loopBudget("coding"),
635
648
  // No built-in effort: the deployment decides (`defaults.efforts.coding`,
636
649
  // `config set channel efforts.coding=…`, or `effort:` per request).
637
650
  machine: "repo-resident",
@@ -652,7 +665,7 @@ const WORK_PRESETS = {
652
665
  machine: "repo-resident",
653
666
  identity: "read", // a read-scoped token and a read-only worktree: it cannot post or push from inside
654
667
  maxTokens: 64000,
655
- ...loopBudget(25), // a safety net — typical reviews land in ~5 minutes
668
+ ...loopBudget("review"), // a safety net — typical reviews land in ~5 minutes
656
669
  effort: "medium", // fast turns; one big-context pass does the deep work
657
670
  },
658
671
  ship: {
@@ -681,7 +694,7 @@ const WORK_PRESETS = {
681
694
  // reviewed pull request, never code landing on main.
682
695
  maxTurns: 1,
683
696
  maxTokens: 16000,
684
- maxMinutes: 120,
697
+ maxMinutes: ASKS.ship,
685
698
  },
686
699
  research: {
687
700
  name: "research",
@@ -692,7 +705,7 @@ const WORK_PRESETS = {
692
705
  machine: "none", // web I/O only; no workspace is provisioned
693
706
  identity: "none",
694
707
  maxTokens: 24000,
695
- ...loopBudget(8),
708
+ ...loopBudget("research"),
696
709
  effort: "medium",
697
710
  },
698
711
  explore: {
@@ -706,7 +719,7 @@ const WORK_PRESETS = {
706
719
  machine: "repo-cold",
707
720
  identity: "read", // a read-scoped token: it can clone and read, never push — whatever the caller holds
708
721
  maxTokens: 64000,
709
- ...loopBudget(120),
722
+ ...loopBudget("explore"),
710
723
  // No built-in effort: the deployment decides, as for coding.
711
724
  },
712
725
  } satisfies Record<string, AgentDef>;
@@ -731,7 +744,7 @@ export const AGENTS: Record<string, AgentDef> = {
731
744
  // spawn exactly those — so no child runs that the record did not name.
732
745
  routable: false,
733
746
  maxTokens: 32000,
734
- ...loopBudget(120), // long enough to outlast a coding child; every child is capped by what remains of it
747
+ ...loopBudget("conductor"), // long enough to outlast a coding child; every child is capped by what remains of it
735
748
  // No built-in effort: the deployment decides, as for coding.
736
749
  },
737
750
  };
@@ -73,10 +73,40 @@ export function budgetedAgent(agent: AgentDef, profile: RunProfile): AgentDef {
73
73
 
74
74
  // ---- boundaries: a scope caps, never grants -----------------------------------
75
75
 
76
+ /** The blast-radius classes a scope may name as its `confirm`
77
+ * (docs/decisions/0044-a-routed-write-is-confirmed-in-proportion-to-its-blast-radius.md):
78
+ * the first class on the ladder the door hands back instead of running.
79
+ * `exec` is on the ladder for the comparison but not settable — a test or
80
+ * build never asks — and `never` is refused until the door's write misbind
81
+ * rate has been measured; the validator names both reasons. */
82
+ export type ConfirmClass = "write" | "destructive";
83
+ export const CONFIRM_CLASSES: readonly ConfirmClass[] = ["write", "destructive"];
84
+
85
+ /** The classes on the door's ladder — the command registry's `BlastRadius`,
86
+ * spelled here rather than imported: a type import still drags the registry's
87
+ * whole graph into every program that compiles this near-leaf module, the
88
+ * Workers included. The door indexes the ladder with the registry's type
89
+ * (`routedRunsAtOnce`), so a class added there without a rung here fails to
90
+ * compile at the one place the two vocabularies meet. */
91
+ export type ConfirmLadderClass = "read" | "exec" | "write" | "destructive";
92
+
93
+ /** The ladder the door compares on, `read < exec < write < destructive`: among
94
+ * the last three, from asking most to asking least — a `confirm` of `write`
95
+ * hands back every write and every destructive write, `destructive` only the
96
+ * destructive ones. A read is on the order so the comparison is total, but the
97
+ * door never asks for one. */
98
+ export const CONFIRM_ORDER: Record<ConfirmLadderClass, number> = { read: 0, exec: 1, write: 2, destructive: 3 };
99
+
100
+ /** The door's confirm class when no scope sets one: every routed write is
101
+ * handed back, a read or a test run at once — the door as it was before the
102
+ * axis existed. Attributed to `built-in`, a word that appears in no config. */
103
+ export const BUILT_IN_CONFIRM: ConfirmClass = "write";
104
+
76
105
  /** A cap on the three axes that any scope may set (`defaults`, `channels.<id>`,
77
- * `users.<id>`; docs/reference/specs/routing-and-config.md item 2). An absent
78
- * axis caps nothing. A boundary never grants: it is not a fourth grants axis,
79
- * and the policy table's one question (who may run a preset) is unchanged. */
106
+ * `users.<id>`; docs/reference/specs/routing-and-config.md item 2), and the
107
+ * door's `confirm` beside them. An absent axis caps nothing. A boundary never
108
+ * grants: it is not a fourth grants axis, and the policy table's one question
109
+ * (who may run a preset) is unchanged. */
80
110
  export interface Boundary {
81
111
  /** The most a run may have, in minutes; at least 2 (the bash tool keeps a 60 s reserve). */
82
112
  maxMinutes?: number;
@@ -84,6 +114,11 @@ export interface Boundary {
84
114
  maxIdentity?: Identity;
85
115
  /** The machine classes a run may execute on; a preset's class must be in every layer's set. */
86
116
  machines?: MachineClass[];
117
+ /** The first blast-radius class a command the router bound is handed back
118
+ * at instead of run (record 0044). Not a run cap: it rides the boundary for
119
+ * its scopes and its intersection-toward-caution, is read by the door alone
120
+ * (`effectiveConfirm`), and never enters `intersectBoundaries` or a profile. */
121
+ confirm?: ConfirmClass;
87
122
  }
88
123
 
89
124
  /** One layer's boundary with the scope it came from, in resolution order:
@@ -143,6 +178,36 @@ export function intersectBoundaries(layers: readonly ScopedBoundary[]): Effectiv
143
178
  return out.maxMinutes || out.maxIdentity || out.machines ? out : undefined;
144
179
  }
145
180
 
181
+ /** Where the door's confirm class came from: a scope that set it, or the
182
+ * built-in default when none did. Local to the confirm axis — `BoundaryScope`
183
+ * itself is unchanged, since no run cap is ever attributed to `built-in`. */
184
+ export type ConfirmScope = BoundaryScope | "built-in";
185
+
186
+ /** The door's decision on the confirm axis: the class and the scope it names. */
187
+ export interface EffectiveConfirm {
188
+ value: ConfirmClass;
189
+ scope: ConfirmScope;
190
+ }
191
+
192
+ /**
193
+ * The confirm axis intersected over a request's path, apart from the run caps:
194
+ * the earliest class on `CONFIRM_ORDER` any layer named — the most cautious,
195
+ * since a scope's value is the most permissive answer it allows and the org's
196
+ * is therefore a floor no layer below it can loosen — attributed to the layer
197
+ * that set it (on a tie the first layer named keeps it, the least specific
198
+ * scope, as `intersectBoundaries` does). No layer set one: the built-in
199
+ * `write`, attributed to `built-in`. Pure over the same layers `intersectBoundaries` takes.
200
+ */
201
+ export function effectiveConfirm(layers: readonly ScopedBoundary[]): EffectiveConfirm {
202
+ let out: EffectiveConfirm | undefined;
203
+ for (const { scope, boundary } of layers) {
204
+ if (boundary.confirm !== undefined && (!out || CONFIRM_ORDER[boundary.confirm] < CONFIRM_ORDER[out.value])) {
205
+ out = { value: boundary.confirm, scope };
206
+ }
207
+ }
208
+ return out ?? { value: BUILT_IN_CONFIRM, scope: "built-in" };
209
+ }
210
+
146
211
  /**
147
212
  * The boundaries on a child's path with its parent's remaining wall clock as
148
213
  * one more layer (docs/reference/specs/routing-and-config.md item 20): the
@@ -83,15 +83,18 @@ interface BoundFields {
83
83
  userName?: string;
84
84
  }
85
85
 
86
- /** The actor id whose grants govern a chat message: the credential that
87
- * authenticated it when it was bound to a person (`authenticatedAs`), else
88
- * the sender. Every `canRunAgent` / `canUseRepo` / `canManageRepos` /
89
- * `canEditChannelConfig` question in the dispatch path asks about THIS id,
90
- * never `msg.userId`: naming the person on a run must not lend the run the
91
- * person's grants (authorization.md item 15). A relayed message (`postedBy`)
92
- * is out of scope here — its gates are item 14's. */
93
- export function grantsSubject(msg: { userId: string; authenticatedAs?: string }): string {
94
- return msg.authenticatedAs ?? msg.userId;
86
+ /** The actor every gate in the dispatch path decides on: `resolveChatActor`
87
+ * over the message, with config's grants lookup. Every `canRunAgent` /
88
+ * `canUseRepo` / `canManageRepos` / `canEditChannelConfig` question asks
89
+ * about THIS actor, never `msg.userId`: a relayed message (`postedBy`,
90
+ * authorization.md item 14) decides on the app ∩ the person, a bound
91
+ * credential (`authenticatedAs`, item 15) on the credential alone — naming
92
+ * a person on a run never lends the run the person's grants. */
93
+ export function chatActorOf(
94
+ config: { grantsFor: GrantsLookup },
95
+ msg: { userId: string; channelId: string; threadKey: string; postedBy?: string } & BoundFields,
96
+ ): Actor {
97
+ return resolveChatActor(msg, (id) => config.grantsFor(id));
95
98
  }
96
99
 
97
100
  const CHAT_SURFACES: Readonly<Record<string, ActorSurface>> = {
@@ -63,6 +63,14 @@ export function principalOf(actor: Actor): Actor {
63
63
  return current;
64
64
  }
65
65
 
66
+ /** The channels the decision's person is in: the root principal's
67
+ * `memberOf`, as `selfIdsOf` reads its `self` — a fact from the channel
68
+ * directory, never a grant; absent → the empty set. */
69
+ export function memberChannelsOf(actor: Actor): ReadonlySet<string> {
70
+ return principalOf(actor).memberOf ?? EMPTY_SET;
71
+ }
72
+ const EMPTY_SET: ReadonlySet<string> = new Set();
73
+
66
74
  /** The ids that mean "me" for a decision (record 0042): the root principal's
67
75
  * `self` when it carries one — its own id and the person a dashboard session
68
76
  * is linked to — else its id alone. Never read by a grant check. */
@@ -117,6 +125,7 @@ export function evaluateCondition(
117
125
  grants: Grants,
118
126
  selfIds: readonly string[],
119
127
  attributes: ResourceAttributes,
128
+ memberOf: ReadonlySet<string> = EMPTY_SET,
120
129
  ): boolean {
121
130
  switch (condition.kind) {
122
131
  case "has-grant": {
@@ -124,11 +133,13 @@ export function evaluateCondition(
124
133
  return grant !== undefined && hasAction(grants.actions, grant);
125
134
  }
126
135
  case "member-of":
127
- // Granted the channel, or the channel is public (a run's stamped
136
+ // Granted the channel, in the channel (the directory's fact on the
137
+ // actor), or the channel is public (a run's stamped
128
138
  // visibility). `unknown` — no stamp, a directory failure — is never
129
139
  // public: fail-closed.
130
140
  return (
131
- (attributes.channelId !== undefined && holds(grants.channels, attributes.channelId)) ||
141
+ (attributes.channelId !== undefined &&
142
+ (holds(grants.channels, attributes.channelId) || memberOf.has(attributes.channelId))) ||
132
143
  attributes.channelVisibility === "public"
133
144
  );
134
145
  case "is-self":
@@ -154,7 +165,8 @@ export function evaluateRule(rule: Rule, actor: Actor, resource: Resource): bool
154
165
  if (rule.originVisibility && !rule.originVisibility.includes(attributes.visibility)) return false;
155
166
  const grants = effectiveGrants(actor);
156
167
  const selfIds = selfIdsOf(actor);
157
- return rule.when.every((condition) => evaluateCondition(condition, grants, selfIds, attributes));
168
+ const memberOf = memberChannelsOf(actor);
169
+ return rule.when.every((condition) => evaluateCondition(condition, grants, selfIds, attributes, memberOf));
158
170
  }
159
171
 
160
172
  /** `authorize` over an explicit (validated) table. Tests use it to drive
@@ -173,9 +185,10 @@ export function authorizeWith(rules: readonly Rule[], actor: Actor, action: Acti
173
185
  if (forOrigin.length === 0) return deny("origin-visibility");
174
186
  const grants = effectiveGrants(actor);
175
187
  const selfIds = selfIdsOf(actor);
188
+ const memberOf = memberChannelsOf(actor);
176
189
  let reason: DenyReason | undefined;
177
190
  for (const rule of forOrigin) {
178
- const failed = rule.when.find((condition) => !evaluateCondition(condition, grants, selfIds, attributes));
191
+ const failed = rule.when.find((condition) => !evaluateCondition(condition, grants, selfIds, attributes, memberOf));
179
192
  if (!failed) return ALLOW;
180
193
  reason ??= FAILURE_REASON[failed.kind];
181
194
  }
@@ -82,6 +82,10 @@ export const POLICY: readonly Rule[] = [
82
82
  { action: "friction:write", resource: "command", when: [grant("friction:write")] },
83
83
 
84
84
  // ── costs ────────────────────────────────────────────────────────────────
85
+ // `costs by` reads the snapshot's arithmetic: what every browser session
86
+ // holds (every group's read) and what a Slack user or a token is granted —
87
+ // never a chat baseline, since a by-user table names who spent what.
88
+ { action: "costs:read", resource: "command", when: [grant("costs:read")] },
85
89
  // `costs snapshot` reads both billing providers and replaces what every
86
90
  // viewer of the costs page sees: the grant, never a baseline (the admins'
87
91
  // `all` and a named `grants` entry hold it).
@@ -57,6 +57,15 @@ export interface Actor {
57
57
  readonly self?: readonly string[];
58
58
  /** The linked person, for display and the audit line (`asUser`); absent when unlinked. */
59
59
  readonly asUser?: { readonly id: string; readonly name?: string };
60
+ /**
61
+ * The channels the platform says the actor's person is in: the
62
+ * channel directory's `channelsOf`, resolved once with the actor — a fact
63
+ * about the person, never a grant from config. `member-of` reads it beside
64
+ * `grants.channels`, so a private channel's runs and config open to the
65
+ * people in it. Absent (no directory, a lookup failure, an unlinked session)
66
+ * → nothing: fail-closed, exactly today's behaviour.
67
+ */
68
+ readonly memberOf?: ReadonlySet<string>;
60
69
  }
61
70
 
62
71
  /** `<group>:<read|write|exec>` plus the non-command actions. A plain
@@ -184,4 +193,7 @@ export type Predicate =
184
193
  export interface ChannelDirectory {
185
194
  info(channelId: string): Promise<{ visibility: ChannelVisibility }>;
186
195
  isMember(actorId: string, channelId: string): Promise<boolean | "unknown">;
196
+ /** Every channel the actor is in, platform-namespaced — what the resolver puts on
197
+ * `Actor.memberOf`; `unknown` when the adapter cannot say (fail-closed). */
198
+ channelsOf(actorId: string): Promise<ReadonlySet<string> | "unknown">;
187
199
  }