toolroll 0.9.81 → 0.9.83

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 (168) hide show
  1. package/CHANGELOG.md +35 -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/desktop-update.d.ts +1 -1
  9. package/dist/eval-gate.d.ts +51 -0
  10. package/dist/eval-gate.js +199 -0
  11. package/dist/eval-hooks.d.ts +39 -0
  12. package/dist/eval-hooks.js +477 -0
  13. package/dist/eval-oracle-main.d.ts +1 -0
  14. package/dist/eval-oracle-main.js +31 -0
  15. package/dist/eval-oracle.d.ts +60 -0
  16. package/dist/eval-oracle.js +217 -0
  17. package/dist/eval-world.d.ts +17 -0
  18. package/dist/eval-world.js +97 -0
  19. package/dist/evals.d.ts +299 -0
  20. package/dist/evals.js +964 -0
  21. package/dist/exec.js +15 -1
  22. package/dist/first-tasks-child.d.ts +1 -0
  23. package/dist/first-tasks-child.js +11 -0
  24. package/dist/flow-draft.js +4 -0
  25. package/dist/flow-sort.js +4 -0
  26. package/dist/folder-look.d.ts +107 -0
  27. package/dist/folder-look.js +302 -0
  28. package/dist/github-jobs.d.ts +25 -0
  29. package/dist/github-jobs.js +22 -0
  30. package/dist/invoke.d.ts +1 -1
  31. package/dist/invoke.js +14 -0
  32. package/dist/lead.js +4 -1
  33. package/dist/learning-child.d.ts +1 -0
  34. package/dist/learning-child.js +34 -0
  35. package/dist/learning-look.d.ts +25 -0
  36. package/dist/learning-look.js +72 -0
  37. package/dist/memory-pass.js +4 -0
  38. package/dist/nightly-check.d.ts +4 -1
  39. package/dist/nightly-check.js +5 -2
  40. package/dist/nightly-github.d.ts +316 -0
  41. package/dist/nightly-github.js +1022 -0
  42. package/dist/operate.d.ts +123 -1
  43. package/dist/operate.js +344 -29
  44. package/dist/project-identity.d.ts +37 -0
  45. package/dist/project-identity.js +106 -0
  46. package/dist/project-knowledge.d.ts +4 -4
  47. package/dist/project-tools.d.ts +4 -1
  48. package/dist/project-tools.js +7 -2
  49. package/dist/project.d.ts +4 -1
  50. package/dist/project.js +11 -4
  51. package/dist/provider.d.ts +3 -0
  52. package/dist/provider.js +9 -2
  53. package/dist/release-adapters.d.ts +21 -21
  54. package/dist/release-adapters.js +52 -19
  55. package/dist/release-auto.js +26 -0
  56. package/dist/release-card.js +3 -0
  57. package/dist/release-cli.js +59 -3
  58. package/dist/release-view.d.ts +3 -0
  59. package/dist/release-view.js +2 -0
  60. package/dist/release.d.ts +16 -0
  61. package/dist/release.js +16 -0
  62. package/dist/reported-usage.d.ts +69 -0
  63. package/dist/reported-usage.js +97 -0
  64. package/dist/repos.d.ts +7 -0
  65. package/dist/repos.js +57 -0
  66. package/dist/serve.js +35 -8
  67. package/dist/server/chat.js +4 -0
  68. package/dist/server/flows.js +9 -3
  69. package/dist/server/guards.d.ts +4 -0
  70. package/dist/server/guards.js +12 -3
  71. package/dist/server/http.d.ts +11 -0
  72. package/dist/server/http.js +3 -0
  73. package/dist/server/pages.js +62 -88
  74. package/dist/server/render-pages.d.ts +2 -16
  75. package/dist/server/render-pages.js +1 -55
  76. package/dist/server/render-tasks.js +1 -0
  77. package/dist/server/runtime.d.ts +4 -0
  78. package/dist/server/runtime.js +1 -0
  79. package/dist/server/settings.js +14 -3
  80. package/dist/server/tasks.js +15 -4
  81. package/dist/store.d.ts +109 -1
  82. package/dist/store.js +394 -4
  83. package/dist/subagents.js +4 -0
  84. package/dist/subscription-chat.d.ts +2 -0
  85. package/dist/subscription-chat.js +52 -13
  86. package/dist/task-sizing.js +4 -0
  87. package/dist/teardown-git.d.ts +9 -2
  88. package/dist/teardown-git.js +56 -33
  89. package/dist/up-processes.d.ts +13 -0
  90. package/dist/up-processes.js +6 -2
  91. package/evals/cases/catalog/case.json +25 -0
  92. package/evals/cases/catalog/fixture/README.md +3 -0
  93. package/evals/cases/catalog/fixture/package.json +8 -0
  94. package/evals/cases/catalog/fixture/test/catalog.test.mjs +4 -0
  95. package/evals/cases/catalog/oracle.mjs +23 -0
  96. package/evals/cases/catalog/probe.mjs +10 -0
  97. package/evals/cases/catalog/source.md +9 -0
  98. package/evals/cases/csv-quoting/case.json +23 -0
  99. package/evals/cases/csv-quoting/fixture/README.md +3 -0
  100. package/evals/cases/csv-quoting/fixture/csv.js +1 -0
  101. package/evals/cases/csv-quoting/fixture/package.json +8 -0
  102. package/evals/cases/csv-quoting/fixture/test/csv.test.mjs +5 -0
  103. package/evals/cases/csv-quoting/oracle.mjs +14 -0
  104. package/evals/cases/csv-quoting/probe.mjs +9 -0
  105. package/evals/cases/csv-quoting/source.md +9 -0
  106. package/evals/cases/dedupe/case.json +23 -0
  107. package/evals/cases/dedupe/fixture/README.md +3 -0
  108. package/evals/cases/dedupe/fixture/dedupe.js +1 -0
  109. package/evals/cases/dedupe/fixture/package.json +8 -0
  110. package/evals/cases/dedupe/fixture/test/dedupe.test.mjs +5 -0
  111. package/evals/cases/dedupe/oracle.mjs +16 -0
  112. package/evals/cases/dedupe/probe.mjs +13 -0
  113. package/evals/cases/dedupe/source.md +9 -0
  114. package/evals/cases/endpoint/case.json +25 -0
  115. package/evals/cases/endpoint/fixture/README.md +3 -0
  116. package/evals/cases/endpoint/fixture/package.json +8 -0
  117. package/evals/cases/endpoint/fixture/test/endpoint.test.mjs +4 -0
  118. package/evals/cases/endpoint/oracle.mjs +27 -0
  119. package/evals/cases/endpoint/probe.mjs +10 -0
  120. package/evals/cases/endpoint/source.md +11 -0
  121. package/evals/cases/math-divide/case.json +23 -0
  122. package/evals/cases/math-divide/fixture/README.md +3 -0
  123. package/evals/cases/math-divide/fixture/package.json +8 -0
  124. package/evals/cases/math-divide/fixture/src/math.js +1 -0
  125. package/evals/cases/math-divide/fixture/test/math.test.js +5 -0
  126. package/evals/cases/math-divide/oracle.mjs +13 -0
  127. package/evals/cases/math-divide/probe.mjs +9 -0
  128. package/evals/cases/math-divide/source.md +9 -0
  129. package/evals/cases/math-multiply/case.json +23 -0
  130. package/evals/cases/math-multiply/fixture/README.md +3 -0
  131. package/evals/cases/math-multiply/fixture/package.json +8 -0
  132. package/evals/cases/math-multiply/fixture/src/math.js +2 -0
  133. package/evals/cases/math-multiply/fixture/test/math.test.js +5 -0
  134. package/evals/cases/math-multiply/oracle.mjs +12 -0
  135. package/evals/cases/math-multiply/probe.mjs +9 -0
  136. package/evals/cases/math-multiply/source.md +9 -0
  137. package/evals/cases/math-subtract/case.json +23 -0
  138. package/evals/cases/math-subtract/fixture/README.md +3 -0
  139. package/evals/cases/math-subtract/fixture/package.json +8 -0
  140. package/evals/cases/math-subtract/fixture/src/math.js +1 -0
  141. package/evals/cases/math-subtract/fixture/test/math.test.js +5 -0
  142. package/evals/cases/math-subtract/oracle.mjs +12 -0
  143. package/evals/cases/math-subtract/probe.mjs +9 -0
  144. package/evals/cases/math-subtract/source.md +9 -0
  145. package/evals/cases/merge-config/case.json +23 -0
  146. package/evals/cases/merge-config/fixture/README.md +3 -0
  147. package/evals/cases/merge-config/fixture/config.js +1 -0
  148. package/evals/cases/merge-config/fixture/package.json +8 -0
  149. package/evals/cases/merge-config/fixture/test/config.test.mjs +5 -0
  150. package/evals/cases/merge-config/oracle.mjs +28 -0
  151. package/evals/cases/merge-config/probe.mjs +15 -0
  152. package/evals/cases/merge-config/source.md +9 -0
  153. package/evals/cases/paginate/case.json +23 -0
  154. package/evals/cases/paginate/fixture/README.md +3 -0
  155. package/evals/cases/paginate/fixture/package.json +8 -0
  156. package/evals/cases/paginate/fixture/paginate.js +1 -0
  157. package/evals/cases/paginate/fixture/test/paginate.test.mjs +5 -0
  158. package/evals/cases/paginate/oracle.mjs +18 -0
  159. package/evals/cases/paginate/probe.mjs +13 -0
  160. package/evals/cases/paginate/source.md +9 -0
  161. package/evals/cases/round2-asks/case.json +24 -0
  162. package/evals/cases/round2-asks/fixture/README.md +3 -0
  163. package/evals/cases/round2-asks/fixture/package.json +8 -0
  164. package/evals/cases/round2-asks/fixture/src/math.js +1 -0
  165. package/evals/cases/round2-asks/fixture/test/math.test.js +5 -0
  166. package/evals/cases/round2-asks/source.md +11 -0
  167. package/evals/manifest.json +15 -0
  168. package/package.json +2 -1
@@ -21,6 +21,7 @@ import { carryRefusalWords, proveCarry, setAskEveryReleaseCommit, setUnsandboxed
21
21
  import { authenticateApprover, machineActing, personProof, proveCredential } from "./scope.js";
22
22
  import { allowAutoPublish, setReleaseApproval } from "./release-auto.js";
23
23
  import { createNightlyCheckFlow } from "./nightly-check.js";
24
+ import { aliasNightlyLane, optInGithubNightly, retireNightlyLane } from "./nightly-github.js";
24
25
  import { parseSchedule } from "./flow-schedule.js";
25
26
  export const RELEASE_HELP = `toolroll release <branch> — release Toolroll itself, from a pushed branch with an open pull request
26
27
 
@@ -58,6 +59,12 @@ toolroll release nightly --repo <path> --as <you> --token <password> [--schedule
58
59
  — the full release check of main's head every night, approved once with your password; it never deploys,
59
60
  merges or completes, and a failure tells you and holds composed releases until a full check passes
60
61
 
62
+ toolroll release nightly --github --repo <path> --as <you> --token <password> — read GitHub's Nightly runs on main
63
+ for this project (its recorded GitHub remote): a lane that fails its tests holds every deploy of the project, a
64
+ release's merge and its tag until a later nightly passes it, or the release's own CI re-ran it after the failure and
65
+ passed. --lane-alias <lane-id> <new name> says a renamed job is the same lane; --lane-retire <lane-id> retires a
66
+ lane no required check names any more (both with your password)
67
+
61
68
  toolroll release ask-every-commit on|off --repo <path> [--as <you> --token <password>] — this project's releases ask
62
69
  for every later commit (on), or carry test-only commits again (off: your password, never the lead's token)
63
70
 
@@ -91,8 +98,8 @@ A runner's lease is returned with \`toolroll worker release <lease>\`.`;
91
98
  /** A runner lease id (claim.ts mints randomUUID()). */
92
99
  export const LEASE_SHAPED = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
93
100
  export const LEASE_DEPRECATION = "`toolroll release <lease>` is now `toolroll worker release <lease>`; the old form goes in the next minor version.";
94
- const VALUE_FLAGS = new Set(["--repo", "--limit", "--tap", "--release-gate", "--to", "--request", "--as", "--token", "--schedule", "--stage"]);
95
- const BARE_FLAGS = new Set(["--new", "--json", "--help", "--yes", "--no-carry", "--start", "--recover-deploy"]);
101
+ const VALUE_FLAGS = new Set(["--repo", "--limit", "--tap", "--release-gate", "--to", "--request", "--as", "--token", "--schedule", "--stage", "--lane-retire"]);
102
+ const BARE_FLAGS = new Set(["--new", "--json", "--help", "--yes", "--no-carry", "--start", "--recover-deploy", "--github"]);
96
103
  export async function runReleaseCommand(args, write, deps = {}) {
97
104
  // The flag, not a secret's value that happens to read "--json".
98
105
  // A secret is never exactly one of these names (the loop below refuses that as a missing value).
@@ -104,12 +111,21 @@ export async function runReleaseCommand(args, write, deps = {}) {
104
111
  };
105
112
  const positional = [];
106
113
  const limits = {};
107
- let stage;
114
+ let stage, laneRetire, laneAlias;
108
115
  let repo, tap, gate, to, request, as, token, scheduleFlag;
109
116
  for (let i = 0; i < args.length; i += 1) {
110
117
  const raw = args[i];
111
118
  if (BARE_FLAGS.has(raw))
112
119
  continue;
120
+ // `--lane-alias <lane-id> <new name>`: two values, neither a flag of this command.
121
+ if (raw === "--lane-alias") {
122
+ const [id, name] = [args[i + 1], args[i + 2]];
123
+ if (id === undefined || name === undefined || id.startsWith("--") || name.startsWith("--"))
124
+ return refuse("usage", "--lane-alias takes <lane-id> <new name>.", 2);
125
+ laneAlias = [id, name];
126
+ i += 2;
127
+ continue;
128
+ }
113
129
  // `--flag=value` for every value flag; a secret (--token) is also the next argument even when it starts with "--".
114
130
  const equals = raw.startsWith("--") ? raw.indexOf("=") : -1;
115
131
  const arg = equals === -1 ? raw : raw.slice(0, equals);
@@ -139,6 +155,8 @@ export async function runReleaseCommand(args, write, deps = {}) {
139
155
  gate = value;
140
156
  else if (arg === "--stage")
141
157
  stage = value;
158
+ else if (arg === "--lane-retire")
159
+ laneRetire = value;
142
160
  else {
143
161
  const match = /^([a-z]+)=([0-9]+)$/.exec(value);
144
162
  if (match === null || !(match[1] in RELEASE_LIMITS) || Number(match[2]) < 1)
@@ -169,6 +187,13 @@ export async function runReleaseCommand(args, write, deps = {}) {
169
187
  return refuse("usage", "--stage goes with --recover-deploy.", 2);
170
188
  if (bare.has("--yes"))
171
189
  return refuse("usage", "--yes goes with --release-gate <id> or --recover-deploy; a release needs no confirmation beyond the owner's approval.", 2);
190
+ if (bare.has("--github") || laneAlias !== undefined || laneRetire !== undefined) {
191
+ if (positional[0] !== "nightly" || positional.length !== 1)
192
+ return refuse("usage", "--github, --lane-alias and --lane-retire go with `toolroll release nightly`.", 2);
193
+ if ([bare.has("--github"), laneAlias !== undefined, laneRetire !== undefined].filter(Boolean).length !== 1 || scheduleFlag !== undefined || bare.has("--start"))
194
+ return refuse("usage", "Use one of --github, --lane-alias or --lane-retire, without --schedule or --start.", 2);
195
+ return runGithubNightlyCommand({ repo: repo === undefined ? undefined : resolve(deps.cwd ?? process.cwd(), repo), as, token, alias: laneAlias, retire: laneRetire }, write, json, deps);
196
+ }
172
197
  if (positional[0] === "nightly" && positional.length === 1)
173
198
  return runNightlyCommand({ repo: repo === undefined ? undefined : resolve(deps.cwd ?? process.cwd(), repo), as, token, schedule: scheduleFlag, start: bare.has("--start") }, write, json, deps);
174
199
  if (["carry", "revoke", "ask-every-commit", "unsandboxed-carry", "auto-approve", "publish-ok"].includes(positional[0] ?? "") && positional.length === 2) {
@@ -463,3 +488,34 @@ async function runNightlyCommand(flags, write, json, deps) {
463
488
  store.close();
464
489
  }
465
490
  }
491
+ /** `toolroll release nightly --github | --lane-alias | --lane-retire`: GitHub's nightly as release debt
492
+ * (nightly-github.ts), each a person's step proved with their password now. */
493
+ async function runGithubNightlyCommand(flags, write, json, deps) {
494
+ const answer = (ok, fields, words, code) => { write(json ? checkedEnvelopeJson({ ok, command: "release nightly", ...fields }) : words); return code; };
495
+ if (flags.repo === undefined)
496
+ return answer(false, { reason: "usage", message: "Name the project with --repo <path>." }, "Name the project with --repo <path>.", 2);
497
+ if (flags.as === undefined || flags.token === undefined)
498
+ return answer(false, { reason: "usage", message: "This needs your password: add --as <you> --token <password>." }, "This needs your password: add --as <you> --token <password>.", 2);
499
+ if (machineActing() || process.env["TOOLROLL_LEAD_TOKEN"] !== undefined || process.env["TOOLROLL_REMOTE_TOKEN"] !== undefined)
500
+ return answer(false, { reason: "person-only", message: "Only a person changes how GitHub's nightly holds releases." }, "Only a person changes how GitHub's nightly holds releases.", 1);
501
+ const store = openStore(deps.database ?? databasePath(process.env, homedir()));
502
+ const now = (deps.now ?? (() => new Date()))();
503
+ try {
504
+ if (flags.alias !== undefined) {
505
+ const [laneId, name] = flags.alias;
506
+ const done = aliasNightlyLane(store, { repo: flags.repo, laneId, name, as: flags.as, token: flags.token }, now);
507
+ return done.ok ? answer(true, { lane: laneId, name }, `Jobs named "${name}" now count for lane ${laneId}.`, 0) : answer(false, { reason: "refused", message: done.message }, done.message, 1);
508
+ }
509
+ if (flags.retire !== undefined) {
510
+ const done = retireNightlyLane(store, { repo: flags.repo, laneId: flags.retire, as: flags.as, token: flags.token }, now);
511
+ return done.ok ? answer(true, { lane: flags.retire, closed: done.closed }, `Retired lane ${flags.retire}${done.closed > 0 ? `; ${done.closed} open failure${done.closed === 1 ? "" : "s"} closed` : ""}.`, 0)
512
+ : answer(false, { reason: "refused", message: done.message }, done.message, 1);
513
+ }
514
+ const made = optInGithubNightly(store, { repo: flags.repo, as: flags.as, token: flags.token }, now);
515
+ return made.ok ? answer(true, { github: made.slug, lanes: made.lanes }, `GitHub's nightly for ${made.slug} now holds this project's deploys, release merges and tags while a lane fails (${made.lanes} lanes).`, 0)
516
+ : answer(false, { reason: "refused", message: made.message }, made.message, 1);
517
+ }
518
+ finally {
519
+ store.close();
520
+ }
521
+ }
@@ -30,5 +30,8 @@ export type ReleaseView = {
30
30
  outcome: string | null;
31
31
  } | null;
32
32
  openMainFailures: number;
33
+ /** GitHub's nightly (nightly-github.ts): each open lane failure, held or exempted for this release's tip, and a
34
+ * warning when it couldn't be read or hasn't run lately. */
35
+ githubNightly: string[];
33
36
  };
34
37
  export declare function releaseViewOf(store: Store, taskId: string, now: Date, obligations?: (authority: number) => string[]): ReleaseView | null;
@@ -4,6 +4,7 @@
4
4
  * main; for a carried task, whose release approval it was carried from. Read-only, from the store.
5
5
  */
6
6
  import { liveReleaseOf } from "./scope.js";
7
+ import { nightlyReleaseLines } from "./nightly-github.js";
7
8
  import { releaseAuthorityOfTask, releaseCarries } from "./store.js";
8
9
  export function releaseViewOf(store, taskId, now, obligations = () => []) {
9
10
  const found = releaseAuthorityOfTask(store, taskId);
@@ -23,5 +24,6 @@ export function releaseViewOf(store, taskId, now, obligations = () => []) {
23
24
  obligations: obligations(a.id),
24
25
  nightly: nightly === undefined ? null : { at: String(nightly["at"]), sha: String(nightly["sha"]), outcome: nightly["outcome"] == null ? null : String(nightly["outcome"]) },
25
26
  openMainFailures: open,
27
+ githubNightly: nightlyReleaseLines(store, { repo: a.repo, taskId: a.tipTask, candidate: a.tipSha, now }),
26
28
  };
27
29
  }
package/dist/release.d.ts CHANGED
@@ -307,6 +307,22 @@ export type ReleaseAuthorityAdapter = {
307
307
  /** Releases that approve themselves when green (docs/plans/auto-approve-green.md): the plane's own seals, each proven
308
308
  * again inside the plane. Absent: a plane that can't, and every release asks a person as before. */
309
309
  auto?: AutoReleaseAdapter;
310
+ /** GitHub's nightly lane debt (nightly-github.ts, docs/plans/evals-nightly.md part A) right before a deploy, the merge
311
+ * and the tag: one bounded read of GitHub (20 s), this candidate's exemptions read again, then the plane's stored
312
+ * debt. A warning never holds; a hold stops the release. Absent: a plane that can't read it. */
313
+ nightly?(input: {
314
+ taskId: string;
315
+ candidate: string;
316
+ pr: number;
317
+ phase: "deploy" | "merge" | "tag";
318
+ }): Promise<{
319
+ ok: true;
320
+ warning: string | null;
321
+ } | {
322
+ ok: false;
323
+ why: string;
324
+ warning: string | null;
325
+ }>;
310
326
  };
311
327
  /** The plane's automatic release approval, as the coordinator asks it. Every answer is the plane's own: the coordinator
312
328
  * hands it ids, never evidence (the plane reads GitHub CI itself for `approve`). */
package/dist/release.js CHANGED
@@ -570,6 +570,16 @@ export async function runRelease(options, adapters) {
570
570
  if (held !== null)
571
571
  throw new Stop("approval", `${held}. It was merged; nothing more is tagged, published or sent to Homebrew until a person lets it go on: \`toolroll release publish-ok ${j.task.id} --as <you> --token <password>\`, then rerun.`);
572
572
  };
573
+ /** GitHub's nightly lane debt before a deploy, the merge or the tag (evals-nightly.md, part A2). */
574
+ const nightlyHolds = async (phase) => {
575
+ if (rel?.nightly === undefined)
576
+ return;
577
+ const answer = await rel.nightly({ taskId: j.task.id, candidate: j.candidate.sha, pr: j.pr, phase });
578
+ if (answer.warning !== null)
579
+ say(` ${answer.warning}`);
580
+ if (!answer.ok)
581
+ throw new Stop("nightly-debt", `${answer.why} Nothing was ${phase === "deploy" ? "deployed" : phase === "merge" ? "merged" : "tagged or published"}; rerun the release once it passes.`);
582
+ };
573
583
  /** identity(), on a plane that keeps release authority; a legacy plane keeps the old rules. */
574
584
  const watch = async (action) => { if (rel !== undefined)
575
585
  await identity(action, true); };
@@ -1000,6 +1010,7 @@ export async function runRelease(options, adapters) {
1000
1010
  await watch("deploy");
1001
1011
  // Every deploy reservation, whatever the journal saved: CI must still be green on the exact commit.
1002
1012
  await autoCiHolds("the deploy");
1013
+ await nightlyHolds("deploy");
1003
1014
  const reserved = await rel.reserve({ authority: j.authority.id, kind: "deploy", taskId: j.task.id, candidate: j.candidate.sha, epoch });
1004
1015
  if (!reserved.ok)
1005
1016
  throw new Stop("release", `The deploy couldn't be reserved: ${reserved.why}`);
@@ -1017,6 +1028,7 @@ export async function runRelease(options, adapters) {
1017
1028
  await pinned();
1018
1029
  // Every deploy issued, whatever the journal saved (a retry after a rollback included): CI still green.
1019
1030
  await autoCiHolds("the deploy");
1031
+ await nightlyHolds("deploy");
1020
1032
  let operation, secret = "";
1021
1033
  if (rel !== undefined && j.authority && epoch !== null) {
1022
1034
  const reserved = await rel.reserve({ authority: j.authority.id, kind: "deploy", taskId: j.task.id, candidate: j.candidate.sha, epoch });
@@ -1110,6 +1122,9 @@ export async function runRelease(options, adapters) {
1110
1122
  const verdict = prChecksVerdict(await adapters.github.checks(j.github, j.candidate.sha), j.candidate.sha, REQUIRED_PR_CHECKS, { ...autoCap(), workflow: REQUIRED_PR_CHECKS_WORKFLOW });
1111
1123
  if (verdict.state !== "passed")
1112
1124
  await ciFailedAfterDeploy(`Pull request checks are no longer all passing on ${j.candidate.sha.slice(0, 7)}`);
1125
+ // GitHub's nightly lane debt (a bounded read), before main's last read so it doesn't widen that window; the
1126
+ // reservation below checks the stored debt again in its own transaction.
1127
+ await nightlyHolds("merge");
1113
1128
  // Main once more, from GitHub, the last thing before the merge is reserved: moved since the comparison, it
1114
1129
  // stops here (finding 4). GitHub's own up-to-date rule refuses the rest of the window.
1115
1130
  const now = await adapters.github.mainHead(j.github);
@@ -1166,6 +1181,7 @@ export async function runRelease(options, adapters) {
1166
1181
  throw new Stop("conflict", `${j.tag} already points at ${at.slice(0, 7)}, not the merge ${merge.sha.slice(0, 7)}.`);
1167
1182
  if (at === null) {
1168
1183
  await autoCiHolds("the tag");
1184
+ await nightlyHolds("tag");
1169
1185
  intend("tag");
1170
1186
  await adapters.github.createTag(j.github, j.tag, merge.sha);
1171
1187
  }
@@ -0,0 +1,69 @@
1
+ import type { Billing } from "./spend.js";
2
+ /** Several reported models are kept as their sorted ids joined by this (it has spaces, so no one id can equal it):
3
+ * a run whose calls ran different models never reads as any one of them. */
4
+ export declare const MODELS_JOIN = " + ";
5
+ export declare function reportedModelOf(value: unknown): string | null;
6
+ /** The one model a Claude result's `modelUsage` names; null when it names none, several, or anything malformed. */
7
+ export declare function soleReportedModel(modelUsage: unknown): string | null;
8
+ /** Two reports of the same piece of work (a resumed turn, a second init): unknown if either is, the model when they
9
+ * agree, otherwise every model named, sorted (never one chosen). */
10
+ export declare function combineReportedModels(a: string | null, b: string | null): string | null;
11
+ export declare function providerTurnsOf(value: unknown): number | null;
12
+ export declare function reportedCostUsdOf(value: unknown): number | null;
13
+ /** One provider call's own report. `tokensIn` is everything the model read (cached included) when the provider
14
+ * reported every part of it (run-efficiency.ts usageOf), else null. */
15
+ export type ProviderReport = {
16
+ modelReported: string | null;
17
+ providerTurns: number | null;
18
+ reportedCostUsd: number | null;
19
+ tokensIn: number | null;
20
+ cacheReadTokens: number | null;
21
+ cacheWriteTokens: number | null;
22
+ };
23
+ /** What the session's own `system`/`init` events named: the one model they agree on, or that they disagree. */
24
+ export type ClaudeInitModel = {
25
+ model: string | null;
26
+ conflict: boolean;
27
+ };
28
+ /** A Claude primary `result` (streamed or buffered `--output-format json`), with what the session's inits named. The
29
+ * init's model is the session's own; inits that disagree leave it unknown; with no init naming one, the result's
30
+ * `modelUsage` counts only when it names exactly one model. */
31
+ export declare function claudeResultReport(result: Record<string, unknown>, init?: ClaudeInitModel): ProviderReport;
32
+ /** Codex's `turn.completed` usage: its input already counts what came from cache; it reports no cache writes, no
33
+ * model and no cost in `exec --json`, so those stay unknown. */
34
+ export declare function codexUsageReport(usage: unknown): ProviderReport;
35
+ /** One of the three amounts, never added to another. */
36
+ export type ReportedAmount = {
37
+ kind: "api";
38
+ microusd: number;
39
+ }
40
+ /** The provider's own reported cost of work a subscription covers: usage, not billed (null: it reported none). */
41
+ | {
42
+ kind: "subscription";
43
+ reportedUsd: number | null;
44
+ } | {
45
+ kind: "unknown";
46
+ };
47
+ /** A run's amount, from its fixed billing and its priced spend (run_spend). Subscription work reads its provider's
48
+ * reported cost as usage, never as dollars; billed work without a reported or priced cost is unknown. v141 (Codex
49
+ * round 1 #4, #10): billing this computer's default guessed (`basis` not evidence) is unknown, and so is a run whose
50
+ * cost the provider never reported (its `cost_usd` then holds a conservative charge, never a measurement). */
51
+ export declare function runAmountOf(run: {
52
+ billing: Billing | null;
53
+ spendMicrousd: number | null;
54
+ costUsd: number | null;
55
+ basis?: "evidence" | "default" | null;
56
+ costUnknown?: boolean;
57
+ }): ReportedAmount;
58
+ /** A chat request's amount: a subscription chat's reported cost is usage; an API chat's settled charge is dollars,
59
+ * unless its spend is unknown (then the amount is, whatever was settled against the reservation). v141 (Codex round 1
60
+ * #4): a subscription harness's request is subscription usage only when its own events said it was billed to the plan
61
+ * (`billingReported`); one they said used a key is API dollars at its reported cost; one they said nothing of is
62
+ * unknown. Absent `billingReported` keeps the older reading for callers that predate it. */
63
+ export declare function chatTurnAmountOf(turn: {
64
+ provider: string;
65
+ settledMicrousd: number | null;
66
+ unknownSpend: boolean;
67
+ reportedCostUsd: number | null;
68
+ billingReported?: "subscription" | "api-key" | null;
69
+ }): ReportedAmount;
@@ -0,0 +1,97 @@
1
+ /**
2
+ * What a provider itself reported about one call (docs/plans/evals-nightly.md, part B1): the exact model, its turn
3
+ * count, its reported cost and its cache usage. Read only from the provider's own events and response bodies (Claude's
4
+ * session `system`/`init` and primary `result`, Codex's `turn.completed`, an API's response body), never from text an
5
+ * agent or a candidate wrote. Anything unreported or malformed is unknown (null), never 0.
6
+ *
7
+ * Three amounts are kept apart and never summed into one number: API dollars (work billed by use, with a reported or
8
+ * priced cost), subscription usage (the provider's own reported cost of work a plan covers: not billed), and unknown.
9
+ */
10
+ import { usageOf } from "./run-efficiency.js";
11
+ /** A model id as a provider reports it: printable ASCII with no spaces, at most 200 characters. Anything else is
12
+ * unknown, never trimmed into something that could read as a different model. */
13
+ const MODEL_ID = /^[\x21-\x7e]{1,200}$/;
14
+ /** Several reported models are kept as their sorted ids joined by this (it has spaces, so no one id can equal it):
15
+ * a run whose calls ran different models never reads as any one of them. */
16
+ export const MODELS_JOIN = " + ";
17
+ export function reportedModelOf(value) {
18
+ return typeof value === "string" && MODEL_ID.test(value) ? value : null;
19
+ }
20
+ /** The one model a Claude result's `modelUsage` names; null when it names none, several, or anything malformed. */
21
+ export function soleReportedModel(modelUsage) {
22
+ if (typeof modelUsage !== "object" || modelUsage === null || Array.isArray(modelUsage))
23
+ return null;
24
+ const keys = Object.keys(modelUsage);
25
+ return keys.length === 1 ? reportedModelOf(keys[0]) : null;
26
+ }
27
+ /** Two reports of the same piece of work (a resumed turn, a second init): unknown if either is, the model when they
28
+ * agree, otherwise every model named, sorted (never one chosen). */
29
+ export function combineReportedModels(a, b) {
30
+ if (a === null || b === null)
31
+ return null;
32
+ if (a === b)
33
+ return a;
34
+ return [...new Set([...a.split(MODELS_JOIN), ...b.split(MODELS_JOIN)])].sort().join(MODELS_JOIN);
35
+ }
36
+ export function providerTurnsOf(value) {
37
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : null;
38
+ }
39
+ export function reportedCostUsdOf(value) {
40
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null;
41
+ }
42
+ /** A Claude primary `result` (streamed or buffered `--output-format json`), with what the session's inits named. The
43
+ * init's model is the session's own; inits that disagree leave it unknown; with no init naming one, the result's
44
+ * `modelUsage` counts only when it names exactly one model. */
45
+ export function claudeResultReport(result, init = { model: null, conflict: false }) {
46
+ const usage = usageOf({ provider: "claude", model: null, tokensIn: null, tokensOut: null, usageJson: usageJsonOf(result["usage"]) });
47
+ return {
48
+ modelReported: init.conflict ? null : init.model ?? soleReportedModel(result["modelUsage"]),
49
+ providerTurns: providerTurnsOf(result["num_turns"]),
50
+ reportedCostUsd: reportedCostUsdOf(result["total_cost_usd"]),
51
+ tokensIn: usage.in,
52
+ cacheReadTokens: usage.cacheRead,
53
+ cacheWriteTokens: usage.cacheWrite,
54
+ };
55
+ }
56
+ /** Codex's `turn.completed` usage: its input already counts what came from cache; it reports no cache writes, no
57
+ * model and no cost in `exec --json`, so those stay unknown. */
58
+ export function codexUsageReport(usage) {
59
+ const read = usageOf({ provider: "codex", model: null, tokensIn: null, tokensOut: null, usageJson: usageJsonOf(usage) });
60
+ return { modelReported: null, providerTurns: null, reportedCostUsd: null, tokensIn: read.in, cacheReadTokens: read.cacheRead, cacheWriteTokens: null };
61
+ }
62
+ function usageJsonOf(usage) {
63
+ return typeof usage === "object" && usage !== null && !Array.isArray(usage) ? JSON.stringify(usage) : null;
64
+ }
65
+ /** A run's amount, from its fixed billing and its priced spend (run_spend). Subscription work reads its provider's
66
+ * reported cost as usage, never as dollars; billed work without a reported or priced cost is unknown. v141 (Codex
67
+ * round 1 #4, #10): billing this computer's default guessed (`basis` not evidence) is unknown, and so is a run whose
68
+ * cost the provider never reported (its `cost_usd` then holds a conservative charge, never a measurement). */
69
+ export function runAmountOf(run) {
70
+ if (run.basis !== undefined && run.basis !== "evidence")
71
+ return { kind: "unknown" };
72
+ if (run.costUnknown === true)
73
+ return { kind: "unknown" };
74
+ if (run.billing === "subscription")
75
+ return { kind: "subscription", reportedUsd: reportedCostUsdOf(run.costUsd) };
76
+ if (run.billing === "api-key" && run.spendMicrousd !== null && Number.isSafeInteger(run.spendMicrousd) && run.spendMicrousd >= 0) {
77
+ return { kind: "api", microusd: run.spendMicrousd };
78
+ }
79
+ return { kind: "unknown" };
80
+ }
81
+ const SUBSCRIPTION_CHAT = new Set(["claude-subscription", "codex-subscription"]);
82
+ /** A chat request's amount: a subscription chat's reported cost is usage; an API chat's settled charge is dollars,
83
+ * unless its spend is unknown (then the amount is, whatever was settled against the reservation). v141 (Codex round 1
84
+ * #4): a subscription harness's request is subscription usage only when its own events said it was billed to the plan
85
+ * (`billingReported`); one they said used a key is API dollars at its reported cost; one they said nothing of is
86
+ * unknown. Absent `billingReported` keeps the older reading for callers that predate it. */
87
+ export function chatTurnAmountOf(turn) {
88
+ if (SUBSCRIPTION_CHAT.has(turn.provider)) {
89
+ if (turn.billingReported === undefined || turn.billingReported === "subscription")
90
+ return { kind: "subscription", reportedUsd: reportedCostUsdOf(turn.reportedCostUsd) };
91
+ const cost = reportedCostUsdOf(turn.reportedCostUsd);
92
+ return turn.billingReported === "api-key" && cost !== null ? { kind: "api", microusd: Math.round(cost * 1_000_000) } : { kind: "unknown" };
93
+ }
94
+ if (turn.unknownSpend || turn.settledMicrousd === null || !Number.isSafeInteger(turn.settledMicrousd) || turn.settledMicrousd < 0)
95
+ return { kind: "unknown" };
96
+ return { kind: "api", microusd: turn.settledMicrousd };
97
+ }
package/dist/repos.d.ts CHANGED
@@ -57,4 +57,11 @@ export declare function updateProjectRegistry(file: string, transform: (registry
57
57
  } | RegistryUpdateFailure>;
58
58
  export declare function addRepos(existing: readonly string[], incoming: readonly string[]): string[];
59
59
  export declare function removeRepos(existing: readonly string[], targets: readonly string[]): string[];
60
+ /** Beside the saved list: how many times each line has been removed from it (written under its lock). */
61
+ export declare function registryRemovalsFile(file: string): string;
62
+ /**
63
+ * How many times each saved line has been removed, as recorded under the saved list's lock. A file that can't be read
64
+ * as one answers null: its caller treats every line as changed (fail closed).
65
+ */
66
+ export declare function registryRemovals(file: string): Record<string, number> | null;
60
67
  export {};
package/dist/repos.js CHANGED
@@ -10,6 +10,7 @@
10
10
  * to edit it by hand or check it into their dotfiles — which also means it is
11
11
  * untrusted input and gets validated like any other.
12
12
  */
13
+ import { closeSync as closeFd, constants as fsConstants, fstatSync, openSync as openFd, readSync, renameSync as renameFile, writeFileSync } from "node:fs";
13
14
  import { mkdir, readFile, writeFile } from "node:fs/promises";
14
15
  import { dirname } from "node:path";
15
16
  import { configBase, namedPath } from "./names.js";
@@ -213,6 +214,11 @@ export async function updateProjectRegistry(file, transform) {
213
214
  held = false; // abandon: no unlink, no publication
214
215
  return { ok: false, reason: "abandoned", message: "the registry lock aged out before the update could publish — nothing changed; try again" };
215
216
  }
217
+ // Each line this update removes counts one more removal, written BEFORE the list is published: whoever holds a
218
+ // start's authority for a saved line (upDeferredRetry) sees that it was removed, even if it was saved again since.
219
+ const gone = current.repos.filter(one => !next.repos.includes(one));
220
+ if (gone.length > 0)
221
+ bumpRegistryRemovals(file, gone, `${process.pid}.${token.slice(0, 8)}`);
216
222
  const temp = `${file}.${process.pid}.${token.slice(0, 8)}.tmp`;
217
223
  const fd = openSync(temp, "wx");
218
224
  writeSync(fd, `${JSON.stringify({ version: CONFIG_VERSION, ...next }, null, 2)}
@@ -267,3 +273,54 @@ function readProjectRegistry(parsed) {
267
273
  function sortUnique(repos) {
268
274
  return [...new Set(repos)].sort();
269
275
  }
276
+ /** Beside the saved list: how many times each line has been removed from it (written under its lock). */
277
+ export function registryRemovalsFile(file) {
278
+ return `${file}.removals.json`;
279
+ }
280
+ /**
281
+ * How many times each saved line has been removed, as recorded under the saved list's lock. A file that can't be read
282
+ * as one answers null: its caller treats every line as changed (fail closed).
283
+ */
284
+ export function registryRemovals(file) {
285
+ let fd;
286
+ try {
287
+ fd = openFd(registryRemovalsFile(file), fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW | fsConstants.O_NONBLOCK);
288
+ }
289
+ catch (error) {
290
+ return error.code === "ENOENT" ? {} : null;
291
+ }
292
+ try {
293
+ const held = fstatSync(fd);
294
+ if (!held.isFile() || held.size > 4 * 1024 * 1024)
295
+ return null;
296
+ const buffer = Buffer.alloc(held.size);
297
+ const read = held.size === 0 ? 0 : readSync(fd, buffer, 0, buffer.length, 0);
298
+ const parsed = JSON.parse(buffer.toString("utf8", 0, read));
299
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
300
+ return null;
301
+ const out = {};
302
+ for (const [line, count] of Object.entries(parsed)) {
303
+ if (!Number.isSafeInteger(count) || count < 0)
304
+ return null;
305
+ out[line] = count;
306
+ }
307
+ return out;
308
+ }
309
+ catch {
310
+ return null;
311
+ }
312
+ finally {
313
+ closeFd(fd);
314
+ }
315
+ }
316
+ /** One more removal for each of `lines`, written whole and renamed into place. Called only under the list's lock. */
317
+ function bumpRegistryRemovals(file, lines, tag) {
318
+ const kept = registryRemovals(file);
319
+ // An unreadable record starts again from the clock, so no count read before can come round again.
320
+ const counts = kept ?? {};
321
+ for (const line of lines)
322
+ counts[line] = kept === null ? Date.now() : (counts[line] ?? 0) + 1;
323
+ const temp = `${registryRemovalsFile(file)}.${tag}.tmp`;
324
+ writeFileSync(temp, `${JSON.stringify(counts)}\n`, { flag: "wx", mode: 0o600 });
325
+ renameFile(temp, registryRemovalsFile(file));
326
+ }
package/dist/serve.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import { secretFolder } from "./tool-secret-store.js";
2
2
  import { acceptedIdentities, recoverLearning } from "./project-learning.js";
3
+ import { syncProjectIdentities } from "./project-identity.js";
4
+ import { firstTasksOffThread, recoverLearningOffThread } from "./learning-look.js";
3
5
  import { loadOrCreateVapidKeys } from "./push.js";
4
6
  import { checkoutPlan } from "./checkout-cleanup.js";
5
7
  import { latestReleaseFor } from "./server/render-settings.js";
@@ -38,7 +40,8 @@ import { asReadOnlyRequest } from './read-only-request.js';
38
40
  import { connectionsOf, localConnectOf, oneClickOf } from "./mcp-connect.js";
39
41
  import { MOBILE_VIEWPORT_SCRIPT } from "./mobile-viewport.js";
40
42
  import { projectAuthority } from "./project-access.js";
41
- import { discoverTools, projectToolsOf, secretsSetFor, TOOL_CATALOG, toolSecretStorage, toolStanding } from "./project-tools.js";
43
+ import { discoverTools, projectToolsOf, savedProjectMcp, secretsSetFor, TOOL_CATALOG, toolSecretStorage, toolStanding } from "./project-tools.js";
44
+ import { lookProjectMcp } from "./folder-look.js";
42
45
  import { toolRefusal } from "./policy.js";
43
46
  import { isEdgeAddress, refuseEdge } from "./server/edge-refusal.js";
44
47
  import { announcePasswordBearer, isPasswordBearer, PASSWORD_BEARER_REFUSED, passwordBearerAccepted } from "./password-bearer.js";
@@ -166,6 +169,9 @@ import { loadConsoleUrl } from "./webhooks.js";
166
169
  import { reviewFactsOf } from "./workspace-ui.js";
167
170
  import { WorktreePool } from "./worktree.js";
168
171
  export function createDecisionServer(options) {
172
+ // Identities come from what the console's start resolved off its thread (startConsole); a path that didn't answer
173
+ // stays unresolved, kept restrictive. Without them (embedders, tests) the old synchronous reads apply.
174
+ const identities = options.identities ?? syncProjectIdentities();
169
175
  const { projectFamilyPeek, familiesInView, familyTasksInView, revisionDestination, runIsLive, runIsTaskResult, revisionViewOf, taskViewData, routeViewOf, taskChatFocus, pullRequestTargetOf, failureOf, explainAttempt, taskScreen, armTaskResume, planViewOf, planContractViewOf, revisionDocOf, revisionLedgerOf, resultDetailOf } = createTaskViews({
170
176
  get store() { return store; },
171
177
  get projectCounts() { return projectCounts; },
@@ -188,6 +194,7 @@ export function createDecisionServer(options) {
188
194
  const { authenticateApprover, authenticateCredential, authorizeMutation, projectOf, projectRequestAllowed, identify, lookupSession, restricted, visible, admissionList, codingActorAllowed, codingProjectAllowed, workAccess, familyOf, taskRepoOf, runVisible } = createSharedGuards({
189
195
  get store() { return store; },
190
196
  get liveCeiling() { return liveCeiling; },
197
+ identities,
191
198
  get codingProjects() { return codingProjects; },
192
199
  get unscopedMode() { return unscopedMode; },
193
200
  get managedRepos() { return managedRepos; },
@@ -448,18 +455,20 @@ export function createDecisionServer(options) {
448
455
  const approvalNonces = new Map();
449
456
  // Resolve startup authority once. Missing paths remain restrictive.
450
457
  // Native additions come only from the controller after its admission checks.
451
- const { ceiling, unresolved: unresolvedRepos } = resolveCeiling([...(options.repo === undefined ? [] : [options.repo]), ...(options.repos ?? [])], options.projectRoots ?? []);
458
+ const { ceiling: startCeiling, unresolved: unresolvedRepos } = resolveCeiling([...(options.repo === undefined ? [] : [options.repo]), ...(options.repos ?? [])], options.projectRoots ?? [], options.identities === undefined ? undefined : identities.known);
459
+ const ceiling = { ...startCeiling, canon: identities.strict };
452
460
  /** The project every fresh session opens with: the sole configured repo, else none. */
453
461
  const defaultProject = ceiling.repos.length === 1 && ceiling.roots.length === 0 ? ceiling.repos[0] : null;
454
462
  /** No ceiling configured at all: the legacy trust-everything mode, named. */
455
- const unscopedMode = ceiling.repos.length === 0 && ceiling.roots.length === 0;
463
+ // An `up` console (it is handed its admitted set) is never unscoped: with every saved project away it is empty.
464
+ const unscopedMode = ceiling.repos.length === 0 && ceiling.roots.length === 0 && options.admittedRepos === undefined;
456
465
  /** Per-row visibility under the ceiling — the authorization question for reads. */
457
466
  /** The exact repositories this console serves now: `up`'s live admitted set when it supplies one, else startup's. */
458
467
  const exactRepos = () => options.admittedRepos?.() ?? ceiling.repos;
459
468
  const liveCeiling = () => {
460
469
  const repos = [...exactRepos(), ...(options.additionalProjectRepos?.() ?? [])];
461
470
  // An admitted set that is empty for now is an empty ceiling, never the legacy unscoped mode.
462
- return { repos: repos.length === 0 && ceiling.roots.length === 0 && !unscopedMode ? [NO_PROJECT] : repos, roots: ceiling.roots };
471
+ return { repos: repos.length === 0 && ceiling.roots.length === 0 && !unscopedMode ? [NO_PROJECT] : repos, roots: ceiling.roots, canon: identities.strict };
463
472
  };
464
473
  /** The explicit, currently proved project list. The callback is supplied
465
474
  * only by `up`, after it has independently checked git-ness and the root
@@ -479,7 +488,7 @@ export function createDecisionServer(options) {
479
488
  const repos = [];
480
489
  const gone = goneRepos();
481
490
  for (const path of [...exactRepos(), ...(options.currentRepos?.() ?? [])]) {
482
- const canonical = canonicalProject(path) ?? path;
491
+ const canonical = identities.hot(path);
483
492
  if (seen.has(canonical) || !visible(canonical) || gone.has(canonical))
484
493
  continue;
485
494
  seen.add(canonical);
@@ -568,7 +577,8 @@ export function createDecisionServer(options) {
568
577
  // A service that connects by signing in (or an app's own Connect) is offered only that way.
569
578
  catalog: TOOL_CATALOG.filter(one => !names.has(one.name) && oneClickOf(one.name) === null && localConnectOf(one.name) === null),
570
579
  connections: connectionsOf(store, repo, toolHome), kit, wanted,
571
- found: discoverTools(repo, codex, toolHome).filter(one => !names.has(one.spec.name)),
580
+ // The project's own .mcp.json as the background pass last read it off the console's thread (never read here).
581
+ found: discoverTools(repo, codex, toolHome, process.env, options.identities === undefined ? undefined : savedProjectMcp.get(repo) ?? null).filter(one => !names.has(one.spec.name)),
572
582
  storage: toolSecretStorage(),
573
583
  };
574
584
  };
@@ -1027,7 +1037,7 @@ export function createDecisionServer(options) {
1027
1037
  }
1028
1038
  const known = new Set(["/recipes/prepare", "/recipes/preview", "/recipes/import", "/recipes/save", "/recipes/launch", "/tasks/add", "/queue/move", "/queue/note", "/people/invite", "/people/invite-revoke", "/people/revoke", "/people/projects", "/projects/select", "/projects/open", "/mode/confirm", "/mode/sign", "/mode/revoke", "/settings/chat-approval/save", "/settings/chat-approval/off", "/settings/project/live-view"]);
1029
1039
  const placed = url.pathname === "/tasks/add" ? body?.get("repo")?.trim() : null;
1030
- return { repo: url.pathname.startsWith("/people/") ? null : placed ? canonicalProject(placed) ?? placed : projectOf(who, request) ?? null, taskId: null, runId: null,
1040
+ return { repo: url.pathname.startsWith("/people/") ? null : placed ? identities.strict(placed) ?? placed : projectOf(who, request) ?? null, taskId: null, runId: null,
1031
1041
  action: known.has(url.pathname) ? url.pathname.slice(1).replaceAll("/", " ") : "console request" };
1032
1042
  }
1033
1043
  async function handleGet(route, url, who, request, response) {
@@ -1955,6 +1965,8 @@ export function createDecisionServer(options) {
1955
1965
  for (const provider of PROVIDER_IDS.filter(isKeyedProvider).filter(one => one !== "claude" && one !== "codex"))
1956
1966
  waits.push(connectionCheck(provider).catch(() => undefined));
1957
1967
  }
1968
+ // Project identities, resolved off the thread: what pages asked about, and answers gone stale.
1969
+ waits.push(identities.refresh([...exactRepos(), ...(options.currentRepos?.() ?? []), ...store.listProjects().map(one => one.path), ...store.knownRepos(), ...ceiling.roots]));
1958
1970
  integrations.refreshIfDue();
1959
1971
  const sso = ssoSettings();
1960
1972
  if (sso !== null && (ssoProvider === null || ssoProvider.issuer !== sso.issuer || Date.now() - ssoProvider.at >= 10 * 60_000))
@@ -1991,13 +2003,22 @@ export function createDecisionServer(options) {
1991
2003
  continue;
1992
2004
  const reading = { until: Date.now() + 10 * 60_000, found: entry?.found ?? null, reading: true };
1993
2005
  firstTasks.set(repo, reading);
1994
- waits.push(findFirstTasks(repo, options.firstTaskRunner ?? execRun).then(found => { reading.found = found; }, () => { }).finally(() => { reading.reading = false; }));
2006
+ // A test's runner reads in-process; otherwise in a process of its own (learning-look.ts), so the hardened
2007
+ // git's synchronous reads of the project's folder never hold the console's thread.
2008
+ waits.push((options.firstTaskRunner !== undefined ? findFirstTasks(repo, options.firstTaskRunner) : firstTasksOffThread(repo)).then(found => { reading.found = found; }, () => { }).finally(() => { reading.reading = false; }));
1995
2009
  }
1996
2010
  }
1997
2011
  // Learning recovered from reviewed runs (and a project's saved rows carried to its current identity), which
1998
2012
  // Settings → Learning and Knowledge used to do as they opened.
1999
2013
  if (lastChecks.due("learning", 10 * 60_000)) {
2000
2014
  waits.push(lastChecks.run("learning", async () => {
2015
+ // Its reads of each project's Git folder are synchronous, so with a database file they run in a process of
2016
+ // their own (learning-look.ts): a folder that doesn't answer costs its own recovery, never the console. An
2017
+ // in-memory database (a test) can't be shared, so it is read here.
2018
+ if (databaseFile !== null) {
2019
+ await recoverLearningOffThread({ databaseFile, evidenceRoot, repos: managedRepos() }, options.learningRunner === undefined ? {} : { runner: options.learningRunner });
2020
+ return;
2021
+ }
2001
2022
  for (const repo of managedRepos()) {
2002
2023
  try {
2003
2024
  acceptedIdentities(store, repo);
@@ -2012,6 +2033,11 @@ export function createDecisionServer(options) {
2012
2033
  }
2013
2034
  // What Codex has configured, per project, for Settings → Tools to offer.
2014
2035
  for (const repo of managedRepos()) {
2036
+ // The project's own .mcp.json, read off the console's thread (folder-look.ts) for the Tools page and the lead.
2037
+ if (options.identities !== undefined && lastChecks.due(`project-mcp:${repo}`, 10 * 60_000)) {
2038
+ waits.push(lastChecks.run(`project-mcp:${repo}`, async () => { const looked = await lookProjectMcp(repo); if (looked.ok)
2039
+ savedProjectMcp.set(repo, looked.value); return looked.ok; }));
2040
+ }
2015
2041
  if (lastChecks.due(`codex-tools:${repo}`, 10 * 60_000))
2016
2042
  waits.push(lastChecks.run(`codex-tools:${repo}`, () => codexServers(repo)));
2017
2043
  }
@@ -2220,6 +2246,7 @@ export function createDecisionServer(options) {
2220
2246
  get revisionLedgerOf() { return revisionLedgerOf; },
2221
2247
  get firstTasks() { return firstTasks; },
2222
2248
  get authenticateCredential() { return authenticateCredential; },
2249
+ get identities() { return identities; },
2223
2250
  };
2224
2251
  const tasksHandlers = createTasksHandlers(handlerRuntime);
2225
2252
  const pagesHandlers = createPagesHandlers(handlerRuntime);