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
@@ -20,13 +20,17 @@ import { createHash } from "node:crypto";
20
20
  import { lstatSync, realpathSync } from "node:fs";
21
21
  import { join } from "node:path";
22
22
  import { checkAccessWords } from "./check-access.js";
23
+ import { ranWords } from "./check-stamp.js";
23
24
  import { CHECK_LEVEL_WORDS, effectiveCheckLevel } from "./check-levels.js";
24
25
  import { verifiedRange } from "./commit-extract.js";
25
- import { releaseTermsSchema } from "./contracts/release-terms.js";
26
+ import { releaseCheckPurposeSchema, releaseTermsSchema } from "./contracts/release-terms.js";
27
+ import { readVerifiedCommit } from "./commit-extract.js";
28
+ import { cleanTerminalLine } from "./terminal-text.js";
26
29
  import { routeFromJson } from "./phase-routing.js";
27
30
  import { projectName } from "./project.js";
28
31
  import { RELEASE_CI_RUNNING_WORDS, releaseCiRunningOf } from "./release-ci-hold.js";
29
32
  import { RELEASE_PATH, releaseConfigProblem, releaseGitEnv, releaseRemote, releaseTools } from "./release-git.js";
33
+ import { taskCheckStamp } from "./store.js";
30
34
  import { nightlyReleaseLines } from "./nightly-github.js";
31
35
  const NONE = "none";
32
36
  /** A budget in dollars from integer micro-dollars, in integer arithmetic: cents when it is whole cents, else every digit. */
@@ -91,6 +95,30 @@ export const RELEASE_TERM_LABELS = {
91
95
  "policy.unsandboxedCarry": "Carry when checks aren't sandboxed",
92
96
  "grant.id": "Release check grant", "grant.digest": "Release check grant digest", access: "Release check access", expiresAt: "Expires",
93
97
  };
98
+ /** Every field of a release check's purpose (contracts/release-terms.ts releaseCheckPurposeSchema): the sealed form
99
+ * Toolroll's own releases take while their checks run outside the sandbox. A contract test checks every leaf has one. */
100
+ export const RELEASE_CHECK_LABELS = {
101
+ version: "Release check version", purpose: "Approved as", repo: "Repository", packageVersion: "Package version", branch: "Branch", pr: "Pull request",
102
+ base: "Base", approvedSha: "Approved commit", approvedTree: "Approved tree", "grant.id": "Release check grant", "grant.digest": "Release check grant digest",
103
+ access: "Release check access",
104
+ };
105
+ export function releaseSealOf(scope) {
106
+ if (scope === null)
107
+ return null;
108
+ if (scope.releaseTerms != null)
109
+ return { kind: "terms", terms: scope.releaseTerms, identity: scope.releaseTerms };
110
+ if (scope.releaseCheck != null)
111
+ return { kind: "check", purpose: scope.releaseCheck, identity: scope.releaseCheck };
112
+ return null;
113
+ }
114
+ /** What an unsandboxed (or sign-ins) release check means, said before anyone approves it. Null in the sandbox. */
115
+ export function releaseAccessWords(access) {
116
+ if (access === "unsandboxed")
117
+ return "The release check runs this commit's code with full access to this computer.";
118
+ if (access === "sign-ins")
119
+ return "The release check runs this commit's code with your sign-ins on this computer.";
120
+ return null;
121
+ }
94
122
  /** The leaf paths of a zod object schema (nested objects joined with dots). */
95
123
  export function schemaLeaves(schema, prefix = "") {
96
124
  const shape = schema.shape;
@@ -140,6 +168,15 @@ export function approvalTermsOf(store, scope, words) {
140
168
  lines.push({ key: leaf.path, label: `Phase route ${leaf.path.slice("route.".length)}`, value: leaf.value });
141
169
  }
142
170
  const terms = scope.releaseTerms ?? null;
171
+ // A release check's purpose (the form Toolroll's own releases seal): every one of its fields, never "none".
172
+ if (terms === null && scope.releaseCheck != null) {
173
+ const purpose = scope.releaseCheck;
174
+ for (const path of schemaLeaves(releaseCheckPurposeSchema)) {
175
+ const value = path === "pr" ? `#${purpose.pr}` : path === "purpose" ? "a release check: every later commit asks again" : said(valueAt(purpose, path));
176
+ lines.push({ key: `releaseCheck.${path}`, label: RELEASE_CHECK_LABELS[path] ?? path, value });
177
+ }
178
+ return lines;
179
+ }
143
180
  for (const path of schemaLeaves(releaseTermsSchema)) {
144
181
  const label = RELEASE_TERM_LABELS[path] ?? path;
145
182
  const value = terms === null ? NONE : path === "pr" ? `#${terms.pr}`
@@ -195,20 +232,120 @@ export function commitsBetween(repo, from, to) {
195
232
  const range = verifiedRange(gitDir, from, to, { git: tools.git, env });
196
233
  if (!range.ok)
197
234
  return null;
198
- return range.value.map(one => ({ sha: one.id, subject: one.message.split("\n")[0].trim() }));
235
+ return range.value.map(one => ({ sha: one.id, subject: subjectText(one.message) }));
199
236
  }
200
- /** A pull request's own commits from GitHub, for the recorded owner/name. Null when GitHub can't be read. */
201
- function pullRequestCommits(slug, pr) {
202
- const tools = releaseTools();
203
- const read = spawnSync(tools.gh, ["api", `repos/${slug}/pulls/${pr}/commits?per_page=100`, "--jq", '.[] | .sha + "\\t" + (.commit.message | split("\\n")[0])'], { cwd: "/", env: releaseGitEnv(), encoding: "utf8", timeout: 15_000 });
204
- if (read.status !== 0)
237
+ /** GitHub lists at most this many of a pull request's commits; a longer one is never shown as complete. */
238
+ export const PULL_REQUEST_COMMITS_MAX = 250;
239
+ /** A commit subject is someone else's text: one line, control-free (src/terminal-text.ts), bounded. The card digest
240
+ * binds this cleaned text, so every surface shows exactly what was bound (Codex round 2, finding 7). */
241
+ export const subjectText = (subject) => cleanTerminalLine(subject.split("\n")[0] ?? "").trim().slice(0, 200);
242
+ const ghRead = args => {
243
+ const read = spawnSync(releaseTools().gh, args, { cwd: "/", env: releaseGitEnv(), encoding: "utf8", timeout: 30_000, maxBuffer: 16 * 1024 * 1024 });
244
+ return { status: read.status, stdout: String(read.stdout ?? "") };
245
+ };
246
+ /**
247
+ * A pull request's own commits from GitHub, for the recorded owner/name, complete and bound to the candidate (Codex
248
+ * round 2, finding 6): every page (`--paginate`), accepted only when the pull request's head is `head`, the newest
249
+ * listed commit is `head` and the count is the pull request's own count. Otherwise why, never a shortened list.
250
+ */
251
+ export function pullRequestCommits(slug, pr, head, read = ghRead) {
252
+ const about = read(["api", `repos/${slug}/pulls/${pr}`, "--jq", '[.head.sha, (.commits|tostring)] | join("\\t")']);
253
+ if (about.status !== 0)
254
+ return { ok: false, why: "GitHub couldn't be read" };
255
+ const [prHead, countText] = about.stdout.trim().split("\t");
256
+ const total = Number(countText);
257
+ if (prHead !== head)
258
+ return { ok: false, why: `the pull request is at ${SHA.test(prHead ?? "") ? prHead.slice(0, 7) : "another commit"}, not ${head.slice(0, 7)}` };
259
+ if (!Number.isSafeInteger(total) || total < 1)
260
+ return { ok: false, why: "GitHub didn't say how many commits the pull request has" };
261
+ if (total > PULL_REQUEST_COMMITS_MAX)
262
+ return { ok: false, why: `incomplete: the pull request has ${total} commits and GitHub lists only ${PULL_REQUEST_COMMITS_MAX}` };
263
+ const listed = read(["api", "--paginate", `repos/${slug}/pulls/${pr}/commits?per_page=100`, "--jq", '.[] | .sha + "\\t" + ((.commit.message | split("\\n")[0]) | @json)']);
264
+ if (listed.status !== 0)
265
+ return { ok: false, why: "GitHub couldn't be read" };
266
+ const commits = [];
267
+ for (const line of listed.stdout.split("\n")) {
268
+ if (line === "")
269
+ continue;
270
+ const tab = line.indexOf("\t");
271
+ const sha = line.slice(0, tab);
272
+ let subject;
273
+ try {
274
+ subject = JSON.parse(line.slice(tab + 1));
275
+ }
276
+ catch {
277
+ return { ok: false, why: "GitHub's answer didn't parse" };
278
+ }
279
+ if (tab < 0 || !SHA.test(sha) || typeof subject !== "string")
280
+ return { ok: false, why: "GitHub's answer didn't parse" };
281
+ commits.push({ sha, subject: subjectText(subject) });
282
+ }
283
+ if (commits.length !== total)
284
+ return { ok: false, why: `incomplete: GitHub listed ${commits.length} of ${total} commits` };
285
+ if (commits[commits.length - 1].sha !== head)
286
+ return { ok: false, why: `the newest commit GitHub listed isn't ${head.slice(0, 7)}` };
287
+ if (new Set(commits.map(one => one.sha)).size !== commits.length)
288
+ return { ok: false, why: "GitHub listed a commit twice" };
289
+ return { ok: true, commits: commits.reverse() };
290
+ }
291
+ /** The ledger act that records a verified release (the merged tree proved equal to the approved one). */
292
+ export const RELEASE_VERIFIED_ACTION = "release verified";
293
+ /** The project's own Git directory: `<realpath(repo)>/.git`, a real directory. */
294
+ function gitDirOf(repo) {
295
+ try {
296
+ const named = join(realpathSync(repo), ".git");
297
+ return lstatSync(named).isDirectory() ? realpathSync(named) : null;
298
+ }
299
+ catch {
205
300
  return null;
206
- return read.stdout.split("\n").filter(line => line !== "").map(line => { const tab = line.indexOf("\t"); return { sha: line.slice(0, tab), subject: line.slice(tab + 1) }; })
207
- .filter(one => SHA.test(one.sha)).reverse();
301
+ }
208
302
  }
209
- /** What changed since the last verified release, for a release's terms, from Toolroll's records only. */
210
- export function releaseChangeList(store, terms) {
211
- const record = releaseBaselineRecord(store, terms.repo);
303
+ /**
304
+ * Record a verified release (Codex round 2, finding 1: verified release history apart from carrying). The coordinator
305
+ * calls it once main's merge commit was proved to hold the approved tree; the plane proves it again here, from its own
306
+ * records only: the task is a release scope of a project, and `merge` read as a verified Git object from the project's
307
+ * recorded Git directory has exactly the sealed approved tree. False when anything doesn't hold (nothing written).
308
+ */
309
+ export function recordVerifiedRelease(store, input) {
310
+ if (!SHA.test(input.merge))
311
+ return false;
312
+ const repo = store.lookupRef(input.taskId)?.repo ?? null;
313
+ const seal = releaseSealOf(store.getScope(input.taskId));
314
+ if (repo === null || seal === null || seal.identity.repo !== repo)
315
+ return false;
316
+ const gitDir = gitDirOf(repo);
317
+ if (gitDir === null)
318
+ return false;
319
+ const commit = readVerifiedCommit(gitDir, input.merge);
320
+ if (!commit.ok || commit.value.tree !== seal.identity.approvedTree)
321
+ return false;
322
+ if (store.handle.prepare("SELECT 1 FROM action_ledger WHERE task_id = ? AND action = ? AND detail = ?").get(input.taskId, RELEASE_VERIFIED_ACTION, input.merge) !== undefined)
323
+ return true;
324
+ store.recordAction({ at: input.now.toISOString(), actor: "toolroll", repo, taskId: input.taskId, runId: null, action: RELEASE_VERIFIED_ACTION, outcome: seal.identity.packageVersion,
325
+ source: "work", detail: input.merge });
326
+ return true;
327
+ }
328
+ /**
329
+ * The last verified release of `repo`, from either record: a merged release authority's succeeded merge, or a verified
330
+ * release row (recordVerifiedRelease). The newer stands. Shaped as releaseBaselineRecord.
331
+ */
332
+ export function verifiedReleaseBaseline(store, repo) {
333
+ const authority = store.handle.prepare(`SELECT o.external_ref, COALESCE(o.finished_at, '') AS at FROM release_authority a LEFT JOIN release_operation o
334
+ ON o.authority = a.id AND o.kind = 'merge' AND o.state = 'succeeded'
335
+ WHERE a.repo = ? AND a.state = 'merged' ORDER BY a.id DESC, o.finished_at DESC LIMIT 1`).get(repo);
336
+ const verified = store.handle.prepare("SELECT detail, at FROM action_ledger WHERE repo = ? AND action = ? ORDER BY id DESC LIMIT 1").get(repo, RELEASE_VERIFIED_ACTION);
337
+ const verifiedSha = typeof verified?.["detail"] === "string" && SHA.test(verified["detail"]) ? verified["detail"] : null;
338
+ if (verified !== undefined && verifiedSha !== null && (authority === undefined || String(verified["at"]) >= String(authority["at"])))
339
+ return { recorded: true, sha: verifiedSha };
340
+ if (authority === undefined)
341
+ return verified === undefined ? { recorded: false } : { recorded: true, sha: null };
342
+ const ref = authority["external_ref"];
343
+ return { recorded: true, sha: typeof ref === "string" && SHA.test(ref) ? ref : null };
344
+ }
345
+ /** What changed since the last verified release, for a release's sealed identity (terms or a release check's purpose),
346
+ * from Toolroll's records only. */
347
+ export function releaseChangeList(store, terms, read) {
348
+ const record = verifiedReleaseBaseline(store, terms.repo);
212
349
  // A release recorded with a merge commit that can't be read: the list couldn't be read (eval-harness review 2,
213
350
  // finding 4) — never the pull request's commits, as if there were no previous release.
214
351
  if (record.recorded && record.sha === null)
@@ -216,6 +353,7 @@ export function releaseChangeList(store, terms) {
216
353
  const baseline = record.recorded ? record.sha : null;
217
354
  let commits;
218
355
  let source;
356
+ let why;
219
357
  if (baseline !== null) {
220
358
  commits = commitsBetween(terms.repo, baseline, terms.approvedSha);
221
359
  source = commits === null ? "unread" : "recorded";
@@ -223,10 +361,14 @@ export function releaseChangeList(store, terms) {
223
361
  else {
224
362
  const remote = store.projectRemote(terms.repo);
225
363
  const parsed = remote === null ? null : releaseRemote(remote);
226
- commits = parsed === null ? null : pullRequestCommits(parsed.slug, terms.pr);
364
+ const listed = parsed === null ? { ok: false, why: "the project has no GitHub remote on record" } : pullRequestCommits(parsed.slug, terms.pr, terms.approvedSha, read);
365
+ commits = listed.ok ? listed.commits : null;
366
+ if (!listed.ok)
367
+ why = listed.why;
227
368
  source = commits === null ? "unread" : "pull-request";
228
369
  }
229
- return { baseline, commits, source, digest: sha256(`release-changes:${JSON.stringify({ baseline, source, commits })}`) };
370
+ return { baseline, commits, source, ...(why === undefined ? {} : { why }),
371
+ digest: sha256(`release-changes:${JSON.stringify(why === undefined ? { baseline, source, commits } : { baseline, source, commits, why })}`) };
230
372
  }
231
373
  /** The card digest: everything the person is shown that can change under them. */
232
374
  export function cardDigestOf(scopeDigest, formDigest, changes) {
@@ -236,25 +378,39 @@ function changeWords(changes, terms, cap) {
236
378
  const head = changes.baseline === null ? `No earlier verified release is recorded. ${changes.source === "pull-request" ? `Pull request #${terms?.pr ?? "?"}'s commits:` : ""}`.trim()
237
379
  : `Changes since the last verified release (${changes.baseline.slice(0, 7)}):`;
238
380
  if (changes.commits === null)
239
- return [head, changes.baseline === null && changes.baselineUnreadable !== true ? `The pull request's commits couldn't be read; see #${terms?.pr ?? "?"} on GitHub.` : "The change list couldn't be read."];
381
+ return [head, changes.baseline === null && changes.baselineUnreadable !== true ? `The pull request's commits couldn't be read${changes.why === undefined ? "" : ` (${changes.why})`}; see #${terms?.pr ?? "?"} on GitHub.` : "The change list couldn't be read."];
240
382
  if (changes.commits.length === 0)
241
383
  return [head, "No commits."];
242
384
  return [head, ...changes.commits.slice(0, cap).map(one => `• ${one.sha.slice(0, 7)} ${one.subject.slice(0, 100)}`), ...(changes.commits.length > cap ? [`• and ${changes.commits.length - cap} more`] : [])];
243
385
  }
244
386
  /** The release card for a deploy gate, against the live records now. */
245
- export function releaseCardOf(store, scope, formDigest, words) {
387
+ /** How this task's last check ran, when one did and it wasn't plainly sandboxed: the sealed level above is what will run;
388
+ * this is what did. Nothing for a release no check has run for. */
389
+ function lastCheckWords(store, repo, taskId) {
390
+ const ran = repo === null ? null : taskCheckStamp(store, repo, taskId);
391
+ const words = ran === null ? null : ranWords(ran.reading);
392
+ return words === null ? [] : [`Last check: ${words}.`];
393
+ }
394
+ export function releaseCardOf(store, scope, formDigest, words, read) {
246
395
  const terms = scope.releaseTerms ?? null;
396
+ // Either sealed form (Codex round 2, finding 1): the identity both share, for the head lines and the change list.
397
+ const identity = releaseSealOf(scope)?.identity ?? null;
247
398
  const lines = approvalTermsOf(store, scope, words);
248
399
  const value = (key) => lines.find(line => line.key === key)?.value ?? NONE;
249
- const changes = terms === null
400
+ const changes = identity === null
250
401
  ? { baseline: null, commits: null, source: "unread", digest: sha256("release-changes:none") }
251
- : releaseChangeList(store, terms);
252
- const version = terms?.packageVersion ?? null;
402
+ : releaseChangeList(store, identity, read);
403
+ const version = identity?.packageVersion ?? null;
253
404
  const fileName = `release-${version ?? scope.taskId.replace(/[^A-Za-z0-9._-]/g, "-")}-terms.txt`;
254
405
  const short = [
255
406
  version === null ? `Deploy gate: ${value("title")}` : `Release ${version}: ${value("title")}`,
256
- terms === null ? `Candidate ${said(scope.candidate).slice(0, 12)} · ${value("project")}` : `${projectName(terms.repo)} · ${terms.branch} · pull request #${terms.pr} · commit ${terms.approvedSha.slice(0, 7)}`,
257
- terms === null ? `Checks: ${value("checks")} · check access: ${value("checkAccess")}` : `Carry later commits: ${terms.policy.carry ? "yes" : "no"} · checks: ${value("checks")} · expires ${terms.expiresAt}`,
407
+ identity === null ? `Candidate ${said(scope.candidate).slice(0, 12)} · ${value("project")}` : `${projectName(identity.repo)} · ${identity.branch} · pull request #${identity.pr} · commit ${identity.approvedSha.slice(0, 7)}`,
408
+ terms === null ? identity === null ? `Checks: ${value("checks")} · check access: ${value("checkAccess")}` : `Carry later commits: no, every later commit asks again · checks: ${value("checks")}`
409
+ : `Carry later commits: ${terms.policy.carry ? "yes" : "no"} · checks: ${value("checks")} · expires ${terms.expiresAt}`,
410
+ // What an unsandboxed check means, before consent (Codex round 2, finding 1).
411
+ ...(identity === null || releaseAccessWords(identity.access) === null ? [] : [releaseAccessWords(identity.access)]),
412
+ // A check that already ran for this task, from its stamp (H2): never read as sandboxed when it can't be shown.
413
+ ...lastCheckWords(store, identity?.repo ?? null, scope.taskId),
258
414
  // Option A's disclosure, on the card as on the approval sheet: never approved from chat without it.
259
415
  ...(terms !== null && terms.policy.carry && terms.access !== "sandboxed" ? ["This project's checks don't run in the sandbox: later test-only commits will run outside the sandbox without being shown to you first."] : []),
260
416
  // Filed while CI runs (Alex, 2026-10-29): a person may approve now, and is told plainly what still waits.
@@ -262,10 +418,10 @@ export function releaseCardOf(store, scope, formDigest, words) {
262
418
  // GitHub's nightly (nightly-github.ts): what holds this release, or why it can't be read. Not a signed term.
263
419
  ...(terms === null ? [] : nightlyReleaseLines(store, { repo: terms.repo, taskId: scope.taskId, candidate: scope.candidate ?? null, now: new Date() })),
264
420
  "",
265
- ...changeWords(changes, terms, 5),
421
+ ...changeWords(changes, identity, 5),
266
422
  "",
267
423
  `Every term is in ${fileName}.`,
268
424
  ].join("\n");
269
- const full = [`${version === null ? "Deploy gate" : `Release ${version}`}: every term`, "", termsText(lines), "", ...changeWords(changes, terms, 200)].join("\n");
425
+ const full = [`${version === null ? "Deploy gate" : `Release ${version}`}: every term`, "", termsText(lines), "", ...changeWords(changes, identity, 200)].join("\n");
270
426
  return { digest: cardDigestOf(scope.digest, formDigest, changes), short, full, fileName, changes };
271
427
  }
@@ -44,8 +44,13 @@ export declare function stricterOf(a: ReleasePolicy | null, b: ReleasePolicy | n
44
44
  * the rules don't rely on that).
45
45
  */
46
46
  export declare function rulePath(path: string): string;
47
- /** Why one changed path doesn't carry, in words, or null when it does. Every rule reads the normalized path. */
48
- export declare function changeRefusal(change: TreeChange, policy: ReleasePolicy): string | null;
47
+ /**
48
+ * Why one changed path doesn't carry, in words, or null when it does. Every rule reads the normalized path. `fresh`: the
49
+ * paths that didn't exist at the approved commit. A test that was there stays exactly as it was (H4): new tests go in new
50
+ * files. Only "unchanged" holds up, since `vi.mock` hoists and a top-level hook reaches every case above it, so an
51
+ * appended line can disarm what it follows.
52
+ */
53
+ export declare function changeRefusal(change: TreeChange, policy: ReleasePolicy, fresh?: ReadonlySet<string>): string | null;
49
54
  /**
50
55
  * A release scope's terms, from the plane's own records: the project's repository, the approved commit and its
51
56
  * verified tree, the effective policy, the live release check grant and the check access (only the fence), and the
@@ -26,14 +26,17 @@ import { join, sep } from "node:path";
26
26
  import { execFileSync } from "node:child_process";
27
27
  import { checkLaunchRoot, fenceFor, linuxFenceAvailable, macosFenceAvailable, runFolders } from "./agent-fence.js";
28
28
  import { touchesProtected } from "./approval-policy.js";
29
- import { extractCommit, readVerifiedCommit, verifiedFirstParentWalk, verifiedTreeDiff } from "./commit-extract.js";
29
+ import { extractCommit, readVerifiedCommit, verifiedBlobs, verifiedFirstParentWalk, verifiedTreeDiff } from "./commit-extract.js";
30
+ import { testImportProblem } from "./test-infra.js";
30
31
  import { projectGitDir } from "./check-report.js";
32
+ import { checkAccessOf } from "./check-access.js";
33
+ import { stampRefusal } from "./check-stamp.js";
31
34
  import { CARRY_RULES_VERSION, readReleasePolicy, releasePolicyDigest } from "./contracts/release-terms.js";
32
35
  import { run } from "./exec.js";
33
36
  import { proxyEnv, startEgressProxy } from "./egress.js";
34
37
  import { liveReleaseOf } from "./scope.js";
35
38
  import { UNSANDBOXED_CARRY_ACTION, carriesUnsandboxed } from "./release-authority.js";
36
- import { carryReleaseApproval, releaseAuthority, releaseCarryTo, sealAnchorArtifact, } from "./store.js";
39
+ import { carryReleaseApproval, releaseAuthority, releaseCarryTo, sealAnchorArtifact, taskCheckStamp, } from "./store.js";
37
40
  // ------------------------------------------------------------------ the rules
38
41
  /** The journey scripts: tests, by Alex's decision (they carry). */
39
42
  export const JOURNEY_SCRIPTS = Object.freeze(["scripts/app-e2e.mjs", "scripts/flows-e2e.mjs"]);
@@ -111,8 +114,15 @@ export function stricterOf(a, b) {
111
114
  export function rulePath(path) {
112
115
  return path.toLowerCase().split("/").map(part => part.replace(/[. ]+$/, "")).filter(part => part !== "" && part !== ".").join("/");
113
116
  }
114
- /** Why one changed path doesn't carry, in words, or null when it does. Every rule reads the normalized path. */
115
- export function changeRefusal(change, policy) {
117
+ /** Tests by the rules' classes: what the existing-test rule holds still. */
118
+ const carriedTest = (normal) => CARRY_RULES.classes.some(one => (one.name === "unit tests" || one.name === "journey scripts") && one.matches(normal));
119
+ /**
120
+ * Why one changed path doesn't carry, in words, or null when it does. Every rule reads the normalized path. `fresh`: the
121
+ * paths that didn't exist at the approved commit. A test that was there stays exactly as it was (H4): new tests go in new
122
+ * files. Only "unchanged" holds up, since `vi.mock` hoists and a top-level hook reaches every case above it, so an
123
+ * appended line can disarm what it follows.
124
+ */
125
+ export function changeRefusal(change, policy, fresh = new Set()) {
116
126
  const path = change.path;
117
127
  const normal = rulePath(path);
118
128
  if (change.kind === "deleted")
@@ -133,6 +143,8 @@ export function changeRefusal(change, policy) {
133
143
  return `${path} is protected in this project`;
134
144
  if (!CARRY_RULES.classes.some(one => one.matches(normal)))
135
145
  return `${path} isn't a test, a doc or the changelog`;
146
+ if (change.kind === "modified" && carriedTest(normal) && !fresh.has(path))
147
+ return `${path} changes an existing test, and new tests go in a new file`;
136
148
  return null;
137
149
  }
138
150
  // ------------------------------------------------------------------ release terms
@@ -211,6 +223,11 @@ export function preflightCarry(store, input) {
211
223
  if ((a.accessDigest !== "sandboxed" || store.projectCheckAccess(a.repo) !== "sandboxed") && policy.unsandboxedCarry !== true) {
212
224
  return refuse("rules", "This project's checks run outside the sandbox, and carrying release approvals then wasn't on when this release was approved, or is off now.");
213
225
  }
226
+ // A carry leans on the check the release already had: it must be shown to have stayed within what was approved (H2).
227
+ const ran = taskCheckStamp(store, a.repo, a.tipTask);
228
+ const unshown = ran === null ? null : stampRefusal(ran.reading, checkAccessOf(a.accessDigest));
229
+ if (unshown !== null)
230
+ return refuse("rules", `This release's approval can't carry: ${unshown}.`);
214
231
  if (!/^[0-9a-f]{40}([0-9a-f]{24})?$/.test(input.to))
215
232
  return refuse("ancestry", `${input.to} isn't a full commit id.`);
216
233
  if (input.to === a.tipSha)
@@ -234,6 +251,10 @@ export function preflightCarry(store, input) {
234
251
  treeOf.set(commit.id, commit.tree);
235
252
  const commits = [];
236
253
  let churn = 0;
254
+ const whole = verifiedTreeDiff(gitDir, a.approvedTree, chain[0].tree);
255
+ if (!whole.ok)
256
+ return refuse("shape", `The change since the approval couldn't be read: ${whole.message}`);
257
+ const fresh = new Set(whole.value.filter(one => one.kind === "added").map(one => one.path));
237
258
  for (const commit of ordered) {
238
259
  const parentTree = treeOf.get(commit.parents[0]);
239
260
  if (parentTree === undefined)
@@ -242,21 +263,30 @@ export function preflightCarry(store, input) {
242
263
  if (!diff.ok)
243
264
  return refuse("shape", `${commit.id.slice(0, 7)} couldn't be read: ${diff.message}`);
244
265
  for (const change of diff.value) {
245
- const why = changeRefusal(change, policy);
266
+ const why = changeRefusal(change, policy, fresh);
246
267
  if (why !== null)
247
268
  return refuse("rules", `${commit.id.slice(0, 7)}: ${why}.`, change.path);
248
269
  }
249
270
  churn += diff.value.reduce((n, one) => n + one.added + one.deleted, 0);
250
271
  commits.push({ sha: commit.id, files: diff.value.map(one => ({ path: one.path, added: one.added, deleted: one.deleted })) });
251
272
  }
252
- const whole = verifiedTreeDiff(gitDir, a.approvedTree, chain[0].tree);
253
- if (!whole.ok)
254
- return refuse("shape", `The change since the approval couldn't be read: ${whole.message}`);
255
273
  for (const change of whole.value) {
256
- const why = changeRefusal(change, policy);
274
+ const why = changeRefusal(change, policy, fresh);
257
275
  if (why !== null)
258
276
  return refuse("rules", `Since the approval: ${why}.`, change.path);
259
277
  }
278
+ // No new test may reach into another (H4). Read from the verified tree, never from a checkout.
279
+ // By the rules' own classifier, so everything that carries as a test is read (a new journey script too).
280
+ const newTests = whole.value.filter(one => one.kind === "added" && carriedTest(rulePath(one.path))).map(one => one.path);
281
+ const added = newTests.length === 0 ? null : verifiedBlobs(gitDir, chain[0].tree, newTests);
282
+ if (added !== null && !added.ok)
283
+ return refuse("shape", `The new tests couldn't be read: ${added.message}`);
284
+ for (const path of newTests) {
285
+ const bytes = added !== null && added.ok ? added.value.get(path)?.bytes : undefined;
286
+ const why = bytes === undefined ? `${path} couldn't be read` : testImportProblem(path, bytes.toString("utf8"));
287
+ if (why !== null)
288
+ return refuse("rules", `Since the approval: ${why}.`, path);
289
+ }
260
290
  const changedLines = whole.value.reduce((n, one) => n + one.added + one.deleted, 0);
261
291
  if (changedLines > policy.limits.lines)
262
292
  return refuse("limits", `${changedLines} lines changed since the approval; at most ${policy.limits.lines} carry.`);
@@ -8,6 +8,7 @@
8
8
  import { type ReleaseAdapters } from "./release.js";
9
9
  import { type GateReleaseWorld } from "./release-gate.js";
10
10
  import { type RescueWorld } from "./deploy-rescue.js";
11
+ import { type RunLockSeams, type SettleWorld } from "./release-next.js";
11
12
  export declare const RELEASE_HELP: string;
12
13
  /** A runner lease id (claim.ts mints randomUUID()). */
13
14
  export declare const LEASE_SHAPED: RegExp;
@@ -23,6 +24,9 @@ export type ReleaseCliDeps = {
23
24
  gateWorld?: GateReleaseWorld;
24
25
  /** What --recover-deploy reads and runs (deploy-rescue.ts); the installation's own by default. */
25
26
  rescueWorld?: RescueWorld;
27
+ /** What a stopped deployment left, settled before the release goes on (release-next.ts). Tests that inject adapters skip it. */
28
+ settle?: SettleWorld;
29
+ runLock?: RunLockSeams;
26
30
  /** The plane's database for the authority commands (default: this installation's). */
27
31
  database?: string;
28
32
  now?: () => Date;
@@ -16,6 +16,7 @@ import { RELEASE_LIMITS, runRelease } from "./release.js";
16
16
  import { processes, releaseAdapters } from "./release-adapters.js";
17
17
  import { journals, releaseGate } from "./release-gate.js";
18
18
  import { rescueDeployment } from "./deploy-rescue.js";
19
+ import { settleDeployment, takeReleaseRun } from "./release-next.js";
19
20
  import { databasePath, elapsedSeconds, latestReleaseStepTask, openStore, releaseProgressFor, revokeRelease } from "./store.js";
20
21
  import { dur } from "./design/kit.js";
21
22
  import { carryRefusalWords, proveCarry, setAskEveryReleaseCommit, setUnsandboxedCarry } from "./release-carry.js";
@@ -34,27 +35,22 @@ export const RELEASE_HELP = `toolroll release <branch> — release Toolroll itse
34
35
  squash-merges, deletes the matching remote branch, verifies the merged tree and tags. Both macOS legs gate npm
35
36
  publication; Homebrew waits for its checks to register and all pass before merging.
36
37
 
37
- Run the same command to resume after a stop. Interrupted deployments recover their saved stage before retrying.
38
+ Run the same command to go on after any stop. It reads where the release got to and does the one thing that's next:
39
+ finishes a deployment forward, puts one back, lifts a pause left before a swap, waits for another run of the release
40
+ (naming its computer and process), or waits for your approval. It says what it did. A branch behind main is brought up
41
+ to date for you; that new commit needs its own approval, CI and check.
38
42
 
39
- A branch whose name is one of this command's own words (status, nightly, carry…) is named with --branch <name>
43
+ A branch whose name is one of this command's own words (approve, status, nightly, carry…) is named with --branch <name>
40
44
  or after --: \`toolroll release --branch status\`.
41
45
 
46
+ toolroll release approve [<branch>] [--repo <path>] — approve the release waiting for you: shows the release, GitHub CI
47
+ now, what approving does, what changed and every term, then asks for your password (or uses your remembered login
48
+ where Passwordless allows it). Names the waiting releases when more than one could be meant. With --json it never
49
+ prompts: --as <you> --token <password> --digest <card digest>
50
+
42
51
  toolroll release status [<task>] — the steps of a release (the newest one recorded, or the release task named): each
43
52
  step's state and how long it took, and where it stopped
44
53
 
45
- toolroll release --release-gate <id> [--yes] — lift the update pause a deployment left before its swap
46
-
47
- Shows why it is safe first: the pause is that deployment's, its journal stopped before the swap, and the
48
- database, service definition and running service are all still the previous build's. --yes lifts that one pause.
49
- Any missing or contradicting evidence refuses and leaves the pause in place.
50
-
51
- toolroll release --recover-deploy [--stage <dir>] [--yes] — put back a deployment that stopped half way
52
-
53
- Finds the one unfinished deployment (or the stage named) and shows what its recovery will do; --yes does it. When
54
- the old service isn't proved stopped, it boots the service out and stops what it left running, but only processes
55
- proved, by exact runtime path and arguments, to be that deployment's own; then it starts the previous service
56
- again, waits for it, and lifts the update pause. Anything it can't prove refuses. Safe to run again.
57
-
58
54
  toolroll release carry <release> --to <commit> [--request <id>] — carry a person's release approval to a later commit
59
55
 
60
56
  Only tests, docs and the changelog may change after the approval, within the limits, with an identical production
@@ -95,10 +91,6 @@ Options
95
91
  --new release a new commit on the branch, setting the earlier unfinished release aside
96
92
  --limit <step>=<min> change one step's time limit (${Object.entries(RELEASE_LIMITS).map(([step, minutes]) => `${step} ${minutes}`).join(", ")})
97
93
  --tap <owner/name> the Homebrew tap (default ap9000/homebrew-toolroll)
98
- --release-gate <id> check (and with --yes, lift) one update pause left before a deployment's swap
99
- --yes with --release-gate: lift the pause once it is proved safe; with --recover-deploy: recover
100
- --recover-deploy recover the unfinished deployment (see above)
101
- --stage <dir> with --recover-deploy: the deployment's staging folder, when more than one is unfinished
102
94
  --json answer with one machine envelope
103
95
 
104
96
  A runner's lease is returned with \`toolroll worker release <lease>\`.`;
@@ -233,6 +225,9 @@ export async function runReleaseCommand(args, write, deps = {}) {
233
225
  return refuse("usage", "`toolroll release status [<task>]` takes only --json.", 2);
234
226
  return runStatusCommand(positional[1], write, json, deps);
235
227
  }
228
+ // `release approve` is the toolroll command's own (cli.ts routes it to the operate commands); never a branch here.
229
+ if (!named && positional[0] === "approve")
230
+ return refuse("usage", "Run `toolroll release approve [<branch>]`. A branch named approve is released with `--branch approve`.", 2);
236
231
  if (!named && positional[0] === "nightly" && positional.length === 1)
237
232
  return runNightlyCommand({ repo: repo === undefined ? undefined : resolve(deps.cwd ?? process.cwd(), repo), as, token, schedule: scheduleFlag, start: bare.has("--start") }, write, json, deps);
238
233
  if (!named && ["carry", "revoke", "ask-every-commit", "unsandboxed-carry", "auto-approve", "publish-ok"].includes(positional[0] ?? "") && positional.length === 2) {
@@ -262,8 +257,23 @@ export async function runReleaseCommand(args, write, deps = {}) {
262
257
  const adapters = deps.adapters === undefined
263
258
  ? releaseAdapters({ checkout, say, ...(tap === undefined ? {} : { tap }), ...(limits["deploy"] === undefined ? {} : { deployMinutes: limits["deploy"] }) })
264
259
  : { ...deps.adapters, say };
265
- const outcome = await runRelease({ branch, checkout, stateDir, limits, fresh: bare.has("--new"), noCarry: bare.has("--no-carry") }, adapters);
266
- return report(outcome, write, json, lines);
260
+ // One command: wait out another run of this release, settle what a stopped deployment left, then go on from the journal.
261
+ const taken = await takeReleaseRun(stateDir, branch, say, deps.runLock);
262
+ if (!taken.ok)
263
+ return refuse("running", `Another release of ${branch} is still running on ${taken.holder.host} (process ${taken.holder.pid}). Nothing was changed.`, 3);
264
+ try {
265
+ const world = deps.settle ?? (deps.adapters === undefined ? { rescue: installedRescueWorld(checkout), gate: installedGateWorld() } : null);
266
+ if (world !== null) {
267
+ const settled = await settleDeployment(world, say);
268
+ if (!settled.ok)
269
+ return refuse("deployment", settled.message, 3);
270
+ }
271
+ const outcome = await runRelease({ branch, checkout, stateDir, limits, fresh: bare.has("--new"), noCarry: bare.has("--no-carry") }, adapters);
272
+ return report(outcome, write, json, lines);
273
+ }
274
+ finally {
275
+ taken.release();
276
+ }
267
277
  }
268
278
  function report(outcome, write, json, lines) {
269
279
  const release = outcome.journal === null ? null : {
@@ -582,10 +592,15 @@ async function runStatusCommand(task, write, json, deps) {
582
592
  }
583
593
  const word = { running: "running", waiting: "waiting", done: "done", failed: "failed" };
584
594
  write(`Release ${progress.version} (${progress.tag})${progress.pr === null ? "" : ` · pull request #${progress.pr}`} · task ${taskId}`);
585
- for (const one of steps)
586
- write(` ${one.step.padEnd(9)} ${word[one.state].padEnd(8)} ${one.seconds === null ? "–" : dur(one.seconds)}${one.detail === null ? "" : ` ${one.detail}`}`);
595
+ for (const one of steps) {
596
+ // A failure the release has gone on from is history, as on the task page.
597
+ const passed = one.state === "failed" && progress.stopped === null;
598
+ write(` ${one.step.padEnd(9)} ${(passed ? "went on" : word[one.state]).padEnd(8)} ${one.seconds === null ? "–" : dur(one.seconds)}${one.detail === null ? "" : ` ${one.detail}`}`);
599
+ }
587
600
  if (progress.stopped !== null)
588
601
  write(`Stopped at ${progress.stopped.step}: ${progress.stopped.message}`);
602
+ if (progress.building !== null)
603
+ write(`Build #${progress.building.run} is running the check.`);
589
604
  return 0;
590
605
  }
591
606
  finally {
@@ -26,7 +26,7 @@ export const gitIsAncestor = (repo, ancestor, descendant) => {
26
26
  let answer;
27
27
  try {
28
28
  // Every git version through a frozen read view, bounded (eval-harness review 1, finding 7).
29
- const status = viewedGitSync(repo, ["merge-base", "--is-ancestor", ancestor, descendant], { timeoutMs: 5_000 }).status;
29
+ const status = viewedGitSync(repo, ["merge-base", "--is-ancestor", ancestor, descendant], { timeoutMs: 5_000, binding: { repo } }).status;
30
30
  if (status !== 0)
31
31
  throw Object.assign(new Error("git merge-base"), { status });
32
32
  answer = true;
@@ -0,0 +1,48 @@
1
+ import { type RescueWorld } from "./deploy-rescue.js";
2
+ import { type GateReleaseWorld } from "./release-gate.js";
3
+ import { type ReleaseJournal } from "./release.js";
4
+ import type { Store } from "./store.js";
5
+ /** How long a run waits for another before it says so and stops. */
6
+ export declare const RELEASE_WAIT_MS: number;
7
+ export type RunHolder = {
8
+ host: string;
9
+ pid: number;
10
+ };
11
+ export type RunLockSeams = {
12
+ host?: string;
13
+ pid?: number;
14
+ alive?: (pid: number) => boolean;
15
+ sleep?: (ms: number) => Promise<void>;
16
+ now?: () => number;
17
+ waitMs?: number;
18
+ pollMs?: number;
19
+ };
20
+ /** Take this branch's run lock, waiting for a live holder. Answers how to give it back, or who kept it. */
21
+ export declare function takeReleaseRun(stateDir: string, branch: string, say: (line: string) => void, seams?: RunLockSeams): Promise<{
22
+ ok: true;
23
+ release: () => void;
24
+ } | {
25
+ ok: false;
26
+ holder: RunHolder;
27
+ }>;
28
+ export type SettleWorld = {
29
+ rescue: RescueWorld;
30
+ gate: GateReleaseWorld;
31
+ };
32
+ export type DeployAction = "none" | "lift-pause" | "forward" | "roll-back" | "refuse";
33
+ /** The one action a deployment's journal calls for. */
34
+ export declare function deployActionOf(phase: unknown): DeployAction;
35
+ /** Settle what a stopped deployment left, saying what was done. A refusal stops the release before it does anything. */
36
+ export declare function settleDeployment(world: SettleWorld, say: (line: string) => void): Promise<{
37
+ ok: true;
38
+ } | {
39
+ ok: false;
40
+ message: string;
41
+ }>;
42
+ export declare const RELEASE_RESUMED_ACTION = "release resumed";
43
+ /**
44
+ * A person let a held release go on (release-auto.ts allowAutoPublish; the Release sheet's button or `publish-ok`): each
45
+ * sweep, its journal goes on from where it stopped, once per that person's act, under the same run lock. It was merged
46
+ * already, so only the tag, publication and Homebrew are left, and each re-proves itself.
47
+ */
48
+ export declare function resumeAllowedReleases(store: Store, stateDir: string, run: (journal: ReleaseJournal) => Promise<unknown>, now: Date): Promise<number>;