toolroll 0.9.61 → 0.9.63

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 (117) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +51 -3
  3. package/dist/agent-fence.d.ts +155 -10
  4. package/dist/agent-fence.js +473 -38
  5. package/dist/browser/workspace.css +1 -1
  6. package/dist/browser/workspace.js +21 -21
  7. package/dist/browser-workspace.d.ts +28 -0
  8. package/dist/chat-actions.js +2 -2
  9. package/dist/check-levels.js +3 -0
  10. package/dist/claim.js +1 -1
  11. package/dist/cli-help.d.ts +1 -1
  12. package/dist/cli-help.js +1 -0
  13. package/dist/cli.js +31 -11
  14. package/dist/contracts/cli.d.ts +8 -0
  15. package/dist/contracts/cli.js +3 -1
  16. package/dist/contracts/console-api.d.ts +19 -1
  17. package/dist/contracts/console-api.js +2 -1
  18. package/dist/contracts/integration-metadata.d.ts +2 -1
  19. package/dist/contracts/integration-metadata.js +2 -1
  20. package/dist/desktop-update.d.ts +1 -1
  21. package/dist/dispatch.js +7 -4
  22. package/dist/doctor.d.ts +53 -0
  23. package/dist/doctor.js +427 -0
  24. package/dist/evidence-pack.d.ts +1 -1
  25. package/dist/evidence-pack.js +2 -2
  26. package/dist/exec.d.ts +3 -0
  27. package/dist/exec.js +5 -4
  28. package/dist/flow-code.d.ts +2 -2
  29. package/dist/flow-code.js +4 -15
  30. package/dist/flow-engine.js +11 -6
  31. package/dist/flow-triggers.d.ts +8 -4
  32. package/dist/flow-triggers.js +45 -25
  33. package/dist/flow-unattended-words.d.ts +28 -0
  34. package/dist/flow-unattended-words.js +50 -0
  35. package/dist/flow-unattended.d.ts +227 -0
  36. package/dist/flow-unattended.js +776 -0
  37. package/dist/flows-cli.d.ts +28 -1
  38. package/dist/flows-cli.js +55 -10
  39. package/dist/flows-ui.d.ts +2 -1
  40. package/dist/flows-ui.js +4 -1
  41. package/dist/google-mail.d.ts +1 -0
  42. package/dist/google-mail.js +61 -18
  43. package/dist/integrations-ui.d.ts +2 -1
  44. package/dist/integrations-ui.js +6 -3
  45. package/dist/integrations.d.ts +17 -3
  46. package/dist/integrations.js +38 -18
  47. package/dist/invoke.js +300 -168
  48. package/dist/landed-cleanup.d.ts +26 -12
  49. package/dist/landed-cleanup.js +199 -85
  50. package/dist/lead-contract.d.ts +1 -1
  51. package/dist/lead-contract.js +3 -3
  52. package/dist/lead-tools.js +11 -3
  53. package/dist/mcp-connect.d.ts +8 -7
  54. package/dist/mcp-connect.js +49 -51
  55. package/dist/oauth-run-token.d.ts +51 -0
  56. package/dist/oauth-run-token.js +127 -0
  57. package/dist/operate.js +74 -17
  58. package/dist/plan-auto.d.ts +11 -0
  59. package/dist/plan-auto.js +62 -11
  60. package/dist/process-recovery-eligibility.d.ts +1 -1
  61. package/dist/project-access.d.ts +3 -0
  62. package/dist/project-access.js +3 -0
  63. package/dist/project-admission.d.ts +10 -6
  64. package/dist/project-admission.js +33 -11
  65. package/dist/project-delete-ui.d.ts +1 -0
  66. package/dist/project-delete-ui.js +4 -1
  67. package/dist/project-delete.d.ts +3 -1
  68. package/dist/project-delete.js +10 -4
  69. package/dist/project-file.d.ts +3 -0
  70. package/dist/project-file.js +16 -0
  71. package/dist/project-tools.d.ts +99 -25
  72. package/dist/project-tools.js +394 -98
  73. package/dist/provider-home.d.ts +22 -0
  74. package/dist/provider-home.js +99 -0
  75. package/dist/provider.d.ts +17 -3
  76. package/dist/provider.js +34 -10
  77. package/dist/remote-main.d.ts +61 -0
  78. package/dist/remote-main.js +179 -0
  79. package/dist/scope.d.ts +1 -1
  80. package/dist/serve.js +11 -3
  81. package/dist/server/flows.js +27 -5
  82. package/dist/server/render-tasks.d.ts +3 -0
  83. package/dist/server/render-tasks.js +3 -0
  84. package/dist/server/route-table.js +3 -0
  85. package/dist/server/runtime.d.ts +1 -1
  86. package/dist/server/settings.js +14 -5
  87. package/dist/server/task-views.js +2 -0
  88. package/dist/storage-sweep-child.js +3 -1
  89. package/dist/storage-sweep-process.d.ts +14 -0
  90. package/dist/storage-sweep-process.js +37 -3
  91. package/dist/storage-sweep-schedule.d.ts +30 -0
  92. package/dist/storage-sweep-schedule.js +164 -0
  93. package/dist/storage-sweep.d.ts +6 -5
  94. package/dist/storage-sweep.js +10 -70
  95. package/dist/storage-ui.d.ts +2 -0
  96. package/dist/storage-ui.js +7 -4
  97. package/dist/store.d.ts +75 -7
  98. package/dist/store.js +241 -17
  99. package/dist/subagent-tools.d.ts +1 -1
  100. package/dist/surface.js +10 -6
  101. package/dist/task-base.d.ts +35 -0
  102. package/dist/task-base.js +51 -0
  103. package/dist/teardown-git.d.ts +14 -0
  104. package/dist/teardown-git.js +119 -0
  105. package/dist/tool-broker.d.ts +37 -0
  106. package/dist/tool-broker.js +369 -0
  107. package/dist/tool-launcher.js +5 -4
  108. package/dist/tool-secret-store.d.ts +170 -0
  109. package/dist/tool-secret-store.js +834 -0
  110. package/dist/toolroll-update.js +4 -2
  111. package/dist/tools-ui.d.ts +7 -1
  112. package/dist/tools-ui.js +17 -8
  113. package/dist/work-index.js +5 -3
  114. package/dist/worktree.d.ts +64 -6
  115. package/dist/worktree.js +171 -35
  116. package/package.json +3 -1
  117. package/scripts/provider-canary.mjs +210 -31
package/dist/operate.js CHANGED
@@ -19,9 +19,11 @@ import { matchesOutsideCheckpoint } from "./ledger-chain.js";
19
19
  import { CLEANUP_CHOICES, bytesWords, cleanupWords, diskBytes, parseCleanup, storageReport } from "./storage.js";
20
20
  import { removeStaleTestTemp, tempRoots, testTempFolders } from "./test-temp.js";
21
21
  import { storageSweepSupervisor } from "./storage-sweep-process.js";
22
- import { dailyStorageSweep, lastSweep, nextSweepAt, saveSweep, storageSweepOff as storageSweepOffNow, sweepDetails, sweepWords as storageSweepWords } from "./storage-sweep.js";
22
+ import { idleStorageSweep, sweepSchedule, sweepScheduleWords } from "./storage-sweep-schedule.js";
23
+ import { lastSweep, saveSweep, sweepDetails, sweepWords as storageSweepWords } from "./storage-sweep.js";
23
24
  import { removeShared, sharedDepsRoot, sharedUse, unusedShared } from "./shared-deps.js";
24
25
  import { cleanLandedBacklog, gitOf, landedBacklog, reconcileLanded, BACKLOG_DAYS } from "./landed-cleanup.js";
26
+ import { RemoteMains } from "./remote-main.js";
25
27
  import { checkoutPlan, cleanCheckouts, discardCheckout, finishedCheckouts, slimKeptCheckouts, whyWords } from "./checkout-cleanup.js";
26
28
  import { closeOutCheckouts, closeOutRuns, stoppedWords } from "./run-closeout.js";
27
29
  import { MIN_DAYS, RETENTION_KINDS, countWords, dailyRetention, isRetentionKind, lastSweepAt, parsePeriod, periodLabel, periodWords, retentionPlan, sweepWords } from "./retention.js";
@@ -50,6 +52,7 @@ import { readHooksBase, scheduleFromWords } from "./flow-triggers.js";
50
52
  import { sendSubagentSummaries } from "./subagent-admin.js";
51
53
  import { runRequestedUndos, sendSubagentWeeklies } from "./subagent-week.js";
52
54
  import { refreshConnections } from "./mcp-connect.js";
55
+ import { SECRETS_FIRST, startToolSecrets, toolSecretStorage } from "./project-tools.js";
53
56
  import { followDiscord } from "./discord.js";
54
57
  import { followTeams } from "./teams.js";
55
58
  import { followSlack } from "./slack.js";
@@ -182,6 +185,7 @@ import { createInterface } from "node:readline";
182
185
  import { propose, approve, addApprover, authenticateAccount, authenticateApprover as passwordApprover, describeScope, approvalOf, hashToken as hashApproverToken, fileAndSealUnderMode, modeFilingCoverage, acceptanceLinesToInput, parseAcceptanceCriteria, splitAcceptanceRubric, rubricIsPlaceholder, isCommitSha } from "./scope.js";
183
186
  import { CHAT_APPROVE_ALL, presetTerms, modeTermsJson, modeDigestOf, modeTermsFromJson, modeWords, MODE_MAX_DAYS } from "./modes.js";
184
187
  import { WorktreePool } from "./worktree.js";
188
+ import { baseStandingWords, freshBaseFor, taskBaseOf } from "./task-base.js";
185
189
  import { requestTaskStop, resumeTaskStop, taskControlOf } from "./task-control.js";
186
190
  import { worktreeAdoptionNotice } from "./worktree-notices.js";
187
191
  import { workIndexPage, workIndexTask, WorkIndexCursorError } from "./work-index.js";
@@ -812,6 +816,9 @@ async function runOperateLocal(command, argv, write, options = {}) {
812
816
  catch (error) {
813
817
  return databaseFailure(write, json, command, file, error);
814
818
  }
819
+ // Toolroll's folder for the secrets it holds, beside this database: made before any build can run.
820
+ if (file !== ":memory:")
821
+ startToolSecrets(store, file, () => new Date(), { eager: SECRETS_FIRST.has(command) });
815
822
  // The lead (a lead token, as --token or TOOLROLL_LEAD_TOKEN) acts for its owner in single commands; its work
816
823
  // pings nobody. Anyone else who signs in inside one of these commands is who acted. Services name nobody.
817
824
  const presentedFlag = text(flags, "token");
@@ -1052,6 +1059,19 @@ function telegramConversation(context, options = {}) {
1052
1059
  phoneOrigin: () => phoneOrigin(process.env, dirname(context.databaseFile), { serverOrigin: options.serverOrigin ?? null }),
1053
1060
  };
1054
1061
  }
1062
+ /** The attempt's history says where it started when that is news: moved to current main, or kept on an older one. */
1063
+ function noteBaseStanding(store, runId, standing, main, now) {
1064
+ if (standing == null)
1065
+ return;
1066
+ const commits = (count) => `${count} commit${count === 1 ? "" : "s"}`;
1067
+ if (standing.moved !== null) {
1068
+ store.addRunNote(runId, "Toolroll", `Started from current ${main} (${standing.main.slice(0, 8)}). The branch had no commits or changes of its own and was ${commits(standing.moved.behind)} behind at ${standing.moved.from.slice(0, 8)}.`, now);
1069
+ }
1070
+ else if (standing.kept !== null && standing.behind > 0) {
1071
+ const why = standing.kept === "has commits" ? "it has commits of its own" : standing.kept === "has changes" ? "it has uncommitted changes" : "its checkout is not on the task branch";
1072
+ store.addRunNote(runId, "Toolroll", `Based on an older ${main}: ${standing.head.slice(0, 8)} is ${commits(standing.behind)} behind. It was not moved because ${why}.`, now);
1073
+ }
1074
+ }
1055
1075
  /** A revision's verified start: its source build's sealed head, while the revision brief still binds that head and scope. */
1056
1076
  function revisionSourceHead(store, evidenceRoot, taskRef, revisionOf) {
1057
1077
  const source = store.revisionSourceOf(taskRef);
@@ -1924,6 +1944,7 @@ async function mcpCommand(file, flags, write, json) {
1924
1944
  if (!door.ok)
1925
1945
  return said(door.message);
1926
1946
  const store = door.store;
1947
+ startToolSecrets(store, file, () => new Date(), { eager: false });
1927
1948
  for (const suffix of ["", "-wal", "-shm"]) {
1928
1949
  try {
1929
1950
  chmodSync(`${file}${suffix}`, 0o600);
@@ -2728,6 +2749,8 @@ async function tickCommand(flags, context) {
2728
2749
  // A plan branch from an earlier plan (its checkout removed by storage retention) is reused, as its kept checkout was.
2729
2750
  reuseBranch: true,
2730
2751
  reclaim: { evidenceRoot: context.evidenceRoot },
2752
+ // Each plan starts from current main unless it revises earlier work, which keeps that work's head.
2753
+ ...freshBaseFor(store, id, planBranch, base),
2731
2754
  });
2732
2755
  if (!planLeased.ok) {
2733
2756
  release(store, lease, clock());
@@ -2751,6 +2774,7 @@ async function tickCommand(flags, context) {
2751
2774
  now: clock(),
2752
2775
  ...(planStamp === null ? {} : { route: planStamp }),
2753
2776
  });
2777
+ noteBaseStanding(store, planRunId, planLeased.base, base, clock());
2754
2778
  // The source is RECORDED on the attempt before the brief is even
2755
2779
  // composed: the exact bytes the planner reads, sealed as evidence,
2756
2780
  // and the filed scope's digest stamped as what this run was proved
@@ -3054,6 +3078,7 @@ async function tickCommand(flags, context) {
3054
3078
  continue;
3055
3079
  }
3056
3080
  }
3081
+ // An existing branch with no commits of its own starts from current main (a new one already does).
3057
3082
  const leased = await worktrees.lease({
3058
3083
  repo,
3059
3084
  branch,
@@ -3062,6 +3087,7 @@ async function tickCommand(flags, context) {
3062
3087
  now: clock(),
3063
3088
  ...(exists.code === 0 ? {} : { base: buildBase }),
3064
3089
  reclaim: { evidenceRoot: context.evidenceRoot },
3090
+ ...(exists.code === 0 ? freshBaseFor(store, id, branch, base) : {}),
3065
3091
  });
3066
3092
  if (!leased.ok) {
3067
3093
  // The task is fine; this machine's pool is not. Hand the claim back so
@@ -3122,6 +3148,7 @@ async function tickCommand(flags, context) {
3122
3148
  dispatched.push({ id, outcome: "skipped", reason: "admission-refused", detail: error instanceof Error ? error.message : String(error) });
3123
3149
  continue;
3124
3150
  }
3151
+ noteBaseStanding(store, runId, leased.base, base, clock());
3125
3152
  if (leased.resumedFromRun !== undefined) {
3126
3153
  store.addRunNote(runId, "Toolroll", `Recovered the ${leased.recoveryKind === "completed" ? "completed source draft" : "work-in-progress draft"} from interrupted attempt #${leased.resumedFromRun}. This fresh attempt is reviewing and verifying it; the safety patch is retained.`, clock());
3127
3154
  }
@@ -3448,10 +3475,15 @@ async function reconcileCommand(flags, context) {
3448
3475
  // Landed work (landed-cleanup.ts): finish teardowns a crash cut short, then mark newly landed results complete (a
3449
3476
  // ledger entry says why) and remove their clean checkouts and local build and plan branches.
3450
3477
  let landed = { completed: [], steps: [] };
3478
+ // Completion trusts only the recorded remote's main, fetched by this process into a private folder it removes after.
3479
+ const remotes = new RemoteMains(store, context.gitRunner, context.evidenceRoot);
3451
3480
  try {
3452
- landed = await reconcileLanded(store, worktrees, gitOf(context.gitRunner), repo, context.evidenceRoot, clock);
3481
+ landed = await reconcileLanded(store, worktrees, gitOf(context.gitRunner), remotes, repo, context.evidenceRoot, clock);
3453
3482
  }
3454
3483
  catch { /* the next pass tries again */ }
3484
+ finally {
3485
+ remotes.close();
3486
+ }
3455
3487
  const landedGone = landed.steps.filter(one => one.outcome === "removed");
3456
3488
  // Checkout cleanup (Settings → Storage): finished work's clean checkouts go as the setting says (by default when
3457
3489
  // the task is complete or cancelled); their branches stay. Each removal is in the ledger.
@@ -3469,15 +3501,19 @@ async function reconcileCommand(flags, context) {
3469
3501
  retention = null; /* the next pass tries again */
3470
3502
  }
3471
3503
  const retained = retention === null ? [] : retention.counts.filter(one => one.count > 0);
3472
- // The daily storage sweep (storage-sweep.ts): stale test temp folders, orphans Toolroll left running, finished
3504
+ // The storage sweep (storage-sweep.ts): stale test temp folders, orphans Toolroll left running, finished
3473
3505
  // checkouts, extra staged runtimes and dependency installs unused for a week; saved for Settings → Storage.
3506
+ // Only the long-lived service (its watch passes `sweepOwner`) starts one, once due and while nothing is running or
3507
+ // waiting (storage-sweep-schedule.ts). A one-shot reconcile would exit and lose the claimed sweep, so it leaves it.
3474
3508
  // Launch only: report a result here only if it already completed. Later results save independently.
3475
3509
  let storageSwept = null;
3476
- try {
3477
- storageSwept = dailyStorageSweep(store, { databaseFile: context.databaseFile, pool: worktrees, tempRoots: context.tempRoots ?? tempRoots(), ...(context.sweepOwner === undefined ? {} : { owner: context.sweepOwner }) }, clock);
3478
- }
3479
- catch {
3480
- storageSwept = null; /* the next pass tries again */
3510
+ if (context.sweepOwner !== undefined) {
3511
+ try {
3512
+ storageSwept = idleStorageSweep(store, { databaseFile: context.databaseFile, pool: worktrees, tempRoots: context.tempRoots ?? tempRoots(), owner: context.sweepOwner }, clock);
3513
+ }
3514
+ catch {
3515
+ storageSwept = null; /* the next pass tries again */
3516
+ }
3481
3517
  }
3482
3518
  const swept = storageSwept === null ? [] : storageSwept.parts.filter(one => one.count > 0);
3483
3519
  const nothing = retained.length === 0 &&
@@ -6023,7 +6059,8 @@ async function runWatchLoop(args) {
6023
6059
  if (!Number.isSafeInteger(reconcileEveryMs) || reconcileEveryMs < 1 || reconcileEveryMs > 2_147_483_647) {
6024
6060
  throw new Error("--reconcile-every must be an integer from 1 to 2147483647 milliseconds");
6025
6061
  }
6026
- const incarnation = randomUUID();
6062
+ // The owner carries lifetime without changing the lease schema. Bounded watches cannot host automatic sweeps.
6063
+ const incarnation = `${runFor === null ? "toolroll-service:" : ""}${randomUUID()}`;
6027
6064
  const lease = acquireWatchLeaseAuthed(store, { runner, token, repo, owner: incarnation, ttlMs: WATCH_LEASE_MS }, new Date());
6028
6065
  if (!lease.ok) {
6029
6066
  return {
@@ -6158,7 +6195,9 @@ async function runWatchLoop(args) {
6158
6195
  },
6159
6196
  run: async () => {
6160
6197
  const maintenanceLines = [];
6161
- const maintenanceContext = { ...quietContext, sweepOwner, write: line => maintenanceLines.push(line) };
6198
+ const maintenanceContext = { ...quietContext, ...(runFor === null ? { sweepOwner } : {}), write: line => maintenanceLines.push(line) };
6199
+ if (runFor !== null)
6200
+ delete maintenanceContext.sweepOwner;
6162
6201
  const code = await reconcileCommand(passFlags(), maintenanceContext);
6163
6202
  if (code !== EXIT.ok)
6164
6203
  throw new Error(maintenanceLines.join(" "));
@@ -7212,6 +7251,9 @@ async function upCommand(flags, context) {
7212
7251
  }
7213
7252
  const loopFlagsFor = (repo) => {
7214
7253
  const copy = new Map();
7254
+ // A bounded `up` must not advertise its child watches as an unbounded sweep service either.
7255
+ if (forGiven !== undefined)
7256
+ copy.set("for", forGiven);
7215
7257
  copy.set("runner", runnerName);
7216
7258
  copy.set("token", runnerToken);
7217
7259
  copy.set("repo", repo);
@@ -8443,7 +8485,8 @@ async function integrationsCommand(positional, flags, context) {
8443
8485
  const counts = { connected: 0, "not-set-up": 0, broken: 0 };
8444
8486
  for (const one of list)
8445
8487
  counts[one.state] += 1;
8446
- return succeed(context.write, context.json, command, { integrations: list, counts }, () => renderIntegrations(list));
8488
+ const secrets = toolSecretStorage();
8489
+ return succeed(context.write, context.json, command, { integrations: list, counts, secrets: { kind: secrets.kind, words: secrets.words, ...(secrets.problem === undefined ? {} : { problem: secrets.problem }) } }, () => renderIntegrations(list, secrets));
8447
8490
  }
8448
8491
  async function waitTask(positional, flags, context) {
8449
8492
  const command = "task wait";
@@ -9054,6 +9097,7 @@ function showTask(positional, context) {
9054
9097
  const shared = listed === null ? null : { headline: listed.status.label, sentence: listed.status.detail };
9055
9098
  const primaryAction = listed === null ? work?.primaryAction ?? null : listed.primaryAction;
9056
9099
  const steeringLive = store.steeringLiveBuild(ref.id, now) !== null;
9100
+ const base = taskBaseOf(store, id);
9057
9101
  const detail = {
9058
9102
  task,
9059
9103
  work: work === null || listed === null ? work : { ...work, status: { ...work.status, label: listed.status.label, detail: listed.status.detail, tone: listed.status.tone }, primaryAction },
@@ -9069,6 +9113,8 @@ function showTask(positional, context) {
9069
9113
  scope,
9070
9114
  approval: approvalOf(scope),
9071
9115
  route: taskRouteView(routed, readiness),
9116
+ // Fresh base: the commit this task's work starts from, and how far current main has moved past it.
9117
+ base,
9072
9118
  runs: runs.map(one => ({ ...one, route: store.runRoute(one.id) })),
9073
9119
  deliverable: ref.deliverable,
9074
9120
  report: readVerifiedReport(store, context.evidenceRoot, ref.id),
@@ -9125,6 +9171,7 @@ function showTask(positional, context) {
9125
9171
  ]
9126
9172
  : [` report: ${detail.report.problem} (run ${detail.report.run})`]),
9127
9173
  ...pullRequestLines(detail.pullRequest),
9174
+ ...(base === null ? [] : [` base: ${baseStandingWords(base)}`]),
9128
9175
  ...(detail.blockedBy.length > 0 ? [` waits for ${detail.blockedBy.join(", ")}`] : []),
9129
9176
  ...(detail.position === null
9130
9177
  ? []
@@ -11165,15 +11212,15 @@ async function storageCommand(positional, flags, context) {
11165
11212
  const sweepFacts = () => {
11166
11213
  const last = lastSweep(store);
11167
11214
  const automatic = last?.source === "automatic" ? last : lastSweep(store, "automatic");
11168
- return { last, lastAutomatic: automatic, next: nextSweepAt(store) };
11215
+ return { last, lastAutomatic: automatic, schedule: sweepSchedule(store, context.clock()) };
11169
11216
  };
11170
11217
  const sweepLines = (facts) => {
11171
11218
  const when = (at) => new Date(at).toLocaleString("en-US", { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
11172
11219
  const one = facts.last;
11173
- const head = one === null ? (facts.next === null && storageSweepOffNow() ? "Automatic sweep: off." : "Automatic sweep: not run yet; it runs once a day.")
11220
+ const head = one === null ? "Not swept yet."
11174
11221
  : `Last swept ${when(one.at)}${one.source === "manual" ? ` by ${one.actor}` : " automatically"}: ${storageSweepWords(one.parts)}`;
11175
11222
  const auto = one !== null && one.source === "manual" && facts.lastAutomatic !== null ? [`Last automatic sweep ${when(facts.lastAutomatic.at)}: ${storageSweepWords(facts.lastAutomatic.parts)}`] : [];
11176
- return [head, ...auto, ...(one === null ? [] : sweepDetails(one.parts).slice(0, 8).map(line => ` ${line}`))];
11223
+ return [head, ...auto, `Automatic sweep: ${sweepScheduleWords(facts.schedule, when)}`, ...(one === null ? [] : sweepDetails(one.parts).slice(0, 8).map(line => ` ${line}`))];
11177
11224
  };
11178
11225
  if (action === undefined) {
11179
11226
  const plan = await checkoutPlan(store, pool, context.clock(), { manual: true });
@@ -11289,6 +11336,16 @@ async function storageCommand(positional, flags, context) {
11289
11336
  * checkouts and local branches go; so do Toolroll's own branches no task uses whose work landed. A preview changes
11290
11337
  * nothing; --yes takes an instance operator, records every teardown first, then carries it out. */
11291
11338
  async function landedCommand(yes, operator, refused, pool, context) {
11339
+ // Completion trusts only the recorded remote's main, fetched by this process into a private folder it removes after.
11340
+ const remotes = new RemoteMains(context.store, context.gitRunner, context.evidenceRoot);
11341
+ try {
11342
+ return await landedCommandWith(remotes, yes, operator, refused, pool, context);
11343
+ }
11344
+ finally {
11345
+ remotes.close();
11346
+ }
11347
+ }
11348
+ async function landedCommandWith(remotes, yes, operator, refused, pool, context) {
11292
11349
  const command = "storage clean --landed";
11293
11350
  const git = gitOf(context.gitRunner);
11294
11351
  const plurals = (count, one, many = `${one}s`) => `${count} ${count === 1 ? one : many}`;
@@ -11300,7 +11357,7 @@ async function landedCommand(yes, operator, refused, pool, context) {
11300
11357
  branches: plan.results.reduce((sum, one) => sum + one.targets.filter(target => target.kind === "branch").length, 0) + plan.branches.length,
11301
11358
  });
11302
11359
  if (!yes) {
11303
- const plan = await landedBacklog(context.store, git, context.evidenceRoot, context.clock());
11360
+ const plan = await landedBacklog(context.store, git, remotes, context.evidenceRoot, context.clock());
11304
11361
  const n = counts(plan);
11305
11362
  const anything = n.results + plan.branches.length + plan.pending > 0;
11306
11363
  return succeed(context.write, context.json, command, { preview: true, cutoff: plan.cutoff, results: plan.results, branches: plan.branches, kept: plan.kept, pending: plan.pending }, () => [
@@ -11315,8 +11372,8 @@ async function landedCommand(yes, operator, refused, pool, context) {
11315
11372
  const actor = await operator();
11316
11373
  if (actor === null)
11317
11374
  return refused("cleans up landed work");
11318
- const plan = await landedBacklog(context.store, git, context.evidenceRoot, context.clock());
11319
- const done = await cleanLandedBacklog(context.store, pool, git, context.evidenceRoot, context.clock, plan, actor);
11375
+ const plan = await landedBacklog(context.store, git, remotes, context.evidenceRoot, context.clock());
11376
+ const done = await cleanLandedBacklog(context.store, pool, git, remotes, context.evidenceRoot, context.clock, plan, actor);
11320
11377
  const gone = done.steps.filter(one => one.outcome === "removed" || one.outcome === "already gone");
11321
11378
  const stays = done.steps.filter(one => one.outcome === "waits" || one.outcome === "kept");
11322
11379
  return succeed(context.write, context.json, command, { completed: done.completed, steps: done.steps, refused: done.refused, kept: plan.kept }, () => [
@@ -1,4 +1,5 @@
1
1
  import type { Store } from "./store.js";
2
+ import { type UnattendedGrant } from "./flow-unattended.js";
2
3
  export declare const PLAN_AUTO_SCHEMA = "CREATE TABLE IF NOT EXISTS plan_authorization (\n task_id TEXT PRIMARY KEY REFERENCES task(id),\n source_digest TEXT NOT NULL,\n scope_digest TEXT NOT NULL,\n mode_digest TEXT NOT NULL,\n signed_by TEXT NOT NULL,\n created_at TEXT NOT NULL\n);";
3
4
  /** Join the console's authority-free proposal to the authenticated filing
4
5
  * road in one transaction. Planning defers the seal until verified ingestion. */
@@ -7,6 +8,16 @@ export declare function applyModeToNewFiling(store: Store, taskId: string, actor
7
8
  * code. This authorizes the exact filed contract, not arbitrary future
8
9
  * planner text. Rewrites, mode renewal, expiry and revocation fail closed. */
9
10
  export declare function authorizePlanUnderMode(store: Store, taskId: string, actor: string, now: Date): boolean;
11
+ /**
12
+ * A flow card's new filing under its flow's "runs on its own" grant (flow-unattended.ts), inside the engine's filing
13
+ * transaction, which has just proved the grant covers this card. A plain build that plans is pre-authorized for an
14
+ * unchanged plan only; anything else is sealed now, under a distinct `grant` basis. False (the filing then waits for a
15
+ * person, as it always did) when the filed terms aren't the safe, fixed ones the grant was signed for.
16
+ */
17
+ export declare function sealFlowFilingUnderGrant(store: Store, taskId: string, grant: UnattendedGrant, card: {
18
+ flow: number;
19
+ id: number;
20
+ }, now: Date): boolean;
10
21
  /** Read-side status only; finalization still proves the authority anew. */
11
22
  export declare function planAutoPending(store: Store, taskId: string, now: Date): boolean;
12
23
  /** Inside the verified planner finalization transaction, after the source,
package/dist/plan-auto.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { releaseUnattendedRun, reserveUnattendedRun, unattendedGrantStands } from "./flow-unattended.js";
1
2
  import { modeTermsFromJson } from "./modes.js";
2
3
  import { plannerContractOf, plannerSourceDigest } from "./planner-source.js";
3
4
  import { modeFilingCoverage, propose } from "./scope.js";
@@ -49,13 +50,68 @@ export function authorizePlanUnderMode(store, taskId, actor, now) {
49
50
  const scope = source.contract.scope;
50
51
  if (scope.proposedVia !== null || scope.acceptance.length === 0 || scope.touches.length === 0 || scope.terms.profileState !== "resolved" || store.scopeSealed(taskId))
51
52
  return false;
52
- store.raw().prepare(`INSERT INTO plan_authorization(task_id,source_digest,scope_digest,mode_digest,signed_by,created_at) VALUES (?,?,?,?,?,?)
53
- ON CONFLICT(task_id) DO UPDATE SET source_digest=excluded.source_digest,scope_digest=excluded.scope_digest,mode_digest=excluded.mode_digest,signed_by=excluded.signed_by,created_at=excluded.created_at`)
53
+ store.raw().prepare(`INSERT INTO plan_authorization(task_id,source_digest,scope_digest,mode_digest,signed_by,created_at,basis) VALUES (?,?,?,?,?,?,'mode')
54
+ ON CONFLICT(task_id) DO UPDATE SET source_digest=excluded.source_digest,scope_digest=excluded.scope_digest,mode_digest=excluded.mode_digest,signed_by=excluded.signed_by,created_at=excluded.created_at,basis='mode'`)
54
55
  .run(taskId, plannerSourceDigest(source.contract), scope.digest, mode.digest, actor, now.toISOString());
55
56
  store.recordAction({ at: now.toISOString(), actor, repo: ref.repo, taskId, runId: null, action: "unchanged plan pre-authorized", outcome: "authorized", source: "work" });
56
57
  return true;
57
58
  });
58
59
  }
60
+ /** A scope whose resolved profile or route runs with full permissions: a flow grant never seals one. */
61
+ function escalatedPosture(store, taskId) {
62
+ const row = store.raw().prepare("SELECT profile_json, proposed_route_json FROM task_scope WHERE task_id = ?").get(taskId);
63
+ const text = `${row?.["profile_json"] ?? ""} ${row?.["proposed_route_json"] ?? ""}`;
64
+ return /"(?:bypassPermissions|danger-full-access|yolo)"/.test(text);
65
+ }
66
+ /**
67
+ * A flow card's new filing under its flow's "runs on its own" grant (flow-unattended.ts), inside the engine's filing
68
+ * transaction, which has just proved the grant covers this card. A plain build that plans is pre-authorized for an
69
+ * unchanged plan only; anything else is sealed now, under a distinct `grant` basis. False (the filing then waits for a
70
+ * person, as it always did) when the filed terms aren't the safe, fixed ones the grant was signed for.
71
+ */
72
+ export function sealFlowFilingUnderGrant(store, taskId, grant, card, now) {
73
+ const ref = store.lookupRef(taskId);
74
+ const scope = store.getScope(taskId);
75
+ if (ref === null || scope === null || scope.proposedVia != null || scope.profileState !== "resolved" || escalatedPosture(store, taskId))
76
+ return false;
77
+ if (!unattendedGrantStands(store, grant.digest, grant.signedBy, ref.repo, scope.touches, now))
78
+ return false;
79
+ // The day's run is taken first, in one capped statement; a filing that then can't be sealed gives it back.
80
+ if (!reserveUnattendedRun(store, grant, taskId, now))
81
+ return false;
82
+ let done = false;
83
+ if (ref.plan === "requested") {
84
+ const source = plannerContractOf(store, taskId);
85
+ if (plainPlan(store, taskId) && source.ok && source.contract.scope !== null && source.contract.revision === null && source.contract.scope.acceptance.length > 0) {
86
+ store.raw().prepare(`INSERT INTO plan_authorization(task_id,source_digest,scope_digest,mode_digest,signed_by,created_at,basis) VALUES (?,?,?,?,?,?,'grant')
87
+ ON CONFLICT(task_id) DO UPDATE SET source_digest=excluded.source_digest,scope_digest=excluded.scope_digest,mode_digest=excluded.mode_digest,signed_by=excluded.signed_by,created_at=excluded.created_at,basis='grant'`)
88
+ .run(taskId, plannerSourceDigest(source.contract), source.contract.scope.digest, grant.digest, grant.signedBy, now.toISOString());
89
+ done = true;
90
+ }
91
+ }
92
+ else {
93
+ done = store.sealScopeApproval(taskId, grant.signedBy, now, {}, { kind: "grant", modeDigest: grant.digest });
94
+ }
95
+ if (!done) {
96
+ releaseUnattendedRun(store, grant, taskId);
97
+ return false;
98
+ }
99
+ store.recordAction({ at: now.toISOString(), actor: grant.signedBy, repo: ref.repo, taskId, runId: null, source: "work",
100
+ action: ref.plan === "requested" ? "flow ran on its own: unchanged plan pre-authorized" : "flow ran on its own: approved", outcome: "authorized", detail: `flow #${card.flow} · card ${card.id}` });
101
+ return true;
102
+ }
103
+ /** Whether the authority that armed this plan authorization still stands (read side and finalization alike). */
104
+ function armedAuthorityStands(store, row, repo, now) {
105
+ const by = String(row["signed_by"]), digest = String(row["mode_digest"]);
106
+ if (row["basis"] === "grant") {
107
+ const scope = store.getScope(String(row["task_id"]));
108
+ return unattendedGrantStands(store, digest, by, repo, scope?.touches ?? [], now) ? { basis: "grant", digest } : null;
109
+ }
110
+ const mode = store.activeMode(repo, now);
111
+ const terms = mode === null ? null : modeTermsFromJson(mode.termsJson);
112
+ return mode !== null && terms?.planAuto === true && terms.autoApproveFiling && mode.digest === digest && mode.signedBy === by && store.accountCanAccess(by, repo)
113
+ ? { basis: "mode", digest } : null;
114
+ }
59
115
  /** Read-side status only; finalization still proves the authority anew. */
60
116
  export function planAutoPending(store, taskId, now) {
61
117
  const ref = store.lookupRef(taskId);
@@ -64,11 +120,8 @@ export function planAutoPending(store, taskId, now) {
64
120
  const row = store.raw().prepare("SELECT * FROM plan_authorization WHERE task_id=?").get(taskId);
65
121
  if (row === undefined)
66
122
  return false;
67
- const mode = store.activeMode(ref.repo, now);
68
- const terms = mode === null ? null : modeTermsFromJson(mode.termsJson);
69
123
  const source = plannerContractOf(store, taskId);
70
- return mode !== null && terms?.planAuto === true && terms.autoApproveFiling && mode.digest === row["mode_digest"] && mode.signedBy === row["signed_by"] &&
71
- store.accountCanAccess(mode.signedBy, ref.repo) && source.ok && plannerSourceDigest(source.contract) === row["source_digest"];
124
+ return armedAuthorityStands(store, row, ref.repo, now) !== null && source.ok && plannerSourceDigest(source.contract) === row["source_digest"];
72
125
  }
73
126
  /** Inside the verified planner finalization transaction, after the source,
74
127
  * artifact, contract changes and lease fence have all been checked. */
@@ -80,13 +133,11 @@ export function sealUnchangedPlanUnderMode(store, taskId, sourceDigest, runId, e
80
133
  store.raw().prepare("DELETE FROM plan_authorization WHERE task_id = ?").run(taskId);
81
134
  const ref = store.lookupRef(taskId);
82
135
  const scope = store.getScope(taskId);
83
- const mode = ref?.repo == null ? null : store.activeMode(ref.repo, now);
84
- const terms = mode === null ? null : modeTermsFromJson(mode.termsJson);
85
136
  const by = String(authorization["signed_by"]);
137
+ const authority = ref?.repo == null ? null : armedAuthorityStands(store, authorization, ref.repo, now);
86
138
  const covered = eligible && plainPlan(store, taskId) && sourceDigest === authorization["source_digest"] && scope?.digest === authorization["scope_digest"] &&
87
- mode !== null && mode.digest === authorization["mode_digest"] && mode.signedBy === by &&
88
- terms?.planAuto === true && terms.autoApproveFiling && store.accountCanAccess(by, ref?.repo ?? null);
89
- const approved = covered && store.sealScopeApproval(taskId, by, now, {}, { kind: "mode", modeDigest: mode.digest });
139
+ authority !== null && (authority.basis === "mode" || !escalatedPosture(store, taskId));
140
+ const approved = covered && store.sealScopeApproval(taskId, by, now, {}, { kind: authority.basis, modeDigest: authority.digest });
90
141
  store.recordAction({ at: now.toISOString(), actor: by, repo: ref?.repo ?? null, taskId, runId, action: "plan auto-approval", outcome: approved ? "approved" : "needs approval", source: "work" });
91
142
  return approved;
92
143
  }
@@ -50,7 +50,7 @@ export declare function preparedCandidateObserverGapEligibility(store: Store, in
50
50
  digest: string;
51
51
  approvedAt: string;
52
52
  approvedBy: string;
53
- approvalBasis: "chat" | "mode" | "password";
53
+ approvalBasis: "chat" | "grant" | "mode" | "password";
54
54
  modeDigest: string | null;
55
55
  };
56
56
  readonly command: {
@@ -5,6 +5,9 @@ export declare function normalizeProjectAccess(value: unknown): string[] | null;
5
5
  /** Corrupt or missing authority never falls back to instance access. */
6
6
  export declare function readProjectAccess(value: unknown): ProjectAccess;
7
7
  export declare function projectAccessAllows(access: ProjectAccess, repo: string | null): boolean;
8
+ /** SQL: projectAccessAllows for an `approver` row (`alias`) and a project path expression, for the dispatch belts. A
9
+ * null list reaches every project; an unreadable one reaches none. */
10
+ export declare const accountReachesSql: (alias: string, repo: string) => string;
8
11
  /** A server-proved resource, never an unchecked header or submitted path.
9
12
  * CLI ceremonies must pass their resource explicitly; global ceremonies
10
13
  * without a project refuse project-scoped credentials. */
@@ -28,6 +28,9 @@ export function readProjectAccess(value) {
28
28
  export function projectAccessAllows(access, repo) {
29
29
  return access === null || (repo !== null && access.includes(canonicalProject(repo) ?? resolve(repo)));
30
30
  }
31
+ /** SQL: projectAccessAllows for an `approver` row (`alias`) and a project path expression, for the dispatch belts. A
32
+ * null list reaches every project; an unreadable one reaches none. */
33
+ export const accountReachesSql = (alias, repo) => `(${alias}.projects_json IS NULL OR (json_valid(${alias}.projects_json) AND EXISTS (SELECT 1 FROM json_each(${alias}.projects_json) WHERE json_each.value = ${repo})))`;
31
34
  /** A server-proved resource, never an unchecked header or submitted path.
32
35
  * CLI ceremonies must pass their resource explicitly; global ceremonies
33
36
  * without a project refuse project-scoped credentials. */
@@ -24,11 +24,16 @@ export type AdmissionFailure = {
24
24
  reason: "unavailable" | "not-git" | "locked" | "registry" | "abandoned" | "failed";
25
25
  message: string;
26
26
  };
27
+ /** `remote`: the origin URL this add recorded as the project's trusted remote (null: none), and why one wasn't. */
27
28
  export type Admitted = {
28
29
  ok: true;
29
30
  repo: string;
30
31
  name: string;
31
32
  added: boolean;
33
+ remote: {
34
+ url: string | null;
35
+ why: string | null;
36
+ };
32
37
  };
33
38
  export type Released = {
34
39
  ok: true;
@@ -43,12 +48,7 @@ export declare function proveProjectRoot(path: string, exec?: ProjectExec): Prom
43
48
  ok: true;
44
49
  repo: string;
45
50
  } | AdmissionFailure>;
46
- /**
47
- * Admit one project. Idempotent: adding a saved project again changes
48
- * nothing and writes no ledger row. The registry is written first, under
49
- * its lock; the project row and its ledger row then commit together, so a
50
- * retry after a failure finishes the job and records it exactly once.
51
- */
51
+ /** Registry first, then the project and its audit records in one transaction. Re-adds preserve trust. */
52
52
  export declare function admitProject(store: Store, input: {
53
53
  /** null: a console with no machine registry (a bare `serve`) keeps only the project list. */
54
54
  registryFile: string | null;
@@ -56,6 +56,10 @@ export declare function admitProject(store: Store, input: {
56
56
  actor: AdmissionActor;
57
57
  now: Date;
58
58
  exec?: ProjectExec;
59
+ /** Only the authorized CLI add opts in; console/open/onboarding never discover a remote. */
60
+ captureOrigin?: boolean;
61
+ /** Explicit value from the owner/instance-operator CLI boundary. */
62
+ remoteUrl?: string;
59
63
  }): Promise<Admitted | AdmissionFailure>;
60
64
  /**
61
65
  * Remove one project from the lists and the builder. Its tasks, results and
@@ -15,7 +15,12 @@ import { userInfo } from "node:os";
15
15
  import { dirname, join, resolve } from "node:path";
16
16
  import { run } from "./exec.js";
17
17
  import { canonicalProject, projectName, sameRepo } from "./project.js";
18
+ import { remoteUrlProblem } from "./remote-main.js";
18
19
  import { addRepos, loadProjectRegistry, removeRepos, updateRepos } from "./repos.js";
20
+ const ADMISSION_GIT = {
21
+ envAllowlist: ["PATH", "HOME", "TMPDIR", "LANG", "LC_ALL"],
22
+ env: { GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null", GIT_NO_REPLACE_OBJECTS: "1" },
23
+ };
19
24
  export const PROJECT_ADDED = "project added";
20
25
  export const PROJECT_REMOVED = "project removed";
21
26
  /** The checkout root a folder belongs to, canonical; never a shell. */
@@ -26,40 +31,57 @@ export async function proveProjectRoot(path, exec = run) {
26
31
  const canonical = canonicalProject(path);
27
32
  if (canonical === null)
28
33
  return { ok: false, reason: "unavailable", message: `${path} isn't a folder on this computer.` };
29
- const top = await exec("git", ["rev-parse", "--show-toplevel"], { cwd: canonical, timeoutMs: 5_000 });
34
+ const top = await exec("git", ["rev-parse", "--show-toplevel"], { cwd: canonical, timeoutMs: 5_000, ...ADMISSION_GIT });
30
35
  const repo = top.code === 0 ? canonicalProject(top.stdout.trim()) : null;
31
36
  if (repo === null)
32
37
  return { ok: false, reason: "not-git", message: `${path} isn't a Git repository. Run git init there first, or choose a folder inside one.` };
33
38
  return { ok: true, repo };
34
39
  }
35
- /**
36
- * Admit one project. Idempotent: adding a saved project again changes
37
- * nothing and writes no ledger row. The registry is written first, under
38
- * its lock; the project row and its ledger row then commit together, so a
39
- * retry after a failure finishes the job and records it exactly once.
40
- */
40
+ /** Read only on first CLI admission; never rediscover trust from an existing project's config. */
41
+ async function originOf(repo, exec) {
42
+ const read = await exec("git", ["config", "--local", "--no-includes", "--get", "remote.origin.url"], { cwd: repo, timeoutMs: 5_000, ...ADMISSION_GIT });
43
+ const url = read.code === 0 ? read.stdout.trim() : "";
44
+ if (url === "")
45
+ return { url: null, why: "it has no origin remote" };
46
+ const problem = remoteUrlProblem(url);
47
+ return problem === null ? { url, why: null } : { url: null, why: problem };
48
+ }
49
+ /** Registry first, then the project and its audit records in one transaction. Re-adds preserve trust. */
41
50
  export async function admitProject(store, input) {
42
51
  const proved = await proveProjectRoot(input.path, input.exec);
43
52
  if (!proved.ok)
44
53
  return proved;
45
54
  const { repo } = proved;
55
+ if (input.remoteUrl !== undefined && (input.actor.origin === "console" || remoteUrlProblem(input.remoteUrl) !== null)) {
56
+ return { ok: false, reason: "failed", message: "Only an owner or instance operator can record a network remote with repos add --remote <url>. Use an HTTPS or SSH URL without credentials." };
57
+ }
58
+ const before = input.registryFile === null ? null : await loadProjectRegistry(input.registryFile);
59
+ const known = store.projectAdmissionKnown(repo) || (before !== null && !("error" in before) && before.repos.includes(repo));
60
+ const discovered = !known && input.captureOrigin === true && input.actor.origin !== "console"
61
+ ? await originOf(repo, input.exec ?? run) : null;
62
+ let remote = { url: store.projectRemote(repo), why: null };
46
63
  let registryHad = true;
47
64
  if (input.registryFile !== null) {
48
- const before = await loadProjectRegistry(input.registryFile);
49
65
  const enrolled = await updateRepos(input.registryFile, repos => addRepos(repos, [repo]));
50
66
  if (!enrolled.ok)
51
67
  return { ok: false, reason: enrolled.reason, message: `${projectName(repo)} wasn't added: ${enrolled.message}` };
52
- registryHad = !("error" in before) && before.repos.includes(repo);
68
+ registryHad = before !== null && !("error" in before) && before.repos.includes(repo);
53
69
  }
54
70
  const added = store.transact(() => {
55
71
  const rowHad = store.listProjects().some(one => one.path === repo);
56
- store.upsertProject(repo, projectName(repo), input.now);
72
+ const previous = store.projectRemote(repo);
73
+ const first = !known && !store.projectAdmissionKnown(repo);
74
+ remote = input.remoteUrl !== undefined ? { url: input.remoteUrl, why: null }
75
+ : first && discovered !== null ? discovered : { url: previous, why: previous === null ? "no remote recorded; use repos add --remote <url>" : null };
76
+ store.upsertProject(repo, projectName(repo), input.now, { remoteUrl: remote.url });
77
+ if (input.remoteUrl !== undefined)
78
+ store.recordProjectRemote(input.actor.label, repo, previous, input.remoteUrl, input.now);
57
79
  if (rowHad && registryHad)
58
80
  return false;
59
81
  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}` });
60
82
  return true;
61
83
  });
62
- return { ok: true, repo, name: projectName(repo), added };
84
+ return { ok: true, repo, name: projectName(repo), added, remote };
63
85
  }
64
86
  /**
65
87
  * Remove one project from the lists and the builder. Its tasks, results and
@@ -8,6 +8,7 @@ import { type Html } from "./html.js";
8
8
  import { type ProjectHoldings } from "./project-delete.js";
9
9
  export declare const PROJECT_DELETE_CSS: string;
10
10
  export type ProjectSettingsView = {
11
+ remoteUrl?: string | null;
11
12
  repo: string;
12
13
  name: string;
13
14
  holdings: ProjectHoldings;
@@ -57,7 +57,10 @@ const note = (notice) => notice.problem ? html `<p class="problem" role="alert">
57
57
  /** The project's page: its path, what Toolroll holds for it, and (for an instance operator) Delete project. */
58
58
  export function projectSettingsHtml(view, notice) {
59
59
  const held = html `<p>Toolroll holds ${holdingsWords(view.holdings)} for <strong>${view.name}</strong>.</p>`;
60
- const head = html `${note(notice)}<p class="path">${view.repo}</p>${held}${buildsHtml(view)}${accessHtml(view)}`;
60
+ const remote = html `<section class="build-access recorded-remote" aria-labelledby="recorded-remote-title"><h2 id="recorded-remote-title">Recorded remote</h2>${view.remoteUrl
61
+ ? html `<p class="path">${view.remoteUrl}</p>`
62
+ : html `<p>No remote recorded. Landed work waits for your review.</p>`}<details><summary>Change remote</summary><p>An owner or instance operator can run:</p><p class="path">toolroll repos add &lt;project&gt; --remote &lt;url&gt;</p></details></section>`;
63
+ const head = html `${note(notice)}<p class="path">${view.repo}</p>${held}${remote}${buildsHtml(view)}${accessHtml(view)}`;
61
64
  if (!view.canDelete)
62
65
  return html `<section class="project-settings">${head}<p class="meta">An instance operator can delete a project.</p></section>`;
63
66
  const body = view.running.length > 0
@@ -1,4 +1,5 @@
1
- import { type ExecResult } from "./exec.js";
1
+ import type { ExecResult } from "./exec.js";
2
+ import type { Runner } from "./worktree.js";
2
3
  import { type Store } from "./store.js";
3
4
  /** What Toolroll names the branches it makes: toolroll/…, and standing-orders/… from before the rename. */
4
5
  export declare const OWN_BRANCH = "toolroll/";
@@ -52,4 +53,5 @@ export declare function deleteProject(store: Store, repo: string, options: {
52
53
  evidenceRoot: string | null;
53
54
  poolRoot: string | null;
54
55
  git?: GitRunner;
56
+ runner?: Runner;
55
57
  }): Promise<ProjectDeleteResult>;