@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
@@ -0,0 +1,313 @@
1
+ // Every wall clock in Switchboard as one table (docs/decisions/0046; docs/reference/specs/harness-pi.md
2
+ // item 15; docs/reference/specs/agent-ship.md item 8). A budget is a LEASE a
3
+ // parent carves from its own remainder, never a constant a file holds on its
4
+ // own: a preset ASKS for a lease and does useful work above a FLOOR; a parent
5
+ // that runs a loop holds back a RESERVE, derived from the floors and the
6
+ // provisioning of every round that must still follow, plus the merge wait's
7
+ // floor; and the lease covers everything the run does, the loop, the write-up
8
+ // and the post-step, each an ALLOWANCE named here. The registry reads its
9
+ // `maxMinutes` and `maxTurns` from this module and `budgets.check.test.ts`
10
+ // asserts the fits, so a number that breaks another's assumption is a red
11
+ // build. `carve` is called by the ship coordinator for every round (agent-ship
12
+ // item 8), `fit` by the config validator at load and by the fork over a clipped
13
+ // request, and `carveChildOfParent` by the conductor's spawn.
14
+ //
15
+ // Deliberately free of node: imports and of the agent registry, so the
16
+ // Workflow-driven coordinator and the deploy Workers can bundle it — the
17
+ // dependency runs registry → budgets, never the reverse.
18
+
19
+ export const MINUTE_MS = 60_000;
20
+
21
+ /** The presets that run the tool loop, and the one pipeline preset. */
22
+ export const LOOP_PRESETS = ["general", "coding", "review", "research", "explore", "conductor"] as const;
23
+ export type LoopPreset = (typeof LOOP_PRESETS)[number];
24
+ export type Preset = LoopPreset | "ship";
25
+
26
+ /** What each preset asks for when nothing above it is tighter, in minutes. The
27
+ * pipeline's (`ship`) is the wall clock one segment of its loop runs under;
28
+ * a deployment's `ship.maxMinutes` replaces it, held to `fit` below. */
29
+ export const ASKS: Readonly<Record<Preset, number>> = {
30
+ general: 5,
31
+ coding: 45,
32
+ review: 25,
33
+ ship: 120,
34
+ research: 8,
35
+ explore: 120,
36
+ conductor: 120,
37
+ };
38
+
39
+ /** The rounds a ship loop is made of. `fix` is a coding child handed the
40
+ * review's findings; `merge` is the runner's wait on the guards. */
41
+ export type RoundKind = "coding" | "review" | "fix" | "merge";
42
+
43
+ /** The least lease in which a round does useful work, in minutes. A carve that
44
+ * falls under the floor is refused rather than dispatched: a two-minute
45
+ * review or fix costs an attach and a model turn and finishes nothing.
46
+ * Review's is the ledger's 90th percentile of completed reviews (5.1 min over
47
+ * 181); coding's and the merge wait's are guesses until the ledger says. */
48
+ export const PRESET_FLOORS: Readonly<Record<LoopPreset, number>> = {
49
+ general: 2,
50
+ coding: 10,
51
+ review: 5,
52
+ research: 3,
53
+ explore: 15,
54
+ conductor: 15,
55
+ };
56
+ export const FLOORS: Readonly<Record<RoundKind, number>> = {
57
+ coding: PRESET_FLOORS.coding,
58
+ fix: PRESET_FLOORS.coding,
59
+ review: PRESET_FLOORS.review,
60
+ merge: 10,
61
+ };
62
+
63
+ /** The merge wait's own ask: how long the runner waits on the guards at most
64
+ * when the remainder allows it. */
65
+ export const MERGE_WAIT_ASK_MINUTES = 60;
66
+
67
+ /** The ship runner's waits, in minutes: the margin a child's wait allows past
68
+ * its budget, the slice a wait is asked in, the merge door's re-ask cadence,
69
+ * and the pause before a busy spawn is asked again. */
70
+ export const SHIP_WAIT = { marginMinutes: 5, chunkMinutes: 5, mergeChunkMinutes: 5, busyRetryMinutes: 2 } as const;
71
+
72
+ /** The named amounts a lease holds back, in minutes. Each stands for a step
73
+ * every run or round pays: `provision` is attach and restore before the
74
+ * harness's clock starts; `writeUp` is the final answer after the loop ends;
75
+ * `commandWriteUp` is what the last command leaves for that answer; `execCall`
76
+ * is the exec client's wait past a command's own budget; `bearerGrace` is how
77
+ * far past the lease the model bearer stays valid for the last call's tail. */
78
+ export const ALLOWANCES = {
79
+ provision: 3,
80
+ writeUp: 3,
81
+ commandWriteUp: 1,
82
+ execCall: 0.5,
83
+ bearerGrace: 1,
84
+ } as const;
85
+
86
+ /** The post-step turn a preset runs after its loop, in minutes: the coding
87
+ * run's description turn, the review's verdict turn, none for the rest. */
88
+ export const POST_STEP_MINUTES: Readonly<Record<Preset, number>> = {
89
+ coding: 5,
90
+ review: 3,
91
+ general: 0,
92
+ research: 0,
93
+ explore: 0,
94
+ conductor: 0,
95
+ ship: 0,
96
+ };
97
+
98
+ /** The post-step allowance of a preset named by its def's `name`: a name
99
+ * outside the table (a test's, a command run's) runs no post-step. */
100
+ export function postStepMinutes(preset: string): number {
101
+ return (POST_STEP_MINUTES as Readonly<Record<string, number>>)[preset] ?? 0;
102
+ }
103
+
104
+ /** The post-step turn's lease, in minutes (fractional when the remainder is:
105
+ * the harness turns it back into ms): the preset's allowance, or the lease's
106
+ * remainder when that is less — never under a minute, so a turn the loop's
107
+ * write-up crowded still gets its one chance. The bearer's grace covers that
108
+ * floor for a turn that starts at or before the lease's end; a turn that
109
+ * starts later runs on a bearer that may expire under it, and its refused
110
+ * call fails soft — the run's answer already stands. Without a remainder
111
+ * (no harness session says) the allowance stands. */
112
+ export function postStepLease(preset: string, remainingMs: number | undefined): number {
113
+ const allowance = postStepMinutes(preset);
114
+ if (remainingMs === undefined) return allowance;
115
+ return Math.min(allowance, Math.max(1, remainingMs / MINUTE_MS));
116
+ }
117
+
118
+ /** A follow-up turn's lease, in ms: the lesser of what it asks and what the
119
+ * run's lease still holds, never under a minute — the grace covers that floor
120
+ * when the turn starts by the lease's end; later, the bearer may expire under
121
+ * the turn and its call is refused, failing soft. */
122
+ export function turnLeaseMs(askMinutes: number, remainingMs: number): number {
123
+ return Math.min(askMinutes * MINUTE_MS, Math.max(MINUTE_MS, remainingMs));
124
+ }
125
+
126
+ /** The wrap-up warning's place before the loop's end: three minutes, or a
127
+ * quarter of the loop when the loop is shorter than twelve. */
128
+ export const WRAP_UP_WARNING = { minutes: 3, fraction: 0.25 } as const;
129
+
130
+ /** The clocks a harness keeps for one lease (docs/reference/specs/harness-pi.md
131
+ * items 6 and 15). The lease ENDS at `deadline`; the LOOP ENDS at `loopEnd`,
132
+ * the write-up allowance and the preset's post-step earlier, so both run
133
+ * inside the lease; the WARNING is steered at `warnAt`; the write-up is
134
+ * BOUNDED by `finaleMs`. A follow-up turn on the session (`kind: "turn"`)
135
+ * holds nothing back — its deliverable is a tool call, not a write-up — and
136
+ * its loop ends at its deadline. A lease shorter than its hold-back has no
137
+ * loop time: the loop ends at its start, never before it. */
138
+ export interface LoopClock {
139
+ startedAt: number;
140
+ deadline: number;
141
+ loopEnd: number;
142
+ warnAt: number;
143
+ finaleMs: number;
144
+ }
145
+
146
+ export function loopClock(
147
+ startedAt: number,
148
+ remainingMs: number,
149
+ preset: string,
150
+ kind: "loop" | "turn" = "loop",
151
+ ): LoopClock {
152
+ const deadline = startedAt + remainingMs;
153
+ const holdBackMs = kind === "loop" ? (ALLOWANCES.writeUp + postStepMinutes(preset)) * MINUTE_MS : 0;
154
+ const loopEnd = Math.max(startedAt, deadline - holdBackMs);
155
+ const warnAt =
156
+ loopEnd - Math.min(WRAP_UP_WARNING.minutes * MINUTE_MS, (loopEnd - startedAt) * WRAP_UP_WARNING.fraction);
157
+ return { startedAt, deadline, loopEnd, warnAt, finaleMs: ALLOWANCES.writeUp * MINUTE_MS };
158
+ }
159
+
160
+ /** When a run's model-proxy bearer expires: the lease's end plus the grace
161
+ * (docs/reference/specs/model-proxy.md item 2). */
162
+ export function bearerExpiresAt(leaseEndsAt: number): number {
163
+ return leaseEndsAt + ALLOWANCES.bearerGrace * MINUTE_MS;
164
+ }
165
+
166
+ /** The bearer's expiry as the mint at provisioning sets it, before the lease
167
+ * has started: the provisioning allowance, the lease and the grace — replaced
168
+ * by `bearerExpiresAt` the moment the harness starts the lease. */
169
+ export function provisionalBearerExpiresAt(now: number, leaseMinutes: number): number {
170
+ return now + (ALLOWANCES.provision + leaseMinutes + ALLOWANCES.bearerGrace) * MINUTE_MS;
171
+ }
172
+
173
+ /** The per-command bash budget's rows: the default when a call names none, the
174
+ * ceiling a call may raise it to, and the floor under which a number is a
175
+ * typo (docs/reference/specs/execution.md item 11). */
176
+ export const BASH_COMMAND = { defaultMinutes: 5, maxMinutes: 20, minMs: 1_000 } as const;
177
+
178
+ /** The pace that marks a run as looping rather than working: a model turn
179
+ * every ten seconds, sustained for the whole wall clock. A busy run takes
180
+ * 20–40 s a turn (a model think plus a tool call), so a run that averages six
181
+ * a minute from start to end is re-issuing calls, not making progress — and
182
+ * its turn cap ends it before the wall clock would, with a write-up that
183
+ * says so (docs/reference/specs/harness-pi.md item 15). */
184
+ export const RUNAWAY_TURNS_PER_MINUTE = 6;
185
+
186
+ /** The turn cap a wall clock implies: `maxMinutes × RUNAWAY_TURNS_PER_MINUTE`.
187
+ * Every preset that runs the loop derives its `maxTurns` from this, so the
188
+ * cap is never a number a good run reaches — the minutes are the budget. */
189
+ export function runawayTurnCap(maxMinutes: number): number {
190
+ return maxMinutes * RUNAWAY_TURNS_PER_MINUTE;
191
+ }
192
+
193
+ /** A pipeline's loop as the config allows it: `maxRounds` counts review rounds. */
194
+ export interface Loop {
195
+ maxRounds: number;
196
+ }
197
+
198
+ /** A round's position in its loop: its kind and its index, so the reserve is
199
+ * the rounds after that index, never a table keyed by kind alone. */
200
+ export interface RoundPosition {
201
+ kind: RoundKind;
202
+ index: number;
203
+ }
204
+
205
+ /** The rounds of a loop in order: the coding round, then a review and, after
206
+ * every review but the last, a fix, then the merge wait. */
207
+ export function loopRounds(loop: Loop): RoundKind[] {
208
+ const rounds: RoundKind[] = ["coding"];
209
+ for (let i = 0; i < loop.maxRounds; i++) {
210
+ rounds.push("review");
211
+ if (i < loop.maxRounds - 1) rounds.push("fix");
212
+ }
213
+ rounds.push("merge");
214
+ return rounds;
215
+ }
216
+
217
+ /** Where a round sits in `loopRounds`: the coding round first; review round
218
+ * `n` (counted from 1) and the fix that follows it at `2n − 1` and `2n`; the
219
+ * merge wait last. The ship coordinator numbers its rounds this way, so the
220
+ * reserve it carves with is the one the fit assumed. */
221
+ export function loopPosition(loop: Loop, kind: RoundKind, n = 0): number {
222
+ switch (kind) {
223
+ case "coding":
224
+ return 0;
225
+ case "review":
226
+ return 2 * n - 1;
227
+ case "fix":
228
+ return 2 * n;
229
+ case "merge":
230
+ return 2 * loop.maxRounds;
231
+ }
232
+ }
233
+
234
+ /** A child spawned outside any loop (a conductor's): the parent's whole
235
+ * remainder in whole minutes, refused under the child preset's floor — a
236
+ * child under its floor costs a thread and a model turn and finishes nothing. */
237
+ export function carveChildOfParent(
238
+ remainingMs: number,
239
+ preset: LoopPreset,
240
+ ): { kind: "carved"; minutes: number } | { kind: "refused"; reason: "under floor"; minutes: number; floor: number } {
241
+ const minutes = Math.max(0, Math.floor(remainingMs / MINUTE_MS));
242
+ const floor = PRESET_FLOORS[preset];
243
+ if (minutes < floor) return { kind: "refused", reason: "under floor", minutes, floor };
244
+ return { kind: "carved", minutes };
245
+ }
246
+
247
+ /** The minutes a round holds back for what must follow it in the loop: the
248
+ * floor plus provisioning of every later review and fix, and the merge
249
+ * wait's floor. A merge holds nothing back; a round with no loop (a
250
+ * conductor's child) holds nothing back either. */
251
+ export function reserveMinutes(
252
+ round: RoundPosition,
253
+ loop: Loop | undefined,
254
+ floors: Readonly<Record<RoundKind, number>> = FLOORS,
255
+ ): number {
256
+ if (!loop) return 0;
257
+ let reserve = 0;
258
+ for (const kind of loopRounds(loop).slice(round.index + 1)) {
259
+ reserve += kind === "merge" ? floors.merge : floors[kind] + ALLOWANCES.provision;
260
+ }
261
+ return reserve;
262
+ }
263
+
264
+ /** What a round asks for: the preset's ask for a coding or fix round, the
265
+ * review's for a review, the merge wait's own for the merge. */
266
+ export function roundAskMinutes(kind: RoundKind): number {
267
+ switch (kind) {
268
+ case "coding":
269
+ case "fix":
270
+ return ASKS.coding;
271
+ case "review":
272
+ return ASKS.review;
273
+ case "merge":
274
+ return MERGE_WAIT_ASK_MINUTES;
275
+ }
276
+ }
277
+
278
+ /** A carve's result: the minutes with what bounded them (`ask` when the round
279
+ * got its whole ask, `parent` when the remainder minus the reserve was
280
+ * tighter) and what the parent holds back, or a refusal when the minutes the
281
+ * remainder leaves fall under the round's floor. */
282
+ export type Carve =
283
+ | { kind: "carved"; minutes: number; boundedBy: "ask" | "parent"; holds: number }
284
+ | { kind: "refused"; reason: "under floor"; minutes: number; floor: number; holds: number };
285
+
286
+ /** The only place a round's minutes are computed: the parent's remainder in
287
+ * whole minutes minus the round's reserve, capped at the round's ask, refused
288
+ * under the round's floor. `loop` is undefined for a child outside any loop. */
289
+ export function carve(remainingMs: number, round: RoundPosition, loop: Loop | undefined): Carve {
290
+ const holds = reserveMinutes(round, loop);
291
+ const ask = roundAskMinutes(round.kind);
292
+ const headroom = Math.floor(remainingMs / MINUTE_MS) - holds;
293
+ const minutes = Math.min(ask, headroom);
294
+ const floor = FLOORS[round.kind];
295
+ // A refusal reports what the remainder left, never a negative number.
296
+ if (minutes < floor) return { kind: "refused", reason: "under floor", minutes: Math.max(0, minutes), floor, holds };
297
+ return { kind: "carved", minutes, boundedBy: minutes === ask ? "ask" : "parent", holds };
298
+ }
299
+
300
+ /** A pipeline's wall clock and its loop: what `fit` judges. */
301
+ export interface Pipeline extends Loop {
302
+ maxMinutes: number;
303
+ }
304
+
305
+ /** The fit: a pipeline holds its first child at its ask and every later round
306
+ * at its floor — `provision + ask(coding) + reserve(coding, loop) ≤
307
+ * maxMinutes`. Asserted at verify over the registry, at config load over the
308
+ * deployment's `ship` block (`validateShip`), and at the fork over a request a
309
+ * boundary or a `budget:` directive clipped. `need` is the sum a refusal names. */
310
+ export function fit(pipeline: Pipeline): { ok: boolean; need: number; have: number } {
311
+ const need = ALLOWANCES.provision + ASKS.coding + reserveMinutes({ kind: "coding", index: 0 }, pipeline);
312
+ return { ok: pipeline.maxMinutes >= need, need, have: pipeline.maxMinutes };
313
+ }
@@ -150,6 +150,8 @@ export interface CoordinatorInstance {
150
150
  /** The bound credential behind the person (authorization.md item 15), when
151
151
  * there was one: every child is authorized under ITS grants, as the request was. */
152
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;
153
155
  channelId: string;
154
156
  channelName?: string;
155
157
  /** The requesting thread: where the card lives and where a generated plan's
@@ -260,7 +262,7 @@ export function isCoordinatorInstance(v: unknown): v is CoordinatorInstance {
260
262
  if (r.kind !== "ship") return false;
261
263
  if (!isText(r.userId) || !isText(r.channelId) || !isText(r.threadKey)) return false;
262
264
  if (!isOptionalText(r.userName) || !isOptionalText(r.channelName) || !isOptionalText(r.sourceUrl)) return false;
263
- if (!isOptionalText(r.authenticatedAs)) return false;
265
+ if (!isOptionalText(r.authenticatedAs) || !isOptionalText(r.postedBy)) return false;
264
266
  if (typeof r.repo !== "string" || !REPO_SLUG.test(r.repo)) return false;
265
267
  if (!isText(r.branch) || !isOptionalText(r.base)) return false;
266
268
  if (!isFinite(r.createdAt)) return false;
@@ -54,7 +54,6 @@ import {
54
54
  type ShipCaps,
55
55
  type StepReturn,
56
56
  type UnitEnding,
57
- type UnitPipelineInput,
58
57
  type UnitPipelineState,
59
58
  } from "../ship/coordinator.js";
60
59
  import { checksSettledEventType, isCoordinatorUnit, runFinishedEventType, type CoordinatorUnit } from "./contract.js";
@@ -170,7 +169,6 @@ interface PlanFacts {
170
169
  repo: string;
171
170
  base: string;
172
171
  caps: ShipCaps;
173
- childMinutes: UnitPipelineInput["childMinutes"];
174
172
  units: CoordinatorUnit[];
175
173
  }
176
174
 
@@ -184,12 +182,6 @@ function readPlan(a: BotAnswer): PlanFacts {
184
182
  if (typeof b.repo !== "string" || typeof b.base !== "string") throw new UnreadableAnswer("plan", a, "repo and base");
185
183
  if (!isMinutes(b.caps) || typeof b.caps.maxRounds !== "number" || typeof b.caps.maxMinutes !== "number")
186
184
  throw new UnreadableAnswer("plan", a, "caps");
187
- if (
188
- !isMinutes(b.childMinutes) ||
189
- typeof b.childMinutes.coding !== "number" ||
190
- typeof b.childMinutes.review !== "number"
191
- )
192
- throw new UnreadableAnswer("plan", a, "childMinutes");
193
185
  const units: unknown = b.units;
194
186
  if (!Array.isArray(units) || !units.every(isCoordinatorUnit)) throw new UnreadableAnswer("plan", a, "units");
195
187
  return {
@@ -204,7 +196,6 @@ function readPlan(a: BotAnswer): PlanFacts {
204
196
  repo: b.repo,
205
197
  base: b.base,
206
198
  caps: { maxRounds: b.caps.maxRounds, maxMinutes: b.caps.maxMinutes },
207
- childMinutes: { coding: b.childMinutes.coding, review: b.childMinutes.review },
208
199
  units,
209
200
  };
210
201
  }
@@ -486,7 +477,6 @@ async function runUnit(
486
477
  repo: plan.repo,
487
478
  base: plan.base,
488
479
  caps: plan.caps,
489
- childMinutes: plan.childMinutes,
490
480
  // The instance's field decides who merges (record 0031's merge grant),
491
481
  // carried here by the plan route: the hand-off wrote `runner` on a
492
482
  // seeded plan and `person` on a task, and the door re-checks it — the
@@ -1,3 +1,9 @@
1
+ import {
2
+ anthropicTokensCostUsd,
3
+ parseModelPrices,
4
+ type AnthropicTokens,
5
+ type ModelPriceTable,
6
+ } from "./modelPricing.js";
1
7
  import { systemClock } from "./trace/clock.js";
2
8
  // Spend report: what a group of deployed pieces ("switchboard" = the bot
3
9
  // Worker + its containers, the resident/sandbox/memory Workers) costs per day,
@@ -68,10 +74,15 @@ export interface CostsConfig {
68
74
  /** Env var holding an Anthropic Admin API key (sk-ant-admin…). Optional feature. */
69
75
  anthropicAdminKeyEnv: string;
70
76
  groups: Record<string, CostGroupConfig>;
77
+ /** `costs.prices`: the operator's per-million rates by `<provider>/<model>`, over the Anthropic list (item 4b). */
78
+ prices: ModelPriceTable;
71
79
  /** The snapshot the page and the twins serve (src/core/costsSnapshot.ts). */
72
80
  snapshot: {
73
81
  /** Hours between two reads of the billing sources. */
74
82
  everyHours: number;
83
+ /** The platform-namespaced channel (`slack:C…`) told when takes keep failing and when they
84
+ * land again (`ALERT_AFTER_FAILURES` in a row); absent → the status alone says so. */
85
+ alertChannel?: string;
75
86
  };
76
87
  }
77
88
 
@@ -82,13 +93,15 @@ const DEFAULT_ANTHROPIC_ADMIN_ENV = "ANTHROPIC_ADMIN_KEY";
82
93
  * default — both sources bucket by UTC day, and the page is read about as often. */
83
94
  export const COSTS_SNAPSHOT_EVERY_HOURS = Object.freeze({ default: 24, min: 1, max: 168 });
84
95
 
85
- /** `costs.snapshot`: absent → the default interval; a value outside the bounds or not a whole number throws by name. */
96
+ /** `costs.snapshot`: absent → the default interval and no alert channel; a value outside the
97
+ * bounds or not a whole number throws by name; `alertChannel`, when given, is a platform-namespaced id. */
86
98
  function snapshotConfig(raw: unknown): CostsConfig["snapshot"] {
87
99
  if (raw === undefined) return { everyHours: COSTS_SNAPSHOT_EVERY_HOURS.default };
88
100
  if (typeof raw !== "object" || raw === null || Array.isArray(raw))
89
101
  throw new Error("costs.snapshot must be a mapping");
90
- const every = (raw as Record<string, unknown>).everyHours;
91
- if (every === undefined) return { everyHours: COSTS_SNAPSHOT_EVERY_HOURS.default };
102
+ const r = raw as Record<string, unknown>;
103
+ // `undefined` alone defaults: a bare `everyHours:` key (null) is a malformed value, refused below by name.
104
+ const every = r.everyHours === undefined ? COSTS_SNAPSHOT_EVERY_HOURS.default : r.everyHours;
92
105
  if (
93
106
  typeof every !== "number" ||
94
107
  !Number.isInteger(every) ||
@@ -98,7 +111,10 @@ function snapshotConfig(raw: unknown): CostsConfig["snapshot"] {
98
111
  throw new Error(
99
112
  `costs.snapshot.everyHours must be a whole number of hours between ${COSTS_SNAPSHOT_EVERY_HOURS.min} and ${COSTS_SNAPSHOT_EVERY_HOURS.max}`,
100
113
  );
101
- return { everyHours: every };
114
+ if (r.alertChannel === undefined) return { everyHours: every };
115
+ if (typeof r.alertChannel !== "string" || !/^[a-z]+:.+$/.test(r.alertChannel))
116
+ throw new Error("costs.snapshot.alertChannel must be a platform-namespaced channel id (`slack:C…`)");
117
+ return { everyHours: every, alertChannel: r.alertChannel };
102
118
  }
103
119
 
104
120
  function labelMap(raw: unknown, what: string): Record<string, string> {
@@ -147,6 +163,7 @@ export function parseCostsConfig(raw: unknown): CostsConfig | undefined {
147
163
  anthropicAdminKeyEnv:
148
164
  typeof r.anthropicAdminKeyEnv === "string" ? r.anthropicAdminKeyEnv : DEFAULT_ANTHROPIC_ADMIN_ENV,
149
165
  groups,
166
+ prices: parseModelPrices(r.prices),
150
167
  snapshot: snapshotConfig(r.snapshot),
151
168
  };
152
169
  }
@@ -683,82 +700,6 @@ export function buildCostReport(
683
700
  };
684
701
  }
685
702
 
686
- // ---- Anthropic list prices ----------------------------------------------------------------
687
-
688
- /** USD per million tokens of one kind, one model family. */
689
- export interface AnthropicModelPrice {
690
- input: number;
691
- output: number;
692
- cacheWrite5m: number;
693
- cacheWrite1h: number;
694
- cacheRead: number;
695
- }
696
-
697
- /** Anthropic list prices per model family (platform.claude.com/docs/en/about-claude/pricing;
698
- * re-check when it moves). Keyed by the family id the usage report spells — a dated
699
- * release (`claude-haiku-4-5-20251001`) resolves to its family through
700
- * `anthropicPriceOf`. Only what the estimate needs: the open day priced at
701
- * the rate it will bill at; the cost report remains the invoice. */
702
- export const ANTHROPIC_PRICES: Record<string, AnthropicModelPrice> = {
703
- "claude-fable-5-1": { input: 10, output: 50, cacheWrite5m: 12.5, cacheWrite1h: 20, cacheRead: 0.25 },
704
- "claude-mythos-5-1": { input: 10, output: 50, cacheWrite5m: 12.5, cacheWrite1h: 20, cacheRead: 0.25 },
705
- "claude-fable-5": { input: 10, output: 50, cacheWrite5m: 12.5, cacheWrite1h: 20, cacheRead: 1 },
706
- "claude-mythos-5": { input: 10, output: 50, cacheWrite5m: 12.5, cacheWrite1h: 20, cacheRead: 1 },
707
- "claude-opus-5": { input: 5, output: 25, cacheWrite5m: 6.25, cacheWrite1h: 10, cacheRead: 0.5 },
708
- "claude-opus-4-8": { input: 5, output: 25, cacheWrite5m: 6.25, cacheWrite1h: 10, cacheRead: 0.5 },
709
- "claude-opus-4-7": { input: 5, output: 25, cacheWrite5m: 6.25, cacheWrite1h: 10, cacheRead: 0.5 },
710
- "claude-opus-4-6": { input: 5, output: 25, cacheWrite5m: 6.25, cacheWrite1h: 10, cacheRead: 0.5 },
711
- "claude-opus-4-5": { input: 5, output: 25, cacheWrite5m: 6.25, cacheWrite1h: 10, cacheRead: 0.5 },
712
- "claude-opus-4-1": { input: 15, output: 75, cacheWrite5m: 18.75, cacheWrite1h: 30, cacheRead: 1.5 },
713
- "claude-opus-4": { input: 15, output: 75, cacheWrite5m: 18.75, cacheWrite1h: 30, cacheRead: 1.5 },
714
- "claude-sonnet-5": { input: 2, output: 10, cacheWrite5m: 2.5, cacheWrite1h: 4, cacheRead: 0.2 },
715
- "claude-sonnet-4-6": { input: 3, output: 15, cacheWrite5m: 3.75, cacheWrite1h: 6, cacheRead: 0.3 },
716
- "claude-sonnet-4-5": { input: 3, output: 15, cacheWrite5m: 3.75, cacheWrite1h: 6, cacheRead: 0.3 },
717
- "claude-sonnet-4": { input: 3, output: 15, cacheWrite5m: 3.75, cacheWrite1h: 6, cacheRead: 0.3 },
718
- "claude-haiku-4-5": { input: 1, output: 5, cacheWrite5m: 1.25, cacheWrite1h: 2, cacheRead: 0.1 },
719
- "claude-haiku-3-5": { input: 0.8, output: 4, cacheWrite5m: 1, cacheWrite1h: 1.6, cacheRead: 0.08 },
720
- };
721
-
722
- const DATED_RELEASE_SUFFIX = /^-\d{8}$/;
723
-
724
- /** The family prices of a model id: the id itself, or the id less a dated
725
- * release suffix (`-YYYYMMDD`). Nothing else counts as "the same family" —
726
- * `claude-fable-5-1` is not `claude-fable-5` with a suffix, and its cache
727
- * reads bill differently. Unknown → undefined, never a guess. */
728
- export function anthropicPriceOf(modelId: string): AnthropicModelPrice | undefined {
729
- const exact = ANTHROPIC_PRICES[modelId];
730
- if (exact) return exact;
731
- for (const family of Object.keys(ANTHROPIC_PRICES)) {
732
- if (modelId.startsWith(family) && DATED_RELEASE_SUFFIX.test(modelId.slice(family.length)))
733
- return ANTHROPIC_PRICES[family];
734
- }
735
- return undefined;
736
- }
737
-
738
- /** Token counts of one usage-report row, in the report's own kinds. */
739
- export interface AnthropicTokens {
740
- uncachedInput: number;
741
- output: number;
742
- cacheRead: number;
743
- cacheWrite5m: number;
744
- cacheWrite1h: number;
745
- }
746
-
747
- /** What those tokens cost at the family's list prices; undefined for a model
748
- * the table does not know (the caller reports the tokens, never $0). */
749
- export function anthropicTokensCostUsd(modelId: string, t: AnthropicTokens): number | undefined {
750
- const p = anthropicPriceOf(modelId);
751
- if (!p) return undefined;
752
- return (
753
- (t.uncachedInput * p.input +
754
- t.output * p.output +
755
- t.cacheRead * p.cacheRead +
756
- t.cacheWrite5m * p.cacheWrite5m +
757
- t.cacheWrite1h * p.cacheWrite1h) /
758
- 1_000_000
759
- );
760
- }
761
-
762
703
  // ---- range ----------------------------------------------------------------------------
763
704
 
764
705
  const DEFAULT_DAYS = 30;
@@ -0,0 +1,21 @@
1
+ // Where a preset's harness word is set (docs/reference/specs/harness.md item
2
+ // 8), in a module with no imports of its own: the run record (`run_meta.harnessScope`)
3
+ // and the timeline fold read it, and both are compiled into the Workers' and
4
+ // the dashboard's programs, where the roster's neighbours — the harness
5
+ // objects, their Node-only process code — must not follow. The roster
6
+ // re-exports these names, so every reader inside the bot can still take them
7
+ // from there.
8
+
9
+ /** The scopes a word is set at, most specific first — the order the resolution
10
+ * walks the layers: the requester's own scope, the channel's, then the
11
+ * deployment's top-level `harness` block, which is the defaults layer under
12
+ * its one spelling. What `run_meta.harnessScope` and the config block name, so
13
+ * a reader of a run on OpenCode never guesses whose word put it there. */
14
+ export const HARNESS_SCOPES = ["user", "channel", "defaults"] as const;
15
+ export type HarnessScope = (typeof HARNESS_SCOPES)[number];
16
+
17
+ /** Whether a value is one of the scopes a word is set at: the timeline's test
18
+ * for the field a record carries. */
19
+ export function isHarnessScope(value: unknown): value is HarnessScope {
20
+ return typeof value === "string" && (HARNESS_SCOPES as readonly string[]).includes(value);
21
+ }