toolroll 0.8.1 → 0.9.0

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 (59) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/dist/agent-onboard.d.ts +7 -0
  3. package/dist/agent-onboard.js +23 -5
  4. package/dist/browser/workspace.css +1 -1
  5. package/dist/browser/workspace.js +21 -20
  6. package/dist/browser-workspace.d.ts +10 -0
  7. package/dist/chat-delivery.js +51 -3
  8. package/dist/chat-quiet.d.ts +28 -0
  9. package/dist/chat-quiet.js +161 -0
  10. package/dist/checkout-cleanup.d.ts +84 -0
  11. package/dist/checkout-cleanup.js +245 -0
  12. package/dist/cli.d.ts +1 -1
  13. package/dist/cli.js +3 -0
  14. package/dist/digest-times.d.ts +3 -0
  15. package/dist/digest-times.js +7 -0
  16. package/dist/dispose.js +2 -0
  17. package/dist/export.js +1 -1
  18. package/dist/first-run.d.ts +4 -1
  19. package/dist/first-run.js +2 -1
  20. package/dist/integrations-ui.d.ts +13 -0
  21. package/dist/integrations-ui.js +65 -0
  22. package/dist/integrations.d.ts +113 -0
  23. package/dist/integrations.js +526 -0
  24. package/dist/mcp.js +26 -0
  25. package/dist/operate.d.ts +5 -6
  26. package/dist/operate.js +254 -25
  27. package/dist/project-delete.js +1 -0
  28. package/dist/prompt.d.ts +7 -2
  29. package/dist/prompt.js +36 -7
  30. package/dist/publish.d.ts +11 -0
  31. package/dist/publish.js +9 -2
  32. package/dist/pull-request-flow.d.ts +140 -0
  33. package/dist/pull-request-flow.js +440 -0
  34. package/dist/pull-request-schema.d.ts +5 -0
  35. package/dist/pull-request-schema.js +21 -0
  36. package/dist/pull-request-ui.d.ts +23 -0
  37. package/dist/pull-request-ui.js +55 -0
  38. package/dist/pulls.d.ts +5 -0
  39. package/dist/pulls.js +11 -0
  40. package/dist/serve.d.ts +6 -0
  41. package/dist/serve.js +323 -26
  42. package/dist/setup-guide.d.ts +2 -2
  43. package/dist/storage-ui.d.ts +10 -0
  44. package/dist/storage-ui.js +67 -0
  45. package/dist/storage.d.ts +15 -1
  46. package/dist/storage.js +62 -7
  47. package/dist/store.d.ts +66 -5
  48. package/dist/store.js +160 -19
  49. package/dist/surface.js +2 -0
  50. package/dist/telegram-progress.d.ts +1 -0
  51. package/dist/telegram-progress.js +1 -1
  52. package/dist/telegram.js +95 -15
  53. package/dist/toolroll-update-ui.d.ts +2 -0
  54. package/dist/toolroll-update-ui.js +1 -1
  55. package/dist/toolroll-update.d.ts +26 -6
  56. package/dist/toolroll-update.js +188 -50
  57. package/dist/worktree.d.ts +24 -10
  58. package/dist/worktree.js +84 -44
  59. package/package.json +1 -1
package/dist/serve.js CHANGED
@@ -56,6 +56,8 @@ import { prepareWorkspaceRevision, WorkspaceValidatorCache } from "./workspace-r
56
56
  import { workIndexPage, workCountsByProject, WorkIndexCursorError } from "./work-index.js";
57
57
  import { openWorkDecisionOf } from "./work-summary.js";
58
58
  import { assignmentOf, checkAssignmentAsOperator } from './assignment.js';
59
+ import { pullRequestSettingsHtml, PULL_REQUEST_SETTINGS_CSS } from './pull-request-ui.js';
60
+ import { checkPublishing, completeAndOpenPullRequest, mergeAsPerson, pullRequestBlocker, pullRequestViewOf, publishingOf, saveMergeSettings, savePublishing, MERGE_METHODS } from './pull-request-flow.js';
59
61
  import { leadBriefHtml, LEAD_CONTEXT_CSS } from './lead-context.js';
60
62
  import { assignmentCatchUp } from './assignment-brief.js';
61
63
  import { assignmentCardOf, assignmentStatusOf, assignmentSummaryHtml, assignmentWithEvidence, ASSIGNMENT_CSS } from './assignment-ui.js';
@@ -167,7 +169,8 @@ import { dirname } from "node:path";
167
169
  import { AsyncLocalStorage } from "node:async_hooks";
168
170
  import { loadOrCreateVapidKeys, validatePushEndpoint } from "./push.js";
169
171
  import { parseGithubRepo, previewGithubRepo, cloneGithubRepo, listGithubRepos, isLargeRepo } from "./onboard.js";
170
- import { verifiedAuthor, LEAD_THREAD } from "./store.js";
172
+ import { verifiedAuthor, LEAD_THREAD, isDigestTime } from "./store.js";
173
+ import { digestTimes } from "./digest-times.js";
171
174
  import { updateRepos, addRepos, removeRepos } from "./repos.js";
172
175
  import { run as execRun } from "./exec.js";
173
176
  import { findFirstTasks, firstResultWords, firstRunSteps, firstTaskSuggestions, SANDBOX_COMMAND } from "./first-run.js";
@@ -201,11 +204,16 @@ import { accessFromGroups, accountNameFor, discoverOidc, exchangeOidcCode, newOi
201
204
  import { readSsoSettings, removeSsoSettings, saveSsoSettings, SSO_CALLBACK, ssoChangeWords } from "./sso-settings.js";
202
205
  import { monitoringChange, readMonitoring, saveMonitoring } from "./monitoring-settings.js";
203
206
  import { MONITORING_CSS, monitoringHtml, signingSecretHtml } from "./monitoring-ui.js";
207
+ import { INTEGRATIONS_CSS, integrationsHtml } from "./integrations-ui.js";
208
+ import { createIntegrationMonitor } from "./integrations.js";
204
209
  import { BACKUP_CSS, backupHtml } from "./backup-ui.js";
205
210
  import { BACKUP_EVERY_HOURS, MAX_KEEP, backupFolderOf, backupNow, checkBackupFolder, defaultBackupFolder } from "./backup.js";
206
211
  import { prometheusMetrics } from "./metrics.js";
207
212
  import { SPEND_CSS, spendCsv, spendHtml } from "./spend-ui.js";
208
213
  import { RETENTION_CSS, retentionHtml } from "./retention-ui.js";
214
+ import { STORAGE_CSS, storageHtml } from "./storage-ui.js";
215
+ import { bytesWords, parseCleanup } from "./storage.js";
216
+ import { checkoutPlan, cleanCheckouts, discardCheckout, previewDigest } from "./checkout-cleanup.js";
209
217
  import { UPDATES_CSS, newerThan, updateStepsHtml, updatesHtml, updatesScript } from "./toolroll-update-ui.js";
210
218
  import { abandonRuntimeUpdate, launchRuntimeUpdate, markWhatsNewSeen, prepareRuntimeUpdate, requestRuntimeUpdateCancel, runningWorkWords, runtimeUpdateStatus, runtimeUpdateTerminal, currentRuntime } from "./toolroll-update.js";
211
219
  import { latestVersionNow } from "./releases.js";
@@ -393,6 +401,8 @@ export function createDecisionServer(options) {
393
401
  }
394
402
  }
395
403
  const clock = options.clock ?? (() => new Date());
404
+ /** Task checkouts, for Settings → Storage: the pool's own root, or the folder beside the database. */
405
+ const storagePool = (databaseFile) => new WorktreePool(store, { root: options.poolRoot ?? join(dirname(databaseFile), "worktrees") });
396
406
  const install = installMethod(options.installBin);
397
407
  /** What the last daily check found (the cache only, never the network), or nothing while checks are off. */
398
408
  const updateFacts = () => {
@@ -433,6 +443,19 @@ export function createDecisionServer(options) {
433
443
  catch { /* the pause keeps its own state */ }
434
444
  return value;
435
445
  };
446
+ // Settings → Integrations: checked in the background with a short cache, as the first-run suggestions are.
447
+ const integrations = createIntegrationMonitor(() => ({
448
+ store,
449
+ dir: options.configDir ?? null,
450
+ telegramTokenFile: options.telegramTokenFile ?? null,
451
+ env: process.env,
452
+ repos: managedRepos(),
453
+ gh: options.firstTaskRunner ?? execRun,
454
+ checkConnection: connectionCheck,
455
+ toolHome,
456
+ clock,
457
+ ...options.integrationIo,
458
+ }));
436
459
  const modelCatalog = openRouterModelsCache(options.modelCatalogFetcher);
437
460
  const modelSeams = {
438
461
  home: providerHome,
@@ -1411,7 +1434,7 @@ export function createDecisionServer(options) {
1411
1434
  !url.searchParams.has('task') && !url.searchParams.has('result') && !url.searchParams.has('request')) {
1412
1435
  const key = createHash('sha256').update(JSON.stringify([workspaceIncarnation, who.name,
1413
1436
  who.session.generation, who.session.csrf, who.session.project, who.session.projectRevision,
1414
- admissionList(), url.pathname + url.search])).digest('hex');
1437
+ admissionList(), url.pathname + url.search, localSignIn?.signedIn ?? null])).digest('hex');
1415
1438
  const revision = workspaceRevision.current(), now = clock();
1416
1439
  const prior = workspaceValidators.get(key);
1417
1440
  if (prior && prior.revision === revision && prior.expiresAt > now.getTime() && request.headers['if-none-match'] === prior.etag) {
@@ -1561,7 +1584,7 @@ export function createDecisionServer(options) {
1561
1584
  }
1562
1585
  const read = new Set(["/settings/skills", "/settings/knowledge", "/settings", "/settings/learning", "/recipes", "/recipes/run", "/recipes/start", "/recipes/new", "/recipes/edit", "/recipes/from-task", "/recipes/preview", "/recipes/export", "/", "/inbox", "/work", "/projects", "/people", "/ledger", "/ledger/export", "/next", "/board", "/tasks", "/tasks/new", "/runs", "/review", "/done", "/routines", "/menu"]);
1563
1586
  const write = new Set(["/settings/skills/import", "/settings/skills/change", "/settings/skills/revise", "/settings/knowledge/change", "/settings/knowledge/refresh", "/settings/learning/change", "/recipes/prepare", "/recipes/preview", "/recipes/import", "/recipes/save", "/recipes/launch", "/projects/select", "/tasks/add", "/routines/add"]);
1564
- const task = matchTaskPath(path, request.method === "GET" ? "(/evidence)?$" : "/(hold|unhold|requeue|cancel|scope|approve|plan|plan-edit|next|reopen|steer|accept-proof|accept-revision|reject-revision|route|retry-review|complete|stop|resume-arm|resume)$");
1587
+ const task = matchTaskPath(path, request.method === "GET" ? "(/evidence)?$" : "/(hold|unhold|requeue|cancel|scope|approve|plan|plan-edit|next|reopen|steer|accept-proof|accept-revision|reject-revision|route|retry-review|complete|merge|stop|resume-arm|resume)$");
1565
1588
  const resource = request.method === "GET"
1566
1589
  ? /^\/(?:r|d)\/[0-9]{1,15}(?:\/evidence\/[0-9]{1,15})?$/.test(path) || /^\/routines\/[0-9]{1,15}$/.test(path) || path === "/flows" || /^\/flows\/[0-9]{1,15}(\/insights|\/runs\/[0-9]{1,15}\/[0-9]{1,15})?$/.test(path)
1567
1590
  : /^\/d\/[0-9]{1,15}\/answer$/.test(path) || /^\/routines\/[0-9]{1,15}\/(approve|refresh|pause|resume|run-now)$/.test(path) || path === "/flows/new" || /^\/flows\/[0-9]{1,15}\/(save|cards|archive|scripts)$/.test(path) || /^\/flows\/[0-9]{1,15}\/cards\/[0-9]{1,15}\/(move|decide|cancel|comment|assign|watch)$/.test(path) || /^\/flows\/[0-9]{1,15}\/triggers(\/[0-9]{1,15}\/(pause|resume|remove|check|press|renew|secret|share|unshare))?$/.test(path) || /^\/r\/[0-9]{1,15}\/(note|comment|revise|draft-repair)$/.test(path);
@@ -3300,7 +3323,7 @@ export function createDecisionServer(options) {
3300
3323
  back: { href: roomId ? "/chat?conversation=" + encodeURIComponent(roomId) : taskChatHref(focusTask?.id ?? ""), label: "Back to chat" },
3301
3324
  }) + (who.role === 'approver' && focusTask?.assignment?.state === 'ready-to-check'
3302
3325
  && focusTask.assignment.receipt?.runId === resultRun.id
3303
- ? completionForm(focusTask.assignment.receipt.taskId, resultRun.id, focusTask.assignment.receipt.digest, who.session.csrf) : '');
3326
+ ? completionForm(focusTask.assignment.receipt.taskId, resultRun.id, focusTask.assignment.receipt.digest, who.session.csrf, pullRequestTargetOf(resultRun.id)) : '');
3304
3327
  const focusProblem = requestedTask !== null && focusTask === null
3305
3328
  ? "That task is not available in this workspace."
3306
3329
  : requestedResult !== null && focusTask !== null && resultRun === null
@@ -3616,6 +3639,13 @@ export function createDecisionServer(options) {
3616
3639
  sessions: store.webSessions(everyone ? null : who.name).map(one => ({ ...one, here: one.idHash === here })), tokens: store.apiTokens(everyone ? null : who.name) };
3617
3640
  return sendScreen(response, 200, screen("Sessions & tokens", `<p><a href="/settings">Settings</a></p><h1>Sessions &amp; tokens</h1>${credentialsHtml(view, who.session.csrf, { said: url.searchParams.get("said"), problem: url.searchParams.get("problem") })}`, { chrome: chromeFor(project, "settings") }));
3618
3641
  }
3642
+ // Settings → Integrations: which integrations work. The list is the last checks; a render never waits on one.
3643
+ if (url.pathname === "/settings/integrations") {
3644
+ if (who.via !== "cookie" || who.role !== "approver" || restricted() || !options.configDir)
3645
+ return refuse(response, who, 403, "An installation approver sees integrations.", "/settings");
3646
+ const list = integrations.list();
3647
+ return sendScreen(response, 200, screen("Integrations", `<p><a href="/settings">Settings</a></p><h1>Integrations</h1>${integrationsHtml(list, who.session.csrf, { said: url.searchParams.get("said"), problem: url.searchParams.get("problem"), checking: integrations.checking })}`, { chrome: chromeFor(project, "settings") }));
3648
+ }
3619
3649
  // v104: Settings → Monitoring, where the audit stream and traces go. An instance operator's page.
3620
3650
  if (url.pathname === "/settings/monitoring") {
3621
3651
  if (who.via !== "cookie" || !store.isInstanceOperator(who.name) || !options.configDir)
@@ -3635,7 +3665,7 @@ export function createDecisionServer(options) {
3635
3665
  const current = options.updates?.current ?? PACKAGE_VERSION;
3636
3666
  // While update checks are off the page asks npm nothing until Check now.
3637
3667
  const checksOff = updateChecksOff(process.env, options.configDir ?? dirname(databaseFile)).off && url.searchParams.get("check") !== "now";
3638
- const view = { current, latest: active ? { version: current } : checksOff ? { off: true } : await latestReleaseFor(options.updates?.latest), method: options.updates?.method ?? installMethod(), journal: status.journal, running: status.running, whatsNew: status.whatsNew, csrf: who.session.csrf };
3668
+ const view = { current, latest: active ? { version: current } : checksOff ? { off: true } : await latestReleaseFor(options.updates?.latest), method: options.updates?.method ?? installMethod(), journal: status.journal, running: status.running, whatsNew: status.whatsNew, rollbackTo: status.lastUpdate?.to === current ? status.lastUpdate.from : null, csrf: who.session.csrf };
3639
3669
  return sendScreen(response, 200, screen("Updates", `<p><a href="/settings">Settings</a></p><h1>Updates</h1>${updatesHtml(view, { said: url.searchParams.get("said"), problem: url.searchParams.get("problem") })}`, { chrome: chromeFor(project, "settings"), ...(active ? { functional: { script: updatesScript(), fetches: true } } : {}) }));
3640
3670
  }
3641
3671
  // v105: Settings → Retention, how long each kind of data is kept. An instance operator's page.
@@ -3645,6 +3675,29 @@ export function createDecisionServer(options) {
3645
3675
  const view = { periods: store.retentionPeriods(), next: retentionPlan(store, evidenceRoot, now).counts, lastSweep: lastSweepAt(store), csrf: who.session.csrf };
3646
3676
  return sendScreen(response, 200, screen("Retention", `<p><a href="/settings">Settings</a></p><h1>Retention</h1>${retentionHtml(view, { said: url.searchParams.get("said"), problem: url.searchParams.get("problem") })}`, { chrome: chromeFor(project, "settings") }));
3647
3677
  }
3678
+ // Settings → Storage: what task checkouts use, cleaning up now (the preview behind the password), and when a
3679
+ // finished task's clean checkout goes by itself. An instance operator's page.
3680
+ if (url.pathname === "/settings/storage") {
3681
+ const databaseFile = store.databaseFile();
3682
+ if (who.via !== "cookie" || !store.isInstanceOperator(who.name) || databaseFile === null)
3683
+ return refuse(response, who, 403, "An instance operator looks after storage.", "/settings");
3684
+ const plan = await checkoutPlan(store, storagePool(databaseFile), now, { manual: true });
3685
+ return sendScreen(response, 200, screen("Storage", `<p><a href="/settings">Settings</a></p><h1>Storage</h1>${storageHtml({ plan, csrf: who.session.csrf }, { said: url.searchParams.get("said"), problem: url.searchParams.get("problem") })}`, { chrome: chromeFor(project, "settings") }));
3686
+ }
3687
+ // Settings → Projects → Pull requests: one project's setup. Off, the checks run on open, so the page says
3688
+ // exactly what turning on would do, or what to fix first.
3689
+ if (url.pathname === "/settings/pull-requests") {
3690
+ const repo = url.searchParams.get("repo") ?? "";
3691
+ const known = [...new Set([...managedRepos(), ...store.knownRepos(), ...store.listProjects().map(one => one.path)])];
3692
+ if (who.via !== "cookie" || !known.includes(repo) || !visible(repo))
3693
+ return refuse(response, who, 404, "No such project.", "/projects");
3694
+ const publishing = publishingOf(store, repo);
3695
+ const canChange = who.role === "approver" && !store.isDemo();
3696
+ const check = publishing.on || !canChange ? null : await checkPublishing(repo, options.publishExec === undefined ? {} : { exec: options.publishExec });
3697
+ const html = pullRequestSettingsHtml({ repo, name: projectName(repo), csrf: who.session.csrf, canChange, publishing, check,
3698
+ said: url.searchParams.get("said"), problem: url.searchParams.get("problem") });
3699
+ return sendScreen(response, 200, screen("Pull requests", `<p><a href="/projects">Projects</a></p><h1>Pull requests</h1>${html}`, { chrome: chromeFor(project, "projects") }));
3700
+ }
3648
3701
  // Sprint 8: Settings → Backups, how the last backup went and the schedule. An instance operator's page.
3649
3702
  if (url.pathname === "/settings/backups") {
3650
3703
  const databaseFile = store.databaseFile();
@@ -3819,6 +3872,9 @@ export function createDecisionServer(options) {
3819
3872
  // The installation fact: when the first Ready result arrived, measured from the first account.
3820
3873
  const at = store.firstSuccessAt(now), since = store.installationStartedAt();
3821
3874
  return at === null || since === null ? null : firstResultWords(since, at);
3875
+ })(), (() => {
3876
+ const chosen = store.notificationPreference(who.name);
3877
+ return { mode: chosen.mode, digestAt: chosen.digestAt };
3822
3878
  })()));
3823
3879
  }
3824
3880
  if (url.pathname === "/push/key") {
@@ -3873,17 +3929,19 @@ export function createDecisionServer(options) {
3873
3929
  /**
3874
3930
  * Whether a coding agent is signed in where the work runs. This machine's
3875
3931
  * own checks (the ones Settings makes) speak only for a worker on this
3876
- * machine; a worker elsewhere speaks through its own readiness report, and
3932
+ * machine, or for the first one while none is registered yet (it is started
3933
+ * here); a worker elsewhere speaks through its own readiness report, and
3877
3934
  * the console never claims to have checked that machine (finding 15).
3878
3935
  * Nothing here waits on a probe: this machine's answer is the last one
3879
- * seen, refreshed in the background.
3936
+ * seen, refreshed in the background, and null until the first one is in.
3880
3937
  */
3881
3938
  function agentSignedIn(now) {
3882
3939
  const here = hostname();
3883
- const workers = store.listRunners().filter(one => one.retiredAt === null && runnerAlive(one, now));
3884
- if ((options.localRunner !== undefined || workers.some(one => one.host === here)) && localAgentSignedIn())
3940
+ const registered = store.listRunners().filter(one => one.retiredAt === null);
3941
+ const workers = registered.filter(one => runnerAlive(one, now));
3942
+ if (workers.some(one => one.host !== here && store.providerReadiness(one.name).some(seen => seen.state === "ready")))
3885
3943
  return true;
3886
- return workers.some(one => one.host !== here && store.providerReadiness(one.name).some(seen => seen.state === "ready"));
3944
+ return options.localRunner !== undefined || registered.length === 0 || workers.some(one => one.host === here) ? localAgentSignedIn() : false;
3887
3945
  }
3888
3946
  let localSignIn = null;
3889
3947
  let localSignInChecking = false;
@@ -3895,7 +3953,7 @@ export function createDecisionServer(options) {
3895
3953
  .catch(() => { })
3896
3954
  .finally(() => { localSignInChecking = false; });
3897
3955
  }
3898
- return localSignIn?.signedIn ?? false;
3956
+ return localSignIn?.signedIn ?? null;
3899
3957
  }
3900
3958
  /** First tasks per project: the last ones found (safe generic ones until then), re-read in the background at most
3901
3959
  * every ten minutes. A render never waits on `gh` or `git grep`. */
@@ -3923,7 +3981,7 @@ export function createDecisionServer(options) {
3923
3981
  return {
3924
3982
  steps,
3925
3983
  suggestions: taskFiled || repo === null ? [] : firstTasksFor(repo),
3926
- sandbox: steps.find(one => one.key === "agent")?.done === false ? SANDBOX_COMMAND : null,
3984
+ sandbox: steps.find(one => one.key === "agent" && !one.done && !one.checking) ? SANDBOX_COMMAND : null,
3927
3985
  };
3928
3986
  }
3929
3987
  // ---- fleet chat (v13) ----------------------------------------------------
@@ -4887,7 +4945,7 @@ export function createDecisionServer(options) {
4887
4945
  peeks[one.path] = peekOf(one.path);
4888
4946
  for (const path of candidates)
4889
4947
  peeks[path] = peekOf(path);
4890
- return sendScreen(response, status, projectsPage(chromeFor(open, "projects"), recent, [...candidates], open, csrf, problem, !restricted() && unscopedMode, !restricted() && (ceiling.roots.length > 0 || unscopedMode), onboardState, peeks, returnTo));
4948
+ return sendScreen(response, status, projectsPage(chromeFor(open, "projects"), recent, [...candidates], open, csrf, problem, !restricted() && unscopedMode, !restricted() && (ceiling.roots.length > 0 || unscopedMode), onboardState, peeks, returnTo, who.via === "cookie" ? (path) => publishingOf(store, path).on : undefined));
4891
4949
  }
4892
4950
  /** The task screen, shared by the GET and by every refusal that re-renders it. */
4893
4951
  /**
@@ -5345,6 +5403,41 @@ export function createDecisionServer(options) {
5345
5403
  : completionReceiptView(store, latest, store.artifactsFor(latest.id), evidenceRoot, reviewFactsFor(latest.id)),
5346
5404
  };
5347
5405
  }
5406
+ /** The newest pull request any version of this task opened through Complete; else, for a result completed
5407
+ * without one, the offer to open it (an approver's browser session only). */
5408
+ /** Where "Complete and open a pull request" would open one for this result, or null when it isn't offered. */
5409
+ function pullRequestTargetOf(runId) {
5410
+ const run = store.getRun(runId);
5411
+ const publishing = publishingOf(store, run === null ? null : store.refById(run.taskRef)?.repo ?? null);
5412
+ if (!publishing.on || publishing.legacy || store.publicationForRun(runId) !== null || pullRequestBlocker(store, runId) !== null)
5413
+ return null;
5414
+ return `${publishing.githubRepo} into ${publishing.base}`;
5415
+ }
5416
+ function taskPullRequestOf(taskId, who) {
5417
+ const family = familyOf(taskId);
5418
+ const rootId = family?.root.id ?? taskId;
5419
+ const ids = family === null ? [taskId] : [...family.versions.map(one => one.id)].reverse();
5420
+ const repo = store.lookupRef(taskId)?.repo ?? null;
5421
+ const publishing = publishingOf(store, repo);
5422
+ const target = publishing.on ? publishing.base : null;
5423
+ for (const id of ids) {
5424
+ const ref = store.lookupRef(id);
5425
+ if (ref === null)
5426
+ continue;
5427
+ for (const run of store.runsFor(ref.id)) {
5428
+ const view = pullRequestViewOf(store, run.id);
5429
+ if (view !== null)
5430
+ return { taskId: rootId, view: { ...view, canMerge: view.canMerge && who.role === "approver" && who.via === "cookie" }, offer: null, target };
5431
+ }
5432
+ }
5433
+ if (!publishing.on || who.via !== "cookie" || who.role !== "approver")
5434
+ return null;
5435
+ const assignment = assignmentOf(store, taskId, clock(), { principal: "operator", repos: admissionList(), includeUnplaced: visible(null) }, evidenceRoot);
5436
+ const receipt = assignment?.receipt ?? null;
5437
+ if (assignment?.state !== "complete" || receipt === null || store.publicationForRun(receipt.runId) !== null || pullRequestBlocker(store, receipt.runId) !== null)
5438
+ return null;
5439
+ return { taskId: rootId, view: null, offer: { taskId: receipt.taskId, runId: receipt.runId, digest: receipt.digest }, target };
5440
+ }
5348
5441
  function taskScreen(response, who, taskId, problem, status, scopeDraft, cancelDraft) {
5349
5442
  const admittedFamily = familyOf(taskId);
5350
5443
  if (admittedFamily?.problem != null)
@@ -5360,6 +5453,7 @@ export function createDecisionServer(options) {
5360
5453
  presentedData.scopeDraft = scopeDraft;
5361
5454
  if (cancelDraft !== undefined)
5362
5455
  presentedData.cancelDraft = cancelDraft;
5456
+ presentedData.pullRequest = taskPullRequestOf(taskId, who);
5363
5457
  const paneProject = restricted() ? store.lookupRef(taskId)?.repo ?? null : who.via === "cookie" ? who.session.project : null;
5364
5458
  const page = taskPage(paneProject === null && !unscopedMode
5365
5459
  ? chromeFor(paneProject, "tasks")
@@ -6620,6 +6714,75 @@ export function createDecisionServer(options) {
6620
6714
  store.setRetentionPeriod(one.kind, one.days ?? null, who.name, now);
6621
6715
  return back("said", changed.length === 0 ? "Nothing changed." : "Saved. The next daily sweep uses these periods.");
6622
6716
  }
6717
+ // Settings → Storage: when a finished task's clean checkout goes, cleaning up what the preview showed, and throwing
6718
+ // away a checkout kept for its changes. Each takes the password; the ledger keeps every change and removal.
6719
+ if (url.pathname === "/settings/storage" || url.pathname === "/settings/storage/clean" || url.pathname === "/settings/storage/discard") {
6720
+ const databaseFile = store.databaseFile();
6721
+ if (who.via !== "cookie" || !store.isInstanceOperator(who.name) || databaseFile === null)
6722
+ return refuse(response, who, 403, "An instance operator looks after storage.", "/settings");
6723
+ const back = (key, words) => redirect(response, `/settings/storage?${key}=${encodeURIComponent(words)}`);
6724
+ const what = url.pathname.endsWith("/clean") ? "clean up" : url.pathname.endsWith("/discard") ? "discard a checkout" : "change checkout cleanup";
6725
+ if (!authenticateApprover(store, who.name, body.get("password") ?? "").ok)
6726
+ return back("problem", `Enter your Toolroll password to ${what}.`);
6727
+ if (url.pathname === "/settings/storage") {
6728
+ const cleanup = parseCleanup(body.get("cleanup") ?? "");
6729
+ if (cleanup === undefined)
6730
+ return back("problem", "Choose when a finished task's checkout is removed.");
6731
+ const before = store.checkoutCleanup();
6732
+ store.setCheckoutCleanup(cleanup, who.name, now);
6733
+ return back("said", cleanup === before ? "Nothing changed." : "Saved.");
6734
+ }
6735
+ const pool = storagePool(databaseFile);
6736
+ if (url.pathname.endsWith("/clean")) {
6737
+ // Only what the preview showed goes: when that changed since, the person looks again.
6738
+ const plan = await checkoutPlan(store, pool, clock(), { manual: true });
6739
+ if (plan.go.length === 0)
6740
+ return back("said", "Nothing to clean up.");
6741
+ if (body.get("preview") !== previewDigest(plan))
6742
+ return back("problem", "What a clean-up would remove changed since you looked. Check the list again.");
6743
+ const done = await cleanCheckouts(store, pool, clock, { manual: true, actor: who.name, only: new Set(plan.go.map(one => one.path)) });
6744
+ return back("said", done.removed.length === 0 ? "Nothing was removed: those checkouts are no longer ready to go." : `Removed ${done.removed.length} checkout${done.removed.length === 1 ? "" : "s"}, about ${bytesWords(done.freed)}. Their branches stay.`);
6745
+ }
6746
+ const done = await discardCheckout(store, pool, body.get("path") ?? "", now, who.name);
6747
+ return done.ok ? back("said", `Discarded, about ${bytesWords(done.bytes)}. Its branch stays.`) : back("problem", done.message);
6748
+ }
6749
+ // Pull requests for one project: turn on (the checks run again and must match what the page showed), merge
6750
+ // settings, or turn off. Each takes the password and lands in the ledger.
6751
+ if (url.pathname === "/settings/pull-requests") {
6752
+ const repo = body.get("repo") ?? "";
6753
+ const known = [...new Set([...managedRepos(), ...store.knownRepos(), ...store.listProjects().map(one => one.path)])];
6754
+ if (who.via !== "cookie" || !known.includes(repo) || !visible(repo))
6755
+ return refuse(response, who, 404, "No such project.", "/projects");
6756
+ if (who.role !== "approver" || store.isDemo())
6757
+ return refuse(response, who, 403, "An approver sets up pull requests.", "/projects");
6758
+ const back = (key, words) => redirect(response, `/settings/pull-requests?repo=${encodeURIComponent(repo)}&${key}=${encodeURIComponent(words)}`);
6759
+ const act = body.get("act");
6760
+ if (!authenticateApprover(store, who.name, body.get("password") ?? "", repo).ok)
6761
+ return back("problem", "Enter your Toolroll password to change pull requests.");
6762
+ if (act === "on") {
6763
+ const checked = await checkPublishing(repo, options.publishExec === undefined ? {} : { exec: options.publishExec });
6764
+ if (!checked.ok)
6765
+ return back("problem", checked.message);
6766
+ if (checked.plan.githubRepo !== body.get("github") || checked.plan.base !== body.get("base"))
6767
+ return back("problem", "GitHub answered differently from what you saw. Check the details again.");
6768
+ savePublishing(store, checked.plan, who.name, {}, now);
6769
+ return back("said", "Pull requests are on.");
6770
+ }
6771
+ if (act === "settings") {
6772
+ const method = body.get("method") ?? "";
6773
+ if (!MERGE_METHODS.includes(method))
6774
+ return back("problem", "Choose a merge method.");
6775
+ const saved = saveMergeSettings(store, repo, { mergeMethod: method, mergeWhenGreen: body.get("when-green") === "1" }, who.name, now);
6776
+ return saved.ok ? back("said", "Saved.") : back("problem", saved.message);
6777
+ }
6778
+ if (act === "off") {
6779
+ const revoked = store.revokePublicationGrant(repo, who.name, now);
6780
+ if (revoked)
6781
+ store.recordAction({ at: now.toISOString(), actor: who.name, repo, taskId: null, runId: null, action: "pull requests turned off", outcome: "off", source: "policy" });
6782
+ return back("said", revoked ? "Pull requests are off." : "Pull requests were already off.");
6783
+ }
6784
+ return back("problem", "That change isn't available.");
6785
+ }
6623
6786
  // Sprint 8: back up now. An instance operator; the outcome is kept like a scheduled one's.
6624
6787
  if (url.pathname === "/settings/backups/now") {
6625
6788
  const databaseFile = store.databaseFile();
@@ -6668,6 +6831,18 @@ export function createDecisionServer(options) {
6668
6831
  "cache-control": "no-store", "x-content-type-options": "nosniff" });
6669
6832
  return void response.end(zip);
6670
6833
  }
6834
+ // Settings → Integrations → Send test: one harmless read-only check of one integration, now.
6835
+ if (url.pathname === "/settings/integrations/test") {
6836
+ if (who.via !== "cookie" || who.role !== "approver" || restricted() || !options.configDir)
6837
+ return refuse(response, who, 403, "An installation approver tests integrations.", "/settings");
6838
+ const key = body.get("key") ?? "";
6839
+ const back = (name, words) => redirect(response, `/settings/integrations?${name}=${encodeURIComponent(words)}`);
6840
+ const checked = (await integrations.check([key])).find(one => one.key === key);
6841
+ if (checked === undefined)
6842
+ return back("problem", "That integration isn't here any more.");
6843
+ return checked.state === "connected" ? back("said", `${checked.name} works${checked.account === null ? "" : ` (${checked.account})`}.`)
6844
+ : back("problem", `${checked.name}: ${checked.action.kind === "fix" ? checked.action.words : "not set up."}`);
6845
+ }
6671
6846
  // v104: change where monitoring goes. A step-up; the ledger keeps before → after (addresses, never secrets).
6672
6847
  if (url.pathname === "/settings/monitoring") {
6673
6848
  if (who.via !== "cookie" || !store.isInstanceOperator(who.name) || !options.configDir)
@@ -7227,6 +7402,20 @@ export function createDecisionServer(options) {
7227
7402
  ? "New tasks now use Strict / release. Approved tasks keep their setting."
7228
7403
  : "New tasks now use Default quality. Approved tasks keep their setting.")}`);
7229
7404
  }
7405
+ if (url.pathname === "/settings/notifications") {
7406
+ // Each person's own choice: a closed list of modes and a 24-hour HH:MM, or off.
7407
+ const mode = body.get("mode"), time = (body.get("digest") ?? "").trim();
7408
+ if (mode !== null && mode !== "quiet" && mode !== "all")
7409
+ return refuse(response, who, 400, "choose Only when I'm needed or Every step", "/settings");
7410
+ if (time !== "" && time !== "off" && !isDigestTime(time))
7411
+ return refuse(response, who, 400, "the evening digest time is HH:MM, or off", "/settings");
7412
+ const before = store.notificationPreference(who.name);
7413
+ const after = store.setNotificationPreference(who.name, { ...(mode === null ? {} : { mode }), ...(time === "" ? {} : { digestAt: time === "off" ? null : time }) }, who.name, now);
7414
+ const said = after.mode !== before.mode
7415
+ ? after.mode === "quiet" ? "Chats now message you only when you're needed." : "Chats now message you at every step."
7416
+ : after.digestAt === null ? "Evening digest off." : `Evening digest at ${after.digestAt}.`;
7417
+ return redirect(response, `/settings?said=${encodeURIComponent(said)}`);
7418
+ }
7230
7419
  if (url.pathname === "/settings/telegram-digest" && options.telegramTokenFile !== undefined) {
7231
7420
  // The cadence is a closed list of minutes — never a free number from
7232
7421
  // a form; "off" clears it. Any approver session may set it.
@@ -7958,7 +8147,28 @@ export function createDecisionServer(options) {
7958
8147
  if (attendAct !== null) {
7959
8148
  return attendMutation(response, who, attendAct.taskId, attendAct.verb, body, now);
7960
8149
  }
7961
- const act = matchTaskPath(url.pathname, "/(hold|unhold|requeue|cancel|scope|approve|plan|plan-edit|block|unblock|repair-dependency|next|reopen|steer|follow-up|accept-proof|accept-revision|reject-revision|route|retry-review|complete|stop|resume-arm|resume)$");
8150
+ const act = matchTaskPath(url.pathname, "/(hold|unhold|requeue|cancel|scope|approve|plan|plan-edit|block|unblock|repair-dependency|next|reopen|steer|follow-up|accept-proof|accept-revision|reject-revision|route|retry-review|complete|merge|stop|resume-arm|resume)$");
8151
+ if (act !== null && act.verb === "merge") {
8152
+ // Merging is a person's act behind their password, typed again: a browser session, an approver, the exact
8153
+ // result's pull request. The flow re-reads GitHub before merging and records who merged in the ledger.
8154
+ if (who.via !== "cookie" || who.role !== "approver")
8155
+ return refuse(response, who, 403, "Only an approver can merge.");
8156
+ if (store.isDemo())
8157
+ return taskScreen(response, who, act.taskId, "The demo merges nothing.", 403);
8158
+ const ref = store.lookupRef(act.taskId);
8159
+ if (ref === null || !visible(ref.repo))
8160
+ return refuse(response, who, 404, "no such task", "/tasks");
8161
+ const named = (body.get("run") ?? "").trim();
8162
+ const run = /^[0-9]{1,15}$/.test(named) ? store.getRun(Number(named)) : null;
8163
+ const runTask = run === null ? null : store.externalIdFor(run.taskRef);
8164
+ if (run === null || runTask === null || familyOf(runTask)?.root.id !== (familyOf(act.taskId)?.root.id ?? act.taskId)) {
8165
+ return taskScreen(response, who, act.taskId, "That pull request isn't part of this task.", 409);
8166
+ }
8167
+ const merged = await mergeAsPerson(store, { runId: run.id, name: who.name, password: body.get("token") ?? "", ...(options.publishExec === undefined ? {} : { exec: options.publishExec }), clock });
8168
+ if (!merged.ok)
8169
+ return taskScreen(response, who, act.taskId, merged.message, merged.reason === "password" ? 403 : 409);
8170
+ return redirect(response, `${taskHref(act.taskId)}#merge`);
8171
+ }
7962
8172
  if (act !== null) {
7963
8173
  return taskMutation(response, who, act.taskId, act.verb, body, now);
7964
8174
  }
@@ -9995,6 +10205,14 @@ export function createDecisionServer(options) {
9995
10205
  if (current === null || current.receipt?.taskId !== taskId || !/^[0-9]{1,15}$/.test(namedRun) || current.receipt.runId !== Number(namedRun)) {
9996
10206
  return taskScreen(response, who, taskId, "This result changed. Open the current result before marking it complete.", 409);
9997
10207
  }
10208
+ if (body.get("publish") === "1") {
10209
+ // "Complete and open a pull request": the same exact-receipt completion, plus the owed PR for this
10210
+ // exact commit in one transaction. The watch process pushes and opens it; the task shows its progress.
10211
+ const opened = completeAndOpenPullRequest(store, { taskId, digest, runId: Number(namedRun), who: principal, root: evidenceRoot }, now);
10212
+ if (!opened.ok)
10213
+ return taskScreen(response, who, taskId, opened.message, 409);
10214
+ return redirect(response, `${taskHref(familyOf(taskId)?.root.id ?? taskId)}#merge`);
10215
+ }
9998
10216
  const completed = checkAssignmentAsOperator(store, taskId, digest, principal, now, evidenceRoot);
9999
10217
  if (!completed.ok)
10000
10218
  return taskScreen(response, who, taskId, completed.message, 409);
@@ -10519,6 +10737,7 @@ export function createDecisionServer(options) {
10519
10737
  proof,
10520
10738
  terminal,
10521
10739
  publication,
10740
+ pullRequestTo: pullRequestTargetOf(run.id),
10522
10741
  ciFailing: publication !== null && publication.prNumber !== null && store.hasOpenCiEpisode(publication.githubRepo, publication.prNumber),
10523
10742
  files,
10524
10743
  outsideTouches: files.filter(one => one.outsideTouches).map(one => one.path),
@@ -12898,6 +13117,23 @@ ${THEME_DARK}
12898
13117
  .status-line[data-tone="ready"] .status-dot, .status-line[data-tone="done"] .status-dot { background: var(--success); }
12899
13118
  .status-line[data-tone="muted"] { color: var(--muted-foreground); font-weight: 500; }
12900
13119
  .status-line[data-tone="muted"] .status-dot { background: var(--border); }
13120
+ .pull-request { display: grid; gap: .35rem; }
13121
+ .pull-request p { margin: 0; overflow-wrap: anywhere; }
13122
+ .pull-request-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: .25rem .75rem; }
13123
+ .pull-request-head a, .pull-request .meta a { display: inline-flex; align-items: center; min-height: 44px; }
13124
+ .pull-request-head strong { font-size: 1.0625rem; font-weight: 600; color: var(--foreground); display: inline-flex; align-items: center; gap: .5rem; }
13125
+ .pull-request-head strong::before { content: ""; width: .5rem; height: .5rem; border-radius: 999px; background: var(--muted-foreground); }
13126
+ .pull-request--ok .pull-request-head strong::before { background: var(--success, #15803d); }
13127
+ .pull-request--problem .pull-request-head strong::before { background: var(--destructive, #b91c1c); }
13128
+ .pull-request-act { display: flex; flex-wrap: wrap; align-items: flex-end; gap: .5rem .75rem; margin: .25rem 0 0; }
13129
+ .pull-request-act .meta { flex: 1 1 100%; }
13130
+ .pull-request-act label { display: grid; gap: .25rem; flex: 1 1 12rem; margin: 0; }
13131
+ .pull-request-act label input { margin: 0; min-height: 44px; box-sizing: border-box; width: 100%; }
13132
+ .pull-request-act button { min-height: 44px; white-space: nowrap; }
13133
+ .result-complete-actions { display: flex; flex-wrap: wrap; gap: .5rem; }
13134
+ .result-complete-actions button { white-space: nowrap; }
13135
+ .result-complete-actions button[name=publish], .pull-request-act button { background: var(--primary); color: var(--primary-foreground); border-color: var(--primary); font-weight: 600; }
13136
+ @media (max-width: 480px) { .pull-request-act button, .result-complete-actions button { flex: 1 1 100%; } }
12901
13137
  .receipt-publication { position: relative; z-index: 1; margin: .35rem 0 0; font-size: .8125rem; color: var(--muted-foreground); }
12902
13138
  /* A raw repository path in ordinary copy wraps at any point rather than
12903
13139
  widening the page (the phone task-list overflow, package 1). */
@@ -14066,7 +14302,7 @@ button.pick-file { min-height: 1.75rem; padding: 0 .55rem; font-size: .75rem; }
14066
14302
  const THEME_CONTROLS_CSS = `.task-repo select{width:100%;min-height:2.75rem;font-size:1rem}.task-repo-add{margin:.35rem .1rem .5rem}.task-repo-add a{display:inline-flex;align-items:center;min-height:2.25rem}details.result-request-open.result-request-form>summary{border:0;background:transparent;padding:.5rem 0;min-height:2.75rem;font-weight:600;display:list-item;list-style:revert}details.result-request-open.result-request-form>summary::-webkit-details-marker{display:revert}form.js-autosave button[type=submit]{display:none}.provider-row{border-bottom:1px solid var(--so-line);padding:.35rem 0}.provider-row:first-of-type{border-top:1px solid var(--so-line)}.provider-head{display:flex;align-items:center;gap:.75rem;margin:.4rem 0 0}.provider-status{display:inline-flex;align-items:center;gap:.4rem;color:var(--so-muted);font-size:.875rem}.provider-status i{width:.5rem;height:.5rem;border-radius:50%;background:var(--so-muted)}.provider-status--ok i{background:var(--so-success)}.provider-status--warn i{background:var(--so-attention)}.provider-status--off i{background:transparent;border:1.5px solid var(--so-muted)}details.provider-manage>summary{cursor:pointer;color:var(--so-accent-text);font-size:.875rem;min-height:2.5rem;display:list-item;padding-block:.5rem}.card.props .row{display:grid;gap:.1rem;margin:0 0 .75rem}.card.props .row>.meta{display:block;font-size:.75rem}.card.props .row>.meta::first-letter{text-transform:uppercase}.card.props .row>.mono{font-family:var(--font-sans);font-size:.875rem}.card.props .row>.mono .seal{font-family:var(--font-mono);font-size:.8125rem}details.evidence-files{margin:1rem 0}details.evidence-files>summary{cursor:pointer;min-height:2.75rem;display:list-item;padding-block:.7rem;font-weight:600}details.evidence-files ul{list-style:none;margin:0;padding:0}details.evidence-files li{display:flex;justify-content:space-between;gap:1rem;padding:.5rem 0;border-bottom:1px solid var(--so-line)}.result-action .result-feedback-link{display:inline-flex;align-items:center;min-height:2.5rem;padding:.5rem 1rem;border:1px solid var(--so-input-line);border-radius:.5rem;background:var(--so-paper);color:var(--so-ink);font-weight:600;text-decoration:none}@media(hover:hover) and (pointer:fine){.result-action .result-feedback-link:hover{background:var(--so-raised)}}.so-sr-only{position:absolute!important;width:1px!important;height:1px!important;padding:0!important;margin:-1px!important;overflow:hidden!important;clip:rect(0,0,0,0)!important;white-space:nowrap!important;border:0!important}.verdict{margin:.5rem 0 .75rem}.verdict-chips{display:flex;flex-wrap:wrap;gap:.4rem;list-style:none;padding:0;margin:0}.verdict-chip{display:inline-flex;align-items:center;gap:.3rem;min-height:1.75rem;padding:.2rem .65rem;border-radius:999px;font-size:.8125rem;font-weight:600;background:var(--so-neutral-soft);color:var(--so-neutral-ink)}.verdict-chip svg{width:.9rem;height:.9rem}.verdict-chip--success{background:var(--so-success-soft);color:var(--so-success)}.verdict-chip--danger{background:var(--so-danger-soft);color:var(--so-danger)}.verdict-chip--warning{background:var(--so-warning-soft);color:var(--so-warning)}.verdict-chip--info{background:var(--so-info-soft);color:var(--so-info)}.verdict-by{margin:.4rem 0 0}details.result-request-open{margin:.5rem 0}details.result-request-open>summary{display:inline-flex;align-items:center;min-height:2.5rem;padding:.5rem 1rem;border:1px solid var(--so-input-line);border-radius:.5rem;background:var(--so-paper);color:var(--so-ink);font-weight:600;cursor:pointer;list-style:none}details.result-request-open>summary::-webkit-details-marker{display:none}details.result-request-open[open]>summary{margin-bottom:.75rem}.settings-tiles{display:grid;grid-template-columns:repeat(auto-fill,minmax(8.5rem,1fr));gap:.5rem;margin:0 0 2rem}.settings-tiles a{display:flex;align-items:center;gap:.6rem;min-height:3rem;padding:.65rem .8rem;border:1px solid var(--so-line);border-radius:.625rem;background:var(--so-paper);color:var(--so-ink);text-decoration:none;font-weight:550;font-size:.875rem}@media(hover:hover) and (pointer:fine){.settings-tiles a:hover{border-color:var(--so-input-line);background:var(--so-raised)}}.settings-tiles svg{width:1.1rem;height:1.1rem;flex-shrink:0;color:var(--so-accent-text)}details.settings-more{margin:.25rem 0 1.25rem}details.settings-more>summary{cursor:pointer;min-height:2.75rem;display:list-item;padding-block:.7rem;font-weight:550}details.settings-more>summary .meta{font-weight:400;margin-left:.35rem}.settings-changed{margin-top:-.25rem}.appearance{margin:0 0 28px}.appearance h2{margin:0 0 10px}.theme-switch{display:inline-flex;flex-wrap:nowrap;max-width:100%;gap:4px;padding:4px;margin:0;border:1px solid var(--so-line);border-radius:10px;background:var(--so-raised)}.theme-switch .theme-choice,.so-native-region .theme-switch .theme-choice{flex:1 1 0;width:auto;white-space:nowrap;min-height:40px;padding:8px 16px;border:0;border-radius:7px;background:transparent;color:var(--so-muted);font:inherit;font-weight:550;box-shadow:none;cursor:pointer}@media(hover:hover) and (pointer:fine){.theme-switch .theme-choice:hover{color:var(--so-ink)}}.theme-switch .theme-choice[aria-pressed="true"]{background:var(--so-paper);color:var(--so-ink);box-shadow:0 1px 2px rgb(0 0 0 / .1)}.appearance .meta{margin:8px 0 0}@media(max-width:600px){.theme-switch .theme-choice{min-height:44px}}.update-notes{white-space:pre-wrap;overflow-wrap:anywhere;font:inherit;max-height:18rem;overflow:auto}`;
14067
14303
  /** The page CSS this module writes itself (not the imported modules'), for the stylesheet contract tests. */
14068
14304
  export const PAGE_CSS = STYLE + THEME_CONTROLS_CSS;
14069
- const WORKSPACE_STYLE = styleAsset(STYLE + APPROVAL_RULES_CSS + SPEND_CSS + RETENTION_CSS + UPDATES_CSS + LIMITS_CSS + MONITORING_CSS + BACKUP_CSS + EXPORT_CSS + PROJECT_DELETE_CSS + POLICY_CSS + EVIDENCE_PACK_CSS + THEME_CONTROLS_CSS + CODING_CSS + CODING_SHIPPING_CSS + RECIPE_CSS + SKILLS_CSS + TOOLS_CSS + FLOWS_CSS + TEAMMATE_CSS + KITS_CSS + SSO_CSS + CREDENTIALS_CSS + KNOWLEDGE_CSS + MODELS_CSS + CHAT_POLISH_CSS + TRANSITIONS_CSS + WORKSPACE_MOTION_CSS + ASSIGNMENT_CSS + LEAD_CONTEXT_CSS + '.learning{min-width:0;overflow-wrap:anywhere}.learning .card{min-width:0}.learning code,.learning blockquote,.learning pre{white-space:pre-wrap;overflow-wrap:anywhere;word-break:break-word}.learning button,.learning summary,.learning .button-link{min-height:44px}.learning button{white-space:nowrap}.learning summary{padding:12px 0;cursor:pointer}.learning form{margin:12px 0}.learning select{max-width:100%}.learning blockquote{margin:8px 0}.learning ul{padding-left:20px}');
14305
+ const WORKSPACE_STYLE = styleAsset(STYLE + APPROVAL_RULES_CSS + SPEND_CSS + RETENTION_CSS + STORAGE_CSS + UPDATES_CSS + LIMITS_CSS + MONITORING_CSS + INTEGRATIONS_CSS + BACKUP_CSS + EXPORT_CSS + PROJECT_DELETE_CSS + POLICY_CSS + EVIDENCE_PACK_CSS + THEME_CONTROLS_CSS + CODING_CSS + CODING_SHIPPING_CSS + RECIPE_CSS + SKILLS_CSS + TOOLS_CSS + FLOWS_CSS + TEAMMATE_CSS + KITS_CSS + SSO_CSS + CREDENTIALS_CSS + KNOWLEDGE_CSS + MODELS_CSS + CHAT_POLISH_CSS + TRANSITIONS_CSS + WORKSPACE_MOTION_CSS + ASSIGNMENT_CSS + LEAD_CONTEXT_CSS + PULL_REQUEST_SETTINGS_CSS + '.learning{min-width:0;overflow-wrap:anywhere}.learning .card{min-width:0}.learning code,.learning blockquote,.learning pre{white-space:pre-wrap;overflow-wrap:anywhere;word-break:break-word}.learning button,.learning summary,.learning .button-link{min-height:44px}.learning button{white-space:nowrap}.learning summary{padding:12px 0;cursor:pointer}.learning form{margin:12px 0}.learning select{max-width:100%}.learning blockquote{margin:8px 0}.learning ul{padding-left:20px}');
14070
14306
  /**
14071
14307
  * The board's liveness: fetch this page's own fragment on a timer and swap
14072
14308
  * it in place — no flicker, no scroll reset, no long-lived stream to manage,
@@ -14725,7 +14961,7 @@ function inboxPage(chrome, data) {
14725
14961
  `<p><strong>Get to your first result</strong></p>` +
14726
14962
  data.wizard
14727
14963
  .map(step => `<p class="row" data-step="${step.key}"><span class="mono" aria-hidden="true">${step.done ? "\u2713" : "\u25cb"}</span> <strong>${escape(step.title)}</strong>` +
14728
- (step.action === null ? ` <span class="meta">done</span>`
14964
+ (step.checking ? ` <span class="meta">checking…</span>` : step.action === null ? ` <span class="meta">done</span>`
14729
14965
  : step.action.kind === "link" ? ` <a href="${escape(step.action.href)}">${escape(step.action.label)}</a>`
14730
14966
  : ` <code>${escape(step.action.command)}</code>`) +
14731
14967
  `</p>`)
@@ -17520,7 +17756,7 @@ function githubReposPage(chrome, data) {
17520
17756
  `<p class="row" style="margin-top:.6rem"><a class="badge" href="/projects">← back to projects</a></p>`,
17521
17757
  ].join("\n"), { chrome });
17522
17758
  }
17523
- function projectsPage(chrome, recent, candidates, open, csrf, problem, _unscopedMode, browsable = false, onboard = null, peeks = {}, returnTo = "/") {
17759
+ function projectsPage(chrome, recent, candidates, open, csrf, problem, _unscopedMode, browsable = false, onboard = null, peeks = {}, returnTo = "/", pullRequestsOn) {
17524
17760
  // The onboarding card (repo onboarding, findings 1-39): preview first,
17525
17761
  // then a password-confirmed clone into a configured root. Disabled
17526
17762
  // states explain themselves in words (finding 28/39).
@@ -17610,6 +17846,7 @@ function projectsPage(chrome, recent, candidates, open, csrf, problem, _unscoped
17610
17846
  return {
17611
17847
  name, path, shortPath: path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path, open: open !== null && open === path, openedAt,
17612
17848
  knowledgeHref: `/settings/knowledge?repo=${encodeURIComponent(path)}`,
17849
+ ...(pullRequestsOn === undefined ? {} : { pullRequests: { href: `/settings/pull-requests?repo=${encodeURIComponent(path)}`, on: pullRequestsOn(path) } }),
17613
17850
  peek: peek === null ? null : [
17614
17851
  ...(peek.waiting > 0 ? [{ label: `${peek.waiting} waiting on you`, href: "/", tone: "attention" }] : []),
17615
17852
  ...(peek.running > 0 ? [{ label: `${peek.running} running`, href: "/runs", tone: "neutral" }] : []),
@@ -18999,6 +19236,9 @@ function taskBodyParts(data) {
18999
19236
  return "no project — no publication grant can apply";
19000
19237
  if (grant === null)
19001
19238
  return "branch only — publishing is not set up";
19239
+ if (grant.publishOn === "complete") {
19240
+ return `pull requests to ${grant.githubRepo} into ${grant.base} when completed · ${grant.mergeWhenGreen === true ? "merges when checks pass" : "a person merges"} (${grant.mergeMethod ?? "squash"})`;
19241
+ }
19002
19242
  return [
19003
19243
  grant.capabilities.includes("push-branch") ? `may push ${grant.headPrefix}* to ${grant.githubRepo}` : "cannot push",
19004
19244
  grant.capabilities.includes("open-pr") ? `may open a PR against ${grant.base}${grant.draft ? " (draft)" : ""}` : null,
@@ -19054,7 +19294,8 @@ function taskBodyParts(data) {
19054
19294
  facts.push({ label: "Budget", parts: [`${data.budgetHold} · `, { label: "Spend", href: "/spend" }] });
19055
19295
  if (data.policyHold != null)
19056
19296
  facts.push({ label: "Policy", parts: [`${data.policyHold} · `, { label: "Policy", href: "/settings/policy" }] });
19057
- if (data.publication !== null && data.publication !== undefined) {
19297
+ // The pull-request card already says this once, with its action.
19298
+ if (data.publication !== null && data.publication !== undefined && data.pullRequest?.view == null) {
19058
19299
  const prHref = safePrUrl(data.publication.prUrl), pr = `PR #${data.publication.prNumber ?? "?"}`;
19059
19300
  facts.push({ label: "Published", parts: [prHref === null ? pr : { label: pr, href: prHref },
19060
19301
  ` · ${data.publication.state}${data.publication.remoteState !== null ? ` · ${data.publication.remoteState.toLowerCase()} on GitHub` : ""}${data.publication.lastCheckState !== null ? ` · CI ${data.publication.lastCheckState} at last observation` : " · no checks observed"}`] });
@@ -19235,7 +19476,9 @@ function taskBodyParts(data) {
19235
19476
  : data.filedVia === null || data.filedVia === undefined
19236
19477
  ? ""
19237
19478
  : ` · filed via ${escape(data.filedVia)}`}${data.deliverable === "report" ? ` · <span class="badge">scout</span>` : ""}</p>`;
19238
- const assignmentOptions = data.assignment == null ? null : { workStatus: status, hideAction: approveForm !== "" && data.dispatch?.action === "approve-scope", problem: status.tone === "problem", diagnostics: [...(status.diagnostics ?? []), ...(status.tone === "problem" && status.detail !== data.assignment.detail ? [status] : [])] };
19479
+ // A pull request that can be merged (or opened) owns the one primary action; the result stays in its section.
19480
+ const pullRequestActs = data.csrf !== "" && (data.pullRequest?.view?.canMerge === true || (data.pullRequest?.view == null && data.pullRequest?.offer != null));
19481
+ const assignmentOptions = data.assignment == null ? null : { workStatus: status, hideAction: (approveForm !== "" && data.dispatch?.action === "approve-scope") || pullRequestActs, problem: status.tone === "problem", diagnostics: [...(status.diagnostics ?? []), ...(status.tone === "problem" && status.detail !== data.assignment.detail ? [status] : [])] };
19239
19482
  // The result takes over from the task status as soon as it is ready.
19240
19483
  const statusHtml = (data.assignment != null && assignmentOptions !== null ? assignmentSummaryHtml(data.assignment, assignmentOptions) : receiptLeads ? completionReceiptCard(data.completion.receipt, task.id, "task", status) : taskStatusCard(status, task.id, data.dispatch ?? null, liveRunId, approveForm !== "" && data.dispatch?.action === "approve-scope")) + checkProgressHtml(data.checkProgress ?? null);
19241
19484
  // The exact-run control (v52), directly under the scheduler's answer:
@@ -19365,6 +19608,7 @@ function taskBodyParts(data) {
19365
19608
  approval: approvalHtml,
19366
19609
  lead: [
19367
19610
  { key: "history", html: data.history ?? "" }, { key: "control", html: controlHtml }, { key: "problem", html: problemHtml },
19611
+ { key: "pull-request", html: data.pullRequest == null ? "" : pullRequestCardHtml(data.pullRequest, data.csrf) },
19368
19612
  { key: "needs-scope", html: needsScopeCard }, { key: "progress", html: progressCard }, { key: "revisions", html: revisionLedgerCard },
19369
19613
  { key: "plan", html: planCard }, { key: "mirror", html: mirrorCard }, { key: "contest", html: contestCard }, { key: "attempt", html: attemptPanel },
19370
19614
  ].filter(one => one.html !== ""),
@@ -19869,7 +20113,7 @@ function reviewCockpitDetailParts(view, csrf, noted, canRetryReview, tab, user)
19869
20113
  const complete = assignment?.state === "ready-to-check" && assignment.receipt !== null && canRetryReview && csrf !== ""
19870
20114
  ? { action: `${taskHref(view.taskId)}/complete`, receipt: assignment.receipt.digest, run: run.id } : null;
19871
20115
  if (complete !== null)
19872
- parts.push(completionForm(view.taskId, run.id, complete.receipt, csrf));
20116
+ parts.push(completionForm(view.taskId, run.id, complete.receipt, csrf, view.detail?.pullRequestTo ?? null));
19873
20117
  if (view.notes.length > 0) {
19874
20118
  parts.push(`<section class="card cockpit-section" data-cockpit-section="notes"><h3>operator notes</h3>` +
19875
20119
  view.notes.map(one => `<p class="row"><span class="meta">${escape(one.author)} · ${escape(when(one.createdAt))}</span> ${escape(one.note)}</p>`).join("\n") +
@@ -19883,8 +20127,41 @@ function reviewCockpitDetailParts(view, csrf, noted, canRetryReview, tab, user)
19883
20127
  },
19884
20128
  };
19885
20129
  }
19886
- function completionForm(taskId, runId, digest, csrf) {
19887
- return `<form method="post" action="${taskHref(taskId)}/complete" class="card result-complete"><input type="hidden" name="csrf" value="${escape(csrf)}"><input type="hidden" name="receipt" value="${escape(digest)}"><input type="hidden" name="run" value="${runId}"><p class="meta">After inspecting the work, mark this result complete. Checks stay unchanged; nothing is published or deployed.</p><button type="submit" style="min-height:44px">Mark complete</button></form>`;
20130
+ function completionForm(taskId, runId, digest, csrf, pullRequestTo = null) {
20131
+ const hidden = `<input type="hidden" name="csrf" value="${escape(csrf)}"><input type="hidden" name="receipt" value="${escape(digest)}"><input type="hidden" name="run" value="${runId}">`;
20132
+ if (pullRequestTo !== null) {
20133
+ // With pull requests set up: the PR is the primary road, a bare completion the quiet one beside it.
20134
+ return `<form method="post" action="${taskHref(taskId)}/complete" class="card result-complete">${hidden}<p class="meta">After inspecting the work, mark this result complete. A pull request opens on ${escape(pullRequestTo)} from this exact commit.</p><div class="result-complete-actions"><button type="submit" name="publish" value="1" style="min-height:44px">Complete and open a pull request</button><button type="submit" class="secondary" style="min-height:44px">Complete only</button></div></form>`;
20135
+ }
20136
+ return `<form method="post" action="${taskHref(taskId)}/complete" class="card result-complete">${hidden}<p class="meta">After inspecting the work, mark this result complete. Checks stay unchanged; nothing is published or deployed.</p><button type="submit" style="min-height:44px">Mark complete</button></form>`;
20137
+ }
20138
+ /** The pull request, in one card: its state and link, one line of detail, and the one action it needs — Merge
20139
+ * behind the password when checks passed, or Open a pull request for a result completed without one. */
20140
+ function pullRequestCardHtml(pr, csrf) {
20141
+ if (pr.view === null) {
20142
+ if (pr.offer === null || csrf === "")
20143
+ return "";
20144
+ return `<section class="card pull-request" id="merge" data-pull-request="none"><div class="pull-request-head"><strong>No pull request</strong></div>` +
20145
+ `<p class="meta">This result was completed without one.</p>` +
20146
+ `<form method="post" action="${taskHref(pr.offer.taskId)}/complete" class="pull-request-act"><input type="hidden" name="csrf" value="${escape(csrf)}"><input type="hidden" name="receipt" value="${escape(pr.offer.digest)}"><input type="hidden" name="run" value="${pr.offer.runId}"><input type="hidden" name="publish" value="1">` +
20147
+ `<button type="submit" class="secondary">Open a pull request</button></form></section>`;
20148
+ }
20149
+ const view = pr.view;
20150
+ const url = safePrUrl(view.prUrl);
20151
+ const link = view.prNumber === null ? "" : url === null ? `<span class="mono">PR #${view.prNumber}</span>` : `<a href="${escape(url)}" class="mono" rel="noreferrer" target="_blank">PR #${view.prNumber}</a>`;
20152
+ const tone = view.state === "ready" || view.state === "merged" ? "ok" : view.state === "failing" || view.state === "failed" || view.state === "closed" ? "problem" : "waiting";
20153
+ const revision = view.revisionTask === null || view.state !== "failing" ? "" : ` <a href="${taskHref(view.revisionTask)}">Open revision</a>`;
20154
+ const merged = view.mergeCommit === null ? "" : `<p class="meta">Merge commit <span class="mono">${escape(view.mergeCommit.slice(0, 12))}</span></p>`;
20155
+ const target = pr.target === null ? "the base branch" : pr.target;
20156
+ const act = !view.canMerge || csrf === "" || view.prNumber === null ? "" :
20157
+ `<form method="post" action="${taskHref(pr.taskId)}/merge" class="pull-request-act" data-merge-form>` +
20158
+ `<input type="hidden" name="csrf" value="${escape(csrf)}"><input type="hidden" name="run" value="${view.runId}">` +
20159
+ `<p class="meta">${escape(view.mergeMethod === "squash" ? "Squash-merges" : view.mergeMethod === "rebase" ? "Rebase-merges" : "Merges")} PR #${view.prNumber} into ${escape(target)} and deletes its branch.</p>` +
20160
+ `<label>Password<input type="password" name="token" autocomplete="current-password" required></label>` +
20161
+ `<button type="submit">Merge</button></form>`;
20162
+ return `<section class="card pull-request pull-request--${tone}" id="merge" data-pull-request="${escape(view.state)}">` +
20163
+ `<div class="pull-request-head"><strong>${escape(view.label)}</strong>${link}</div>` +
20164
+ `<p class="meta">${escape(view.detail)}${revision}</p>${merged}${act}</section>`;
19888
20165
  }
19889
20166
  /** Exactly one primary road per result, chosen from its state; the
19890
20167
  * others stay reachable from their own sections. Every form posts to the
@@ -21489,7 +21766,7 @@ function telegramDeliveryWords(store, bot) {
21489
21766
  return `Telegram pushes new messages to ${state.url}${state.problem === null ? "." : `, but ${state.problem.charAt(0).toLowerCase()}${state.problem.slice(1)}.`}`;
21490
21767
  return `Toolroll asks Telegram for new messages every few seconds${state?.problem ? ` (${state.problem})` : ""}.`;
21491
21768
  }
21492
- function settingsPage(chrome, existing, hasEnv, csrf, problem, messaging = null, push = null, providerKeys = null, digest = null, permissionDefault = null, qualityDefault = null, email = null, telegramDelivery = null, workers = null, updates = null, firstResult = null) {
21769
+ function settingsPage(chrome, existing, hasEnv, csrf, problem, messaging = null, push = null, providerKeys = null, digest = null, permissionDefault = null, qualityDefault = null, email = null, telegramDelivery = null, workers = null, updates = null, firstResult = null, chatNotices = null) {
21493
21770
  const permissionCard = permissionDefault === null
21494
21771
  ? ""
21495
21772
  : [
@@ -21518,7 +21795,23 @@ function settingsPage(chrome, existing, hasEnv, csrf, problem, messaging = null,
21518
21795
  ? ""
21519
21796
  : `<p class="meta settings-changed">Changed ${escape(when(qualityDefault.updatedAt))}${qualityDefault.updatedBy === null ? "" : ` by ${escape(qualityDefault.updatedBy)}`}</p>`,
21520
21797
  ].join("\n");
21521
- const digestCard = digest === null || csrf === ""
21798
+ // Quiet chat: this person's own choice. The installation's Telegram cadence only bundles Every step updates.
21799
+ const chatCard = chatNotices === null || csrf === ""
21800
+ ? ""
21801
+ : [
21802
+ "<h3>Chat messages</h3>",
21803
+ `<form method="post" action="/settings/notifications" class="card" data-autosave>`,
21804
+ `<input type="hidden" name="csrf" value="${escape(csrf)}">`,
21805
+ `<label><input type="radio" name="mode" value="quiet"${chatNotices.mode === "quiet" ? " checked" : ""}> Only when I'm needed <span class="meta">· one message per task, updated as it moves</span></label>`,
21806
+ `<label><input type="radio" name="mode" value="all"${chatNotices.mode === "all" ? " checked" : ""}> Every step <span class="meta">· a new message for each update</span></label>`,
21807
+ `<label>Evening digest<select name="digest">` +
21808
+ digestTimes(chatNotices.digestAt).map(([value, label]) => `<option value="${value}"${value === (chatNotices.digestAt ?? "off") ? " selected" : ""}>${label}</option>`).join("") +
21809
+ `</select></label>`,
21810
+ `<p class="meta">One message: what finished, what waits, what failed.</p>`,
21811
+ `<button type="submit">Save</button>`,
21812
+ `</form>`,
21813
+ ].join("\n");
21814
+ const digestCard = digest === null || csrf === "" || chatNotices?.mode === "quiet"
21522
21815
  ? ""
21523
21816
  : [
21524
21817
  "<h3>Telegram digest</h3>",
@@ -21681,7 +21974,8 @@ function settingsPage(chrome, existing, hasEnv, csrf, problem, messaging = null,
21681
21974
  }),
21682
21975
  services: messaging === null || messaging.configured.length === 0 ? null : { configured: messaging.configured, channel: messaging.channel, implicit: messaging.implicit },
21683
21976
  push: push === null || csrf === "" ? null : { available: push.available, devices: push.devices.filter(one => one.retiredAt === null || one.retiredReason === "gone").map(one => ({ id: one.id, words: `${one.uaWords} · since ${when(one.createdAt)}`, state: one.retiredAt !== null ? "expired" : one.consecutiveFailures >= 20 ? "failing" : "ok", removable: one.retiredAt === null })) },
21684
- digest: digest === null || csrf === "" ? null : { every: digest.everyMs === null ? "off" : String(Math.round(digest.everyMs / 60_000)), held: digest.everyMs === null ? null : `${digest.held} routine fact(s) held` },
21977
+ chat: chatNotices === null || csrf === "" ? null : chatNotices,
21978
+ digest: digest === null || csrf === "" || chatNotices?.mode === "quiet" ? null : { every: digest.everyMs === null ? "off" : String(Math.round(digest.everyMs / 60_000)), held: digest.everyMs === null ? null : `${digest.held} routine fact(s) held` },
21685
21979
  telegram: { state: hasEnv ? "from the environment" : existing === null ? "not set" : "saved", current, delivery: telegramDelivery },
21686
21980
  email,
21687
21981
  workers,
@@ -21698,7 +21992,8 @@ function settingsPage(chrome, existing, hasEnv, csrf, problem, messaging = null,
21698
21992
  workersCard(workers),
21699
21993
  updatesCard(updates, csrf),
21700
21994
  firstResult === null ? "" : `<p class="meta" data-first-result>${escape(firstResult)}</p>`,
21701
- pushCard === "" && messagingCard === "" && digestCard === "" ? "" : `<h2>Notifications</h2>`,
21995
+ pushCard === "" && messagingCard === "" && digestCard === "" && chatCard === "" ? "" : `<h2>Notifications</h2>`,
21996
+ chatCard,
21702
21997
  messagingCard,
21703
21998
  pushCard,
21704
21999
  digestCard,
@@ -21794,6 +22089,7 @@ function settingsTiles() {
21794
22089
  return `<nav class="settings-tiles" aria-label="Settings sections">${tiles.map(([href, label, icon]) => `<a href="${href}">${strokeIcon(icon)}<span>${label}</span></a>`).join("")}</nav>`;
21795
22090
  }
21796
22091
  const SETTINGS_TILE_ICONS = [
22092
+ ["/settings/integrations", "Integrations", `<path d="M9 2v6M15 2v6"/><path d="M6 8h12v4a6 6 0 0 1-12 0z"/><path d="M12 18v4"/>`],
21797
22093
  ["/settings/models", "Models", `<rect x="4" y="4" width="16" height="16" rx="2"/><rect x="9" y="9" width="6" height="6"/><path d="M9 2v2M15 2v2M9 20v2M15 20v2M2 9h2M2 15h2M20 9h2M20 15h2"/>`],
21798
22094
  ["/settings/skills", "Skills", `<path d="m12 3 1.9 5.8L20 10l-5 3.6L16.8 20 12 16.4 7.2 20 9 13.6 4 10l6.1-1.2z"/>`],
21799
22095
  ["/settings/tools", "Tools", `<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>`],
@@ -21810,6 +22106,7 @@ const SETTINGS_TILE_ICONS = [
21810
22106
  ["/settings/approval", "Approval rules", `<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><path d="m9 12 2 2 4-4"/>`],
21811
22107
  ["/settings/monitoring", "Monitoring", `<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>`],
21812
22108
  ["/settings/retention", "Retention", `<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>`],
22109
+ ["/settings/storage", "Storage", `<path d="M22 12H2"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/><path d="M6 16h.01M10 16h.01"/>`],
21813
22110
  ["/settings/updates", "Updates", `<path d="M21 12a9 9 0 1 1-2.64-6.36"/><path d="M21 3v6h-6"/>`],
21814
22111
  ["/settings/backups", "Backups", `<ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5v14c0 1.7 4 3 9 3s9-1.3 9-3V5"/><path d="M3 12c0 1.7 4 3 9 3s9-1.3 9-3"/>`],
21815
22112
  ["/settings/data", "Data", `<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 10 5 5 5-5"/><path d="M12 15V3"/>`],