toolroll 0.9.76 → 0.9.78

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 (144) hide show
  1. package/CHANGELOG.md +273 -0
  2. package/README.md +11 -4
  3. package/THIRD_PARTY_NOTICES.md +5 -5
  4. package/dist/agent-fence.js +46 -10
  5. package/dist/assignment.js +3 -0
  6. package/dist/backup-ui.d.ts +9 -1
  7. package/dist/backup-ui.js +28 -3
  8. package/dist/backup.js +4 -3
  9. package/dist/browser/THIRD_PARTY_NOTICES.txt +5 -5
  10. package/dist/browser/workspace.css +2 -2
  11. package/dist/browser/workspace.js +31 -31
  12. package/dist/browser-workspace.d.ts +2 -0
  13. package/dist/builder.d.ts +18 -3
  14. package/dist/builder.js +152 -47
  15. package/dist/chat-decide.js +4 -0
  16. package/dist/check-failure.d.ts +92 -0
  17. package/dist/check-failure.js +295 -0
  18. package/dist/check-progress.d.ts +28 -1
  19. package/dist/check-progress.js +105 -6
  20. package/dist/check-report.d.ts +67 -6
  21. package/dist/check-report.js +254 -9
  22. package/dist/checkout-cleanup.js +2 -2
  23. package/dist/claim.d.ts +9 -0
  24. package/dist/claim.js +9 -1
  25. package/dist/cli.js +8 -5
  26. package/dist/coding-handoff.d.ts +1 -0
  27. package/dist/coding-handoff.js +20 -11
  28. package/dist/commit-extract.d.ts +6 -0
  29. package/dist/commit-extract.js +63 -8
  30. package/dist/console-chrome.d.ts +41 -0
  31. package/dist/console-chrome.js +166 -0
  32. package/dist/contracts/console-api.d.ts +1 -1
  33. package/dist/contracts/console-api.js +1 -1
  34. package/dist/contracts/release-check.d.ts +12 -0
  35. package/dist/contracts/release-check.js +17 -0
  36. package/dist/contracts/release-terms.d.ts +41 -0
  37. package/dist/contracts/release-terms.js +53 -0
  38. package/dist/contracts/verification-receipt.d.ts +20 -2
  39. package/dist/contracts/verification-receipt.js +15 -3
  40. package/dist/contracts/verification-view.d.ts +8 -0
  41. package/dist/controller-service.js +4 -1
  42. package/dist/controller-supervisor.d.ts +8 -0
  43. package/dist/controller-supervisor.js +33 -1
  44. package/dist/demo.js +3 -2
  45. package/dist/deploy-gate.js +5 -0
  46. package/dist/deploy-rescue.d.ts +41 -0
  47. package/dist/deploy-rescue.js +94 -0
  48. package/dist/desktop-update.d.ts +1 -1
  49. package/dist/dispose.d.ts +3 -0
  50. package/dist/dispose.js +9 -5
  51. package/dist/doctor.d.ts +7 -0
  52. package/dist/doctor.js +10 -2
  53. package/dist/envelope.js +2 -1
  54. package/dist/evidence.d.ts +9 -0
  55. package/dist/evidence.js +19 -4
  56. package/dist/exec.d.ts +9 -0
  57. package/dist/exec.js +22 -2
  58. package/dist/git-floor.d.ts +16 -0
  59. package/dist/git-floor.js +36 -0
  60. package/dist/grant.js +5 -1
  61. package/dist/guides.js +22 -11
  62. package/dist/latency-timeline.d.ts +46 -0
  63. package/dist/latency-timeline.js +120 -0
  64. package/dist/live-backup.d.ts +39 -0
  65. package/dist/live-backup.js +271 -0
  66. package/dist/operate.d.ts +13 -0
  67. package/dist/operate.js +969 -328
  68. package/dist/planner.js +2 -2
  69. package/dist/prepared-evidence.d.ts +2 -1
  70. package/dist/prepared-evidence.js +8 -3
  71. package/dist/project-knowledge.js +6 -2
  72. package/dist/project-learning.js +2 -2
  73. package/dist/proof.js +6 -0
  74. package/dist/read-executor-worker.js +8 -4
  75. package/dist/read-executor.d.ts +18 -1
  76. package/dist/read-executor.js +8 -0
  77. package/dist/release-adapters.d.ts +80 -2
  78. package/dist/release-adapters.js +441 -16
  79. package/dist/release-authority.js +6 -2
  80. package/dist/release-auto.d.ts +38 -7
  81. package/dist/release-auto.js +131 -15
  82. package/dist/release-card.d.ts +15 -3
  83. package/dist/release-card.js +25 -9
  84. package/dist/release-carry.d.ts +21 -1
  85. package/dist/release-carry.js +22 -0
  86. package/dist/release-ci-hold.d.ts +41 -0
  87. package/dist/release-ci-hold.js +53 -0
  88. package/dist/release-cli.d.ts +3 -0
  89. package/dist/release-cli.js +95 -17
  90. package/dist/release-coverage.js +5 -2
  91. package/dist/release-gate.d.ts +5 -0
  92. package/dist/release-gate.js +1 -1
  93. package/dist/release-recheck.d.ts +1 -1
  94. package/dist/release-recheck.js +8 -2
  95. package/dist/release.d.ts +144 -14
  96. package/dist/release.js +421 -81
  97. package/dist/review-context.js +8 -6
  98. package/dist/runner-process.d.ts +67 -0
  99. package/dist/runner-process.js +219 -0
  100. package/dist/runner.d.ts +48 -6
  101. package/dist/runner.js +73 -9
  102. package/dist/sandbox-runtime.d.ts +8 -0
  103. package/dist/sandbox-runtime.js +14 -2
  104. package/dist/scope.d.ts +19 -1
  105. package/dist/scope.js +26 -3
  106. package/dist/scout.js +2 -2
  107. package/dist/secret-mint.d.ts +1 -0
  108. package/dist/secret-mint.js +14 -0
  109. package/dist/serve.js +112 -51
  110. package/dist/server/chat.js +5 -5
  111. package/dist/server/http.d.ts +2 -0
  112. package/dist/server/pages.js +7 -3
  113. package/dist/server/remote-hooks.d.ts +1 -1
  114. package/dist/server/remote-hooks.js +5 -3
  115. package/dist/server/render-results.d.ts +2 -0
  116. package/dist/server/render-results.js +1 -0
  117. package/dist/server/render-tasks.d.ts +2 -0
  118. package/dist/server/render-tasks.js +2 -1
  119. package/dist/server/request-context.d.ts +8 -0
  120. package/dist/server/runtime.d.ts +2 -0
  121. package/dist/server/settings.js +22 -2
  122. package/dist/server/task-views.js +7 -2
  123. package/dist/server/tasks.js +4 -1
  124. package/dist/setup-guide.d.ts +2 -2
  125. package/dist/storage-sweep.js +1 -1
  126. package/dist/storage.d.ts +5 -0
  127. package/dist/storage.js +28 -1
  128. package/dist/store.d.ts +135 -2
  129. package/dist/store.js +275 -13
  130. package/dist/surface.js +3 -1
  131. package/dist/task-base.d.ts +4 -0
  132. package/dist/task-base.js +22 -2
  133. package/dist/teardown-git.d.ts +52 -3
  134. package/dist/teardown-git.js +145 -33
  135. package/dist/test-infra.js +1 -1
  136. package/dist/toolroll-update.js +2 -2
  137. package/dist/up-processes.d.ts +137 -0
  138. package/dist/up-processes.js +165 -0
  139. package/dist/up-role-child.d.ts +1 -0
  140. package/dist/up-role-child.js +81 -0
  141. package/dist/verification-evidence.d.ts +1 -0
  142. package/dist/verification-evidence.js +13 -1
  143. package/dist/workspace-revision.js +3 -1
  144. package/package.json +2 -1
@@ -14,7 +14,8 @@ const sqlite = () => createRequire(import.meta.url)("node:sqlite");
14
14
  import { checkedEnvelopeJson } from "./contracts/cli.js";
15
15
  import { RELEASE_LIMITS, runRelease } from "./release.js";
16
16
  import { processes, releaseAdapters } from "./release-adapters.js";
17
- import { releaseGate } from "./release-gate.js";
17
+ import { journals, releaseGate } from "./release-gate.js";
18
+ import { rescueDeployment } from "./deploy-rescue.js";
18
19
  import { databasePath, openStore, revokeRelease } from "./store.js";
19
20
  import { carryRefusalWords, proveCarry, setAskEveryReleaseCommit, setUnsandboxedCarry } from "./release-carry.js";
20
21
  import { authenticateApprover, machineActing, personProof, proveCredential } from "./scope.js";
@@ -39,6 +40,13 @@ toolroll release --release-gate <id> [--yes] — lift the update pause a deploym
39
40
  database, service definition and running service are all still the previous build's. --yes lifts that one pause.
40
41
  Any missing or contradicting evidence refuses and leaves the pause in place.
41
42
 
43
+ toolroll release --recover-deploy [--stage <dir>] [--yes] — put back a deployment that stopped half way
44
+
45
+ Finds the one unfinished deployment (or the stage named) and shows what its recovery will do; --yes does it. When
46
+ the old service isn't proved stopped, it boots the service out and stops what it left running, but only processes
47
+ proved, by exact runtime path and arguments, to be that deployment's own; then it starts the previous service
48
+ again, waits for it, and lifts the update pause. Anything it can't prove refuses. Safe to run again.
49
+
42
50
  toolroll release carry <release> --to <commit> [--request <id>] — carry a person's release approval to a later commit
43
51
 
44
52
  Only tests, docs and the changelog may change after the approval, within the limits, with an identical production
@@ -74,33 +82,45 @@ Options
74
82
  --limit <step>=<min> change one step's time limit (${Object.entries(RELEASE_LIMITS).map(([step, minutes]) => `${step} ${minutes}`).join(", ")})
75
83
  --tap <owner/name> the Homebrew tap (default ap9000/homebrew-toolroll)
76
84
  --release-gate <id> check (and with --yes, lift) one update pause left before a deployment's swap
77
- --yes with --release-gate: lift the pause once it is proved safe
85
+ --yes with --release-gate: lift the pause once it is proved safe; with --recover-deploy: recover
86
+ --recover-deploy recover the unfinished deployment (see above)
87
+ --stage <dir> with --recover-deploy: the deployment's staging folder, when more than one is unfinished
78
88
  --json answer with one machine envelope
79
89
 
80
90
  A runner's lease is returned with \`toolroll worker release <lease>\`.`;
81
91
  /** A runner lease id (claim.ts mints randomUUID()). */
82
92
  export const LEASE_SHAPED = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
83
93
  export const LEASE_DEPRECATION = "`toolroll release <lease>` is now `toolroll worker release <lease>`; the old form goes in the next minor version.";
84
- const VALUE_FLAGS = new Set(["--repo", "--limit", "--tap", "--release-gate", "--to", "--request", "--as", "--token", "--schedule"]);
85
- const BARE_FLAGS = new Set(["--new", "--json", "--help", "--yes", "--no-carry", "--start"]);
94
+ const VALUE_FLAGS = new Set(["--repo", "--limit", "--tap", "--release-gate", "--to", "--request", "--as", "--token", "--schedule", "--stage"]);
95
+ const BARE_FLAGS = new Set(["--new", "--json", "--help", "--yes", "--no-carry", "--start", "--recover-deploy"]);
86
96
  export async function runReleaseCommand(args, write, deps = {}) {
87
- const json = args.includes("--json");
97
+ // The flag, not a secret's value that happens to read "--json".
98
+ // A secret is never exactly one of these names (the loop below refuses that as a missing value).
99
+ const bare = new Set(args.filter(one => BARE_FLAGS.has(one)));
100
+ const json = bare.has("--json");
88
101
  const refuse = (reason, message, code) => {
89
102
  write(json ? checkedEnvelopeJson({ ok: false, command: "release", reason, message }) : message);
90
103
  return code;
91
104
  };
92
105
  const positional = [];
93
106
  const limits = {};
107
+ let stage;
94
108
  let repo, tap, gate, to, request, as, token, scheduleFlag;
95
109
  for (let i = 0; i < args.length; i += 1) {
96
- const arg = args[i];
97
- if (BARE_FLAGS.has(arg))
110
+ const raw = args[i];
111
+ if (BARE_FLAGS.has(raw))
98
112
  continue;
113
+ // `--flag=value` for every value flag; a secret (--token) is also the next argument even when it starts with "--".
114
+ const equals = raw.startsWith("--") ? raw.indexOf("=") : -1;
115
+ const arg = equals === -1 ? raw : raw.slice(0, equals);
99
116
  if (VALUE_FLAGS.has(arg)) {
100
- const value = args[i + 1];
101
- if (value === undefined || value.startsWith("--"))
117
+ const value = equals === -1 ? args[i + 1] : raw.slice(equals + 1);
118
+ // A secret may start with "--"; it is the value unless it is exactly one of this command's flags (never swallowed).
119
+ const namesFlag = value !== undefined && (BARE_FLAGS.has(value) || VALUE_FLAGS.has(value.split("=")[0]));
120
+ if (value === undefined || (equals === -1 && value.startsWith("--") && (arg !== "--token" || namesFlag)))
102
121
  return refuse("usage", `${arg} takes a value.`, 2);
103
- i += 1;
122
+ if (equals === -1)
123
+ i += 1;
104
124
  if (arg === "--repo")
105
125
  repo = value;
106
126
  else if (arg === "--to")
@@ -117,6 +137,8 @@ export async function runReleaseCommand(args, write, deps = {}) {
117
137
  tap = value;
118
138
  else if (arg === "--release-gate")
119
139
  gate = value;
140
+ else if (arg === "--stage")
141
+ stage = value;
120
142
  else {
121
143
  const match = /^([a-z]+)=([0-9]+)$/.exec(value);
122
144
  if (match === null || !(match[1] in RELEASE_LIMITS) || Number(match[2]) < 1)
@@ -125,23 +147,30 @@ export async function runReleaseCommand(args, write, deps = {}) {
125
147
  }
126
148
  continue;
127
149
  }
128
- if (arg.startsWith("--"))
150
+ if (raw.startsWith("--"))
129
151
  return refuse("usage", `Unknown option ${arg}.\n${RELEASE_HELP}`, 2);
130
152
  positional.push(arg);
131
153
  }
132
- if (args.includes("--help")) {
154
+ if (bare.has("--help")) {
133
155
  write(RELEASE_HELP);
134
156
  return 0;
135
157
  }
136
158
  if (gate !== undefined) {
137
159
  if (positional.length > 0 || args.some(arg => ["--repo", "--limit", "--tap", "--new"].includes(arg)))
138
160
  return refuse("usage", "--release-gate <id> takes no branch or release options.", 2);
139
- return runGateRelease(gate, args.includes("--yes"), write, json, deps.gateWorld ?? installedGateWorld());
161
+ return runGateRelease(gate, bare.has("--yes"), write, json, deps.gateWorld ?? installedGateWorld());
140
162
  }
141
- if (args.includes("--yes"))
142
- return refuse("usage", "--yes goes with --release-gate <id>; a release needs no confirmation beyond the owner's approval.", 2);
163
+ if (bare.has("--recover-deploy")) {
164
+ if (positional.length > 0 || args.some(arg => ["--repo", "--limit", "--tap", "--new", "--release-gate"].includes(arg.split("=")[0])))
165
+ return refuse("usage", "--recover-deploy takes only --stage <dir>, --yes and --json.", 2);
166
+ return runRescue(stage, bare.has("--yes"), write, json, deps.rescueWorld ?? installedRescueWorld(deps.cwd ?? process.cwd()));
167
+ }
168
+ if (stage !== undefined)
169
+ return refuse("usage", "--stage goes with --recover-deploy.", 2);
170
+ if (bare.has("--yes"))
171
+ return refuse("usage", "--yes goes with --release-gate <id> or --recover-deploy; a release needs no confirmation beyond the owner's approval.", 2);
143
172
  if (positional[0] === "nightly" && positional.length === 1)
144
- return runNightlyCommand({ repo: repo === undefined ? undefined : resolve(deps.cwd ?? process.cwd(), repo), as, token, schedule: scheduleFlag, start: args.includes("--start") }, write, json, deps);
173
+ return runNightlyCommand({ repo: repo === undefined ? undefined : resolve(deps.cwd ?? process.cwd(), repo), as, token, schedule: scheduleFlag, start: bare.has("--start") }, write, json, deps);
145
174
  if (["carry", "revoke", "ask-every-commit", "unsandboxed-carry", "auto-approve", "publish-ok"].includes(positional[0] ?? "") && positional.length === 2) {
146
175
  return runAuthorityCommand(positional[0], positional[1], { to, request, as, token, repo: repo === undefined ? undefined : resolve(deps.cwd ?? process.cwd(), repo) }, write, json, deps);
147
176
  }
@@ -169,7 +198,7 @@ export async function runReleaseCommand(args, write, deps = {}) {
169
198
  const adapters = deps.adapters === undefined
170
199
  ? releaseAdapters({ checkout, say, ...(tap === undefined ? {} : { tap }), ...(limits["deploy"] === undefined ? {} : { deployMinutes: limits["deploy"] }) })
171
200
  : { ...deps.adapters, say };
172
- const outcome = await runRelease({ branch, checkout, stateDir, limits, fresh: args.includes("--new"), noCarry: args.includes("--no-carry") }, adapters);
201
+ const outcome = await runRelease({ branch, checkout, stateDir, limits, fresh: bare.has("--new"), noCarry: bare.has("--no-carry") }, adapters);
173
202
  return report(outcome, write, json, lines);
174
203
  }
175
204
  function report(outcome, write, json, lines) {
@@ -208,6 +237,55 @@ function installedGateWorld() {
208
237
  .filter(path => existsSync(path)).map(path => ({ path, text: readFileSync(path, "utf8") })),
209
238
  };
210
239
  }
240
+ /** The installation's deployments, its plane's records and the release's own recovery road, for --recover-deploy. */
241
+ function installedRescueWorld(cwd) {
242
+ const database = databasePath(process.env, homedir());
243
+ const stateDir = dirname(database);
244
+ const adapters = releaseAdapters({ checkout: cwd, say: line => process.stderr.write(`${line}\n`) });
245
+ return {
246
+ database, stateDir,
247
+ journals: () => journals(stateDir),
248
+ runWorktree: run => {
249
+ if (!existsSync(database))
250
+ return null;
251
+ const db = new (sqlite().DatabaseSync)(database, { readOnly: true });
252
+ try {
253
+ const row = db.prepare("SELECT worktree FROM run WHERE id = ?").get(run);
254
+ return typeof row?.["worktree"] === "string" && row["worktree"] !== "" ? row["worktree"] : null;
255
+ }
256
+ catch {
257
+ return null;
258
+ }
259
+ finally {
260
+ db.close();
261
+ }
262
+ },
263
+ recover: async (input) => {
264
+ const answer = await adapters.toolroll.recoverDeploy(input);
265
+ return answer.ok ? { ok: true } : { ok: false, message: answer.message };
266
+ },
267
+ };
268
+ }
269
+ async function runRescue(stage, yes, write, json, world) {
270
+ let outcome;
271
+ try {
272
+ outcome = await rescueDeployment(stage, yes, world);
273
+ }
274
+ catch (error) {
275
+ outcome = { ok: false, reason: "failed", message: `${error.message} Run \`toolroll release --recover-deploy --yes\` again once the cause is fixed.` };
276
+ }
277
+ if (!outcome.ok) {
278
+ write(json ? checkedEnvelopeJson({ ok: false, command: "release", reason: outcome.reason, message: outcome.message }) : `✗ ${outcome.message}`);
279
+ return outcome.reason === "usage" ? 2 : 3;
280
+ }
281
+ if (json) {
282
+ write(checkedEnvelopeJson({ ok: true, command: "release", recovery: { acted: outcome.acted, stage: outcome.stage, phase: outcome.phase, said: outcome.words } }));
283
+ return 0;
284
+ }
285
+ for (const line of outcome.words)
286
+ write(outcome.acted ? `✓ ${line}` : line);
287
+ return 0;
288
+ }
211
289
  function runGateRelease(id, yes, write, json, world) {
212
290
  let outcome;
213
291
  try {
@@ -4,7 +4,7 @@
4
4
  * existing records and Git; nothing is written back, so no proof, receipt,
5
5
  * scope digest or schema changes. Fails closed: a receipt that can't be read,
6
6
  * a missing commit or any Git error leaves the result uncovered. */
7
- import { execFileSync } from "node:child_process";
7
+ import { viewedGitSync } from "./teardown-git.js";
8
8
  import { verificationEvidence } from "./verification-evidence.js";
9
9
  import { runCheckLevel } from "./check-levels.js";
10
10
  /** How many of the newest passing release checks one read considers. */
@@ -25,7 +25,10 @@ export const gitIsAncestor = (repo, ancestor, descendant) => {
25
25
  return known;
26
26
  let answer;
27
27
  try {
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 });
28
+ // Every git version through a frozen read view, bounded (eval-harness review 1, finding 7).
29
+ const status = viewedGitSync(repo, ["merge-base", "--is-ancestor", ancestor, descendant], { timeoutMs: 5_000 }).status;
30
+ if (status !== 0)
31
+ throw Object.assign(new Error("git merge-base"), { status });
29
32
  answer = true;
30
33
  }
31
34
  catch (error) {
@@ -61,6 +61,11 @@ export type GateProof = {
61
61
  };
62
62
  /** The owner id written into the live pause's triggers, when there is one pause and it names one id. */
63
63
  export declare function gateOwner(db: Database): string | null;
64
+ /** Every deployment journal under staged-upgrades/: the stage it is in and what it holds, or why it can't be read. */
65
+ export declare function journals(stateDir: string): {
66
+ stage: string;
67
+ journal: Record<string, unknown> | null;
68
+ }[];
64
69
  /** Whether the pause owned by `id` was left by a deployment that never began its swap, and the evidence that says so. */
65
70
  export declare function proveBeforeSwap(id: string, world: GateReleaseWorld): GateProof;
66
71
  export type GateReleaseOutcome = {
@@ -36,7 +36,7 @@ export function gateOwner(db) {
36
36
  return ids.size === 1 ? [...ids][0] : null;
37
37
  }
38
38
  /** Every deployment journal under staged-upgrades/: the stage it is in and what it holds, or why it can't be read. */
39
- function journals(stateDir) {
39
+ export function journals(stateDir) {
40
40
  const root = join(stateDir, "staged-upgrades");
41
41
  let stages = [];
42
42
  try {
@@ -72,7 +72,7 @@ export declare function planTargetedRecheck(store: Store, root: string, input: {
72
72
  now: Date;
73
73
  }): RecheckDecision;
74
74
  /** Coverage of a check: full, composed from A, partial (never passes), or identity-removed (asks again). */
75
- export type Coverage = "full" | "composed" | "partial" | "identity-removed";
75
+ export type Coverage = "full" | "composed" | "partial" | "identity-removed" | "no-result";
76
76
  /**
77
77
  * Whether a targeted run B, with A's sealed evidence, its plan and snapshot, covers every item of B's inventory
78
78
  * (coverage conservation), and every required identity passed — re-derived from sealed values only.
@@ -20,12 +20,12 @@
20
20
  * Coverage trusts test code (Alex, 2026-10-25): these results protect against accidental damage, not a hostile test.
21
21
  * Authority never reads them; the release's own records do.
22
22
  */
23
- import { CHECK_CONTEXT_CAPTURE, CHECK_INVENTORY_CAPTURE, CHECK_REPORT_CAPTURE, OBLIGATION_SNAPSHOT_CAPTURE, TARGETED_PLAN_CAPTURE, checkContextOf, isUnitFile, payloadDigest, projectGitDir, readSealedJson, treeFactsOf } from "./check-report.js";
23
+ import { CHECK_CONTEXT_CAPTURE, CHECK_INVENTORY_CAPTURE, CHECK_REPORT_CAPTURE, OBLIGATION_SNAPSHOT_CAPTURE, TARGETED_PLAN_CAPTURE, checkContextOf, fullCheckProblem, isUnitFile, payloadDigest, projectGitDir, readSealedJson, treeFactsOf } from "./check-report.js";
24
24
  import { readVerifiedCommit, verifiedTreeDiff } from "./commit-extract.js";
25
25
  import { checkContextSchema, fileIdentity, groupIdentity, journeyIdentity, obligationSnapshotSchema, readCheckReport, sealedInventorySchema, targetedPlanSchema, unitIdentity, versionIdentity, } from "./contracts/release-check.js";
26
26
  import { parseContract } from "./contracts/contract.js";
27
27
  import { releaseAuthority, releaseAuthorityOfTask, releaseCarries } from "./release-authority.js";
28
- import { liveReleaseOf } from "./scope.js";
28
+ import { liveReleaseOf, releaseCheckScope } from "./scope.js";
29
29
  import { checkAttemptOf } from "./store.js";
30
30
  /** The sanity pass every targeted check runs beside its plan: a few fast tests of the core rules, when the tree has them. */
31
31
  export const SANITY_UNITS = Object.freeze(["src/scope.test.ts", "src/contract.test.ts", "src/check-report.test.ts", "src/update-safe-migrations.test.ts"]);
@@ -483,6 +483,12 @@ export function settleCoverage(store, root, input, gateOf) {
483
483
  if (removedProtected(b.report, b.inventory, protectedIdentities(store, root, b.attempt.authority, input.runId)))
484
484
  return removed();
485
485
  }
486
+ // A release's Full check (its task carries signed release terms, before or after its authority opens — an automatic
487
+ // approval's check runs before) counts only with a complete, valid result (check-report.ts): anything less is no result.
488
+ // A release check purpose (pipeline-speed.md, P0) counts too: a termsless release is held to the same rules.
489
+ const release = b.attempt.authority !== null || releaseCheckScope(store.getScope(input.taskId));
490
+ if (release && fullCheckProblem({ report: b.report, inventory: b.inventory, exitCode: input.exitCode }) !== null)
491
+ return { coverage: "no-result", composed: null };
486
492
  return { coverage: "full", composed: null };
487
493
  }
488
494
  if (b.plan === null || b.snapshot === null || b.attempt.baseRun === null || b.attempt.authority === null)
package/dist/release.d.ts CHANGED
@@ -34,9 +34,34 @@
34
34
  */
35
35
  /** The pull-request checks a release waits for, by exact job name (.github/workflows/ci.yml). Every one must have
36
36
  * concluded `success` on the gated commit; src/ci-workflow.test.ts holds the workflow to this list. */
37
- export declare const REQUIRED_PR_CHECKS: readonly ["node 22 · ubuntu-latest", "node 24 · ubuntu-latest", "linux native containment · delegated cgroup v2", "linux fence · sandbox-runtime", "Windows Job Object containment"];
38
- /** The checks a release tag runs (.github/workflows/publish.yml), awaited before Homebrew moves. */
39
- export declare const RELEASE_TAG_CHECKS: readonly ["node 22 · macos-latest", "node 24 · macos-latest", "publish"];
37
+ export declare const REQUIRED_PR_CHECKS: readonly ["node 22 · ubuntu-latest", "node 24 · ubuntu-latest", "linux native containment · delegated cgroup v2", "linux fence · sandbox-runtime", "Windows Job Object containment", "git 2.41 · debian trixie", "git 2.47 · debian trixie", "node 22 · macos-latest", "node 24 · macos-latest"];
38
+ /** The workflow file every REQUIRED_PR_CHECKS job belongs to: a job of that name from any other workflow (a lookalike
39
+ * added beside it) never stands for it (Codex review round 1, inherited CI limitation). */
40
+ export declare const REQUIRED_PR_CHECKS_WORKFLOW = ".github/workflows/ci.yml";
41
+ /**
42
+ * The pull-request checks main's GitHub protection is not yet required to name (eval-harness review 2, finding 1): the
43
+ * git lanes join main's protection once they have run there, so a protection without them is reported, never refused.
44
+ * A later release empties this list; from then on main's protection must name every REQUIRED_PR_CHECKS check.
45
+ */
46
+ export declare const PROTECTION_REPORT_ONLY_CHECKS: readonly string[];
47
+ /** The checks main's protection must require at minimum: every required check that isn't report-only. */
48
+ export declare const PROTECTION_MINIMUM_CHECKS: readonly string[];
49
+ /**
50
+ * Main's protection as the plane's gh reads it (release-adapters.ts readMainProtection): `effective` only when one layer
51
+ * (classic protection or one active ruleset) requires up-to-date branches, binds every PROTECTION_MINIMUM_CHECKS check to GitHub Actions,
52
+ * and nobody merging as this gh can bypass it. `missing` names each absent property; `notYetRequired` the report-only
53
+ * checks the effective layer doesn't name.
54
+ */
55
+ export type MainProtection = {
56
+ effective: boolean;
57
+ missing: string[];
58
+ notYetRequired: string[];
59
+ };
60
+ /** The checks a release's publication waits for on its tag (.github/workflows/publish.yml, pipeline-speed.md item 3):
61
+ * the publish job and the macOS legs it needs, each named. Nothing else reported on the merge commit (main's own CI push
62
+ * run) holds publication. */
63
+ export declare const RELEASE_TAG_CHECKS: readonly ["publish", "tag macOS · node 22", "tag macOS · node 24"];
64
+ export declare const RELEASE_TAG_WORKFLOW = ".github/workflows/publish.yml";
40
65
  export declare const RELEASE_STEPS: readonly ["gate", "approval", "check", "complete", "quiet", "deploy", "link", "ci", "merge", "tree", "tag", "publish", "homebrew", "done"];
41
66
  export type ReleaseStep = typeof RELEASE_STEPS[number];
42
67
  type WaitingStep = "gate" | "approval" | "check" | "quiet" | "deploy" | "ci" | "publish" | "homebrew";
@@ -55,10 +80,20 @@ export type CheckRun = {
55
80
  head?: string | null;
56
81
  /** The workflow (its id, from GitHub's workflow-run API) that this check's suite is a run of, when known. */
57
82
  workflow?: number | null;
83
+ /** That workflow's file in the repository (GitHub's workflow-run `path`), when known. */
84
+ workflowPath?: string | null;
85
+ /** GitHub's own job record (its steps, read by the plane's gh) shows this unsuccessful attempt ended before any of the
86
+ * workflow's own steps ran: the runner failed, not the tests. Absent or false: nothing shows that. */
87
+ beforeTests?: boolean | null;
58
88
  };
59
- /** How many failed attempts of one CI check, and failed release-check runs, an automatic approval tolerates (flakes on a
60
- * pinned commit and digest), before a person decides (auto-approve-green.md). */
61
- export declare const AUTO_FLAKE_CAP = 2;
89
+ /**
90
+ * Failure classes replace a flat flake allowance (docs/plans/eval-harness.md; check-failure.ts says the classes): only a
91
+ * failure the plane itself confirmed as infrastructure is ever run again under the same approval — at most once per
92
+ * incident and twice per candidate — for a release check and for a GitHub CI job alike. A failing test, a typecheck, an
93
+ * attack test, a migration: none tolerated. An unexplained failure blocks, and a person decides.
94
+ */
95
+ export declare const INFRA_RERUNS_PER_INCIDENT = 1;
96
+ export declare const INFRA_RERUNS_PER_CANDIDATE = 2;
62
97
  /** One failed attempt that a later attempt of the same job passed. */
63
98
  export type CheckFailure = {
64
99
  id: number;
@@ -138,6 +173,8 @@ export type DeployStop = {
138
173
  phase: string;
139
174
  };
140
175
  };
176
+ /** The tap's formula for a version: current, a proposal open, missing — or `newer`: the tap's default branch already
177
+ * names a later version, which an older release must never overwrite. */
141
178
  export type HomebrewState = {
142
179
  kind: "current";
143
180
  } | {
@@ -146,7 +183,14 @@ export type HomebrewState = {
146
183
  head: string;
147
184
  } | {
148
185
  kind: "missing";
186
+ } | {
187
+ kind: "newer";
188
+ version: string;
149
189
  };
190
+ /** Homebrew after publication (pipeline-speed.md, P1): its own durable state, finished by `toolroll release <branch>`.
191
+ * `pending`: a change proposed or its checks still running; `failed`: its checks failed (a later run reads them again);
192
+ * `current`: merged, or already on the tap; `superseded`: the tap already names a newer version, so nothing was changed. */
193
+ export type HomebrewProgress = "pending" | "failed" | "current" | "superseded";
150
194
  /** One commit that landed after the approval, with its files and line counts (release_carry). */
151
195
  export type LandedCommit = {
152
196
  sha: string;
@@ -168,6 +212,18 @@ export type AuthorityView = {
168
212
  landed: LandedCommit[];
169
213
  };
170
214
  export type ReleaseAction = "dispatch" | "deploy" | "merge" | "complete";
215
+ /** The plane's answer after a failed release check: rerun (confirmed infrastructure), already rerunning, or stop. */
216
+ export type FailureClassName = "deterministic" | "infrastructure" | "unknown" | "agent-behaviour";
217
+ export type RecheckView = {
218
+ action: "rerun";
219
+ why: string;
220
+ } | {
221
+ action: "pending";
222
+ } | {
223
+ action: "stop";
224
+ class: FailureClassName | null;
225
+ why: string;
226
+ };
171
227
  export type OperationView = {
172
228
  id: string;
173
229
  kind: "deploy" | "merge";
@@ -231,6 +287,20 @@ export type ReleaseAuthorityAdapter = {
231
287
  * the authority could not be stopped (a stale epoch, a merge already reserved): the coordinator fails closed. */
232
288
  needsApproval(authority: number, reason: string, epoch: number): Promise<boolean>;
233
289
  operations(authority: number): Promise<OperationView[]>;
290
+ /** After a failed release check (check-failure.ts): the plane classifies it from its own records and, only for
291
+ * confirmed infrastructure within its limits, runs the check again on the same candidate under the same approval.
292
+ * Absent on an older plane: a failed check stops as before. */
293
+ recheck?(taskId: string): Promise<RecheckView>;
294
+ /** Whether a passing check stands given every earlier attempt on the same commit (an earlier failure that wasn't
295
+ * confirmed infrastructure isn't erased by a pass), and what passed only on a retry. */
296
+ standing?(taskId: string, run: number): Promise<{
297
+ ok: true;
298
+ retried: string[] | null;
299
+ } | {
300
+ ok: false;
301
+ why: string;
302
+ retried: string[] | null;
303
+ }>;
234
304
  /** Releases that approve themselves when green (docs/plans/auto-approve-green.md): the plane's own seals, each proven
235
305
  * again inside the plane. Absent: a plane that can't, and every release asks a person as before. */
236
306
  auto?: AutoReleaseAdapter;
@@ -279,6 +349,17 @@ export type AutoReleaseAdapter = {
279
349
  }>;
280
350
  /** The automatic road ended without a release: the task asks a person, and the owner is told why. */
281
351
  giveUp(taskId: string, why: string): Promise<void>;
352
+ /** The task is filed while CI still runs on `candidate` (recorded in the plane before the task is filed): its approval
353
+ * card says so until the plane reads CI passing. It holds nothing (Alex, 2026-10-29: a person may approve during CI). */
354
+ ciRunning?(taskId: string, candidate: string): Promise<void>;
355
+ /** The plane reads CI on `candidate` itself and, when every required check passed, ends the "CI still running" note
356
+ * (and lifts a hold an older coordinator placed); `ask` tells the owner of a held release it needs them now. */
357
+ ciPassed?(taskId: string, candidate: string, ask: boolean): Promise<{
358
+ ok: true;
359
+ } | {
360
+ ok: false;
361
+ why: string;
362
+ }>;
282
363
  /** The release check failed while checking automatically: the owner is told; the commit may be checked again (a
283
364
  * regate of the same commit and digest, within the plane's cap on failed runs). */
284
365
  checkFailed(taskId: string, why: string): Promise<void>;
@@ -306,10 +387,6 @@ export type ReleaseAdapters = {
306
387
  deleteBranch(branch: string, head: string): Promise<void>;
307
388
  tree(sha: string): Promise<string>;
308
389
  fileAt(sha: string, path: string): Promise<string | null>;
309
- /** origin/main as the last sync fetched it. */
310
- mainHead(): Promise<string>;
311
- /** Whether `ancestor` is in `sha`'s history. */
312
- contains(sha: string, ancestor: string): Promise<boolean>;
313
390
  };
314
391
  toolroll: {
315
392
  /** Which runtime each launchd service and each CLI name (`toolroll`, `standing-orders`) runs from. */
@@ -377,11 +454,21 @@ export type ReleaseAdapters = {
377
454
  github: {
378
455
  authenticated(): Promise<boolean>;
379
456
  pullRequest(repo: string, branch: string): Promise<PullRequest | null>;
380
- checks(repo: string, sha: string): Promise<CheckRun[]>;
457
+ /** `tag`: a release tag's push binds its workflow file too (only that exact tag's push). */
458
+ checks(repo: string, sha: string, options?: {
459
+ tag?: string;
460
+ }): Promise<CheckRun[]>;
381
461
  /** Whether GitHub's check history of this commit can be read whole (fewer check suites than the check-runs
382
462
  * endpoint's ceiling). Absent or false: an automatic release fails closed; a person's is unaffected. */
383
463
  complete?(repo: string, sha: string): Promise<boolean>;
384
464
  merge(repo: string, pr: number, head: string, message?: string): Promise<string>;
465
+ /** main's head as GitHub's API answers now (never a local ref a build could move). */
466
+ mainHead(repo: string): Promise<string>;
467
+ /** Whether `sha` already holds `ancestor`, by GitHub's own comparison. */
468
+ contains(repo: string, sha: string, ancestor: string): Promise<boolean>;
469
+ /** Whether GitHub refuses to merge a branch that is behind main ("Require branches to be up to date before
470
+ * merging"); null when it can't be read. Read only. */
471
+ upToDateRequired(repo: string): Promise<MainProtection | null>;
385
472
  tag(repo: string, tag: string): Promise<string | null>;
386
473
  createTag(repo: string, tag: string, sha: string): Promise<void>;
387
474
  release(repo: string, tag: string): Promise<boolean>;
@@ -400,6 +487,14 @@ export type ReleaseAdapters = {
400
487
  pr: number;
401
488
  head: string;
402
489
  }>;
490
+ /** Right before the merge: the change is the tap's own open pull request at exactly `head`, and its formula is
491
+ * exactly the tap's formula with npm's published tarball for `version` and that tarball's own sha256. */
492
+ verified(pr: number, head: string, version: string): Promise<{
493
+ ok: true;
494
+ } | {
495
+ ok: false;
496
+ why: string;
497
+ }>;
403
498
  merge(pr: number, head: string): Promise<void>;
404
499
  };
405
500
  };
@@ -466,6 +561,9 @@ export type ReleaseJournal = {
466
561
  pr: number | null;
467
562
  head: string | null;
468
563
  merged: boolean;
564
+ /** Absent in a journal from before Homebrew was finished apart: read from `merged`. */
565
+ state?: HomebrewProgress;
566
+ why?: string | null;
469
567
  } | null;
470
568
  /** The release's authority record (release-once.md), once a person approved terms: the id and generation. */
471
569
  authority?: {
@@ -490,6 +588,13 @@ export type ReleaseJournal = {
490
588
  why?: string;
491
589
  ci?: number[];
492
590
  } | null;
591
+ /** The gate filed the task while the pull request's checks still ran (pipeline-speed.md, D2 and "Decision: a person's
592
+ * release asks during CI"): the release check runs beside CI. A person may approve meanwhile; the plane's automatic
593
+ * approval waits for every check to pass, and nothing deploys, merges, tags or publishes before they do. */
594
+ ciAhead?: boolean;
595
+ /** Set with ciAhead and never cleared for this commit: a person's release reads CI again, fresh, right before the
596
+ * deploy, whatever was seen while asking (a green CI may have a newer attempt failing since). */
597
+ ciBeforeDeploy?: boolean;
493
598
  /** When each step was about to change something outside this machine: what a resume must look for before acting. */
494
599
  intents: Partial<Record<ReleaseStep, string>>;
495
600
  stopped: {
@@ -556,6 +661,13 @@ export declare function checksVerdict(runs: readonly CheckRun[], required: reado
556
661
  state: "failed";
557
662
  failed: string[];
558
663
  };
664
+ /**
665
+ * Publication's verdict on the tag commit (pipeline-speed.md, item 3): only RELEASE_TAG_CHECKS, from GitHub Actions
666
+ * runs of publish.yml bound to the tag's push, each by its newest attempt. Every other check on the merge commit — main's
667
+ * own CI run — is not waited for: the candidate's checks already passed on its exact head before the merge, and the
668
+ * merged tree was proved equal to it.
669
+ */
670
+ export declare function tagChecksVerdict(runs: readonly CheckRun[]): ReturnType<typeof checksVerdict>;
559
671
  /** Run the release from wherever its journal stopped. */
560
672
  export declare function runRelease(options: ReleaseOptions, adapters: ReleaseAdapters): Promise<ReleaseOutcome>;
561
673
  /** A probe's failure that is only the network to GitHub being slow, never a decision (a Stop) or another error. */
@@ -565,11 +677,14 @@ export declare function transientNetworkError(error: unknown): boolean;
565
677
  * each reported attempt must be filed under `head` with a check-run id; a required check counts only from GitHub
566
678
  * Actions (an app that names a check alike can't stand in for it); attempts are grouped by the job they belong to — the
567
679
  * app, its check suite (a workflow run, whose re-runs stay in it) and the name — and every group is judged by its
568
- * latest attempt; every required check needs a group. With `cap`, a group whose earlier attempts failed more than `cap`
569
- * times fails too (an automatic approval's flake allowance); the failures it tolerated are returned.
680
+ * latest attempt; every required check needs a group. With `auto` (an automatic approval), every other failed attempt on
681
+ * the commit — an earlier attempt, or a job a newer run renamed or dropped — must be one GitHub's own job record shows ending before any workflow step ran (`beforeTests`: confirmed
682
+ * infrastructure), and at most INFRA_RERUNS_PER_CANDIDATE of them on the commit; any other earlier failure — a test,
683
+ * a typecheck, or one nothing explains — fails the verdict, so a person decides. The failures it tolerated are returned.
570
684
  */
571
685
  export declare function prChecksVerdict(runs: readonly CheckRun[], head: string, required: readonly string[], options?: {
572
- cap?: number;
686
+ auto?: true;
687
+ workflow?: string;
573
688
  }): {
574
689
  state: "passed";
575
690
  failures: CheckFailure[];
@@ -580,4 +695,19 @@ export declare function prChecksVerdict(runs: readonly CheckRun[], head: string,
580
695
  state: "failed";
581
696
  failed: string[];
582
697
  };
698
+ /**
699
+ * What a failed release check means and what happens next, by its class (check-failure.ts), in words. Only confirmed
700
+ * infrastructure reruns on its own; nothing else is rerun to pass.
701
+ */
702
+ export declare function checkFailedWords(input: {
703
+ sha: string;
704
+ run: number;
705
+ class: FailureClassName | null;
706
+ why: string;
707
+ branch: string;
708
+ checkout: string;
709
+ taskId: string;
710
+ }): string;
711
+ /** Whether GitHub's workflow-run `path` names exactly `file` (it may carry the ref it ran from, after an `@`). */
712
+ export declare function workflowIs(path: string | null | undefined, file: string): boolean;
583
713
  export {};