toolroll 0.9.92 → 0.9.94

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 (341) hide show
  1. package/CHANGELOG.md +87 -0
  2. package/dist/activity-line.d.ts +7 -4
  3. package/dist/activity-line.js +9 -7
  4. package/dist/agent-fence.d.ts +4 -0
  5. package/dist/agent-fence.js +4 -1
  6. package/dist/approval-authority.d.ts +7 -1
  7. package/dist/approval-authority.js +11 -2
  8. package/dist/approval-password.d.ts +31 -0
  9. package/dist/approval-password.js +26 -0
  10. package/dist/assignment-status.d.ts +1 -1
  11. package/dist/assignment-ui.js +3 -3
  12. package/dist/assignment.js +16 -3
  13. package/dist/boot-identity.d.ts +2 -1
  14. package/dist/boot-identity.js +6 -4
  15. package/dist/bounded-spawn.d.ts +57 -0
  16. package/dist/bounded-spawn.js +220 -0
  17. package/dist/browser/terminal.js +42 -42
  18. package/dist/browser/workspace.css +2 -2
  19. package/dist/browser/workspace.js +26 -26
  20. package/dist/browser-crew.js +2 -2
  21. package/dist/browser-shell.d.ts +1 -1
  22. package/dist/browser-shell.js +2 -2
  23. package/dist/browser-workspace.d.ts +48 -7
  24. package/dist/browser-workspace.js +3 -3
  25. package/dist/builder-restart.d.ts +21 -0
  26. package/dist/builder-restart.js +50 -0
  27. package/dist/builder.d.ts +4 -0
  28. package/dist/builder.js +73 -25
  29. package/dist/chat-acceptance.js +1 -1
  30. package/dist/chat-actions.d.ts +12 -2
  31. package/dist/chat-actions.js +25 -2
  32. package/dist/chat-channel.js +11 -0
  33. package/dist/chat-decide.d.ts +7 -2
  34. package/dist/chat-decide.js +37 -9
  35. package/dist/chat-quiet.js +3 -3
  36. package/dist/chat-voice.d.ts +1 -1
  37. package/dist/chat-voice.js +2 -2
  38. package/dist/check-facts.d.ts +126 -0
  39. package/dist/check-facts.js +318 -0
  40. package/dist/check-failure.d.ts +22 -3
  41. package/dist/check-failure.js +57 -13
  42. package/dist/check-levels-ui.d.ts +4 -0
  43. package/dist/check-levels-ui.js +10 -1
  44. package/dist/check-load.d.ts +85 -0
  45. package/dist/check-load.js +147 -0
  46. package/dist/check-only-standing.d.ts +36 -0
  47. package/dist/check-only-standing.js +39 -0
  48. package/dist/check-only.d.ts +15 -0
  49. package/dist/check-only.js +99 -0
  50. package/dist/check-report.d.ts +7 -0
  51. package/dist/check-report.js +4 -3
  52. package/dist/check-stamp.d.ts +46 -0
  53. package/dist/check-stamp.js +93 -0
  54. package/dist/checkout-cleanup.d.ts +36 -5
  55. package/dist/checkout-cleanup.js +166 -14
  56. package/dist/cli-help.d.ts +4 -1
  57. package/dist/cli-help.js +47 -30
  58. package/dist/cli.js +16 -1
  59. package/dist/code-extract.d.ts +44 -0
  60. package/dist/code-extract.js +88 -0
  61. package/dist/code-map-acts.d.ts +24 -0
  62. package/dist/code-map-acts.js +53 -0
  63. package/dist/code-map-admission.d.ts +52 -0
  64. package/dist/code-map-admission.js +295 -0
  65. package/dist/code-map-build.d.ts +63 -0
  66. package/dist/code-map-build.js +415 -0
  67. package/dist/code-map-cli.d.ts +153 -0
  68. package/dist/code-map-cli.js +103 -0
  69. package/dist/code-map-main.d.ts +1 -0
  70. package/dist/code-map-main.js +48 -0
  71. package/dist/code-map-read.d.ts +106 -0
  72. package/dist/code-map-read.js +171 -0
  73. package/dist/code-map-report.d.ts +37 -0
  74. package/dist/code-map-report.js +95 -0
  75. package/dist/code-map-shape.d.ts +172 -0
  76. package/dist/code-map-shape.js +327 -0
  77. package/dist/code-map-store.d.ts +136 -0
  78. package/dist/code-map-store.js +233 -0
  79. package/dist/coding-provider.d.ts +2 -0
  80. package/dist/coding-provider.js +3 -2
  81. package/dist/coding-update.js +3 -2
  82. package/dist/coding-workspace.js +4 -4
  83. package/dist/console-chrome.js +2 -2
  84. package/dist/containment.d.ts +5 -0
  85. package/dist/containment.js +14 -1
  86. package/dist/contracts/chat-actions.d.ts +9 -0
  87. package/dist/contracts/chat-actions.js +2 -0
  88. package/dist/contracts/cli.d.ts +52 -0
  89. package/dist/contracts/cli.js +4 -0
  90. package/dist/contracts/console-api.d.ts +12 -5
  91. package/dist/contracts/console-api.js +12 -5
  92. package/dist/contracts/gateway-tools.d.ts +17 -0
  93. package/dist/contracts/gateway-tools.js +3 -1
  94. package/dist/contracts/lead-tools.d.ts +61 -0
  95. package/dist/contracts/lead-tools.js +10 -1
  96. package/dist/contracts/project-knowledge.d.ts +38 -5
  97. package/dist/contracts/project-knowledge.js +11 -5
  98. package/dist/contracts/project-memory.d.ts +3 -1
  99. package/dist/contracts/project-memory.js +3 -2
  100. package/dist/contracts/release-check.d.ts +25 -1
  101. package/dist/contracts/release-check.js +19 -0
  102. package/dist/contracts/storage.d.ts +14 -0
  103. package/dist/contracts/storage.js +3 -1
  104. package/dist/contracts/store-json.d.ts +2 -0
  105. package/dist/contracts/youiyoux-request.d.ts +1 -1
  106. package/dist/contracts/youiyoux-request.js +1 -1
  107. package/dist/desktop-access.js +6 -2
  108. package/dist/desktop-update.d.ts +2 -2
  109. package/dist/desktop-update.js +13 -9
  110. package/dist/dispatch.js +18 -15
  111. package/dist/dispose.js +8 -2
  112. package/dist/easy-start.js +2 -3
  113. package/dist/eval-hooks.d.ts +55 -0
  114. package/dist/eval-hooks.js +227 -4
  115. package/dist/evals.js +2 -1
  116. package/dist/fast-start.d.ts +136 -0
  117. package/dist/fast-start.js +431 -0
  118. package/dist/fast-terms.d.ts +9 -0
  119. package/dist/fast-terms.js +23 -0
  120. package/dist/fenced-check-coverage.d.ts +62 -0
  121. package/dist/fenced-check-coverage.js +54 -0
  122. package/dist/first-run.js +1 -1
  123. package/dist/flaky-ci.d.ts +31 -0
  124. package/dist/flaky-ci.js +149 -0
  125. package/dist/flaky-cli.d.ts +60 -0
  126. package/dist/flaky-cli.js +60 -0
  127. package/dist/flaky.d.ts +83 -0
  128. package/dist/flaky.js +264 -0
  129. package/dist/flow-card-task.js +3 -0
  130. package/dist/flow-triggers.d.ts +1 -1
  131. package/dist/flow-triggers.js +1 -1
  132. package/dist/github-import.d.ts +62 -0
  133. package/dist/github-import.js +146 -0
  134. package/dist/guides.js +15 -10
  135. package/dist/invoke.d.ts +5 -1
  136. package/dist/invoke.js +49 -14
  137. package/dist/job-object-helper.ps1 +10 -3
  138. package/dist/keymap.d.ts +41 -0
  139. package/dist/keymap.js +51 -0
  140. package/dist/knowledge-cli.js +1 -1
  141. package/dist/knowledge-ui.d.ts +21 -4
  142. package/dist/knowledge-ui.js +75 -7
  143. package/dist/laptop-background.d.ts +16 -0
  144. package/dist/laptop-background.js +108 -2
  145. package/dist/lead-context.js +1 -1
  146. package/dist/lead-contract.d.ts +1 -1
  147. package/dist/lead-contract.js +2 -2
  148. package/dist/lead-progress.js +3 -0
  149. package/dist/lead-status.d.ts +2 -0
  150. package/dist/lead-status.js +6 -2
  151. package/dist/lead-tools.js +51 -2
  152. package/dist/lead.js +1 -1
  153. package/dist/lesson-notes.d.ts +81 -0
  154. package/dist/lesson-notes.js +336 -0
  155. package/dist/limits-ui.js +1 -1
  156. package/dist/local-owner.d.ts +1 -1
  157. package/dist/local-owner.js +11 -0
  158. package/dist/machine-check.d.ts +16 -0
  159. package/dist/machine-check.js +26 -0
  160. package/dist/machine-identity.d.ts +75 -0
  161. package/dist/machine-identity.js +170 -0
  162. package/dist/mcp-core.js +11 -1
  163. package/dist/memory-cli.d.ts +300 -1
  164. package/dist/memory-cli.js +62 -9
  165. package/dist/memory-pass.d.ts +16 -0
  166. package/dist/memory-pass.js +67 -3
  167. package/dist/memory-tidy.d.ts +181 -0
  168. package/dist/memory-tidy.js +523 -0
  169. package/dist/memory-use.d.ts +35 -0
  170. package/dist/memory-use.js +109 -0
  171. package/dist/merge-checklist.d.ts +55 -0
  172. package/dist/merge-checklist.js +182 -0
  173. package/dist/needs-you.d.ts +32 -13
  174. package/dist/needs-you.js +108 -49
  175. package/dist/nightly-github.d.ts +2 -0
  176. package/dist/nightly-github.js +4 -4
  177. package/dist/operate-remote-arguments.js +6 -1
  178. package/dist/operate-remote.js +3 -1
  179. package/dist/operate.d.ts +6 -1
  180. package/dist/operate.js +540 -70
  181. package/dist/phase-routing.js +2 -2
  182. package/dist/plain-schema.d.ts +23 -0
  183. package/dist/plain-schema.js +94 -0
  184. package/dist/plain-words.d.ts +20 -0
  185. package/dist/plain-words.js +66 -0
  186. package/dist/planner.js +2 -1
  187. package/dist/process-liveness.d.ts +2 -0
  188. package/dist/process-liveness.js +61 -17
  189. package/dist/project-delete-ui.d.ts +7 -2
  190. package/dist/project-delete-ui.js +12 -1
  191. package/dist/project-delete.js +13 -2
  192. package/dist/project-knowledge.d.ts +18 -11
  193. package/dist/project-knowledge.js +39 -9
  194. package/dist/project-learning.d.ts +2 -1
  195. package/dist/project-learning.js +15 -45
  196. package/dist/project-memory.d.ts +5 -0
  197. package/dist/project-memory.js +22 -2
  198. package/dist/proof.js +30 -5
  199. package/dist/provider-auth.js +1 -1
  200. package/dist/publish.js +9 -1
  201. package/dist/pull-request-flow.js +5 -0
  202. package/dist/push.d.ts +26 -3
  203. package/dist/push.js +55 -15
  204. package/dist/release-adapters.d.ts +9 -0
  205. package/dist/release-adapters.js +87 -1
  206. package/dist/release-approval.d.ts +191 -0
  207. package/dist/release-approval.js +382 -0
  208. package/dist/release-auto.js +26 -17
  209. package/dist/release-card.d.ts +74 -6
  210. package/dist/release-card.js +179 -23
  211. package/dist/release-carry.d.ts +7 -2
  212. package/dist/release-carry.js +39 -9
  213. package/dist/release-cli.d.ts +4 -0
  214. package/dist/release-cli.js +38 -23
  215. package/dist/release-coverage.js +1 -1
  216. package/dist/release-next.d.ts +48 -0
  217. package/dist/release-next.js +161 -0
  218. package/dist/release.d.ts +50 -1
  219. package/dist/release.js +118 -11
  220. package/dist/remote-destination.d.ts +36 -0
  221. package/dist/remote-destination.js +217 -0
  222. package/dist/remote-main.d.ts +48 -0
  223. package/dist/remote-main.js +84 -5
  224. package/dist/repository-context.d.ts +3 -2
  225. package/dist/repository-context.js +112 -70
  226. package/dist/restart-certification.d.ts +1 -0
  227. package/dist/restart-certification.js +8 -6
  228. package/dist/result-acts.d.ts +29 -21
  229. package/dist/result-acts.js +36 -25
  230. package/dist/result-follow-ups.d.ts +2 -2
  231. package/dist/result-follow-ups.js +18 -4
  232. package/dist/result-review.d.ts +1 -1
  233. package/dist/route-change-check.d.ts +4 -0
  234. package/dist/route-change-check.js +13 -0
  235. package/dist/route-change.d.ts +22 -0
  236. package/dist/route-change.js +89 -0
  237. package/dist/route-hooks.js +3 -2
  238. package/dist/route-results.d.ts +6 -1
  239. package/dist/route-results.js +14 -5
  240. package/dist/run-closeout.d.ts +1 -1
  241. package/dist/run-closeout.js +7 -5
  242. package/dist/runner-process.js +3 -2
  243. package/dist/runner.js +2 -1
  244. package/dist/scope.d.ts +6 -1
  245. package/dist/scope.js +22 -9
  246. package/dist/serve.d.ts +1 -1
  247. package/dist/serve.js +24 -18
  248. package/dist/server/chat.js +14 -12
  249. package/dist/server/chrome.d.ts +1 -1
  250. package/dist/server/chrome.js +26 -35
  251. package/dist/server/console.css +23 -0
  252. package/dist/server/guards.js +4 -0
  253. package/dist/server/home.d.ts +6 -0
  254. package/dist/server/home.js +21 -13
  255. package/dist/server/http.js +1 -1
  256. package/dist/server/kit.js +1 -1
  257. package/dist/server/merge-ready-view.d.ts +10 -0
  258. package/dist/server/merge-ready-view.js +30 -0
  259. package/dist/server/pages-assets.d.ts +1 -0
  260. package/dist/server/pages-assets.js +8 -5
  261. package/dist/server/pages.js +103 -82
  262. package/dist/server/render-chat.js +2 -2
  263. package/dist/server/render-code-map.d.ts +30 -0
  264. package/dist/server/render-code-map.js +137 -0
  265. package/dist/server/render-flaky.d.ts +20 -0
  266. package/dist/server/render-flaky.js +59 -0
  267. package/dist/server/render-history.js +1 -1
  268. package/dist/server/render-new-task.d.ts +26 -1
  269. package/dist/server/render-new-task.js +30 -5
  270. package/dist/server/render-pages.d.ts +1 -175
  271. package/dist/server/render-pages.js +38 -313
  272. package/dist/server/render-projects.d.ts +2 -1
  273. package/dist/server/render-projects.js +9 -7
  274. package/dist/server/render-release.d.ts +19 -0
  275. package/dist/server/render-release.js +84 -0
  276. package/dist/server/render-results.d.ts +2 -2
  277. package/dist/server/render-results.js +71 -73
  278. package/dist/server/render-tasks.d.ts +13 -2
  279. package/dist/server/render-tasks.js +156 -139
  280. package/dist/server/retired-routes.d.ts +17 -0
  281. package/dist/server/retired-routes.js +53 -0
  282. package/dist/server/route-table.d.ts +4 -1
  283. package/dist/server/route-table.js +52 -13
  284. package/dist/server/settings.js +129 -22
  285. package/dist/server/task-views.js +61 -26
  286. package/dist/server/tasks.d.ts +17 -6
  287. package/dist/server/tasks.js +273 -338
  288. package/dist/server/welcome.js +1 -1
  289. package/dist/settings-groups-ui.js +1 -1
  290. package/dist/setup-guide.d.ts +2 -2
  291. package/dist/stop-proof.d.ts +67 -0
  292. package/dist/stop-proof.js +295 -0
  293. package/dist/storage-sweep-process.js +1 -1
  294. package/dist/storage-sweep.d.ts +5 -3
  295. package/dist/storage-sweep.js +30 -9
  296. package/dist/storage-ui.js +28 -17
  297. package/dist/storage.d.ts +27 -2
  298. package/dist/storage.js +39 -2
  299. package/dist/store.d.ts +148 -34
  300. package/dist/store.js +626 -177
  301. package/dist/surface.js +46 -5
  302. package/dist/sweep-quarantine.d.ts +1 -0
  303. package/dist/sweep-quarantine.js +6 -4
  304. package/dist/tamper-scan.d.ts +4 -0
  305. package/dist/tamper-scan.js +90 -0
  306. package/dist/task-aliases.d.ts +5 -0
  307. package/dist/task-aliases.js +9 -0
  308. package/dist/task-control.js +4 -1
  309. package/dist/task-outcome-cli.d.ts +2 -0
  310. package/dist/task-outcome-cli.js +36 -17
  311. package/dist/task-sizing.d.ts +5 -0
  312. package/dist/task-sizing.js +2 -0
  313. package/dist/task-source.d.ts +7 -0
  314. package/dist/task-source.js +14 -0
  315. package/dist/task-status.d.ts +9 -0
  316. package/dist/task-status.js +40 -49
  317. package/dist/teardown-git.d.ts +16 -0
  318. package/dist/teardown-git.js +63 -5
  319. package/dist/telegram-progress.js +5 -3
  320. package/dist/telegram-status.js +6 -4
  321. package/dist/test-infra.d.ts +8 -0
  322. package/dist/test-infra.js +18 -0
  323. package/dist/tidy.js +1 -1
  324. package/dist/toolroll-update-ui.js +2 -2
  325. package/dist/toolroll-update.js +18 -5
  326. package/dist/tui-contract.d.ts +24 -0
  327. package/dist/tui-source.js +67 -8
  328. package/dist/tui.js +51 -51
  329. package/dist/vanished-retry.d.ts +30 -0
  330. package/dist/vanished-retry.js +174 -0
  331. package/dist/work-index.d.ts +2 -0
  332. package/dist/work-index.js +82 -70
  333. package/dist/work-summary.d.ts +2 -2
  334. package/dist/work-summary.js +2 -0
  335. package/dist/workspace-ui.d.ts +4 -2
  336. package/dist/workspace-ui.js +28 -22
  337. package/dist/worktree.d.ts +12 -3
  338. package/dist/worktree.js +68 -13
  339. package/package.json +1 -1
  340. package/dist/server/render-next.d.ts +0 -63
  341. package/dist/server/render-next.js +0 -109
package/dist/push.js CHANGED
@@ -20,6 +20,7 @@ import { closeSync, constants as fsConstants, fsyncSync, openSync, readFileSync,
20
20
  import { join } from "node:path";
21
21
  import { createCipheriv, createECDH, createPrivateKey, hkdfSync, randomBytes, createHash, sign as cryptoSign } from "node:crypto";
22
22
  import { isCheckProgressLine } from "./check-progress.js";
23
+ import { RELEASE_PUSH_WORDS, releasePushOf, releaseReadyObsolete } from "./release-approval.js";
23
24
  export const VAPID_FILE = "vapid-keys.json";
24
25
  /** RFC 8291 §4: rs 4096 leaves room for the record's delimiter and tag. */
25
26
  const RECORD_SIZE = 4096;
@@ -193,38 +194,70 @@ export function validatePushEndpoint(raw) {
193
194
  return { ok: false, problem: "that host is not a known push service — extending the list is a code change, deliberately" };
194
195
  return { ok: true, url };
195
196
  }
196
- // ---- the payload ------------------------------------------------------------
197
197
  /** Fixed words, except for one validated machine-only suite/count line. */
198
198
  export const PUSH_WORDS = {
199
199
  decision: { title: "Toolroll", body: "a decision needs you" },
200
200
  pick: { title: "Toolroll", body: "agents finished — pick a winner" },
201
201
  merge: { title: "Toolroll", body: "a pull request needs a person" },
202
- attention: { title: "Toolroll", body: "the plane needs attention" },
202
+ attention: { title: "Toolroll", body: "Toolroll needs your attention" },
203
203
  progress: { title: "Toolroll", body: "checks in progress" },
204
+ release: { title: "Toolroll", body: RELEASE_PUSH_WORDS },
204
205
  };
205
- const LINK_SHAPES = [/^\/next$/, /^\/review$/, /^\/system$/, /^\/routines$/, /^\/routines\/[0-9]{1,15}$/, /^\/d\/[0-9]{1,15}$/, /^\/r\/[0-9]{1,15}$/];
206
+ // `/approve/<n>`: a release's opaque locator (a task ref number: no version, commit or branch), resolved by the console.
207
+ const RELEASE_LINK = /^\/approve\/[0-9]{1,15}$/;
208
+ /** The relative links a push may carry (docs/plans/plain-toolroll.md P1.4): every shape an older worker took, kept for
209
+ * good (/next and /review now redirect to Home and Tasks' Ready view; the rest keep their pages), then Home, Tasks'
210
+ * Ready and Board views, and a task by its stored number (/n/<n>, resolved under the viewer's own access). */
211
+ export const LINK_SHAPES = [/^\/next$/, /^\/review$/, /^\/system$/, /^\/routines$/, /^\/routines\/[0-9]{1,15}$/, /^\/d\/[0-9]{1,15}$/, /^\/r\/[0-9]{1,15}$/, RELEASE_LINK,
212
+ /^\/$/, /^\/tasks\?view=ready$/, /^\/tasks\?view=board$/, /^\/n\/[1-9][0-9]{0,14}$/];
206
213
  const CLASS_FALLBACK = {
207
- decision: "/next",
208
- pick: "/next",
209
- merge: "/review",
210
- attention: "/next",
211
- progress: "/next",
214
+ decision: "/",
215
+ pick: "/",
216
+ merge: "/tasks?view=ready",
217
+ attention: "/",
218
+ progress: "/",
219
+ release: "/",
212
220
  };
213
221
  /** Static words and machine-minted numbers only — re-proved at send time. */
214
222
  export function safePushLink(pushClass, link) {
223
+ // The release locator rides only the derived `release` class: a stored link of that shape on any other row is not one.
224
+ if (link !== null && RELEASE_LINK.test(link))
225
+ return pushClass === "release" ? link : CLASS_FALLBACK[pushClass];
215
226
  if (link !== null && LINK_SHAPES.some(shape => shape.test(link)))
216
227
  return link;
217
228
  return CLASS_FALLBACK[pushClass];
218
229
  }
219
- export function buildPushPayload(notification, waiting = null) {
230
+ /** One line per ping (docs/plans/plain-toolroll.md P1.5), built only from the task's stored number and fixed words: no
231
+ * title, no free text. A fact about a task opens that task (/n/<n>); anything else keeps its class's fixed words and
232
+ * its own allow-listed link. Rows are tried in order; the first that matches says the line. */
233
+ export const PUSH_LINES = [
234
+ { when: one => one.kind === "plan-ready", say: number => `#${number}: plan ready` },
235
+ { when: one => one.pushClass === "decision" || one.kind === "task-ask" || one.kind === "decision", say: number => `#${number} has a question` },
236
+ { when: one => one.pushClass === "merge" || one.pushClass === "pick" || one.kind === "pull-request-ready" || one.kind === "acceptance-ready", say: number => `#${number} is ready to review` },
237
+ { when: () => true, say: number => `#${number} needs you` },
238
+ ];
239
+ /** The one line a ping says, and where its tap lands. */
240
+ export function pushLineOf(notification) {
220
241
  const pushClass = notification.pushClass;
221
- const words = PUSH_WORDS[pushClass];
242
+ if (pushClass === "progress" && isCheckProgressLine(notification.body))
243
+ return { body: notification.body, url: safePushLink(pushClass, notification.link) };
244
+ const number = notification.taskRef;
245
+ if (typeof number === "number" && Number.isSafeInteger(number) && number > 0) {
246
+ const row = PUSH_LINES.find(one => one.when(notification));
247
+ return { body: row.say(number), url: `/n/${number}` };
248
+ }
249
+ return { body: PUSH_WORDS[pushClass].body, url: safePushLink(pushClass, notification.link) };
250
+ }
251
+ /** `release`: the class and link the plane derived for this row at send time (releasePushOf): fixed words and a release's
252
+ * own link, never the task line. Absent or null, the row's own line. */
253
+ export function buildPushPayload(notification, waiting = null, release = null) {
254
+ const line = release !== null ? { body: PUSH_WORDS.release.body, url: safePushLink("release", release.link) } : pushLineOf(notification);
222
255
  return JSON.stringify({
223
- title: words.title,
224
- body: pushClass === "progress" && isCheckProgressLine(notification.body) ? notification.body : words.body,
225
- url: safePushLink(pushClass, notification.link),
256
+ title: "Toolroll",
257
+ body: line.body,
258
+ url: line.url,
226
259
  // The opaque tag collapses a crash-duplicate on the lock screen.
227
- tag: pushClass === "progress" && notification.run !== null ? `so-run-${notification.run}` : `so-${notification.id}`,
260
+ tag: release === null && notification.pushClass === "progress" && notification.run !== null ? `so-run-${notification.run}` : `so-${notification.id}`,
228
261
  // The app-icon badge (Phase 2E, v2 S4): the server-computed
229
262
  // waiting-on-you COUNT at send time — a number, never content, so the
230
263
  // closed-class discipline holds. The console page recomputes and
@@ -268,6 +301,13 @@ export async function pushPass(store, options) {
268
301
  if (fenced === null)
269
302
  continue; // resolved, retired, rotated, or lost — nothing to send
270
303
  const subscription = fenced.subscriptionRow;
304
+ // A release's "ready for your approval" that no longer stands (approved, superseded, put away) is resolved, never
305
+ // sent; one that stands is sent as `release`, its class and link derived from the plane's state now.
306
+ if (releaseReadyObsolete(store, fenced.notificationRow)) {
307
+ store.handle.prepare("UPDATE notification SET resolved_at = ? WHERE id = ? AND resolved_at IS NULL").run(clock().toISOString(), fenced.notificationRow.id);
308
+ continue;
309
+ }
310
+ const release = releasePushOf(store, fenced.notificationRow);
271
311
  const endpoint = validatePushEndpoint(subscription.endpoint);
272
312
  if (!endpoint.ok) {
273
313
  store.retirePushSubscription(subscription.id, "invalid-endpoint", clock());
@@ -280,7 +320,7 @@ export async function pushPass(store, options) {
280
320
  // The badge count rides every push: the same inbox classifier the
281
321
  // console's own sidebar badge reads, unscoped (a phone has no
282
322
  // project pane), computed at send time.
283
- buildPushPayload(fenced.notificationRow, store.countInboxScoped(null, clock(), 100, null).count));
323
+ buildPushPayload(fenced.notificationRow, store.countInboxScoped(null, clock(), 100, null).count, release));
284
324
  }
285
325
  catch {
286
326
  // Off-curve or malformed subscriber material: the device, not us.
@@ -131,6 +131,8 @@ export type AutoObservers = {
131
131
  /** Whether main's protection holds for the account that merges (readMainProtection); null when unreadable. */
132
132
  upToDateRequired: () => Promise<boolean | null>;
133
133
  evidenceRoot: string;
134
+ /** The cases that ran in `sha`'s macOS CI (fenced-check-coverage.ts); null when they can't be read. Absent: never read. */
135
+ macosCases?: (sha: string) => Promise<ReadonlyMap<string, boolean> | null>;
134
136
  };
135
137
  /**
136
138
  * Every check run GitHub holds for one commit — every attempt, not only the latest (`filter=all`; GitHub's default
@@ -169,6 +171,13 @@ export declare function readMergeBase(run: Exec, gh: string, repo: string, base:
169
171
  * never from a ref in a checkout a build can write.
170
172
  */
171
173
  export declare function readMainHead(run: Exec, gh: string, repo: string, branch?: string): Promise<string>;
174
+ /** GitHub's own "Update branch", with its expected head: a push that got there first refuses. */
175
+ export declare function updatePullRequestBranch(run: Exec, gh: string, repo: string, pr: number, expectedHead: string): Promise<{
176
+ ok: true;
177
+ } | {
178
+ ok: false;
179
+ why: string;
180
+ }>;
172
181
  /** Whether `sha` already holds `ancestor` (GitHub's compare: `sha` is not behind it), as GitHub answers now. */
173
182
  export declare function readContains(run: Exec, gh: string, repo: string, sha: string, ancestor: string): Promise<boolean>;
174
183
  /**
@@ -24,6 +24,10 @@ import { databasePath, finishReleaseOperation, requireFreshReleaseApproval, open
24
24
  import { acquireDeploymentLock } from "./deploy-lock.js";
25
25
  import { autoReleaseApprovalOf, liveReleaseOf } from "./scope.js";
26
26
  import { recheckReleaseTask, releaseCheckStanding } from "./check-failure.js";
27
+ import { CHECK_REPORT_CAPTURE, readSealedJson } from "./check-report.js";
28
+ import { readCheckReport } from "./contracts/release-check.js";
29
+ import { macosCases, skipCoverage, skippedCases, uncoveredWords } from "./fenced-check-coverage.js";
30
+ import { runCheckStamp } from "./store.js";
27
31
  import { noteCiRunning, releaseCiHoldOf, releaseCiRunningOf } from "./release-ci-hold.js";
28
32
  import { autoPublishHeld, holdAutoPublish, liftCiHold, authorizeAutoCheck, giveUpAutoApproval, noteAutoCheckFailed, noteAutoRelease, releaseAutoApproves, sealAutoGreen } from "./release-auto.js";
29
33
  import { proveCarry } from "./release-carry.js";
@@ -31,6 +35,8 @@ import { randomBytes, randomUUID } from "node:crypto";
31
35
  import { deploymentGate } from "./deploy-gate.js";
32
36
  import { failedBeforeTests } from "./github-jobs.js";
33
37
  import { mainHistoryOf, nightlyBoundary } from "./nightly-github.js";
38
+ import { noteReleaseReady, noteReleaseStopped, recordReleaseCi } from "./release-approval.js";
39
+ import { recordVerifiedRelease } from "./release-card.js";
34
40
  export { failedBeforeTests } from "./github-jobs.js";
35
41
  import { extractCommit } from "./commit-extract.js";
36
42
  import { teardownGit, refusal as gitRefusal } from "./teardown-git.js";
@@ -434,6 +440,7 @@ export function releaseAdapters(options) {
434
440
  };
435
441
  const release = storeReleaseAdapter(() => openStore(database), store => store.close(), options.now ?? (() => new Date()), undefined, { observe: sha => observeCi(sha), mergeBase: (base, sha) => readMergeBase(reads, ciGh, releaseRepo().slug, base, sha),
436
442
  upToDateRequired: () => readUpToDateRequired(reads, ciGh, releaseRepo().slug), evidenceRoot: join(dirname(database), "evidence"),
443
+ macosCases: sha => macosCasesOf(run, ciGh, releaseRepo().slug, sha),
437
444
  // A successful Actions job authenticates the job, not the candidate-writable JSON it uploads. Until the
438
445
  // executor protects that reporting channel, production releases must take the person's approval road.
439
446
  reportsProtected: false },
@@ -529,7 +536,7 @@ export function releaseAdapters(options) {
529
536
  },
530
537
  async complete(taskId, receipt) {
531
538
  const answer = await toolroll("task", "complete", taskId, "--digest", receipt);
532
- return answer["ok"] === true ? { ok: true } : { ok: false, message: refused(answer) };
539
+ return answer["ok"] === true ? { ok: true } : { ok: false, message: refused(answer), ...(typeof answer["reason"] === "string" ? { reason: answer["reason"] } : {}) };
533
540
  },
534
541
  async busy() {
535
542
  const db = new (sqlite().DatabaseSync)(database, { readOnly: true });
@@ -658,6 +665,7 @@ export function releaseAdapters(options) {
658
665
  async complete(repo, sha) { return readSuitesComplete(reads, ciGh, repo, sha); },
659
666
  async mainHead(repo) { return readMainHead(reads, ciGh, repo); },
660
667
  async contains(repo, sha, ancestor) { return readContains(reads, ciGh, repo, sha, ancestor); },
668
+ async updateBranch(repo, pr, expectedHead) { return updatePullRequestBranch(reads, ciGh, repo, pr, expectedHead); },
661
669
  async upToDateRequired(repo) { return readMainProtection(reads, ciGh, repo); },
662
670
  async merge(repo, pr, head, message) {
663
671
  // The squash commit's message lists every commit that landed after the approval (release-once.md). Never asked
@@ -971,6 +979,14 @@ nightlyReads) {
971
979
  } };
972
980
  return {
973
981
  ...(auto === undefined ? {} : { auto: autoReleaseAdapter(withStore, now, auto) }),
982
+ // One-tap release approval (trusted-release-reports.md, slice A): ids and the coordinator's own readings only; the
983
+ // plane decides from its own records whether anything is written.
984
+ ready: {
985
+ async asked(taskId) { withStore(store => { noteReleaseReady(store, { taskId, now: now() }); }); },
986
+ async ci(input) { withStore(store => { recordReleaseCi(store, { ...input, now: now() }); }); },
987
+ async stopped(input) { withStore(store => { noteReleaseStopped(store, { ...input, now: now() }); }); },
988
+ async verified(input) { withStore(store => { recordVerifiedRelease(store, { ...input, now: now() }); }); },
989
+ },
974
990
  // After a failed release check, and before a pass is taken: the plane's own classification (check-failure.ts).
975
991
  ...(auto === undefined ? {} : {
976
992
  async recheck(taskId) {
@@ -979,6 +995,33 @@ nightlyReads) {
979
995
  return answer.action === "rerun" ? { action: "rerun", why: answer.why } : answer.action === "pending" ? { action: "pending" } : { action: "stop", class: answer.class, why: answer.why };
980
996
  });
981
997
  },
998
+ async fencedCoverage(_taskId, run, candidate) {
999
+ // Today's road, read first and answered at once: a check that ran unsandboxed skips nothing for this reason. A run
1000
+ // from before stamps (a release resumed across an update) is judged by the level its dispatch recorded.
1001
+ const asked = withStore(store => {
1002
+ if (store.runCheckAccess(run) === "unsandboxed")
1003
+ return null;
1004
+ const reading = runCheckStamp(store, run);
1005
+ if (reading.state !== "current")
1006
+ return { refused: "how its check ran wasn't recorded, so what it skipped can't be judged" };
1007
+ if (reading.stamp.level === "unsandboxed")
1008
+ return null;
1009
+ const sealed = readSealedJson(store, auto.evidenceRoot, run, CHECK_REPORT_CAPTURE);
1010
+ const report = sealed === null ? null : readCheckReport(sealed.value);
1011
+ return report?.ok === true ? { skipped: skippedCases(report.value) } : { refused: "its report can't be read, so what it skipped can't be judged" };
1012
+ });
1013
+ if (asked === null)
1014
+ return { ok: true, covered: 0 };
1015
+ if ("refused" in asked)
1016
+ return { ok: false, why: asked.refused };
1017
+ if (asked.skipped.length === 0)
1018
+ return { ok: true, covered: 0 };
1019
+ const macos = await auto.macosCases?.(candidate) ?? null;
1020
+ if (macos === null)
1021
+ return { ok: false, why: "this commit's macOS CI reports couldn't be read, so what the sandboxed check skipped isn't covered" };
1022
+ const coverage = skipCoverage(asked.skipped, macos);
1023
+ return coverage.uncovered.length > 0 ? { ok: false, why: uncoveredWords(coverage.uncovered) } : { ok: true, covered: coverage.covered.length };
1024
+ },
982
1025
  async standing(taskId, run) {
983
1026
  return withStore(store => {
984
1027
  const standing = releaseCheckStanding(store, auto.evidenceRoot, { taskId, run });
@@ -1307,6 +1350,13 @@ export async function readMainHead(run, gh, repo, branch = "main") {
1307
1350
  throw new Error(`GitHub named no commit for ${branch}`);
1308
1351
  return match[1];
1309
1352
  }
1353
+ /** GitHub's own "Update branch", with its expected head: a push that got there first refuses. */
1354
+ export async function updatePullRequestBranch(run, gh, repo, pr, expectedHead) {
1355
+ if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo) || !Number.isSafeInteger(pr) || pr < 1 || !/^[0-9a-f]{40}$/.test(expectedHead))
1356
+ return { ok: false, why: "not a pull request GitHub can be asked to update" };
1357
+ const ran = await run(gh, ["api", "--hostname", "github.com", "--method", "PUT", `repos/${repo}/pulls/${pr}/update-branch`, "-f", `expected_head_sha=${expectedHead}`], { cwd: "/", env: ciEnv() });
1358
+ return ran.code === 0 ? { ok: true } : { ok: false, why: (ran.stderr.trim().split("\n")[0] ?? "").slice(0, 200) || `gh exited ${ran.code}` };
1359
+ }
1310
1360
  /** Whether `sha` already holds `ancestor` (GitHub's compare: `sha` is not behind it), as GitHub answers now. */
1311
1361
  export async function readContains(run, gh, repo, sha, ancestor) {
1312
1362
  if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo) || !/^[0-9a-f]{40}$/.test(sha) || !/^[0-9a-f]{40}$/.test(ancestor))
@@ -1520,3 +1570,39 @@ export function releaseProgressRecorder(open, done) {
1520
1570
  }
1521
1571
  };
1522
1572
  }
1573
+ /** The macOS CI's cases on exactly `sha`: the newest pull-request run of ci.yml there, each shard's newest attempt. Null
1574
+ * when they can't all be read. The folder is this function's own, removed before it returns. */
1575
+ async function macosCasesOf(run, gh, slug, sha) {
1576
+ const folder = mkdtempSync(join(tmpdir(), "so-macos-ci-"));
1577
+ try {
1578
+ const listed = await run(gh, ["api", `repos/${slug}/actions/runs?head_sha=${sha}&event=pull_request&per_page=100`], { timeoutMs: 60_000 });
1579
+ if (listed.code !== 0)
1580
+ return null;
1581
+ const runs = JSON.parse(listed.stdout).workflow_runs ?? [];
1582
+ const newest = runs.filter(one => typeof one.id === "number" && one.head_sha === sha && (one.path === ".github/workflows/ci.yml" || String(one.path).startsWith(".github/workflows/ci.yml@"))).map(one => one.id).sort((a, b) => b - a)[0];
1583
+ if (newest === undefined)
1584
+ return null;
1585
+ const got = await run(gh, ["run", "download", String(newest), "--repo", slug, "--pattern", "macos-shard-*", "--dir", folder], { timeoutMs: 180_000 });
1586
+ if (got.code !== 0)
1587
+ return null;
1588
+ const shards = new Map();
1589
+ for (const name of readdirSync(folder)) {
1590
+ const match = /^macos-(shard-\d+-\d+)-a([1-9]\d*)$/.exec(name);
1591
+ if (match === null)
1592
+ return null;
1593
+ const was = shards.get(match[1]);
1594
+ if (was === undefined || Number(match[2]) > was.attempt)
1595
+ shards.set(match[1], { attempt: Number(match[2]), name });
1596
+ }
1597
+ if (shards.size === 0)
1598
+ return null;
1599
+ const reports = [...shards.values()].map(one => JSON.parse(readFileSync(join(folder, one.name, "shard-report.json"), "utf8")));
1600
+ return macosCases(reports, `/Users/runner/work/${slug.split("/")[1]}/${slug.split("/")[1]}`);
1601
+ }
1602
+ catch {
1603
+ return null;
1604
+ }
1605
+ finally {
1606
+ rmSync(folder, { recursive: true, force: true });
1607
+ }
1608
+ }
@@ -0,0 +1,191 @@
1
+ import { type PullRequestReader, type ReleaseCard, type ReleaseSeal, type TermLine } from "./release-card.js";
2
+ import { type ApproveResult, type Scope } from "./scope.js";
3
+ import { type Notification, type Store } from "./store.js";
4
+ /** The ledger act holding the coordinator's latest reading of the pull request's CI on the exact candidate. */
5
+ export declare const RELEASE_CI_SEEN_ACTION = "release CI observed";
6
+ /** One check's newest attempt, as the coordinator's own gh read it. A re-run is a new check run id. */
7
+ export type ReleaseCiRun = {
8
+ id: number | null;
9
+ name: string;
10
+ status: string;
11
+ conclusion: string | null;
12
+ };
13
+ export type ReleaseCiSnapshot = {
14
+ candidate: string;
15
+ at: string;
16
+ state: "passed" | "running" | "failed";
17
+ failed: string[];
18
+ runs: ReleaseCiRun[];
19
+ };
20
+ /**
21
+ * Record what the coordinator just read of the pull request's CI on `candidate` (its verdict and every check's newest
22
+ * attempt), when it differs from the last reading recorded: the sheet and the CLI show it, and the card digest binds
23
+ * it. The coordinator is Toolroll's own runtime reading GitHub through the plane's recorded repository.
24
+ */
25
+ export declare function recordReleaseCi(store: Store, input: {
26
+ taskId: string;
27
+ candidate: string;
28
+ state: ReleaseCiSnapshot["state"];
29
+ failed: readonly string[];
30
+ runs: readonly {
31
+ id?: number | null;
32
+ name: string;
33
+ status: string;
34
+ conclusion: string | null;
35
+ }[];
36
+ now: Date;
37
+ }): boolean;
38
+ /** The coordinator's latest CI reading for this task on exactly `candidate`, or null (not read yet, or another commit). */
39
+ export declare function releaseCiOf(store: Store, taskId: string, candidate: string): ReleaseCiSnapshot | null;
40
+ /** What of a CI reading the card binds: its state and each check's attempt, whether it finished, and how. Queued and
41
+ * in progress read alike (neither is finished); the time it was read is not bound. */
42
+ export declare function ciBinding(ci: ReleaseCiSnapshot | null): unknown;
43
+ /** GitHub CI in one line, as the sheet and the CLI say it. */
44
+ export declare function ciWords(ci: ReleaseCiSnapshot | null, candidate: string): string;
45
+ export type ReleaseApprovalCard = {
46
+ taskId: string;
47
+ taskRef: number;
48
+ repo: string;
49
+ scope: Scope;
50
+ seal: ReleaseSeal;
51
+ /** The chat release card (release-card.ts): the change list and its own digest. */
52
+ card: ReleaseCard;
53
+ /** Every signed term, one line each (release-card.ts approvalTermsOf). */
54
+ terms: TermLine[];
55
+ ci: ReleaseCiSnapshot | null;
56
+ /** Why it can't be approved now, in words; null when it can (or already is). */
57
+ refusal: string | null;
58
+ approved: {
59
+ by: string;
60
+ at: string;
61
+ } | null;
62
+ /** The release coordinator stopped (its last step failed): approving needs `toolroll release` run again. */
63
+ stopped: {
64
+ step: string;
65
+ message: string;
66
+ } | null;
67
+ /** Binds everything above that a person is shown and can change under them. */
68
+ digest: string;
69
+ };
70
+ /** A release task from the plane's own state: a sealed form (terms or a release check's purpose) whose repository is the
71
+ * task's project and whose approved commit is the scope's candidate. Never from a title or a task id. */
72
+ export declare function releaseTaskOf(store: Store, taskId: string): {
73
+ ref: {
74
+ id: number;
75
+ repo: string;
76
+ };
77
+ scope: Scope;
78
+ seal: ReleaseSeal;
79
+ } | null;
80
+ /** The authoritative card for a release task waiting for its person, or why there is none. Reads only. */
81
+ export declare function releaseApprovalCard(store: Store, taskId: string, options?: {
82
+ read?: PullRequestReader;
83
+ }): {
84
+ ok: true;
85
+ card: ReleaseApprovalCard;
86
+ } | {
87
+ ok: false;
88
+ why: string;
89
+ };
90
+ /** What approving does, in the sheet's and the CLI's words: the consequence, the check's access, and whether later
91
+ * commits carry. */
92
+ export declare function releaseConsequence(card: ReleaseApprovalCard): string[];
93
+ export type ReleaseCardRefusal = {
94
+ ok: false;
95
+ reason: "not-release" | "changed" | "approved" | "refused";
96
+ message: string;
97
+ } | {
98
+ ok: false;
99
+ reason: "approve";
100
+ result: Exclude<ApproveResult, {
101
+ ok: true;
102
+ }>;
103
+ };
104
+ /**
105
+ * The one card-approval wrapper (Codex round 2, finding 2): rebuild the authoritative card and compare the digest the
106
+ * person saw; then, in one transaction, re-read the store's parts (the scope digest, the CI reading, the refusal state)
107
+ * and refuse on any change; then hand THAT snapshot's scope digest to `approveNow`, the caller's unchanged
108
+ * transactional `approve()`, which proves the person and seals. Nothing here proves anyone.
109
+ */
110
+ export declare function approveReleaseCard(store: Store, input: {
111
+ taskId: string;
112
+ cardDigest: string;
113
+ read?: PullRequestReader;
114
+ approveNow: (scopeDigest: string) => ApproveResult;
115
+ }): {
116
+ ok: true;
117
+ scope: Scope;
118
+ } | ReleaseCardRefusal;
119
+ export declare const RELEASE_READY_KIND = "release-ready";
120
+ export declare const RELEASE_STOPPED_KIND = "release-stopped";
121
+ /** The one push phrase for a release waiting on its person (push.ts PUSH_WORDS.release). */
122
+ export declare const RELEASE_PUSH_WORDS = "A release is ready for your approval";
123
+ /** The sheet's own address for a task (the in-app link; a push carries the opaque `/approve/<task ref>` instead). */
124
+ export declare const releaseSheetHref: (taskId: string) => string;
125
+ /**
126
+ * The coordinator asked a person (on entering the approval step, a fresh filing or a resumed one): one notification per
127
+ * task and stable scope digest, so a crash between filing and asking is healed by the next run and a repeat says nothing
128
+ * new. Every older `release-ready` row of the project (another task or digest) is resolved first. Nothing when the task
129
+ * isn't a release waiting for a person.
130
+ */
131
+ export declare function noteReleaseReady(store: Store, input: {
132
+ taskId: string;
133
+ now: Date;
134
+ }): boolean;
135
+ /** Resolve a task's `release-ready` rows (it was approved, or the release stopped): a push not sent yet never is. */
136
+ export declare function retireReleaseReady(store: Store, taskRef: number, now: Date): number;
137
+ /**
138
+ * The push class `release` and its link, derived at send time from the plane's own state, never from a title, a task id,
139
+ * the stored link or the stored class: the row is a `release-ready` row for exactly its task ref and a scope digest, and
140
+ * that task is a release scope (terms or a release check's purpose, its candidate the approved commit), not put away,
141
+ * not approved, at that same digest. Null otherwise.
142
+ */
143
+ export declare function releasePushOf(store: Store, notification: Pick<Notification, "kind" | "dedupeKey" | "taskRef" | "resolvedAt">): {
144
+ link: string;
145
+ } | null;
146
+ /** A `release-ready` row that no longer stands (the push pass resolves it instead of sending it). */
147
+ export declare function releaseReadyObsolete(store: Store, notification: Pick<Notification, "kind" | "dedupeKey" | "taskRef" | "resolvedAt">): boolean;
148
+ /**
149
+ * A person's release stopped (CI failed, the check failed, the deploy, the merge, the tag or publication failed, or it
150
+ * stopped waiting): one attention notification per task, step and reason, with the stop's words in the app (the push
151
+ * says only the fixed attention phrase). Its `release-ready` row is resolved, except while it only stopped waiting
152
+ * for the approval (the sheet still approves it then).
153
+ */
154
+ export declare function noteReleaseStopped(store: Store, input: {
155
+ taskId: string;
156
+ step: string;
157
+ reason: string;
158
+ message: string;
159
+ now: Date;
160
+ }): boolean;
161
+ /** The card as the CLI prints it, every line control-free (src/terminal-text.ts). */
162
+ export declare function releaseCardLines(card: ReleaseApprovalCard): string[];
163
+ /** One release journal as discovery reads it: strictly, every field it relies on in its exact shape. */
164
+ export type WaitingJournal = {
165
+ file: string;
166
+ branch: string;
167
+ checkout: string;
168
+ taskId: string;
169
+ scopeDigest: string;
170
+ candidate: string;
171
+ version: string;
172
+ pr: number;
173
+ };
174
+ /** A journal's own fields, strictly; null when any is missing or not its exact shape. */
175
+ export declare function readWaitingJournal(file: string, raw: unknown, expectedFile: (branch: string) => string): WaitingJournal | null;
176
+ /**
177
+ * Whether a journal (discovery only) names exactly the release the plane sealed: the task is a release scope whose
178
+ * sealed identity has this exact branch, version, pull request and candidate at this scope digest, and the journal's
179
+ * checkout, the task's project and the sealed repository are one real folder (`repo` too, when given). Why not, in words.
180
+ */
181
+ export declare function journalMatchesPlane(store: Store, journal: WaitingJournal, repo: string | null): {
182
+ ok: true;
183
+ } | {
184
+ ok: false;
185
+ why: string;
186
+ };
187
+ /** Every release journal in the state folder, read strictly; unreadable ones are counted, never guessed at. */
188
+ export declare function waitingJournals(stateDir: string, expectedFile: (branch: string) => string): {
189
+ journals: WaitingJournal[];
190
+ unreadable: string[];
191
+ };