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
@@ -16,13 +16,16 @@ import { RELEASE_LIMITS, runRelease } from "./release.js";
16
16
  import { processes, releaseAdapters } from "./release-adapters.js";
17
17
  import { releaseGate } from "./release-gate.js";
18
18
  import { databasePath, openStore, revokeRelease } from "./store.js";
19
- import { carryRefusalWords, proveCarry, setAskEveryReleaseCommit } from "./release-carry.js";
19
+ import { carryRefusalWords, proveCarry, setAskEveryReleaseCommit, setUnsandboxedCarry } from "./release-carry.js";
20
20
  import { authenticateApprover, machineActing, personProof } from "./scope.js";
21
21
  import { createNightlyCheckFlow } from "./nightly-check.js";
22
22
  import { parseSchedule } from "./flow-schedule.js";
23
23
  export const RELEASE_HELP = `toolroll release <branch> — release Toolroll itself, from a pushed branch with an open pull request
24
24
 
25
- Requires an active approver's scope approval (the only human step) and a passing Full check on the exact commit.
25
+ First looks at the pull request's checks: a required check that failed stops before anything is filed or sent for
26
+ approval, and checks not started or still running are named and waited for (stopping if they don't finish within
27
+ the gate's limit). Then requires an active approver's scope approval (the only human step) and a passing Full check
28
+ on the exact commit.
26
29
  Completes that result, drains, backs up, deploys and relinks both CLI names to that commit. After PR checks pass,
27
30
  squash-merges, deletes the matching remote branch, verifies the merged tree and tags. Both macOS legs gate npm
28
31
  publication; Homebrew waits for its checks to register and all pass before merging.
@@ -49,6 +52,11 @@ toolroll release nightly --repo <path> --as <you> --token <password> [--schedule
49
52
  toolroll release ask-every-commit on|off --repo <path> [--as <you> --token <password>] — this project's releases ask
50
53
  for every later commit (on), or carry test-only commits again (off: your password, never the lead's token)
51
54
 
55
+ toolroll release unsandboxed-carry on|off --repo <path> [--as <you> --token <password>] — carry release approvals
56
+ when this project's checks aren't sandboxed (on: your password, never the lead's token). With it on, later
57
+ test-only commits of a release run outside the sandbox without being shown to you first. Off by default; it counts
58
+ only for releases approved while it is on.
59
+
52
60
  Options
53
61
  --no-carry every later commit of this release asks for approval again
54
62
  --to <commit> with carry: the later commit
@@ -126,7 +134,7 @@ export async function runReleaseCommand(args, write, deps = {}) {
126
134
  return refuse("usage", "--yes goes with --release-gate <id>; a release needs no confirmation beyond the owner's approval.", 2);
127
135
  if (positional[0] === "nightly" && positional.length === 1)
128
136
  return runNightlyCommand({ repo: repo === undefined ? undefined : resolve(deps.cwd ?? process.cwd(), repo), as, token, schedule: scheduleFlag, start: args.includes("--start") }, write, json, deps);
129
- if (["carry", "revoke", "ask-every-commit"].includes(positional[0] ?? "") && positional.length === 2) {
137
+ if (["carry", "revoke", "ask-every-commit", "unsandboxed-carry"].includes(positional[0] ?? "") && positional.length === 2) {
130
138
  return runAuthorityCommand(positional[0], positional[1], { to, request, as, token, repo: repo === undefined ? undefined : resolve(deps.cwd ?? process.cwd(), repo) }, write, json, deps);
131
139
  }
132
140
  if (positional.length !== 1)
@@ -255,6 +263,31 @@ async function runAuthorityCommand(command, target, flags, write, json, deps) {
255
263
  const words = target === "on" ? "Every later release commit in this project asks for approval." : "Later test-only release commits ship under the release approval.";
256
264
  return answer(true, { repo: flags.repo, ask: target === "on" }, words, 0);
257
265
  }
266
+ if (command === "unsandboxed-carry") {
267
+ if (target !== "on" && target !== "off")
268
+ return answer(false, { reason: "usage", message: "unsandboxed-carry takes on or off." }, "unsandboxed-carry takes on or off.", 2);
269
+ if (flags.repo === undefined)
270
+ return answer(false, { reason: "usage", message: "Name the project with --repo <path>." }, "Name the project with --repo <path>.", 2);
271
+ // Turning it on relaxes a release's rules: a person's own ceremony, as an approval needs, never a lead or remote
272
+ // token. Turning it off is only stricter.
273
+ let actor = "cli";
274
+ if (target === "on") {
275
+ const refused = (message) => answer(false, { reason: "person-only", message }, message, 1);
276
+ if (machineActing() || process.env["TOOLROLL_LEAD_TOKEN"] !== undefined || process.env["TOOLROLL_REMOTE_TOKEN"] !== undefined)
277
+ return refused("Only a person turns this on, never the lead or a remote token.");
278
+ if (flags.as === undefined || flags.token === undefined)
279
+ return refused("Turning this on needs your password: add --as <you> --token <password>.");
280
+ const proved = authenticateApprover(store, flags.as, flags.token, flags.repo);
281
+ if (!proved.ok || !personProof(proved.method))
282
+ return refused("Only an approver of this project, proved as a person, turns this on.");
283
+ actor = flags.as;
284
+ }
285
+ setUnsandboxedCarry(store, flags.repo, target === "on", actor, now);
286
+ const words = target === "on"
287
+ ? "Release approvals approved from now on carry when this project's checks aren't sandboxed: later test-only commits will run outside the sandbox without being shown to you first."
288
+ : "Release approvals carry only when this project's checks are sandboxed.";
289
+ return answer(true, { repo: flags.repo, unsandboxedCarry: target === "on" }, words, 0);
290
+ }
258
291
  const id = Number(target);
259
292
  if (!Number.isSafeInteger(id) || id < 1)
260
293
  return answer(false, { reason: "usage", message: `${target} isn't a release id.` }, `${target} isn't a release id.`, 2);
@@ -0,0 +1,43 @@
1
+ /**
2
+ * The release's repository as Toolroll records it (docs/plans/chat-management.md §0a.2), never as the gate checkout
3
+ * says: the checkout is one of the plane's recorded projects (by real path), its Git directory is that project's own
4
+ * plain `.git` folder, and its GitHub `owner/name` and the URL fetched from come from the recorded remote
5
+ * (`project.remote_url`), not the checkout's `origin`. The checkout is where the release check ran the build's own code,
6
+ * so its `.git/config` is treated as build-written.
7
+ */
8
+ export type ReleaseRepo = {
9
+ checkout: string;
10
+ gitDir: string;
11
+ remote: string;
12
+ slug: string;
13
+ ssh: boolean;
14
+ };
15
+ /** The programs the coordinator runs, by absolute path, found once when it starts. */
16
+ export type ReleaseTools = {
17
+ git: string;
18
+ gh: string;
19
+ ssh: string;
20
+ };
21
+ /** owner/name and transport of a recorded GitHub remote, read strictly (https or ssh to github.com, nothing else). */
22
+ export declare function releaseRemote(url: string): {
23
+ slug: string;
24
+ ssh: boolean;
25
+ } | null;
26
+ /** Fixed system folders: the only PATH the coordinator's Git and gh run with. */
27
+ export declare const RELEASE_PATH = "/usr/bin:/bin:/usr/sbin:/sbin";
28
+ /** Git, gh and ssh by absolute path: the system's Git (or the first in the fixed system folders), the installed gh as
29
+ * this process's PATH finds it now, and the system ssh. Resolved once; never looked up again. */
30
+ export declare function releaseTools(path?: string): ReleaseTools;
31
+ /** The coordinator's Git and gh environment: an allowlist, a fixed PATH, no system or global Git config, no prompts. */
32
+ export declare function releaseGitEnv(base?: Readonly<NodeJS.ProcessEnv>): NodeJS.ProcessEnv;
33
+ /** Every coordinator Git command's fixed settings, ahead of its own arguments: the recorded Git directory and checkout,
34
+ * no hooks, fsmonitor, replacement objects, pager or submodule recursion, only https (and ssh for an ssh remote), the
35
+ * system ssh, and gh as the only credential helper. */
36
+ export declare function releaseGitArgs(repo: ReleaseRepo, tools: ReleaseTools): string[];
37
+ /** The named remote the coordinator fetches, looks and pushes through: defined only on its own command line (`-c`), so
38
+ * no checkout setting names or rewrites it. */
39
+ export declare const RELEASE_REMOTE = "toolroll-release";
40
+ /** The first local or worktree setting a gate checkout's config holds that a release won't run with, or null: one
41
+ * outside the allowlist (an include, a command, a proxy, TLS or header setting, a credential helper, a URL rewrite, a
42
+ * hooks folder), a remote named like a URL or after the coordinator's own, or a remote over a transport helper. */
43
+ export declare function releaseConfigProblem(listing: string): string | null;
@@ -0,0 +1,106 @@
1
+ /**
2
+ * The release coordinator's Git, as one hardened runner's pieces (docs/plans/chat-management.md §0a.2): the recorded
3
+ * repository's shape, the programs by absolute path, the allowlisted environment, the fixed settings and the checkout
4
+ * config allowlist. Kept apart from release-adapters.ts so the release card (release-card.ts) reads Git the same way
5
+ * without loading the coordinator.
6
+ */
7
+ import { accessSync, constants as fsConstants, statSync } from "node:fs";
8
+ import { delimiter, isAbsolute, join } from "node:path";
9
+ import { HARDENED_GIT_ARGS } from "./agent-fence.js";
10
+ import { TEARDOWN_FLAGS } from "./teardown-git.js";
11
+ /** owner/name and transport of a recorded GitHub remote, read strictly (https or ssh to github.com, nothing else). */
12
+ export function releaseRemote(url) {
13
+ const part = "([A-Za-z0-9_.-]+)";
14
+ const https = new RegExp(`^https://github\\.com/${part}/${part}?(?:\\.git)?/?$`).exec(url);
15
+ const ssh = new RegExp(`^(?:ssh://)?git@github\\.com[:/]${part}/${part}?(?:\\.git)?/?$`).exec(url);
16
+ const match = https ?? ssh;
17
+ if (match === null || match[2] === undefined || match[1].startsWith(".") || match[2].startsWith("."))
18
+ return null;
19
+ return { slug: `${match[1]}/${match[2].replace(/\.git$/, "")}`, ssh: https === null };
20
+ }
21
+ const executable = (path) => { try {
22
+ accessSync(path, fsConstants.X_OK);
23
+ return statSync(path).isFile();
24
+ }
25
+ catch {
26
+ return false;
27
+ } };
28
+ /** Fixed system folders: the only PATH the coordinator's Git and gh run with. */
29
+ export const RELEASE_PATH = "/usr/bin:/bin:/usr/sbin:/sbin";
30
+ /** Git, gh and ssh by absolute path: the system's Git (or the first in the fixed system folders), the installed gh as
31
+ * this process's PATH finds it now, and the system ssh. Resolved once; never looked up again. */
32
+ export function releaseTools(path = process.env["PATH"] ?? "") {
33
+ const find = (name, dirs) => {
34
+ for (const dir of dirs) {
35
+ if (!isAbsolute(dir))
36
+ continue;
37
+ const candidate = join(dir, name);
38
+ if (executable(candidate))
39
+ return candidate;
40
+ }
41
+ return null;
42
+ };
43
+ const system = ["/usr/bin", "/bin", "/usr/local/bin", "/opt/homebrew/bin"];
44
+ return {
45
+ git: find("git", system) ?? "/usr/bin/git",
46
+ gh: find("gh", [...path.split(delimiter), ...system]) ?? "/usr/bin/gh",
47
+ ssh: find("ssh", ["/usr/bin", "/bin"]) ?? "/usr/bin/ssh",
48
+ };
49
+ }
50
+ /** The coordinator's Git and gh environment: an allowlist, a fixed PATH, no system or global Git config, no prompts. */
51
+ export function releaseGitEnv(base = process.env) {
52
+ const kept = ["HOME", "LANG", "LC_ALL", "LC_CTYPE", "TMPDIR", "USER", "LOGNAME", "SSH_AUTH_SOCK", "GH_CONFIG_DIR", "XDG_CONFIG_HOME", "GH_TOKEN", "GITHUB_TOKEN", "GH_HOST"];
53
+ return {
54
+ ...Object.fromEntries(kept.flatMap(name => base[name] === undefined ? [] : [[name, base[name]]])),
55
+ PATH: RELEASE_PATH, GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null", GIT_TERMINAL_PROMPT: "0", GH_PROMPT_DISABLED: "1", GIT_ASKPASS: "", SSH_ASKPASS: "",
56
+ };
57
+ }
58
+ /** Every coordinator Git command's fixed settings, ahead of its own arguments: the recorded Git directory and checkout,
59
+ * no hooks, fsmonitor, replacement objects, pager or submodule recursion, only https (and ssh for an ssh remote), the
60
+ * system ssh, and gh as the only credential helper. */
61
+ export function releaseGitArgs(repo, tools) {
62
+ return [`--git-dir=${repo.gitDir}`, `--work-tree=${repo.checkout}`, "--no-replace-objects", "--no-pager", ...HARDENED_GIT_ARGS, ...TEARDOWN_FLAGS,
63
+ "-c", "protocol.allow=never", "-c", "protocol.https.allow=always", ...(repo.ssh ? ["-c", "protocol.ssh.allow=always"] : []),
64
+ "-c", `core.sshCommand=${tools.ssh}`, "-c", "core.askPass=", "-c", "fetch.recurseSubmodules=false", "-c", "core.pager=cat",
65
+ // Where it connects and what it sends: verified TLS, no proxy, no extra header or cookie file. (An empty CA file
66
+ // or path can't be given here, Git then trusts nothing; a checkout setting either stops the release instead.)
67
+ "-c", "http.sslVerify=true", "-c", "http.proxy=", "-c", "http.extraHeader=", "-c", "http.cookieFile=",
68
+ // The recorded URL, as a remote that exists only here.
69
+ "-c", `remote.${RELEASE_REMOTE}.url=${repo.remote}`, "-c", `remote.${RELEASE_REMOTE}.pushurl=${repo.remote}`, "-c", `remote.${RELEASE_REMOTE}.proxy=`,
70
+ ...(repo.ssh ? [] : ["-c", `credential.helper=!'${tools.gh.replace(/'/g, "'\\''")}' auth git-credential`])];
71
+ }
72
+ /** The named remote the coordinator fetches, looks and pushes through: defined only on its own command line (`-c`), so
73
+ * no checkout setting names or rewrites it. */
74
+ export const RELEASE_REMOTE = "toolroll-release";
75
+ /** The local and worktree settings a gate checkout may hold for a release (an allowlist): plain values Git writes
76
+ * itself and that never decide where it connects, what it sends or what it runs. Anything else stops the release. */
77
+ const RELEASE_KEYS = [
78
+ /^core\.(repositoryformatversion|filemode|bare|logallrefupdates|ignorecase|precomposeunicode|symlinks|autocrlf|eol|safecrlf|quotepath|longpaths|checkstat|trustctime)$/,
79
+ /^extensions\.(worktreeconfig|objectformat|refstorage)$/,
80
+ /^remote\.[^/:\\]+\.(url|fetch|pushurl|tagopt|prune)$/,
81
+ /^branch\..+\.(remote|merge|rebase|pushremote|description|vscode-merge-base)$/,
82
+ /^(user\.(name|email)|pull\.(rebase|ff)|init\.defaultbranch|lfs\.repositoryformatversion)$/,
83
+ ];
84
+ /** The first local or worktree setting a gate checkout's config holds that a release won't run with, or null: one
85
+ * outside the allowlist (an include, a command, a proxy, TLS or header setting, a credential helper, a URL rewrite, a
86
+ * hooks folder), a remote named like a URL or after the coordinator's own, or a remote over a transport helper. */
87
+ export function releaseConfigProblem(listing) {
88
+ const fields = listing.split("\0");
89
+ for (let at = 0; at + 1 < fields.length; at += 2) {
90
+ if (fields[at] !== "local" && fields[at] !== "worktree")
91
+ continue;
92
+ const entry = fields[at + 1];
93
+ const newline = entry.indexOf("\n");
94
+ const key = (newline < 0 ? entry : entry.slice(0, newline)).toLowerCase();
95
+ const value = (newline < 0 ? "" : entry.slice(newline + 1)).trim();
96
+ if (key.includes(RELEASE_REMOTE))
97
+ return key;
98
+ if (key === "core.fsmonitor" && /^(false|no|off|0)$/i.test(value))
99
+ continue;
100
+ if (!RELEASE_KEYS.some(pattern => pattern.test(key)))
101
+ return key;
102
+ if (/^remote\./.test(key) && /^[A-Za-z][A-Za-z0-9+.-]*::/.test(value))
103
+ return key;
104
+ }
105
+ return null;
106
+ }
package/dist/release.d.ts CHANGED
@@ -5,7 +5,11 @@
5
5
  *
6
6
  * gate → approval → check → complete → quiet → deploy → link → ci → merge → tree → tag → publish → homebrew → done
7
7
  *
8
- * gate files a prepared-candidate task pinned to the branch's exact commit, with the full release check. approval
8
+ * gate, before filing a task for that commit, looks at the pull request's required checks on it: one that failed stops
9
+ * the release before anything is filed or sent for approval; it waits, naming them, for checks not started or still
10
+ * running, and stops if they don't finish within its limit (a task already filed is not looked at again; ci checks
11
+ * once more before merging). It then files a prepared-candidate task pinned to the branch's exact commit, with the full
12
+ * release check. approval
9
13
  * waits for an active approver's yes to that exact scope (never a mode's, an AI's or the lead's). check requires a Full
10
14
  * result of that commit: a failed check stops the release and stays visible, nothing reruns it. complete marks that
11
15
  * exact result Complete through the task's own authorization. quiet waits until nothing builds. deploy runs the
@@ -35,7 +39,7 @@ export declare const REQUIRED_PR_CHECKS: readonly ["node 22 · ubuntu-latest", "
35
39
  export declare const RELEASE_TAG_CHECKS: readonly ["node 22 · macos-latest", "node 24 · macos-latest", "publish"];
36
40
  export declare const RELEASE_STEPS: readonly ["gate", "approval", "check", "complete", "quiet", "deploy", "link", "ci", "merge", "tree", "tag", "publish", "homebrew", "done"];
37
41
  export type ReleaseStep = typeof RELEASE_STEPS[number];
38
- type WaitingStep = "approval" | "check" | "quiet" | "deploy" | "ci" | "publish" | "homebrew";
42
+ type WaitingStep = "gate" | "approval" | "check" | "quiet" | "deploy" | "ci" | "publish" | "homebrew";
39
43
  /** How long each waiting step may take, in minutes, before the release stops there (rerunning continues from it). */
40
44
  export declare const RELEASE_LIMITS: Readonly<Record<WaitingStep, number>>;
41
45
  /** One check attempt; `app` is the GitHub App that made it (github-actions for a workflow), when known. */
@@ -86,6 +90,9 @@ export type GateState = {
86
90
  } | null;
87
91
  /** A finished build that left no result to complete (it failed before the check), in words. */
88
92
  failed: string | null;
93
+ /** A newer attempt is queued or running (`task regate` after a failed check): the result shown is the one before it.
94
+ * Absent from a plane too old to say. */
95
+ pending?: boolean;
89
96
  completion: {
90
97
  actor: string;
91
98
  at: string;
@@ -467,4 +474,6 @@ export declare function checksVerdict(runs: readonly CheckRun[], required: reado
467
474
  };
468
475
  /** Run the release from wherever its journal stopped. */
469
476
  export declare function runRelease(options: ReleaseOptions, adapters: ReleaseAdapters): Promise<ReleaseOutcome>;
477
+ /** A probe's failure that is only the network to GitHub being slow, never a decision (a Stop) or another error. */
478
+ export declare function transientNetworkError(error: unknown): boolean;
470
479
  export {};
package/dist/release.js CHANGED
@@ -5,7 +5,11 @@
5
5
  *
6
6
  * gate → approval → check → complete → quiet → deploy → link → ci → merge → tree → tag → publish → homebrew → done
7
7
  *
8
- * gate files a prepared-candidate task pinned to the branch's exact commit, with the full release check. approval
8
+ * gate, before filing a task for that commit, looks at the pull request's required checks on it: one that failed stops
9
+ * the release before anything is filed or sent for approval; it waits, naming them, for checks not started or still
10
+ * running, and stops if they don't finish within its limit (a task already filed is not looked at again; ci checks
11
+ * once more before merging). It then files a prepared-candidate task pinned to the branch's exact commit, with the full
12
+ * release check. approval
9
13
  * waits for an active approver's yes to that exact scope (never a mode's, an AI's or the lead's). check requires a Full
10
14
  * result of that commit: a failed check stops the release and stays visible, nothing reruns it. complete marks that
11
15
  * exact result Complete through the task's own authorization. quiet waits until nothing builds. deploy runs the
@@ -45,7 +49,7 @@ export const REQUIRED_PR_CHECKS = [
45
49
  export const RELEASE_TAG_CHECKS = ["node 22 · macos-latest", "node 24 · macos-latest", "publish"];
46
50
  export const RELEASE_STEPS = ["gate", "approval", "check", "complete", "quiet", "deploy", "link", "ci", "merge", "tree", "tag", "publish", "homebrew", "done"];
47
51
  /** How long each waiting step may take, in minutes, before the release stops there (rerunning continues from it). */
48
- export const RELEASE_LIMITS = { approval: 24 * 60, check: 120, quiet: 60, deploy: 30, ci: 60, publish: 45, homebrew: 45 };
52
+ export const RELEASE_LIMITS = { gate: 60, approval: 24 * 60, check: 120, quiet: 60, deploy: 30, ci: 60, publish: 45, homebrew: 45 };
49
53
  const POLL_MS = 15_000;
50
54
  /** A stop the release reports: the step it stopped at and why, in words. */
51
55
  class Stop extends Error {
@@ -177,16 +181,29 @@ export async function runRelease(options, adapters) {
177
181
  delete j.intents.deploy;
178
182
  save();
179
183
  };
180
- /** Ask `probe` until it answers, within this step's limit. */
184
+ /** Ask `probe` until it answers, within this step's limit. A short network failure on the way to GitHub (git or gh
185
+ * timing out) is no answer yet, never the release's end: the next poll asks again, until the step's own limit. */
181
186
  const waitFor = async (at, what, probe) => {
182
187
  const deadline = adapters.now().getTime() + limits[at] * 60_000;
183
- let said = false;
188
+ let said = false, unreachable = null;
184
189
  for (;;) {
185
- const answer = await probe();
190
+ let answer;
191
+ try {
192
+ answer = await probe();
193
+ unreachable = null;
194
+ }
195
+ catch (error) {
196
+ if (!transientNetworkError(error))
197
+ throw error;
198
+ if (unreachable === null)
199
+ say(` GitHub didn't answer in time (${networkWords(error)}); asking again`);
200
+ unreachable = networkWords(error);
201
+ answer = null;
202
+ }
186
203
  if (answer !== null)
187
204
  return answer;
188
205
  if (adapters.now().getTime() >= deadline)
189
- throw new Stop("timeout", `Still waiting for ${what} after ${limits[at]} minutes.`);
206
+ throw new Stop("timeout", `Still waiting for ${what} after ${limits[at]} minutes${unreachable === null ? "" : ` (GitHub last didn't answer: ${unreachable})`}.`);
190
207
  if (!said) {
191
208
  say(` waiting for ${what} (up to ${limits[at]} min)`);
192
209
  said = true;
@@ -464,11 +481,13 @@ export async function runRelease(options, adapters) {
464
481
  if (existing.exists && j.freshTask === true && j.intents.gate === undefined)
465
482
  throw new Stop("approval", `Task ${j.task.id} appeared before this release filed it; rerun with --new.`);
466
483
  if (existing.exists) {
484
+ // Filed already (a resume after a lost answer): the task stands, and the ci step judges its checks.
467
485
  if (existing.candidate !== j.candidate.sha)
468
486
  throw new Stop("conflict", `Task ${j.task.id} exists for a different commit (${existing.candidate?.slice(0, 7) ?? "none"}).`);
469
487
  j.task.scopeDigest = existing.scopeDigest;
470
488
  }
471
489
  else {
490
+ await earlyChecks(adapters, j, (what, probe) => waitFor("gate", what, probe));
472
491
  intend("gate");
473
492
  await adapters.repo.sync(j.branch);
474
493
  const filed = await adapters.toolroll.fileGate({
@@ -516,6 +535,9 @@ export async function runRelease(options, adapters) {
516
535
  await watch("dispatch");
517
536
  const gate = await adapters.toolroll.gate(j.task.id);
518
537
  await approved(gate);
538
+ // A rerun of the same approved commit is on its way: the failure before it is not this release's answer.
539
+ if (gate.pending === true && gate.result?.check !== "passed")
540
+ return null;
519
541
  if (gate.failed !== null)
520
542
  throw new Stop("check-failed", `The release build of ${j.task.id} failed: ${gate.failed}. Fix it and release the new commit with --new.`);
521
543
  if (gate.result === null || gate.result.check === "running" || gate.result.check === "pending")
@@ -886,3 +908,57 @@ async function deployed(adapters, candidate) {
886
908
  const [runtime] = runtimes;
887
909
  return runtime !== undefined && runtime !== "" && await adapters.toolroll.runtimeCommit(runtime) === candidate ? runtime : null;
888
910
  }
911
+ /**
912
+ * The gate's look at the pull request's required checks on the exact commit, before anything is filed or anyone is
913
+ * asked to approve (#237): a required check that failed or was cancelled stops here; checks not started or still
914
+ * running are named, and the gate waits for them; ones that never finish within the gate's limit stop here too. GitHub
915
+ * not answering is waited out the same way, never taken for a pass. The ci step still checks again before merging.
916
+ */
917
+ async function earlyChecks(adapters, j, wait) {
918
+ const sha = j.candidate.sha.slice(0, 7);
919
+ const required = REQUIRED_PR_CHECKS;
920
+ let named = "", unread = false;
921
+ try {
922
+ await wait(`the pull request checks on ${sha}`, async () => {
923
+ let runs;
924
+ try {
925
+ runs = await adapters.github.checks(j.github, j.candidate.sha);
926
+ }
927
+ catch (error) {
928
+ if (!unread)
929
+ adapters.say(` Couldn't read the pull request checks on ${sha} (${error?.message ?? error}); trying again.`);
930
+ unread = true;
931
+ return null;
932
+ }
933
+ const verdict = checksVerdict(runs.filter(run => required.includes(run.name)), required);
934
+ if (verdict.state === "failed") {
935
+ throw new Stop("ci-failed", `Pull request checks already failed on ${sha}: ${verdict.failed.join(", ")}. Nothing was filed or sent for approval. Fix the branch, then release the new commit with \`toolroll release ${j.branch} --repo ${j.checkout} --new\` (or rerun the failed job on GitHub and run this command again).`);
936
+ }
937
+ if (verdict.state === "passed")
938
+ return true;
939
+ const now = verdict.waiting.join(", ");
940
+ if (now !== named) {
941
+ adapters.say(` Pull request checks on ${sha} not finished yet: ${now}.`);
942
+ named = now;
943
+ }
944
+ return null;
945
+ });
946
+ }
947
+ catch (error) {
948
+ if (!(error instanceof Stop) || error.reason !== "timeout")
949
+ throw error;
950
+ throw new Stop("ci-missing", `Pull request checks on ${sha} didn't all finish in time${named === "" ? (unread ? " (GitHub didn't answer)" : "") : `: ${named}`}. Nothing was filed or sent for approval. Start or rerun them on GitHub, then run this command again.`);
951
+ }
952
+ }
953
+ /** The network failures a release's waits ride out: git or gh timing out (or cut off) on the way to GitHub. */
954
+ const TRANSIENT_NETWORK = /Recv failure: (?:Operation timed out|Connection reset by peer)|TLS handshake timeout|Operation timed out|Connection timed out|i\/o timeout|Connection reset by peer|Failed to connect to [^\s]*github\.com port \d+/i;
955
+ /** A probe's failure that is only the network to GitHub being slow, never a decision (a Stop) or another error. */
956
+ export function transientNetworkError(error) {
957
+ if (error instanceof Stop || error instanceof Restart)
958
+ return false;
959
+ return TRANSIENT_NETWORK.test(String(error?.message ?? error));
960
+ }
961
+ /** The part of a network failure worth saying: its matching phrase. */
962
+ function networkWords(error) {
963
+ return TRANSIENT_NETWORK.exec(String(error?.message ?? error))?.[0] ?? "a network failure";
964
+ }
@@ -1,5 +1,6 @@
1
1
  import { html, postForm } from "./html.js";
2
+ import { framedSection } from './knowledge-ui.js';
2
3
  export function repositoryContextHtml(repo, query, result, csrf) {
3
4
  const root = `/settings/knowledge?repo=${encodeURIComponent(repo)}`;
4
- return html `<section class="knowledge repository-context"><h2>Relevant code</h2><form method="get" action="/settings/knowledge"><input type="hidden" name="repo" value="${repo}"><label>Find code<input name="q" value="${query}" maxlength="1000" placeholder="A feature, file or symbol"></label><button type="submit">Search</button></form>${result === null ? '' : html `<p class="meta">${result.index.engine === 'typescript-ast' ? 'Code relationships' : 'Source search'}${result.mode === 'impact' ? ' · possible change impact' : ''}</p>${result.excerpts.length ? result.excerpts.map(one => html `<details><summary>${one.file}:${one.line}${one.symbol ? ` · ${one.symbol}` : ''}</summary><p class="meta">${one.reason}</p><pre>${one.text}</pre><a href="${root}&mode=impact&q=${encodeURIComponent(one.file)}">What this may affect</a></details>`) : html `<p>No matching source was found in this bounded search.</p>`}${result.relationships.length ? html `<details><summary>Related files</summary><ul>${result.relationships.map(one => html `<li>${one.from} → ${one.to} · ${one.kind}</li>`)}</ul></details>` : ''}<details><summary>Context details</summary><p>${result.index.status}${result.checkout ? ` · checkout ${result.checkout.head.slice(0, 8)}` : ''}</p>${result.warnings.map(one => html `<p>${one}</p>`)}</details>`}${csrf ? html `<details><summary>Refresh code index</summary>${postForm("/settings/knowledge/refresh", html `<p>Builds a local map of TypeScript and JavaScript imports. Uses no model.</p><button type="submit">Refresh index</button>`, { hidden: { repo } })}</details>` : ''}</section>`;
5
+ return framedSection('Relevant code', html `<form class="field-row" method="get" action="/settings/knowledge"><input type="hidden" name="repo" value="${repo}"><label>Find code<input name="q" value="${query}" maxlength="1000" placeholder="A feature, file or symbol"></label><button type="submit">Search</button></form>${result === null ? '' : html `<p class="meta">${result.index.engine === 'typescript-ast' ? 'Code relationships' : 'Source search'}${result.mode === 'impact' ? ' · possible change impact' : ''}</p>${result.excerpts.length ? result.excerpts.map(one => html `<details><summary>${one.file}:${one.line}${one.symbol ? ` · ${one.symbol}` : ''}</summary><p class="meta">${one.reason}</p><pre>${one.text}</pre><a href="${root}&mode=impact&q=${encodeURIComponent(one.file)}">What this may affect</a></details>`) : html `<p>No matching source was found in this bounded search.</p>`}${result.relationships.length ? html `<details><summary>Related files</summary><ul>${result.relationships.map(one => html `<li>${one.from} → ${one.to} · ${one.kind}</li>`)}</ul></details>` : ''}<details><summary>Context details</summary><p>${result.index.status}${result.checkout ? ` · checkout ${result.checkout.head.slice(0, 8)}` : ''}</p>${result.warnings.map(one => html `<p>${one}</p>`)}</details>`}${csrf ? html `<details><summary>Refresh code index</summary>${postForm("/settings/knowledge/refresh", html `<p>Builds a local map of TypeScript and JavaScript imports. Uses no model.</p><button type="submit">Refresh index</button>`, { hidden: { repo } })}</details>` : ''}`, 'repository-context');
5
6
  }
@@ -8,7 +8,7 @@
8
8
  * kept 24 hours) are read once, bounded, through the descriptor-safe live reader, for step counts and observed step
9
9
  * times. No command text is ever stored here: repeats are counted, never quoted.
10
10
  */
11
- import type { Run, Store } from "./store.js";
11
+ import { type Run, type Store } from "./store.js";
12
12
  import type { LiveActivityKind, LiveRecord } from "./live-records.js";
13
13
  export declare const RUN_EFFICIENCY_VERSION = 3;
14
14
  /** Budgets for one derivation: never more than the live file's own cap, and a bounded record count. */
@@ -62,10 +62,10 @@ export declare function usageOf(run: Pick<Run, "provider" | "model" | "tokensIn"
62
62
  * others their times are partly made up (Codex reports a step when it completes, ACP agents may report late), so no
63
63
  * duration is claimed for them. */
64
64
  export declare function stepsOf(records: Iterable<LiveRecord>, truncated: string | null, measured?: boolean): RunEfficiency["steps"];
65
- /** Derive a finished run's How it ran from its records. */
65
+ /** Derive a finished run's How it ran from its records, read under the run's own captured "Show commands" answer. */
66
66
  export declare function deriveRunEfficiency(run: Run & {
67
67
  usageJson?: string | null;
68
- }, evidenceRoot: string, now: Date): RunEfficiency;
68
+ }, evidenceRoot: string, now: Date, commands?: boolean): RunEfficiency;
69
69
  /**
70
70
  * A finished run's How it ran: the saved one when it was derived by this version, else derived now and saved (once:
71
71
  * a row whose steps were complete or have expired is final). An unfinished run is derived but never saved.
@@ -1,3 +1,14 @@
1
+ /**
2
+ * How a run went (docs/plans/run-efficiency.md): derived from the run's own records, display only. It never changes a
3
+ * completion, an approval, a charge or a check verdict, and it claims nothing its sources don't hold — an unreported
4
+ * number is unknown (null, shown as a dash), never zero.
5
+ *
6
+ * Sources, and what each can say: the run row (wall times, tokens, the provider's raw usage with its cache counts, the
7
+ * agent's turns where reported) is kept with the run; the live records (`<evidence>/live/<run>.jsonl`, display-only,
8
+ * kept 24 hours) are read once, bounded, through the descriptor-safe live reader, for step counts and observed step
9
+ * times. No command text is ever stored here: repeats are counted, never quoted.
10
+ */
11
+ import { runLiveCommands } from "./store.js";
1
12
  import { closeSync, constants, fstatSync, lstatSync, openSync, readSync } from "node:fs";
2
13
  import { join } from "node:path";
3
14
  import { LIVE_RETAIN_MS, legacyLiveLogPath, liveLogPath, liveWindowRecords } from "./live.js";
@@ -95,7 +106,7 @@ const noSteps = (state, reason) => ({ state, reason, total: null, failed: null,
95
106
  * directory), measured once, and never more than the budget read whatever the file does meanwhile; then parsed line by
96
107
  * line. A file over the budget, in the older text format, with lines that don't read, or cut or capped while the agent
97
108
  * ran, says its steps are incomplete. Null when there is no step file at all. */
98
- function readRecords(evidenceRoot, runId) {
109
+ function readRecords(evidenceRoot, runId, commands) {
99
110
  try {
100
111
  const dir = lstatSync(join(evidenceRoot, "live"));
101
112
  if (!dir.isDirectory() || dir.isSymbolicLink())
@@ -154,10 +165,10 @@ function readRecords(evidenceRoot, runId) {
154
165
  whole.length = EFFICIENCY_RECORDS;
155
166
  const keys = { inKey: false };
156
167
  // One record per line: a whole line that doesn't read is a record lost.
157
- const read = liveWindowRecords({ text: whole.length === 0 ? "" : `${whole.join("\n")}\n`, format: "records" }, keys);
168
+ const read = liveWindowRecords({ text: whole.length === 0 ? "" : `${whole.join("\n")}\n`, format: "records" }, commands, keys);
158
169
  const lost = whole.length - read.length;
159
170
  // A finished run's last line without its newline is read like any other; one that doesn't read is a record lost.
160
- const tail = unfinished.trim() === "" ? [] : liveWindowRecords({ text: `${unfinished}\n`, format: "records" }, keys);
171
+ const tail = unfinished.trim() === "" ? [] : liveWindowRecords({ text: `${unfinished}\n`, format: "records" }, commands, keys);
161
172
  const records = [...read, ...tail];
162
173
  const lostAll = lost + (unfinished.trim() !== "" && tail.length === 0 ? 1 : 0);
163
174
  if (records.length === 0 && text.trim() !== "")
@@ -170,10 +181,10 @@ function readRecords(evidenceRoot, runId) {
170
181
  : null;
171
182
  return { records, truncated };
172
183
  }
173
- /** Derive a finished run's How it ran from its records. */
174
- export function deriveRunEfficiency(run, evidenceRoot, now) {
184
+ /** Derive a finished run's How it ran from its records, read under the run's own captured "Show commands" answer. */
185
+ export function deriveRunEfficiency(run, evidenceRoot, now, commands = false) {
175
186
  const finished = run.finishedAt;
176
- const read = readRecords(evidenceRoot, run.id);
187
+ const read = readRecords(evidenceRoot, run.id, commands);
177
188
  const steps = read !== null && "unreadable" in read ? noSteps("truncated", read.unreadable)
178
189
  : read !== null ? stepsOf(read.records, read.truncated, run.provider === "claude")
179
190
  : finished !== null && now.getTime() - Date.parse(finished) > LIVE_RETAIN_MS ? noSteps("expired", "Step details are kept for a day after a run; this one's have expired.")
@@ -196,8 +207,9 @@ export function runEfficiencyOf(store, evidenceRoot, runId, now) {
196
207
  return null;
197
208
  const usageJson = store.handle.prepare("SELECT usage_json FROM run WHERE id = ?").get(runId)?.["usage_json"];
198
209
  const full = { ...run, usageJson: typeof usageJson === "string" ? usageJson : null };
210
+ const commands = runLiveCommands(store, runId);
199
211
  if (run.finishedAt === null)
200
- return deriveRunEfficiency(full, evidenceRoot, now);
212
+ return deriveRunEfficiency(full, evidenceRoot, now, commands);
201
213
  let saved;
202
214
  try {
203
215
  saved = store.handle.prepare("SELECT parser_version, payload_json FROM run_efficiency WHERE run = ?").get(runId);
@@ -211,7 +223,7 @@ export function runEfficiencyOf(store, evidenceRoot, runId, now) {
211
223
  }
212
224
  catch { /* derived again below */ }
213
225
  }
214
- const derived = deriveRunEfficiency(full, evidenceRoot, now);
226
+ const derived = deriveRunEfficiency(full, evidenceRoot, now, commands);
215
227
  const payload = JSON.stringify(derived);
216
228
  // A row too large for its column (it can't be: the counts are bounded) is shown, never saved.
217
229
  // Saving is a convenience: a store that can't take the row (read-only, busy) still shows what was derived.
package/dist/scope.d.ts CHANGED
@@ -945,3 +945,10 @@ export declare function liveReleaseOf(store: Store, taskId: string, action: Rele
945
945
  * check was composed from a targeted run: a full pass is needed first. Null when nothing blocks.
946
946
  */
947
947
  export declare function mainDebtProblem(store: Store, repo: string, taskId: string): string | null;
948
+ /**
949
+ * A deploy gate (docs/plans/chat-management.md §0a.5): a scope whose signed terms name a prepared candidate or carry
950
+ * release terms. Both are in the scope digest and written at filing, before any release authority exists, so this is
951
+ * read from the scope alone: a first approval and a fallback gate filed without --release are deploy gates too. From
952
+ * chat it is approved only through the release card.
953
+ */
954
+ export declare function deployGateScope(scope: Pick<Scope, "candidate" | "releaseTerms">): boolean;
package/dist/scope.js CHANGED
@@ -1327,7 +1327,8 @@ export function describeScope(scope, readiness = NO_READINESS) {
1327
1327
  export function releaseTermsSentence(terms) {
1328
1328
  if (!terms.policy.carry)
1329
1329
  return `Release ${terms.packageVersion} from pull request #${terms.pr} at ${terms.approvedSha.slice(0, 7)}. Every later commit asks for your approval again.`;
1330
- return `Release ${terms.packageVersion} from pull request #${terms.pr} at ${terms.approvedSha.slice(0, 7)}. Later commits on this pull request that only change tests, docs or the changelog ship without asking you again, until it merges or for 3 days. You'll see each one in the release summary.`;
1330
+ const outside = terms.access === "sandboxed" ? "" : " This project's checks don't run in the sandbox: those later test-only commits will run outside the sandbox without being shown to you first.";
1331
+ return `Release ${terms.packageVersion} from pull request #${terms.pr} at ${terms.approvedSha.slice(0, 7)}. Later commits on this pull request that only change tests, docs or the changelog ship without asking you again, until it merges or for 3 days. You'll see each one in the release summary.${outside}`;
1331
1332
  }
1332
1333
  /** What asks again, in full: the terms behind the sentence. */
1333
1334
  export function releaseAsksAgainWords(terms) {
@@ -1338,7 +1339,7 @@ export function releaseAsksAgainWords(terms) {
1338
1339
  `when it deletes, renames or changes the mode of a file, adds a binary, a symlink or a submodule; when it changes the production build;`,
1339
1340
  `when more than ${p.limits.lines} lines or ${p.limits.files} files change after this approval, or ${p.limits.churn} lines of churn;`,
1340
1341
  ...(p.protectProject ? ["when it changes anything: this project is protected;"] : p.protectedPaths.length === 0 ? [] : [`when it changes a protected path: ${p.protectedPaths.join(", ")};`]),
1341
- `and always once the deploy starts. Repository ${terms.repo}, branch ${terms.branch} into ${terms.base}, check grant ${terms.grant.id}, checks in the sandbox, until ${terms.expiresAt}.`,
1342
+ `and always once the deploy starts. Repository ${terms.repo}, branch ${terms.branch} into ${terms.base}, check grant ${terms.grant.id}, ${terms.access === "sandboxed" ? "checks in the sandbox" : `checks outside the sandbox (${terms.access}), carried because the project allows it`}, until ${terms.expiresAt}.`,
1342
1343
  ];
1343
1344
  }
1344
1345
  function releaseTermsWords(terms) {
@@ -1358,7 +1359,7 @@ function describeApproval(approval) {
1358
1359
  export { personProof } from "./person-proof.js";
1359
1360
  import { personProof } from "./person-proof.js";
1360
1361
  import { releaseTermsDigest } from "./contracts/release-terms.js";
1361
- import { newestReleaseGeneration, releaseAuthorityOfTask, releaseSealProblem } from "./release-authority.js";
1362
+ import { CHECK_ACCESS_WIDTH, carriesUnsandboxed, newestReleaseGeneration, releaseAuthorityOfTask, releaseSealProblem } from "./release-authority.js";
1362
1363
  /** Whether the command running now is a machine's: the lead (its token) or a remote API run. Trusted context only. */
1363
1364
  export const machineActing = () => currentActor()?.lead === true || activeRemote() !== null;
1364
1365
  /** A proof row's detail, either form: the legacy text (`<at> · generation <n> · digest <d>`) or the versioned record
@@ -1494,8 +1495,15 @@ export function liveReleaseOf(store, taskId, action, now = new Date()) {
1494
1495
  const grant = store.liveVerifyCommand(a.repo);
1495
1496
  if (grant === null || grant.id !== a.verifyCommand || grant.digest !== a.verifyDigest)
1496
1497
  return refuse("the release check command changed since the approval");
1497
- if (a.accessDigest !== "sandboxed" || store.projectCheckAccess(a.repo) !== "sandboxed")
1498
- return refuse("the project's checks no longer run in the sandbox");
1498
+ // The checks reach no further than at the approval. Wider than the sandbox, a carried commit stands only while both
1499
+ // the sealed and the current "Carry release approvals when checks aren't sandboxed" allow it (the stricter of the two).
1500
+ const access = store.projectCheckAccess(a.repo);
1501
+ const sealedWidth = CHECK_ACCESS_WIDTH[a.accessDigest], nowWidth = CHECK_ACCESS_WIDTH[access];
1502
+ if (sealedWidth === undefined || nowWidth === undefined || nowWidth > sealedWidth)
1503
+ return refuse(a.accessDigest === "sandboxed" ? "the project's checks no longer run in the sandbox" : "the project's checks reach further than when the release was approved");
1504
+ if (carry !== null && (a.accessDigest !== "sandboxed" || access !== "sandboxed") && (a.policy?.unsandboxedCarry !== true || !carriesUnsandboxed(store, a.repo))) {
1505
+ return refuse("carrying release approvals when checks aren't sandboxed is off");
1506
+ }
1499
1507
  if (a.policy === null)
1500
1508
  return refuse("this Toolroll doesn't know the release's carry rules; update Toolroll first");
1501
1509
  if (action === "deploy" || action === "merge") {
@@ -1522,3 +1530,12 @@ export function mainDebtProblem(store, repo, taskId) {
1522
1530
  return null;
1523
1531
  return `the nightly full check on main has ${open} open failure${open === 1 ? "" : "s"}, so a check made from a re-run of what failed can't deploy; a full check has to pass first`;
1524
1532
  }
1533
+ /**
1534
+ * A deploy gate (docs/plans/chat-management.md §0a.5): a scope whose signed terms name a prepared candidate or carry
1535
+ * release terms. Both are in the scope digest and written at filing, before any release authority exists, so this is
1536
+ * read from the scope alone: a first approval and a fallback gate filed without --release are deploy gates too. From
1537
+ * chat it is approved only through the release card.
1538
+ */
1539
+ export function deployGateScope(scope) {
1540
+ return (scope.candidate ?? null) !== null || (scope.releaseTerms ?? null) !== null;
1541
+ }
package/dist/scout.d.ts CHANGED
@@ -131,6 +131,9 @@ export declare function readReportImage(folder: string, file: string): {
131
131
  };
132
132
  /** The shorten turn's ask: the fields over their limits, and the same report again within them. */
133
133
  export declare function shortenReportBrief(over: readonly ReportProblem[], reportFile: string, structured: boolean): string;
134
+ /** Whether a stream event shows the session writing its plan-mode plan
135
+ * file (~/.claude/plans/…) or handing a plan to ExitPlanMode. */
136
+ export declare function touchesPlanFile(event: Record<string, unknown>): boolean;
134
137
  /** A cited link without its secrets: credential-shaped query values and path parts become REDACTED, a fragment
135
138
  * that carries one is dropped, and a link still showing one keeps only its origin. Unchanged when it has none. */
136
139
  export declare function scrubUrl(text: string): {