toolroll 0.9.82 → 0.9.84

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 (92) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +4 -3
  3. package/dist/assignment-ui.js +5 -1
  4. package/dist/browser/workspace.css +1 -1
  5. package/dist/browser/workspace.js +22 -22
  6. package/dist/browser-crew.js +5 -1
  7. package/dist/browser-workspace.d.ts +7 -0
  8. package/dist/contracts/console-api.d.ts +2 -1
  9. package/dist/contracts/console-api.js +5 -1
  10. package/dist/control-setup.d.ts +9 -0
  11. package/dist/control-setup.js +31 -4
  12. package/dist/control-ui.d.ts +7 -1
  13. package/dist/control-ui.js +3 -2
  14. package/dist/dispatch.d.ts +15 -0
  15. package/dist/dispatch.js +26 -4
  16. package/dist/first-run.d.ts +5 -2
  17. package/dist/first-run.js +13 -2
  18. package/dist/first-tasks-child.d.ts +1 -0
  19. package/dist/first-tasks-child.js +11 -0
  20. package/dist/folder-look.d.ts +110 -0
  21. package/dist/folder-look.js +306 -0
  22. package/dist/guides.js +4 -3
  23. package/dist/learning-child.d.ts +1 -0
  24. package/dist/learning-child.js +34 -0
  25. package/dist/learning-look.d.ts +25 -0
  26. package/dist/learning-look.js +72 -0
  27. package/dist/model-catalog.d.ts +5 -0
  28. package/dist/model-catalog.js +11 -2
  29. package/dist/model-choices.d.ts +188 -0
  30. package/dist/model-choices.js +405 -0
  31. package/dist/model-names.d.ts +6 -0
  32. package/dist/model-names.js +34 -0
  33. package/dist/models-ui.d.ts +69 -5
  34. package/dist/models-ui.js +53 -10
  35. package/dist/needs-you.d.ts +8 -0
  36. package/dist/needs-you.js +8 -0
  37. package/dist/operate.d.ts +53 -0
  38. package/dist/operate.js +277 -49
  39. package/dist/project-admission.d.ts +4 -1
  40. package/dist/project-admission.js +23 -2
  41. package/dist/project-identity.d.ts +44 -0
  42. package/dist/project-identity.js +153 -0
  43. package/dist/project-knowledge.d.ts +6 -6
  44. package/dist/project-tools.d.ts +4 -1
  45. package/dist/project-tools.js +7 -2
  46. package/dist/project.d.ts +4 -1
  47. package/dist/project.js +11 -4
  48. package/dist/repos.d.ts +7 -0
  49. package/dist/repos.js +57 -0
  50. package/dist/serve.js +91 -17
  51. package/dist/server/chat.js +5 -4
  52. package/dist/server/chrome.d.ts +3 -0
  53. package/dist/server/chrome.js +5 -2
  54. package/dist/server/console.css +5 -2
  55. package/dist/server/flows.js +13 -3
  56. package/dist/server/guards.d.ts +4 -0
  57. package/dist/server/guards.js +12 -3
  58. package/dist/server/http.d.ts +12 -0
  59. package/dist/server/http.js +9 -0
  60. package/dist/server/pages.js +102 -92
  61. package/dist/server/people-tokens.js +40 -20
  62. package/dist/server/render-pages.d.ts +12 -16
  63. package/dist/server/render-pages.js +30 -69
  64. package/dist/server/render-people.d.ts +14 -2
  65. package/dist/server/render-people.js +35 -12
  66. package/dist/server/render-settings.js +1 -0
  67. package/dist/server/render-tasks.js +7 -14
  68. package/dist/server/request-context.d.ts +2 -0
  69. package/dist/server/route-table.js +8 -1
  70. package/dist/server/runtime.d.ts +4 -0
  71. package/dist/server/runtime.js +1 -0
  72. package/dist/server/settings.d.ts +58 -1
  73. package/dist/server/settings.js +211 -71
  74. package/dist/server/signup-gate.d.ts +9 -0
  75. package/dist/server/signup-gate.js +40 -0
  76. package/dist/server/tasks.js +19 -5
  77. package/dist/settings-kit-script.d.ts +21 -0
  78. package/dist/settings-kit-script.js +67 -0
  79. package/dist/settings-kit.d.ts +137 -0
  80. package/dist/settings-kit.js +186 -0
  81. package/dist/settings-proofs.d.ts +64 -0
  82. package/dist/settings-proofs.js +101 -0
  83. package/dist/setup-guide.js +4 -2
  84. package/dist/spend-report.d.ts +87 -0
  85. package/dist/spend-report.js +265 -0
  86. package/dist/spend-ui.d.ts +29 -8
  87. package/dist/spend-ui.js +74 -39
  88. package/dist/task-sizing.d.ts +5 -3
  89. package/dist/task-sizing.js +4 -4
  90. package/dist/up-processes.d.ts +13 -0
  91. package/dist/up-processes.js +6 -2
  92. package/package.json +1 -1
package/dist/operate.js CHANGED
@@ -110,7 +110,7 @@ import { claimInstance, clearInstance, openInstanceSocket } from "./console-inst
110
110
  import { authorizePlanUnderMode } from "./plan-auto.js";
111
111
  import { ghDispatchAdapter, mirrorTaskId, syncPass } from "./sync.js";
112
112
  import { LIVE_FILE_BOUND, LIVE_RETAIN_MS, sweepLiveLogs } from "./live.js";
113
- import { addRepos, removeRepos, updateRepos, loadRepos, loadProjectRegistry, updateProjectRegistry } from "./repos.js";
113
+ import { addRepos, removeRepos, updateRepos, loadRepos, loadProjectRegistry, updateProjectRegistry, registryRemovals } from "./repos.js";
114
114
  import { deleteProject, holdingsWords, projectHoldings, projectRunning } from "./project-delete.js";
115
115
  import { pushPass } from "./push.js";
116
116
  import { chmodSync, closeSync, constants as fsConstants, existsSync, fstatSync, fsyncSync, openSync, readFileSync, readSync, realpathSync, renameSync, unlinkSync, writeSync, writeFileSync } from "node:fs";
@@ -181,6 +181,8 @@ import { createDecisionServer } from "./serve.js";
181
181
  import { readTailnetNames } from "./tailnet.js";
182
182
  import { daemonLaunchCommand, daemonStatus, installReplacingLegacy, installedLegacyDaemon, planDaemon, uninstallDaemon, awaitFreshHeartbeat, } from "./daemon.js";
183
183
  import { bridgePass, clearBotToken, createTransport, followBridge, hashPairingCode, loadBotToken, mintPairingCode, networkFence, networkFenceLine, redactToken, saveBotToken, PAIRING_TTL_MS, TOKEN_ENV, telegramHookSecret, telegramPushUrl, } from "./telegram.js";
184
+ import { projectIdentities } from "./project-identity.js";
185
+ import { lookIdentities } from "./folder-look.js";
184
186
  import { scanRepo } from "./capscan.js";
185
187
  import { computeGaps, describeCapability } from "./gaps.js";
186
188
  import { ask as promptAsk, askHidden as promptHidden, confirm as promptConfirm, interactive as promptInteractive, underAgent } from "./prompt.js";
@@ -220,6 +222,7 @@ import { fileTaskProposal, validateTaskText } from "./proposal.js";
220
222
  import { TEMPLATES, templateByName } from "./templates.js";
221
223
  import { isDirectChatProvider, isSubscriptionChatProvider, priceOf, PRICED_MODELS } from "./converse.js";
222
224
  import { resolvePhaseAgent, resolveScopeProfile, resolveRouteCandidates, routeOfTask, INSTALLATION_SCOPE } from "./agentconfig.js";
225
+ import { applyModelChoices, stateDigest } from "./model-choices.js";
223
226
  import { isTaskSize, legOf, projectRoute, routeDigestOf, routeWords, TASK_SIZES, PHASES as ROUTE_PHASES } from "./phase-routing.js";
224
227
  import { observeProviderReadiness, reportProviderReadinessAuthed } from "./runner.js";
225
228
  import { parseDemoUrl, projectDemoUrl, saveProjectDemo } from "./project-demo.js";
@@ -965,6 +968,7 @@ async function dispatchOn(command, positional, flags, repoList, base, options, e
965
968
  ...(options.onboardSeams === undefined ? {} : { onboardSeams: options.onboardSeams }),
966
969
  ...(options.upSeams === undefined ? {} : { upSeams: options.upSeams }),
967
970
  ...(options.upRole === undefined ? {} : { upRole: options.upRole }),
971
+ ...(options.folderLook === undefined ? {} : { folderLook: options.folderLook }),
968
972
  ...(upNeedsOneProcess(options) ? { upOneProcess: true } : {}),
969
973
  ...(activeRemote() === null ? {} : { principal: activeRemote() }),
970
974
  });
@@ -3939,7 +3943,13 @@ async function startConsole(options) {
3939
3943
  const { context } = options;
3940
3944
  // A git incident (teardown-git.ts) raises an attention notification in this console's outbox.
3941
3945
  noticeGitIncidents(context.store, context.clock);
3946
+ // Every project folder this console starts with is resolved off its thread, under a deadline, after it is listening
3947
+ // (consoleIdentitiesInBackground): the console answers at once, and a project shows up once its folder answers. Until
3948
+ // then it is unresolved and kept restrictive; one that doesn't answer (a drive away, an iCloud folder macOS is
3949
+ // holding) is looked at again by the background pass (project-identity.ts).
3950
+ const identities = projectIdentities(context.folderLook);
3942
3951
  const server = createDecisionServer({
3952
+ identities,
3943
3953
  store: context.store,
3944
3954
  // Sign-in, integration and model checks, and the tidy-ups no page visit does (docs/plans/get-side-effects.md).
3945
3955
  backgroundChecks: true,
@@ -3974,6 +3984,7 @@ async function startConsole(options) {
3974
3984
  }).catch(error => {
3975
3985
  throw new Error(`could not listen on ${options.host}:${options.port} — ${describe(error)}`);
3976
3986
  });
3987
+ consoleIdentitiesInBackground(identities, [...options.repos ?? [], ...options.projectRoots ?? [], ...context.store.listProjects().map(one => one.path), ...context.store.knownRepos()]);
3977
3988
  // v104: monitoring runs beside the console (every setup has one): one loop sends the audit stream and traces where
3978
3989
  // Settings → Monitoring says, and stops when the console does. A lease keeps a second process from sending too.
3979
3990
  const stopMonitoring = startMonitoring({
@@ -5034,6 +5045,9 @@ async function configCommand(positional, flags, context) {
5034
5045
  if (phase === undefined || !["plan", "build", "repair", "review"].includes(phase))
5035
5046
  return fail(write, json, `config ${action}`, "usage", "which phase? plan, build, repair, review — or chat", EXIT.usage);
5036
5047
  const acting = await askCredentials(flags, context);
5048
+ // The credential generation the proof below is checked against, read BEFORE it: a rotation after this read makes
5049
+ // the write refuse (model-choices.ts re-checks it inside the transaction), never borrow the new generation.
5050
+ const provedGeneration = acting === null ? -1 : store.accountOf(acting.name)?.generation ?? -1;
5037
5051
  const verified = acting === null ? null : authenticateApprover(store, acting.name, acting.token);
5038
5052
  if (acting === null || !verified?.ok)
5039
5053
  return refuseApprover(context, `config ${action}`, verified, "changing spend routing takes `--as <you> --token <t>`", phase);
@@ -5049,6 +5063,18 @@ async function configCommand(positional, flags, context) {
5049
5063
  // Clearing one left from before stays possible.
5050
5064
  if (tierGiven === "light" && phase !== "build" && !(action === "clear" && phase === "plan" && !flag(flags, "also")))
5051
5065
  return fail(write, json, `config ${action}`, "usage", "--tier light is for build only — a small change makes no plan, and repairs resume the builder's session", EXIT.usage);
5066
+ // Every row below — plain, tier, `--also`, and a leftover light planner's removal — is written through the one write
5067
+ // path (model-choices.ts): one transaction, the actor re-proved as an instance operator at the credential generation
5068
+ // proved above by a proof that still stands, the target and the resulting configuration checked, every row ledgered.
5069
+ // A project named with --repo must be one Toolroll knows (the editor's own rule).
5070
+ const projectKnown = repoGiven === undefined || (await knownProject(context, repoGiven, { listed: true, canonical: true })) !== undefined;
5071
+ const proof = { actor: acting.name, generation: provedGeneration, method: verified.method };
5072
+ const written = (slot, to, extra = []) => writeModelRow(store, proof, scope, () => projectKnown, slot, to, clock(), extra);
5073
+ // A configuration still missing a role's model is saved, and said (JSON: `incomplete`).
5074
+ const said = (command, result, payload, lines) => {
5075
+ const incomplete = result.ok ? result.unresolved : null;
5076
+ return succeed(write, json, command, { ...payload, ...(incomplete === null ? {} : { incomplete }) }, () => [...lines(), ...(incomplete === null ? [] : [` ! new tasks can't be filed here yet: ${incomplete}`])]);
5077
+ };
5052
5078
  // Another provider's candidate on the same tier (`--also`): one per provider, and at filing the one whose plan
5053
5079
  // has more room runs.
5054
5080
  if (flag(flags, "also")) {
@@ -5059,8 +5085,11 @@ async function configCommand(positional, flags, context) {
5059
5085
  if (alsoProvider !== undefined && !isProviderId(alsoProvider))
5060
5086
  return fail(write, json, `config ${action}`, "usage", `--provider is one of ${PROVIDER_IDS.join(", ")}`, EXIT.usage);
5061
5087
  if (action === "clear") {
5062
- const removed = store.clearPhaseTierAlternates(scope, phase, tier, alsoProvider ?? null);
5063
- return succeed(write, json, "config clear", { scope, phase, tier, also: true, provider: alsoProvider ?? null, cleared: removed }, () => [
5088
+ const alsoClear = written(null, null, [{ kind: "also-clear", phase, tier, provider: alsoProvider ?? null }]);
5089
+ if (!alsoClear.ok)
5090
+ return fail(write, json, "config clear", alsoClear.reason, alsoClear.problem, EXIT.refused);
5091
+ const removed = alsoClear.extra;
5092
+ return said("config clear", alsoClear, { scope, phase, tier, also: true, provider: alsoProvider ?? null, cleared: removed }, () => [
5064
5093
  removed > 0 ? `Removed ${removed} other-provider ${tier} ${phase} agent${removed === 1 ? "" : "s"} at ${scope}; sealed routes are untouched.` : `No other-provider ${tier} ${phase} agent${alsoProvider === undefined ? "" : ` on ${alsoProvider}`} was configured at ${scope}.`,
5065
5094
  ]);
5066
5095
  }
@@ -5070,10 +5099,12 @@ async function configCommand(positional, flags, context) {
5070
5099
  const alsoValid = validateSpec({ provider: alsoProvider, model: alsoModel });
5071
5100
  if (!alsoValid.ok)
5072
5101
  return fail(write, json, "config set", "invalid", alsoValid.problem, EXIT.usage);
5073
- store.setPhaseTierAlternate(scope, phase, tier, alsoProvider, alsoModel, acting.name, clock());
5102
+ const alsoSet = written(null, null, [{ kind: "also-set", phase, tier, provider: alsoProvider, model: alsoModel }]);
5103
+ if (!alsoSet.ok)
5104
+ return fail(write, json, "config set", alsoSet.reason, alsoSet.problem, EXIT.refused);
5074
5105
  const resolvedNow = resolveRouteCandidates(store, scope === INSTALLATION_SCOPE ? null : scope);
5075
5106
  const own = !resolvedNow.ok ? null : tier === "routine" ? resolvedNow.candidates[phase].routine : tier === "light" ? resolvedNow.candidates[phase].light ?? null : resolvedNow.candidates[phase].strong;
5076
- return succeed(write, json, "config set", { scope, phase, tier, also: true, provider: alsoProvider, model: alsoModel }, () => [
5107
+ return said("config set", alsoSet, { scope, phase, tier, also: true, provider: alsoProvider, model: alsoModel }, () => [
5077
5108
  `${tier} ${phase} at ${scope === INSTALLATION_SCOPE ? "the installation" : scope} can also run ${alsoProvider} · ${alsoModel}, set by ${acting.name}.`,
5078
5109
  own !== null && own.provider === alsoProvider && !own.source.includes("also")
5079
5110
  ? ` ! the ${tier} ${phase} agent already runs ${alsoProvider} (${own.model}) — this one is not used while it does.`
@@ -5081,20 +5112,39 @@ async function configCommand(positional, flags, context) {
5081
5112
  ]);
5082
5113
  }
5083
5114
  if (action === "clear") {
5115
+ if (tierGiven === "light" && phase === "build") {
5116
+ const lightWrite = written({ phase, tier: "light" }, null);
5117
+ if (!lightWrite.ok)
5118
+ return fail(write, json, "config clear", lightWrite.reason, lightWrite.problem, EXIT.refused);
5119
+ const clearedLight = lightWrite.diff.length > 0;
5120
+ return said("config clear", lightWrite, { scope, phase, tier: "light", cleared: clearedLight }, () => [
5121
+ clearedLight ? `Cleared the light ${phase} agent at ${scope} — small changes keep the default and say so; sealed routes are untouched.` : `No light ${phase} agent was configured at ${scope}.`,
5122
+ ]);
5123
+ }
5084
5124
  if (tierGiven === "light") {
5085
- const clearedLight = store.clearPhaseTierConfig(scope, phase, "light");
5086
- return succeed(write, json, "config clear", { scope, phase, tier: "light", cleared: clearedLight }, () => [
5125
+ // A light planner from before light became build-only: removed here, never written.
5126
+ const legacyClear = written(null, null, [{ kind: "legacy-light-clear", phase: phase }]);
5127
+ if (!legacyClear.ok)
5128
+ return fail(write, json, "config clear", legacyClear.reason, legacyClear.problem, EXIT.refused);
5129
+ const clearedLight = legacyClear.extra > 0;
5130
+ return said("config clear", legacyClear, { scope, phase, tier: "light", cleared: clearedLight }, () => [
5087
5131
  clearedLight ? `Cleared the light ${phase} agent at ${scope} — small changes keep the default and say so; sealed routes are untouched.` : `No light ${phase} agent was configured at ${scope}.`,
5088
5132
  ]);
5089
5133
  }
5090
5134
  if (tierGiven === "strong") {
5091
- const clearedStrong = store.clearPhaseTierConfig(scope, phase, "strong");
5092
- return succeed(write, json, "config clear", { scope, phase, tier: "strong", cleared: clearedStrong }, () => [
5135
+ const strongWrite = written({ phase: phase, tier: "strong" }, null);
5136
+ if (!strongWrite.ok)
5137
+ return fail(write, json, "config clear", strongWrite.reason, strongWrite.problem, EXIT.refused);
5138
+ const clearedStrong = strongWrite.diff.length > 0;
5139
+ return said("config clear", strongWrite, { scope, phase, tier: "strong", cleared: clearedStrong }, () => [
5093
5140
  clearedStrong ? `Cleared the strong ${phase} agent at ${scope} — demanding routes keep the default and say so; sealed routes are untouched.` : `No strong ${phase} agent was configured at ${scope}.`,
5094
5141
  ]);
5095
5142
  }
5096
- const cleared = store.clearPhaseConfig(scope, phase);
5097
- return succeed(write, json, "config clear", { scope, phase, cleared }, () => [
5143
+ const routineWrite = written({ phase: phase, tier: "routine" }, null);
5144
+ if (!routineWrite.ok)
5145
+ return fail(write, json, "config clear", routineWrite.reason, routineWrite.problem, EXIT.refused);
5146
+ const cleared = routineWrite.diff.length > 0;
5147
+ return said("config clear", routineWrite, { scope, phase, cleared }, () => [
5098
5148
  cleared ? `Cleared ${phase} at ${scope} — it resolves one layer down now.` : `Nothing was configured for ${phase} at ${scope}.`,
5099
5149
  ]);
5100
5150
  }
@@ -5108,8 +5158,10 @@ async function configCommand(positional, flags, context) {
5108
5158
  if (tierGiven === "light") {
5109
5159
  if (modelGiven === null)
5110
5160
  return fail(write, json, "config set", "usage", `a light ${phase} agent names an exact --model — approvals bind exact routing`, EXIT.usage);
5111
- store.setPhaseTierConfig(scope, phase, "light", providerGiven, modelGiven, acting.name, clock());
5112
- return succeed(write, json, "config set", { scope, phase, tier: "light", provider: providerGiven, model: modelGiven }, () => [
5161
+ const lightSet = written({ phase: phase, tier: "light" }, { provider: providerGiven, model: modelGiven });
5162
+ if (!lightSet.ok)
5163
+ return fail(write, json, "config set", lightSet.reason, lightSet.problem, EXIT.refused);
5164
+ return said("config set", lightSet, { scope, phase, tier: "light", provider: providerGiven, model: modelGiven }, () => [
5113
5165
  `light ${phase} at ${scope === INSTALLATION_SCOPE ? "the installation" : scope} is ${providerGiven} · ${modelGiven}, set by ${acting.name}.`,
5114
5166
  " small changes filed from now on build on it with no plan; sealed routes are untouched.",
5115
5167
  ]);
@@ -5119,14 +5171,19 @@ async function configCommand(positional, flags, context) {
5119
5171
  return fail(write, json, "config set", "invalid", "gemini has no isolation posture for the review phase yet — name claude or codex as the strong reviewer", EXIT.usage);
5120
5172
  if (modelGiven === null)
5121
5173
  return fail(write, json, "config set", "usage", `a strong ${phase} agent names an exact --model — approvals bind exact routing`, EXIT.usage);
5122
- store.setPhaseTierConfig(scope, phase, "strong", providerGiven, modelGiven, acting.name, clock());
5123
- return succeed(write, json, "config set", { scope, phase, tier: "strong", provider: providerGiven, model: modelGiven }, () => [
5174
+ const strongSet = written({ phase: phase, tier: "strong" }, { provider: providerGiven, model: modelGiven });
5175
+ if (!strongSet.ok)
5176
+ return fail(write, json, "config set", strongSet.reason, strongSet.problem, EXIT.refused);
5177
+ return said("config set", strongSet, { scope, phase, tier: "strong", provider: providerGiven, model: modelGiven }, () => [
5124
5178
  `strong ${phase} at ${scope === INSTALLATION_SCOPE ? "the installation" : scope} is ${providerGiven}${modelGiven === null ? "" : ` · ${modelGiven}`}, set by ${acting.name}.`,
5125
5179
  " large or risky, strict, screenshot-proof, and automerge routes filed from now on reach for it; sealed routes are untouched.",
5126
5180
  ...(phase === "repair" ? [" a strong repair agent applies only when its provider is the build's — cross-provider repair does not exist."] : []),
5127
5181
  ]);
5128
5182
  }
5129
- store.setPhaseConfig(scope, phase, providerGiven, modelGiven, acting.name, clock());
5183
+ // A routine row may still name no model on the command line (said below); the editor never writes one.
5184
+ const routineSet = written({ phase: phase, tier: "routine" }, { provider: providerGiven, model: modelGiven });
5185
+ if (!routineSet.ok)
5186
+ return fail(write, json, "config set", routineSet.reason, routineSet.problem, EXIT.refused);
5130
5187
  const warnings = [];
5131
5188
  if (modelGiven === null) {
5132
5189
  warnings.push(`no --model named: approvals bind exact routing, so scopes cannot file until ${phase} has an exact model — \`config set ${phase} --provider ${providerGiven} --model <model>\``);
@@ -5137,7 +5194,7 @@ async function configCommand(positional, flags, context) {
5137
5194
  if (providerGiven !== "claude") {
5138
5195
  warnings.push(`${providerGiven} does not report dollar cost: its runs land as UNMEASURED, and any schedule with a weekly limit fails closed on them by design.`);
5139
5196
  }
5140
- return succeed(write, json, "config set", { scope, phase, provider: providerGiven, model: modelGiven, warnings }, () => [
5197
+ return said("config set", routineSet, { scope, phase, provider: providerGiven, model: modelGiven, warnings }, () => [
5141
5198
  `${phase} at ${scope === INSTALLATION_SCOPE ? "the installation" : scope} now runs ${providerGiven}${modelGiven === null ? "" : ` · ${modelGiven}`}, set by ${acting.name}.`,
5142
5199
  ...warnings.map(one => ` ! ${one}`),
5143
5200
  ]);
@@ -7055,6 +7112,8 @@ async function upCommand(flags, context) {
7055
7112
  // folders they may be added from are installation state, not arguments a
7056
7113
  // person must repeat on every start.
7057
7114
  const registryPath = registryPathOf(context);
7115
+ // Read before the list itself: a line removed after this (even if saved again) counts differently from now on.
7116
+ const removalsAtStart = registryRemovals(registryPath);
7058
7117
  const loadedRegistry = await loadProjectRegistry(registryPath);
7059
7118
  if ("error" in loadedRegistry)
7060
7119
  return fail(write, json, "up", "registry", loadedRegistry.error, EXIT.refused);
@@ -7078,23 +7137,28 @@ async function upCommand(flags, context) {
7078
7137
  // soon as a projects folder or saved repository exists.
7079
7138
  const repos = [];
7080
7139
  const proveRepo = async (input) => {
7081
- const top = await gitRun("git", ["rev-parse", "--show-toplevel"], { cwd: resolve(input) });
7082
- if (top.code !== 0)
7083
- return null;
7084
- try {
7085
- return realpathSync(top.stdout.trim());
7086
- }
7087
- catch {
7088
- return null;
7089
- }
7140
+ // The checkout's real path is read off the thread, under a deadline (folder-look.ts): a folder that doesn't answer
7141
+ // is unavailable, never a wait.
7142
+ return (await upProveSaved(gitRun, input))?.root ?? null;
7090
7143
  };
7144
+ // A saved project that doesn't answer now (a drive not connected, a folder macOS is holding) is looked at again
7145
+ // while this start runs, and connects once it answers (upDeferredRetry).
7146
+ const deferredRepos = [];
7147
+ const deferredGenerations = {};
7148
+ // The saved line(s) each project was proved from, so its removal is judged by its own line alone.
7149
+ const repoLines = {};
7091
7150
  for (const input of loadedRegistry.repos) {
7092
7151
  const root = await proveRepo(input);
7093
7152
  if (root === null) {
7094
- progress(`saved project unavailable — skipped ${input}`);
7153
+ progress(`saved project unavailable — skipped ${input}; it connects when it's back`);
7154
+ deferredRepos.push(input);
7155
+ // An unreadable removal record leaves no count that could match later: the authority can't be used.
7156
+ deferredGenerations[input] = removalsAtStart === null ? -1 : removalsAtStart[input] ?? 0;
7095
7157
  }
7096
- else if (!repos.includes(root)) {
7097
- repos.push(root);
7158
+ else {
7159
+ if (!repos.includes(root))
7160
+ repos.push(root);
7161
+ (repoLines[root] ??= []).push(input);
7098
7162
  }
7099
7163
  }
7100
7164
  const explicitRepos = context.repoList ?? [];
@@ -7118,7 +7182,9 @@ async function upCommand(flags, context) {
7118
7182
  if (cwdRepo !== null)
7119
7183
  pinnedRepos.add(cwdRepo);
7120
7184
  }
7121
- if (repos.length === 0 && projectRoots.length === 0)
7185
+ // Saved projects that are only away for now still start the service: it serves an empty, restrictive set until they
7186
+ // answer (never the unscoped mode), and connects each as it comes back.
7187
+ if (repos.length === 0 && projectRoots.length === 0 && deferredRepos.length === 0)
7122
7188
  return fail(write, json, "up", "no-projects", "choose where your projects live once: `toolroll up --project-root <dir>`", EXIT.refused);
7123
7189
  // A person at a terminal reads three lines (onboarding); the folders are for --verbose, a service log, or an agent.
7124
7190
  const quietStart = !json && !flags.has("verbose") && (context.upSeams?.terminal ?? process.stdout.isTTY === true) && !underAgent(context.upSeams?.env ?? process.env);
@@ -7376,6 +7442,9 @@ async function upCommand(flags, context) {
7376
7442
  type: "start", role: "worker", databaseFile: context.databaseFile, containment: containmentFlag, build,
7377
7443
  config: {
7378
7444
  runner: runnerName, token: runnerToken, repos: generation === 1 ? [...repos] : [...currentRepos], pinnedRepos: [...pinnedRepos], projectRoots, registryPath, pool,
7445
+ // What this start still holds for its saved projects that were away: a restarted worker never gets back an
7446
+ // authority an earlier one gave up (connected, or removed from the saved list).
7447
+ deferredRepos: [...deferredRepos], deferredGenerations: { ...deferredGenerations }, repoLines,
7379
7448
  forMs: forGiven === undefined ? null : Number(forGiven), publicUrl: publicUrlFlag ?? null, stopGraceMs, verbose: flags.has("verbose"),
7380
7449
  terminal: context.upSeams?.terminal ?? process.stdout.isTTY === true,
7381
7450
  // A restart waits out a crashed predecessor's lease (at most a minute), then recovers it: as a fresh `up` after a crash.
@@ -7385,6 +7454,12 @@ async function upCommand(flags, context) {
7385
7454
  onMessage: message => {
7386
7455
  if (message.type === "log")
7387
7456
  roleLine("worker")(message.line);
7457
+ else if (message.type === "deferred") {
7458
+ // Only ever narrows: a worker's word can't give back an authority this start no longer holds.
7459
+ const narrowed = upNarrowDeferred(deferredRepos, message.repos);
7460
+ deferredRepos.length = 0;
7461
+ deferredRepos.push(...narrowed);
7462
+ }
7388
7463
  else if (message.type === "repos") {
7389
7464
  // The console's admitted projects widen only to what the builder is bound to in the store (the worker binds
7390
7465
  // through the authenticated door before it reports): a worker's word alone admits nothing.
@@ -7467,11 +7542,14 @@ async function upCommand(flags, context) {
7467
7542
  pendingLinks.set(id, value => { clearTimeout(timer); resolveLink(value); });
7468
7543
  consoleRole?.send({ type: "sign-in-link", id, account });
7469
7544
  });
7470
- const home = homedir();
7471
- const file = approverPlan2.passwordFile === null ? null : approverPlan2.passwordFile.startsWith(`${home}/`) ? `~${approverPlan2.passwordFile.slice(home.length)}` : approverPlan2.passwordFile;
7545
+ // Every start says where the saved password is and the exact command that shows it, browser or not (Settings
7546
+ // revamp S4, D2). Only the path is printed, never what the file holds: absolute (a relative --db names this
7547
+ // directory), single-quoted (no ~ or $ expands), after `--` (a leading dash is never an option).
7548
+ const saved = approverPlan2.passwordFile === null ? null : resolve(approverPlan2.passwordFile);
7549
+ const showCommand = saved === null ? null : `cat -- '${saved.replaceAll("'", "'\\''")}'`;
7472
7550
  const signInWords = account === null
7473
7551
  ? `sign in as one of ${approverPlan2.approvers.join(", ")}`
7474
- : file === null ? `sign in as ${account} with your password` : `sign in as ${account}: the password is in ${file}`;
7552
+ : showCommand === null ? `sign in as ${account} with your password` : `sign in as ${account}`;
7475
7553
  write("");
7476
7554
  write(`Toolroll is on ${url}`);
7477
7555
  if (link !== null) {
@@ -7481,8 +7559,11 @@ async function upCommand(flags, context) {
7481
7559
  }
7482
7560
  else {
7483
7561
  write(`${signInWords.charAt(0).toUpperCase()}${signInWords.slice(1)}.`);
7484
- write("Ctrl-C stops it.");
7485
7562
  }
7563
+ if (showCommand !== null)
7564
+ write(`Your password: run \`${showCommand}\``);
7565
+ if (link === null)
7566
+ write("Ctrl-C stops it.");
7486
7567
  }
7487
7568
  }
7488
7569
  // A bounded run (--for) counts from readiness, so a slow start never eats the window it asked for.
@@ -11718,7 +11799,7 @@ function upNeedsOneProcess(options) {
11718
11799
  options.flowEveryMs !== undefined || options.platform !== undefined || options.fenceAvailable !== undefined || options.flowFetch !== undefined ||
11719
11800
  options.integrationIo !== undefined || options.shouldStop !== undefined || options.leadSeams !== undefined || options.waitSleep !== undefined ||
11720
11801
  options.releaseIo !== undefined || options.installBin !== undefined || options.tempRoots !== undefined || options.filingSizer !== undefined ||
11721
- options.now !== undefined;
11802
+ options.now !== undefined || options.folderLook !== undefined;
11722
11803
  }
11723
11804
  /** The entry a role child runs: the built sibling when installed, the TypeScript source through tsx in a checkout. */
11724
11805
  /**
@@ -12093,15 +12174,9 @@ async function runUpWorkerRole(context, config, port, build) {
12093
12174
  const pinnedRepos = new Set(config.pinnedRepos);
12094
12175
  const gitRun = context.gitRunner ?? ((file, args, opts) => run(file, [...args], { ...(opts?.cwd === undefined ? {} : { cwd: opts.cwd }), timeoutMs: 10_000 }));
12095
12176
  const proveRepo = async (input) => {
12096
- const top = await gitRun("git", ["rev-parse", "--show-toplevel"], { cwd: resolve(input) });
12097
- if (top.code !== 0)
12098
- return null;
12099
- try {
12100
- return realpathSync(top.stdout.trim());
12101
- }
12102
- catch {
12103
- return null;
12104
- }
12177
+ // The checkout's real path is read off the thread, under a deadline (folder-look.ts): a folder that doesn't answer
12178
+ // is unavailable, never a wait.
12179
+ return (await upProveSaved(gitRun, input))?.root ?? null;
12105
12180
  };
12106
12181
  // The proved set this worker builds; `up` relays each change to the console.
12107
12182
  const activeRepos = new Set(config.repos);
@@ -12286,8 +12361,17 @@ async function runUpWorkerRole(context, config, port, build) {
12286
12361
  await pause(follower === null ? 2_000 : 5_000);
12287
12362
  }
12288
12363
  })();
12289
- const dynamicCeiling = resolveCeiling(config.repos, config.projectRoots).ceiling;
12364
+ // The start's own projects and folders were resolved by `up`: taken as they are, never read again on this thread.
12365
+ const dynamicCeiling = resolveCeiling(config.repos, config.projectRoots, path => resolve(path)).ceiling;
12290
12366
  const rejected = new Set();
12367
+ // Saved lines' identities, resolved off the thread under a deadline (project-identity.ts): a line whose folder
12368
+ // doesn't answer is skipped this pass, and never holds up the others.
12369
+ const lineIdentities = projectIdentities();
12370
+ /** The saved line(s) each active project came from: `up`'s proof for the start's own, the line admitted for later ones. */
12371
+ const admittedLines = new Map(Object.entries(config.repoLines ?? {}));
12372
+ const deferred = upDeferredRetry(config.deferredRepos ?? [], input => upProveSaved(gitRun, input));
12373
+ const deferredGenerations = config.deferredGenerations ?? {};
12374
+ const reportDeferred = () => port.send({ type: "deferred", repos: deferred.list() });
12291
12375
  const registrySupervisor = (async () => {
12292
12376
  const sleep = (ms) => new Promise(resolveSleep => setTimeout(resolveSleep, ms));
12293
12377
  while (!stopping) {
@@ -12297,11 +12381,19 @@ async function runUpWorkerRole(context, config, port, build) {
12297
12381
  stop();
12298
12382
  break;
12299
12383
  }
12384
+ // A saved line removed while this start runs takes the start's authority for it with it, for good.
12385
+ if (deferred.keepOnly(loaded.repos))
12386
+ reportDeferred();
12387
+ const plain = loaded.repos.filter(one => !deferred.has(one));
12388
+ await lineIdentities.refresh(plain);
12300
12389
  // Removed from the saved list: stop building it and take it out of the console. A pinned start keeps its own.
12301
- const saved = new Set(loaded.repos.map(one => canonicalProject(one) ?? one));
12390
+ // Each project is judged by its own saved line (the one it was proved or admitted from, or itself), never by
12391
+ // whether some other folder answered: an unrelated folder that is away never keeps a removed project building.
12392
+ const saved = new Set(loaded.repos);
12302
12393
  let removed = false;
12303
12394
  for (const repo of [...activeRepos]) {
12304
- if (stopping || pinnedRepos.has(repo) || saved.has(repo))
12395
+ const lines = admittedLines.get(repo) ?? [repo];
12396
+ if (stopping || pinnedRepos.has(repo) || saved.has(repo) || lines.some(line => saved.has(line)))
12305
12397
  continue;
12306
12398
  activeRepos.delete(repo);
12307
12399
  removed = true;
@@ -12314,12 +12406,23 @@ async function runUpWorkerRole(context, config, port, build) {
12314
12406
  for (const candidate of loaded.repos) {
12315
12407
  if (stopping)
12316
12408
  break;
12317
- const canonical = canonicalProject(candidate);
12409
+ // Skipped at start because it didn't answer: connected on the start's own authority once it does.
12410
+ if (deferred.has(candidate)) {
12411
+ await connectDeferred(candidate);
12412
+ continue;
12413
+ }
12414
+ const canonical = lineIdentities.known(candidate);
12415
+ // Not answered yet: looked at again on the next pass.
12416
+ if (canonical === undefined)
12417
+ continue;
12318
12418
  // Still letting go of an earlier removal: it rejoins on the next pass after its watch has stopped.
12319
12419
  if (canonical !== null && (activeRepos.has(canonical) || releasedRepos.has(canonical)))
12320
12420
  continue;
12321
12421
  const approvedAdditions = context.additionalProjectRepos?.() ?? [];
12322
- const admittedCeiling = resolveCeiling([...dynamicCeiling.repos, ...approvedAdditions], dynamicCeiling.roots).ceiling;
12422
+ // A start serving nothing yet (its saved projects all away) is an empty ceiling, never the unscoped mode: a
12423
+ // name that matches no path stands in, so a line saved later still needs its recorded admission.
12424
+ const admittedRepos = [...dynamicCeiling.repos, ...approvedAdditions];
12425
+ const admittedCeiling = { repos: admittedRepos.length === 0 && dynamicCeiling.roots.length === 0 ? ["/\0nothing-admitted"] : admittedRepos, roots: dynamicCeiling.roots, canon: (path) => lineIdentities.known(path) ?? null };
12323
12426
  // A project added after this start (`repos add`, the lead, the console) carries its recorded admission; a
12324
12427
  // registry line alone is not authority. Either way the folder is proved to be that Git checkout again.
12325
12428
  const allowed = canonical !== null &&
@@ -12333,6 +12436,7 @@ async function runUpWorkerRole(context, config, port, build) {
12333
12436
  continue;
12334
12437
  }
12335
12438
  rejected.delete(candidate);
12439
+ admittedLines.set(canonical, [...new Set([...admittedLines.get(canonical) ?? [], candidate])]);
12336
12440
  await launchRepo(canonical, true);
12337
12441
  }
12338
12442
  if (!stopping)
@@ -12342,6 +12446,61 @@ async function runUpWorkerRole(context, config, port, build) {
12342
12446
  fatal = `the project registry stopped (${describe(error)})`;
12343
12447
  stop();
12344
12448
  });
12449
+ /**
12450
+ * A saved project the start couldn't reach, back now: connected only as the very folder that was saved (a parent
12451
+ * checkout found because the saved one lost its .git is another project, needing its own admission), and only while
12452
+ * the line is still saved, checked under the saved list's lock with the binding made inside it, so a removal racing
12453
+ * the proof always wins.
12454
+ */
12455
+ async function connectDeferred(candidate) {
12456
+ const proved = await deferred.retry(candidate);
12457
+ if (proved === null || stopping)
12458
+ return;
12459
+ // Only the very folder that was saved: the saved line must still resolve to itself, and git's checkout must be that
12460
+ // folder. A parent checkout, or the saved path now leading somewhere else, is another project needing admission.
12461
+ if (proved.line !== candidate || proved.root !== candidate) {
12462
+ deferred.revoke(candidate);
12463
+ reportDeferred();
12464
+ progress(proved.root !== proved.line
12465
+ ? `saved project not connected — ${candidate} came back as part of ${proved.root}; add it again to connect it`
12466
+ : `saved project not connected — ${candidate} now leads to ${proved.line}; add it again to connect it`);
12467
+ return;
12468
+ }
12469
+ const root = candidate;
12470
+ if (activeRepos.has(root) || releasedRepos.has(root)) {
12471
+ deferred.revoke(candidate);
12472
+ reportDeferred();
12473
+ return;
12474
+ }
12475
+ // Bound under the saved list's lock: the line is still saved AND has not been removed since this start read it
12476
+ // (its removal count unchanged, so a removal and a save again during the proof still counts as removed).
12477
+ let launching = null;
12478
+ let removedSince = false;
12479
+ await updateProjectRegistry(registryPath, current => {
12480
+ if (stopping || !deferred.has(candidate))
12481
+ return current;
12482
+ const count = registryRemovals(registryPath)?.[candidate] ?? (registryRemovals(registryPath) === null ? -2 : 0);
12483
+ if (!current.repos.includes(candidate) || count !== deferredGenerations[candidate]) {
12484
+ deferred.revoke(candidate);
12485
+ removedSince = true;
12486
+ return current;
12487
+ }
12488
+ deferred.revoke(candidate);
12489
+ admittedLines.set(root, [candidate]);
12490
+ launching = launchRepo(root, true);
12491
+ return current;
12492
+ });
12493
+ if (removedSince) {
12494
+ reportDeferred();
12495
+ progress(`saved project not connected — ${candidate} was removed while it was away; add it again to connect it`);
12496
+ return;
12497
+ }
12498
+ if (launching === null)
12499
+ return;
12500
+ reportDeferred();
12501
+ progress(`saved project is back — connecting ${root}`);
12502
+ await launching;
12503
+ }
12345
12504
  // Provider readiness (v47): this machine's non-spending observations, recorded under its own runner name ONCE at
12346
12505
  // start — so every route projection can say ready / unavailable / unknown per provider, and the dispatch gate can
12347
12506
  // halt a leg this machine cannot run. Production only: a test's injected agent runner is a fake, and its readiness
@@ -12530,3 +12689,72 @@ async function evalsAbandonCommand(flags, context) {
12530
12689
  return fail(write, json, command, ended.reason, ended.message, EXIT.refused);
12531
12690
  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
12691
  }
12692
+ /**
12693
+ * The saved projects an `up` start couldn't reach (upCommand): each is proved again every `everyMs` while the worker
12694
+ * runs, by `prove` (upProveSaved), so a folder that answers again connects without a restart. Only what the start itself
12695
+ * read from the saved list is here, and the set only shrinks: a line connected, removed from the saved list
12696
+ * (`keepOnly`) or come back as some other checkout (`revoke`) is gone for good, so a line saved again later needs its
12697
+ * recorded admission like any other addition. `retry` answers the proof when one is due and answered, else null.
12698
+ */
12699
+ export function upDeferredRetry(inputs, prove, everyMs = 5_000, now = Date.now) {
12700
+ // Wall time, not the store's clock (a test's may stand still): this is only how often a folder is asked.
12701
+ const nextAt = new Map(inputs.map(input => [input, now() + everyMs]));
12702
+ return {
12703
+ has: input => nextAt.has(input),
12704
+ list: () => [...nextAt.keys()],
12705
+ retry: async (input) => {
12706
+ const due = nextAt.get(input);
12707
+ if (due === undefined || now() < due)
12708
+ return null;
12709
+ nextAt.set(input, now() + everyMs);
12710
+ return prove(input);
12711
+ },
12712
+ revoke: input => { nextAt.delete(input); },
12713
+ keepOnly: saved => {
12714
+ const keep = new Set(saved);
12715
+ let changed = false;
12716
+ for (const input of [...nextAt.keys()])
12717
+ if (!keep.has(input)) {
12718
+ nextAt.delete(input);
12719
+ changed = true;
12720
+ }
12721
+ return changed;
12722
+ },
12723
+ };
12724
+ }
12725
+ /**
12726
+ * Whether a saved project's folder is a Git checkout now, read without blocking this thread: `git rev-parse
12727
+ * --show-toplevel` (bounded, its own process), then the real paths of the saved line and of the checkout, resolved off
12728
+ * the thread under a deadline (folder-look.ts). Null when it isn't one, or didn't answer.
12729
+ */
12730
+ export async function upProveSaved(gitRun, input, look = {}) {
12731
+ const top = await gitRun("git", ["rev-parse", "--show-toplevel"], { cwd: resolve(input) });
12732
+ if (top.code !== 0)
12733
+ return null;
12734
+ const answered = await lookIdentities([resolve(input), top.stdout.trim()], look);
12735
+ if (!answered.ok)
12736
+ return null;
12737
+ const line = answered.value.get(resolve(input)) ?? null, root = answered.value.get(top.stdout.trim()) ?? null;
12738
+ return line === null || root === null ? null : { line, root };
12739
+ }
12740
+ /** What `up` hands its next worker after the worker reports which saved projects it still holds start authority for:
12741
+ * only what both still hold. A worker's report can give up an authority, never add or give back one. */
12742
+ export function upNarrowDeferred(held, reported) {
12743
+ const kept = new Set(reported);
12744
+ return held.filter(one => kept.has(one));
12745
+ }
12746
+ /** Resolve the console's project identities after it is listening, never before: the deploy's health check and every
12747
+ * page answer at once, and each project shows up as its folder answers (project-identity.ts `refresh`, its looks a
12748
+ * few at a time under their deadline). Nothing waits on it; a pass that fails leaves those paths unresolved, which
12749
+ * fails closed, for the background pass to try again. */
12750
+ export function consoleIdentitiesInBackground(identities, paths) {
12751
+ void identities.refresh(paths).catch(() => false);
12752
+ }
12753
+ /** `config set` and `config clear` on the command line, through Settings → Models' one write path (model-choices.ts,
12754
+ * docs/plans/spend-dashboard.md slice 3), under the same target and resulting-configuration checks as the editor: the
12755
+ * person proved themselves with their own password or login at `proved.generation`; a machine credential (a remote
12756
+ * token, the lead's token) never changes which models run. The write re-proves them inside its transaction: still an
12757
+ * unrestricted instance operator, at that very generation, by a proof that still stands. */
12758
+ export function writeModelRow(store, proved, scope, knownProject, slot, to, now, extra = []) {
12759
+ return applyModelChoices(store, { scope, change: slot === null ? [] : [{ slot, to }], expected: null, strict: false, knownProject, extra }, { ...proved, live: () => true }, now);
12760
+ }
@@ -21,7 +21,7 @@ export type AdmissionActor = {
21
21
  };
22
22
  export type AdmissionFailure = {
23
23
  ok: false;
24
- reason: "unavailable" | "not-git" | "locked" | "registry" | "abandoned" | "failed";
24
+ reason: "unavailable" | "not-git" | "locked" | "registry" | "abandoned" | "failed" | "moved";
25
25
  message: string;
26
26
  };
27
27
  /** `remote`: the origin URL this add recorded as the project's trusted remote (null: none), and why one wasn't. */
@@ -60,6 +60,9 @@ export declare function admitProject(store: Store, input: {
60
60
  captureOrigin?: boolean;
61
61
  /** Explicit value from the owner/instance-operator CLI boundary. */
62
62
  remoteUrl?: string;
63
+ /** The caller's authority, asked again about the proved repo inside the locked registry update and inside the
64
+ * database transaction (the console: nothing it proved before an await moved, and the repo is still visible). */
65
+ stillAllowed?: (repo: string) => boolean;
63
66
  }): Promise<Admitted | AdmissionFailure>;
64
67
  /**
65
68
  * Remove one project from the lists and the builder. Its tasks, results and
@@ -61,13 +61,29 @@ export async function admitProject(store, input) {
61
61
  ? await originOf(repo, input.exec ?? run) : null;
62
62
  let remote = { url: store.projectRemote(repo), why: null };
63
63
  let registryHad = true;
64
+ const allowed = () => input.stillAllowed === undefined || input.stillAllowed(repo);
65
+ const moved = { ok: false, reason: "moved", message: `${projectName(repo)} wasn't added: a project folder changed while it was being checked. Try again.` };
64
66
  if (input.registryFile !== null) {
65
- const enrolled = await updateRepos(input.registryFile, repos => addRepos(repos, [repo]));
67
+ let refused = false;
68
+ let had = false;
69
+ const enrolled = await updateRepos(input.registryFile, repos => {
70
+ had = repos.includes(repo);
71
+ if (!allowed()) {
72
+ refused = true;
73
+ return repos;
74
+ }
75
+ return addRepos(repos, [repo]);
76
+ });
66
77
  if (!enrolled.ok)
67
78
  return { ok: false, reason: enrolled.reason, message: `${projectName(repo)} wasn't added: ${enrolled.message}` };
68
- registryHad = before !== null && !("error" in before) && before.repos.includes(repo);
79
+ if (refused)
80
+ return moved;
81
+ registryHad = had;
69
82
  }
70
83
  const added = store.transact(() => {
84
+ // Re-asked with the write; a refusal writes nothing here and takes back the registry line this call added below.
85
+ if (!allowed())
86
+ return null;
71
87
  const rowHad = store.listProjects().some(one => one.path === repo);
72
88
  const previous = store.projectRemote(repo);
73
89
  const first = !known && !store.projectAdmissionKnown(repo);
@@ -81,6 +97,11 @@ export async function admitProject(store, input) {
81
97
  store.recordAction({ at: input.now.toISOString(), actor: input.actor.label, repo, taskId: null, runId: null, action: PROJECT_ADDED, outcome: "added", source: "access", detail: `from ${input.actor.origin}` });
82
98
  return true;
83
99
  });
100
+ if (added === null) {
101
+ if (input.registryFile !== null && !registryHad)
102
+ await updateRepos(input.registryFile, repos => removeRepos(repos, [repo]));
103
+ return moved;
104
+ }
84
105
  return { ok: true, repo, name: projectName(repo), added, remote };
85
106
  }
86
107
  /**