toolroll 0.9.74 → 0.9.76

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 (119) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/dist/agent-fence.js +8 -0
  3. package/dist/approval-authority.js +6 -2
  4. package/dist/approval-rules-ui.d.ts +11 -0
  5. package/dist/approval-rules-ui.js +13 -1
  6. package/dist/assignment.d.ts +3 -0
  7. package/dist/assignment.js +27 -8
  8. package/dist/batch-checks.js +3 -1
  9. package/dist/browser/workspace.js +27 -27
  10. package/dist/browser-workspace.d.ts +1 -1
  11. package/dist/builder.js +1 -1
  12. package/dist/chat-acceptance.d.ts +4 -1
  13. package/dist/chat-acceptance.js +13 -3
  14. package/dist/cli.js +1 -1
  15. package/dist/coding-handoff.js +4 -3
  16. package/dist/coding-ui.js +2 -2
  17. package/dist/coding-workspace.js +23 -9
  18. package/dist/contracts/console-api.d.ts +6 -1
  19. package/dist/contracts/console-api.js +11 -0
  20. package/dist/control-ui.d.ts +3 -2
  21. package/dist/control-ui.js +7 -2
  22. package/dist/demo.js +5 -3
  23. package/dist/deploy-gate.d.ts +3 -0
  24. package/dist/deploy-gate.js +3 -2
  25. package/dist/desktop-update.d.ts +4 -1
  26. package/dist/desktop-update.js +43 -9
  27. package/dist/discover.js +6 -1
  28. package/dist/first-run.d.ts +8 -5
  29. package/dist/first-run.js +42 -7
  30. package/dist/flow-card-task.js +3 -2
  31. package/dist/flow-unattended.d.ts +2 -0
  32. package/dist/flow-unattended.js +12 -1
  33. package/dist/git-incident.d.ts +49 -0
  34. package/dist/git-incident.js +69 -0
  35. package/dist/git-views.d.ts +20 -0
  36. package/dist/git-views.js +120 -0
  37. package/dist/grant.js +3 -1
  38. package/dist/integrations.d.ts +4 -3
  39. package/dist/integrations.js +5 -6
  40. package/dist/lead-contract.d.ts +1 -1
  41. package/dist/lead-contract.js +2 -2
  42. package/dist/lead-follow.js +3 -1
  43. package/dist/openrouter-models.d.ts +5 -2
  44. package/dist/openrouter-models.js +7 -4
  45. package/dist/operate.js +9 -1
  46. package/dist/planner.js +5 -1
  47. package/dist/project-knowledge.js +1 -1
  48. package/dist/project-learning.js +65 -7
  49. package/dist/project-tools.js +19 -1
  50. package/dist/pull-request-flow.js +4 -0
  51. package/dist/pull-request-ui.d.ts +1 -1
  52. package/dist/pull-request-ui.js +5 -1
  53. package/dist/push.d.ts +2 -0
  54. package/dist/push.js +24 -19
  55. package/dist/read-only-request.d.ts +4 -0
  56. package/dist/read-only-request.js +15 -0
  57. package/dist/release-adapters.d.ts +24 -2
  58. package/dist/release-adapters.js +136 -10
  59. package/dist/release-authority.d.ts +46 -0
  60. package/dist/release-authority.js +64 -0
  61. package/dist/release-auto.d.ts +153 -0
  62. package/dist/release-auto.js +496 -0
  63. package/dist/release-cli.js +56 -2
  64. package/dist/release-coverage.js +1 -1
  65. package/dist/release.d.ts +105 -1
  66. package/dist/release.js +229 -9
  67. package/dist/repository-context.js +5 -2
  68. package/dist/result-follow-ups.js +23 -2
  69. package/dist/result-review.js +5 -1
  70. package/dist/scope.d.ts +31 -2
  71. package/dist/scope.js +44 -3
  72. package/dist/scout.js +5 -1
  73. package/dist/serve.js +162 -6
  74. package/dist/server/agents-live-filed.d.ts +17 -0
  75. package/dist/server/agents-live-filed.js +97 -0
  76. package/dist/server/agents-live-room.js +4 -1
  77. package/dist/server/chat.js +31 -23
  78. package/dist/server/http.d.ts +5 -0
  79. package/dist/server/last-checks.d.ts +17 -0
  80. package/dist/server/last-checks.js +22 -0
  81. package/dist/server/pages.js +18 -4
  82. package/dist/server/people-tokens.js +7 -4
  83. package/dist/server/render-pages.d.ts +2 -1
  84. package/dist/server/render-pages.js +28 -24
  85. package/dist/server/render-results.d.ts +11 -2
  86. package/dist/server/render-results.js +54 -12
  87. package/dist/server/render-settings.js +3 -3
  88. package/dist/server/render-tasks.d.ts +10 -0
  89. package/dist/server/render-tasks.js +30 -17
  90. package/dist/server/route-table.js +17 -1
  91. package/dist/server/runtime.d.ts +16 -1
  92. package/dist/server/settings.js +111 -31
  93. package/dist/server/task-views.js +8 -1
  94. package/dist/server/tasks.js +11 -2
  95. package/dist/storage-ui.d.ts +2 -0
  96. package/dist/storage-ui.js +1 -1
  97. package/dist/storage.js +2 -1
  98. package/dist/store.d.ts +4 -1
  99. package/dist/store.js +52 -13
  100. package/dist/subagent-work.js +3 -0
  101. package/dist/task-base.js +1 -1
  102. package/dist/task-status.d.ts +28 -1
  103. package/dist/task-status.js +93 -11
  104. package/dist/teardown-git.d.ts +59 -5
  105. package/dist/teardown-git.js +1022 -90
  106. package/dist/telegram-progress.js +4 -4
  107. package/dist/telegram-status.js +8 -3
  108. package/dist/test-infra.d.ts +26 -0
  109. package/dist/test-infra.js +321 -0
  110. package/dist/tool-secret-store.js +8 -0
  111. package/dist/toolroll-update-ui.d.ts +2 -0
  112. package/dist/toolroll-update-ui.js +4 -3
  113. package/dist/toolroll-update.js +10 -9
  114. package/dist/work-index.js +29 -12
  115. package/dist/workspace-ui.d.ts +5 -0
  116. package/dist/workspace-ui.js +38 -10
  117. package/dist/worktree.d.ts +4 -0
  118. package/dist/worktree.js +13 -7
  119. package/package.json +1 -1
@@ -0,0 +1,496 @@
1
+ /**
2
+ * Releases that approve themselves when green (docs/plans/auto-approve-green.md): the project setting "Release
3
+ * approval: Ask me / Approve automatically when checks pass", and the plane's two automatic seals of a release task.
4
+ *
5
+ * auto-check (the approval step) lets the release task's build run its check — nothing else;
6
+ * auto-green (after the check) the release approval, once the plane itself saw the pull request's CI and the
7
+ * release check pass on the exact candidate. It opens the release's authority as a
8
+ * person's seal of release terms does.
9
+ *
10
+ * Both are written only here, each in one write transaction that proves every fact again from the plane's own records,
11
+ * its own `gh` observation (handed in by release-adapters.ts, which made it) and verified objects in the project's own
12
+ * Git directory. No command or console road reaches these functions: `toolroll release` calls them through its store
13
+ * adapter. Any refusal unseals `auto-check`, so the task asks a person, its passing check kept.
14
+ */
15
+ import { assignmentOf } from "./assignment.js";
16
+ import { projectGitDir } from "./check-report.js";
17
+ import { runCheckLevel } from "./check-levels.js";
18
+ import { readVerifiedCommit, verifiedBlobs, verifiedTreeDiff, verifiedTreeEntries } from "./commit-extract.js";
19
+ import { mightName, testInfrastructure } from "./test-infra.js";
20
+ import { chatControlHref } from "./chat-controls.js";
21
+ import { AUTO_FLAKE_CAP, REQUIRED_PR_CHECKS, prChecksVerdict } from "./release.js";
22
+ import { CARRY_RULES, rulePath } from "./release-carry.js";
23
+ import { AUTO_EVIDENCE_ACTION, AUTO_WITHDRAWN_ACTION, LIVE_RELEASE_STATES, RELEASE_APPROVAL_ACTION, releaseAuthorityOfTask, releaseAutoApproves, releaseApprovalSetting } from "./release-authority.js";
24
+ import { autoReleaseApprovalOf, personApprovalOf } from "./scope.js";
25
+ import { releaseOperations, revokeRelease } from "./store.js";
26
+ import { verificationEvidence } from "./verification-evidence.js";
27
+ export { RELEASE_APPROVAL_ACTION, releaseApprovalSetting, releaseAutoApproves };
28
+ /** The setting's words, one place. */
29
+ export const RELEASE_APPROVAL_WORDS = { ask: "Ask me", auto: "Approve automatically when checks pass" };
30
+ /** How old the plane's own CI observation may be when it seals. */
31
+ export const CI_OBSERVATION_MS = 5 * 60_000;
32
+ const refuse = (why) => ({ ok: false, why });
33
+ /**
34
+ * Change the setting: an approver's act, proven by the caller (on: a password typed now; off: one click). Lowering it,
35
+ * in the same transaction, unseals every automatic seal of the project whose release hasn't started merging and stops
36
+ * their live releases, so nothing approves — or goes on — on the old setting.
37
+ */
38
+ export function setReleaseApproval(store, repo, auto, by, now) {
39
+ return store.transact(() => {
40
+ const before = releaseApprovalSetting(store, repo).auto;
41
+ if (before === auto)
42
+ return { changed: false, withdrawn: [] };
43
+ store.recordAction({ at: now.toISOString(), actor: by, repo, taskId: null, runId: null, action: RELEASE_APPROVAL_ACTION, outcome: auto ? "auto" : "ask", source: "policy",
44
+ detail: `Release approval: ${before ? RELEASE_APPROVAL_WORDS.auto : RELEASE_APPROVAL_WORDS.ask} → ${auto ? RELEASE_APPROVAL_WORDS.auto : RELEASE_APPROVAL_WORDS.ask}` });
45
+ const withdrawn = [];
46
+ if (!auto) {
47
+ const sealed = store.handle.prepare(`SELECT s.task_id FROM task_scope s JOIN task_ref r ON r.external_id = s.task_id AND r.backend = 'built-in'
48
+ WHERE r.repo = ? AND s.approved_at IS NOT NULL AND s.approval_basis IN ('auto-check', 'auto-green')`).all(repo).map(row => String(row["task_id"]));
49
+ for (const taskId of sealed) {
50
+ const found = releaseAuthorityOfTask(store, taskId);
51
+ const a = found?.authority ?? null;
52
+ if (a !== null && a.state === "merged")
53
+ continue;
54
+ if (a !== null && a.state === "merge-reserved") {
55
+ // A merge already issued (running, or its outcome unknown) is genuinely in flight: it finishes through its own
56
+ // reconciliation. One only reserved, never issued, is stopped for good here, so it can't come back.
57
+ const issued = releaseOperations(store, a.id).some(op => op.kind === "merge" && (op.state === "running" || op.state === "unknown" || op.state === "succeeded"));
58
+ if (issued) {
59
+ // In flight: it reconciles on its own (merged finishes; failed goes back to deploying) — but the seal is
60
+ // withdrawn now, so a failed merge never regains the automatic approval, even with the setting back on.
61
+ withdraw(store, taskId, now);
62
+ continue;
63
+ }
64
+ store.handle.prepare("UPDATE release_authority SET state = 'revoked', ended_at = ?, ended_by = ?, rev = rev + 1 WHERE id = ? AND rev = ?")
65
+ .run(now.toISOString(), `${by}: automatic release approval turned off`, a.id, a.rev);
66
+ store.recordAction({ at: now.toISOString(), actor: by, repo: a.repo, taskId, runId: null, action: "release revoked", outcome: "revoked", source: "work", detail: `release ${a.id}: automatic release approval turned off before its merge was issued` });
67
+ }
68
+ else if (a !== null && LIVE_RELEASE_STATES.includes(a.state))
69
+ revokeRelease(store, a.id, `${by}: automatic release approval turned off`, now);
70
+ unseal(store, taskId, now);
71
+ withdrawn.push(taskId);
72
+ store.enqueueNotification({ dedupeKey: `release-auto-withdrawn:${taskId}:${now.toISOString()}`, kind: "release-auto", subject: "Release needs your approval",
73
+ body: "Automatic release approval was turned off, so this release waits for a person's approval.", link: chatControlHref("task", taskId), source: { taskRef: store.lookupRef(taskId).id } }, now);
74
+ }
75
+ }
76
+ store.bumpWake();
77
+ return { changed: true, withdrawn };
78
+ });
79
+ }
80
+ /** Take back an automatic seal for good: the task asks a person again (its result, if any, is kept), and its evidence is
81
+ * marked withdrawn, so no later reading — the setting turned on again included — lets it stand. */
82
+ function unseal(store, taskId, now) {
83
+ withdraw(store, taskId, now);
84
+ store.handle.prepare(`UPDATE task_scope SET approved_at = NULL, approved_by = NULL, approved_digest = NULL, approved_profile_json = NULL,
85
+ approved_route_json = NULL, approval_basis = NULL, mode_digest = NULL WHERE task_id = ? AND approval_basis IN ('auto-check', 'auto-green')`).run(taskId);
86
+ }
87
+ /** Mark a task's automatic seal's evidence withdrawn for good (its seal row may stay while an issued merge reconciles). */
88
+ function withdraw(store, taskId, now) {
89
+ const sealedWith = store.handle.prepare("SELECT mode_digest FROM task_scope WHERE task_id = ? AND approval_basis IN ('auto-check', 'auto-green')").get(taskId)?.["mode_digest"];
90
+ if (typeof sealedWith === "string")
91
+ store.recordAction({ at: now.toISOString(), actor: "toolroll", repo: store.lookupRef(taskId)?.repo ?? null, taskId, runId: null, action: AUTO_WITHDRAWN_ACTION, outcome: sealedWith, source: "work" });
92
+ }
93
+ /** What every automatic seal needs before anything else: the setting on now, a release task in this project with its
94
+ * candidate and release terms signed in, the exact digest the coordinator filed, and a person who can approve here. */
95
+ function releaseTaskFacts(store, input) {
96
+ const ref = store.lookupRef(input.taskId);
97
+ const scope = store.getScope(input.taskId);
98
+ if (ref === null || ref.repo === null || scope === null)
99
+ return refuse("the release task isn't in a project Toolroll knows");
100
+ const setting = releaseApprovalSetting(store, ref.repo);
101
+ if (!setting.auto || setting.setBy === null)
102
+ return refuse("this project's releases aren't approved automatically");
103
+ if ((scope.candidate ?? null) === null || (scope.releaseTerms ?? null) === null)
104
+ return refuse("only a release task is approved automatically");
105
+ if (scope.candidate !== input.candidate)
106
+ return refuse(`the release task is for ${String(scope.candidate).slice(0, 7)}, not ${input.candidate.slice(0, 7)}`);
107
+ if (scope.digest !== input.digest)
108
+ return refuse("the release scope changed after it was filed");
109
+ if (scope.termsProblem != null)
110
+ return refuse("the release scope can't be read exactly");
111
+ const by = setting.setBy;
112
+ const account = store.accountOf(by);
113
+ if (account === null || account.revokedAt !== null || account.role !== "approver" || !store.accountCanAccess(by, ref.repo))
114
+ return refuse(`${by}, who turned on automatic release approval, can no longer approve in this project`);
115
+ const terms = scope.releaseTerms;
116
+ // A release a person or Toolroll stopped (revoked, or a CI failure after its deploy) asks a person again.
117
+ const stopped = store.handle.prepare("SELECT 1 AS hit FROM release_authority WHERE repo = ? AND version = ? AND pr = ? AND state = 'revoked' LIMIT 1").get(terms.repo, terms.packageVersion, terms.pr);
118
+ if (stopped !== undefined)
119
+ return refuse(`release ${terms.packageVersion} was stopped before, so a person approves it again`);
120
+ return { ok: true, repo: ref.repo, by, taskRef: ref.id, version: terms.packageVersion, pr: terms.pr };
121
+ }
122
+ /** A refusal inside a seal's transaction: thrown, so nothing it wrote (the evidence row) stays. */
123
+ class AutoRefused extends Error {
124
+ }
125
+ /** One evidence row, then the seal naming it, in the caller's transaction. Throws AutoRefused when the seal primitive
126
+ * refused, so the evidence row rolls back with it. */
127
+ function sealWith(store, kind, taskId, by, repo, detail, now, why) {
128
+ // Written whole (recordAction shortens details for display): the evidence is the record of what proved the seal.
129
+ const written = store.handle.prepare("INSERT INTO action_ledger(at,actor,repo,task_id,run_id,action,outcome,source,detail) VALUES (?,?,?,?,?,?,?,?,?)")
130
+ .run(now.toISOString(), "toolroll", repo, taskId, typeof detail["run"] === "number" ? detail["run"] : null, AUTO_EVIDENCE_ACTION, kind, "work", JSON.stringify({ v: 1, by, ...detail }));
131
+ const id = Number(written.lastInsertRowid);
132
+ if (!store.sealScopeApproval(taskId, by, now, {}, { kind, modeDigest: `auto:${id}` }, "automation"))
133
+ throw new AutoRefused(why);
134
+ }
135
+ /** Run `body` in one write transaction; an AutoRefused inside it rolls everything back and is the answer. */
136
+ function inTransaction(store, body) {
137
+ try {
138
+ return store.transact(body);
139
+ }
140
+ catch (error) {
141
+ if (error instanceof AutoRefused)
142
+ return refuse(error.message);
143
+ throw error;
144
+ }
145
+ }
146
+ /**
147
+ * The approval step with the setting on: seal `auto-check`, so the release task's build runs its check before anyone
148
+ * approves it. Idempotent: an `auto-check` already standing on this digest is the answer. A person's own approval
149
+ * already there is left alone (the coordinator then takes the person's road).
150
+ */
151
+ export function authorizeAutoCheck(store, input) {
152
+ return inTransaction(store, () => {
153
+ const facts = releaseTaskFacts(store, input);
154
+ if (!facts.ok)
155
+ return facts;
156
+ const scope = store.getScope(input.taskId);
157
+ if (scope.approvedAt !== null && scope.approvedDigest === scope.digest) {
158
+ if (scope.approvalBasis === "auto-check") {
159
+ const standing = autoReleaseApprovalOf(store, input.taskId, "auto-check");
160
+ return standing.ok ? { ok: true, already: true } : refuse(standing.why);
161
+ }
162
+ return refuse(personApprovalOf(store, input.taskId).ok ? "a person already approved it" : "it already has an approval that isn't automatic");
163
+ }
164
+ if (!/^[0-9a-f]{40}$/.test(input.base))
165
+ return refuse("GitHub named no base for this commit");
166
+ const gitDir = projectGitDir(facts.repo);
167
+ if (gitDir === null || !readVerifiedCommit(gitDir, input.base).ok)
168
+ return refuse(`the base ${input.base.slice(0, 7)} isn't in the project's own Git directory`);
169
+ sealWith(store, "auto-check", input.taskId, facts.by, facts.repo, { digest: input.digest, candidate: input.candidate, base: input.base }, input.now, "the scope can't be approved automatically (its approval rules, text or check access need a person)");
170
+ return { ok: true, already: false };
171
+ });
172
+ }
173
+ /** Test infrastructure that lives under src/ (helpers and fixtures, not tests): a person approves changes to it. */
174
+ const SRC_TEST_INFRA = /^src\/(fixtures\/|(.*\/)?(test-[^/]*|[^/]*-test-support[^/]*|[^/]*fixtures?[^/]*)\.[cm]?[jt]sx?$)/;
175
+ /** A test file (normalized path): `*.test.ts`, never inside a fixtures folder. */
176
+ const TEST_FILE = /^(?!.*(^|\/)fixtures?\/)[^]*\.test\.ts$/;
177
+ /** Build or type configuration anywhere: a person approves it. */
178
+ const BUILD_CONFIG = /(^|\/)(tsconfig[^/]*\.json|[^/]*\.config\.[cm]?[jt]s|\.npmrc|\.nvmrc|\.node-version)$/;
179
+ /**
180
+ * What an automatic approval may ship without a person, as an allowlist over each changed path (auto-approve-green.md):
181
+ * product code under `src/` (its tests included; never its test helpers or fixtures, nor build configuration), test files
182
+ * under `test/` (only `*.test.ts`: never a helper, fixture or setup file; deleting any test needs a person), docs
183
+ * (never the testing policy or plans, never agent or security policy), the changelog and README, and a version bump of
184
+ * package.json and package-lock.json. Everything else — CI, scripts (the check, the build, the harness), test/,
185
+ * dependencies, the lockfile beyond its version, config — and every path the release carry rules never carry, needs a
186
+ * person. Null when the path may ship.
187
+ */
188
+ export function autoPathProblem(path) {
189
+ const normal = rulePath(path);
190
+ const testUnderTest = TEST_FILE.test(normal) && normal.startsWith("test/");
191
+ // Every deny rule first — the carry rules' never list (minus its blanket test/ rule, for test files there only) and
192
+ // build configuration — so a test file can't slip a policy or CI path through (test/attacks/.github/a.test.ts).
193
+ for (const rule of CARRY_RULES.never) {
194
+ if (testUnderTest && rule.matches("test/__probe__.test.ts"))
195
+ continue;
196
+ if (rule.matches(normal))
197
+ return rule.why;
198
+ }
199
+ // Test files under test/ carry the same trust as src's (Alex: test authors are trusted) — only the `.test.ts` files
200
+ // themselves, never a helper, fixture, setup or config file beside them, nor one inside a fixtures folder.
201
+ if (testUnderTest)
202
+ return null;
203
+ if (BUILD_CONFIG.test(normal))
204
+ return "it configures the build or the checks";
205
+ if (normal === "package.json" || normal === "package-lock.json")
206
+ return null;
207
+ if (normal.startsWith("src/"))
208
+ return SRC_TEST_INFRA.test(normal) ? "it's a test helper or fixture" : null;
209
+ if (normal === "changelog.md" || normal === "readme.md")
210
+ return null;
211
+ if (normal.startsWith("docs/") && normal.endsWith(".md"))
212
+ return null;
213
+ return "it's outside what a release approves itself for (product code, tests, docs, the changelog and a version bump)";
214
+ }
215
+ /** Flakes: how many failed attempts one CI job, and how many unsuccessful release-check runs, may have before a person
216
+ * decides. The head and digest are pinned, so between attempts only nondeterminism differs. */
217
+ export const MAX_FAILED_ATTEMPTS = AUTO_FLAKE_CAP;
218
+ /**
219
+ * GitHub CI on the exact head, as the plane read it (every attempt): fresh, every run GitHub Actions, and the same
220
+ * verdict every later release boundary uses (release.ts prChecksVerdict: exact head, each job by its latest attempt,
221
+ * every required check present), with the flake cap. The tolerated failures are returned for the evidence.
222
+ */
223
+ export function ciVerdict(observation, candidate, now) {
224
+ const no = (problem) => ({ ok: false, problem });
225
+ if (observation.head !== candidate)
226
+ return no(`CI was read for ${observation.head.slice(0, 7)}, not ${candidate.slice(0, 7)}`);
227
+ const at = Date.parse(observation.observedAt);
228
+ if (!Number.isFinite(at) || at > now.getTime() + 60_000 || now.getTime() - at > CI_OBSERVATION_MS)
229
+ return no("the CI reading isn't fresh");
230
+ if (observation.complete !== true)
231
+ return no("GitHub's check history of this commit can't be read whole");
232
+ const other = observation.runs.find(run => run.app !== "github-actions");
233
+ if (other !== undefined)
234
+ return no(`${other.name} was reported by ${other.app ?? "an unknown app"}, not GitHub Actions`);
235
+ const verdict = prChecksVerdict(observation.runs, candidate, REQUIRED_PR_CHECKS, { cap: MAX_FAILED_ATTEMPTS });
236
+ if (verdict.state === "failed")
237
+ return no(verdict.failed.join(", "));
238
+ if (verdict.state === "pending")
239
+ return no(`not finished: ${verdict.waiting.join(", ")}`);
240
+ return { ok: true, failures: verdict.failures };
241
+ }
242
+ /** ciVerdict's problem, or null when CI passes. */
243
+ export function ciProblem(observation, candidate, now) {
244
+ const verdict = ciVerdict(observation, candidate, now);
245
+ return verdict.ok ? null : verdict.problem;
246
+ }
247
+ /** package.json or package-lock.json with only its version fields left out, canonical (sorted keys); undefined when it
248
+ * can't be read. */
249
+ function withoutVersion(path, bytes) {
250
+ if (bytes === undefined)
251
+ return null;
252
+ let parsed;
253
+ try {
254
+ parsed = JSON.parse(bytes.toString("utf8"));
255
+ }
256
+ catch {
257
+ return undefined;
258
+ }
259
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
260
+ return undefined;
261
+ const copy = structuredClone(parsed);
262
+ delete copy["version"];
263
+ if (path === "package-lock.json") {
264
+ const root = copy["packages"]?.[""];
265
+ if (root !== undefined && typeof root === "object" && root !== null)
266
+ delete root["version"];
267
+ }
268
+ const canonical = (value) => Array.isArray(value) ? value.map(canonical)
269
+ : value !== null && typeof value === "object" ? Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical(value[key])])) : value;
270
+ return JSON.stringify(canonical(copy));
271
+ }
272
+ /** The test infrastructure of one verified tree: its source files under src/ and test/ and its vitest configs, scanned. */
273
+ function infrastructureAt(gitDir, tree) {
274
+ const entries = verifiedTreeEntries(gitDir, tree);
275
+ if (!entries.ok)
276
+ return null;
277
+ const files = entries.value.filter(one => one.mode === "100644" || one.mode === "100755");
278
+ const paths = files.filter(one => (/\.(?:[cm]?[jt]sx?)$/.test(one.path) && !one.path.startsWith("node_modules/")) || one.path === "tsconfig.json" || one.path === "package.json").map(one => one.path);
279
+ const blobs = verifiedBlobs(gitDir, tree, paths);
280
+ if (!blobs.ok)
281
+ return null;
282
+ try {
283
+ return testInfrastructure(new Map([...blobs.value].map(([path, blob]) => [path, blob.bytes.toString("utf8")])), new Set(entries.value.map(one => one.path)));
284
+ }
285
+ catch {
286
+ return null;
287
+ }
288
+ }
289
+ /** What the candidate changes since `base` (the plane's own reading of where it left its base branch), from verified
290
+ * objects, that an automatic approval never ships. Null when every change may ship. */
291
+ function changeProblem(repo, base, candidate) {
292
+ const gitDir = projectGitDir(repo);
293
+ if (gitDir === null)
294
+ return "the project's Git directory can't be read";
295
+ if (base === null || !/^[0-9a-f]{40}$/.test(base))
296
+ return "no base was recorded to compare with";
297
+ const before = readVerifiedCommit(gitDir, base), after = readVerifiedCommit(gitDir, candidate);
298
+ if (!before.ok || !after.ok)
299
+ return "the candidate's commits can't be read";
300
+ const diff = verifiedTreeDiff(gitDir, before.value.tree, after.value.tree);
301
+ if (!diff.ok)
302
+ return `the candidate's change can't be read: ${diff.message}`;
303
+ // Test infrastructure found from the import graph (test-infra.ts), at the base — which the candidate can't rewrite —
304
+ // and at the candidate (a new helper only tests import). Unreadable: refuse.
305
+ const scans = [];
306
+ for (const tree of [before.value.tree, after.value.tree]) {
307
+ const found = infrastructureAt(gitDir, tree);
308
+ if (found === null)
309
+ return "the tests' imports can't be read";
310
+ scans.push(found);
311
+ }
312
+ const infra = new Set(scans.flatMap(one => [...one.infra]));
313
+ for (const change of diff.value) {
314
+ const modes = [change.before?.mode, change.after?.mode].filter((one) => one !== undefined);
315
+ if (modes.includes("120000") || modes.includes("160000"))
316
+ return `${change.path} is a symlink or submodule`;
317
+ // Taking a test away is never automatic, wherever it lives.
318
+ if (change.kind === "deleted" && TEST_FILE.test(rulePath(change.path)))
319
+ return `${change.path} was deleted (removing a test needs a person)`;
320
+ const why = autoPathProblem(change.path);
321
+ if (why !== null)
322
+ return `${change.path} changed (${why})`;
323
+ if (infra.has(change.path))
324
+ return `${change.path} changed (tests import it and the product doesn't: it's test infrastructure)`;
325
+ // An import the tests make that couldn't be resolved might name this file: fail closed.
326
+ for (const scan of scans) {
327
+ const open = scan.unresolved.find(one => mightName(one, change.path, scan));
328
+ if (open !== undefined)
329
+ return `${change.path} changed, and ${open.from} imports ${open.specifier === null ? "a computed path" : open.specifier}, which couldn't be resolved and might name it`;
330
+ }
331
+ const normal = rulePath(change.path);
332
+ if (normal === "package.json" || normal === "package-lock.json") {
333
+ const read = (tree) => { const blobs = verifiedBlobs(gitDir, tree, [change.path]); return blobs.ok ? withoutVersion(normal, blobs.value.get(change.path)?.bytes) : undefined; };
334
+ const was = read(before.value.tree), now = read(after.value.tree);
335
+ if (was === undefined || now === undefined || was !== now)
336
+ return `${change.path} changed beyond its version (dependencies or scripts need a person)`;
337
+ }
338
+ }
339
+ return null;
340
+ }
341
+ /**
342
+ * The release approval, once the check passed: seal `auto-green` over the standing `auto-check`, or refuse in words —
343
+ * and on any refusal take the `auto-check` back, so a person decides. Everything is proven again in this one write
344
+ * transaction (docs/plans/auto-approve-green.md, "What auto-green proves").
345
+ */
346
+ export function sealAutoGreen(store, input) {
347
+ const answer = inTransaction(store, () => {
348
+ const facts = releaseTaskFacts(store, input);
349
+ if (!facts.ok)
350
+ return facts;
351
+ const checking = autoReleaseApprovalOf(store, input.taskId, "auto-check");
352
+ if (!checking.ok)
353
+ return refuse(`its check wasn't authorized automatically: ${checking.why}`);
354
+ // The release check: this task's build on this digest, its native receipt, a Full level and no failed check before.
355
+ const run = store.getRun(input.run);
356
+ if (run === null || run.taskRef !== facts.taskRef || run.role !== "builder" || run.finishedAt === null || !(run.outcome === "built" || run.outcome === "no-change"))
357
+ return refuse(`run ${input.run} isn't a finished build of this release task`);
358
+ if (run.scopeDigest !== input.digest)
359
+ return refuse(`run ${input.run} checked different scope text`);
360
+ const head = run.headRevision;
361
+ if (head === null)
362
+ return refuse(`run ${input.run} recorded no commit`);
363
+ if (runCheckLevel(store, run.id) !== "full")
364
+ return refuse("the release check wasn't Full");
365
+ // A flake: earlier builds of this task may have failed — every one that didn't finish built with a passed check
366
+ // counts (a failed check, a check that never ran, a build that failed or was interrupted) — but only re-runs of the
367
+ // same signed scope (the regate road keeps the candidate and digest), and at most MAX_FAILED_ATTEMPTS of them.
368
+ const failedRuns = store.handle.prepare(`SELECT r.id, r.scope_digest FROM run r LEFT JOIN run_check c ON c.run = r.id
369
+ WHERE r.task_ref = ? AND r.role = 'builder' AND r.id <> ?
370
+ AND NOT (COALESCE(r.outcome, '') IN ('built', 'no-change') AND COALESCE(c.status, '') = 'passed') ORDER BY r.id`).all(facts.taskRef, run.id);
371
+ if (failedRuns.some(one => one["scope_digest"] !== input.digest))
372
+ return refuse("an earlier check of this release ran on different scope text, so a person decides");
373
+ if (failedRuns.length > MAX_FAILED_ATTEMPTS)
374
+ return refuse(`the release check failed ${failedRuns.length} times before it passed, so a person decides`);
375
+ const checkFailures = failedRuns.map(one => Number(one["id"]));
376
+ const newest = store.runsFor(facts.taskRef).find(one => one.role === "builder");
377
+ if (newest?.id !== run.id)
378
+ return refuse(`run ${input.run} isn't this task's newest build`);
379
+ const evidence = verificationEvidence(store, input.evidenceRoot, run.id);
380
+ if (!evidence.ok || evidence.bytes === null)
381
+ return refuse(`the release check's receipt can't be read${evidence.ok ? "" : `: ${evidence.problem}`}`);
382
+ let gate;
383
+ try {
384
+ gate = JSON.parse(evidence.bytes.toString());
385
+ }
386
+ catch {
387
+ return refuse("the release check's receipt can't be read");
388
+ }
389
+ const live = store.liveVerifyCommand(facts.repo);
390
+ const result = gate["result"];
391
+ const command = gate["command"];
392
+ if (gate["coverage"] !== undefined && gate["coverage"] !== "full")
393
+ return refuse(`the release check didn't cover everything (${String(gate["coverage"])})`);
394
+ if (gate["run"] !== run.id || gate["head"] !== head || gate["scopeDigest"] !== input.digest || result?.ran !== true || result.exitCode !== 0 || live === null || command?.command !== live.command) {
395
+ return refuse("the release check didn't pass this candidate under the approved command");
396
+ }
397
+ const assignment = assignmentOf(store, input.taskId, input.now, { principal: "operator", repos: null, includeUnplaced: true }, input.evidenceRoot);
398
+ const receipt = assignment?.receipt ?? null;
399
+ if (receipt === null || receipt.runId !== run.id || receipt.digest !== input.receipt || receipt.head !== head || receipt.scopeDigest !== input.digest)
400
+ return refuse("the result the release read isn't this build's");
401
+ // The candidate's change, from verified objects: nothing that defines CI or the check, or steers agents.
402
+ if (head !== input.candidate) {
403
+ // A prepared candidate's build records its own commit of exactly the candidate's tree (builder.ts).
404
+ const gitDir = projectGitDir(facts.repo);
405
+ const built = gitDir === null ? null : readVerifiedCommit(gitDir, head), wanted = gitDir === null ? null : readVerifiedCommit(gitDir, input.candidate);
406
+ if (built === null || wanted === null || !built.ok || !wanted.ok || built.value.tree !== wanted.value.tree)
407
+ return refuse(`the build's commit ${head.slice(0, 7)} doesn't hold exactly ${input.candidate.slice(0, 7)}'s files`);
408
+ }
409
+ // Compared with where the candidate left its base branch, as the plane read it when the check was authorized —
410
+ // the same base for every attempt, never a build's own recorded base (a task branch can be seeded ahead of time).
411
+ const base = checking.evidence.base;
412
+ const changed = changeProblem(facts.repo, base, input.candidate);
413
+ if (changed !== null)
414
+ return refuse(changed);
415
+ // GitHub CI, as the plane's own gh saw it moments ago, on exactly this commit.
416
+ const ci = ciVerdict(input.ci, input.candidate, input.now);
417
+ if (!ci.ok)
418
+ return refuse(`CI: ${ci.problem}`);
419
+ const observed = input.ci.runs.map(one => ({ id: one.id, name: one.name, suite: one.suite ?? null }));
420
+ sealWith(store, "auto-green", input.taskId, facts.by, facts.repo, {
421
+ digest: input.digest, candidate: input.candidate, run: run.id, receipt: input.receipt, head, base, ci: observed, observedAt: input.ci.observedAt,
422
+ // What failed first on this same commit and digest (flakes), for whoever reads the record later.
423
+ ciFailures: ci.failures, checkFailures,
424
+ }, input.now, "the release terms can't be approved now (an unsettled deploy, a merge already started, or rules that need a person)");
425
+ store.enqueueNotification({ dedupeKey: `release-auto-approved:${input.taskId}:${input.digest}`, kind: "release-auto",
426
+ subject: `Release ${facts.version} approved automatically`,
427
+ body: `CI and the release check passed on ${input.candidate.slice(0, 7)}, so Toolroll approved it under your project setting. It deploys, merges and publishes next.`,
428
+ link: chatControlHref("task", input.taskId), source: { taskRef: facts.taskRef } }, input.now);
429
+ return { ok: true, by: facts.by, at: input.now.toISOString(), ci: observed };
430
+ });
431
+ if (!answer.ok)
432
+ giveUpAutoApproval(store, { taskId: input.taskId, why: answer.why, now: input.now });
433
+ return answer;
434
+ }
435
+ /** The automatic road ended without an approval (a refusal, a failed check, a CI failure after the deploy): take the
436
+ * `auto-check` back so the task asks a person, and tell the owner why. Idempotent. */
437
+ export function giveUpAutoApproval(store, input) {
438
+ store.transact(() => {
439
+ const ref = store.lookupRef(input.taskId);
440
+ if (ref === null)
441
+ return;
442
+ unseal(store, input.taskId, input.now);
443
+ const scope = store.getScope(input.taskId);
444
+ const version = scope?.releaseTerms?.packageVersion ?? null;
445
+ store.enqueueNotification({ dedupeKey: `release-auto-stopped:${input.taskId}:${input.why.slice(0, 120)}`, kind: "release-auto", pushClass: "attention",
446
+ subject: `Release${version === null ? "" : ` ${version}`} needs you`,
447
+ body: `Not approved automatically: ${input.why}. Approve it yourself, or fix the branch and release the new commit.`,
448
+ link: chatControlHref("task", input.taskId), source: { taskRef: ref.id } }, input.now);
449
+ });
450
+ }
451
+ /** A release check failed while checking automatically: tell the owner. The `auto-check` stays, so a regate of the same
452
+ * commit and digest (a flake) may still approve itself, within MAX_FAILED_ATTEMPTS failed runs. */
453
+ export function noteAutoCheckFailed(store, input) {
454
+ const ref = store.lookupRef(input.taskId);
455
+ if (ref === null)
456
+ return;
457
+ const version = store.getScope(input.taskId)?.releaseTerms?.packageVersion ?? null;
458
+ store.enqueueNotification({ dedupeKey: `release-auto-check-failed:${input.taskId}:${input.why.slice(0, 120)}`, kind: "release-auto", pushClass: "attention",
459
+ subject: `Release${version === null ? "" : ` ${version}`} check failed`,
460
+ body: `${input.why}. Rerun it on the same commit (a flake still approves itself, up to ${MAX_FAILED_ATTEMPTS} failed checks), or fix the branch and release the new commit.`,
461
+ link: chatControlHref("task", input.taskId), source: { taskRef: ref.id } }, input.now);
462
+ }
463
+ /** "Released 0.9.x automatically": the release that approved itself finished. */
464
+ export function noteAutoRelease(store, input) {
465
+ const ref = store.lookupRef(input.taskId);
466
+ if (ref === null)
467
+ return;
468
+ store.enqueueNotification({ dedupeKey: `release-auto-released:${input.taskId}:${input.tag}`, kind: "release-auto",
469
+ subject: `Released ${input.tag} automatically`, body: `${input.tag} is deployed, merged, tagged and published. CI and the release check passed, so nobody had to approve it.`,
470
+ link: chatControlHref("task", input.taskId), source: { taskRef: ref.id } }, input.now);
471
+ }
472
+ /** Whether a task carries an automatic release seal (any state): it never merges or deploys outside `toolroll release`. */
473
+ export function autoReleaseSealed(store, taskId) {
474
+ return store.handle.prepare("SELECT 1 AS hit FROM task_scope WHERE task_id = ? AND approval_basis IN ('auto-check', 'auto-green')").get(taskId) !== undefined;
475
+ }
476
+ /** The plane's record that an automatic release's publication waits for a person (CI stopped passing after its merge),
477
+ * and a person's answer. The newest row for the task wins. */
478
+ export const PUBLISH_HELD_ACTION = "release publish held";
479
+ export const PUBLISH_ALLOWED_ACTION = "release publish allowed";
480
+ export function holdAutoPublish(store, input) {
481
+ store.recordAction({ at: input.now.toISOString(), actor: "toolroll", repo: store.lookupRef(input.taskId)?.repo ?? null, taskId: input.taskId, runId: null,
482
+ action: PUBLISH_HELD_ACTION, outcome: "held", source: "work", detail: input.why });
483
+ }
484
+ /** Why the release's tag, publication and Homebrew wait for a person, or null. */
485
+ export function autoPublishHeld(store, taskId) {
486
+ const row = store.handle.prepare("SELECT action, detail FROM action_ledger WHERE task_id = ? AND action IN (?, ?) ORDER BY id DESC LIMIT 1").get(taskId, PUBLISH_HELD_ACTION, PUBLISH_ALLOWED_ACTION);
487
+ return row === undefined || row["action"] !== PUBLISH_HELD_ACTION ? null : String(row["detail"] ?? "This release's publication waits for a person");
488
+ }
489
+ /** A person lets a held publication go on: the caller proved a person (never the lead, a token or a mode). */
490
+ export function allowAutoPublish(store, input) {
491
+ if (autoPublishHeld(store, input.taskId) === null)
492
+ return refuse("nothing about this release waits for a person");
493
+ store.recordAction({ at: input.now.toISOString(), actor: input.by, repo: store.lookupRef(input.taskId)?.repo ?? null, taskId: input.taskId, runId: null,
494
+ action: PUBLISH_ALLOWED_ACTION, outcome: "allowed", source: "request", detail: `${input.by} lets the release's tag, publication and Homebrew go on` });
495
+ return { ok: true };
496
+ }
@@ -17,7 +17,8 @@ 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
19
  import { carryRefusalWords, proveCarry, setAskEveryReleaseCommit, setUnsandboxedCarry } from "./release-carry.js";
20
- import { authenticateApprover, machineActing, personProof } from "./scope.js";
20
+ import { authenticateApprover, machineActing, personProof, proveCredential } from "./scope.js";
21
+ import { allowAutoPublish, setReleaseApproval } from "./release-auto.js";
21
22
  import { createNightlyCheckFlow } from "./nightly-check.js";
22
23
  import { parseSchedule } from "./flow-schedule.js";
23
24
  export const RELEASE_HELP = `toolroll release <branch> — release Toolroll itself, from a pushed branch with an open pull request
@@ -52,6 +53,13 @@ toolroll release nightly --repo <path> --as <you> --token <password> [--schedule
52
53
  toolroll release ask-every-commit on|off --repo <path> [--as <you> --token <password>] — this project's releases ask
53
54
  for every later commit (on), or carry test-only commits again (off: your password, never the lead's token)
54
55
 
56
+ toolroll release auto-approve on|off --repo <path> [--as <you> --token <password>] — this project's release approval:
57
+ on approves a release automatically once its pull request's CI and the release check pass on the exact commit
58
+ (your password typed now, never the lead's token or a remembered login); off asks you again (no password).
59
+
60
+ toolroll release publish-ok <task> --as <you> --token <password> — let an automatically approved release that was held
61
+ after its merge (its checks stopped passing) go on to its tag, publication and Homebrew (a person only)
62
+
55
63
  toolroll release unsandboxed-carry on|off --repo <path> [--as <you> --token <password>] — carry release approvals
56
64
  when this project's checks aren't sandboxed (on: your password, never the lead's token). With it on, later
57
65
  test-only commits of a release run outside the sandbox without being shown to you first. Off by default; it counts
@@ -134,7 +142,7 @@ export async function runReleaseCommand(args, write, deps = {}) {
134
142
  return refuse("usage", "--yes goes with --release-gate <id>; a release needs no confirmation beyond the owner's approval.", 2);
135
143
  if (positional[0] === "nightly" && positional.length === 1)
136
144
  return runNightlyCommand({ repo: repo === undefined ? undefined : resolve(deps.cwd ?? process.cwd(), repo), as, token, schedule: scheduleFlag, start: args.includes("--start") }, write, json, deps);
137
- if (["carry", "revoke", "ask-every-commit", "unsandboxed-carry"].includes(positional[0] ?? "") && positional.length === 2) {
145
+ if (["carry", "revoke", "ask-every-commit", "unsandboxed-carry", "auto-approve", "publish-ok"].includes(positional[0] ?? "") && positional.length === 2) {
138
146
  return runAuthorityCommand(positional[0], positional[1], { to, request, as, token, repo: repo === undefined ? undefined : resolve(deps.cwd ?? process.cwd(), repo) }, write, json, deps);
139
147
  }
140
148
  if (positional.length !== 1)
@@ -263,6 +271,52 @@ async function runAuthorityCommand(command, target, flags, write, json, deps) {
263
271
  const words = target === "on" ? "Every later release commit in this project asks for approval." : "Later test-only release commits ship under the release approval.";
264
272
  return answer(true, { repo: flags.repo, ask: target === "on" }, words, 0);
265
273
  }
274
+ if (command === "publish-ok") {
275
+ // A person's own decision to go on after an automatic release's checks stopped passing past its merge: never the
276
+ // lead, a remote token or a mode.
277
+ const refused = (message) => answer(false, { reason: "person-only", message }, message, 1);
278
+ if (machineActing() || process.env["TOOLROLL_LEAD_TOKEN"] !== undefined || process.env["TOOLROLL_REMOTE_TOKEN"] !== undefined)
279
+ return refused("Only a person lets a held release go on, never the lead or a remote token.");
280
+ if (flags.as === undefined || flags.token === undefined || flags.token === "")
281
+ return refused("Letting a held release go on needs your password: add --as <you> --token <password>.");
282
+ const repo = store.lookupRef(target)?.repo ?? null;
283
+ if (repo === null)
284
+ return answer(false, { reason: "not-found", message: `No release task ${target}.` }, `No release task ${target}.`, 1);
285
+ const proved = authenticateApprover(store, flags.as, flags.token, repo);
286
+ if (!proved.ok || !personProof(proved.method))
287
+ return refused("Only an approver of this project, proved as a person, lets a held release go on.");
288
+ const allowed = allowAutoPublish(store, { taskId: target, by: flags.as, now });
289
+ return allowed.ok ? answer(true, { task: target }, `Release task ${target} may be tagged and published: rerun \`toolroll release\` to go on.`, 0) : answer(false, { reason: "refused", message: allowed.why }, allowed.why, 1);
290
+ }
291
+ if (command === "auto-approve") {
292
+ if (target !== "on" && target !== "off")
293
+ return answer(false, { reason: "usage", message: "auto-approve takes on or off." }, "auto-approve takes on or off.", 2);
294
+ if (flags.repo === undefined)
295
+ return answer(false, { reason: "usage", message: "Name the project with --repo <path>." }, "Name the project with --repo <path>.", 2);
296
+ const repo = store.listProjects().map(one => one.path).find(path => path === flags.repo) ?? null;
297
+ if (repo === null)
298
+ return answer(false, { reason: "not-found", message: "That isn't a project Toolroll knows." }, "That isn't a project Toolroll knows.", 1);
299
+ // Turning it on lets releases approve themselves: a credential act (docs/plans/passwordless.md), only a password
300
+ // typed now by an approver of this project — never the lead's token, an API token, a remembered login or a session.
301
+ // Turning it off only asks a person again, so it needs no password.
302
+ let actor = flags.as ?? "cli";
303
+ if (target === "on") {
304
+ const refused = (message) => answer(false, { reason: "person-only", message }, message, 1);
305
+ if (machineActing() || process.env["TOOLROLL_LEAD_TOKEN"] !== undefined || process.env["TOOLROLL_REMOTE_TOKEN"] !== undefined)
306
+ return refused("Only a person turns on automatic release approval, never the lead or a remote token.");
307
+ if (flags.as === undefined || flags.token === undefined || flags.token === "")
308
+ return refused("Turning on automatic release approval needs your password: add --as <you> --token <password>.");
309
+ const proved = proveCredential(store, flags.as, flags.token, repo);
310
+ if (!proved.ok)
311
+ return refused("Only an approver of this project, with their password typed now, turns on automatic release approval.");
312
+ actor = flags.as;
313
+ }
314
+ const changed = setReleaseApproval(store, repo, target === "on", actor, now);
315
+ const words = target === "on"
316
+ ? "Releases in this project approve themselves once the pull request's CI and the release check pass on the exact commit. You're told when one does; anything red still asks you."
317
+ : `Releases in this project ask you again.${changed.withdrawn.length === 0 ? "" : ` ${changed.withdrawn.length} automatic approval${changed.withdrawn.length === 1 ? " was" : "s were"} taken back.`}`;
318
+ return answer(true, { repo, autoApprove: target === "on", changed: changed.changed, withdrawn: changed.withdrawn }, words, 0);
319
+ }
266
320
  if (command === "unsandboxed-carry") {
267
321
  if (target !== "on" && target !== "off")
268
322
  return answer(false, { reason: "usage", message: "unsandboxed-carry takes on or off." }, "unsandboxed-carry takes on or off.", 2);
@@ -25,7 +25,7 @@ export const gitIsAncestor = (repo, ancestor, descendant) => {
25
25
  return known;
26
26
  let answer;
27
27
  try {
28
- execFileSync("git", ["-C", repo, "merge-base", "--is-ancestor", ancestor, descendant], { stdio: "ignore", timeout: 5_000 });
28
+ execFileSync("git", ["-C", repo, "--no-lazy-fetch", "--no-optional-locks", "-c", "core.fsmonitor=false", "-c", "core.hooksPath=/dev/null", "merge-base", "--is-ancestor", ancestor, descendant], { stdio: "ignore", timeout: 5_000 });
29
29
  answer = true;
30
30
  }
31
31
  catch (error) {