toolroll 0.9.54 → 0.9.56

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 (350) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +29 -12
  3. package/dist/action-ledger.d.ts +2 -4
  4. package/dist/action-ledger.js +2 -10
  5. package/dist/agent-onboard.js +4 -1
  6. package/dist/approval-policy.d.ts +5 -5
  7. package/dist/approval-policy.js +4 -4
  8. package/dist/approval-rules-ui.d.ts +3 -2
  9. package/dist/approval-rules-ui.js +17 -22
  10. package/dist/assignment-ui.d.ts +3 -2
  11. package/dist/assignment-ui.js +4 -11
  12. package/dist/backup-ui.d.ts +2 -1
  13. package/dist/backup-ui.js +16 -27
  14. package/dist/brand-mark.d.ts +3 -2
  15. package/dist/brand-mark.js +4 -4
  16. package/dist/browser/workspace.css +1 -1
  17. package/dist/browser/workspace.js +25 -25
  18. package/dist/browser-shell.d.ts +2 -1
  19. package/dist/browser-shell.js +9 -9
  20. package/dist/browser-workspace.d.ts +14 -15
  21. package/dist/browser-workspace.js +0 -3
  22. package/dist/budget-alerts.js +3 -3
  23. package/dist/chat-actions.d.ts +23 -18
  24. package/dist/chat-actions.js +110 -88
  25. package/dist/chat-ask.d.ts +2 -2
  26. package/dist/chat-ask.js +5 -5
  27. package/dist/chat-channel.d.ts +15 -15
  28. package/dist/chat-channel.js +20 -20
  29. package/dist/chat-controls.d.ts +0 -4
  30. package/dist/chat-controls.js +0 -1
  31. package/dist/chat-core.js +1 -1
  32. package/dist/chat-delivery-state.js +2 -2
  33. package/dist/chat-delivery.d.ts +2 -2
  34. package/dist/chat-delivery.js +27 -34
  35. package/dist/chat-flow.js +3 -3
  36. package/dist/chat-messages.d.ts +1 -1
  37. package/dist/chat-messages.js +4 -4
  38. package/dist/chat-migration.d.ts +1 -1
  39. package/dist/chat-migration.js +11 -11
  40. package/dist/chat-polish.d.ts +5 -4
  41. package/dist/chat-polish.js +9 -10
  42. package/dist/chat-rooms.js +3 -3
  43. package/dist/chat-warmth.d.ts +2 -2
  44. package/dist/check-levels-ui.d.ts +2 -1
  45. package/dist/check-levels-ui.js +26 -53
  46. package/dist/cli.d.ts +3 -1
  47. package/dist/cli.js +15 -10
  48. package/dist/coding-shipping-ui.d.ts +2 -1
  49. package/dist/coding-shipping-ui.js +6 -6
  50. package/dist/coding-ui.d.ts +3 -1
  51. package/dist/coding-ui.js +30 -29
  52. package/dist/contracts/chat-actions.d.ts +32 -27
  53. package/dist/contracts/chat-actions.js +29 -11
  54. package/dist/contracts/chat-content.d.ts +1 -1
  55. package/dist/contracts/chat-content.js +1 -1
  56. package/dist/contracts/chat-tables.js +4 -4
  57. package/dist/contracts/cli.d.ts +1 -3
  58. package/dist/contracts/console-api.d.ts +7 -7
  59. package/dist/contracts/console-api.js +13 -12
  60. package/dist/contracts/flow-propose.d.ts +3 -3
  61. package/dist/contracts/flow.d.ts +132 -125
  62. package/dist/contracts/flow.js +27 -12
  63. package/dist/contracts/health.d.ts +1 -3
  64. package/dist/contracts/lead-context.d.ts +1 -1
  65. package/dist/contracts/lead-context.js +2 -2
  66. package/dist/contracts/lead-tools.d.ts +20 -18
  67. package/dist/contracts/lead-tools.js +15 -15
  68. package/dist/contracts/stage-output.d.ts +1 -1
  69. package/dist/contracts/stage-output.js +1 -1
  70. package/dist/contracts/store-json.d.ts +23 -22
  71. package/dist/contracts/store-json.js +17 -17
  72. package/dist/contracts/{teammate-turn.d.ts → subagent-turn.d.ts} +5 -5
  73. package/dist/contracts/{teammate-turn.js → subagent-turn.js} +7 -7
  74. package/dist/control-ui.d.ts +5 -4
  75. package/dist/control-ui.js +9 -34
  76. package/dist/converse.d.ts +23 -23
  77. package/dist/converse.js +28 -28
  78. package/dist/coordinator-proposals.d.ts +2 -2
  79. package/dist/coordinator-proposals.js +1 -1
  80. package/dist/coordinator.js +1 -1
  81. package/dist/credentials-ui.d.ts +5 -4
  82. package/dist/credentials-ui.js +23 -24
  83. package/dist/demo-chat.d.ts +3 -3
  84. package/dist/demo-chat.js +29 -76
  85. package/dist/deprecations.d.ts +12 -0
  86. package/dist/deprecations.js +20 -0
  87. package/dist/desktop-update-gate.js +8 -4
  88. package/dist/desktop-update.js +6 -1
  89. package/dist/discord-chat.js +6 -6
  90. package/dist/discord-settings.d.ts +3 -2
  91. package/dist/discord-settings.js +17 -42
  92. package/dist/dispatch.d.ts +1 -1
  93. package/dist/dispatch.js +1 -1
  94. package/dist/envelope.js +10 -1
  95. package/dist/evidence-pack.d.ts +2 -1
  96. package/dist/evidence-pack.js +45 -49
  97. package/dist/export-ui.d.ts +7 -2
  98. package/dist/export-ui.js +4 -10
  99. package/dist/export.js +7 -7
  100. package/dist/flow-engine.d.ts +2 -2
  101. package/dist/flow-engine.js +21 -21
  102. package/dist/flow-gallery-ui.d.ts +4 -3
  103. package/dist/flow-gallery-ui.js +28 -43
  104. package/dist/flow-insights.d.ts +1 -1
  105. package/dist/flow-share.js +13 -8
  106. package/dist/flow-starters-ui.d.ts +2 -1
  107. package/dist/flow-starters-ui.js +12 -15
  108. package/dist/flow-steps.d.ts +5 -5
  109. package/dist/flow-steps.js +19 -19
  110. package/dist/flow-triggers.d.ts +2 -1
  111. package/dist/flow-triggers.js +17 -15
  112. package/dist/flows-cli.js +1 -1
  113. package/dist/flows-ui.d.ts +4 -3
  114. package/dist/flows-ui.js +38 -49
  115. package/dist/flows.js +21 -20
  116. package/dist/guides.js +112 -6
  117. package/dist/html.d.ts +76 -0
  118. package/dist/html.js +173 -0
  119. package/dist/integrations-ui.d.ts +2 -1
  120. package/dist/integrations-ui.js +19 -23
  121. package/dist/invoke.js +1 -1
  122. package/dist/kits-ui.d.ts +5 -4
  123. package/dist/kits-ui.js +22 -24
  124. package/dist/kits.d.ts +12 -12
  125. package/dist/kits.js +31 -31
  126. package/dist/knowledge-ui.d.ts +6 -5
  127. package/dist/knowledge-ui.js +26 -25
  128. package/dist/{mate-cli.d.ts → lead-cli.d.ts} +11 -11
  129. package/dist/{mate-cli.js → lead-cli.js} +38 -38
  130. package/dist/lead-commitments.js +4 -4
  131. package/dist/lead-context.d.ts +12 -2
  132. package/dist/lead-context.js +15 -17
  133. package/dist/{mate-contract.d.ts → lead-contract.d.ts} +3 -3
  134. package/dist/{mate-contract.js → lead-contract.js} +7 -6
  135. package/dist/{mate-doors.d.ts → lead-doors.d.ts} +6 -6
  136. package/dist/{mate-doors.js → lead-doors.js} +24 -24
  137. package/dist/lead-follow.d.ts +5 -5
  138. package/dist/lead-follow.js +9 -9
  139. package/dist/lead-people.d.ts +5 -5
  140. package/dist/lead-people.js +14 -14
  141. package/dist/{mate-progress.d.ts → lead-progress.d.ts} +9 -9
  142. package/dist/{mate-progress.js → lead-progress.js} +3 -3
  143. package/dist/{mate-tools.d.ts → lead-tools.d.ts} +19 -19
  144. package/dist/{mate-tools.js → lead-tools.js} +109 -101
  145. package/dist/{mate.d.ts → lead.d.ts} +27 -27
  146. package/dist/{mate.js → lead.js} +102 -102
  147. package/dist/ledger-view.d.ts +2 -1
  148. package/dist/ledger-view.js +27 -29
  149. package/dist/limits-ui.d.ts +3 -2
  150. package/dist/limits-ui.js +8 -8
  151. package/dist/live-bus.d.ts +50 -8
  152. package/dist/live-bus.js +133 -50
  153. package/dist/mcp-connect.d.ts +1 -1
  154. package/dist/mcp-connect.js +3 -3
  155. package/dist/mcp-core.js +1 -1
  156. package/dist/mcp-oauth.js +35 -36
  157. package/dist/memory-pass.d.ts +2 -2
  158. package/dist/memory-pass.js +7 -7
  159. package/dist/models-ui.d.ts +2 -1
  160. package/dist/models-ui.js +25 -35
  161. package/dist/monitoring-ui.d.ts +4 -3
  162. package/dist/monitoring-ui.js +13 -26
  163. package/dist/notification-rows.d.ts +2 -2
  164. package/dist/openrouter-models.d.ts +2 -1
  165. package/dist/openrouter-models.js +5 -9
  166. package/dist/operate.d.ts +3 -3
  167. package/dist/operate.js +46 -41
  168. package/dist/people-audit-ui.d.ts +2 -1
  169. package/dist/people-audit-ui.js +13 -13
  170. package/dist/policy-ui.d.ts +3 -2
  171. package/dist/policy-ui.js +17 -23
  172. package/dist/policy.d.ts +1 -1
  173. package/dist/principal.d.ts +1 -1
  174. package/dist/principal.js +2 -2
  175. package/dist/project-delete-ui.d.ts +4 -3
  176. package/dist/project-delete-ui.js +16 -28
  177. package/dist/project-delete.d.ts +1 -1
  178. package/dist/project-delete.js +26 -26
  179. package/dist/project-memory.js +1 -1
  180. package/dist/project-tools.d.ts +1 -1
  181. package/dist/project-tools.js +1 -1
  182. package/dist/pull-request-ui.d.ts +2 -1
  183. package/dist/pull-request-ui.js +17 -32
  184. package/dist/recipe-ui.d.ts +8 -7
  185. package/dist/recipe-ui.js +39 -32
  186. package/dist/release-adapters.d.ts +60 -0
  187. package/dist/release-adapters.js +435 -0
  188. package/dist/release-cli.d.ts +24 -0
  189. package/dist/release-cli.js +181 -0
  190. package/dist/release-gate.d.ts +80 -0
  191. package/dist/release-gate.js +150 -0
  192. package/dist/release-links.d.ts +29 -0
  193. package/dist/release-links.js +102 -0
  194. package/dist/release.d.ts +331 -0
  195. package/dist/release.js +550 -0
  196. package/dist/reply-shape.d.ts +2 -1
  197. package/dist/reply-shape.js +5 -5
  198. package/dist/repository-context-ui.d.ts +2 -1
  199. package/dist/repository-context-ui.js +2 -7
  200. package/dist/request-budget-ui.d.ts +2 -1
  201. package/dist/request-budget-ui.js +16 -16
  202. package/dist/result-review.d.ts +2 -3
  203. package/dist/result-review.js +3 -9
  204. package/dist/retention-ui.d.ts +7 -1
  205. package/dist/retention-ui.js +7 -11
  206. package/dist/retention.js +4 -4
  207. package/dist/scope.d.ts +2 -2
  208. package/dist/scout-report.d.ts +1 -1
  209. package/dist/scout-report.js +1 -1
  210. package/dist/serve.d.ts +11 -2
  211. package/dist/serve.js +105 -99
  212. package/dist/server/chat.d.ts +7 -10
  213. package/dist/server/chat.js +984 -1021
  214. package/dist/server/chrome.d.ts +291 -0
  215. package/dist/server/chrome.js +747 -0
  216. package/dist/server/console.css +2819 -0
  217. package/dist/server/flows.d.ts +2 -6
  218. package/dist/server/flows.js +854 -773
  219. package/dist/server/guards.d.ts +1 -1
  220. package/dist/server/guards.js +8 -5
  221. package/dist/server/handler-context.d.ts +1 -1
  222. package/dist/server/handler-context.js +1 -1
  223. package/dist/server/handler-registry.d.ts +8 -6
  224. package/dist/server/handler-registry.js +14 -2
  225. package/dist/server/http.d.ts +274 -0
  226. package/dist/server/http.js +239 -0
  227. package/dist/server/live.d.ts +34 -0
  228. package/dist/server/live.js +309 -0
  229. package/dist/server/pages.d.ts +2 -6
  230. package/dist/server/pages.js +808 -789
  231. package/dist/server/people-tokens.d.ts +1 -6
  232. package/dist/server/people-tokens.js +581 -573
  233. package/dist/server/remote.d.ts +4 -7
  234. package/dist/server/remote.js +39 -70
  235. package/dist/server/render-chat.d.ts +402 -0
  236. package/dist/server/render-chat.js +927 -0
  237. package/dist/server/render-pages.d.ts +535 -0
  238. package/dist/server/render-pages.js +1277 -0
  239. package/dist/server/render-people.d.ts +32 -0
  240. package/dist/server/render-people.js +121 -0
  241. package/dist/server/render-results.d.ts +744 -0
  242. package/dist/server/render-results.js +2237 -0
  243. package/dist/server/render-settings.d.ts +154 -0
  244. package/dist/server/render-settings.js +580 -0
  245. package/dist/server/render-tasks.d.ts +632 -0
  246. package/dist/server/render-tasks.js +2345 -0
  247. package/dist/server/request-context.d.ts +46 -0
  248. package/dist/server/request-context.js +17 -0
  249. package/dist/server/route-table.d.ts +25 -3
  250. package/dist/server/route-table.js +167 -77
  251. package/dist/server/runtime.d.ts +28 -20
  252. package/dist/server/runtime.js +7 -1
  253. package/dist/server/session.d.ts +138 -0
  254. package/dist/server/session.js +94 -0
  255. package/dist/server/settings.d.ts +1 -5
  256. package/dist/server/settings.js +2186 -2121
  257. package/dist/server/task-views.d.ts +11 -4
  258. package/dist/server/task-views.js +26 -22
  259. package/dist/server/tasks.d.ts +9 -9
  260. package/dist/server/tasks.js +1489 -1420
  261. package/dist/server-telemetry.d.ts +4 -3
  262. package/dist/server-telemetry.js +4 -8
  263. package/dist/setup-guide.d.ts +2 -2
  264. package/dist/skills-ui.d.ts +4 -3
  265. package/dist/skills-ui.js +14 -17
  266. package/dist/skills.d.ts +1 -1
  267. package/dist/skills.js +2 -1
  268. package/dist/slack-chat.d.ts +2 -2
  269. package/dist/slack-chat.js +6 -6
  270. package/dist/slack-settings.d.ts +3 -2
  271. package/dist/slack-settings.js +17 -42
  272. package/dist/spend-ui.d.ts +5 -4
  273. package/dist/spend-ui.js +17 -40
  274. package/dist/spend.d.ts +14 -14
  275. package/dist/spend.js +31 -31
  276. package/dist/sso-ui.d.ts +3 -2
  277. package/dist/sso-ui.js +26 -27
  278. package/dist/storage-ui.d.ts +2 -1
  279. package/dist/storage-ui.js +14 -33
  280. package/dist/store-baseline.d.ts +19 -0
  281. package/dist/store-baseline.js +2873 -0
  282. package/dist/store.d.ts +197 -249
  283. package/dist/store.js +747 -3084
  284. package/dist/subagent-admin.d.ts +53 -0
  285. package/dist/{teammate-admin.js → subagent-admin.js} +37 -37
  286. package/dist/{teammate-desk.d.ts → subagent-desk.d.ts} +22 -23
  287. package/dist/{teammate-desk.js → subagent-desk.js} +26 -35
  288. package/dist/{teammate-memory.d.ts → subagent-memory.d.ts} +17 -17
  289. package/dist/{teammate-memory.js → subagent-memory.js} +41 -41
  290. package/dist/{teammate-question.d.ts → subagent-question.d.ts} +6 -6
  291. package/dist/{teammate-question.js → subagent-question.js} +17 -17
  292. package/dist/{teammate-tools.d.ts → subagent-tools.d.ts} +18 -18
  293. package/dist/{teammate-tools.js → subagent-tools.js} +36 -36
  294. package/dist/{teammate-week.d.ts → subagent-week.d.ts} +10 -10
  295. package/dist/{teammate-week.js → subagent-week.js} +23 -23
  296. package/dist/{teammate-work.d.ts → subagent-work.d.ts} +21 -21
  297. package/dist/{teammate-work.js → subagent-work.js} +50 -50
  298. package/dist/subagents-ui.d.ts +22 -0
  299. package/dist/subagents-ui.js +137 -0
  300. package/dist/{teammates.d.ts → subagents.d.ts} +11 -11
  301. package/dist/{teammates.js → subagents.js} +21 -21
  302. package/dist/subscription-chat.d.ts +13 -13
  303. package/dist/subscription-chat.js +19 -19
  304. package/dist/surface.d.ts +19 -2
  305. package/dist/surface.js +20 -33
  306. package/dist/task-composer.d.ts +2 -1
  307. package/dist/task-composer.js +7 -7
  308. package/dist/task-status.d.ts +4 -3
  309. package/dist/task-status.js +15 -16
  310. package/dist/team-cli.d.ts +1 -0
  311. package/dist/team-cli.js +14 -2
  312. package/dist/team-http.d.ts +5 -9
  313. package/dist/team-http.js +5 -63
  314. package/dist/team-leads.js +13 -13
  315. package/dist/team-runtime.d.ts +2 -2
  316. package/dist/team-runtime.js +8 -8
  317. package/dist/team-ui.d.ts +2 -1
  318. package/dist/team-ui.js +2 -5
  319. package/dist/teams-chat.js +6 -6
  320. package/dist/teams-settings.d.ts +3 -2
  321. package/dist/teams-settings.js +20 -31
  322. package/dist/telegram-flow.d.ts +3 -3
  323. package/dist/telegram-flow.js +3 -3
  324. package/dist/{telegram-mate.d.ts → telegram-lead.d.ts} +5 -5
  325. package/dist/{telegram-mate.js → telegram-lead.js} +27 -27
  326. package/dist/telegram-settings.d.ts +3 -2
  327. package/dist/telegram-settings.js +14 -25
  328. package/dist/telegram-status.js +1 -1
  329. package/dist/telegram-team.d.ts +1 -1
  330. package/dist/telegram-team.js +5 -5
  331. package/dist/telegram.d.ts +1 -1
  332. package/dist/telegram.js +9 -30
  333. package/dist/text-limits.d.ts +18 -18
  334. package/dist/text-limits.js +18 -18
  335. package/dist/toolroll-update-ui.d.ts +3 -2
  336. package/dist/toolroll-update-ui.js +35 -39
  337. package/dist/toolroll-update.d.ts +8 -0
  338. package/dist/toolroll-update.js +45 -8
  339. package/dist/tools-ui.d.ts +5 -2
  340. package/dist/tools-ui.js +31 -31
  341. package/dist/when-html.d.ts +11 -3
  342. package/dist/when-html.js +5 -6
  343. package/dist/workspace-ui.d.ts +4 -2
  344. package/dist/workspace-ui.js +10 -10
  345. package/package.json +1 -2
  346. package/dist/server/shared.d.ts +0 -3151
  347. package/dist/server/shared.js +0 -12001
  348. package/dist/teammate-admin.d.ts +0 -53
  349. package/dist/teammates-ui.d.ts +0 -20
  350. package/dist/teammates-ui.js +0 -166
@@ -0,0 +1,550 @@
1
+ /**
2
+ * `toolroll release <branch>` (D10): the whole release of Toolroll itself as one resumable command. The owner's
3
+ * approval of the release scope is the only human step; everything else runs headless, each waiting step with a time
4
+ * limit:
5
+ *
6
+ * gate → approval → check → complete → quiet → deploy → link → ci → merge → tree → tag → publish → homebrew → done
7
+ *
8
+ * gate files a prepared-candidate task pinned to the branch's exact commit, with the full release check. approval
9
+ * waits for an active approver's yes to that exact scope (never a mode's, an AI's or the lead's). check requires a Full
10
+ * result of that commit: a failed check stops the release and stays visible, nothing reruns it. complete marks that
11
+ * exact result Complete through the task's own authorization. quiet waits until nothing builds. deploy runs the
12
+ * candidate's own deploy (scripts/deploy-browser.mjs: drain, backup, rehearsal, swap, health) and keeps the runtime its
13
+ * final JSON names; an interrupted one is recovered from its saved stage (--phase recover) before anything else. link
14
+ * points both CLI names at that runtime's bin.js, putting them back if either doesn't answer, and goes on only when
15
+ * the console, worker and both names run the gated commit. ci waits for every
16
+ * reported pull-request check on that same commit to pass, with every required check present: pending, missing,
17
+ * skipped or failed never merges. merge
18
+ * squash-merges with the head pinned to the gated commit, tree proves main's new commit has exactly the gated tree,
19
+ * and only then tag pushes the version tag, whose macOS matrix must pass before npm publication (publish.yml). publish
20
+ * waits for npm, the GitHub release and the macOS checks; homebrew updates the tap's formula and merges it.
21
+ *
22
+ * A private journal (mode 0600, written whole then renamed) records what each step is about to do before it does
23
+ * anything outside this machine, and what it saw after. A stop — a time limit, a failure, a crash, Ctrl-C — leaves the
24
+ * journal at its step; running the same command again finds what already happened (the task, the completion, the
25
+ * merge, the tag, the package, the tap change) instead of doing it twice, and continues from the first unfinished
26
+ * step. A branch, pull request or tag that no longer matches what was gated stops the release rather than guess.
27
+ *
28
+ * Everything outside this module is an adapter (release-adapters.ts), so the state machine is tested without git,
29
+ * GitHub, npm or a running Toolroll.
30
+ */
31
+ import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, writeFileSync } from "node:fs";
32
+ import { randomUUID } from "node:crypto";
33
+ import { dirname, join } from "node:path";
34
+ /** The pull-request checks a release waits for, by exact job name (.github/workflows/ci.yml). Every one must have
35
+ * concluded `success` on the gated commit; src/ci-workflow.test.ts holds the workflow to this list. */
36
+ export const REQUIRED_PR_CHECKS = [
37
+ "node 22 · ubuntu-latest",
38
+ "node 24 · ubuntu-latest",
39
+ "linux native containment · delegated cgroup v2",
40
+ "Windows Job Object containment",
41
+ ];
42
+ /** The checks a release tag runs (.github/workflows/publish.yml), awaited before Homebrew moves. */
43
+ export const RELEASE_TAG_CHECKS = ["node 22 · macos-latest", "node 24 · macos-latest", "publish"];
44
+ export const RELEASE_STEPS = ["gate", "approval", "check", "complete", "quiet", "deploy", "link", "ci", "merge", "tree", "tag", "publish", "homebrew", "done"];
45
+ /** How long each waiting step may take, in minutes, before the release stops there (rerunning continues from it). */
46
+ export const RELEASE_LIMITS = { approval: 24 * 60, check: 120, quiet: 60, deploy: 30, ci: 60, publish: 45, homebrew: 45 };
47
+ const POLL_MS = 15_000;
48
+ /** A stop the release reports: the step it stopped at and why, in words. */
49
+ class Stop extends Error {
50
+ reason;
51
+ constructor(reason, message) {
52
+ super(message);
53
+ this.reason = reason;
54
+ }
55
+ }
56
+ const branchFile = (branch) => branch.replace(/[^A-Za-z0-9._-]+/g, "_");
57
+ export const journalFile = (stateDir, branch) => join(stateDir, "releases", `${branchFile(branch)}.json`);
58
+ export const resumeCommand = (branch, checkout) => `toolroll release ${branch} --repo ${checkout}`;
59
+ /** Whole or not at all: a private temp file, synced, renamed into place, its folder synced. */
60
+ export function writeJournal(file, journal) {
61
+ mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
62
+ const temp = `${file}.${randomUUID()}.tmp`;
63
+ const fd = openSync(temp, "wx", 0o600);
64
+ try {
65
+ writeFileSync(fd, `${JSON.stringify(journal, null, 2)}\n`);
66
+ fsyncSync(fd);
67
+ }
68
+ finally {
69
+ closeSync(fd);
70
+ }
71
+ renameSync(temp, file);
72
+ if (process.platform !== "win32") {
73
+ const folder = openSync(dirname(file), "r");
74
+ try {
75
+ fsyncSync(folder);
76
+ }
77
+ finally {
78
+ closeSync(folder);
79
+ }
80
+ }
81
+ }
82
+ export function readJournal(file) {
83
+ if (!existsSync(file))
84
+ return null;
85
+ const parsed = JSON.parse(readFileSync(file, "utf8"));
86
+ if (parsed.version !== 1 || !RELEASE_STEPS.includes(parsed.step))
87
+ throw new Error(`${file} is not a release journal this version reads`);
88
+ return parsed;
89
+ }
90
+ /** The release task's id: one per exact commit, so a rerun finds the task it filed instead of filing another. */
91
+ export const gateTaskId = (version, sha) => `release-${version.replace(/[^0-9A-Za-z.-]/g, "-")}-${sha.slice(0, 7)}`;
92
+ /** Whether the scope's approval is the owner's own yes to these exact bytes: a person through the password (or
93
+ * identity) ceremony or a chat confirmation — never a mode's coverage, an AI teammate, a routine or the lead. */
94
+ export function ownerApproval(gate, digest) {
95
+ const approval = gate.approval;
96
+ if (approval === null)
97
+ return { ok: false, why: "not approved yet" };
98
+ if (digest === null || approval.digest !== digest)
99
+ return { ok: false, why: "the approval is for different scope text" };
100
+ if (approval.basis !== "password" && approval.basis !== "chat")
101
+ return { ok: false, why: `approved by ${approval.basis ?? "an unknown road"}, not by a person` };
102
+ if (/ \(AI\)$/.test(approval.by) || /^mode /.test(approval.by) || /^lead\b/.test(approval.by) || approval.by === "routine")
103
+ return { ok: false, why: `approved as ${approval.by}, not by a person` };
104
+ if (approval.role !== "approver" || !approval.active)
105
+ return { ok: false, why: `approved by ${approval.by}, who is not an active approver` };
106
+ return { ok: true, by: approval.by, at: approval.at };
107
+ }
108
+ /** Every required check present, and every reported check's newest attempt completed successfully. */
109
+ export function checksVerdict(runs, required) {
110
+ const failed = [], waiting = [];
111
+ // The adapter orders attempts oldest first, so the last attempt for each name is authoritative.
112
+ const latest = new Map(runs.map(run => [run.name, run]));
113
+ for (const name of new Set([...required, ...latest.keys()])) {
114
+ const run = latest.get(name);
115
+ if (run === undefined || run.status !== "completed") {
116
+ waiting.push(run === undefined ? `${name} (not started)` : `${name} (${run.status})`);
117
+ continue;
118
+ }
119
+ if (run.conclusion !== "success")
120
+ failed.push(`${name} (${run.conclusion ?? "no conclusion"})`);
121
+ }
122
+ if (failed.length > 0)
123
+ return { state: "failed", failed };
124
+ return waiting.length > 0 ? { state: "pending", waiting } : { state: "passed" };
125
+ }
126
+ /** Run the release from wherever its journal stopped. */
127
+ export async function runRelease(options, adapters) {
128
+ const file = journalFile(options.stateDir, options.branch);
129
+ const resume = resumeCommand(options.branch, options.checkout);
130
+ const limits = { ...RELEASE_LIMITS, ...options.limits };
131
+ const pollMs = options.pollMs ?? POLL_MS;
132
+ const say = adapters.say;
133
+ let journal = null;
134
+ let step = "preflight";
135
+ const save = () => { journal.updatedAt = adapters.now().toISOString(); writeJournal(file, journal); };
136
+ const advance = (next) => { journal.step = next; journal.stopped = null; save(); };
137
+ const intend = (at) => { journal.intents[at] = adapters.now().toISOString(); save(); };
138
+ const recoverDeploy = async () => {
139
+ const j = journal;
140
+ if (j.step !== "deploy" || j.intents.deploy === undefined)
141
+ return;
142
+ if (!j.deployAttempt)
143
+ throw new Stop("deploy-recovery", "The interrupted deployment has no saved staging path. Recover it with the deployment journal before starting another release.");
144
+ const recovered = await adapters.toolroll.recoverDeploy({ ...j.check, stage: j.deployAttempt.stage });
145
+ if (!recovered.ok)
146
+ throw deployStop("deploy-recovery", `Deployment recovery stopped: ${recovered.message}`, recovered);
147
+ j.deployAttempt = null;
148
+ delete j.intents.deploy;
149
+ save();
150
+ };
151
+ /** Ask `probe` until it answers, within this step's limit. */
152
+ const waitFor = async (at, what, probe) => {
153
+ const deadline = adapters.now().getTime() + limits[at] * 60_000;
154
+ let said = false;
155
+ for (;;) {
156
+ const answer = await probe();
157
+ if (answer !== null)
158
+ return answer;
159
+ if (adapters.now().getTime() >= deadline)
160
+ throw new Stop("timeout", `Still waiting for ${what} after ${limits[at]} minutes.`);
161
+ if (!said) {
162
+ say(` waiting for ${what} (up to ${limits[at]} min)`);
163
+ said = true;
164
+ }
165
+ await adapters.sleep(pollMs);
166
+ }
167
+ };
168
+ try {
169
+ journal = readJournal(file);
170
+ if (journal !== null && options.fresh === true && journal.step !== "done") {
171
+ if (journal.intents.merge !== undefined)
172
+ throw new Stop("merging", `The release of ${journal.candidate.sha.slice(0, 7)} already began merging; finish it with \`${resume}\` instead of starting over.`);
173
+ await recoverDeploy();
174
+ const aside = file.replace(/\.json$/, `.superseded-${adapters.now().toISOString().replace(/[:.]/g, "-")}.json`);
175
+ renameSync(file, aside);
176
+ say(`Kept the earlier release journal at ${aside}.`);
177
+ journal = null;
178
+ }
179
+ if (journal?.step === "done") {
180
+ say(`Released ${journal.tag} (${journal.candidate.sha.slice(0, 7)}) — nothing left to do.`);
181
+ return { ok: true, journal, file };
182
+ }
183
+ if (!await adapters.github.authenticated())
184
+ throw new Stop("github-auth", "GitHub CLI is not signed in: run `gh auth login`, then rerun.");
185
+ if (journal === null)
186
+ journal = await begin(options, adapters, adapters.now());
187
+ else
188
+ say(`Resuming the release of ${journal.tag} (${journal.candidate.sha.slice(0, 7)}) at ${journal.step}.`);
189
+ const j = journal;
190
+ if (j.checkout !== options.checkout)
191
+ throw new Stop("checkout", `This release runs in ${j.checkout}; rerun with --repo ${j.checkout}.`);
192
+ save();
193
+ // Before anything merges, the branch and its pull request must still be the gated commit.
194
+ const pinned = async () => {
195
+ const head = await adapters.repo.remoteHead(j.branch);
196
+ if (head !== j.candidate.sha)
197
+ throw new Stop("drift", `${j.branch} moved from ${j.candidate.sha.slice(0, 7)} to ${head?.slice(0, 7) ?? "nothing"} after it was gated. Run \`toolroll release ${j.branch} --repo ${j.checkout} --new\` to release the new commit.`);
198
+ const pr = await adapters.github.pullRequest(j.github, j.branch);
199
+ if (pr === null || pr.number !== j.pr || (pr.state === "open" && pr.head !== j.candidate.sha))
200
+ throw new Stop("drift", `Pull request #${j.pr} no longer has ${j.candidate.sha.slice(0, 7)} at its head.`);
201
+ return pr;
202
+ };
203
+ const approved = (gate) => {
204
+ if (gate.candidate !== j.candidate.sha || gate.scopeDigest !== j.task.scopeDigest)
205
+ throw new Stop("scope-changed", `Task ${j.task.id}'s candidate or scope changed after approval.`);
206
+ const owner = ownerApproval(gate, j.task.scopeDigest);
207
+ if (!owner.ok)
208
+ throw new Stop("approval", `Task ${j.task.id}: ${owner.why}. An active approver must approve the release scope.`);
209
+ };
210
+ const fullCheck = (gate) => {
211
+ if (gate.result?.level !== "full")
212
+ throw new Stop("check-level", `A Full check is required for this release (run ${gate.result?.run ?? "unknown"}: ${gate.result?.level ?? "unknown"}). Quick and Off checks cannot release a build.`);
213
+ if (gate.result.check !== "passed")
214
+ throw new Stop("check-failed", `The Full release check of ${j.candidate.sha.slice(0, 7)} did not pass (run ${gate.result.run}: ${gate.result.check}). It stays as it is: fix the branch and release the new commit with --new.`);
215
+ };
216
+ const checked = (gate) => {
217
+ approved(gate);
218
+ if (gate.result?.head !== j.candidate.sha || gate.result?.receipt !== j.check?.receipt)
219
+ throw new Stop("drift", `Task ${j.task.id}'s result changed since its check passed.`);
220
+ fullCheck(gate);
221
+ };
222
+ while (j.step !== "done") {
223
+ step = j.step;
224
+ say(`• ${j.step}`);
225
+ switch (j.step) {
226
+ case "gate": {
227
+ await pinned();
228
+ const existing = await adapters.toolroll.gate(j.task.id);
229
+ if (existing.exists) {
230
+ if (existing.candidate !== j.candidate.sha)
231
+ throw new Stop("conflict", `Task ${j.task.id} exists for a different commit (${existing.candidate?.slice(0, 7) ?? "none"}).`);
232
+ j.task.scopeDigest = existing.scopeDigest;
233
+ }
234
+ else {
235
+ intend("gate");
236
+ await adapters.repo.sync(j.branch);
237
+ const filed = await adapters.toolroll.fileGate({
238
+ taskId: j.task.id, title: `Release ${j.packageVersion}`, checkout: j.checkout, candidate: j.candidate.sha,
239
+ goal: `Verify ${j.packageName} ${j.packageVersion} (${j.branch} at ${j.candidate.sha}) passes the approved release check before it is deployed, merged, tagged and published.`,
240
+ acceptance: "The approved release check passes on the candidate commit|check",
241
+ });
242
+ j.task.scopeDigest = filed.scopeDigest;
243
+ }
244
+ advance("approval");
245
+ break;
246
+ }
247
+ case "approval": {
248
+ say(` The release scope needs your approval: \`toolroll task approve ${j.task.id} --digest ${j.task.scopeDigest}\`, or open the task in the console.`);
249
+ const approved = await waitFor("approval", `a person to approve task ${j.task.id}`, async () => {
250
+ const gate = await adapters.toolroll.gate(j.task.id);
251
+ if (gate.scopeDigest !== j.task.scopeDigest)
252
+ throw new Stop("scope-changed", `The scope of ${j.task.id} changed after it was filed; the release only runs on the scope it filed.`);
253
+ const owner = ownerApproval(gate, j.task.scopeDigest);
254
+ if (!owner.ok && gate.approval !== null && owner.why !== "the approval is for different scope text")
255
+ throw new Stop("approval", `Task ${j.task.id} was ${owner.why}; the release needs a person to approve it.`);
256
+ return owner.ok ? owner : null;
257
+ });
258
+ j.approval = { by: approved.by, at: approved.at };
259
+ say(` approved by ${approved.by}`);
260
+ advance("check");
261
+ break;
262
+ }
263
+ case "check": {
264
+ const result = await waitFor("check", `the release check of ${j.candidate.sha.slice(0, 7)}`, async () => {
265
+ const gate = await adapters.toolroll.gate(j.task.id);
266
+ approved(gate);
267
+ if (gate.failed !== null)
268
+ throw new Stop("check-failed", `The release build of ${j.task.id} failed: ${gate.failed}. Fix it and release the new commit with --new.`);
269
+ if (gate.result === null || gate.result.check === "running" || gate.result.check === "pending")
270
+ return null;
271
+ if (gate.result.head !== j.candidate.sha)
272
+ throw new Stop("drift", `The release result is for ${gate.result.head?.slice(0, 7) ?? "no commit"}, not ${j.candidate.sha.slice(0, 7)}.`);
273
+ fullCheck(gate);
274
+ if (gate.result.worktree === null)
275
+ throw new Stop("worktree", `Run ${gate.result.run} names no checkout to deploy from.`);
276
+ return { run: gate.result.run, receipt: gate.result.receipt, worktree: gate.result.worktree };
277
+ });
278
+ j.check = result;
279
+ say(` run ${result.run} passed on ${j.candidate.sha.slice(0, 7)}`);
280
+ advance("complete");
281
+ break;
282
+ }
283
+ case "complete": {
284
+ const check = j.check;
285
+ const gate = await adapters.toolroll.gate(j.task.id);
286
+ checked(gate);
287
+ if (gate.completion?.digest !== check.receipt) {
288
+ intend("complete");
289
+ const done = await adapters.toolroll.complete(j.task.id, check.receipt);
290
+ if (!done.ok)
291
+ throw new Stop("complete", `Couldn't mark task ${j.task.id} complete: ${done.message} Mark that exact result complete in the console, then rerun.`);
292
+ }
293
+ const after = await adapters.toolroll.gate(j.task.id);
294
+ if (after.completion?.digest !== check.receipt)
295
+ throw new Stop("complete", `Task ${j.task.id} is not marked complete for result ${check.receipt.slice(0, 12)}.`);
296
+ j.completion = { actor: after.completion.actor, at: after.completion.at };
297
+ advance("quiet");
298
+ break;
299
+ }
300
+ case "quiet": {
301
+ await waitFor("quiet", "running work to finish", async () => {
302
+ const busy = await adapters.toolroll.busy();
303
+ return busy.running === 0 ? true : null;
304
+ });
305
+ advance("deploy");
306
+ break;
307
+ }
308
+ case "deploy": {
309
+ const check = j.check;
310
+ await recoverDeploy();
311
+ checked(await adapters.toolroll.gate(j.task.id));
312
+ // A deployment that finished but whose answer was lost: the services already run a runtime of this commit.
313
+ let runtime = await served(adapters, j.candidate.sha);
314
+ if (runtime === null) {
315
+ await pinned();
316
+ j.deployAttempt = { stage: join(options.stateDir, "staged-upgrades", `browser-${j.candidate.sha.slice(0, 7)}-${randomUUID()}`) };
317
+ intend("deploy");
318
+ const result = await adapters.toolroll.deploy({ run: check.run, worktree: check.worktree, stage: j.deployAttempt.stage });
319
+ if (!result.ok)
320
+ throw deployStop("deploy", `The deployment of run ${check.run} stopped: ${result.message}`, result);
321
+ if (!/[\\/]dist$/.test(result.runtime))
322
+ throw new Stop("deploy", `The deployment named ${result.runtime} as its runtime, which is not a dist folder.`);
323
+ runtime = result.runtime;
324
+ // Saved at once: everything after this (relinking, a rerun) uses the runtime the deployer named.
325
+ j.deploy = { runtime, at: adapters.now().toISOString() };
326
+ save();
327
+ }
328
+ const want = runtime;
329
+ await waitFor("deploy", `the console and worker to run the gated commit from ${want}`, async () => (await served(adapters, j.candidate.sha)) === want ? true : null);
330
+ j.deploy = { runtime: want, at: j.deploy?.runtime === want ? j.deploy.at : adapters.now().toISOString() };
331
+ j.deployAttempt = null;
332
+ delete j.intents.deploy;
333
+ say(` the service runs ${j.packageVersion} from ${want}`);
334
+ advance("link");
335
+ break;
336
+ }
337
+ case "link": {
338
+ const runtime = j.deploy.runtime;
339
+ if (await served(adapters, j.candidate.sha) !== runtime)
340
+ throw new Stop("drift", `The console and worker no longer run ${runtime}; the CLI names were not moved.`);
341
+ if (j.links === undefined || j.links === null) {
342
+ j.links = await adapters.toolroll.cliLinks();
343
+ intend("link");
344
+ }
345
+ try {
346
+ await adapters.toolroll.switchLinks(j.links, join(runtime, "bin.js"), j.packageVersion);
347
+ }
348
+ catch (error) {
349
+ throw new Stop("link", `The CLI names were not moved to ${runtime} (they point where they did): ${error.message}. The service already runs the new build; rerun to try again.`);
350
+ }
351
+ const everywhere = await deployed(adapters, j.candidate.sha);
352
+ if (everywhere !== runtime)
353
+ throw new Stop("mixed-runtime", `After relinking, the console, worker and both CLI names don't all run ${runtime} at ${j.candidate.sha.slice(0, 7)}.`);
354
+ say(` toolroll and standing-orders run ${join(runtime, "bin.js")}`);
355
+ advance("ci");
356
+ break;
357
+ }
358
+ case "ci": {
359
+ await waitFor("ci", `the pull request checks on ${j.candidate.sha.slice(0, 7)}`, async () => {
360
+ await pinned();
361
+ const verdict = checksVerdict(await adapters.github.checks(j.github, j.candidate.sha), REQUIRED_PR_CHECKS);
362
+ if (verdict.state === "failed")
363
+ throw new Stop("ci-failed", `Pull request checks failed on ${j.candidate.sha.slice(0, 7)}: ${verdict.failed.join(", ")}. Nothing was merged. Rerun the failed job on GitHub, then rerun this command.`);
364
+ return verdict.state === "passed" ? true : null;
365
+ });
366
+ j.ci = { checks: [...REQUIRED_PR_CHECKS], at: adapters.now().toISOString() };
367
+ advance("merge");
368
+ break;
369
+ }
370
+ case "merge": {
371
+ const pr = await adapters.github.pullRequest(j.github, j.branch);
372
+ if (pr?.state === "merged" && pr.number === j.pr && pr.mergeCommit !== null) {
373
+ if (pr.head !== j.candidate.sha || pr.base !== "main")
374
+ throw new Stop("drift", `Merged pull request #${j.pr} does not match the gated commit and base.`);
375
+ if (j.intents.merge === undefined)
376
+ intend("merge");
377
+ j.merge = { sha: pr.mergeCommit, tree: null };
378
+ }
379
+ else {
380
+ await pinned();
381
+ // A squash merge only reproduces the gated tree when main hasn't moved past the branch's base.
382
+ await adapters.repo.sync(j.branch);
383
+ const main = await adapters.repo.mainHead();
384
+ if (!await adapters.repo.contains(j.candidate.sha, main))
385
+ throw new Stop("base-moved", `main moved to ${main.slice(0, 7)}, which ${j.branch} doesn't contain, so the merge wouldn't be the gated tree. Nothing was merged: update the branch and release the new commit with --new.`);
386
+ // The checks again, at the moment of merging: a rerun that failed since must not merge.
387
+ const verdict = checksVerdict(await adapters.github.checks(j.github, j.candidate.sha), REQUIRED_PR_CHECKS);
388
+ if (verdict.state !== "passed")
389
+ throw new Stop("ci-failed", `Pull request checks are no longer all passing on ${j.candidate.sha.slice(0, 7)}; nothing was merged.`);
390
+ intend("merge");
391
+ j.merge = { sha: await adapters.github.merge(j.github, j.pr, j.candidate.sha), tree: null };
392
+ }
393
+ save();
394
+ // This also runs after discovering a merge whose response was lost. Never delete a reused branch.
395
+ await adapters.repo.deleteBranch(j.branch, j.candidate.sha);
396
+ advance("tree");
397
+ break;
398
+ }
399
+ case "tree": {
400
+ const merge = j.merge;
401
+ await adapters.repo.sync(j.branch);
402
+ const tree = await adapters.repo.tree(merge.sha);
403
+ merge.tree = tree;
404
+ if (tree !== j.candidate.tree)
405
+ throw new Stop("tree-mismatch", `Main's merge commit ${merge.sha.slice(0, 7)} has tree ${tree.slice(0, 12)}, not the gated ${j.candidate.tree.slice(0, 12)}: main changed what was checked. Nothing was tagged; the deployed build stays as it is.`);
406
+ advance("tag");
407
+ break;
408
+ }
409
+ case "tag": {
410
+ const merge = j.merge;
411
+ const at = await adapters.github.tag(j.github, j.tag);
412
+ if (at !== null && at !== merge.sha)
413
+ throw new Stop("conflict", `${j.tag} already points at ${at.slice(0, 7)}, not the merge ${merge.sha.slice(0, 7)}.`);
414
+ if (at === null) {
415
+ intend("tag");
416
+ await adapters.github.createTag(j.github, j.tag, merge.sha);
417
+ }
418
+ j.tagged = { sha: merge.sha };
419
+ advance("publish");
420
+ break;
421
+ }
422
+ case "publish": {
423
+ if (j.intents.publish === undefined)
424
+ intend("publish");
425
+ await waitFor("publish", `npm, the GitHub release and the tag's checks for ${j.tag}`, async () => {
426
+ if (await adapters.github.tag(j.github, j.tag) !== j.tagged.sha)
427
+ throw new Stop("conflict", `${j.tag} no longer points at the verified merge commit. Homebrew was not updated.`);
428
+ const verdict = checksVerdict(await adapters.github.checks(j.github, j.tagged.sha), RELEASE_TAG_CHECKS);
429
+ if (verdict.state === "failed")
430
+ throw new Stop("publish-failed", `The release tag's checks failed: ${verdict.failed.join(", ")}. Homebrew was not updated.`);
431
+ if (verdict.state === "pending")
432
+ return null;
433
+ return await adapters.registry.published(j.packageName, j.packageVersion) && await adapters.github.release(j.github, j.tag) ? true : null;
434
+ });
435
+ j.published = { at: adapters.now().toISOString() };
436
+ advance("homebrew");
437
+ break;
438
+ }
439
+ case "homebrew": {
440
+ const brew = adapters.homebrew;
441
+ let state = await brew.state(j.packageVersion);
442
+ if (state.kind === "missing") {
443
+ intend("homebrew");
444
+ const proposed = await brew.propose(j.packageVersion);
445
+ state = { kind: "proposed", ...proposed };
446
+ }
447
+ if (state.kind === "proposed") {
448
+ const proposed = state;
449
+ j.homebrew = { pr: proposed.pr, head: proposed.head, merged: false };
450
+ save();
451
+ await waitFor("homebrew", `the Homebrew change's checks (${brew.repo} #${proposed.pr})`, async () => {
452
+ const runs = await adapters.github.checks(brew.repo, proposed.head);
453
+ // GitHub registers checks asynchronously; an empty set says nothing about success.
454
+ if (runs.length === 0)
455
+ return null;
456
+ const verdict = checksVerdict(runs, [...new Set(runs.map(one => one.name))]);
457
+ if (verdict.state === "failed")
458
+ throw new Stop("homebrew-failed", `The Homebrew change's checks failed: ${verdict.failed.join(", ")}.`);
459
+ return verdict.state === "passed" ? true : null;
460
+ });
461
+ await brew.merge(proposed.pr, proposed.head);
462
+ }
463
+ j.homebrew = { pr: j.homebrew?.pr ?? null, head: j.homebrew?.head ?? null, merged: true };
464
+ advance("done");
465
+ break;
466
+ }
467
+ }
468
+ }
469
+ say(`Released ${j.tag}: deployed, merged as ${j.merge.sha.slice(0, 7)}, tagged, published and on Homebrew.`);
470
+ return { ok: true, journal: j, file };
471
+ }
472
+ catch (error) {
473
+ const stop = error instanceof Stop ? error : new Stop("error", String(error?.message ?? error));
474
+ if (journal !== null) {
475
+ journal.stopped = { step: journal.step, reason: stop.reason, message: stop.message, at: adapters.now().toISOString() };
476
+ try {
477
+ save();
478
+ }
479
+ catch { /* the stop is still reported */ }
480
+ }
481
+ return { ok: false, reason: stop.reason, message: stop.message, step: journal?.step ?? step, journal, file, resume };
482
+ }
483
+ }
484
+ /** The first run: everything checked before anything changes. */
485
+ async function begin(options, adapters, now) {
486
+ const { branch, checkout } = options;
487
+ if (["main", "master"].includes(branch))
488
+ throw new Stop("usage", "Release a branch with an open pull request, not main.");
489
+ const installed = await adapters.toolroll.installed();
490
+ const runtimes = new Set([...installed.services, ...installed.clis.map(one => one.runtime ?? "(not linked)")]);
491
+ if (installed.services.length === 0 || runtimes.size !== 1) {
492
+ throw new Stop("mixed-runtime", `The console, worker and CLI names don't run one build (${[...installed.services.map(one => `service ${one}`), ...installed.clis.map(one => `${one.name} ${one.runtime ?? "not linked"}`)].join("; ")}). Update them to one verified build first.`);
493
+ }
494
+ const github = await adapters.repo.github();
495
+ await adapters.repo.sync(branch);
496
+ const sha = await adapters.repo.remoteHead(branch);
497
+ if (sha === null)
498
+ throw new Stop("no-branch", `${branch} is not on ${github}: push it first.`);
499
+ const pr = await adapters.github.pullRequest(github, branch);
500
+ if (pr === null || pr.state !== "open")
501
+ throw new Stop("no-pull-request", `${branch} has no open pull request: open one first.`);
502
+ if (pr.head !== sha)
503
+ throw new Stop("drift", `Pull request #${pr.number} is at ${pr.head.slice(0, 7)}, the branch at ${sha.slice(0, 7)}.`);
504
+ if (pr.base !== "main")
505
+ throw new Stop("base", `Pull request #${pr.number} merges into ${pr.base}, not main.`);
506
+ const manifest = await adapters.repo.fileAt(sha, "package.json");
507
+ const pkg = manifest === null ? null : JSON.parse(manifest);
508
+ if (typeof pkg?.name !== "string" || typeof pkg.version !== "string")
509
+ throw new Stop("version", `package.json at ${sha.slice(0, 7)} names no package version.`);
510
+ const tag = `v${pkg.version}`;
511
+ const changelog = await adapters.repo.fileAt(sha, "CHANGELOG.md") ?? "";
512
+ if (!new RegExp(`^## ${pkg.version.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}( |$)`, "m").test(changelog))
513
+ throw new Stop("changelog", `CHANGELOG.md at ${sha.slice(0, 7)} has no "## ${pkg.version}" entry; the lead writes it before the release.`);
514
+ if (await adapters.github.tag(github, tag) !== null)
515
+ throw new Stop("version", `${tag} already exists: bump the version first.`);
516
+ if (await adapters.registry.published(pkg.name, pkg.version))
517
+ throw new Stop("version", `${pkg.name}@${pkg.version} is already on npm: bump the version first.`);
518
+ const tree = await adapters.repo.tree(sha);
519
+ const at = now.toISOString();
520
+ adapters.say(`Releasing ${pkg.name} ${pkg.version}: ${branch} at ${sha.slice(0, 7)}, pull request #${pr.number}.`);
521
+ return {
522
+ version: 1, id: randomUUID(), github, checkout, branch, candidate: { sha, tree }, packageName: pkg.name, packageVersion: pkg.version, tag, pr: pr.number,
523
+ step: "gate", task: { id: gateTaskId(pkg.version, sha), scopeDigest: null }, approval: null, check: null, completion: null, deploy: null, ci: null,
524
+ merge: null, tagged: null, published: null, homebrew: null, intents: {}, stopped: null, createdAt: at, updatedAt: at,
525
+ };
526
+ }
527
+ /** A deployment stop; when it left an update pause before the swap, the guided way to lift it. */
528
+ function deployStop(reason, message, stop) {
529
+ if (stop.gate === undefined)
530
+ return new Stop(reason, message);
531
+ return new Stop("gate-held", `${message} New work is still paused by update ${stop.gate.id} (its journal stopped at ${stop.gate.phase}, before the swap). Check and lift that pause with \`toolroll release --release-gate ${stop.gate.id}\`, then rerun.`);
532
+ }
533
+ /** The one runtime the launchd services run, when it records the gated commit; otherwise null. */
534
+ async function served(adapters, candidate) {
535
+ const installed = await adapters.toolroll.installed();
536
+ const runtimes = new Set(installed.services);
537
+ if (runtimes.size !== 1)
538
+ return null;
539
+ const [runtime] = runtimes;
540
+ return runtime !== undefined && await adapters.toolroll.runtimeCommit(runtime) === candidate ? runtime : null;
541
+ }
542
+ /** The one runtime the services and both CLI names run, when it records the gated commit; otherwise null. */
543
+ async function deployed(adapters, candidate) {
544
+ const installed = await adapters.toolroll.installed();
545
+ const runtimes = new Set([...installed.services, ...installed.clis.map(one => one.runtime ?? "")]);
546
+ if (installed.services.length === 0 || installed.clis.length < 2 || runtimes.size !== 1)
547
+ return null;
548
+ const [runtime] = runtimes;
549
+ return runtime !== undefined && runtime !== "" && await adapters.toolroll.runtimeCommit(runtime) === candidate ? runtime : null;
550
+ }
@@ -12,6 +12,7 @@
12
12
  * - Runs of blank lines collapse to one.
13
13
  * Code (inline or fenced) is left exactly as written.
14
14
  */
15
+ import { type Html } from "./html.js";
15
16
  export type ReplyChannel = "console" | "terminal" | "telegram" | "slack" | "discord" | "teams";
16
17
  export type ShapeOptions = {
17
18
  /** The owner's message this reply answers: when it asks for ids, the ids stay. */
@@ -61,7 +62,7 @@ export declare function replyPieces(shaped: string): ReplyPiece[];
61
62
  /** Discord's presentation syntax, escaped; mentions are broken so nothing pings. */
62
63
  export declare const discordPlain: (text: string) => string;
63
64
  /** One line of a shaped reply as console HTML (the console lays out paragraphs and lists itself). */
64
- export declare function replyHtmlInline(shapedLine: string): string;
65
+ export declare function replyHtmlInline(shapedLine: string): Html;
65
66
  /** A shaped reply in a channel's own text format (Telegram's formatting rides entities: see `telegramReply`). */
66
67
  export declare function renderReply(shaped: string, channel: ReplyChannel): string;
67
68
  export type TelegramEntity = {
@@ -12,6 +12,7 @@
12
12
  * - Runs of blank lines collapse to one.
13
13
  * Code (inline or fenced) is left exactly as written.
14
14
  */
15
+ import { html, joinHtml } from "./html.js";
15
16
  export const BOLD_ANCHORS = 3;
16
17
  /** A bold anchor is a few words, not a sentence. */
17
18
  export const BOLD_ANCHOR_CHARS = 40;
@@ -293,17 +294,16 @@ export function replyPieces(shaped) {
293
294
  pieces.push({ kind: "text", text: shaped.slice(at) });
294
295
  return pieces;
295
296
  }
296
- const html = (value) => value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
297
297
  const slack = (value) => value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
298
298
  /** Discord's presentation syntax, escaped; mentions are broken so nothing pings. */
299
299
  export const discordPlain = (text) => text.replace(/([\\`*_~|>#\[\]])/g, "\\$1").replace(/@/g, "@\u200b");
300
300
  const teams = (value) => value.replace(/([\\`*_\[\]<>#])/g, "\\$1");
301
301
  /** One line of a shaped reply as console HTML (the console lays out paragraphs and lists itself). */
302
302
  export function replyHtmlInline(shapedLine) {
303
- return replyPieces(shapedLine).map(piece => piece.kind === "text" ? html(piece.text)
304
- : piece.kind === "bold" ? `<strong>${html(piece.text)}</strong>`
305
- : piece.kind === "code" ? `<code>${html(piece.text)}</code>`
306
- : `<a href="${html(piece.url)}" rel="noopener noreferrer" target="_blank">${html(piece.label)}</a>`).join("");
303
+ return joinHtml(replyPieces(shapedLine).map(piece => piece.kind === "text" ? piece.text
304
+ : piece.kind === "bold" ? html `<strong>${piece.text}</strong>`
305
+ : piece.kind === "code" ? html `<code>${piece.text}</code>`
306
+ : html `<a href="${piece.url}" rel="noopener noreferrer" target="_blank">${piece.label}</a>`));
307
307
  }
308
308
  /** A shaped reply in a channel's own text format (Telegram's formatting rides entities: see `telegramReply`). */
309
309
  export function renderReply(shaped, channel) {
@@ -1,2 +1,3 @@
1
+ import { type Html } from "./html.js";
1
2
  import type { RepositoryContext } from './repository-context.js';
2
- export declare function repositoryContextHtml(repo: string, query: string, result: RepositoryContext | null, csrf: string): string;
3
+ export declare function repositoryContextHtml(repo: string, query: string, result: RepositoryContext | null, csrf: string): Html;
@@ -1,10 +1,5 @@
1
- const escape = (value) => value.replace(/[&<>"']/g, char => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[char]);
1
+ import { html, postForm } from "./html.js";
2
2
  export function repositoryContextHtml(repo, query, result, csrf) {
3
3
  const root = `/settings/knowledge?repo=${encodeURIComponent(repo)}`;
4
- return `<section class="knowledge repository-context"><h2>Relevant code</h2><form method="get" action="/settings/knowledge"><input type="hidden" name="repo" value="${escape(repo)}"><label>Find code<input name="q" value="${escape(query)}" maxlength="1000" placeholder="A feature, file or symbol"></label><button type="submit">Search</button></form>` +
5
- (result === null ? '' : `<p class="meta">${result.index.engine === 'typescript-ast' ? 'Code relationships' : 'Source search'}${result.mode === 'impact' ? ' · possible change impact' : ''}</p>` +
6
- (result.excerpts.length ? result.excerpts.map(one => `<details><summary>${escape(one.file)}:${one.line}${one.symbol ? ` · ${escape(one.symbol)}` : ''}</summary><p class="meta">${escape(one.reason)}</p><pre>${escape(one.text)}</pre><a href="${root}&mode=impact&q=${encodeURIComponent(one.file)}">What this may affect</a></details>`).join('') : '<p>No matching source was found in this bounded search.</p>') +
7
- (result.relationships.length ? `<details><summary>Related files</summary><ul>${result.relationships.map(one => `<li>${escape(one.from)} → ${escape(one.to)} · ${escape(one.kind)}</li>`).join('')}</ul></details>` : '') +
8
- `<details><summary>Context details</summary><p>${escape(result.index.status)}${result.checkout ? ` · checkout ${escape(result.checkout.head.slice(0, 8))}` : ''}</p>${result.warnings.map(one => `<p>${escape(one)}</p>`).join('')}</details>`) +
9
- (csrf ? `<details><summary>Refresh code index</summary><form method="post" action="/settings/knowledge/refresh"><input type="hidden" name="csrf" value="${escape(csrf)}"><input type="hidden" name="repo" value="${escape(repo)}"><p>Builds a local map of TypeScript and JavaScript imports. Uses no model.</p><button type="submit">Refresh index</button></form></details>` : '') + `</section>`;
4
+ return html `<section class="knowledge repository-context"><h2>Relevant code</h2><form method="get" action="/settings/knowledge"><input type="hidden" name="repo" value="${repo}"><label>Find code<input name="q" value="${query}" maxlength="1000" placeholder="A feature, file or symbol"></label><button type="submit">Search</button></form>${result === null ? '' : html `<p class="meta">${result.index.engine === 'typescript-ast' ? 'Code relationships' : 'Source search'}${result.mode === 'impact' ? ' · possible change impact' : ''}</p>${result.excerpts.length ? result.excerpts.map(one => html `<details><summary>${one.file}:${one.line}${one.symbol ? ` · ${one.symbol}` : ''}</summary><p class="meta">${one.reason}</p><pre>${one.text}</pre><a href="${root}&mode=impact&q=${encodeURIComponent(one.file)}">What this may affect</a></details>`) : html `<p>No matching source was found in this bounded search.</p>`}${result.relationships.length ? html `<details><summary>Related files</summary><ul>${result.relationships.map(one => html `<li>${one.from} → ${one.to} · ${one.kind}</li>`)}</ul></details>` : ''}<details><summary>Context details</summary><p>${result.index.status}${result.checkout ? ` · checkout ${result.checkout.head.slice(0, 8)}` : ''}</p>${result.warnings.map(one => html `<p>${one}</p>`)}</details>`}${csrf ? html `<details><summary>Refresh code index</summary>${postForm("/settings/knowledge/refresh", html `<p>Builds a local map of TypeScript and JavaScript imports. Uses no model.</p><button type="submit">Refresh index</button>`, { hidden: { repo } })}</details>` : ''}</section>`;
10
5
  }
@@ -4,8 +4,9 @@
4
4
  * password (or a fresh identity-provider sign-in), in a browser only.
5
5
  */
6
6
  import type { ApiTokenRow, Database } from "./store.js";
7
+ import { type Html } from "./html.js";
7
8
  export declare const REQUEST_LIMITS_CSS: string;
8
9
  /** "120 requests a minute · 10,000 a day" for one token's line. */
9
10
  export declare function tokenLimitWords(db: Database, token: ApiTokenRow): string;
10
11
  /** The operator's disclosure: everyone's tokens, or one token. Blank fields use the default shown. */
11
- export declare function requestLimitsHtml(db: Database, tokens: readonly ApiTokenRow[], csrf: string, now: number): string;
12
+ export declare function requestLimitsHtml(db: Database, tokens: readonly ApiTokenRow[], now: number): Html;