toolroll 0.9.72 → 0.9.74

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 (137) hide show
  1. package/CHANGELOG.md +108 -0
  2. package/THIRD_PARTY_NOTICES.md +40 -0
  3. package/dist/agent-fence.d.ts +5 -0
  4. package/dist/agent-fence.js +42 -7
  5. package/dist/agent-turns.js +5 -0
  6. package/dist/agents-live.d.ts +31 -0
  7. package/dist/agents-live.js +43 -0
  8. package/dist/browser/THIRD_PARTY_NOTICES.txt +40 -0
  9. package/dist/browser/workspace.css +2 -2
  10. package/dist/browser/workspace.js +31 -30
  11. package/dist/browser-workspace.d.ts +6 -0
  12. package/dist/builder.js +1 -1
  13. package/dist/chat-actions.js +13 -8
  14. package/dist/chat-continuity.js +10 -0
  15. package/dist/chat-decide.d.ts +47 -1
  16. package/dist/chat-decide.js +170 -4
  17. package/dist/chat-quiet.js +3 -3
  18. package/dist/check-report.js +2 -1
  19. package/dist/coding-provider.js +4 -2
  20. package/dist/commit-extract.d.ts +17 -0
  21. package/dist/commit-extract.js +82 -4
  22. package/dist/contracts/console-api.d.ts +3 -3
  23. package/dist/contracts/console-api.js +4 -4
  24. package/dist/contracts/release-terms.d.ts +7 -1
  25. package/dist/contracts/release-terms.js +6 -2
  26. package/dist/deploy-gate.js +5 -2
  27. package/dist/desktop-update.d.ts +1 -1
  28. package/dist/dispose.js +35 -2
  29. package/dist/exec.d.ts +1 -1
  30. package/dist/exec.js +20 -19
  31. package/dist/first-run.d.ts +17 -1
  32. package/dist/first-run.js +38 -0
  33. package/dist/flow-gallery-ui.js +4 -2
  34. package/dist/flow-gallery.d.ts +3 -0
  35. package/dist/flow-gallery.js +6 -2
  36. package/dist/flow-triggers.d.ts +8 -0
  37. package/dist/flow-triggers.js +15 -0
  38. package/dist/flows-cli.js +13 -3
  39. package/dist/guides.js +10 -1
  40. package/dist/invoke.d.ts +13 -0
  41. package/dist/invoke.js +21 -12
  42. package/dist/kits-ui.js +4 -2
  43. package/dist/kits.d.ts +6 -4
  44. package/dist/kits.js +9 -11
  45. package/dist/knowledge-ui.d.ts +4 -1
  46. package/dist/knowledge-ui.js +10 -5
  47. package/dist/lead-progress.d.ts +4 -1
  48. package/dist/lead-progress.js +3 -1
  49. package/dist/lead.js +4 -0
  50. package/dist/live-bus.d.ts +2 -0
  51. package/dist/live-bus.js +1 -0
  52. package/dist/live-children-read.d.ts +52 -0
  53. package/dist/live-children-read.js +333 -0
  54. package/dist/live-children.d.ts +73 -0
  55. package/dist/live-children.js +318 -0
  56. package/dist/live-detail.d.ts +4 -3
  57. package/dist/live-detail.js +9 -5
  58. package/dist/live-input.js +7 -8
  59. package/dist/live-main-read.d.ts +19 -0
  60. package/dist/live-main-read.js +91 -0
  61. package/dist/live-open.d.ts +28 -0
  62. package/dist/live-open.js +125 -0
  63. package/dist/live-records.d.ts +10 -1
  64. package/dist/live-records.js +16 -5
  65. package/dist/live.d.ts +54 -22
  66. package/dist/live.js +218 -69
  67. package/dist/mcp-connect.d.ts +10 -0
  68. package/dist/mcp-connect.js +2 -0
  69. package/dist/operate.js +3 -2
  70. package/dist/peek-cli.d.ts +10 -3
  71. package/dist/peek-cli.js +17 -8
  72. package/dist/planner.js +1 -1
  73. package/dist/project-tools.js +3 -1
  74. package/dist/protected-storage.d.ts +67 -0
  75. package/dist/protected-storage.js +398 -0
  76. package/dist/provider-limits.d.ts +3 -1
  77. package/dist/provider-limits.js +5 -2
  78. package/dist/provider.js +6 -12
  79. package/dist/release-adapters.d.ts +8 -1
  80. package/dist/release-adapters.js +84 -14
  81. package/dist/release-authority.d.ts +6 -0
  82. package/dist/release-authority.js +9 -0
  83. package/dist/release-card.d.ts +76 -0
  84. package/dist/release-card.js +252 -0
  85. package/dist/release-carry.d.ts +3 -0
  86. package/dist/release-carry.js +23 -4
  87. package/dist/release-cli.js +36 -3
  88. package/dist/release-git.d.ts +43 -0
  89. package/dist/release-git.js +106 -0
  90. package/dist/release.d.ts +11 -2
  91. package/dist/release.js +82 -6
  92. package/dist/repository-context-ui.js +2 -1
  93. package/dist/run-efficiency.d.ts +3 -3
  94. package/dist/run-efficiency.js +20 -8
  95. package/dist/scope.d.ts +7 -0
  96. package/dist/scope.js +22 -5
  97. package/dist/scout.d.ts +3 -0
  98. package/dist/scout.js +5 -3
  99. package/dist/serve.js +34 -23
  100. package/dist/server/agents-live-lead.d.ts +47 -0
  101. package/dist/server/agents-live-lead.js +116 -0
  102. package/dist/server/agents-live-room.d.ts +23 -0
  103. package/dist/server/agents-live-room.js +88 -0
  104. package/dist/server/chat.js +84 -30
  105. package/dist/server/chrome.js +2 -2
  106. package/dist/server/console.css +68 -40
  107. package/dist/server/flows.js +40 -10
  108. package/dist/server/live.d.ts +4 -0
  109. package/dist/server/live.js +42 -2
  110. package/dist/server/pages.js +81 -1
  111. package/dist/server/render-chat.d.ts +15 -1
  112. package/dist/server/render-chat.js +14 -7
  113. package/dist/server/render-pages.d.ts +8 -4
  114. package/dist/server/render-pages.js +55 -50
  115. package/dist/server/render-settings.js +1 -1
  116. package/dist/server/render-tasks.js +10 -3
  117. package/dist/server/route-table.js +5 -1
  118. package/dist/server/runtime.d.ts +1 -1
  119. package/dist/server/settings.js +29 -8
  120. package/dist/server/task-views.js +1 -1
  121. package/dist/server/tasks.js +59 -41
  122. package/dist/setup-guide.d.ts +2 -2
  123. package/dist/store.d.ts +34 -1
  124. package/dist/store.js +81 -2
  125. package/dist/stream-scope.d.ts +18 -0
  126. package/dist/stream-scope.js +28 -0
  127. package/dist/subagent-memory.d.ts +1 -0
  128. package/dist/subagent-memory.js +1 -1
  129. package/dist/subagent-tools.d.ts +22 -6
  130. package/dist/subagent-tools.js +44 -8
  131. package/dist/subagent-work.d.ts +2 -0
  132. package/dist/subagent-work.js +5 -2
  133. package/dist/subagents-ui.js +6 -4
  134. package/dist/subscription-chat.js +5 -3
  135. package/dist/telegram.d.ts +6 -1
  136. package/dist/telegram.js +58 -6
  137. package/package.json +1 -1
@@ -24,6 +24,9 @@ import { proveCarry } from "./release-carry.js";
24
24
  import { randomBytes, randomUUID } from "node:crypto";
25
25
  import { deploymentGate } from "./deploy-gate.js";
26
26
  import { extractCommit } from "./commit-extract.js";
27
+ import { checkoutIncludes } from "./teardown-git.js";
28
+ import { RELEASE_REMOTE, releaseConfigProblem, releaseGitArgs, releaseGitEnv, releaseRemote, releaseTools } from "./release-git.js";
29
+ export { RELEASE_PATH, RELEASE_REMOTE, releaseConfigProblem, releaseGitArgs, releaseGitEnv, releaseRemote, releaseTools } from "./release-git.js";
27
30
  /** A child process, its output kept whole; a missing program or a time limit is exit 127 or 124, never a throw. */
28
31
  export const exec = (command, args, options = {}) => new Promise(done => {
29
32
  const child = execFile(command, [...args], { cwd: options.cwd, env: options.env ?? process.env, timeout: options.timeoutMs ?? 120_000, maxBuffer: 64 * 1024 * 1024, encoding: "utf8" }, (error, stdout, stderr) => {
@@ -112,6 +115,9 @@ export function gateStateOf(answer, accounts = []) {
112
115
  const failed = newest !== undefined && newest["finishedAt"] != null && newest["outcome"] !== "built" && newest["outcome"] !== "no-change" && Number(newest["id"]) !== resultRun
113
116
  ? `run ${String(newest["id"])} ended ${String(newest["outcome"] ?? "without an outcome")}` : null;
114
117
  const worktree = runs.find(run => run["id"] === resultRun)?.["worktree"];
118
+ // Queued again or still building past the result shown (a regate): that result is the one before the rerun.
119
+ const state = object(answer["task"])["state"];
120
+ const pending = resultRun !== null && (state === "queued" || state === "running" || (newest !== undefined && newest["finishedAt"] == null && Number(newest["id"]) > resultRun));
115
121
  return {
116
122
  exists: true,
117
123
  candidate: textOr(scope["candidate"]),
@@ -128,6 +134,7 @@ export function gateStateOf(answer, accounts = []) {
128
134
  : checks["status"] === "passed" && checks["level"] !== "full" ? "not-full" : String(checks["status"] ?? "not-run"),
129
135
  },
130
136
  failed,
137
+ pending,
131
138
  completion: textOr(completion["digest"]) === null ? null : { actor: String(completion["actor"]), at: String(completion["at"]), digest: String(completion["digest"]) },
132
139
  };
133
140
  }
@@ -162,6 +169,48 @@ export function stageGate(stage, owner) {
162
169
  const id = textOr(journal["id"]), phase = textOr(journal["phase"]);
163
170
  return id !== null && phase !== null && BEFORE_SWAP.includes(phase) && owner() === id ? { id, phase } : null;
164
171
  }
172
+ /** The recorded repository for a gate checkout, from the plane's database; throws in plain words when there is none. */
173
+ export function recordedReleaseRepo(database, checkout) {
174
+ const real = (path) => { try {
175
+ return realpathSync(path);
176
+ }
177
+ catch {
178
+ return null;
179
+ } };
180
+ const at = real(checkout);
181
+ if (at === null)
182
+ throw new Error(`${checkout} isn't there.`);
183
+ if (!existsSync(database))
184
+ throw new Error("The plane's database isn't here, so the release's repository can't be read from its records.");
185
+ const db = new (sqlite().DatabaseSync)(database, { readOnly: true });
186
+ let project = null, remote = null;
187
+ try {
188
+ const store = new Store(db);
189
+ project = store.listProjects().map(one => one.path).find(path => real(path) === at) ?? null;
190
+ remote = project === null ? null : store.projectRemote(project);
191
+ }
192
+ finally {
193
+ db.close();
194
+ }
195
+ if (project === null)
196
+ throw new Error(`${checkout} isn't one of the projects Toolroll records, so it can't be released from here.`);
197
+ if (remote === null)
198
+ throw new Error(`${project} has no recorded remote. Record it with \`toolroll repos add ${project} --remote <url>\` first.`);
199
+ const parsed = releaseRemote(remote);
200
+ if (parsed === null)
201
+ throw new Error(`${project}'s recorded remote (${remote}) isn't a GitHub repository over https or ssh.`);
202
+ const named = join(at, ".git");
203
+ let gitDir;
204
+ try {
205
+ if (!lstatSync(named).isDirectory())
206
+ throw new Error("not a folder");
207
+ gitDir = realpathSync(named);
208
+ }
209
+ catch {
210
+ throw new Error(`${project}'s Git directory isn't a plain folder, so the release won't read it.`);
211
+ }
212
+ return { checkout: at, gitDir, remote, ...parsed };
213
+ }
165
214
  /** How many times, and how far apart, a merged branch's deletion is looked for after the push lost a race. */
166
215
  export const BRANCH_GONE_LOOKS = 5;
167
216
  export const BRANCH_GONE_PAUSE_MS = 2_000;
@@ -171,9 +220,11 @@ export function releaseAdapters(options) {
171
220
  const { checkout } = options;
172
221
  const tap = options.tap ?? "ap9000/homebrew-toolroll";
173
222
  const formula = options.formula ?? "Formula/toolroll.rb";
174
- const git = (...args) => run("git", ["-C", checkout, ...args]);
175
223
  const pause = options.sleep ?? ((ms) => new Promise(done => setTimeout(done, ms)));
176
- const gh = (...args) => run("gh", args);
224
+ const tools = options.tools ?? releaseTools();
225
+ const gitEnv = releaseGitEnv();
226
+ // Never a PATH lookup or the checkout's Git config: gh by its absolute path, from a neutral folder, naming the repo.
227
+ const gh = (...args) => run(tools.gh, args, { cwd: "/", env: gitEnv });
177
228
  const ghJson = async (what, ...args) => JSON.parse(await must(gh(...args), what));
178
229
  const toolroll = async (...args) => {
179
230
  const result = await run("toolroll", [...args, "--json"], { env: cleanEnv(), timeoutMs: 120_000 });
@@ -186,6 +237,29 @@ export function releaseAdapters(options) {
186
237
  };
187
238
  const refused = (answer) => `${String(answer["message"] ?? answer["reason"] ?? "refused")}`;
188
239
  const database = options.database ?? databasePath(process.env, homedir());
240
+ let recorded = options.releaseRepo ?? null;
241
+ /** The recorded repository, read once (chat-management.md §0a.2). */
242
+ const releaseRepo = () => recorded ??= recordedReleaseRepo(database, checkout);
243
+ /**
244
+ * The coordinator's one way to run Git (§0a.2): on the recorded Git directory, hardened, with an allowlisted
245
+ * environment and the absolute Git. Before every command the checkout's own config is read with includes off and
246
+ * as plain files; one that includes a file, names a command or redirects a remote stops the release, naming it.
247
+ */
248
+ const git = async (...args) => {
249
+ const repo = releaseRepo();
250
+ const stop = (why) => new Error(`Toolroll won't run Git in ${repo.checkout} for a release: ${why}. Remove it from the project's Git config, then rerun.`);
251
+ if (options.releaseRepo === undefined && checkoutIncludes(repo.checkout))
252
+ throw stop("its Git config includes another file, or couldn't be read as a plain file");
253
+ const listing = await run(tools.git, [`--git-dir=${repo.gitDir}`, "--no-optional-locks", "config", "--list", "--no-includes", "--show-scope", "-z"], { cwd: "/", env: gitEnv });
254
+ if (listing.code !== 0)
255
+ throw stop("its Git config couldn't be read");
256
+ const setting = releaseConfigProblem(listing.stdout);
257
+ if (setting !== null)
258
+ throw stop(`its Git config sets ${setting}, which a release doesn't run with (it could run a command, or change where Git connects or what it sends)`);
259
+ return run(tools.git, [...releaseGitArgs(repo, tools), ...args], { cwd: repo.checkout, env: gitEnv });
260
+ };
261
+ /** The remote every fetch, look and push names: the recorded URL, defined only on the command line. */
262
+ const remoteUrl = () => RELEASE_REMOTE;
189
263
  const liveOwner = () => {
190
264
  if (!existsSync(database))
191
265
  return null;
@@ -293,16 +367,12 @@ export function releaseAdapters(options) {
293
367
  sleep: options.sleep ?? sleep,
294
368
  say: options.say ?? (line => process.stdout.write(`${line}\n`)),
295
369
  repo: {
296
- async github() {
297
- const slug = githubSlug(await must(git("remote", "get-url", "origin"), "git remote get-url origin"));
298
- if (slug === null)
299
- throw new Error(`${checkout}'s origin is not a GitHub repository`);
300
- return slug;
301
- },
370
+ // The recorded owner/name, never the checkout's origin.
371
+ async github() { return releaseRepo().slug; },
302
372
  async sync(branch) {
303
- await must(git("fetch", "--quiet", "origin", "+refs/heads/main:refs/remotes/origin/main"), "git fetch origin main");
373
+ await must(git("fetch", "--quiet", "--no-tags", remoteUrl(), "+refs/heads/main:refs/remotes/origin/main"), "git fetch main");
304
374
  // A merged branch may be gone from GitHub; its commit is already here.
305
- await git("fetch", "--quiet", "origin", `+refs/heads/${branch}:refs/remotes/origin/${branch}`);
375
+ await git("fetch", "--quiet", "--no-tags", remoteUrl(), `+refs/heads/${branch}:refs/remotes/origin/${branch}`);
306
376
  const current = (await git("symbolic-ref", "--quiet", "--short", "HEAD")).stdout.trim();
307
377
  if (current === "main") {
308
378
  await must(git("merge", "--ff-only", "--quiet", "origin/main"), "git merge --ff-only origin/main");
@@ -316,17 +386,17 @@ export function releaseAdapters(options) {
316
386
  throw new Error(`${checkout}'s local main has commits origin/main doesn't; the gate diffs from it, so fix it first`);
317
387
  },
318
388
  async remoteHead(branch) {
319
- const out = await must(git("ls-remote", "origin", `refs/heads/${branch}`), "git ls-remote");
389
+ const out = await must(git("ls-remote", remoteUrl(), `refs/heads/${branch}`), "git ls-remote");
320
390
  return out.trim().split(/\s+/)[0] || null;
321
391
  },
322
392
  async deleteBranch(branch, head) {
323
393
  const ref = `refs/heads/${branch}`;
324
- const current = (await must(git("ls-remote", "origin", ref), "git ls-remote")).trim().split(/\s+/)[0] || null;
394
+ const current = (await must(git("ls-remote", remoteUrl(), ref), "git ls-remote")).trim().split(/\s+/)[0] || null;
325
395
  if (current === null)
326
396
  return;
327
397
  if (current !== head)
328
398
  throw new Error(`${branch} moved after merging; its new head was not deleted.`);
329
- const pushed = await git("push", `--force-with-lease=${ref}:${head}`, "origin", `:${ref}`);
399
+ const pushed = await git("push", "--no-verify", `--force-with-lease=${ref}:${head}`, remoteUrl(), `:${ref}`);
330
400
  if (pushed.code === 0)
331
401
  return;
332
402
  // GitHub may delete a merged branch itself between the look and the push; gone is what was wanted. Its refs can
@@ -334,7 +404,7 @@ export function releaseAdapters(options) {
334
404
  for (let look = 0; look < BRANCH_GONE_LOOKS; look++) {
335
405
  if (look > 0)
336
406
  await pause(BRANCH_GONE_PAUSE_MS);
337
- const after = (await must(git("ls-remote", "origin", ref), "git ls-remote")).trim().split(/\s+/)[0] || null;
407
+ const after = (await must(git("ls-remote", remoteUrl(), ref), "git ls-remote")).trim().split(/\s+/)[0] || null;
338
408
  if (after === null)
339
409
  return;
340
410
  }
@@ -98,6 +98,12 @@ export declare function releaseAuthorityOfTask(store: Store, taskId: string): {
98
98
  carry: ReleaseCarry | null;
99
99
  } | null;
100
100
  /** Whether a task belongs to any release authority (anchor or carried), from the records alone. */
101
+ /** "Carry release approvals when checks aren't sandboxed": a project setting, newest choice wins (a ledger row each);
102
+ * off by default. Turning it on is a person's own proven act (release-cli.ts); turning it off is anyone's. */
103
+ export declare const UNSANDBOXED_CARRY_ACTION = "release unsandboxed carrying changed";
104
+ export declare function carriesUnsandboxed(store: Store, repo: string): boolean;
105
+ /** How far a check reaches, narrowest first. */
106
+ export declare const CHECK_ACCESS_WIDTH: Readonly<Record<string, number>>;
101
107
  export declare function belongsToRelease(store: Store, taskId: string): boolean;
102
108
  export declare function releaseCarries(store: Store, authority: number): ReleaseCarry[];
103
109
  export declare function releaseCarryTo(store: Store, authority: number, toSha: string): ReleaseCarry | null;
@@ -86,6 +86,15 @@ export function releaseAuthorityOfTask(store, taskId) {
86
86
  return row === undefined ? null : { authority: readReleaseAuthority(row), carry: null };
87
87
  }
88
88
  /** Whether a task belongs to any release authority (anchor or carried), from the records alone. */
89
+ /** "Carry release approvals when checks aren't sandboxed": a project setting, newest choice wins (a ledger row each);
90
+ * off by default. Turning it on is a person's own proven act (release-cli.ts); turning it off is anyone's. */
91
+ export const UNSANDBOXED_CARRY_ACTION = "release unsandboxed carrying changed";
92
+ export function carriesUnsandboxed(store, repo) {
93
+ const row = store.handle.prepare("SELECT outcome FROM action_ledger WHERE repo = ? AND action = ? ORDER BY id DESC LIMIT 1").get(repo, UNSANDBOXED_CARRY_ACTION);
94
+ return row !== undefined && row["outcome"] === "on";
95
+ }
96
+ /** How far a check reaches, narrowest first. */
97
+ export const CHECK_ACCESS_WIDTH = Object.freeze({ sandboxed: 0, "sign-ins": 1, unsandboxed: 2 });
89
98
  export function belongsToRelease(store, taskId) {
90
99
  return store.handle.prepare("SELECT 1 AS hit FROM release_authority WHERE anchor_task = ? UNION ALL SELECT 1 FROM release_carry WHERE task_id = ? LIMIT 1").get(taskId, taskId) !== undefined;
91
100
  }
@@ -0,0 +1,76 @@
1
+ import { type ReleaseTerms } from "./contracts/release-terms.js";
2
+ import type { Scope } from "./scope.js";
3
+ import type { Store } from "./store.js";
4
+ /** One signed term as the card says it. `key` names the scope field or the release term's path. */
5
+ export type TermLine = {
6
+ key: string;
7
+ label: string;
8
+ value: string;
9
+ };
10
+ /** The scope's own terms the card names, in order. A contract test holds `goal`, `outOfScope`, `touches` and
11
+ * `acceptance` (contracts/scope.ts) and the scope's other signed fields to this list; the agent profile and the phase
12
+ * route follow as one line per signed setting (`profile.…`, `route.…`). */
13
+ export declare const SCOPE_TERM_LABELS: {
14
+ readonly title: "Title";
15
+ readonly project: "Project";
16
+ readonly filedBy: "Filed by";
17
+ readonly goal: "Goal";
18
+ readonly outOfScope: "Not in scope";
19
+ readonly touches: "Paths it may touch";
20
+ readonly acceptance: "Done when";
21
+ readonly qualityMode: "Evidence standard";
22
+ readonly agents: "Agents and model";
23
+ readonly permission: "Agents may";
24
+ readonly checks: "Checks";
25
+ readonly checkAccess: "Check access";
26
+ readonly budgetMicrousd: "Budget per attempt";
27
+ readonly candidate: "Candidate commit";
28
+ };
29
+ /** Every field of the release terms, by its path in the terms (contracts/release-terms.ts), against the schema's
30
+ * own leaves: the contract test checks every one has a label. */
31
+ export declare const RELEASE_TERM_LABELS: Readonly<Record<string, string>>;
32
+ /** The leaf paths of a zod object schema (nested objects joined with dots). */
33
+ export declare function schemaLeaves(schema: unknown, prefix?: string): string[];
34
+ /** Every signed term of `scope`, its release terms included, one line each. Reads only. */
35
+ export declare function approvalTermsOf(store: Store, scope: Scope, words: {
36
+ profile: (profile: Scope["profile"]) => string | null;
37
+ }): TermLine[];
38
+ export declare const termsText: (lines: readonly TermLine[]) => string;
39
+ export type ChangeList = {
40
+ /** The merge commit of the last verified release, or null when none is recorded. */
41
+ baseline: string | null;
42
+ /** Newest first; null when it couldn't be read. */
43
+ commits: {
44
+ sha: string;
45
+ subject: string;
46
+ }[] | null;
47
+ source: "recorded" | "pull-request" | "unread";
48
+ digest: string;
49
+ };
50
+ /** The plane's own record of the last verified release of `repo`: the merge commit GitHub answered with, recorded once
51
+ * the release verified the merged tree (the newest merged authority whose merge operation succeeded). Never a tag. */
52
+ export declare function releaseBaseline(store: Store, repo: string): string | null;
53
+ /** The commits after `from` up to `to`, as verified Git objects only, from the project's recorded Git directory: the
54
+ * release coordinator's checkout config allowlist first, then commit-extract's verified range (every commit hashed here,
55
+ * a shallow boundary, grafts or replacement refs refused, and the range proved to stop only in the last release's own
56
+ * history). Null when they can't be read, so a card never shows a shortened list. */
57
+ export declare function commitsBetween(repo: string, from: string, to: string): {
58
+ sha: string;
59
+ subject: string;
60
+ }[] | null;
61
+ /** What changed since the last verified release, for a release's terms, from Toolroll's records only. */
62
+ export declare function releaseChangeList(store: Store, terms: ReleaseTerms): ChangeList;
63
+ export type ReleaseCard = {
64
+ /** Binds the scope digest, the plan form's digest, the baseline and the change list. */
65
+ digest: string;
66
+ short: string;
67
+ full: string;
68
+ fileName: string;
69
+ changes: ChangeList;
70
+ };
71
+ /** The card digest: everything the person is shown that can change under them. */
72
+ export declare function cardDigestOf(scopeDigest: string, formDigest: string, changes: Pick<ChangeList, "baseline" | "digest">): string;
73
+ /** The release card for a deploy gate, against the live records now. */
74
+ export declare function releaseCardOf(store: Store, scope: Scope, formDigest: string, words: {
75
+ profile: (profile: Scope["profile"]) => string | null;
76
+ }): ReleaseCard;
@@ -0,0 +1,252 @@
1
+ /**
2
+ * The release card (docs/plans/chat-management.md §0a.5): how a deploy gate is approved from chat until the
3
+ * authenticator ships. Alex's decision (2026-10-25): chat keeps approving releases by tap, so the tap is made to mean
4
+ * exactly what was shown.
5
+ *
6
+ * - `approvalTermsOf`: every signed term of the scope (its agent profile and phase route setting by setting) and every
7
+ * field of its release terms, one line each, "none" for an empty one and "any file in the project" for unnamed paths
8
+ * (a contract test changes each input of the approval digest in turn and holds the text to change with it).
9
+ * - `releaseChangeList`: what changed since the last verified release, from the plane's own record of it (the merge
10
+ * commit of the newest merged release whose merge operation succeeded), read as verified Git objects from the
11
+ * project's recorded Git directory: never a tag or a ref, so moving one changes nothing, and history rewritten around
12
+ * the objects (a shallow boundary, grafts, replacement refs) refuses the list rather than shortening it.
13
+ * With no earlier release on record, the pull request's own commits from GitHub, for the recorded owner/name.
14
+ * - `releaseCardOf`: the short card, the full terms (sent as a file where the chat app limits a message), and
15
+ * `cardDigest`, which binds the scope digest, the plan form's digest, the baseline and the change list: Continue and
16
+ * Approve carry it, and anything different at the tap refuses.
17
+ */
18
+ import { spawnSync } from "node:child_process";
19
+ import { createHash } from "node:crypto";
20
+ import { lstatSync, realpathSync } from "node:fs";
21
+ import { join } from "node:path";
22
+ import { checkAccessWords } from "./check-access.js";
23
+ import { CHECK_LEVEL_WORDS, effectiveCheckLevel } from "./check-levels.js";
24
+ import { verifiedRange } from "./commit-extract.js";
25
+ import { releaseTermsSchema } from "./contracts/release-terms.js";
26
+ import { routeFromJson } from "./phase-routing.js";
27
+ import { projectName } from "./project.js";
28
+ import { RELEASE_PATH, releaseConfigProblem, releaseGitEnv, releaseRemote, releaseTools } from "./release-git.js";
29
+ const NONE = "none";
30
+ /** A budget in dollars from integer micro-dollars, in integer arithmetic: cents when it is whole cents, else every digit. */
31
+ const money = (micro) => {
32
+ const exact = BigInt(micro), whole = exact / 1000000n, rest = (exact < 0n ? -exact : exact) % 1000000n;
33
+ const fraction = rest.toString().padStart(6, "0");
34
+ return `${exact < 0n && whole === 0n ? "-" : ""}$${whole}.${rest % 10000n === 0n ? fraction.slice(0, 2) : fraction}`;
35
+ };
36
+ /** Words the card itself says in place of a value: a signed string spelled the same is quoted, never mistaken for one. */
37
+ const RESERVED = new Set([NONE, "yes", "no", "null", "empty", "not set", "any file in the project", "the whole project", "unreadable"]);
38
+ /** Text that reads the same plain as quoted: ASCII letters, digits and ordinary punctuation only (no lookalike letters
39
+ * from other scripts, no direction marks), never a comma, semicolon, parenthesis, quote, backslash, line break or
40
+ * control character, nor a trailing space. */
41
+ const PLAIN = /^[A-Za-z0-9~/.#$@][A-Za-z0-9 ._/@~+#=!?&%$*:<>|^-]*$/;
42
+ /** JSON-quoted with everything outside printable ASCII \u-escaped (JSON.stringify leaves non-ASCII as it is). */
43
+ const quoted = (value) => JSON.stringify(value).replace(/[^\x20-\x7e]/g, one => `\\u${one.charCodeAt(0).toString(16).padStart(4, "0")}`);
44
+ /** One signed string, unambiguous: as it is when plain, else quoted; an empty, number-like or colon-bearing string, or
45
+ * one of the card's own words, is quoted too. */
46
+ const text = (value) => PLAIN.test(value) && !value.endsWith(" ") && !RESERVED.has(value.toLowerCase()) && !/^-?\d+(\.\d+)?$/.test(value) ? value : quoted(value);
47
+ /** One signed value as the card says it, every shape distinct: "none" for null, "not set" for absent, "empty" for an
48
+ * empty list, `""` for an empty string; a list's entries each said the same way, so a comma never joins two into one. */
49
+ const said = (value) => {
50
+ if (value === undefined)
51
+ return "not set";
52
+ if (value === null)
53
+ return NONE;
54
+ if (typeof value === "boolean")
55
+ return value ? "yes" : "no";
56
+ if (typeof value === "number")
57
+ return String(value);
58
+ if (Array.isArray(value))
59
+ return value.length === 0 ? "empty" : value.map(one => typeof one === "string" ? text(one) : one === null ? "null" : said(one)).join(", ");
60
+ return text(String(value));
61
+ };
62
+ /** The scope's own terms the card names, in order. A contract test holds `goal`, `outOfScope`, `touches` and
63
+ * `acceptance` (contracts/scope.ts) and the scope's other signed fields to this list; the agent profile and the phase
64
+ * route follow as one line per signed setting (`profile.…`, `route.…`). */
65
+ export const SCOPE_TERM_LABELS = {
66
+ title: "Title", project: "Project", filedBy: "Filed by", goal: "Goal", outOfScope: "Not in scope", touches: "Paths it may touch",
67
+ acceptance: "Done when", qualityMode: "Evidence standard", agents: "Agents and model", permission: "Agents may", checks: "Checks", checkAccess: "Check access",
68
+ budgetMicrousd: "Budget per attempt", candidate: "Candidate commit",
69
+ };
70
+ /** Every leaf of a signed value, at its path (`a.b`, `legs.0.model`), as the card says it. */
71
+ function leavesOf(value, path) {
72
+ if (Array.isArray(value))
73
+ return value.every(one => one === null || typeof one !== "object") ? [{ path, value: said(value) }]
74
+ : value.flatMap((one, index) => leavesOf(one, `${path}.${index}`));
75
+ if (value !== null && typeof value === "object") {
76
+ const keys = Object.keys(value).sort();
77
+ return keys.length === 0 ? [{ path, value: "no settings" }] : keys.flatMap(key => leavesOf(value[key], `${path}.${key}`));
78
+ }
79
+ return [{ path, value: said(value) }];
80
+ }
81
+ /** Every field of the release terms, by its path in the terms (contracts/release-terms.ts), against the schema's
82
+ * own leaves: the contract test checks every one has a label. */
83
+ export const RELEASE_TERM_LABELS = {
84
+ version: "Release terms version", repo: "Repository", packageVersion: "Package version", branch: "Branch", pr: "Pull request", base: "Base",
85
+ approvedSha: "Approved commit", approvedTree: "Approved tree",
86
+ "policy.version": "Carry policy version", "policy.rules": "Carry rules", "policy.carry": "Carry later commits",
87
+ "policy.protectProject": "Protected: the whole project", "policy.protectedPaths": "Protected paths",
88
+ "policy.limits.lines": "Carry limit: lines", "policy.limits.files": "Carry limit: files", "policy.limits.churn": "Carry limit: churn",
89
+ "policy.unsandboxedCarry": "Carry when checks aren't sandboxed",
90
+ "grant.id": "Release check grant", "grant.digest": "Release check grant digest", access: "Release check access", expiresAt: "Expires",
91
+ };
92
+ /** The leaf paths of a zod object schema (nested objects joined with dots). */
93
+ export function schemaLeaves(schema, prefix = "") {
94
+ const shape = schema.shape;
95
+ if (shape === undefined || shape === null)
96
+ return [prefix];
97
+ return Object.entries(shape).flatMap(([key, child]) => schemaLeaves(child, prefix === "" ? key : `${prefix}.${key}`));
98
+ }
99
+ const valueAt = (terms, path) => path.split(".").reduce((at, key) => at !== null && typeof at === "object" ? at[key] : undefined, terms);
100
+ /** Every signed term of `scope`, its release terms included, one line each. Reads only. */
101
+ export function approvalTermsOf(store, scope, words) {
102
+ const ref = store.lookupRef(scope.taskId);
103
+ const repo = ref?.repo ?? null;
104
+ const filer = store.taskFiler(scope.taskId);
105
+ const profile = scope.profile ?? null;
106
+ const scopeValues = {
107
+ title: said(store.getTask(scope.taskId)?.title),
108
+ project: repo === null ? NONE : `${text(projectName(repo))} (${text(repo)})`,
109
+ filedBy: filer === null ? NONE : filer.name === null ? filer.kind : `${text(filer.name)}${filer.kind === "person" ? "" : ` (${filer.kind})`}`,
110
+ goal: said(scope.goal),
111
+ // null (nothing excluded) and an empty exclusion are signed apart, and read apart.
112
+ outOfScope: said(scope.outOfScope),
113
+ // No paths named is no limit: the work may touch any file in the project.
114
+ touches: scope.touches.length === 0 ? "any file in the project" : said(scope.touches),
115
+ // Each criterion's fields quoted, always: a colon or a parenthesis in one never moves the boundary to the next.
116
+ acceptance: scope.acceptance.length === 0 ? NONE : scope.acceptance.map(one => `${quoted(one.id)}: ${quoted(one.statement)} (evidence: ${said(one.evidence)})`).join("; "),
117
+ qualityMode: scope.qualityMode === "strict" ? "strict" : "standard",
118
+ agents: profile === null ? NONE : `${profile.provider} · ${text(profile.model)}`,
119
+ permission: said(words.profile(profile)),
120
+ checks: CHECK_LEVEL_WORDS[effectiveCheckLevel(store, repo, scope.taskId).level],
121
+ checkAccess: checkAccessWords(profile?.checkAccess ?? "sandboxed"),
122
+ budgetMicrousd: scope.budgetMicrousd === null ? NONE : money(scope.budgetMicrousd),
123
+ candidate: said(scope.candidate),
124
+ };
125
+ const lines = Object.keys(SCOPE_TERM_LABELS).map(key => ({ key, label: SCOPE_TERM_LABELS[key], value: scopeValues[key] }));
126
+ // Every signed setting of the agent profile (what it may read, reach and write, its limits) and the phase route the
127
+ // digest binds, one line each: the summary lines above never stand in for them.
128
+ if (profile !== null)
129
+ for (const leaf of leavesOf(profile, "profile"))
130
+ lines.push({ key: leaf.path, label: `Agent setting ${leaf.path.slice("profile.".length)}`, value: leaf.value });
131
+ if (scope.proposedRouteJson != null) {
132
+ const route = routeFromJson(scope.proposedRouteJson);
133
+ // Unreadable (it then never seals): its stored text, exactly, so two different ones never read alike.
134
+ if (route === null)
135
+ lines.push({ key: "route", label: "Phase route (unreadable)", value: quoted(scope.proposedRouteJson) });
136
+ else
137
+ for (const leaf of leavesOf(route, "route"))
138
+ lines.push({ key: leaf.path, label: `Phase route ${leaf.path.slice("route.".length)}`, value: leaf.value });
139
+ }
140
+ const terms = scope.releaseTerms ?? null;
141
+ for (const path of schemaLeaves(releaseTermsSchema)) {
142
+ const label = RELEASE_TERM_LABELS[path] ?? path;
143
+ const value = terms === null ? NONE : path === "pr" ? `#${terms.pr}`
144
+ // The whole project is protected, and the signed list is said too (it is part of what was signed).
145
+ : path === "policy.protectedPaths" && terms.policy.protectProject ? `the whole project (listed: ${said(terms.policy.protectedPaths)})`
146
+ // Absent (sealed before the setting existed) is off, said apart from a signed "no".
147
+ : path === "policy.unsandboxedCarry" && terms.policy.unsandboxedCarry === undefined ? "no (not set when sealed)" : said(valueAt(terms, path));
148
+ lines.push({ key: `release.${path}`, label, value });
149
+ }
150
+ return lines;
151
+ }
152
+ export const termsText = (lines) => lines.map(line => `${line.label}: ${line.value}`).join("\n");
153
+ const SHA = /^[0-9a-f]{40}$/;
154
+ const sha256 = (text) => createHash("sha256").update(text).digest("hex");
155
+ /** The plane's own record of the last verified release of `repo`: the merge commit GitHub answered with, recorded once
156
+ * the release verified the merged tree (the newest merged authority whose merge operation succeeded). Never a tag. */
157
+ export function releaseBaseline(store, repo) {
158
+ const row = store.handle.prepare(`SELECT o.external_ref FROM release_authority a JOIN release_operation o ON o.authority = a.id
159
+ WHERE a.repo = ? AND a.state = 'merged' AND o.kind = 'merge' AND o.state = 'succeeded' AND o.external_ref IS NOT NULL ORDER BY a.id DESC, o.finished_at DESC LIMIT 1`).get(repo);
160
+ const ref = row === undefined ? null : String(row["external_ref"]);
161
+ return ref !== null && SHA.test(ref) ? ref : null;
162
+ }
163
+ /** The commits after `from` up to `to`, as verified Git objects only, from the project's recorded Git directory: the
164
+ * release coordinator's checkout config allowlist first, then commit-extract's verified range (every commit hashed here,
165
+ * a shallow boundary, grafts or replacement refs refused, and the range proved to stop only in the last release's own
166
+ * history). Null when they can't be read, so a card never shows a shortened list. */
167
+ export function commitsBetween(repo, from, to) {
168
+ if (!SHA.test(from) || !SHA.test(to))
169
+ return null;
170
+ let gitDir;
171
+ try {
172
+ const named = join(realpathSync(repo), ".git");
173
+ if (!lstatSync(named).isDirectory())
174
+ return null;
175
+ gitDir = realpathSync(named);
176
+ }
177
+ catch {
178
+ return null;
179
+ }
180
+ const tools = releaseTools();
181
+ const env = { ...releaseGitEnv(), PATH: RELEASE_PATH };
182
+ const listing = spawnSync(tools.git, [`--git-dir=${gitDir}`, "--no-optional-locks", "config", "--list", "--no-includes", "--show-scope", "-z"], { cwd: "/", env, encoding: "utf8", timeout: 15_000 });
183
+ if (listing.status !== 0 || releaseConfigProblem(listing.stdout) !== null)
184
+ return null;
185
+ const range = verifiedRange(gitDir, from, to, { git: tools.git, env });
186
+ if (!range.ok)
187
+ return null;
188
+ return range.value.map(one => ({ sha: one.id, subject: one.message.split("\n")[0].trim() }));
189
+ }
190
+ /** A pull request's own commits from GitHub, for the recorded owner/name. Null when GitHub can't be read. */
191
+ function pullRequestCommits(slug, pr) {
192
+ const tools = releaseTools();
193
+ 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 });
194
+ if (read.status !== 0)
195
+ return null;
196
+ 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) }; })
197
+ .filter(one => SHA.test(one.sha)).reverse();
198
+ }
199
+ /** What changed since the last verified release, for a release's terms, from Toolroll's records only. */
200
+ export function releaseChangeList(store, terms) {
201
+ const baseline = releaseBaseline(store, terms.repo);
202
+ let commits;
203
+ let source;
204
+ if (baseline !== null) {
205
+ commits = commitsBetween(terms.repo, baseline, terms.approvedSha);
206
+ source = commits === null ? "unread" : "recorded";
207
+ }
208
+ else {
209
+ const remote = store.projectRemote(terms.repo);
210
+ const parsed = remote === null ? null : releaseRemote(remote);
211
+ commits = parsed === null ? null : pullRequestCommits(parsed.slug, terms.pr);
212
+ source = commits === null ? "unread" : "pull-request";
213
+ }
214
+ return { baseline, commits, source, digest: sha256(`release-changes:${JSON.stringify({ baseline, source, commits })}`) };
215
+ }
216
+ /** The card digest: everything the person is shown that can change under them. */
217
+ export function cardDigestOf(scopeDigest, formDigest, changes) {
218
+ return sha256(`release-card:v1:${JSON.stringify([scopeDigest, formDigest, changes.baseline, changes.digest])}`);
219
+ }
220
+ function changeWords(changes, terms, cap) {
221
+ const head = changes.baseline === null ? `No earlier verified release is recorded. ${changes.source === "pull-request" ? `Pull request #${terms?.pr ?? "?"}'s commits:` : ""}`.trim()
222
+ : `Changes since the last verified release (${changes.baseline.slice(0, 7)}):`;
223
+ if (changes.commits === null)
224
+ return [head, changes.baseline === null ? `The pull request's commits couldn't be read; see #${terms?.pr ?? "?"} on GitHub.` : "The change list couldn't be read."];
225
+ if (changes.commits.length === 0)
226
+ return [head, "No commits."];
227
+ 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`] : [])];
228
+ }
229
+ /** The release card for a deploy gate, against the live records now. */
230
+ export function releaseCardOf(store, scope, formDigest, words) {
231
+ const terms = scope.releaseTerms ?? null;
232
+ const lines = approvalTermsOf(store, scope, words);
233
+ const value = (key) => lines.find(line => line.key === key)?.value ?? NONE;
234
+ const changes = terms === null
235
+ ? { baseline: null, commits: null, source: "unread", digest: sha256("release-changes:none") }
236
+ : releaseChangeList(store, terms);
237
+ const version = terms?.packageVersion ?? null;
238
+ const fileName = `release-${version ?? scope.taskId.replace(/[^A-Za-z0-9._-]/g, "-")}-terms.txt`;
239
+ const short = [
240
+ version === null ? `Deploy gate: ${value("title")}` : `Release ${version}: ${value("title")}`,
241
+ 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)}`,
242
+ terms === null ? `Checks: ${value("checks")} · check access: ${value("checkAccess")}` : `Carry later commits: ${terms.policy.carry ? "yes" : "no"} · checks: ${value("checks")} · expires ${terms.expiresAt}`,
243
+ // Option A's disclosure, on the card as on the approval sheet: never approved from chat without it.
244
+ ...(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."] : []),
245
+ "",
246
+ ...changeWords(changes, terms, 5),
247
+ "",
248
+ `Every term is in ${fileName}.`,
249
+ ].join("\n");
250
+ const full = [`${version === null ? "Deploy gate" : `Release ${version}`}: every term`, "", termsText(lines), "", ...changeWords(changes, terms, 200)].join("\n");
251
+ return { digest: cardDigestOf(scope.digest, formDigest, changes), short, full, fileName, changes };
252
+ }
@@ -27,6 +27,9 @@ export declare const CARRY_LIMITS: Readonly<{
27
27
  /** "Ask for every release commit": a project setting, newest choice wins (a ledger row each); off by default. */
28
28
  export declare const ASK_EVERY_COMMIT_ACTION = "release carrying changed";
29
29
  export declare function asksEveryReleaseCommit(store: Store, repo: string): boolean;
30
+ /** Set "Carry release approvals when checks aren't sandboxed". `by` is who set it; turning it on is only ever called
31
+ * after a person's own proof (release-cli.ts). */
32
+ export declare function setUnsandboxedCarry(store: Store, repo: string, on: boolean, by: string, now: Date): void;
30
33
  export declare function setAskEveryReleaseCommit(store: Store, repo: string, ask: boolean, by: string, now: Date): void;
31
34
  /** The effective policy now: the runtime rules, the project's protected paths and the limits. */
32
35
  export declare function effectivePolicy(store: Store, repo: string, options?: {
@@ -32,6 +32,7 @@ import { CARRY_RULES_VERSION, readReleasePolicy, releasePolicyDigest } from "./c
32
32
  import { run } from "./exec.js";
33
33
  import { proxyEnv, startEgressProxy } from "./egress.js";
34
34
  import { liveReleaseOf } from "./scope.js";
35
+ import { UNSANDBOXED_CARRY_ACTION, carriesUnsandboxed } from "./release-authority.js";
35
36
  import { carryReleaseApproval, releaseAuthority, releaseCarryTo, sealAnchorArtifact, } from "./store.js";
36
37
  // ------------------------------------------------------------------ the rules
37
38
  /** The journey scripts: tests, by Alex's decision (they carry). */
@@ -70,6 +71,12 @@ export function asksEveryReleaseCommit(store, repo) {
70
71
  const row = store.handle.prepare("SELECT outcome FROM action_ledger WHERE repo = ? AND action = ? ORDER BY id DESC LIMIT 1").get(repo, ASK_EVERY_COMMIT_ACTION);
71
72
  return row !== undefined && row["outcome"] === "ask";
72
73
  }
74
+ /** Set "Carry release approvals when checks aren't sandboxed". `by` is who set it; turning it on is only ever called
75
+ * after a person's own proof (release-cli.ts). */
76
+ export function setUnsandboxedCarry(store, repo, on, by, now) {
77
+ store.recordAction({ at: now.toISOString(), actor: by, repo, taskId: null, runId: null, action: UNSANDBOXED_CARRY_ACTION, outcome: on ? "on" : "off", source: "request",
78
+ detail: on ? "Carry release approvals when checks aren't sandboxed: later test-only commits run outside the sandbox without being shown first" : "Release approvals carry only when checks are sandboxed" });
79
+ }
73
80
  export function setAskEveryReleaseCommit(store, repo, ask, by, now) {
74
81
  store.recordAction({ at: now.toISOString(), actor: by, repo, taskId: null, runId: null, action: ASK_EVERY_COMMIT_ACTION, outcome: ask ? "ask" : "carry", source: "request",
75
82
  detail: ask ? "Ask for every release commit" : "Later test-only commits ship under the release approval" });
@@ -80,6 +87,7 @@ export function effectivePolicy(store, repo, options = {}) {
80
87
  return {
81
88
  version: 1, rules: CARRY_RULES_VERSION, carry: options.noCarry !== true && !asksEveryReleaseCommit(store, repo),
82
89
  protectProject: rules.protectProject, protectedPaths: [...new Set(rules.protectedPaths)].sort(), limits: { ...CARRY_LIMITS },
90
+ unsandboxedCarry: carriesUnsandboxed(store, repo),
83
91
  };
84
92
  }
85
93
  /** The stricter of two policies: what either protects asks again, the lower limit wins, either one's "ask" asks. Null
@@ -91,6 +99,8 @@ export function stricterOf(a, b) {
91
99
  version: 1, rules: CARRY_RULES_VERSION, carry: a.carry && b.carry, protectProject: a.protectProject || b.protectProject,
92
100
  protectedPaths: [...new Set([...a.protectedPaths, ...b.protectedPaths])].sort(),
93
101
  limits: { lines: Math.min(a.limits.lines, b.limits.lines), files: Math.min(a.limits.files, b.limits.files), churn: Math.min(a.limits.churn, b.limits.churn) },
102
+ // Carrying when checks aren't sandboxed only while both allow it: turning it on later never widens an approval.
103
+ unsandboxedCarry: a.unsandboxedCarry === true && b.unsandboxedCarry === true,
94
104
  };
95
105
  }
96
106
  /**
@@ -141,12 +151,17 @@ export function releaseTermsFor(store, input) {
141
151
  const grant = store.liveVerifyCommand(input.repo);
142
152
  if (grant === null)
143
153
  return { ok: false, why: "the project has no approved release check" };
144
- if (store.projectCheckAccess(input.repo) !== "sandboxed")
145
- return { ok: false, why: "the project's checks run outside the sandbox, so later commits always ask again" };
154
+ const access = store.projectCheckAccess(input.repo);
155
+ const policy = effectivePolicy(store, input.repo, { noCarry: input.noCarry ?? false });
156
+ // Checks outside the sandbox carry only with the project's "Carry release approvals when checks aren't sandboxed" on,
157
+ // sealed here: turning it on after this approval never carries for it.
158
+ if (access !== "sandboxed" && policy.unsandboxedCarry !== true) {
159
+ return { ok: false, why: "the project's checks run outside the sandbox, so later commits always ask again (unless “Carry release approvals when checks aren't sandboxed” is on)" };
160
+ }
146
161
  return { ok: true, terms: {
147
162
  version: 1, repo: input.repo, packageVersion: input.version, branch: input.branch, pr: input.pr, base: "main",
148
- approvedSha: input.candidate, approvedTree: commit.value.tree, policy: effectivePolicy(store, input.repo, { noCarry: input.noCarry ?? false }),
149
- grant: { id: grant.id, digest: grant.digest }, access: "sandboxed",
163
+ approvedSha: input.candidate, approvedTree: commit.value.tree, policy,
164
+ grant: { id: grant.id, digest: grant.digest }, access,
150
165
  expiresAt: new Date(input.now.getTime() + 3 * 24 * 60 * 60_000).toISOString(),
151
166
  } };
152
167
  }
@@ -170,6 +185,10 @@ export function preflightCarry(store, input) {
170
185
  return refuse("authority", "This Toolroll doesn't know the release's carry rules; update Toolroll first.");
171
186
  if (!policy.carry)
172
187
  return refuse("rules", "This project asks for every release commit.");
188
+ // Checks outside the sandbox (at the approval or now): only when the sealed setting and the current one both allow it.
189
+ if ((a.accessDigest !== "sandboxed" || store.projectCheckAccess(a.repo) !== "sandboxed") && policy.unsandboxedCarry !== true) {
190
+ 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.");
191
+ }
173
192
  if (!/^[0-9a-f]{40}([0-9a-f]{24})?$/.test(input.to))
174
193
  return refuse("ancestry", `${input.to} isn't a full commit id.`);
175
194
  if (input.to === a.tipSha)