toolroll 0.9.72 → 0.9.73

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 (63) hide show
  1. package/CHANGELOG.md +76 -0
  2. package/dist/agent-fence.d.ts +5 -0
  3. package/dist/agent-fence.js +42 -7
  4. package/dist/browser/workspace.js +28 -28
  5. package/dist/chat-actions.js +13 -8
  6. package/dist/chat-decide.d.ts +47 -1
  7. package/dist/chat-decide.js +170 -4
  8. package/dist/check-report.js +2 -1
  9. package/dist/coding-provider.js +4 -2
  10. package/dist/commit-extract.d.ts +17 -0
  11. package/dist/commit-extract.js +82 -4
  12. package/dist/contracts/console-api.d.ts +3 -3
  13. package/dist/contracts/console-api.js +4 -4
  14. package/dist/contracts/release-terms.d.ts +7 -1
  15. package/dist/contracts/release-terms.js +6 -2
  16. package/dist/exec.d.ts +1 -1
  17. package/dist/exec.js +7 -3
  18. package/dist/flow-gallery-ui.js +4 -2
  19. package/dist/flow-gallery.d.ts +3 -0
  20. package/dist/flow-gallery.js +6 -2
  21. package/dist/flow-triggers.d.ts +8 -0
  22. package/dist/flow-triggers.js +15 -0
  23. package/dist/flows-cli.js +13 -3
  24. package/dist/guides.js +10 -1
  25. package/dist/kits-ui.js +4 -2
  26. package/dist/kits.d.ts +6 -4
  27. package/dist/kits.js +9 -11
  28. package/dist/mcp-connect.d.ts +10 -0
  29. package/dist/mcp-connect.js +2 -0
  30. package/dist/project-tools.js +3 -1
  31. package/dist/protected-storage.d.ts +67 -0
  32. package/dist/protected-storage.js +398 -0
  33. package/dist/release-adapters.d.ts +8 -1
  34. package/dist/release-adapters.js +80 -14
  35. package/dist/release-authority.d.ts +6 -0
  36. package/dist/release-authority.js +9 -0
  37. package/dist/release-card.d.ts +76 -0
  38. package/dist/release-card.js +252 -0
  39. package/dist/release-carry.d.ts +3 -0
  40. package/dist/release-carry.js +23 -4
  41. package/dist/release-cli.js +36 -3
  42. package/dist/release-git.d.ts +43 -0
  43. package/dist/release-git.js +106 -0
  44. package/dist/release.d.ts +8 -2
  45. package/dist/release.js +79 -6
  46. package/dist/scope.d.ts +7 -0
  47. package/dist/scope.js +22 -5
  48. package/dist/server/flows.js +40 -10
  49. package/dist/server/render-settings.js +1 -1
  50. package/dist/server/settings.js +26 -5
  51. package/dist/setup-guide.d.ts +2 -2
  52. package/dist/store.d.ts +3 -0
  53. package/dist/store.js +6 -0
  54. package/dist/subagent-memory.d.ts +1 -0
  55. package/dist/subagent-memory.js +1 -1
  56. package/dist/subagent-tools.d.ts +22 -6
  57. package/dist/subagent-tools.js +44 -8
  58. package/dist/subagent-work.d.ts +2 -0
  59. package/dist/subagent-work.js +5 -2
  60. package/dist/subagents-ui.js +6 -4
  61. package/dist/telegram.d.ts +6 -1
  62. package/dist/telegram.js +58 -6
  63. package/package.json +1 -1
@@ -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. */
@@ -467,4 +471,6 @@ export declare function checksVerdict(runs: readonly CheckRun[], required: reado
467
471
  };
468
472
  /** Run the release from wherever its journal stopped. */
469
473
  export declare function runRelease(options: ReleaseOptions, adapters: ReleaseAdapters): Promise<ReleaseOutcome>;
474
+ /** A probe's failure that is only the network to GitHub being slow, never a decision (a Stop) or another error. */
475
+ export declare function transientNetworkError(error: unknown): boolean;
470
476
  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({
@@ -886,3 +905,57 @@ async function deployed(adapters, candidate) {
886
905
  const [runtime] = runtimes;
887
906
  return runtime !== undefined && runtime !== "" && await adapters.toolroll.runtimeCommit(runtime) === candidate ? runtime : null;
888
907
  }
908
+ /**
909
+ * The gate's look at the pull request's required checks on the exact commit, before anything is filed or anyone is
910
+ * asked to approve (#237): a required check that failed or was cancelled stops here; checks not started or still
911
+ * running are named, and the gate waits for them; ones that never finish within the gate's limit stop here too. GitHub
912
+ * not answering is waited out the same way, never taken for a pass. The ci step still checks again before merging.
913
+ */
914
+ async function earlyChecks(adapters, j, wait) {
915
+ const sha = j.candidate.sha.slice(0, 7);
916
+ const required = REQUIRED_PR_CHECKS;
917
+ let named = "", unread = false;
918
+ try {
919
+ await wait(`the pull request checks on ${sha}`, async () => {
920
+ let runs;
921
+ try {
922
+ runs = await adapters.github.checks(j.github, j.candidate.sha);
923
+ }
924
+ catch (error) {
925
+ if (!unread)
926
+ adapters.say(` Couldn't read the pull request checks on ${sha} (${error?.message ?? error}); trying again.`);
927
+ unread = true;
928
+ return null;
929
+ }
930
+ const verdict = checksVerdict(runs.filter(run => required.includes(run.name)), required);
931
+ if (verdict.state === "failed") {
932
+ 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).`);
933
+ }
934
+ if (verdict.state === "passed")
935
+ return true;
936
+ const now = verdict.waiting.join(", ");
937
+ if (now !== named) {
938
+ adapters.say(` Pull request checks on ${sha} not finished yet: ${now}.`);
939
+ named = now;
940
+ }
941
+ return null;
942
+ });
943
+ }
944
+ catch (error) {
945
+ if (!(error instanceof Stop) || error.reason !== "timeout")
946
+ throw error;
947
+ 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.`);
948
+ }
949
+ }
950
+ /** The network failures a release's waits ride out: git or gh timing out (or cut off) on the way to GitHub. */
951
+ 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;
952
+ /** A probe's failure that is only the network to GitHub being slow, never a decision (a Stop) or another error. */
953
+ export function transientNetworkError(error) {
954
+ if (error instanceof Stop || error instanceof Restart)
955
+ return false;
956
+ return TRANSIENT_NETWORK.test(String(error?.message ?? error));
957
+ }
958
+ /** The part of a network failure worth saying: its matching phrase. */
959
+ function networkWords(error) {
960
+ return TRANSIENT_NETWORK.exec(String(error?.message ?? error))?.[0] ?? "a network failure";
961
+ }
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
+ }
@@ -12,13 +12,13 @@ import { run as execRun } from "../exec.js";
12
12
  import { setFlowSecret } from "../flow-actions.js";
13
13
  import { addCardToFlow, advanceFlows, cancelFlowCard, crossProjectProblem, decideFlowCard, flowDefinitionOf, moveCardInFlow } from "../flow-engine.js";
14
14
  import { galleryHtml, galleryUseHtml } from "../flow-gallery-ui.js";
15
- import { galleryDefaults, galleryDiagram, galleryTemplateOf, galleryToolsOf, previewGallery, useGalleryTemplate } from "../flow-gallery.js";
15
+ import { galleryAdmitsAnyone, galleryDefaults, galleryDiagram, galleryTemplateOf, galleryToolsOf, previewGallery, useGalleryTemplate } from "../flow-gallery.js";
16
16
  import { flowInsights } from "../flow-insights.js";
17
17
  import { assignFlowCard, commentOnFlowCard, watchFlowCard } from "../flow-people.js";
18
18
  import { saveScript } from "../flow-scripts.js";
19
19
  import { chooseFlowCard } from "../flow-send.js";
20
20
  import { exportFlow, fetchFlowFile, FlowFileError, importFlow, parseFlowFile, planFlowImport } from "../flow-share.js";
21
- import { addFlowTriggerTo, checkFlowTriggerNow, pressFlowButton, removeFlowTrigger, removeLinearKey, setFlowTriggerOn, renewFlowHook, saveHooksBase, saveLinearKey, saveSigningSecret, setUpFlowHook, changeFlowHookSender, setFlowHookScope, shareFlowButton, stopSharingFlowButton, rawTakesDeliveries } from "../flow-triggers.js";
21
+ import { addFlowTriggerTo, checkFlowTriggerNow, pressFlowButton, removeFlowTrigger, removeLinearKey, setFlowTriggerOn, renewFlowHook, saveHooksBase, saveLinearKey, saveSigningSecret, setUpFlowHook, changeFlowHookSender, setFlowHookScope, shareFlowButton, stopSharingFlowButton, rawTakesDeliveries, rawAdmitsAnyone } from "../flow-triggers.js";
22
22
  import { FLOW_IMPORT_SCRIPT, flowFallbackHtml, flowImportHtml, flowsListHtml, flowView } from "../flows-ui.js";
23
23
  import { FLOW_TEMPLATES, FlowContractError, validateFlowDefinition, withZoneNames } from "../flows.js";
24
24
  import { prepareUnattendedCandidates, parseUnattendedAsk, revokeUnattended, signUnattended, untilWords } from "../flow-unattended.js";
@@ -32,7 +32,7 @@ import { createWorkflowPreview, exportRecipe, findRecipe, importRecipe, launchWo
32
32
  import { isAlive as runnerAlive } from "../runner.js";
33
33
  import { createSubagentFrom, labelOf, nameOf, saveSoul, sendSubagentSummaries, setSubagentState, subagentSettings } from "../subagent-admin.js";
34
34
  import { addRoutine, removeRoutine, runRoutine } from "../subagent-desk.js";
35
- import { editMemory, forgetMemory, tellSubagent } from "../subagent-memory.js";
35
+ import { ACCEPT as ACCEPT_SUGGESTION, editMemory, forgetMemory, tellSubagent } from "../subagent-memory.js";
36
36
  import { grantTool, revokeTool, rulesFromForm, setToolRules } from "../subagent-tools.js";
37
37
  import { sendSubagentWeeklies, undoCall } from "../subagent-week.js";
38
38
  import { answerSubagentQuestion } from "../subagent-work.js";
@@ -301,11 +301,16 @@ export function createFlowsHandlers(runtime) {
301
301
  return redirect(response, `/kits?problem=${encodeURIComponent("Choose one of your projects.")}`);
302
302
  const page = (key, words) => redirect(response, `/kits/${kit.id}?repo=${encodeURIComponent(repo)}&${key}=${encodeURIComponent(words)}`);
303
303
  if (step === "setup") {
304
- const made = await setUpKit(store, kit, repo, who.name, now, options.configDir ?? null, { toolHome });
304
+ // Its tools come with it only on the person's proof (a password, or the session under Passwordless): a grant widens.
305
+ const proved = authenticateApprover(who, body.get("password") ?? "", repo).ok;
306
+ const made = await setUpKit(store, kit, repo, who.name, now, options.configDir ?? null, { toolHome }, proved ? { widening: "proved" } : {});
305
307
  return page(made.ok ? "said" : "problem", made.said);
306
308
  }
307
309
  if (step === "github") {
308
- const made = addKitGithubTrigger(store, kit, repo, who.name, now, options.configDir ?? null);
310
+ // Issues from anyone on GitHub come into a flow: a widening, so it takes a password (chat-management.md §0a.3).
311
+ if (!authenticateApprover(who, body.get("password") ?? "", repo).ok)
312
+ return page("problem", "Enter your Toolroll password to bring in GitHub issues. Nothing changed.");
313
+ const made = addKitGithubTrigger(store, kit, repo, who.name, now, options.configDir ?? null, "proved");
309
314
  return page(made.ok ? "said" : "problem", made.said);
310
315
  }
311
316
  const tried = addKitSample(store, kit, repo, who.name, now);
@@ -330,7 +335,10 @@ export function createFlowsHandlers(runtime) {
330
335
  const card = question === null ? null : store.getFlowCard(question.card);
331
336
  const flow = card === null ? null : store.getFlow(card.flow);
332
337
  const done = question === null || flow === null || !visible(flow.repo) ? { ok: false, said: "No such question." }
333
- : answerSubagentQuestion(store, question.id, { choice: body.get("choice"), text: body.get("text"), by: who.name, via: "web" }, now);
338
+ // Accepting a looser rule it suggested takes the password (chat-management.md §0a.3); every other answer doesn't.
339
+ : question.suggestion !== null && body.get("choice") === ACCEPT_SUGGESTION && !authenticateApprover(who, body.get("password") ?? "").ok
340
+ ? { ok: false, said: "Enter your Toolroll password to change the rule." }
341
+ : answerSubagentQuestion(store, question.id, { choice: body.get("choice"), text: body.get("text"), by: who.name, via: "web", ...(question.suggestion !== null ? { widening: "proved" } : {}) }, now);
334
342
  if (wantsJson)
335
343
  return respond(response, done.ok ? 200 : 409, "application/json; charset=utf-8", JSON.stringify(done));
336
344
  const back = question === null ? "/settings/lead/subagents" : `/settings/lead/subagents/${question.subagent}`;
@@ -403,9 +411,23 @@ export function createFlowsHandlers(runtime) {
403
411
  // v94: which project tools it may use, and its rule for each action.
404
412
  const tool = body.get("tool") ?? "", op = body.get("op");
405
413
  const grant = store.subagentGrant(mate.id, tool);
406
- const changed = op === "grant" ? await grantTool(store, mate, tool, who.name, now, { toolHome })
414
+ // Giving a subagent a tool widens what it does unasked: a password (chat-management.md §0a.3). Revoking and
415
+ // tightening a rule don't need one.
416
+ if (op === "grant" && !authenticateApprover(who, body.get("password") ?? "", mate.repo).ok) {
417
+ return redirect(response, `${back}?problem=${encodeURIComponent(`Enter your Toolroll password to let ${nameOf(mate)} use ${tool}. Nothing changed.`)}#tools`);
418
+ }
419
+ const changed = op === "grant" ? await grantTool(store, mate, tool, who.name, now, { toolHome }, { widening: "proved" })
407
420
  : op === "revoke" ? revokeTool(store, mate, tool, who.name, now)
408
- : grant === null ? { ok: false, said: `${nameOf(mate)} doesn't use ${tool}.` } : setToolRules(store, mate, tool, rulesFromForm(grant, key => body.sent.get(key)), who.name, now);
421
+ : grant === null ? { ok: false, said: `${nameOf(mate)} doesn't use ${tool}.` } : (() => {
422
+ // Loosening a rule widens what it does unasked: a password (chat-management.md §0a.3); tightening needs none.
423
+ const changes = rulesFromForm(grant, key => body.sent.get(key));
424
+ const tried = setToolRules(store, mate, tool, changes, who.name, now);
425
+ if (tried.ok || !/takes your password/.test(tried.said))
426
+ return tried;
427
+ if (!authenticateApprover(who, body.get("password") ?? "", mate.repo).ok)
428
+ return { ok: false, said: `Enter your Toolroll password to let ${nameOf(mate)} do more on its own. Nothing changed.` };
429
+ return setToolRules(store, mate, tool, changes, who.name, now, { widening: "proved" });
430
+ })();
409
431
  return redirect(response, `${back}?${changed.ok ? "said" : "problem"}=${encodeURIComponent(changed.said)}#tools`);
410
432
  }
411
433
  const done = part === "state" ? setSubagentState(store, mate, body.get("state") === "removed" ? "removed" : body.get("state") === "paused" ? "paused" : "active", who.name, now)
@@ -445,7 +467,11 @@ export function createFlowsHandlers(runtime) {
445
467
  }
446
468
  if (digest === null || digest !== body.get("previewed"))
447
469
  return sendGalleryUse(response, who, template, projects, repo, answers, name, digest === null ? null : "Your answers changed. Check the preview, then create the flow.");
448
- const used = useGalleryTemplate(store, template, repo, answers, { name, by: who.name, now: clock(), dir: options.configDir ?? null });
470
+ // A template whose trigger brings in items from anyone takes the password (chat-management.md §0a.3).
471
+ const anyone = galleryAdmitsAnyone(template);
472
+ if (anyone && !authenticateApprover(who, body.get("password") ?? "", repo).ok)
473
+ return sendGalleryUse(response, who, template, projects, repo, answers, name, "Enter your Toolroll password: this flow brings in items from anyone.");
474
+ const used = useGalleryTemplate(store, template, repo, answers, { name, by: who.name, now: clock(), dir: options.configDir ?? null, ...(anyone ? { widening: "proved" } : {}) });
449
475
  if (!used.ok)
450
476
  return sendGalleryUse(response, who, template, projects, repo, answers, name, used.said);
451
477
  return redirect(response, `/flows/${used.flow}`);
@@ -576,7 +602,11 @@ export function createFlowsHandlers(runtime) {
576
602
  const ingress = rawTakesDeliveries(raw);
577
603
  if (ingress && !authenticateCredential(who, body.get("password") ?? "").ok)
578
604
  return answer(403, { ok: false, said: "Enter your Toolroll password to make a webhook address." });
579
- const made = addFlowTriggerTo(store, flow, raw, who.name, now, dir, ingress ? { ingress: "proved" } : {});
605
+ // Items from anyone: a widening, an ordinary step-up (chat-management.md §0a.3).
606
+ const anyone = !ingress && rawAdmitsAnyone(raw);
607
+ if (anyone && !authenticateApprover(who, body.get("password") ?? "", flow.repo).ok)
608
+ return answer(403, { ok: false, said: "Enter your Toolroll password to bring in items from anyone." });
609
+ const made = addFlowTriggerTo(store, flow, raw, who.name, now, dir, ingress ? { ingress: "proved" } : anyone ? { widening: "proved" } : {});
580
610
  if (made.ok)
581
611
  await prepareUnattendedCandidates(store, flow.id);
582
612
  return made.ok ? settle(made.said, made.reveal === null ? {} : { reveal: made.reveal }) : answer(400, { ok: false, said: made.message });
@@ -170,7 +170,7 @@ export function settingsPage(chrome, existing, hasEnv, csrf, problem, messaging
170
170
  : joinHtml([
171
171
  html `<h2>Unattended permissions</h2>`,
172
172
  permissionDefault.canManage && csrf !== ""
173
- ? postForm("/settings/permission-default", html `${permissionModeChoices("permission-mode", permissionDefault.mode)}<button type="submit">Save default</button>`, { attrs: { class: "card permission-policy", "data-autosave": true } })
173
+ ? postForm("/settings/permission-default", html `${permissionModeChoices("permission-mode", permissionDefault.mode)}${permissionDefault.mode === "bypassPermissions" ? "" : reauth(html `<label>Your password <span class="meta">(needed for Full access)</span><input type="password" name="password" autocomplete="current-password"></label>`)}<button type="submit">Save default</button>`, { attrs: { class: "card permission-policy" } })
174
174
  : html `<div class="card"><p><strong>${permissionDefault.mode === "bypassPermissions" ? "Full access" : "Auto"}</strong></p><p class="meta">An approver can change this default</p></div>`,
175
175
  permissionDefault.updatedAt === null
176
176
  ? ""
@@ -1422,7 +1422,12 @@ ${repositoryContextHtml(chosen, query, result, who.role === 'approver' ? csrf :
1422
1422
  for (const [key, visit] of connectVisits)
1423
1423
  if (visit.expires < Date.now())
1424
1424
  connectVisits.delete(key);
1425
- connectVisits.set(started.state, started.visit);
1425
+ // The password above proves one thing: this connection, by this account as it is now, and (from a kit whose subagent
1426
+ // is set up) letting that subagent use this tool in this project. The callback holds the visit to exactly that.
1427
+ const kitSet = kit === null || also !== null ? null : kitOf(kit);
1428
+ const installed = kitSet === null || !kitSet.tools.some(one => one.tool === service.id) ? null : kitInstalled(store, kitSet, target);
1429
+ connectVisits.set(started.state, { ...started.visit, generation: store.accountOf(who.name)?.generation ?? -1,
1430
+ grant: installed === null ? null : { subagent: installed.mate.id, tool: service.id, repo: target } });
1426
1431
  return goOutside(response, started.go, `Going to ${service.label} to sign in…`, { state: started.state, path: CONNECT_CALLBACK, secure: origin.startsWith("https:") });
1427
1432
  }
1428
1433
  async function toolsChange(ctx) {
@@ -2015,6 +2020,11 @@ ${postForm("/settings/chat-approval/save", html `\
2015
2020
  if (wanted !== "auto" && wanted !== "bypassPermissions") {
2016
2021
  return refuse(response, who, 400, "permissions must be Auto or Full access", "/settings");
2017
2022
  }
2023
+ // Widening every new task to Full access takes a password (chat-management.md §0a.3): an ordinary step-up, so a
2024
+ // signed-in session does under Passwordless. Back to Auto narrows, and needs none.
2025
+ if (wanted === "bypassPermissions" && store.permissionDefault().mode !== "bypassPermissions" && !authenticateApprover(who, body.get("password") ?? "").ok) {
2026
+ return redirect(response, `/settings?said=${encodeURIComponent("Enter your Toolroll password to start new tasks with Full access. Nothing changed.")}`);
2027
+ }
2018
2028
  store.setPermissionDefault(wanted, who.name, now);
2019
2029
  return redirect(response, `/settings?said=${encodeURIComponent(wanted === "bypassPermissions"
2020
2030
  ? "New tasks now start with Full access. Approved tasks keep their setting."
@@ -2413,7 +2423,14 @@ ${modeWords(terms)
2413
2423
  return done("/settings/tools", "problem", "That sign-in was started in another browser. Connect again from this one.");
2414
2424
  const visit = connectVisits.get(state);
2415
2425
  connectVisits.delete(state);
2416
- if (visit === undefined || visit.expires < Date.now() || store.accountOf(visit.by)?.role !== "approver")
2426
+ // The signer as at Connect: still an approver, not revoked, the same generation, still in the project. Checked here,
2427
+ // before the connection changes, and again at the grant, after every wait.
2428
+ const standing = () => {
2429
+ const account = visit === undefined ? null : store.accountOf(visit.by);
2430
+ return visit !== undefined && account !== null && account.role === "approver" && account.revokedAt === null && visit.generation !== undefined
2431
+ && account.generation === visit.generation && store.accountCanAccess(visit.by, visit.repo);
2432
+ };
2433
+ if (visit === undefined || visit.expires < Date.now() || !standing())
2417
2434
  return done("/settings/tools", "problem", "That sign-in expired. Connect again.");
2418
2435
  const service = oneClickOf(visit.service);
2419
2436
  const kit = visit.kit === null ? null : kitOf(visit.kit);
@@ -2430,13 +2447,17 @@ ${modeWords(terms)
2430
2447
  const now = clock();
2431
2448
  if (!adapterPolicy({ caller: "service", capability: "none" }).ok)
2432
2449
  return respond(response, 403, "text/plain", "Forbidden");
2433
- const finished = await finishConnect(store, visit, code, now, { fetcher: options.connectFetch ?? fetch, home: toolHome, omitEnv: ALL_CREDENTIAL_ENV });
2450
+ const finished = await finishConnect(store, visit, code, now, { fetcher: options.connectFetch ?? fetch, home: toolHome, omitEnv: ALL_CREDENTIAL_ENV, stillStanding: standing });
2434
2451
  if (!finished.ok)
2435
2452
  return done(back, "problem", finished.said);
2436
2453
  // From a kit: its teammate may use the tool now (reading freely, the rest after a person approves each call).
2437
2454
  const set = kit === null || !kit.tools.some(one => one.tool === service.id) ? null : kitInstalled(store, kit, visit.repo);
2438
- if (set !== null && store.subagentGrant(set.mate.id, service.id) === null) {
2439
- const granted = await grantTool(store, set.mate, service.id, visit.by, now, { toolHome });
2455
+ const bound = visit.grant ?? null;
2456
+ if (set !== null && bound !== null && bound.subagent === set.mate.id && bound.tool === service.id && bound.repo === visit.repo && set.mate.repo === visit.repo
2457
+ && store.subagentGrant(set.mate.id, service.id) === null && standing()) {
2458
+ // The password at Connect (toolsConnect) is this grant's proof, for exactly the grant the visit names, while the
2459
+ // signer still stands: re-checked after the tool's listing, in the step that saves the grant.
2460
+ const granted = await grantTool(store, set.mate, service.id, visit.by, now, { toolHome }, { widening: "proved", stillProved: standing });
2440
2461
  if (granted.ok)
2441
2462
  return done(back, "said", `${service.label} is connected to ${to}, and ${nameOf(set.mate)} can use it: reading freely, the rest after you approve each call.`);
2442
2463
  }
@@ -15,10 +15,10 @@ export declare function detectPreparation(repo: string): {
15
15
  } | null;
16
16
  /** Browser installation may write only this known file inside its project. */
17
17
  export declare function previewProjectInstructions(repo: string): {
18
+ content?: never;
18
19
  ok: false;
19
20
  message: string;
20
21
  plan?: never;
21
- content?: never;
22
22
  fingerprint?: never;
23
23
  installed?: never;
24
24
  } | {
@@ -30,10 +30,10 @@ export declare function previewProjectInstructions(repo: string): {
30
30
  installed: boolean;
31
31
  };
32
32
  export declare function addProjectInstructions(repo: string, fingerprint: string): {
33
+ content?: never;
33
34
  ok: false;
34
35
  message: string;
35
36
  plan?: never;
36
- content?: never;
37
37
  fingerprint?: never;
38
38
  installed?: never;
39
39
  } | import("./skills.js").InstallResult;
package/dist/store.d.ts CHANGED
@@ -2354,6 +2354,9 @@ export declare class Store {
2354
2354
  sealScopeApproval(taskId: string, by: string, now: Date, mutation?: Mutation, basis?: {
2355
2355
  kind: "mode" | "chat" | "grant" | "schedule";
2356
2356
  modeDigest: string;
2357
+ /** A chat seal of a deploy gate: the release card it came from (chat-management.md §0a.5). */
2358
+ card?: "release";
2359
+ cardDigest?: string;
2357
2360
  }, approverKind?: ApproverKind): boolean;
2358
2361
  /** Whether this task's scope carries a LIVE seal: approved, and the
2359
2362
  * approved digest is the current one (a rewritten scope unseals). The
package/dist/store.js CHANGED
@@ -6989,6 +6989,12 @@ export class Store {
6989
6989
  const terms = storedReleaseTerms(working["release_terms_json"]);
6990
6990
  if (!terms.ok)
6991
6991
  return false;
6992
+ // A DEPLOY GATE FROM CHAT (chat-management.md §0a.5): a signed candidate or release terms seal from chat only
6993
+ // with the release card's evidence (its act and card digest), never through the plain plan card or a generic
6994
+ // one. Read from the raw row, so a terms column that doesn't parse is a deploy gate too.
6995
+ const deployGate = (typeof working["candidate"] === "string" && working["candidate"] !== "") || working["release_terms_json"] != null;
6996
+ if (deployGate && basis?.kind === "chat" && !(basis.card === "release" && typeof basis.cardDigest === "string" && /^[0-9a-f]{64}$/.test(basis.cardDigest)))
6997
+ return false;
6992
6998
  if (terms.terms !== null && basis !== undefined && basis.kind !== "chat")
6993
6999
  return false;
6994
7000
  if (terms.terms !== null && releaseSealProblem(this, terms.terms) !== null)
@@ -60,5 +60,6 @@ export declare function answerSuggestion(store: Store, mate: SubagentRow, sugges
60
60
  choice: string | null;
61
61
  text: string | null;
62
62
  by: string;
63
+ widening?: "proved";
63
64
  }, now: Date): Done;
64
65
  export {};
@@ -183,7 +183,7 @@ export function answerSuggestion(store, mate, suggestion, answer, now) {
183
183
  store.decideSubagentSuggestion(offered.id, "stale", null, now);
184
184
  return { ok: false, said: "Its rules changed since it suggested this, so nothing was changed." };
185
185
  }
186
- const changed = setToolRules(store, mate, offered.tool, { [offered.action]: { use: offered.rule.use, limit: offered.rule.limit ?? null, undo: current.undo ?? "" } }, answer.by, now);
186
+ const changed = setToolRules(store, mate, offered.tool, { [offered.action]: { use: offered.rule.use, limit: offered.rule.limit ?? null, undo: current.undo ?? "" } }, answer.by, now, answer.widening === undefined ? {} : { widening: answer.widening });
187
187
  if (!changed.ok)
188
188
  return changed;
189
189
  store.decideSubagentSuggestion(offered.id, "accepted", answer.by, now);