toolroll 0.9.79 → 0.9.82

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 (139) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/dist/cli.js +1 -0
  3. package/dist/commit-extract.d.ts +18 -0
  4. package/dist/commit-extract.js +39 -1
  5. package/dist/converse.d.ts +12 -0
  6. package/dist/converse.js +18 -2
  7. package/dist/deploy-gate.js +8 -0
  8. package/dist/eval-gate.d.ts +51 -0
  9. package/dist/eval-gate.js +199 -0
  10. package/dist/eval-hooks.d.ts +39 -0
  11. package/dist/eval-hooks.js +477 -0
  12. package/dist/eval-oracle-main.d.ts +1 -0
  13. package/dist/eval-oracle-main.js +31 -0
  14. package/dist/eval-oracle.d.ts +60 -0
  15. package/dist/eval-oracle.js +217 -0
  16. package/dist/eval-world.d.ts +17 -0
  17. package/dist/eval-world.js +97 -0
  18. package/dist/evals.d.ts +299 -0
  19. package/dist/evals.js +964 -0
  20. package/dist/exec.js +15 -1
  21. package/dist/flow-draft.js +4 -0
  22. package/dist/flow-sort.js +4 -0
  23. package/dist/github-jobs.d.ts +25 -0
  24. package/dist/github-jobs.js +22 -0
  25. package/dist/guides.js +12 -9
  26. package/dist/invoke.d.ts +1 -1
  27. package/dist/invoke.js +14 -0
  28. package/dist/lead.js +4 -1
  29. package/dist/memory-pass.js +4 -0
  30. package/dist/nightly-check.d.ts +4 -1
  31. package/dist/nightly-check.js +5 -2
  32. package/dist/nightly-github.d.ts +316 -0
  33. package/dist/nightly-github.js +1022 -0
  34. package/dist/operate.d.ts +91 -1
  35. package/dist/operate.js +158 -1
  36. package/dist/project-knowledge.d.ts +4 -4
  37. package/dist/provider.d.ts +3 -0
  38. package/dist/provider.js +9 -2
  39. package/dist/release-adapters.d.ts +21 -21
  40. package/dist/release-adapters.js +53 -20
  41. package/dist/release-auto.d.ts +1 -1
  42. package/dist/release-auto.js +26 -0
  43. package/dist/release-card.js +3 -0
  44. package/dist/release-cli.js +59 -3
  45. package/dist/release-view.d.ts +3 -0
  46. package/dist/release-view.js +2 -0
  47. package/dist/release.d.ts +36 -17
  48. package/dist/release.js +74 -24
  49. package/dist/reported-usage.d.ts +69 -0
  50. package/dist/reported-usage.js +97 -0
  51. package/dist/server/chat.js +4 -0
  52. package/dist/server/render-tasks.js +1 -0
  53. package/dist/server/tasks.js +8 -1
  54. package/dist/store.d.ts +109 -1
  55. package/dist/store.js +394 -4
  56. package/dist/subagents.js +4 -0
  57. package/dist/subscription-chat.d.ts +2 -0
  58. package/dist/subscription-chat.js +52 -13
  59. package/dist/task-sizing.js +4 -0
  60. package/dist/teardown-git.d.ts +9 -2
  61. package/dist/teardown-git.js +56 -33
  62. package/evals/cases/catalog/case.json +25 -0
  63. package/evals/cases/catalog/fixture/README.md +3 -0
  64. package/evals/cases/catalog/fixture/package.json +8 -0
  65. package/evals/cases/catalog/fixture/test/catalog.test.mjs +4 -0
  66. package/evals/cases/catalog/oracle.mjs +23 -0
  67. package/evals/cases/catalog/probe.mjs +10 -0
  68. package/evals/cases/catalog/source.md +9 -0
  69. package/evals/cases/csv-quoting/case.json +23 -0
  70. package/evals/cases/csv-quoting/fixture/README.md +3 -0
  71. package/evals/cases/csv-quoting/fixture/csv.js +1 -0
  72. package/evals/cases/csv-quoting/fixture/package.json +8 -0
  73. package/evals/cases/csv-quoting/fixture/test/csv.test.mjs +5 -0
  74. package/evals/cases/csv-quoting/oracle.mjs +14 -0
  75. package/evals/cases/csv-quoting/probe.mjs +9 -0
  76. package/evals/cases/csv-quoting/source.md +9 -0
  77. package/evals/cases/dedupe/case.json +23 -0
  78. package/evals/cases/dedupe/fixture/README.md +3 -0
  79. package/evals/cases/dedupe/fixture/dedupe.js +1 -0
  80. package/evals/cases/dedupe/fixture/package.json +8 -0
  81. package/evals/cases/dedupe/fixture/test/dedupe.test.mjs +5 -0
  82. package/evals/cases/dedupe/oracle.mjs +16 -0
  83. package/evals/cases/dedupe/probe.mjs +13 -0
  84. package/evals/cases/dedupe/source.md +9 -0
  85. package/evals/cases/endpoint/case.json +25 -0
  86. package/evals/cases/endpoint/fixture/README.md +3 -0
  87. package/evals/cases/endpoint/fixture/package.json +8 -0
  88. package/evals/cases/endpoint/fixture/test/endpoint.test.mjs +4 -0
  89. package/evals/cases/endpoint/oracle.mjs +27 -0
  90. package/evals/cases/endpoint/probe.mjs +10 -0
  91. package/evals/cases/endpoint/source.md +11 -0
  92. package/evals/cases/math-divide/case.json +23 -0
  93. package/evals/cases/math-divide/fixture/README.md +3 -0
  94. package/evals/cases/math-divide/fixture/package.json +8 -0
  95. package/evals/cases/math-divide/fixture/src/math.js +1 -0
  96. package/evals/cases/math-divide/fixture/test/math.test.js +5 -0
  97. package/evals/cases/math-divide/oracle.mjs +13 -0
  98. package/evals/cases/math-divide/probe.mjs +9 -0
  99. package/evals/cases/math-divide/source.md +9 -0
  100. package/evals/cases/math-multiply/case.json +23 -0
  101. package/evals/cases/math-multiply/fixture/README.md +3 -0
  102. package/evals/cases/math-multiply/fixture/package.json +8 -0
  103. package/evals/cases/math-multiply/fixture/src/math.js +2 -0
  104. package/evals/cases/math-multiply/fixture/test/math.test.js +5 -0
  105. package/evals/cases/math-multiply/oracle.mjs +12 -0
  106. package/evals/cases/math-multiply/probe.mjs +9 -0
  107. package/evals/cases/math-multiply/source.md +9 -0
  108. package/evals/cases/math-subtract/case.json +23 -0
  109. package/evals/cases/math-subtract/fixture/README.md +3 -0
  110. package/evals/cases/math-subtract/fixture/package.json +8 -0
  111. package/evals/cases/math-subtract/fixture/src/math.js +1 -0
  112. package/evals/cases/math-subtract/fixture/test/math.test.js +5 -0
  113. package/evals/cases/math-subtract/oracle.mjs +12 -0
  114. package/evals/cases/math-subtract/probe.mjs +9 -0
  115. package/evals/cases/math-subtract/source.md +9 -0
  116. package/evals/cases/merge-config/case.json +23 -0
  117. package/evals/cases/merge-config/fixture/README.md +3 -0
  118. package/evals/cases/merge-config/fixture/config.js +1 -0
  119. package/evals/cases/merge-config/fixture/package.json +8 -0
  120. package/evals/cases/merge-config/fixture/test/config.test.mjs +5 -0
  121. package/evals/cases/merge-config/oracle.mjs +28 -0
  122. package/evals/cases/merge-config/probe.mjs +15 -0
  123. package/evals/cases/merge-config/source.md +9 -0
  124. package/evals/cases/paginate/case.json +23 -0
  125. package/evals/cases/paginate/fixture/README.md +3 -0
  126. package/evals/cases/paginate/fixture/package.json +8 -0
  127. package/evals/cases/paginate/fixture/paginate.js +1 -0
  128. package/evals/cases/paginate/fixture/test/paginate.test.mjs +5 -0
  129. package/evals/cases/paginate/oracle.mjs +18 -0
  130. package/evals/cases/paginate/probe.mjs +13 -0
  131. package/evals/cases/paginate/source.md +9 -0
  132. package/evals/cases/round2-asks/case.json +24 -0
  133. package/evals/cases/round2-asks/fixture/README.md +3 -0
  134. package/evals/cases/round2-asks/fixture/package.json +8 -0
  135. package/evals/cases/round2-asks/fixture/src/math.js +1 -0
  136. package/evals/cases/round2-asks/fixture/test/math.test.js +5 -0
  137. package/evals/cases/round2-asks/source.md +11 -0
  138. package/evals/manifest.json +15 -0
  139. package/package.json +2 -1
package/dist/operate.d.ts CHANGED
@@ -2,6 +2,7 @@ import { type IntegrationIo } from "./integrations.js";
2
2
  import { type OnboardIo } from "./agent-onboard.js";
3
3
  import { type Sizer } from "./task-sizing.js";
4
4
  import { type TriggerIo } from "./flow-triggers.js";
5
+ import { type GhRead } from "./nightly-github.js";
5
6
  import type { StepIo } from "./flow-steps.js";
6
7
  import type { FetchLike } from "./flow-share.js";
7
8
  import { type Store } from "./store.js";
@@ -9,7 +10,7 @@ import { type DispatchAdapter } from "./sync.js";
9
10
  import { type RolePort, type UpRoleStart } from "./up-processes.js";
10
11
  import { type TelegramTransport } from "./telegram.js";
11
12
  import { type LeadCliSeams } from "./lead-cli.js";
12
- import { type Principal, type RemoteSource } from "./operate-remote.js";
13
+ import { type Principal, type RemoteRun, type RemoteSource } from "./operate-remote.js";
13
14
  import { type PublishExec } from "./publish.js";
14
15
  import { type Runner as CommandRunner } from "./builder.js";
15
16
  import { type ReleaseIo } from "./releases.js";
@@ -150,6 +151,79 @@ export declare function runOperateAs(argv: string[], opts: {
150
151
  tool?: string;
151
152
  options?: OperateOptions;
152
153
  }): Promise<number>;
154
+ type Context = {
155
+ /** A remote run's person (runOperateAs): every credential check resolves to them. Absent for every local command. */
156
+ principal?: RemoteRun;
157
+ /** The lead token this command runs under: it signs in as its owner. */
158
+ leadToken?: string;
159
+ desktopIdentity?: string;
160
+ /** A desktop service directory is state, never an implicitly enrolled project. */
161
+ inferProjectFromCwd?: boolean;
162
+ /** Exact additions approved by the native project picker, independent of the enrollment registry. */
163
+ additionalProjectRepos?: () => readonly string[];
164
+ store: Store;
165
+ write: Write;
166
+ json: boolean;
167
+ /** Every --repo occurrence in order (arc 2) — only `up` reads it. */
168
+ repoList?: string[];
169
+ now: Date;
170
+ clock: () => Date;
171
+ evidenceRoot: string;
172
+ /** The directory holding the database — where credential files live. */
173
+ databaseFile: string;
174
+ /** Where `storage` looks for leftover test temp folders; default the temp folder and /tmp. */
175
+ tempRoots?: readonly string[];
176
+ /** The long-lived service's token: only a pass carrying it may start the automatic storage sweep. */
177
+ sweepOwner?: object;
178
+ telegramTokenFile: string;
179
+ agentRunner?: CommandRunner;
180
+ gitRunner?: CommandRunner;
181
+ telegramTransport?: TelegramTransport;
182
+ telegramEnv?: Record<string, string | undefined>;
183
+ /** Injected by tests: what `publish` runs for git and gh. */
184
+ publishExec?: PublishExec;
185
+ leadSeams?: LeadCliSeams;
186
+ /** Injected by tests: the external-dispatch gh surface. */
187
+ dispatchAdapter?: DispatchAdapter;
188
+ /** Injected by tests: how flow triggers reach GitHub (gh) and Linear (fetch), and where their secrets live. */
189
+ flowTriggerIo?: Partial<TriggerIo>;
190
+ /** Injected by tests: how flow check and update steps run commands and reach GitHub and Linear. */
191
+ flowStepIo?: Partial<StepIo>;
192
+ /** Injected by tests: how often a worker's flow passes run beside its builds (default FLOW_EVERY_MS). */
193
+ flowEveryMs?: number;
194
+ /** Injected by tests: the operating system this runs on (default process.platform). */
195
+ platform?: NodeJS.Platform;
196
+ /** Injected by tests: whether this machine has an OS fence for checks (default: probed). */
197
+ fenceAvailable?: () => boolean;
198
+ /** Injected by tests: how `flows import` fetches a flow file's address. */
199
+ flowFetch?: FetchLike;
200
+ integrationIo?: Partial<IntegrationIo>;
201
+ /**
202
+ * The stop fence (Codex M5-M8 audit, IV-1): set by the watch when a
203
+ * signal lands. A pass that sees true admits NOTHING more — no schedule
204
+ * fires, no claim, no run, no spawn. The in-flight build finishes under
205
+ * its own bounds (or the grace kill); admission is what stops.
206
+ */
207
+ shouldStop?: () => boolean;
208
+ /** A failed recovery pass pauses new admissions without stopping an owned build. */
209
+ shouldPauseAdmission?: () => boolean;
210
+ /** Test seam for task wait; production uses a timer. */
211
+ waitSleep?: (milliseconds: number) => Promise<void>;
212
+ releaseIo?: ReleaseIo;
213
+ installBin?: string;
214
+ onboardSeams?: OnboardSeams;
215
+ upSeams?: {
216
+ terminal?: boolean;
217
+ env?: Record<string, string | undefined>;
218
+ openBrowser?: (url: string) => void;
219
+ };
220
+ /** This process is one role of a `toolroll up`, started by it (never from the command line). */
221
+ upRole?: UpRoleHost;
222
+ /** In-memory seams a child process can't receive: `up` runs its console and worker in this one process. */
223
+ upOneProcess?: boolean;
224
+ /** Test seam: this computer's tailnet names (production asks `tailscale status`). */
225
+ tailnetNames?: () => Promise<readonly string[]>;
226
+ };
153
227
  /** Durably create the login file BEFORE the bootstrap commits (arc 2
154
228
  * finding 27): exclusive, 0600, fsynced — file and directory both — so the
155
229
  * row only ever follows a durable secret. Throws on any failure. */
@@ -181,3 +255,19 @@ export type UpRoleHost = {
181
255
  /** A role child's whole run (up-role-child.ts): the ordinary command prelude (containment, secrets, limits), then the
182
256
  * role itself. Only a process started by `up` gets here; no command line selects a role. */
183
257
  export declare function runUpRole(start: UpRoleStart, port: RolePort): Promise<number>;
258
+ /** GitHub's nightly as release debt (docs/plans/evals-nightly.md, part A): the watch loop's background read of each
259
+ * opted-in project's nightly runs, at most every 30 minutes, within a 60 s overall deadline, through the plane's own gh
260
+ * (release-adapters.ts githubReads). Nothing is read for a project that never opted in. */
261
+ export declare function refreshGithubNightlies(store: Store, now: () => Date, reads?: GhRead): Promise<number>;
262
+ /**
263
+ * `toolroll evals run | attempt | cases | recover` (docs/plans/evals-nightly.md, part C).
264
+ *
265
+ * - `run [--diagnostic] [--case a,b] [--k n] --as <you> --token <password>`: admit a campaign (a person's proof, the
266
+ * no release busy) and run it to its end from this installed plane. With no flags it is canonical,
267
+ * refused while any case is a draft. Each attempt is a child process: `evals attempt`.
268
+ * - `attempt <campaign> <case> <n> --world <dir> --db <world db>`: one attempt's world (eval-world.ts). Only under its
269
+ * runner's dispatch gate, and only on the world's own database.
270
+ * - `cases`: each installed case, its version, its pin, and whether a person has validated it yet.
271
+ * - `recover [--abandon <campaign>]`: settle campaigns whose runner stopped; with --abandon, a person ends a stuck one.
272
+ */
273
+ export declare function evalsCommand(positional: readonly string[], flags: Map<string, string | true>, context: Context): Promise<number>;
package/dist/operate.js CHANGED
@@ -60,6 +60,9 @@ import { runRequestedUndos, sendSubagentWeeklies } from "./subagent-week.js";
60
60
  import { refreshConnections } from "./mcp-connect.js";
61
61
  import { releaseCheckPurposeFor, releaseTermsFor } from "./release-carry.js";
62
62
  import { refreshTrustedMains, settleReleaseChecks } from "./nightly-check.js";
63
+ import { githubNightlyRepos, readGithubNightlies } from "./nightly-github.js";
64
+ import { exec as releaseExec, githubReads } from "./release-adapters.js";
65
+ import { releaseTools } from "./release-git.js";
63
66
  import { SECRETS_FIRST, startToolSecrets, toolSecretStorage } from "./project-tools.js";
64
67
  import { followDiscord } from "./discord.js";
65
68
  import { followTeams } from "./teams.js";
@@ -99,7 +102,7 @@ import { taskReviewBrief, renderReviewBrief } from "./task-review-brief.js";
99
102
  * **Nothing ever prompts.** There is no terminal on the other end at 3am.
100
103
  */
101
104
  import { homedir, hostname } from "node:os";
102
- import { basename, dirname, join, resolve } from "node:path";
105
+ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
103
106
  import { releaseStalledUpdate, waitingUpdate } from "./toolroll-update.js";
104
107
  import { openStore, openStoreNoMigrate, CONCURRENT_WRITER_WAIT_MS, isDatabaseBusy, databasePath, BUILT_IN, parseCapabilityKey, verifiedAuthor, isDigestTime, predecessorWatches, RESULT_SCREENSHOTS, lifecycleOf, reportLifecycle, beatLifecycle, } from "./store.js";
105
108
  import { createHash, randomBytes, randomInt, randomUUID } from "node:crypto";
@@ -247,6 +250,7 @@ import { installationStatus, renderInstallationStatus, renderTaskWait, repairSta
247
250
  import { latestRelease, newerRelease, notifySecurityRelease, notifyVersionLive, recordRunnerVersion, startUpdateChecks, updateLine } from "./releases.js";
248
251
  import { installMethod } from "./install-method.js";
249
252
  import { PACKAGE_VERSION } from "./version.js";
253
+ import { machineActing } from "./scope.js";
250
254
  /**
251
255
  * 0 done · 1 broke · 2 bad usage · 3 ran fine, the answer is no.
252
256
  *
@@ -676,6 +680,9 @@ export const OPERATE_VALUE_FLAGS = new Set([
676
680
  "choose", "note", "max-open-decisions", "name", "days", "publication", "auto-approve", "review-auto", "port", "host", "allow-host",
677
681
  "for", "tick-every", "bridge-every", "reconcile-every", "incarnation",
678
682
  "say", "ceiling-usd", "cap-usd",
683
+ // evals: the cases a diagnostic campaign runs, its k, an attempt's world and what its builds may not read, and the
684
+ // stuck campaign a person ends (recover --abandon).
685
+ "case", "k", "world", "deny", "abandon",
679
686
  "token-file", "bin", "poll", "github", "remote", "head-prefix", "password",
680
687
  // v102: project rules.
681
688
  "not-requester", "protect", "protect-paths",
@@ -714,6 +721,8 @@ export const OPERATE_VALUE_FLAGS = new Set([
714
721
  "release",
715
722
  ]);
716
723
  export const OPERATE_BOOLEAN_FLAGS = new Set([
724
+ // evals run: a report-only campaign; keep each attempt's world for a look afterwards.
725
+ "diagnostic", "keep",
717
726
  // task scope --release ... --no-carry: every later commit asks again.
718
727
  "no-carry",
719
728
  "json", "yes", "all", "brief", "local", "history", "latest-watch", "dry-run", "file",
@@ -1147,6 +1156,8 @@ async function dispatch(command, positional, flags, context) {
1147
1156
  switch (command) {
1148
1157
  case "status":
1149
1158
  return statusCommand(positional, flags, context);
1159
+ case "evals":
1160
+ return evalsCommand(positional, flags, context);
1150
1161
  case "ready":
1151
1162
  return readyCommand(flags, context);
1152
1163
  case "task":
@@ -6279,6 +6290,10 @@ async function runWatchLoop(args) {
6279
6290
  const settledChecks = settleReleaseChecks(store, context.evidenceRoot, context.clock());
6280
6291
  if (settledChecks > 0)
6281
6292
  progress(`watch: settled ${settledChecks} release check(s)`);
6293
+ // GitHub's nightly as release debt (nightly-github.ts): each opted-in project at most every 30 minutes, 60 s in
6294
+ // all. A test's injected agent runner means a test: no real gh.
6295
+ if (context.agentRunner === undefined)
6296
+ await refreshGithubNightlies(store, context.clock);
6282
6297
  // A finished check may send a build's card down its failure path: now, not on the next beat.
6283
6298
  if (ran + batched > 0)
6284
6299
  moveCards(store, repo, context.clock(), context.evidenceRoot);
@@ -12373,3 +12388,145 @@ async function runUpWorkerRole(context, config, port, build) {
12373
12388
  }
12374
12389
  return EXIT.ok;
12375
12390
  }
12391
+ /** GitHub's nightly as release debt (docs/plans/evals-nightly.md, part A): the watch loop's background read of each
12392
+ * opted-in project's nightly runs, at most every 30 minutes, within a 60 s overall deadline, through the plane's own gh
12393
+ * (release-adapters.ts githubReads). Nothing is read for a project that never opted in. */
12394
+ export async function refreshGithubNightlies(store, now, reads) {
12395
+ if (githubNightlyRepos(store).length === 0)
12396
+ return 0;
12397
+ return readGithubNightlies(store, { gh: reads ?? githubReads(releaseExec, releaseTools().gh, { retry: true }), now });
12398
+ }
12399
+ /**
12400
+ * `toolroll evals run | attempt | cases | recover` (docs/plans/evals-nightly.md, part C).
12401
+ *
12402
+ * - `run [--diagnostic] [--case a,b] [--k n] --as <you> --token <password>`: admit a campaign (a person's proof, the
12403
+ * no release busy) and run it to its end from this installed plane. With no flags it is canonical,
12404
+ * refused while any case is a draft. Each attempt is a child process: `evals attempt`.
12405
+ * - `attempt <campaign> <case> <n> --world <dir> --db <world db>`: one attempt's world (eval-world.ts). Only under its
12406
+ * runner's dispatch gate, and only on the world's own database.
12407
+ * - `cases`: each installed case, its version, its pin, and whether a person has validated it yet.
12408
+ * - `recover [--abandon <campaign>]`: settle campaigns whose runner stopped; with --abandon, a person ends a stuck one.
12409
+ */
12410
+ export async function evalsCommand(positional, flags, context) {
12411
+ const { write, json } = context;
12412
+ // Loaded when an eval command runs, so no other command (nor the console's bundle) pays for the eval runner.
12413
+ const { admitEvalCampaign, campaignSpending, EVALS_ROOT, installedBuildDigest, loadEvalSet, planCampaign, recoverEvalCampaigns, runEvalCampaign } = await import("./evals.js");
12414
+ const { inEvalWorld } = await import("./eval-gate.js");
12415
+ const { evalCliEntry, runEvalWorld } = await import("./eval-world.js");
12416
+ const action = positional[0];
12417
+ const command = `evals${action === undefined ? "" : ` ${action}`}`;
12418
+ const usage = "Use: toolroll evals run [--diagnostic] [--case <ids>] [--k <n>] --as <you> --token <password> | toolroll evals cases | toolroll evals recover [--abandon <campaign> --as <you> --token <password>]";
12419
+ if (action === "recover" && flags.has("abandon"))
12420
+ return evalsAbandonCommand(flags, context);
12421
+ if (action === "recover") {
12422
+ // A campaign whose runner stopped: its unfinished attempts recorded as interrupted, then it ends incomplete.
12423
+ const recovered = recoverEvalCampaigns(context.store, context.clock());
12424
+ return succeed(write, json, command, { campaigns: recovered }, () => recovered.length === 0 ? ["No campaign is running."] : recovered.map(one => `${one.id}: ${one.recovered ? "recovered" : "left running"} (${one.why})`));
12425
+ }
12426
+ if (action === "cases") {
12427
+ const set = loadEvalSet();
12428
+ if (!set.ok)
12429
+ return fail(write, json, command, "unreadable", set.problem, EXIT.failed);
12430
+ const cases = set.cases.map(one => ({ id: one.id, kind: one.kind, version: one.version, pin: one.pin, validatedBy: one.validatedBy, draft: one.validatedBy === null }));
12431
+ return succeed(write, json, command, { manifestDigest: set.manifestDigest, cases }, () => cases.map(one => `${one.id} (${one.kind}) ${one.version.slice(0, 12)} on ${one.pin.model}: ${one.draft ? "draft, awaiting a person's validation" : `validated by ${one.validatedBy}`}`));
12432
+ }
12433
+ if (action === "attempt") {
12434
+ // Only its campaign's runner starts an attempt: under the runner's dispatch gate, on the world's own database.
12435
+ if (!inEvalWorld())
12436
+ return fail(write, json, command, "refused", "An eval attempt runs only from its campaign's runner.", EXIT.refused);
12437
+ const [, campaign, caseId, n] = positional;
12438
+ const world = text(flags, "world");
12439
+ if (campaign === undefined || caseId === undefined || n === undefined || !/^[1-9][0-9]?$/.test(n) || world === undefined)
12440
+ return fail(write, json, command, "usage", "Use: toolroll evals attempt <campaign> <case> <n> --world <dir> --db <world>/toolroll/orders.db", EXIT.usage);
12441
+ let worldDir;
12442
+ try {
12443
+ worldDir = realpathSync(world);
12444
+ }
12445
+ catch {
12446
+ return fail(write, json, command, "refused", "The world's folder can't be read.", EXIT.refused);
12447
+ }
12448
+ if (resolve(context.databaseFile) !== join(worldDir, "toolroll", "orders.db"))
12449
+ return fail(write, json, command, "refused", "An eval attempt runs only on its world's own database.", EXIT.refused);
12450
+ const set = loadEvalSet();
12451
+ const one = set.ok ? set.cases.find(item => item.id === caseId) : undefined;
12452
+ if (one === undefined)
12453
+ return fail(write, json, command, "refused", set.ok ? `No case is named ${caseId}.` : set.problem, EXIT.refused);
12454
+ const deny = (text(flags, "deny") ?? "").split(",").filter(path => path !== "" && isAbsolute(path));
12455
+ const report = await runEvalWorld(one, { worldDir, databaseFile: context.databaseFile, deny, timeoutMs: one.timeoutMinutes * 60_000 });
12456
+ return succeed(write, json, command, { ...report }, () => [JSON.stringify(report)]);
12457
+ }
12458
+ if (action !== "run" || positional.length !== 1)
12459
+ return fail(write, json, command, "usage", usage, EXIT.usage);
12460
+ if (inEvalWorld())
12461
+ return fail(write, json, command, "refused", "An eval world doesn't start campaigns of its own.", EXIT.refused);
12462
+ if (machineActing() || process.env["TOOLROLL_LEAD_TOKEN"] !== undefined)
12463
+ return fail(write, json, command, "person-only", "Only a person starts an eval campaign: it spends money.", EXIT.refused);
12464
+ const k = text(flags, "k");
12465
+ const cases = text(flags, "case");
12466
+ const set = loadEvalSet();
12467
+ if (!set.ok)
12468
+ return fail(write, json, command, "unreadable", set.problem, EXIT.failed);
12469
+ const planned = planCampaign(set, { ...(cases === undefined ? {} : { cases: cases.split(",").filter(Boolean) }), ...(k === undefined ? {} : { k: Number(k) }), diagnostic: flag(flags, "diagnostic") });
12470
+ if (!planned.ok)
12471
+ return fail(write, json, command, "refused", planned.problem, EXIT.refused);
12472
+ const acting = await askCredentials(flags, context);
12473
+ if (acting === null)
12474
+ return fail(write, json, command, "usage", "An eval campaign is started with your password: add --as <you> --token <password>.", EXIT.usage);
12475
+ const { plan } = planned;
12476
+ const buildDigest = () => installedBuildDigest(PACKAGE_VERSION);
12477
+ const sealed = { cases: plan.cases.map(one => ({ id: one.id, version: one.version, pin: one.pin, maxDispatches: one.maxDispatches })), k: plan.k, baseline: null, evals: EVALS_ROOT };
12478
+ // A campaign whose runner stopped no longer holds its place.
12479
+ recoverEvalCampaigns(context.store, context.clock());
12480
+ const admitted = admitEvalCampaign(context.store, { kind: "eval", canonical: plan.canonical, manifestDigest: set.manifestDigest, buildDigest: buildDigest(), version: PACKAGE_VERSION, k: plan.k,
12481
+ sealed, as: acting.name, token: acting.token, now: context.clock() });
12482
+ if (!admitted.ok)
12483
+ return fail(write, json, command, admitted.reason, admitted.message, EXIT.refused);
12484
+ const stateDir = dirname(context.databaseFile);
12485
+ const entry = evalCliEntry();
12486
+ const keep = ["PATH", "HOME", "USER", "LOGNAME", "LANG", "SHELL", "TMPDIR"];
12487
+ const ended = await runEvalCampaign(context.store, admitted.id, plan.cases, {
12488
+ // Beside the plane's state folder, never inside it: a world's builds read-deny the plane's state.
12489
+ workRoot: join(dirname(stateDir), "toolroll-eval-worlds", admitted.id),
12490
+ buildDigest,
12491
+ keepWorlds: flag(flags, "keep"),
12492
+ now: context.clock,
12493
+ command: input => ({ file: entry.file, args: [...entry.args, "evals", "attempt", input.campaign, input.caseId, String(input.n), "--world", input.worldDir, "--deny", stateDir, "--db", input.databaseFile, "--json"],
12494
+ env: { ...Object.fromEntries(keep.flatMap(name => process.env[name] === undefined ? [] : [[name, process.env[name]]])), XDG_CONFIG_HOME: join(input.worldDir, "config") } }),
12495
+ });
12496
+ const rows = context.store.handle.prepare("SELECT case_id, n, replaces, outcome, error_why, api_microusd, subscription_microusd, usage_unknown FROM eval_attempt WHERE campaign = ? ORDER BY case_id, n, replaces").all(admitted.id);
12497
+ const nullable = (value) => value === null || value === undefined ? null : Number(value);
12498
+ const attempts = rows.map(row => ({ case: String(row["case_id"]), n: Number(row["n"]), replaces: Number(row["replaces"]), outcome: row["outcome"] === null ? null : String(row["outcome"]),
12499
+ why: row["error_why"] === null ? null : String(row["error_why"]), apiMicrousd: nullable(row["api_microusd"]), subscriptionMicrousd: nullable(row["subscription_microusd"]), usageUnknown: Number(row["usage_unknown"]) === 1 }));
12500
+ // What was actually spent, as recorded: API dollars and subscription usage apart, unknown never shown as measured.
12501
+ const spent = campaignSpending(context.store, admitted.id);
12502
+ const dollars = (micro, unknown) => micro === null ? `unknown (${unknown} attempt${unknown === 1 ? "" : "s"} with unknown spending)` : `$${(micro / 1_000_000).toFixed(2)}`;
12503
+ return succeed(write, json, command, { campaign: admitted.id, canonical: plan.canonical, state: ended.state, why: ended.why, attempts, spending: spent }, () => [
12504
+ `Campaign ${admitted.id} (${plan.canonical ? "canonical" : "diagnostic"}) is ${ended.state}${ended.why === null ? "" : `: ${ended.why}`}.`,
12505
+ ...attempts.map(one => `${one.case} #${one.n}${one.replaces === 0 ? "" : " (replacement)"}: ${one.outcome ?? "not finished"}${one.why === null ? "" : ` — ${one.why}`}`),
12506
+ `API dollars: ${dollars(spent.apiMicrousd, spent.unknownAttempts)}. Subscription usage, not billed: ${dollars(spent.subscriptionMicrousd, spent.unknownAttempts)}.`,
12507
+ ]);
12508
+ }
12509
+ /**
12510
+ * `toolroll evals recover --abandon <campaign> --as <you> --token <password>` (PR #263 review): a person ends a campaign
12511
+ * recovery can't settle (its runner or a world's processes can't be proven gone). Its unfinished attempts are recorded
12512
+ * as errors and its spending as unknown, and it ends incomplete; nothing is signalled and no world is removed. A lead
12513
+ * token, a machine or an eval world is refused; the password is checked inside the write (evals.ts abandonEvalCampaign).
12514
+ */
12515
+ async function evalsAbandonCommand(flags, context) {
12516
+ const { write, json } = context;
12517
+ const command = "evals recover";
12518
+ const { abandonEvalCampaign } = await import("./evals.js");
12519
+ const { inEvalWorld } = await import("./eval-gate.js");
12520
+ const id = text(flags, "abandon");
12521
+ if (id === undefined || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/.test(id))
12522
+ return fail(write, json, command, "usage", "Use: toolroll evals recover --abandon <campaign> --as <you> --token <password>", EXIT.usage);
12523
+ if (inEvalWorld() || machineActing() || process.env["TOOLROLL_LEAD_TOKEN"] !== undefined)
12524
+ return fail(write, json, command, "person-only", "Only a person abandons an eval campaign.", EXIT.refused);
12525
+ const acting = await askCredentials(flags, context);
12526
+ if (acting === null)
12527
+ return fail(write, json, command, "usage", "A campaign is abandoned with your password: add --as <you> --token <password>.", EXIT.usage);
12528
+ const ended = abandonEvalCampaign(context.store, { id, as: acting.name, token: acting.token, now: context.clock() });
12529
+ if (!ended.ok)
12530
+ return fail(write, json, command, ended.reason, ended.message, EXIT.refused);
12531
+ return succeed(write, json, command, { campaign: id, state: "incomplete", attempts: ended.attempts, spending: "unknown" }, () => [`Campaign ${id} ended incomplete. ${ended.attempts} unfinished attempt${ended.attempts === 1 ? "" : "s"} recorded as errors; what it spent is unknown. Nothing was stopped: check for its processes yourself.`]);
12532
+ }
@@ -23,18 +23,18 @@ export declare function knowledgeVersion(store: Store, repo: string, actor: stri
23
23
  /** Chat reads only an admitted project. Keep the full source in the project UI. */
24
24
  export declare function conversationKnowledge(store: Store, repo: string, actor: string, reference?: string, decision?: number): {
25
25
  title?: never;
26
+ content?: never;
26
27
  history?: never;
27
28
  revision: number;
28
29
  stale: boolean;
29
30
  notice: string;
30
31
  decision?: never;
31
- content?: never;
32
32
  instructions?: never;
33
33
  references?: never;
34
34
  decisions?: never;
35
35
  } | {
36
36
  title?: never;
37
- revision?: never;
37
+ content?: never;
38
38
  history?: never;
39
39
  stale?: never;
40
40
  decision: {
@@ -52,7 +52,7 @@ export declare function conversationKnowledge(store: Store, repo: string, actor:
52
52
  id: number;
53
53
  };
54
54
  notice: string;
55
- content?: never;
55
+ revision?: never;
56
56
  instructions?: never;
57
57
  references?: never;
58
58
  decisions?: never;
@@ -69,9 +69,9 @@ export declare function conversationKnowledge(store: Store, repo: string, actor:
69
69
  decisions?: never;
70
70
  } | {
71
71
  title?: never;
72
+ content?: never;
72
73
  stale?: never;
73
74
  decision?: never;
74
- content?: never;
75
75
  revision: number;
76
76
  history: {
77
77
  revision: number;
@@ -177,6 +177,9 @@ export type ParsedEnvelope = {
177
177
  structuralTerminal: import("./exhaustion.js").StructuralTerminal | null;
178
178
  /** What an ACP agent itself reported it used — kept on the run, never priced. Absent for every other dialect. */
179
179
  agentReportedUsage?: string | null;
180
+ /** v140: the exact model the provider itself reported for this turn (Claude: its session's init, or a buffered
181
+ * result's one `modelUsage` model). Absent or null: not reported — every other dialect today. */
182
+ modelReported?: string | null;
180
183
  /** An ACP conversation's facts the conformance check reads (acp-client.ts AcpEnvelope): nothing else does. */
181
184
  acp?: {
182
185
  offered: string[];
package/dist/provider.js CHANGED
@@ -30,6 +30,7 @@ import { ACP_AGENTS, isAcpProvider, withAcp } from "./acp-agents.js";
30
30
  import { acpParse } from "./acp-client.js";
31
31
  import { agentSpecSchema, MODEL_ID, modelIdSchema, PROVIDER_ID_VALUES, providerIdSchema } from "./contracts/provider.js";
32
32
  import { primaryResultEvent, topLevelEvent } from "./stream-scope.js";
33
+ import { claudeResultReport, reportedModelOf } from "./reported-usage.js";
33
34
  export const PROVIDER_IDS = PROVIDER_ID_VALUES;
34
35
  export function isProviderId(value) {
35
36
  return providerIdSchema.safeParse(value).success;
@@ -293,7 +294,7 @@ function claudeFinalMessage(result) {
293
294
  }
294
295
  return typeof result.result === "string" ? result.result : null;
295
296
  }
296
- function claudeEnvelopeOf(result, sessionFromInit, initObserved, initSessionConflict = false) {
297
+ function claudeEnvelopeOf(result, sessionFromInit, initObserved, initSessionConflict = false, initModel = { model: null, conflict: false }) {
297
298
  const input = result?.usage?.input_tokens;
298
299
  const output = result?.usage?.output_tokens;
299
300
  const cost = result?.total_cost_usd;
@@ -322,6 +323,8 @@ function claudeEnvelopeOf(result, sessionFromInit, initObserved, initSessionConf
322
323
  costUsd: typeof cost === "number" && cost >= 0 ? cost : null,
323
324
  ...(result !== null && result[CLAUDE_COST_UNKNOWN] === true ? { costUnknown: true } : {}),
324
325
  usageRaw: result?.usage === undefined ? null : JSON.stringify(result.usage).slice(0, USAGE_JSON_CAP),
326
+ // The model only when the session's own events name it (never the agent's words); no result, no report.
327
+ modelReported: result === null ? (initModel.conflict ? null : initModel.model) : claudeResultReport(result, initModel).modelReported,
325
328
  initObserved,
326
329
  // Structural, never prose (finding 15): consumed means the primary
327
330
  // result says SUCCESS. An error result keeps its text as diagnostics
@@ -354,6 +357,7 @@ function claudeParse(stdout) {
354
357
  let initSeen = false;
355
358
  let sessionFromInit = null;
356
359
  let initSessionConflict = false;
360
+ const initModels = new Set();
357
361
  let primary = null;
358
362
  let transportFailure = null;
359
363
  for (const line of stdout.split("\n")) {
@@ -379,6 +383,9 @@ function claudeParse(stdout) {
379
383
  }
380
384
  if (String(type) === "system" && String(event["subtype"] ?? "") === "init") {
381
385
  initSeen = true;
386
+ const model = reportedModelOf(event["model"]);
387
+ if (model !== null)
388
+ initModels.add(model);
382
389
  const id = sessionIdOf(event["session_id"]);
383
390
  if (id !== null) {
384
391
  if (sessionFromInit === null)
@@ -402,7 +409,7 @@ function claudeParse(stdout) {
402
409
  primary = result;
403
410
  }
404
411
  }
405
- const envelope = claudeEnvelopeOf(primary, sessionFromInit, initSeen, initSessionConflict);
412
+ const envelope = claudeEnvelopeOf(primary, sessionFromInit, initSeen, initSessionConflict, { model: initModels.size === 1 ? [...initModels][0] : null, conflict: initModels.size > 1 });
406
413
  if (transportFailure === null)
407
414
  return envelope;
408
415
  return {
@@ -8,6 +8,8 @@ import type { CheckRun, GateState, Installed, MainProtection, ReleaseAdapters }
8
8
  import { Store } from "./store.js";
9
9
  import { type CiObservation } from "./release-auto.js";
10
10
  import { type BuildRunner } from "./release-carry.js";
11
+ import { type GhRead } from "./nightly-github.js";
12
+ export { failedBeforeTests, type JobRecord } from "./github-jobs.js";
11
13
  import { type ReleaseRepo, type ReleaseTools } from "./release-git.js";
12
14
  export { RELEASE_PATH, RELEASE_REMOTE, releaseConfigProblem, releaseGitArgs, releaseGitEnv, releaseRemote, releaseTools, type ReleaseRepo, type ReleaseTools } from "./release-git.js";
13
15
  export type Exec = (command: string, args: readonly string[], options?: {
@@ -112,7 +114,10 @@ export declare function bumpFormula(text: string, url: string, sha256: string):
112
114
  export declare function storeReleaseAdapter(open: () => Store, done: (store: Store) => void, now: () => Date, runner?: BuildRunner,
113
115
  /** Releases that approve themselves (auto-approve-green.md): the plane's own look at CI on a commit, and where the
114
116
  * check receipts are kept. Absent: no automatic approval, every release asks a person. */
115
- auto?: AutoObservers): NonNullable<ReleaseAdapters["release"]>;
117
+ auto?: AutoObservers,
118
+ /** GitHub's nightly (nightly-github.ts): the plane's own gh reads for a boundary's one bounded read. Absent: nothing
119
+ * is read at the boundary, and the stored debt still holds inside every reservation. */
120
+ nightlyReads?: GhRead): NonNullable<ReleaseAdapters["release"]>;
116
121
  /** What the plane itself reads for an automatic release approval. */
117
122
  export type AutoObservers = {
118
123
  /** The plane's executor policy, never a report field or project setting. Unknown refuses. Production currently
@@ -135,28 +140,23 @@ export declare function readCheckRuns(run: Exec, gh: string, repo: string, sha:
135
140
  options?: {
136
141
  tag?: string;
137
142
  }): Promise<CheckRun[]>;
138
- /** One job's step outcomes as GitHub's Actions API reports them. */
139
- export type JobRecord = {
140
- id: number;
141
- status: string;
142
- conclusion: string | null;
143
- steps: {
144
- number: number;
145
- name: string;
146
- status: string;
147
- conclusion: string | null;
148
- }[];
149
- };
150
- /**
151
- * Whether a finished, unsuccessful job ended before any of the workflow's own steps ran: every step after GitHub's own
152
- * first one ("Set up job", always number 1) never ran (no conclusion, or skipped) — leaving out only GitHub's own
153
- * closing "Complete job" when it is the last step. Step numbers and outcomes are the runner's records; nothing the
154
- * candidate prints is read. A job with no steps never reached one. Anything else (a step that ran, even the checkout)
155
- * is not confirmed infrastructure.
156
- */
157
- export declare function failedBeforeTests(job: JobRecord): boolean;
158
143
  /** GitHub's job record for one check run (its job), read strictly: null when it can't be read whole. */
159
144
  export declare function readJobBeforeTests(run: Exec, gh: string, repo: string, id: number): Promise<boolean | null>;
145
+ /**
146
+ * GitHub's REST API as GitHub's nightly reads it (nightly-github.ts): GET only, github.com only, a repository path, the
147
+ * plane's own gh with the allowlisted environment, each call bounded by its caller's deadline. A background read rides
148
+ * out a short network failure (release-net.ts, within that call's own share of the deadline); a boundary's read is
149
+ * plain exec. Never an answer that isn't GitHub's whole JSON.
150
+ */
151
+ export declare function githubReads(run: Exec, gh: string, options?: {
152
+ retry?: boolean;
153
+ }): (path: string, timeoutMs: number) => Promise<{
154
+ ok: true;
155
+ body: unknown;
156
+ } | {
157
+ ok: false;
158
+ why: string;
159
+ }>;
160
160
  /** GitHub lists check runs from at most the latest 1000 check suites of a commit: the history is whole only below that. */
161
161
  export declare const CHECK_SUITE_CEILING = 1000;
162
162
  export declare function readSuitesComplete(run: Exec, gh: string, repo: string, sha: string): Promise<boolean>;
@@ -28,6 +28,9 @@ import { autoPublishHeld, holdAutoPublish, liftCiHold, authorizeAutoCheck, giveU
28
28
  import { proveCarry } from "./release-carry.js";
29
29
  import { randomBytes, randomUUID } from "node:crypto";
30
30
  import { deploymentGate } from "./deploy-gate.js";
31
+ import { failedBeforeTests } from "./github-jobs.js";
32
+ import { mainHistoryOf, nightlyBoundary } from "./nightly-github.js";
33
+ export { failedBeforeTests } from "./github-jobs.js";
31
34
  import { extractCommit } from "./commit-extract.js";
32
35
  import { teardownGit, refusal as gitRefusal } from "./teardown-git.js";
33
36
  import { RELEASE_REMOTE, releaseConfigProblem, releaseGitArgs, releaseGitEnv, releaseRemote, releaseTools } from "./release-git.js";
@@ -422,7 +425,9 @@ export function releaseAdapters(options) {
422
425
  upToDateRequired: () => readUpToDateRequired(reads, ciGh, releaseRepo().slug), evidenceRoot: join(dirname(database), "evidence"),
423
426
  // A successful Actions job authenticates the job, not the candidate-writable JSON it uploads. Until the
424
427
  // executor protects that reporting channel, production releases must take the person's approval road.
425
- reportsProtected: false });
428
+ reportsProtected: false },
429
+ // A boundary's one bounded read of GitHub's nightly: plain exec, never the release's long retries (20 s in all).
430
+ githubReads(run, ciGh));
426
431
  return {
427
432
  ...(existsSync(database) ? { release } : {}),
428
433
  now: options.now ?? (() => new Date()),
@@ -925,7 +930,10 @@ function personAnswerOf(value) {
925
930
  export function storeReleaseAdapter(open, done, now, runner,
926
931
  /** Releases that approve themselves (auto-approve-green.md): the plane's own look at CI on a commit, and where the
927
932
  * check receipts are kept. Absent: no automatic approval, every release asks a person. */
928
- auto) {
933
+ auto,
934
+ /** GitHub's nightly (nightly-github.ts): the plane's own gh reads for a boundary's one bounded read. Absent: nothing
935
+ * is read at the boundary, and the stored debt still holds inside every reservation. */
936
+ nightlyReads) {
929
937
  const withStore = (body) => { const store = open(); try {
930
938
  return body(store);
931
939
  }
@@ -962,6 +970,20 @@ auto) {
962
970
  async live(taskId, action) {
963
971
  return withStore(store => { const live = liveReleaseOf(store, taskId, action, now()); return live.applies ? { applies: true, ok: live.ok, why: live.ok ? null : live.why } : { applies: false, ok: false, why: null }; });
964
972
  },
973
+ ...(nightlyReads === undefined ? {} : {
974
+ async nightly(input) {
975
+ const store = open();
976
+ try {
977
+ const repo = store.lookupRef(input.taskId)?.repo ?? null;
978
+ if (repo === null)
979
+ return { ok: false, why: `Task ${input.taskId} is in no project, so GitHub's nightly debt can't be read for it.`, warning: null };
980
+ return await nightlyBoundary(store, { repo, taskId: input.taskId, candidate: input.candidate, pr: input.pr, phase: input.phase }, { ...mainHistoryOf(store, repo, now()), gh: nightlyReads, now });
981
+ }
982
+ finally {
983
+ done(store);
984
+ }
985
+ },
986
+ }),
965
987
  async lease(authority, owner) { return withStore(store => takeReleaseLease(store, authority, owner, now())); },
966
988
  async renew(authority, owner, epoch) { return withStore(store => renewReleaseLease(store, authority, owner, epoch, now())); },
967
989
  async carry(authority, to, requestId, epoch) {
@@ -1169,23 +1191,6 @@ options = {}) {
1169
1191
  }
1170
1192
  return runs.sort((a, b) => a.id - b.id);
1171
1193
  }
1172
- /**
1173
- * Whether a finished, unsuccessful job ended before any of the workflow's own steps ran: every step after GitHub's own
1174
- * first one ("Set up job", always number 1) never ran (no conclusion, or skipped) — leaving out only GitHub's own
1175
- * closing "Complete job" when it is the last step. Step numbers and outcomes are the runner's records; nothing the
1176
- * candidate prints is read. A job with no steps never reached one. Anything else (a step that ran, even the checkout)
1177
- * is not confirmed infrastructure.
1178
- */
1179
- export function failedBeforeTests(job) {
1180
- if (job.status !== "completed" || job.conclusion === "success" || job.conclusion === null)
1181
- return false;
1182
- const steps = [...job.steps].sort((a, b) => a.number - b.number);
1183
- if (new Set(steps.map(one => one.number)).size !== steps.length)
1184
- return false;
1185
- const last = steps.at(-1);
1186
- const own = steps.filter(one => one.number > 1 && !(one === last && one.name === "Complete job" && steps.length > 1));
1187
- return own.every(one => one.status !== "in_progress" && (one.conclusion === null || one.conclusion === "skipped"));
1188
- }
1189
1194
  /** GitHub's job record for one check run (its job), read strictly: null when it can't be read whole. */
1190
1195
  export async function readJobBeforeTests(run, gh, repo, id) {
1191
1196
  if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo) || !Number.isSafeInteger(id) || id <= 0)
@@ -1214,6 +1219,34 @@ export async function readJobBeforeTests(run, gh, repo, id) {
1214
1219
  return failedBeforeTests({ id, status: String(job["status"]), conclusion: textOr(job["conclusion"]),
1215
1220
  steps: steps.map(one => ({ number: Number(one["number"]), name: String(one["name"]), status: String(one["status"]), conclusion: textOr(one["conclusion"]) })) });
1216
1221
  }
1222
+ /**
1223
+ * GitHub's REST API as GitHub's nightly reads it (nightly-github.ts): GET only, github.com only, a repository path, the
1224
+ * plane's own gh with the allowlisted environment, each call bounded by its caller's deadline. A background read rides
1225
+ * out a short network failure (release-net.ts, within that call's own share of the deadline); a boundary's read is
1226
+ * plain exec. Never an answer that isn't GitHub's whole JSON.
1227
+ */
1228
+ export function githubReads(run, gh, options = {}) {
1229
+ return async (path, timeoutMs) => {
1230
+ if (!/^repos\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_./?&=%-]+$/.test(path) || timeoutMs <= 0)
1231
+ return { ok: false, why: "not a read this makes" };
1232
+ const exec = options.retry === true ? retryingExec(run, { say: () => { }, sleep, budgetMs: timeoutMs }) : run;
1233
+ let result;
1234
+ try {
1235
+ result = await exec(gh, ["api", "--hostname", "github.com", "--method", "GET", path], { cwd: "/", env: ciEnv(), timeoutMs });
1236
+ }
1237
+ catch (error) {
1238
+ return { ok: false, why: String(error?.message ?? error).slice(0, 200) };
1239
+ }
1240
+ if (result.code !== 0)
1241
+ return { ok: false, why: result.code === 124 ? "GitHub didn't answer in time" : result.code === 127 ? "gh isn't installed" : (result.stderr || result.stdout).trim().split("\n").slice(-1)[0].slice(0, 200) || `gh exited ${result.code}` };
1242
+ try {
1243
+ return { ok: true, body: JSON.parse(result.stdout) };
1244
+ }
1245
+ catch {
1246
+ return { ok: false, why: "GitHub's answer couldn't be read" };
1247
+ }
1248
+ };
1249
+ }
1217
1250
  /** GitHub lists check runs from at most the latest 1000 check suites of a commit: the history is whole only below that. */
1218
1251
  export const CHECK_SUITE_CEILING = 1000;
1219
1252
  export async function readSuitesComplete(run, gh, repo, sha) {
@@ -1290,7 +1323,7 @@ export async function readMainProtection(run, gh, repo, branch = "main") {
1290
1323
  else {
1291
1324
  const lacking = PROTECTION_MINIMUM_CHECKS.filter(name => !checks.includes(name));
1292
1325
  if (lacking.length > 0)
1293
- own.push(`${where}: required checks lack ${lacking.join(", ")}`);
1326
+ own.push(`${where}: required checks lack ${lacking.join(", ")} (add ${lacking.length === 1 ? "it" : "them"} to main's required status checks: a release's proof trusts what main required)`);
1294
1327
  const unbound = PROTECTION_MINIMUM_CHECKS.filter(name => checks.includes(name) && !bound.includes(name));
1295
1328
  if (unbound.length > 0)
1296
1329
  own.push(`${where}: required checks must require GitHub Actions (app 15368) as their source: ${unbound.join(", ")}`);
@@ -19,7 +19,7 @@ export type ObservedCheckRun = {
19
19
  suite?: number | null;
20
20
  head?: string | null;
21
21
  workflow?: number | null;
22
- /** The workflow's file (GitHub's workflow-run `path`): a required check counts only from ci.yml. */
22
+ /** The workflow's file (GitHub's workflow-run `path`): a required check counts only from its own (REQUIRED_PR_CHECKS_WORKFLOW). */
23
23
  workflowPath?: string | null;
24
24
  /** GitHub's job record shows this unsuccessful attempt ended before any workflow step ran (release-adapters.ts). */
25
25
  beforeTests?: boolean | null;