@coreplane/switchboard 1.240.0 → 1.242.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 (146) hide show
  1. package/dist/assets/config/config.example.yaml +40 -8
  2. package/dist/assets/deploy/cloudflare/worker.ts +77 -18
  3. package/dist/assets/deploy/cloudflare-resident/worker.ts +198 -15
  4. package/dist/assets/deploy/cloudflare-sandbox/worker.ts +38 -5
  5. package/dist/assets/deploy/secrets.manifest.json +6 -0
  6. package/dist/assets/package-lock.json +3 -3
  7. package/dist/assets/package.json +1 -1
  8. package/dist/assets/project.json +2 -2
  9. package/dist/assets/source.json +3 -3
  10. package/dist/assets/src/agents/registry.ts +58 -22
  11. package/dist/assets/src/core/authz/actor.ts +17 -9
  12. package/dist/assets/src/core/authz/authorize.ts +17 -4
  13. package/dist/assets/src/core/authz/grants.ts +20 -9
  14. package/dist/assets/src/core/authz/types.ts +12 -0
  15. package/dist/assets/src/core/coordinator/contract.ts +40 -1
  16. package/dist/assets/src/core/coordinator/driver.ts +17 -1
  17. package/dist/assets/src/core/coordinator/instancesRoute.ts +72 -0
  18. package/dist/assets/src/core/costs.ts +22 -9
  19. package/dist/assets/src/core/harness/scope.ts +21 -0
  20. package/dist/assets/src/core/runEvents.ts +20 -1
  21. package/dist/assets/src/core/runLedger/types.ts +2 -0
  22. package/dist/assets/src/core/runRecord.ts +4 -0
  23. package/dist/assets/src/core/ship/contract.ts +4 -2
  24. package/dist/assets/src/core/ship/coordinator.ts +22 -7
  25. package/dist/assets/src/core/trace/attrs.ts +1 -1
  26. package/dist/assets/src/execution/bindingPurge.ts +2 -2
  27. package/dist/assets/src/execution/residentAutoRebuild.ts +4 -2
  28. package/dist/assets/src/execution/residentHead.ts +23 -10
  29. package/dist/assets/src/execution/residentInfraStreak.ts +89 -0
  30. package/dist/assets/src/execution/residentRefresh.ts +15 -1
  31. package/dist/assets/src/execution/residentSteps.ts +1 -0
  32. package/dist/assets/src/execution/sandboxErrors.ts +6 -0
  33. package/dist/assets/src/execution/seedPlan.ts +17 -0
  34. package/dist/assets/web/dist/.vite/manifest.json +435 -439
  35. package/dist/assets/web/dist/assets/AppShell-IDMGG6yi.js +1 -0
  36. package/dist/assets/web/dist/assets/{CostsPage-BD3w5j_S.js → CostsPage-CtmKhhOF.js} +2 -2
  37. package/dist/assets/web/dist/assets/{DeliveryPage-BtvzKA1m.js → DeliveryPage-n1tz5I_v.js} +1 -1
  38. package/dist/assets/web/dist/assets/HomePage-QtH1EwYF.js +2 -0
  39. package/dist/assets/web/dist/assets/{NotFoundPage-DwOX1HaZ.js → NotFoundPage-C97EhJcQ.js} +1 -1
  40. package/dist/assets/web/dist/assets/PendingTurnRow-BZA_vQt3.js +1 -0
  41. package/dist/assets/web/dist/assets/{ResidentDetailPage-CUAOh5d9.js → ResidentDetailPage-DTMBgnIW.js} +1 -1
  42. package/dist/assets/web/dist/assets/{ResidentsIndexPage-B3KUlYy2.js → ResidentsIndexPage-CaDvXhzJ.js} +1 -1
  43. package/dist/assets/web/dist/assets/RunFoldRow-mgyLW0oV.js +1 -0
  44. package/dist/assets/web/dist/assets/RunRoutePage-DypJYMQa.js +6 -0
  45. package/dist/assets/web/dist/assets/RunsIndexPage-DYraPoWD.js +1 -0
  46. package/dist/assets/web/dist/assets/{RunsTabs-BnkvCBrM.js → RunsTabs-BUfdk0lH.js} +1 -1
  47. package/dist/assets/web/dist/assets/{ScheduledPage-C1y9jgAk.js → ScheduledPage-DXD2gLJk.js} +1 -1
  48. package/dist/assets/web/dist/assets/SettingsPage-BIGio8Y0.js +1 -0
  49. package/dist/assets/web/dist/assets/{StatusDot-CiByEtoR.js → StatusDot-ELoXHlFt.js} +1 -1
  50. package/dist/assets/web/dist/assets/{Tooltip-DyRxk8A8.js → Tooltip-BoeFwYP2.js} +1 -1
  51. package/dist/assets/web/dist/assets/UnitRoutePage-jhCrWW3i.js +1 -0
  52. package/dist/assets/web/dist/assets/{angular-html-DAQ_1T1v.js → angular-html-oBNfPJR0.js} +1 -1
  53. package/dist/assets/web/dist/assets/{angular-ts-D4kxRVp1.js → angular-ts-BvNwsyWA.js} +1 -1
  54. package/dist/assets/web/dist/assets/{apl-Dy07l64y.js → apl-CNUdRlYf.js} +1 -1
  55. package/dist/assets/web/dist/assets/{astro-DZIbTlc7.js → astro-Zb0NriSe.js} +1 -1
  56. package/dist/assets/web/dist/assets/{blade-CmSXWX3l.js → blade-qPRVheqq.js} +1 -1
  57. package/dist/assets/web/dist/assets/{c-ggzVdJLK.js → c-D8Awx4YO.js} +1 -1
  58. package/dist/assets/web/dist/assets/{chapel-Bo1wktsG.js → chapel-Bt72Mhsx.js} +1 -1
  59. package/dist/assets/web/dist/assets/{cobol-DJUmtKM2.js → cobol-BOBacexg.js} +1 -1
  60. package/dist/assets/web/dist/assets/{coffee-BZWOVz4C.js → coffee-E4u0liHW.js} +1 -1
  61. package/dist/assets/web/dist/assets/{cpp-Dtk39yMw.js → cpp-q2sLNlul.js} +1 -1
  62. package/dist/assets/web/dist/assets/{crystal-CGf_-Zoj.js → crystal-DyWqUnlb.js} +1 -1
  63. package/dist/assets/web/dist/assets/{css-CX2fcL4u.js → css-CQY0hFsD.js} +1 -1
  64. package/dist/assets/web/dist/assets/{dist-D8TaC961.js → dist-BU5UivXC.js} +2 -2
  65. package/dist/assets/web/dist/assets/durationTone-BobbycC-.js +1 -0
  66. package/dist/assets/web/dist/assets/{edge-BYS8FtgQ.js → edge-C1MwhJkX.js} +1 -1
  67. package/dist/assets/web/dist/assets/{elixir-fhUpi3nO.js → elixir-Bb3YbHfn.js} +1 -1
  68. package/dist/assets/web/dist/assets/{elm-DAQNiwsN.js → elm-DAN9IGQw.js} +1 -1
  69. package/dist/assets/web/dist/assets/{erb-CWzEys6U.js → erb-BEB8Xlsj.js} +1 -1
  70. package/dist/assets/web/dist/assets/{git-rebase-9rY3p-Rw.js → git-rebase-tqpjRfxO.js} +1 -1
  71. package/dist/assets/web/dist/assets/{glimmer-js-tY7hy0nE.js → glimmer-js-Ccbo65zR.js} +1 -1
  72. package/dist/assets/web/dist/assets/{glimmer-ts-CTrLv5JF.js → glimmer-ts-B6WBVMpE.js} +1 -1
  73. package/dist/assets/web/dist/assets/{glsl-BRJbiDfq.js → glsl-tRec3Fcu.js} +1 -1
  74. package/dist/assets/web/dist/assets/{graphql-B-MtVLh2.js → graphql-P8kbxT4F.js} +1 -1
  75. package/dist/assets/web/dist/assets/{hack-BtIkOypQ.js → hack-H9Zhkagy.js} +1 -1
  76. package/dist/assets/web/dist/assets/{haml-CoOH6gfa.js → haml-DtEnpn7Z.js} +1 -1
  77. package/dist/assets/web/dist/assets/{handlebars-DgrRu-GP.js → handlebars-DkgPfoAz.js} +1 -1
  78. package/dist/assets/web/dist/assets/{html-DZaaI7ca.js → html-D30RXpIs.js} +1 -1
  79. package/dist/assets/web/dist/assets/{html-derivative-DgHvT-qZ.js → html-derivative-DwozLrEx.js} +1 -1
  80. package/dist/assets/web/dist/assets/{http-CvAd51yz.js → http-DXuzBAPm.js} +1 -1
  81. package/dist/assets/web/dist/assets/{hurl-CXGqAfzx.js → hurl-d1UUJIt_.js} +1 -1
  82. package/dist/assets/web/dist/assets/{indexRow-Cse3JGb0.js → indexRow-BD1VT8o8.js} +1 -1
  83. package/dist/assets/web/dist/assets/{java-CIQStIXF.js → java-DL0gWf34.js} +1 -1
  84. package/dist/assets/web/dist/assets/{javascript-WbH8s7E3.js → javascript-Cl7vavnS.js} +1 -1
  85. package/dist/assets/web/dist/assets/{jinja-DzyWEVKC.js → jinja-CfQOWMX9.js} +1 -1
  86. package/dist/assets/web/dist/assets/{jison-CrT-zCa-.js → jison-KdYirlqm.js} +1 -1
  87. package/dist/assets/web/dist/assets/{json-lHjpjXMQ.js → json-C9cDQ-Qj.js} +1 -1
  88. package/dist/assets/web/dist/assets/{jsx-WTwYCHW6.js → jsx-CRx5NItd.js} +1 -1
  89. package/dist/assets/web/dist/assets/{julia-Bqpk9H7E.js → julia-CmsQsZQl.js} +1 -1
  90. package/dist/assets/web/dist/assets/{just-LdXk1r3m.js → just-CsM3Q8TE.js} +1 -1
  91. package/dist/assets/web/dist/assets/{latex-h_y2A5KD.js → latex-BXCh5YRX.js} +1 -1
  92. package/dist/assets/web/dist/assets/{liquid-CY7bF2_p.js → liquid-BF2vwK8p.js} +1 -1
  93. package/dist/assets/web/dist/assets/{lua-oQ7XVZOY.js → lua-DcMBATrl.js} +1 -1
  94. package/dist/assets/web/dist/assets/main-B2fX10aW.css +1 -0
  95. package/dist/assets/web/dist/assets/{main-CGO6fQ8k.js → main-D4EA1g6n.js} +2 -2
  96. package/dist/assets/web/dist/assets/{marko-zv_qZ4QV.js → marko-88MndKvG.js} +1 -1
  97. package/dist/assets/web/dist/assets/{mdc-Dl4kL_YK.js → mdc-DJ4kVAd8.js} +1 -1
  98. package/dist/assets/web/dist/assets/{nginx-CAHCZj4F.js → nginx-CP6mRgtV.js} +1 -1
  99. package/dist/assets/web/dist/assets/{nim-BOtmwCJH.js → nim-QQfj3fpF.js} +1 -1
  100. package/dist/assets/web/dist/assets/{org-DspQk3HQ.js → org-Bke3eBzc.js} +1 -1
  101. package/dist/assets/web/dist/assets/{perl-CAXmvVl0.js → perl-Bkh0N7Kz.js} +1 -1
  102. package/dist/assets/web/dist/assets/{php-CMUx4VyM.js → php-Dnv1Piya.js} +1 -1
  103. package/dist/assets/web/dist/assets/{pug-B55xVJTv.js → pug-D6peFFI7.js} +1 -1
  104. package/dist/assets/web/dist/assets/{qml-DsNKCCRk.js → qml-D8PEs-C-.js} +1 -1
  105. package/dist/assets/web/dist/assets/{r-DbhP0VvO.js → r-B1EL9b_j.js} +1 -1
  106. package/dist/assets/web/dist/assets/{razor-BCpE6P-p.js → razor-BsMTIh2b.js} +1 -1
  107. package/dist/assets/web/dist/assets/{regexp-BllsjiN4.js → regexp-HhvC8spD.js} +1 -1
  108. package/dist/assets/web/dist/assets/{rst-CO5Rpq9V.js → rst-D5paAxpg.js} +1 -1
  109. package/dist/assets/web/dist/assets/{ruby-C2A4OgQq.js → ruby-BrQwhLrl.js} +1 -1
  110. package/dist/assets/web/dist/assets/{sas-Butzoyql.js → sas-afot2B1o.js} +1 -1
  111. package/dist/assets/web/dist/assets/{scss-PEUwJBpf.js → scss-Efm-mwuG.js} +1 -1
  112. package/dist/assets/web/dist/assets/{shellscript-3Yhq0khE.js → shellscript-BK0Vv5fT.js} +1 -1
  113. package/dist/assets/web/dist/assets/{shellsession-04Hkb7m3.js → shellsession-BGqlMC7N.js} +1 -1
  114. package/dist/assets/web/dist/assets/{soy-BnId73cP.js → soy-M0b4UwGM.js} +1 -1
  115. package/dist/assets/web/dist/assets/{sql-BKwdc0ch.js → sql-sxe6IE9j.js} +1 -1
  116. package/dist/assets/web/dist/assets/sseReplay-g7ml86LM.js +9 -0
  117. package/dist/assets/web/dist/assets/{stata-BPPtNkay.js → stata-nPF_ddLP.js} +1 -1
  118. package/dist/assets/web/dist/assets/{surrealql-CDPChcYP.js → surrealql-D_GrC6u7.js} +1 -1
  119. package/dist/assets/web/dist/assets/{svelte-B15DIQ-z.js → svelte-DwL1AtNP.js} +1 -1
  120. package/dist/assets/web/dist/assets/{templ-D-rp_AiB.js → templ-7s7LTDkc.js} +1 -1
  121. package/dist/assets/web/dist/assets/{tex-BnVDCD7p.js → tex-Bx-5fMxe.js} +1 -1
  122. package/dist/assets/web/dist/assets/{ts-tags-cpujdyAR.js → ts-tags-DUMJnke_.js} +1 -1
  123. package/dist/assets/web/dist/assets/{tsx-D1vO4L72.js → tsx-BN8biPDe.js} +1 -1
  124. package/dist/assets/web/dist/assets/{twig-oQGnQGRc.js → twig-8nIu84TN.js} +1 -1
  125. package/dist/assets/web/dist/assets/{typescript-CKSFpXiH.js → typescript-BooSPq_S.js} +1 -1
  126. package/dist/assets/web/dist/assets/{typst-DKHFo8BF.js → typst-CTBiBsem.js} +1 -1
  127. package/dist/assets/web/dist/assets/{vue-BstokpiS.js → vue-C6Ft4Lea.js} +1 -1
  128. package/dist/assets/web/dist/assets/{vue-html-DpCp0_Sz.js → vue-html-Ba36dD5D.js} +1 -1
  129. package/dist/assets/web/dist/assets/{vue-vine-y5d5wUb1.js → vue-vine-NkFexVo2.js} +1 -1
  130. package/dist/assets/web/dist/assets/{xml-Br2hx5Ax.js → xml-C_THnHXZ.js} +1 -1
  131. package/dist/assets/web/dist/assets/{xsl-Bc8Cobap.js → xsl-D8G5xqjY.js} +1 -1
  132. package/dist/assets/web/dist/assets/{yaml-LOITy_I1.js → yaml-jAMIJzge.js} +1 -1
  133. package/dist/cli.js +4632 -817
  134. package/package.json +1 -1
  135. package/dist/assets/web/dist/assets/AppShell-CM_fh-WW.js +0 -1
  136. package/dist/assets/web/dist/assets/HomePage-BU6aRCSe.js +0 -2
  137. package/dist/assets/web/dist/assets/RunFoldRow-Dulcbg25.js +0 -1
  138. package/dist/assets/web/dist/assets/RunRoutePage-DGoGrQZq.js +0 -6
  139. package/dist/assets/web/dist/assets/RunsIndexPage-37W7OZbC.js +0 -1
  140. package/dist/assets/web/dist/assets/SettingsPage-ggfB-Rqr.js +0 -1
  141. package/dist/assets/web/dist/assets/SlackMark-VDNs7Vjh.js +0 -1
  142. package/dist/assets/web/dist/assets/Slideover-CX-LEklF.js +0 -1
  143. package/dist/assets/web/dist/assets/UnitRoutePage-CnWyOizV.js +0 -1
  144. package/dist/assets/web/dist/assets/durationTone-DXG-3R7_.js +0 -1
  145. package/dist/assets/web/dist/assets/main-O8UXRuEv.css +0 -1
  146. package/dist/assets/web/dist/assets/sseReplay-DutHNghE.js +0 -9
@@ -87,8 +87,10 @@ import {
87
87
  SEED_ABANDONED_RESTORE_WAIT_MS,
88
88
  seedFixupScript,
89
89
  seedMarkerText,
90
+ type RestorePhases,
90
91
  type SandboxSeed,
91
92
  type SeedAnswer,
93
+ type SeedPhases,
92
94
  type SeedStep,
93
95
  } from "../../src/execution/seedPlan.js";
94
96
  import { shellQuote } from "../../src/execution/shellQuote.js";
@@ -450,12 +452,13 @@ export class SwitchboardSandbox extends Sandbox<Env> {
450
452
  // marker for another handle.
451
453
  await this.runRoot(["sh", "-c", `${unmountAllRestoresScript()}\n${this.seedSweep()}`], 60_000);
452
454
  let t = systemClock();
453
- await this.restoreSeedInto(seed.checkoutBackupId, SEED_CHECKOUT_DIR, deadline, "checkout");
455
+ const checkout = await this.restoreSeedInto(seed.checkoutBackupId, SEED_CHECKOUT_DIR, deadline, "checkout");
454
456
  steps.restore = systemClock() - t;
457
+ const phases: SeedPhases = { checkout, deps: null };
455
458
  if (seed.depsBackupId) {
456
459
  step = "deps";
457
460
  t = systemClock();
458
- await this.restoreSeedInto(seed.depsBackupId, SEED_DEPS_STAGING_DIR, deadline, "deps");
461
+ phases.deps = await this.restoreSeedInto(seed.depsBackupId, SEED_DEPS_STAGING_DIR, deadline, "deps");
459
462
  steps.deps = systemClock() - t;
460
463
  }
461
464
  step = "fixup";
@@ -480,9 +483,27 @@ export class SwitchboardSandbox extends Sandbox<Env> {
480
483
  );
481
484
  const ms = systemClock() - t0;
482
485
  console.log(
483
- JSON.stringify({ event: "sandbox.seeded", slug: seed.slug, ref: seed.fetchRef ?? seed.ref, sha, steps, ms }),
486
+ JSON.stringify({
487
+ event: "sandbox.seeded",
488
+ slug: seed.slug,
489
+ ref: seed.fetchRef ?? seed.ref,
490
+ sha,
491
+ steps,
492
+ phases,
493
+ ms,
494
+ }),
484
495
  );
485
- return { seeded: true, cached: false, slug: seed.slug, ref: seed.fetchRef ?? seed.ref, sha, from, steps, ms };
496
+ return {
497
+ seeded: true,
498
+ cached: false,
499
+ slug: seed.slug,
500
+ ref: seed.fetchRef ?? seed.ref,
501
+ sha,
502
+ from,
503
+ steps,
504
+ phases,
505
+ ms,
506
+ };
486
507
  } catch (err) {
487
508
  const shape = thrownShape(err);
488
509
  // A half seed never survives either: the run that follows goes cold
@@ -510,7 +531,12 @@ export class SwitchboardSandbox extends Sandbox<Env> {
510
531
  * a staging sibling, the wait is judged by bytes arriving (the SDK's call
511
532
  * takes no timeout) against the seed's one deadline, then the extract
512
533
  * script puts a real tree in place and the target appears last. */
513
- private async restoreSeedInto(id: string, targetDir: string, deadlineMs: number, what: string): Promise<void> {
534
+ private async restoreSeedInto(
535
+ id: string,
536
+ targetDir: string,
537
+ deadlineMs: number,
538
+ what: string,
539
+ ): Promise<RestorePhases> {
514
540
  const attempt = crypto.randomUUID().slice(0, 8);
515
541
  const mountDir = restoreMountDir(targetDir, attempt);
516
542
  const backup: DirectoryBackup = { id, dir: mountDir };
@@ -535,11 +561,18 @@ export class SwitchboardSandbox extends Sandbox<Env> {
535
561
  const verdict = judgeRestoreProgress({ startedMs, nowMs: systemClock(), samples, deadlineMs });
536
562
  if (verdict.verdict !== "wait") throw new Error(`${what} restore ${verdict.verdict}: ${verdict.detail}`);
537
563
  }
564
+ const download = systemClock() - startedMs;
565
+ const extractStartedMs = systemClock();
538
566
  const r = await this.runRoot(
539
567
  ["sh", "-c", extractRestoreScript({ mountDir, backupId: id, archivePath: restoreArchivePath(id), targetDir })],
540
568
  Math.max(60_000, deadlineMs - systemClock()),
541
569
  );
542
570
  if (r.exitCode !== 0) throw new Error(`${what} extract exited ${r.exitCode}: ${tail(r.stderr || r.stdout)}`);
571
+ const extract = systemClock() - extractStartedMs;
572
+ console.log(
573
+ JSON.stringify({ event: "sandbox.seed-restore", what, id, download, extract, method: r.stdout.trim() }),
574
+ );
575
+ return { download, extract };
543
576
  }
544
577
 
545
578
  /** Restores this object started that have not settled: what a failure
@@ -40,6 +40,12 @@
40
40
  "optional": true,
41
41
  "note": "JSON bearer→identity map for POST /ingress and POST /mcp: `{ subject, channel?, email? }` per entry. Self-minted (openssl rand -hex 32 per entry). Optional: without it /ingress and /mcp are disabled and `deploy restart` has no bearer. The `cron` entry fires schedules; a `coordinator` entry (granted `coordinator:step`) is the ship coordinator's, rotated apart from cron. An `email` binds the token to a person: their runs, their grants untouched (authorization.md item 15)."
42
42
  },
43
+ {
44
+ "name": "GITHUB_WEBHOOK_SECRET",
45
+ "workers": ["bot"],
46
+ "optional": true,
47
+ "note": "GitHub webhook signing secret for POST /webhooks/github (the check-run intake). Set the same value on the repo's webhook. Optional: without it the intake answers 503 disabled and the merge wait falls back to its bounded timeout."
48
+ },
43
49
  {
44
50
  "name": "SANDBOX_TOKEN",
45
51
  "workers": ["bot", "sandbox"],
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "switchboard",
3
- "version": "1.240.0",
3
+ "version": "1.242.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "switchboard",
9
- "version": "1.240.0",
9
+ "version": "1.242.0",
10
10
  "license": "Apache-2.0",
11
11
  "workspaces": [
12
12
  "web",
@@ -20445,7 +20445,7 @@
20445
20445
  },
20446
20446
  "packages/switchboard": {
20447
20447
  "name": "@coreplane/switchboard",
20448
- "version": "1.240.0",
20448
+ "version": "1.242.0",
20449
20449
  "license": "Apache-2.0",
20450
20450
  "dependencies": {
20451
20451
  "@earendil-works/pi-ai": "0.85.1",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchboard",
3
- "version": "1.240.0",
3
+ "version": "1.242.0",
4
4
  "private": true,
5
5
  "description": "Mention it in Slack and an agent reviews the PR, ships the fix, or answers the question — on the model you choose, with its tools running where you decide.",
6
6
  "license": "Apache-2.0",
@@ -204,11 +204,11 @@
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": {
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.240.0",
3
- "commit": "dd69a6edcfaa523c998ff1059f47b8ef1675ebe9",
4
- "builtAt": "2026-09-17T00:22:24.645Z"
2
+ "version": "1.242.0",
3
+ "commit": "c0dc26a1590c854d944c451829e9b21f6ce7d721",
4
+ "builtAt": "2026-09-17T05:42:17.412Z"
5
5
  }
@@ -133,6 +133,23 @@ export interface AgentDef {
133
133
  // and rendered against the head sha at render time, so a repush is a
134
134
  // re-render by Switchboard — the agent only resubmits when the CONTENT (line
135
135
  // numbers included) changed.
136
+ /** The status-card rule every tool-running preset carries (docs/reference/specs/run-visibility.md
137
+ * item 2). One sentence in one place: the card shows the command running right
138
+ * now beside the checklist, so the checklist's markers must be facts — ✱ from
139
+ * the item's first command, ✓ only once its result has been read — or the two
140
+ * contradict each other on the card. `examples` are the preset's own outcome
141
+ * phrasings; the rule itself never varies by preset. */
142
+ export function statusCardRule(examples = '"Implement the fix", "Run the test suite"'): string {
143
+ return (
144
+ "Maintain the user-facing status card with the update_status tool. Post your plan as a checklist (○ pending) as soon as you have it, " +
145
+ "then keep it truthful at every moment: the markers are facts, not intentions. Mark an item ✱ when you issue the first command that does it, " +
146
+ "and ✓ only after you have read the result that proves it happened — never in the same turn as the command, never because you intend to run it next, " +
147
+ "never for a reporting or posting step you have not done. The order is: mark the item ✱, run its commands, read the result, then mark it ✓ and the next item ✱. " +
148
+ "The card shows the command running right now beside your checklist, so a ✓ item whose command is still running reads as a lie. " +
149
+ `Items are short outcomes (${examples}), never commands. This is the only progress the user sees while you work.`
150
+ );
151
+ }
152
+
136
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.
137
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.
138
155
  - **title**: the PR title — one line naming the change, specific enough to pick out of a PR list.
@@ -206,6 +223,14 @@ export const FENCED_CONTENT_RULE =
206
223
  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.
207
224
  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.`;
208
225
 
226
+ // Every coding prompt carries this verbatim (docs/reference/specs/agent-coding.md
227
+ // item 13): the order of checks and the push. Three plan children died at their
228
+ // budget in one evening with finished work unpushed because each ran the
229
+ // project's most expensive checks first; the rule is the runner's to hold, not
230
+ // a line every requester remembers to paste. Stack-agnostic on purpose — the
231
+ // classes are by duration, the project's own scripts and CI say which is which.
232
+ 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.`;
233
+
209
234
  const CODING_SYSTEM = `You are Switchboard's coding agent, operating from a Slack request.
210
235
 
211
236
  You work inside a dedicated workspace directory with bash, read_file, and write_file tools. ${SANDBOX_TOOLCHAIN}
@@ -220,10 +245,13 @@ Workflow for shipping a PR:
220
245
  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.
221
246
  2. Create a branch with a descriptive name.
222
247
  3. Implement the change. Match the surrounding code's style and conventions.
223
- 4. Run the project's tests/linters if they exist and are quick enough to run.
224
- 5. Commit with a clear message and push the branch.
225
- 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.
226
- 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.
248
+ 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.
249
+ 5. Commit with a clear message and push the branch — before any full suite, build or full verification.
250
+ 6. Then, if the budget allows, run the project's expensive checks once and fix forward with further commits and pushes.
251
+ 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.
252
+ 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.
253
+
254
+ ${CHECKS_BY_COST}
227
255
 
228
256
  ${NEVER_MERGE}
229
257
 
@@ -237,7 +265,7 @@ ${SHOW_FILES}
237
265
 
238
266
  ${NOTEPAD}
239
267
 
240
- Maintain the user-facing status card with the update_status tool: right after you decide your plan, post it as a checklist (○ pending items), then update it whenever an item starts (✱) or finishes (✓). Items are short outcomes ("Clone repo and read the diff", "Run the test suite"), never commands. Mark an item ✓ only after it has actually happened — never pre-mark reporting/posting steps. This is the only progress the user sees while you work.
268
+ ${statusCardRule('"Clone repo and read the diff", "Run the test suite"')}
241
269
 
242
270
  If the request doesn't name a repository and you can't infer it, ask for it instead of guessing.
243
271
  Report outcomes faithfully: if tests fail or a step was skipped, say so plainly.
@@ -262,11 +290,14 @@ Environment notes:
262
290
  Workflow for shipping a change:
263
291
  1. Create a branch with a descriptive name off the bound branch.
264
292
  2. Implement the change. Match the surrounding code's style and conventions.
265
- 3. Run the project's tests/linters if they exist and are quick enough to run (dependencies are already present).
266
- 4. Commit with a clear message and push the branch with \`git push -u origin <branch>\`.
267
- 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.
268
- 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.
269
- 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.
293
+ 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).
294
+ 4. Commit with a clear message and push the branch with \`git push -u origin <branch>\` — before any full suite, build or full verification.
295
+ 5. Then, if the budget allows, run the project's expensive checks once and fix forward with further commits and pushes.
296
+ 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.
297
+ 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.
298
+ 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.
299
+
300
+ ${CHECKS_BY_COST}
270
301
 
271
302
  ${NEVER_MERGE}
272
303
 
@@ -280,7 +311,7 @@ ${SHOW_FILES}
280
311
 
281
312
  ${NOTEPAD}
282
313
 
283
- Maintain the user-facing status card with the update_status tool: right after you decide your plan, post it as a checklist (○ pending items), then update it whenever an item starts (✱) or finishes (✓). Items are short outcomes ("Implement the fix", "Run the test suite"), never commands. Mark an item ✓ only after it has actually happened — never pre-mark reporting/posting steps. This is the only progress the user sees while you work.
314
+ ${statusCardRule()}
284
315
 
285
316
  Report outcomes faithfully: if tests fail or a step was skipped, say so plainly.
286
317
  ${FENCED_CONTENT_RULE}
@@ -301,11 +332,14 @@ THE REPOSITORY IS ALREADY CLONED at \`/workspace/checkout\` — seeded from the
301
332
  Workflow for shipping a change:
302
333
  1. Create a branch with a descriptive name off the current branch.
303
334
  2. Implement the change. Match the surrounding code's style and conventions.
304
- 3. Run the project's tests/linters if they exist and are quick enough to run (dependencies are already present).
305
- 4. Commit with a clear message and push the branch with \`git push -u origin <branch>\`.
306
- 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.
307
- 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.
308
- 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.
335
+ 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).
336
+ 4. Commit with a clear message and push the branch with \`git push -u origin <branch>\` — before any full suite, build or full verification.
337
+ 5. Then, if the budget allows, run the project's expensive checks once and fix forward with further commits and pushes.
338
+ 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.
339
+ 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.
340
+ 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.
341
+
342
+ ${CHECKS_BY_COST}
309
343
 
310
344
  ${NEVER_MERGE}
311
345
 
@@ -319,7 +353,7 @@ ${SHOW_FILES}
319
353
 
320
354
  ${NOTEPAD}
321
355
 
322
- Maintain the user-facing status card with the update_status tool: right after you decide your plan, post it as a checklist (○ pending items), then update it whenever an item starts (✱) or finishes (✓). Items are short outcomes ("Implement the fix", "Run the test suite"), never commands. Mark an item ✓ only after it has actually happened — never pre-mark reporting/posting steps. This is the only progress the user sees while you work.
356
+ ${statusCardRule()}
323
357
 
324
358
  Report outcomes faithfully: if tests fail or a step was skipped, say so plainly.
325
359
  ${FENCED_CONTENT_RULE}
@@ -384,7 +418,7 @@ ${REVIEW_VERDICT_INSTRUCTION}
384
418
 
385
419
  ${NOTEPAD}
386
420
 
387
- Maintain the user-facing status card with the update_status tool: post your plan as a checklist (○ pending), update as items start (✱) and finish (✓ — only after they actually happened; never pre-mark reporting steps). Items are short outcomes, never commands.
421
+ ${statusCardRule('"Gather the diff and the files", "Analyze the change", "Post the verdict"')}
388
422
 
389
423
  ${FENCED_CONTENT_RULE}
390
424
  Your final message is posted to Slack. Lead with a one-line verdict, then the findings.`;
@@ -417,7 +451,7 @@ ${REVIEW_VERDICT_INSTRUCTION}
417
451
 
418
452
  ${NOTEPAD}
419
453
 
420
- Maintain the user-facing status card with the update_status tool: post your plan as a checklist (○ pending), update as items start (✱) and finish (✓ — only after they actually happened; never pre-mark reporting steps). Items are short outcomes, never commands.
454
+ ${statusCardRule('"Gather the diff and the files", "Analyze the change", "Post the verdict"')}
421
455
 
422
456
  ${FENCED_CONTENT_RULE}
423
457
  Your final message is posted to Slack. Lead with a one-line verdict, then the findings.`;
@@ -451,7 +485,7 @@ ${REVIEW_VERDICT_INSTRUCTION}
451
485
 
452
486
  ${NOTEPAD}
453
487
 
454
- Maintain the user-facing status card with the update_status tool: post your plan as a checklist (○ pending), update as items start (✱) and finish (✓ — only after they actually happened; never pre-mark reporting steps). Items are short outcomes, never commands.
488
+ ${statusCardRule('"Gather the diff and the files", "Analyze the change", "Post the verdict"')}
455
489
 
456
490
  ${FENCED_CONTENT_RULE}
457
491
  Your final message is posted to Slack. Lead with a one-line verdict, then the findings.`;
@@ -469,7 +503,7 @@ How to work:
469
503
  2. Prefer primary sources; corroborate a surprising claim with a second source.
470
504
  3. Answer concisely and cite the URLs (or repo paths) you used. If sources conflict or you couldn't verify something, say so plainly. If web search is unconfigured, use web_fetch / the GitHub tools on what you have and say search was unavailable.
471
505
 
472
- Maintain the user-facing status card with the update_status tool: post a short checklist (○ pending) after you plan, and update items as they start (✱) and finish (✓ — only once they actually happened).
506
+ ${statusCardRule('"Search the sources", "Write the answer"')}
473
507
 
474
508
  ${FENCED_CONTENT_RULE}
475
509
  Use Slack-friendly formatting (no markdown headers; *bold*, bullets, code blocks). Your final message is posted to Slack — lead with the answer, then supporting detail and sources.`;
@@ -485,6 +519,8 @@ Answer directly and concisely. Use Slack-friendly formatting (no markdown header
485
519
 
486
520
  Your tools work without a workspace: the GitHub tools — \`github_repos\` (the org repositories you can reach), \`github_tree\` / \`github_file\` / \`github_search_code\` (browse, read, search their code and docs, private repos included), \`github_issue_list\` / \`github_issue_get\` (read issues), \`github_issue_create\` / \`github_issue_update\` / \`github_issue_comment\` / \`github_issue_delete\` (act on issues) — and \`web_fetch\` (read a public URL). Use them: when the user names a repo loosely ("the switchboard app"), resolve it with github_repos (or the thread) rather than asking; when asked about one of our repos, read it before answering. Report exactly what a tool did (issue number + URL) — never claim an action you did not perform, and never fabricate file contents, URLs, or command output.
487
521
 
522
+ ${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.
523
+
488
524
  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).`;
489
525
 
490
526
  // The explore agent (docs/reference/specs/agent-explore.md): a long, read-only
@@ -512,7 +548,7 @@ READ-ONLY: NEVER open a pull request, and never commit or push — no branch, no
512
548
 
513
549
  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.
514
550
 
515
- Maintain the user-facing status card with the update_status tool: post your plan as a checklist (○ pending) once you have it, and update items as they start (✱) and finish (✓ — only after they actually happened). Items are short outcomes ("Clone and install", "Time the full suite"), never commands.
551
+ ${statusCardRule('"Clone and install", "Time the full suite"')}
516
552
 
517
553
  ${NOTEPAD}
518
554
 
@@ -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>> = {
@@ -100,6 +103,11 @@ const CHAT_SURFACES: Readonly<Record<string, ActorSurface>> = {
100
103
  mcp: "mcp",
101
104
  cli: "cli",
102
105
  schedule: "schedule",
106
+ // The web chat (record 0043): a browser session speaks as text, so its
107
+ // `access:<sub>` is a chat credential too — the browser actor, its baseline
108
+ // and its entry. A service token never reaches the chat (the dashboard gate
109
+ // serves it `/api/*` only), so `access:svc:` is no chat surface.
110
+ access: "access-browser",
103
111
  };
104
112
 
105
113
  /** A chat message's `userId` is already namespaced by its adapter (`slack:U…`,
@@ -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
  }
@@ -62,10 +62,20 @@ export const CHAT_OPEN_ACTIONS: readonly string[] = [
62
62
  "mcp:write",
63
63
  ];
64
64
 
65
- /** What an Access browser session holds implicitly: every registered
66
- * group's read — never a write, never an exec. */
67
- export function browserReadActions(commandGroups: readonly string[]): Set<string> {
68
- return new Set(commandGroups.map((g) => `${g}:read`));
65
+ /** The two writes a browser session holds beside its reads (record 0043): the
66
+ * same personal writes every Slack user holds (`CHAT_OPEN_ACTIONS`), because
67
+ * the web chat makes a browser session a chat user — `memory remember` and
68
+ * `mcp add` for its own tier. The tier rows still decide the target: a shared
69
+ * tier needs the grant it always needed. */
70
+ export const BROWSER_WRITE_ACTIONS: readonly string[] = ["memory:write", "mcp:write"];
71
+
72
+ /** What an Access browser session holds implicitly: every registered group's
73
+ * read plus the two personal chat writes — never another write, never an exec.
74
+ * No groups known (a process that registered no commands) → nothing at all,
75
+ * fail-closed: the writes ride with the catalogue they belong to. */
76
+ export function browserActions(commandGroups: readonly string[]): Set<string> {
77
+ if (commandGroups.length === 0) return new Set();
78
+ return new Set([...commandGroups.map((g) => `${g}:read`), ...BROWSER_WRITE_ACTIONS]);
69
79
  }
70
80
 
71
81
  // ---- the native `grants` block ----------------------------------------------
@@ -243,8 +253,9 @@ export interface GrantsTable {
243
253
  surfaces: Map<string, Grants>;
244
254
  /** What every `slack:` user holds, listed or not: the open chat commands and `agent:run:<name>` for every unrestricted agent. */
245
255
  everyone: Grants;
246
- /** What every Access browser session (`access:<sub>`, never `access:svc:`) holds: each registered group's read. */
247
- browserReads: Grants;
256
+ /** What every Access browser session (`access:<sub>`, never `access:svc:`) holds:
257
+ * each registered group's read and the two personal chat writes. */
258
+ browser: Grants;
248
259
  restrict: Restriction;
249
260
  }
250
261
 
@@ -253,9 +264,9 @@ export interface GrantsTable {
253
264
  * `<group>:read`. Every other namespace (`schedule:`, `access:svc:`, `http:`,
254
265
  * `mcp:`) is a credential or a job that holds exactly what names it — an
255
266
  * unlisted one is `NO_GRANTS` (fail-closed). */
256
- export function namespaceBaseline(actorId: string, table: Pick<GrantsTable, "everyone" | "browserReads">): Grants {
267
+ export function namespaceBaseline(actorId: string, table: Pick<GrantsTable, "everyone" | "browser">): Grants {
257
268
  if (actorId.startsWith("slack:")) return table.everyone;
258
- if (actorId.startsWith("access:") && !actorId.startsWith("access:svc:")) return table.browserReads;
269
+ if (actorId.startsWith("access:") && !actorId.startsWith("access:svc:")) return table.browser;
259
270
  return NO_GRANTS;
260
271
  }
261
272
 
@@ -271,7 +282,7 @@ export function grantsTable(source: GrantsSource): GrantsTable {
271
282
  const openAgents = (source.agentNames ?? []).filter((a) => !restrict.agents.has(a)).map(agentRunAction);
272
283
  const baselines = {
273
284
  everyone: { ...NO_GRANTS, actions: new Set([...CHAT_OPEN_ACTIONS, ...openAgents]) },
274
- browserReads: { ...NO_GRANTS, actions: browserReadActions(source.commandGroups ?? []) },
285
+ browser: { ...NO_GRANTS, actions: browserActions(source.commandGroups ?? []) },
275
286
  };
276
287
  const grants = new Map<string, Grants>();
277
288
  const surfaces = new Map<string, Grants>();
@@ -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
  }
@@ -80,6 +80,22 @@ export function runFinishedEventType(runId: string): string {
80
80
  return `${RUN_FINISHED_EVENT_PREFIX}${runId}`;
81
81
  }
82
82
 
83
+ /** The event the bot's GitHub check-run intake sends a merge-waiting parent:
84
+ * the type carries the head sha (hex — inside the platform's alphabet), so a
85
+ * driver waiting at that head matches its own event and any other head's is
86
+ * buffered harmlessly. */
87
+ export const CHECKS_SETTLED_EVENT_PREFIX = "checks-settled-";
88
+ export function checksSettledEventType(headSha: string): string {
89
+ return `${CHECKS_SETTLED_EVENT_PREFIX}${headSha}`;
90
+ }
91
+
92
+ /** What the checks-settled event carries — the head and a clock; the parent
93
+ * re-asks the merge door before it acts, so nothing more rides here. */
94
+ export interface ChecksSettledPayload {
95
+ headSha: string;
96
+ settledAt: number;
97
+ }
98
+
83
99
  /** What the event carries — ids, a status and a clock; the parent confirms
84
100
  * through `read-record` before it acts, so nothing more rides here. */
85
101
  export interface RunFinishedPayload {
@@ -134,6 +150,8 @@ export interface CoordinatorInstance {
134
150
  /** The bound credential behind the person (authorization.md item 15), when
135
151
  * there was one: every child is authorized under ITS grants, as the request was. */
136
152
  authenticatedAs?: string;
153
+ /** The app that relayed the request for the person (authorization.md item 14), when one did: every child is authorized under app ∩ person, as the request was. */
154
+ postedBy?: string;
137
155
  channelId: string;
138
156
  channelName?: string;
139
157
  /** The requesting thread: where the card lives and where a generated plan's
@@ -244,7 +262,7 @@ export function isCoordinatorInstance(v: unknown): v is CoordinatorInstance {
244
262
  if (r.kind !== "ship") return false;
245
263
  if (!isText(r.userId) || !isText(r.channelId) || !isText(r.threadKey)) return false;
246
264
  if (!isOptionalText(r.userName) || !isOptionalText(r.channelName) || !isOptionalText(r.sourceUrl)) return false;
247
- if (!isOptionalText(r.authenticatedAs)) return false;
265
+ if (!isOptionalText(r.authenticatedAs) || !isOptionalText(r.postedBy)) return false;
248
266
  if (typeof r.repo !== "string" || !REPO_SLUG.test(r.repo)) return false;
249
267
  if (!isText(r.branch) || !isOptionalText(r.base)) return false;
250
268
  if (!isFinite(r.createdAt)) return false;
@@ -310,6 +328,27 @@ export type RunFinishedSend =
310
328
  | { kind: "no-binding"; instance: string }
311
329
  | { kind: "failed"; instance: string; type: string; reason: string };
312
330
 
331
+ /** The one send per settled head (http-ingress.md item 12): best effort like
332
+ * `sendRunFinished` — a refusal is answered, never thrown, and the parent's
333
+ * bounded merge wait times out on its own. */
334
+ export async function sendChecksSettled(
335
+ workflow: WorkflowSender | undefined,
336
+ instance: string,
337
+ headSha: string,
338
+ settledAt: number,
339
+ ): Promise<RunFinishedSend> {
340
+ if (!workflow) return { kind: "no-binding", instance };
341
+ const type = checksSettledEventType(headSha);
342
+ const payload: ChecksSettledPayload = { headSha, settledAt };
343
+ try {
344
+ const handle = await workflow.get(instance);
345
+ await handle.sendEvent({ type, payload });
346
+ return { kind: "sent", instance, type };
347
+ } catch (err) {
348
+ return { kind: "failed", instance, type, reason: err instanceof Error ? err.message : String(err) };
349
+ }
350
+ }
351
+
313
352
  /** The one send per committed terminal record (run-history item 47). */
314
353
  export async function sendRunFinished(
315
354
  workflow: WorkflowSender | undefined,