toolroll 0.9.91 → 0.9.93

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 (124) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/dist/approval-authority.d.ts +9 -1
  3. package/dist/approval-authority.js +15 -2
  4. package/dist/assignment-status.d.ts +1 -1
  5. package/dist/assignment-ui.js +2 -2
  6. package/dist/assignment.js +11 -3
  7. package/dist/browser/workspace.css +1 -1
  8. package/dist/browser/workspace.js +30 -30
  9. package/dist/browser-crew.js +2 -2
  10. package/dist/browser-workspace.d.ts +3 -5
  11. package/dist/build-review.js +2 -0
  12. package/dist/builder.js +21 -2
  13. package/dist/chat-actions.d.ts +11 -3
  14. package/dist/chat-actions.js +47 -2
  15. package/dist/chat-decide.js +2 -0
  16. package/dist/chat-review.d.ts +2 -1
  17. package/dist/chat-review.js +2 -2
  18. package/dist/check-facts.d.ts +124 -0
  19. package/dist/check-facts.js +272 -0
  20. package/dist/check-report.d.ts +5 -0
  21. package/dist/check-report.js +4 -3
  22. package/dist/cli.js +5 -0
  23. package/dist/contracts/chat-actions.d.ts +10 -0
  24. package/dist/contracts/chat-actions.js +4 -0
  25. package/dist/contracts/console-api.d.ts +3 -2
  26. package/dist/contracts/console-api.js +5 -2
  27. package/dist/contracts/release-check.d.ts +25 -1
  28. package/dist/contracts/release-check.js +19 -0
  29. package/dist/contracts/route-results.d.ts +121 -0
  30. package/dist/contracts/route-results.js +71 -0
  31. package/dist/contracts/route.d.ts +343 -5
  32. package/dist/contracts/route.js +36 -7
  33. package/dist/contracts/store-json.d.ts +2 -0
  34. package/dist/desktop-update.d.ts +2 -2
  35. package/dist/desktop-update.js +13 -9
  36. package/dist/dispose.js +6 -0
  37. package/dist/eval-hooks.d.ts +55 -0
  38. package/dist/eval-hooks.js +227 -4
  39. package/dist/flaky.d.ts +17 -0
  40. package/dist/flaky.js +9 -0
  41. package/dist/flow-engine.js +1 -1
  42. package/dist/flow-pull-request.js +1 -1
  43. package/dist/lead-doors.js +4 -2
  44. package/dist/lead-tools.js +10 -2
  45. package/dist/local-owner.d.ts +1 -1
  46. package/dist/local-owner.js +11 -0
  47. package/dist/memory-cli.d.ts +3 -0
  48. package/dist/memory-cli.js +3 -3
  49. package/dist/memory-pass.js +5 -1
  50. package/dist/model-choices.d.ts +7 -3
  51. package/dist/model-choices.js +26 -11
  52. package/dist/models-cli.d.ts +205 -0
  53. package/dist/models-cli.js +143 -4
  54. package/dist/models-ui.d.ts +57 -0
  55. package/dist/models-ui.js +44 -1
  56. package/dist/needs-you.d.ts +7 -7
  57. package/dist/needs-you.js +12 -10
  58. package/dist/operate-remote-arguments.js +4 -4
  59. package/dist/operate-remote.js +1 -1
  60. package/dist/operate.js +191 -23
  61. package/dist/phase-routing.d.ts +82 -2
  62. package/dist/phase-routing.js +132 -18
  63. package/dist/project-delete.d.ts +4 -0
  64. package/dist/project-delete.js +16 -4
  65. package/dist/project-memory.d.ts +8 -0
  66. package/dist/project-memory.js +20 -1
  67. package/dist/pull-request-flow.js +1 -1
  68. package/dist/push.d.ts +10 -3
  69. package/dist/push.js +22 -6
  70. package/dist/release-adapters.js +11 -1
  71. package/dist/release-approval.d.ts +191 -0
  72. package/dist/release-approval.js +382 -0
  73. package/dist/release-auto.js +22 -4
  74. package/dist/release-card.d.ts +73 -4
  75. package/dist/release-card.js +168 -23
  76. package/dist/release-cli.js +9 -1
  77. package/dist/release.d.ts +30 -0
  78. package/dist/release.js +42 -3
  79. package/dist/result-actions.d.ts +4 -0
  80. package/dist/result-actions.js +3 -0
  81. package/dist/result-acts.d.ts +3 -3
  82. package/dist/result-acts.js +1 -1
  83. package/dist/route-hooks.d.ts +54 -0
  84. package/dist/route-hooks.js +179 -0
  85. package/dist/route-results.d.ts +70 -0
  86. package/dist/route-results.js +340 -0
  87. package/dist/route-rules.d.ts +324 -0
  88. package/dist/route-rules.js +781 -0
  89. package/dist/route-savings.d.ts +136 -0
  90. package/dist/route-savings.js +193 -0
  91. package/dist/route-store.d.ts +300 -0
  92. package/dist/route-store.js +598 -0
  93. package/dist/scope.d.ts +3 -1
  94. package/dist/scope.js +4 -1
  95. package/dist/server/guards.js +1 -0
  96. package/dist/server/pages-assets.js +1 -1
  97. package/dist/server/render-chat.js +2 -2
  98. package/dist/server/render-release.d.ts +17 -0
  99. package/dist/server/render-release.js +84 -0
  100. package/dist/server/render-results.d.ts +0 -2
  101. package/dist/server/render-results.js +4 -6
  102. package/dist/server/render-tasks.d.ts +7 -0
  103. package/dist/server/render-tasks.js +16 -3
  104. package/dist/server/route-table.d.ts +1 -1
  105. package/dist/server/route-table.js +19 -1
  106. package/dist/server/settings.d.ts +2 -1
  107. package/dist/server/settings.js +87 -1
  108. package/dist/server/task-views.js +6 -0
  109. package/dist/server/tasks.d.ts +15 -0
  110. package/dist/server/tasks.js +105 -20
  111. package/dist/settings-proofs.js +2 -0
  112. package/dist/spend-ui.d.ts +11 -0
  113. package/dist/spend-ui.js +49 -2
  114. package/dist/stop-proof.d.ts +67 -0
  115. package/dist/stop-proof.js +293 -0
  116. package/dist/store.d.ts +106 -31
  117. package/dist/store.js +653 -128
  118. package/dist/surface.js +17 -5
  119. package/dist/task-outcome-cli.js +24 -2
  120. package/dist/toolroll-update-ui.js +2 -2
  121. package/dist/toolroll-update.js +4 -2
  122. package/dist/work-index.js +7 -9
  123. package/dist/work-summary.d.ts +2 -2
  124. package/package.json +1 -1
@@ -1,4 +1,4 @@
1
- import { type ReleaseTerms } from "./contracts/release-terms.js";
1
+ import { type ReleaseCheckPurpose, type ReleaseTerms } from "./contracts/release-terms.js";
2
2
  import type { Scope } from "./scope.js";
3
3
  import type { Store } from "./store.js";
4
4
  /** One signed term as the card says it. `key` names the scope field or the release term's path. */
@@ -29,6 +29,23 @@ export declare const SCOPE_TERM_LABELS: {
29
29
  /** Every field of the release terms, by its path in the terms (contracts/release-terms.ts), against the schema's
30
30
  * own leaves: the contract test checks every one has a label. */
31
31
  export declare const RELEASE_TERM_LABELS: Readonly<Record<string, string>>;
32
+ /** Every field of a release check's purpose (contracts/release-terms.ts releaseCheckPurposeSchema): the sealed form
33
+ * Toolroll's own releases take while their checks run outside the sandbox. A contract test checks every leaf has one. */
34
+ export declare const RELEASE_CHECK_LABELS: Readonly<Record<string, string>>;
35
+ /** What a release scope sealed, either form: release terms (later test-only commits may carry) or a release check's
36
+ * purpose (every later commit asks again). The identity both share is read from here, never from a title or an id. */
37
+ export type ReleaseSeal = {
38
+ kind: "terms";
39
+ terms: ReleaseTerms;
40
+ identity: ReleaseTerms;
41
+ } | {
42
+ kind: "check";
43
+ purpose: ReleaseCheckPurpose;
44
+ identity: ReleaseCheckPurpose;
45
+ };
46
+ export declare function releaseSealOf(scope: Pick<Scope, "releaseTerms" | "releaseCheck"> | null): ReleaseSeal | null;
47
+ /** What an unsandboxed (or sign-ins) release check means, said before anyone approves it. Null in the sandbox. */
48
+ export declare function releaseAccessWords(access: ReleaseTerms["access"]): string | null;
32
49
  /** The leaf paths of a zod object schema (nested objects joined with dots). */
33
50
  export declare function schemaLeaves(schema: unknown, prefix?: string): string[];
34
51
  /** Every signed term of `scope`, its release terms included, one line each. Reads only. */
@@ -48,6 +65,9 @@ export type ChangeList = {
48
65
  digest: string;
49
66
  /** A release is on the record but its merge commit can't be read (eval-harness review 2, finding 4). */
50
67
  baselineUnreadable?: true;
68
+ /** Why the list couldn't be read whole (Codex round 2, finding 6): the pull request moved, or it is longer than
69
+ * GitHub lists. Part of the digest; never a shortened list. */
70
+ why?: string;
51
71
  };
52
72
  /**
53
73
  * The plane's own record of the last verified release of `repo`: the merge commit GitHub answered with, recorded once
@@ -70,8 +90,57 @@ export declare function commitsBetween(repo: string, from: string, to: string):
70
90
  sha: string;
71
91
  subject: string;
72
92
  }[] | null;
73
- /** What changed since the last verified release, for a release's terms, from Toolroll's records only. */
74
- export declare function releaseChangeList(store: Store, terms: ReleaseTerms): ChangeList;
93
+ /** GitHub lists at most this many of a pull request's commits; a longer one is never shown as complete. */
94
+ export declare const PULL_REQUEST_COMMITS_MAX = 250;
95
+ /** A commit subject is someone else's text: one line, control-free (src/terminal-text.ts), bounded. The card digest
96
+ * binds this cleaned text, so every surface shows exactly what was bound (Codex round 2, finding 7). */
97
+ export declare const subjectText: (subject: string) => string;
98
+ /** How the plane reads GitHub for a pull request's commits (a seam for tests). */
99
+ export type PullRequestReader = (args: readonly string[]) => {
100
+ status: number | null;
101
+ stdout: string;
102
+ };
103
+ /**
104
+ * A pull request's own commits from GitHub, for the recorded owner/name, complete and bound to the candidate (Codex
105
+ * round 2, finding 6): every page (`--paginate`), accepted only when the pull request's head is `head`, the newest
106
+ * listed commit is `head` and the count is the pull request's own count. Otherwise why, never a shortened list.
107
+ */
108
+ export declare function pullRequestCommits(slug: string, pr: number, head: string, read?: PullRequestReader): {
109
+ ok: true;
110
+ commits: {
111
+ sha: string;
112
+ subject: string;
113
+ }[];
114
+ } | {
115
+ ok: false;
116
+ why: string;
117
+ };
118
+ /** The ledger act that records a verified release (the merged tree proved equal to the approved one). */
119
+ export declare const RELEASE_VERIFIED_ACTION = "release verified";
120
+ /**
121
+ * Record a verified release (Codex round 2, finding 1: verified release history apart from carrying). The coordinator
122
+ * calls it once main's merge commit was proved to hold the approved tree; the plane proves it again here, from its own
123
+ * records only: the task is a release scope of a project, and `merge` read as a verified Git object from the project's
124
+ * recorded Git directory has exactly the sealed approved tree. False when anything doesn't hold (nothing written).
125
+ */
126
+ export declare function recordVerifiedRelease(store: Store, input: {
127
+ taskId: string;
128
+ merge: string;
129
+ now: Date;
130
+ }): boolean;
131
+ /**
132
+ * The last verified release of `repo`, from either record: a merged release authority's succeeded merge, or a verified
133
+ * release row (recordVerifiedRelease). The newer stands. Shaped as releaseBaselineRecord.
134
+ */
135
+ export declare function verifiedReleaseBaseline(store: Store, repo: string): {
136
+ recorded: false;
137
+ } | {
138
+ recorded: true;
139
+ sha: string | null;
140
+ };
141
+ /** What changed since the last verified release, for a release's sealed identity (terms or a release check's purpose),
142
+ * from Toolroll's records only. */
143
+ export declare function releaseChangeList(store: Store, terms: Pick<ReleaseTerms, "repo" | "approvedSha" | "pr">, read?: PullRequestReader): ChangeList;
75
144
  export type ReleaseCard = {
76
145
  /** Binds the scope digest, the plan form's digest, the baseline and the change list. */
77
146
  digest: string;
@@ -85,4 +154,4 @@ export declare function cardDigestOf(scopeDigest: string, formDigest: string, ch
85
154
  /** The release card for a deploy gate, against the live records now. */
86
155
  export declare function releaseCardOf(store: Store, scope: Scope, formDigest: string, words: {
87
156
  profile: (profile: Scope["profile"]) => string | null;
88
- }): ReleaseCard;
157
+ }, read?: PullRequestReader): ReleaseCard;
@@ -22,7 +22,9 @@ import { join } from "node:path";
22
22
  import { checkAccessWords } from "./check-access.js";
23
23
  import { CHECK_LEVEL_WORDS, effectiveCheckLevel } from "./check-levels.js";
24
24
  import { verifiedRange } from "./commit-extract.js";
25
- import { releaseTermsSchema } from "./contracts/release-terms.js";
25
+ import { releaseCheckPurposeSchema, releaseTermsSchema } from "./contracts/release-terms.js";
26
+ import { readVerifiedCommit } from "./commit-extract.js";
27
+ import { cleanTerminalLine } from "./terminal-text.js";
26
28
  import { routeFromJson } from "./phase-routing.js";
27
29
  import { projectName } from "./project.js";
28
30
  import { RELEASE_CI_RUNNING_WORDS, releaseCiRunningOf } from "./release-ci-hold.js";
@@ -91,6 +93,30 @@ export const RELEASE_TERM_LABELS = {
91
93
  "policy.unsandboxedCarry": "Carry when checks aren't sandboxed",
92
94
  "grant.id": "Release check grant", "grant.digest": "Release check grant digest", access: "Release check access", expiresAt: "Expires",
93
95
  };
96
+ /** Every field of a release check's purpose (contracts/release-terms.ts releaseCheckPurposeSchema): the sealed form
97
+ * Toolroll's own releases take while their checks run outside the sandbox. A contract test checks every leaf has one. */
98
+ export const RELEASE_CHECK_LABELS = {
99
+ version: "Release check version", purpose: "Approved as", repo: "Repository", packageVersion: "Package version", branch: "Branch", pr: "Pull request",
100
+ base: "Base", approvedSha: "Approved commit", approvedTree: "Approved tree", "grant.id": "Release check grant", "grant.digest": "Release check grant digest",
101
+ access: "Release check access",
102
+ };
103
+ export function releaseSealOf(scope) {
104
+ if (scope === null)
105
+ return null;
106
+ if (scope.releaseTerms != null)
107
+ return { kind: "terms", terms: scope.releaseTerms, identity: scope.releaseTerms };
108
+ if (scope.releaseCheck != null)
109
+ return { kind: "check", purpose: scope.releaseCheck, identity: scope.releaseCheck };
110
+ return null;
111
+ }
112
+ /** What an unsandboxed (or sign-ins) release check means, said before anyone approves it. Null in the sandbox. */
113
+ export function releaseAccessWords(access) {
114
+ if (access === "unsandboxed")
115
+ return "The release check runs this commit's code with full access to this computer.";
116
+ if (access === "sign-ins")
117
+ return "The release check runs this commit's code with your sign-ins on this computer.";
118
+ return null;
119
+ }
94
120
  /** The leaf paths of a zod object schema (nested objects joined with dots). */
95
121
  export function schemaLeaves(schema, prefix = "") {
96
122
  const shape = schema.shape;
@@ -140,6 +166,15 @@ export function approvalTermsOf(store, scope, words) {
140
166
  lines.push({ key: leaf.path, label: `Phase route ${leaf.path.slice("route.".length)}`, value: leaf.value });
141
167
  }
142
168
  const terms = scope.releaseTerms ?? null;
169
+ // A release check's purpose (the form Toolroll's own releases seal): every one of its fields, never "none".
170
+ if (terms === null && scope.releaseCheck != null) {
171
+ const purpose = scope.releaseCheck;
172
+ for (const path of schemaLeaves(releaseCheckPurposeSchema)) {
173
+ const value = path === "pr" ? `#${purpose.pr}` : path === "purpose" ? "a release check: every later commit asks again" : said(valueAt(purpose, path));
174
+ lines.push({ key: `releaseCheck.${path}`, label: RELEASE_CHECK_LABELS[path] ?? path, value });
175
+ }
176
+ return lines;
177
+ }
143
178
  for (const path of schemaLeaves(releaseTermsSchema)) {
144
179
  const label = RELEASE_TERM_LABELS[path] ?? path;
145
180
  const value = terms === null ? NONE : path === "pr" ? `#${terms.pr}`
@@ -195,20 +230,120 @@ export function commitsBetween(repo, from, to) {
195
230
  const range = verifiedRange(gitDir, from, to, { git: tools.git, env });
196
231
  if (!range.ok)
197
232
  return null;
198
- return range.value.map(one => ({ sha: one.id, subject: one.message.split("\n")[0].trim() }));
233
+ return range.value.map(one => ({ sha: one.id, subject: subjectText(one.message) }));
199
234
  }
200
- /** A pull request's own commits from GitHub, for the recorded owner/name. Null when GitHub can't be read. */
201
- function pullRequestCommits(slug, pr) {
202
- const tools = releaseTools();
203
- const read = spawnSync(tools.gh, ["api", `repos/${slug}/pulls/${pr}/commits?per_page=100`, "--jq", '.[] | .sha + "\\t" + (.commit.message | split("\\n")[0])'], { cwd: "/", env: releaseGitEnv(), encoding: "utf8", timeout: 15_000 });
204
- if (read.status !== 0)
235
+ /** GitHub lists at most this many of a pull request's commits; a longer one is never shown as complete. */
236
+ export const PULL_REQUEST_COMMITS_MAX = 250;
237
+ /** A commit subject is someone else's text: one line, control-free (src/terminal-text.ts), bounded. The card digest
238
+ * binds this cleaned text, so every surface shows exactly what was bound (Codex round 2, finding 7). */
239
+ export const subjectText = (subject) => cleanTerminalLine(subject.split("\n")[0] ?? "").trim().slice(0, 200);
240
+ const ghRead = args => {
241
+ const read = spawnSync(releaseTools().gh, args, { cwd: "/", env: releaseGitEnv(), encoding: "utf8", timeout: 30_000, maxBuffer: 16 * 1024 * 1024 });
242
+ return { status: read.status, stdout: String(read.stdout ?? "") };
243
+ };
244
+ /**
245
+ * A pull request's own commits from GitHub, for the recorded owner/name, complete and bound to the candidate (Codex
246
+ * round 2, finding 6): every page (`--paginate`), accepted only when the pull request's head is `head`, the newest
247
+ * listed commit is `head` and the count is the pull request's own count. Otherwise why, never a shortened list.
248
+ */
249
+ export function pullRequestCommits(slug, pr, head, read = ghRead) {
250
+ const about = read(["api", `repos/${slug}/pulls/${pr}`, "--jq", '[.head.sha, (.commits|tostring)] | join("\\t")']);
251
+ if (about.status !== 0)
252
+ return { ok: false, why: "GitHub couldn't be read" };
253
+ const [prHead, countText] = about.stdout.trim().split("\t");
254
+ const total = Number(countText);
255
+ if (prHead !== head)
256
+ return { ok: false, why: `the pull request is at ${SHA.test(prHead ?? "") ? prHead.slice(0, 7) : "another commit"}, not ${head.slice(0, 7)}` };
257
+ if (!Number.isSafeInteger(total) || total < 1)
258
+ return { ok: false, why: "GitHub didn't say how many commits the pull request has" };
259
+ if (total > PULL_REQUEST_COMMITS_MAX)
260
+ return { ok: false, why: `incomplete: the pull request has ${total} commits and GitHub lists only ${PULL_REQUEST_COMMITS_MAX}` };
261
+ const listed = read(["api", "--paginate", `repos/${slug}/pulls/${pr}/commits?per_page=100`, "--jq", '.[] | .sha + "\\t" + ((.commit.message | split("\\n")[0]) | @json)']);
262
+ if (listed.status !== 0)
263
+ return { ok: false, why: "GitHub couldn't be read" };
264
+ const commits = [];
265
+ for (const line of listed.stdout.split("\n")) {
266
+ if (line === "")
267
+ continue;
268
+ const tab = line.indexOf("\t");
269
+ const sha = line.slice(0, tab);
270
+ let subject;
271
+ try {
272
+ subject = JSON.parse(line.slice(tab + 1));
273
+ }
274
+ catch {
275
+ return { ok: false, why: "GitHub's answer didn't parse" };
276
+ }
277
+ if (tab < 0 || !SHA.test(sha) || typeof subject !== "string")
278
+ return { ok: false, why: "GitHub's answer didn't parse" };
279
+ commits.push({ sha, subject: subjectText(subject) });
280
+ }
281
+ if (commits.length !== total)
282
+ return { ok: false, why: `incomplete: GitHub listed ${commits.length} of ${total} commits` };
283
+ if (commits[commits.length - 1].sha !== head)
284
+ return { ok: false, why: `the newest commit GitHub listed isn't ${head.slice(0, 7)}` };
285
+ if (new Set(commits.map(one => one.sha)).size !== commits.length)
286
+ return { ok: false, why: "GitHub listed a commit twice" };
287
+ return { ok: true, commits: commits.reverse() };
288
+ }
289
+ /** The ledger act that records a verified release (the merged tree proved equal to the approved one). */
290
+ export const RELEASE_VERIFIED_ACTION = "release verified";
291
+ /** The project's own Git directory: `<realpath(repo)>/.git`, a real directory. */
292
+ function gitDirOf(repo) {
293
+ try {
294
+ const named = join(realpathSync(repo), ".git");
295
+ return lstatSync(named).isDirectory() ? realpathSync(named) : null;
296
+ }
297
+ catch {
205
298
  return null;
206
- return read.stdout.split("\n").filter(line => line !== "").map(line => { const tab = line.indexOf("\t"); return { sha: line.slice(0, tab), subject: line.slice(tab + 1) }; })
207
- .filter(one => SHA.test(one.sha)).reverse();
299
+ }
300
+ }
301
+ /**
302
+ * Record a verified release (Codex round 2, finding 1: verified release history apart from carrying). The coordinator
303
+ * calls it once main's merge commit was proved to hold the approved tree; the plane proves it again here, from its own
304
+ * records only: the task is a release scope of a project, and `merge` read as a verified Git object from the project's
305
+ * recorded Git directory has exactly the sealed approved tree. False when anything doesn't hold (nothing written).
306
+ */
307
+ export function recordVerifiedRelease(store, input) {
308
+ if (!SHA.test(input.merge))
309
+ return false;
310
+ const repo = store.lookupRef(input.taskId)?.repo ?? null;
311
+ const seal = releaseSealOf(store.getScope(input.taskId));
312
+ if (repo === null || seal === null || seal.identity.repo !== repo)
313
+ return false;
314
+ const gitDir = gitDirOf(repo);
315
+ if (gitDir === null)
316
+ return false;
317
+ const commit = readVerifiedCommit(gitDir, input.merge);
318
+ if (!commit.ok || commit.value.tree !== seal.identity.approvedTree)
319
+ return false;
320
+ if (store.handle.prepare("SELECT 1 FROM action_ledger WHERE task_id = ? AND action = ? AND detail = ?").get(input.taskId, RELEASE_VERIFIED_ACTION, input.merge) !== undefined)
321
+ return true;
322
+ store.recordAction({ at: input.now.toISOString(), actor: "toolroll", repo, taskId: input.taskId, runId: null, action: RELEASE_VERIFIED_ACTION, outcome: seal.identity.packageVersion,
323
+ source: "work", detail: input.merge });
324
+ return true;
325
+ }
326
+ /**
327
+ * The last verified release of `repo`, from either record: a merged release authority's succeeded merge, or a verified
328
+ * release row (recordVerifiedRelease). The newer stands. Shaped as releaseBaselineRecord.
329
+ */
330
+ export function verifiedReleaseBaseline(store, repo) {
331
+ const authority = store.handle.prepare(`SELECT o.external_ref, COALESCE(o.finished_at, '') AS at FROM release_authority a LEFT JOIN release_operation o
332
+ ON o.authority = a.id AND o.kind = 'merge' AND o.state = 'succeeded'
333
+ WHERE a.repo = ? AND a.state = 'merged' ORDER BY a.id DESC, o.finished_at DESC LIMIT 1`).get(repo);
334
+ const verified = store.handle.prepare("SELECT detail, at FROM action_ledger WHERE repo = ? AND action = ? ORDER BY id DESC LIMIT 1").get(repo, RELEASE_VERIFIED_ACTION);
335
+ const verifiedSha = typeof verified?.["detail"] === "string" && SHA.test(verified["detail"]) ? verified["detail"] : null;
336
+ if (verified !== undefined && verifiedSha !== null && (authority === undefined || String(verified["at"]) >= String(authority["at"])))
337
+ return { recorded: true, sha: verifiedSha };
338
+ if (authority === undefined)
339
+ return verified === undefined ? { recorded: false } : { recorded: true, sha: null };
340
+ const ref = authority["external_ref"];
341
+ return { recorded: true, sha: typeof ref === "string" && SHA.test(ref) ? ref : null };
208
342
  }
209
- /** What changed since the last verified release, for a release's terms, from Toolroll's records only. */
210
- export function releaseChangeList(store, terms) {
211
- const record = releaseBaselineRecord(store, terms.repo);
343
+ /** What changed since the last verified release, for a release's sealed identity (terms or a release check's purpose),
344
+ * from Toolroll's records only. */
345
+ export function releaseChangeList(store, terms, read) {
346
+ const record = verifiedReleaseBaseline(store, terms.repo);
212
347
  // A release recorded with a merge commit that can't be read: the list couldn't be read (eval-harness review 2,
213
348
  // finding 4) — never the pull request's commits, as if there were no previous release.
214
349
  if (record.recorded && record.sha === null)
@@ -216,6 +351,7 @@ export function releaseChangeList(store, terms) {
216
351
  const baseline = record.recorded ? record.sha : null;
217
352
  let commits;
218
353
  let source;
354
+ let why;
219
355
  if (baseline !== null) {
220
356
  commits = commitsBetween(terms.repo, baseline, terms.approvedSha);
221
357
  source = commits === null ? "unread" : "recorded";
@@ -223,10 +359,14 @@ export function releaseChangeList(store, terms) {
223
359
  else {
224
360
  const remote = store.projectRemote(terms.repo);
225
361
  const parsed = remote === null ? null : releaseRemote(remote);
226
- commits = parsed === null ? null : pullRequestCommits(parsed.slug, terms.pr);
362
+ const listed = parsed === null ? { ok: false, why: "the project has no GitHub remote on record" } : pullRequestCommits(parsed.slug, terms.pr, terms.approvedSha, read);
363
+ commits = listed.ok ? listed.commits : null;
364
+ if (!listed.ok)
365
+ why = listed.why;
227
366
  source = commits === null ? "unread" : "pull-request";
228
367
  }
229
- return { baseline, commits, source, digest: sha256(`release-changes:${JSON.stringify({ baseline, source, commits })}`) };
368
+ return { baseline, commits, source, ...(why === undefined ? {} : { why }),
369
+ digest: sha256(`release-changes:${JSON.stringify(why === undefined ? { baseline, source, commits } : { baseline, source, commits, why })}`) };
230
370
  }
231
371
  /** The card digest: everything the person is shown that can change under them. */
232
372
  export function cardDigestOf(scopeDigest, formDigest, changes) {
@@ -236,25 +376,30 @@ function changeWords(changes, terms, cap) {
236
376
  const head = changes.baseline === null ? `No earlier verified release is recorded. ${changes.source === "pull-request" ? `Pull request #${terms?.pr ?? "?"}'s commits:` : ""}`.trim()
237
377
  : `Changes since the last verified release (${changes.baseline.slice(0, 7)}):`;
238
378
  if (changes.commits === null)
239
- return [head, changes.baseline === null && changes.baselineUnreadable !== true ? `The pull request's commits couldn't be read; see #${terms?.pr ?? "?"} on GitHub.` : "The change list couldn't be read."];
379
+ return [head, changes.baseline === null && changes.baselineUnreadable !== true ? `The pull request's commits couldn't be read${changes.why === undefined ? "" : ` (${changes.why})`}; see #${terms?.pr ?? "?"} on GitHub.` : "The change list couldn't be read."];
240
380
  if (changes.commits.length === 0)
241
381
  return [head, "No commits."];
242
382
  return [head, ...changes.commits.slice(0, cap).map(one => `• ${one.sha.slice(0, 7)} ${one.subject.slice(0, 100)}`), ...(changes.commits.length > cap ? [`• and ${changes.commits.length - cap} more`] : [])];
243
383
  }
244
384
  /** The release card for a deploy gate, against the live records now. */
245
- export function releaseCardOf(store, scope, formDigest, words) {
385
+ export function releaseCardOf(store, scope, formDigest, words, read) {
246
386
  const terms = scope.releaseTerms ?? null;
387
+ // Either sealed form (Codex round 2, finding 1): the identity both share, for the head lines and the change list.
388
+ const identity = releaseSealOf(scope)?.identity ?? null;
247
389
  const lines = approvalTermsOf(store, scope, words);
248
390
  const value = (key) => lines.find(line => line.key === key)?.value ?? NONE;
249
- const changes = terms === null
391
+ const changes = identity === null
250
392
  ? { baseline: null, commits: null, source: "unread", digest: sha256("release-changes:none") }
251
- : releaseChangeList(store, terms);
252
- const version = terms?.packageVersion ?? null;
393
+ : releaseChangeList(store, identity, read);
394
+ const version = identity?.packageVersion ?? null;
253
395
  const fileName = `release-${version ?? scope.taskId.replace(/[^A-Za-z0-9._-]/g, "-")}-terms.txt`;
254
396
  const short = [
255
397
  version === null ? `Deploy gate: ${value("title")}` : `Release ${version}: ${value("title")}`,
256
- terms === null ? `Candidate ${said(scope.candidate).slice(0, 12)} · ${value("project")}` : `${projectName(terms.repo)} · ${terms.branch} · pull request #${terms.pr} · commit ${terms.approvedSha.slice(0, 7)}`,
257
- terms === null ? `Checks: ${value("checks")} · check access: ${value("checkAccess")}` : `Carry later commits: ${terms.policy.carry ? "yes" : "no"} · checks: ${value("checks")} · expires ${terms.expiresAt}`,
398
+ identity === null ? `Candidate ${said(scope.candidate).slice(0, 12)} · ${value("project")}` : `${projectName(identity.repo)} · ${identity.branch} · pull request #${identity.pr} · commit ${identity.approvedSha.slice(0, 7)}`,
399
+ terms === null ? identity === null ? `Checks: ${value("checks")} · check access: ${value("checkAccess")}` : `Carry later commits: no, every later commit asks again · checks: ${value("checks")}`
400
+ : `Carry later commits: ${terms.policy.carry ? "yes" : "no"} · checks: ${value("checks")} · expires ${terms.expiresAt}`,
401
+ // What an unsandboxed check means, before consent (Codex round 2, finding 1).
402
+ ...(identity === null || releaseAccessWords(identity.access) === null ? [] : [releaseAccessWords(identity.access)]),
258
403
  // Option A's disclosure, on the card as on the approval sheet: never approved from chat without it.
259
404
  ...(terms !== null && terms.policy.carry && terms.access !== "sandboxed" ? ["This project's checks don't run in the sandbox: later test-only commits will run outside the sandbox without being shown to you first."] : []),
260
405
  // Filed while CI runs (Alex, 2026-10-29): a person may approve now, and is told plainly what still waits.
@@ -262,10 +407,10 @@ export function releaseCardOf(store, scope, formDigest, words) {
262
407
  // GitHub's nightly (nightly-github.ts): what holds this release, or why it can't be read. Not a signed term.
263
408
  ...(terms === null ? [] : nightlyReleaseLines(store, { repo: terms.repo, taskId: scope.taskId, candidate: scope.candidate ?? null, now: new Date() })),
264
409
  "",
265
- ...changeWords(changes, terms, 5),
410
+ ...changeWords(changes, identity, 5),
266
411
  "",
267
412
  `Every term is in ${fileName}.`,
268
413
  ].join("\n");
269
- const full = [`${version === null ? "Deploy gate" : `Release ${version}`}: every term`, "", termsText(lines), "", ...changeWords(changes, terms, 200)].join("\n");
414
+ const full = [`${version === null ? "Deploy gate" : `Release ${version}`}: every term`, "", termsText(lines), "", ...changeWords(changes, identity, 200)].join("\n");
270
415
  return { digest: cardDigestOf(scope.digest, formDigest, changes), short, full, fileName, changes };
271
416
  }
@@ -36,9 +36,14 @@ export const RELEASE_HELP = `toolroll release <branch> — release Toolroll itse
36
36
 
37
37
  Run the same command to resume after a stop. Interrupted deployments recover their saved stage before retrying.
38
38
 
39
- A branch whose name is one of this command's own words (status, nightly, carry…) is named with --branch <name>
39
+ A branch whose name is one of this command's own words (approve, status, nightly, carry…) is named with --branch <name>
40
40
  or after --: \`toolroll release --branch status\`.
41
41
 
42
+ toolroll release approve [<branch>] [--repo <path>] — approve the release waiting for you: shows the release, GitHub CI
43
+ now, what approving does, what changed and every term, then asks for your password (or uses your remembered login
44
+ where Passwordless allows it). Names the waiting releases when more than one could be meant. With --json it never
45
+ prompts: --as <you> --token <password> --digest <card digest>
46
+
42
47
  toolroll release status [<task>] — the steps of a release (the newest one recorded, or the release task named): each
43
48
  step's state and how long it took, and where it stopped
44
49
 
@@ -233,6 +238,9 @@ export async function runReleaseCommand(args, write, deps = {}) {
233
238
  return refuse("usage", "`toolroll release status [<task>]` takes only --json.", 2);
234
239
  return runStatusCommand(positional[1], write, json, deps);
235
240
  }
241
+ // `release approve` is the toolroll command's own (cli.ts routes it to the operate commands); never a branch here.
242
+ if (!named && positional[0] === "approve")
243
+ return refuse("usage", "Run `toolroll release approve [<branch>]`. A branch named approve is released with `--branch approve`.", 2);
236
244
  if (!named && positional[0] === "nightly" && positional.length === 1)
237
245
  return runNightlyCommand({ repo: repo === undefined ? undefined : resolve(deps.cwd ?? process.cwd(), repo), as, token, schedule: scheduleFlag, start: bare.has("--start") }, write, json, deps);
238
246
  if (!named && ["carry", "revoke", "ask-every-commit", "unsandboxed-carry", "auto-approve", "publish-ok"].includes(positional[0] ?? "") && positional.length === 2) {
package/dist/release.d.ts CHANGED
@@ -332,6 +332,34 @@ export type ReleaseAuthorityAdapter = {
332
332
  why: string;
333
333
  warning: string | null;
334
334
  }>;
335
+ /** One-tap release approval (docs/plans/trusted-release-reports.md, slice A): what the plane tells a person and
336
+ * keeps for the approval sheet. Best effort: a failure here never changes the release. Absent on an older plane. */
337
+ ready?: ReleaseReadyAdapter;
338
+ };
339
+ /** What the coordinator tells the plane for a person's release (release-approval.ts). Ids and its own readings only. */
340
+ export type ReleaseReadyAdapter = {
341
+ /** The approval step was entered (a fresh filing or a resumed one): one notification per task and scope digest. */
342
+ asked(taskId: string): Promise<void>;
343
+ /** The pull request's required checks on the candidate, as this coordinator just read them. */
344
+ ci(input: {
345
+ taskId: string;
346
+ candidate: string;
347
+ state: "passed" | "running" | "failed";
348
+ failed: readonly string[];
349
+ runs: readonly CheckRun[];
350
+ }): Promise<void>;
351
+ /** A person's release stopped: one attention notification per task, step and reason. */
352
+ stopped(input: {
353
+ taskId: string;
354
+ step: string;
355
+ reason: string;
356
+ message: string;
357
+ }): Promise<void>;
358
+ /** Main's merge commit was proved to hold the approved tree: the plane checks it again and keeps the release's record. */
359
+ verified(input: {
360
+ taskId: string;
361
+ merge: string;
362
+ }): Promise<void>;
335
363
  };
336
364
  /** The plane's automatic release approval, as the coordinator asks it. Every answer is the plane's own: the coordinator
337
365
  * hands it ids, never evidence (the plane reads GitHub CI itself for `approve`). */
@@ -451,11 +479,13 @@ export type ReleaseAdapters = {
451
479
  }): Promise<{
452
480
  scopeDigest: string;
453
481
  }>;
482
+ /** `reason` "stopping": Toolroll is still proving the check's build stopped (stop-proof.ts); it clears on its own. */
454
483
  complete(taskId: string, receipt: string): Promise<{
455
484
  ok: true;
456
485
  } | {
457
486
  ok: false;
458
487
  message: string;
488
+ reason?: string;
459
489
  }>;
460
490
  busy(): Promise<{
461
491
  running: number;
package/dist/release.js CHANGED
@@ -453,6 +453,20 @@ export async function runRelease(options, adapters) {
453
453
  };
454
454
  // ---- one approval per release (release-once.md) -------------------------------------------------------------
455
455
  const rel = adapters.release;
456
+ /** One-tap release approval: tell the plane, never stopping or changing the release whatever it answers. */
457
+ const tell = async (act) => { if (rel?.ready === undefined)
458
+ return; try {
459
+ await act(rel.ready);
460
+ }
461
+ catch { /* best effort */ } };
462
+ /** The last pull request checks this run read on the candidate (the gate reads them before the task exists). */
463
+ let lastCi = null;
464
+ const noteCi = async (runs, verdict) => {
465
+ lastCi = { candidate: j.candidate.sha, state: verdict.state === "pending" ? "running" : verdict.state, failed: verdict.failed ?? [], runs: runs.filter(run => REQUIRED_PR_CHECKS.includes(run.name)) };
466
+ const seen = lastCi;
467
+ await tell(ready => ready.ci({ taskId: j.task.id, ...seen }));
468
+ };
469
+ const currentCi = () => lastCi;
456
470
  let epoch = null;
457
471
  // This invocation's own lease owner (review round 1): two runs of the same journal never share an epoch.
458
472
  const owner = `${j.id}:${randomUUID()}`;
@@ -693,6 +707,7 @@ export async function runRelease(options, adapters) {
693
707
  throw error;
694
708
  }
695
709
  const verdict = prChecksVerdict(runs, j.candidate.sha, REQUIRED_PR_CHECKS, { workflow: REQUIRED_PR_CHECKS_WORKFLOW });
710
+ await noteCi(runs, verdict);
696
711
  if (verdict.state === "failed") {
697
712
  throw new Stop("ci-failed", `GitHub's checks failed on ${j.candidate.sha.slice(0, 7)}: ${verdict.failed.join(", ")}. ${nothing}. ${j.approval === null ? "" : `The approval covers ${j.candidate.sha.slice(0, 7)} only. `}Rerun the failed job on GitHub and run \`${resume}\` again, or fix the branch and release the new commit with \`toolroll release ${releaseBranchArg(j.branch)} --repo ${j.checkout} --new\`.`);
698
713
  }
@@ -810,7 +825,7 @@ export async function runRelease(options, adapters) {
810
825
  // The gate only stops on a check that already failed: the task is filed and a person asked while CI runs, and
811
826
  // the release check runs beside it (pipeline-speed.md, D2 and "Decision: a person's release asks during
812
827
  // CI"). The automatic approval waits for CI (the check step); nothing deploys before it passes (the deploy).
813
- const settled = await earlyChecks(adapters, j, (what, probe) => waitFor("gate", what, probe), { pendingIsEnough: true });
828
+ const settled = await earlyChecks(adapters, j, (what, probe) => waitFor("gate", what, probe), { pendingIsEnough: true, seen: (runs, verdict) => noteCi(runs, verdict) });
814
829
  // Recorded before the task exists, so its approval card says CI still runs from the first moment.
815
830
  if (!settled)
816
831
  await rel?.auto?.ciRunning?.(j.task.id, j.candidate.sha);
@@ -830,6 +845,10 @@ export async function runRelease(options, adapters) {
830
845
  ...(rel === undefined ? {} : { release: { version: j.packageVersion, pr: j.pr, branch: j.branch, noCarry: options.noCarry === true } }),
831
846
  });
832
847
  j.task.scopeDigest = filed.scopeDigest;
848
+ // The checks the gate read before the task existed, kept for its approval sheet now that it does.
849
+ const seen = currentCi();
850
+ if (seen !== null)
851
+ await tell(ready => ready.ci({ taskId: j.task.id, ...seen }));
833
852
  }
834
853
  advance("approval");
835
854
  break;
@@ -862,7 +881,11 @@ export async function runRelease(options, adapters) {
862
881
  // A check that already failed asks nobody (as at the gate).
863
882
  if (j.ciAhead === true)
864
883
  await ciWhileRunning("Nothing was deployed or merged", true);
884
+ // One push, on every entry to this step (a fresh filing or a resumed one): the plane files it once per task and
885
+ // scope digest, so a crash between filing and here is healed by the next run.
886
+ await tell(ready => ready.asked(j.task.id));
865
887
  say(` The release scope needs your approval: \`toolroll task approve ${j.task.id} --digest ${j.task.scopeDigest}\`, or open the task in the console.`);
888
+ say(` Or approve it from here: \`toolroll release approve ${releaseBranchArg(j.branch)}\`.`);
866
889
  if (j.ciAhead === true)
867
890
  say(` ${CI_RUNNING_WORDS}`);
868
891
  const approved = await waitFor("approval", `a person to approve task ${j.task.id}`, async () => {
@@ -1007,7 +1030,12 @@ export async function runRelease(options, adapters) {
1007
1030
  await checked(gate, "complete");
1008
1031
  if (gate.completion?.digest !== check.receipt) {
1009
1032
  intend("complete");
1010
- const done = await adapters.toolroll.complete(j.task.id, check.receipt);
1033
+ // Toolroll proves the check's build stopped by itself (stop-proof.ts) a short while after it finished: the
1034
+ // release waits for that, never for a person to confirm it.
1035
+ const done = await waitFor("quiet", "Toolroll to make sure the release check stopped", async () => {
1036
+ const tried = await adapters.toolroll.complete(j.task.id, check.receipt);
1037
+ return !tried.ok && tried.reason === "stopping" ? null : tried;
1038
+ });
1011
1039
  if (!done.ok)
1012
1040
  throw new Stop("complete", `Couldn't mark task ${j.task.id} complete: ${done.message} Mark that exact result complete in the console, then rerun.`);
1013
1041
  }
@@ -1202,6 +1230,8 @@ export async function runRelease(options, adapters) {
1202
1230
  merge.tree = tree;
1203
1231
  if (tree !== j.candidate.tree)
1204
1232
  throw new Stop("tree-mismatch", `Main's merge commit ${merge.sha.slice(0, 7)} has tree ${tree.slice(0, 12)}, not the gated ${j.candidate.tree.slice(0, 12)}: main changed what was checked. Nothing was tagged; the deployed build stays as it is.`);
1233
+ // The release's verified record (trusted-release-reports.md A1): the next release's change list starts here.
1234
+ await tell(ready => ready.verified({ taskId: j.task.id, merge: merge.sha }));
1205
1235
  advance("tag");
1206
1236
  break;
1207
1237
  }
@@ -1288,6 +1318,14 @@ export async function runRelease(options, adapters) {
1288
1318
  }
1289
1319
  catch { /* the stop is still reported */ }
1290
1320
  mark(journal.step, "failed", stop.message);
1321
+ // A person's release that stops tells them (trusted-release-reports.md A5); the automatic road keeps its own notices.
1322
+ const ready = adapters.release?.ready;
1323
+ if (ready !== undefined && journal.auto?.state !== "checking" && journal.auto?.state !== "approved") {
1324
+ try {
1325
+ await ready.stopped({ taskId: journal.task.id, step: journal.step, reason: stop.reason, message: stop.message });
1326
+ }
1327
+ catch { /* best effort */ }
1328
+ }
1291
1329
  }
1292
1330
  return { ok: false, reason: stop.reason, message: stop.message, step: journal?.step ?? step, journal, file, resume };
1293
1331
  }
@@ -1428,6 +1466,7 @@ async function earlyChecks(adapters, j, wait, options = {}) {
1428
1466
  return null;
1429
1467
  }
1430
1468
  const verdict = prChecksVerdict(runs.filter(run => required.includes(run.name)), j.candidate.sha, required, { workflow: REQUIRED_PR_CHECKS_WORKFLOW });
1469
+ await options.seen?.(runs, verdict);
1431
1470
  if (verdict.state === "failed") {
1432
1471
  throw new Stop("ci-failed", `Pull request checks already failed on ${sha}: ${verdict.failed.join(", ")}. ${nothing}. 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).`);
1433
1472
  }
@@ -1616,6 +1655,6 @@ export function workflowIs(path, file) {
1616
1655
  return /^[A-Za-z0-9._+/-]+$/.test(ref) && !/\.ya?ml$/i.test(ref);
1617
1656
  }
1618
1657
  /** The words `toolroll release` reads as its own (release-cli.ts), never as a branch: such a branch is named with --branch. */
1619
- const RELEASE_WORDS = new Set(["status", "nightly", "carry", "revoke", "ask-every-commit", "unsandboxed-carry", "auto-approve", "publish-ok"]);
1658
+ const RELEASE_WORDS = new Set(["status", "nightly", "carry", "revoke", "ask-every-commit", "unsandboxed-carry", "auto-approve", "publish-ok", "approve"]);
1620
1659
  /** How a person names this branch to `toolroll release` again. */
1621
1660
  export function releaseBranchArg(branch) { return RELEASE_WORDS.has(branch) ? `--branch ${branch}` : branch; }
@@ -1,5 +1,6 @@
1
1
  import type { Store } from "./store.js";
2
2
  import { type FeedbackImage } from "./result-review.js";
3
+ import type { RevisionOrigin } from "./contracts/route-results.js";
3
4
  export type RevisionResult = {
4
5
  ok: true;
5
6
  id: string;
@@ -28,5 +29,8 @@ export declare function createResultRevision(store: Store, evidenceRoot: string,
28
29
  repos: readonly string[] | null;
29
30
  includeUnplaced?: boolean;
30
31
  allowMode?: boolean;
32
+ /** Who this revision comes from (results routing): each caller says it from its own trusted context, never from
33
+ * `allowMode` or the actor's name. Only `person` counts against the model that built the result. */
34
+ origin: RevisionOrigin;
31
35
  }, now: Date): RevisionResult;
32
36
  export {};
@@ -9,6 +9,7 @@ import { parseFeedbackImage, sameFeedbackImage, isRevisionFeedback, parseRevisio
9
9
  import { validateNote, hasForbiddenControls } from "./decision.js";
10
10
  import { TASK_TEXT_LIMITS, validateTaskText } from "./task-text.js";
11
11
  import { modeFilingCoverage } from "./scope.js";
12
+ import { recordRevisionOrigin } from "./route-store.js";
12
13
  /** Re-prove image custody and current result at each write. No file paths supplied by callers. */
13
14
  export function feedbackImageProblem(store, root, runId, image) {
14
15
  if (image == null)
@@ -257,6 +258,8 @@ export function createResultRevision(store, evidenceRoot, input, now) {
257
258
  // and collision handling. Set only this new row's display title,
258
259
  // atomically with its seal; retries returned earlier, untouched.
259
260
  store.raw().prepare("UPDATE task SET title = ? WHERE id = ?").run(title, result.id);
261
+ // Who it comes from, with its seal (route-store.ts: written once, on a revision only).
262
+ recordRevisionOrigin(store, result.id, input.origin);
260
263
  }
261
264
  // Planned afresh: the planner reads the copied terms and these notes, and a note asking for
262
265
  // more than the old plan allows becomes an amendment the person approves before anything
@@ -11,7 +11,7 @@
11
11
  * report and changes disagree,
12
12
  * or the proof is refuted → Request changes, then Accept (as allowed);
13
13
  * never Accept as ink, even with no form here
14
- * a Needs you form resolves it → that form (Build again, Confirm it stopped)
14
+ * a Needs you form resolves it → that form (Build again, Check again)
15
15
  * saved notes or a failing PR → Revise / Draft a repair task
16
16
  * a failed build → Retry (its note starts with what to change), then Run checks when they
17
17
  * didn't run; never Accept: a failed build is not a result to accept
@@ -37,7 +37,7 @@ export declare function evidenceProblemOf(verdict: ProofVerdict | null, reasons:
37
37
  * when nothing refutes it (a person's acceptance settles it). The result page
38
38
  * shows it before Request changes, its one ink act. */
39
39
  export declare function cantAcceptYetOf(verdict: ProofVerdict | null, reasons: readonly string[], accepted: boolean): string | null;
40
- export type ResultActKind = "retry" | "accept" | "accept-anyway" | "next-check" | "checks-running" | "run-checks" | "request-changes" | "rebuild" | "confirm-stopped" | "revise" | "draft-repair";
40
+ export type ResultActKind = "retry" | "accept" | "accept-anyway" | "next-check" | "checks-running" | "run-checks" | "request-changes" | "rebuild" | "recheck-stopped" | "revise" | "draft-repair";
41
41
  export type ResultActFacts = {
42
42
  /** An Accept this page can post (Accept and finish, or the person's acceptance alone); `ready`: every requirement met and checks passed. */
43
43
  accept: {
@@ -52,7 +52,7 @@ export type ResultActFacts = {
52
52
  /** The feedback form is on this page. */
53
53
  canRequest: boolean;
54
54
  /** A Needs you form that resolves it here. */
55
- need: "rebuild" | "confirm-stopped" | null;
55
+ need: "rebuild" | "recheck-stopped" | null;
56
56
  /** Saved notes ready to become a revision, or a failing pull request's repair. */
57
57
  next: "revise" | "draft-repair" | null;
58
58
  /** A failed build: `retry` when the task can be retried from here; `acceptAnyway` when it delivered a result that