toolroll 0.8.2 → 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.
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,6 +204,8 @@ 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";
@@ -438,6 +443,19 @@ export function createDecisionServer(options) {
438
443
  catch { /* the pause keeps its own state */ }
439
444
  return value;
440
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
+ }));
441
459
  const modelCatalog = openRouterModelsCache(options.modelCatalogFetcher);
442
460
  const modelSeams = {
443
461
  home: providerHome,
@@ -1566,7 +1584,7 @@ export function createDecisionServer(options) {
1566
1584
  }
1567
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"]);
1568
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"]);
1569
- 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)$");
1570
1588
  const resource = request.method === "GET"
1571
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)
1572
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);
@@ -3305,7 +3323,7 @@ export function createDecisionServer(options) {
3305
3323
  back: { href: roomId ? "/chat?conversation=" + encodeURIComponent(roomId) : taskChatHref(focusTask?.id ?? ""), label: "Back to chat" },
3306
3324
  }) + (who.role === 'approver' && focusTask?.assignment?.state === 'ready-to-check'
3307
3325
  && focusTask.assignment.receipt?.runId === resultRun.id
3308
- ? 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)) : '');
3309
3327
  const focusProblem = requestedTask !== null && focusTask === null
3310
3328
  ? "That task is not available in this workspace."
3311
3329
  : requestedResult !== null && focusTask !== null && resultRun === null
@@ -3621,6 +3639,13 @@ export function createDecisionServer(options) {
3621
3639
  sessions: store.webSessions(everyone ? null : who.name).map(one => ({ ...one, here: one.idHash === here })), tokens: store.apiTokens(everyone ? null : who.name) };
3622
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") }));
3623
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
+ }
3624
3649
  // v104: Settings → Monitoring, where the audit stream and traces go. An instance operator's page.
3625
3650
  if (url.pathname === "/settings/monitoring") {
3626
3651
  if (who.via !== "cookie" || !store.isInstanceOperator(who.name) || !options.configDir)
@@ -3659,6 +3684,20 @@ export function createDecisionServer(options) {
3659
3684
  const plan = await checkoutPlan(store, storagePool(databaseFile), now, { manual: true });
3660
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") }));
3661
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
+ }
3662
3701
  // Sprint 8: Settings → Backups, how the last backup went and the schedule. An instance operator's page.
3663
3702
  if (url.pathname === "/settings/backups") {
3664
3703
  const databaseFile = store.databaseFile();
@@ -3833,6 +3872,9 @@ export function createDecisionServer(options) {
3833
3872
  // The installation fact: when the first Ready result arrived, measured from the first account.
3834
3873
  const at = store.firstSuccessAt(now), since = store.installationStartedAt();
3835
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 };
3836
3878
  })()));
3837
3879
  }
3838
3880
  if (url.pathname === "/push/key") {
@@ -4903,7 +4945,7 @@ export function createDecisionServer(options) {
4903
4945
  peeks[one.path] = peekOf(one.path);
4904
4946
  for (const path of candidates)
4905
4947
  peeks[path] = peekOf(path);
4906
- 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));
4907
4949
  }
4908
4950
  /** The task screen, shared by the GET and by every refusal that re-renders it. */
4909
4951
  /**
@@ -5361,6 +5403,41 @@ export function createDecisionServer(options) {
5361
5403
  : completionReceiptView(store, latest, store.artifactsFor(latest.id), evidenceRoot, reviewFactsFor(latest.id)),
5362
5404
  };
5363
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
+ }
5364
5441
  function taskScreen(response, who, taskId, problem, status, scopeDraft, cancelDraft) {
5365
5442
  const admittedFamily = familyOf(taskId);
5366
5443
  if (admittedFamily?.problem != null)
@@ -5376,6 +5453,7 @@ export function createDecisionServer(options) {
5376
5453
  presentedData.scopeDraft = scopeDraft;
5377
5454
  if (cancelDraft !== undefined)
5378
5455
  presentedData.cancelDraft = cancelDraft;
5456
+ presentedData.pullRequest = taskPullRequestOf(taskId, who);
5379
5457
  const paneProject = restricted() ? store.lookupRef(taskId)?.repo ?? null : who.via === "cookie" ? who.session.project : null;
5380
5458
  const page = taskPage(paneProject === null && !unscopedMode
5381
5459
  ? chromeFor(paneProject, "tasks")
@@ -6668,6 +6746,43 @@ export function createDecisionServer(options) {
6668
6746
  const done = await discardCheckout(store, pool, body.get("path") ?? "", now, who.name);
6669
6747
  return done.ok ? back("said", `Discarded, about ${bytesWords(done.bytes)}. Its branch stays.`) : back("problem", done.message);
6670
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
+ }
6671
6786
  // Sprint 8: back up now. An instance operator; the outcome is kept like a scheduled one's.
6672
6787
  if (url.pathname === "/settings/backups/now") {
6673
6788
  const databaseFile = store.databaseFile();
@@ -6716,6 +6831,18 @@ export function createDecisionServer(options) {
6716
6831
  "cache-control": "no-store", "x-content-type-options": "nosniff" });
6717
6832
  return void response.end(zip);
6718
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
+ }
6719
6846
  // v104: change where monitoring goes. A step-up; the ledger keeps before → after (addresses, never secrets).
6720
6847
  if (url.pathname === "/settings/monitoring") {
6721
6848
  if (who.via !== "cookie" || !store.isInstanceOperator(who.name) || !options.configDir)
@@ -7275,6 +7402,20 @@ export function createDecisionServer(options) {
7275
7402
  ? "New tasks now use Strict / release. Approved tasks keep their setting."
7276
7403
  : "New tasks now use Default quality. Approved tasks keep their setting.")}`);
7277
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
+ }
7278
7419
  if (url.pathname === "/settings/telegram-digest" && options.telegramTokenFile !== undefined) {
7279
7420
  // The cadence is a closed list of minutes — never a free number from
7280
7421
  // a form; "off" clears it. Any approver session may set it.
@@ -8006,7 +8147,28 @@ export function createDecisionServer(options) {
8006
8147
  if (attendAct !== null) {
8007
8148
  return attendMutation(response, who, attendAct.taskId, attendAct.verb, body, now);
8008
8149
  }
8009
- 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
+ }
8010
8172
  if (act !== null) {
8011
8173
  return taskMutation(response, who, act.taskId, act.verb, body, now);
8012
8174
  }
@@ -10043,6 +10205,14 @@ export function createDecisionServer(options) {
10043
10205
  if (current === null || current.receipt?.taskId !== taskId || !/^[0-9]{1,15}$/.test(namedRun) || current.receipt.runId !== Number(namedRun)) {
10044
10206
  return taskScreen(response, who, taskId, "This result changed. Open the current result before marking it complete.", 409);
10045
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
+ }
10046
10216
  const completed = checkAssignmentAsOperator(store, taskId, digest, principal, now, evidenceRoot);
10047
10217
  if (!completed.ok)
10048
10218
  return taskScreen(response, who, taskId, completed.message, 409);
@@ -10567,6 +10737,7 @@ export function createDecisionServer(options) {
10567
10737
  proof,
10568
10738
  terminal,
10569
10739
  publication,
10740
+ pullRequestTo: pullRequestTargetOf(run.id),
10570
10741
  ciFailing: publication !== null && publication.prNumber !== null && store.hasOpenCiEpisode(publication.githubRepo, publication.prNumber),
10571
10742
  files,
10572
10743
  outsideTouches: files.filter(one => one.outsideTouches).map(one => one.path),
@@ -12946,6 +13117,23 @@ ${THEME_DARK}
12946
13117
  .status-line[data-tone="ready"] .status-dot, .status-line[data-tone="done"] .status-dot { background: var(--success); }
12947
13118
  .status-line[data-tone="muted"] { color: var(--muted-foreground); font-weight: 500; }
12948
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%; } }
12949
13137
  .receipt-publication { position: relative; z-index: 1; margin: .35rem 0 0; font-size: .8125rem; color: var(--muted-foreground); }
12950
13138
  /* A raw repository path in ordinary copy wraps at any point rather than
12951
13139
  widening the page (the phone task-list overflow, package 1). */
@@ -14114,7 +14302,7 @@ button.pick-file { min-height: 1.75rem; padding: 0 .55rem; font-size: .75rem; }
14114
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}`;
14115
14303
  /** The page CSS this module writes itself (not the imported modules'), for the stylesheet contract tests. */
14116
14304
  export const PAGE_CSS = STYLE + THEME_CONTROLS_CSS;
14117
- const WORKSPACE_STYLE = styleAsset(STYLE + APPROVAL_RULES_CSS + SPEND_CSS + RETENTION_CSS + STORAGE_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}');
14118
14306
  /**
14119
14307
  * The board's liveness: fetch this page's own fragment on a timer and swap
14120
14308
  * it in place — no flicker, no scroll reset, no long-lived stream to manage,
@@ -17568,7 +17756,7 @@ function githubReposPage(chrome, data) {
17568
17756
  `<p class="row" style="margin-top:.6rem"><a class="badge" href="/projects">← back to projects</a></p>`,
17569
17757
  ].join("\n"), { chrome });
17570
17758
  }
17571
- 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) {
17572
17760
  // The onboarding card (repo onboarding, findings 1-39): preview first,
17573
17761
  // then a password-confirmed clone into a configured root. Disabled
17574
17762
  // states explain themselves in words (finding 28/39).
@@ -17658,6 +17846,7 @@ function projectsPage(chrome, recent, candidates, open, csrf, problem, _unscoped
17658
17846
  return {
17659
17847
  name, path, shortPath: path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path, open: open !== null && open === path, openedAt,
17660
17848
  knowledgeHref: `/settings/knowledge?repo=${encodeURIComponent(path)}`,
17849
+ ...(pullRequestsOn === undefined ? {} : { pullRequests: { href: `/settings/pull-requests?repo=${encodeURIComponent(path)}`, on: pullRequestsOn(path) } }),
17661
17850
  peek: peek === null ? null : [
17662
17851
  ...(peek.waiting > 0 ? [{ label: `${peek.waiting} waiting on you`, href: "/", tone: "attention" }] : []),
17663
17852
  ...(peek.running > 0 ? [{ label: `${peek.running} running`, href: "/runs", tone: "neutral" }] : []),
@@ -19047,6 +19236,9 @@ function taskBodyParts(data) {
19047
19236
  return "no project — no publication grant can apply";
19048
19237
  if (grant === null)
19049
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
+ }
19050
19242
  return [
19051
19243
  grant.capabilities.includes("push-branch") ? `may push ${grant.headPrefix}* to ${grant.githubRepo}` : "cannot push",
19052
19244
  grant.capabilities.includes("open-pr") ? `may open a PR against ${grant.base}${grant.draft ? " (draft)" : ""}` : null,
@@ -19102,7 +19294,8 @@ function taskBodyParts(data) {
19102
19294
  facts.push({ label: "Budget", parts: [`${data.budgetHold} · `, { label: "Spend", href: "/spend" }] });
19103
19295
  if (data.policyHold != null)
19104
19296
  facts.push({ label: "Policy", parts: [`${data.policyHold} · `, { label: "Policy", href: "/settings/policy" }] });
19105
- 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) {
19106
19299
  const prHref = safePrUrl(data.publication.prUrl), pr = `PR #${data.publication.prNumber ?? "?"}`;
19107
19300
  facts.push({ label: "Published", parts: [prHref === null ? pr : { label: pr, href: prHref },
19108
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"}`] });
@@ -19283,7 +19476,9 @@ function taskBodyParts(data) {
19283
19476
  : data.filedVia === null || data.filedVia === undefined
19284
19477
  ? ""
19285
19478
  : ` · filed via ${escape(data.filedVia)}`}${data.deliverable === "report" ? ` · <span class="badge">scout</span>` : ""}</p>`;
19286
- 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] : [])] };
19287
19482
  // The result takes over from the task status as soon as it is ready.
19288
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);
19289
19484
  // The exact-run control (v52), directly under the scheduler's answer:
@@ -19413,6 +19608,7 @@ function taskBodyParts(data) {
19413
19608
  approval: approvalHtml,
19414
19609
  lead: [
19415
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) },
19416
19612
  { key: "needs-scope", html: needsScopeCard }, { key: "progress", html: progressCard }, { key: "revisions", html: revisionLedgerCard },
19417
19613
  { key: "plan", html: planCard }, { key: "mirror", html: mirrorCard }, { key: "contest", html: contestCard }, { key: "attempt", html: attemptPanel },
19418
19614
  ].filter(one => one.html !== ""),
@@ -19917,7 +20113,7 @@ function reviewCockpitDetailParts(view, csrf, noted, canRetryReview, tab, user)
19917
20113
  const complete = assignment?.state === "ready-to-check" && assignment.receipt !== null && canRetryReview && csrf !== ""
19918
20114
  ? { action: `${taskHref(view.taskId)}/complete`, receipt: assignment.receipt.digest, run: run.id } : null;
19919
20115
  if (complete !== null)
19920
- 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));
19921
20117
  if (view.notes.length > 0) {
19922
20118
  parts.push(`<section class="card cockpit-section" data-cockpit-section="notes"><h3>operator notes</h3>` +
19923
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") +
@@ -19931,8 +20127,41 @@ function reviewCockpitDetailParts(view, csrf, noted, canRetryReview, tab, user)
19931
20127
  },
19932
20128
  };
19933
20129
  }
19934
- function completionForm(taskId, runId, digest, csrf) {
19935
- 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>`;
19936
20165
  }
19937
20166
  /** Exactly one primary road per result, chosen from its state; the
19938
20167
  * others stay reachable from their own sections. Every form posts to the
@@ -21537,7 +21766,7 @@ function telegramDeliveryWords(store, bot) {
21537
21766
  return `Telegram pushes new messages to ${state.url}${state.problem === null ? "." : `, but ${state.problem.charAt(0).toLowerCase()}${state.problem.slice(1)}.`}`;
21538
21767
  return `Toolroll asks Telegram for new messages every few seconds${state?.problem ? ` (${state.problem})` : ""}.`;
21539
21768
  }
21540
- 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) {
21541
21770
  const permissionCard = permissionDefault === null
21542
21771
  ? ""
21543
21772
  : [
@@ -21566,7 +21795,23 @@ function settingsPage(chrome, existing, hasEnv, csrf, problem, messaging = null,
21566
21795
  ? ""
21567
21796
  : `<p class="meta settings-changed">Changed ${escape(when(qualityDefault.updatedAt))}${qualityDefault.updatedBy === null ? "" : ` by ${escape(qualityDefault.updatedBy)}`}</p>`,
21568
21797
  ].join("\n");
21569
- 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"
21570
21815
  ? ""
21571
21816
  : [
21572
21817
  "<h3>Telegram digest</h3>",
@@ -21729,7 +21974,8 @@ function settingsPage(chrome, existing, hasEnv, csrf, problem, messaging = null,
21729
21974
  }),
21730
21975
  services: messaging === null || messaging.configured.length === 0 ? null : { configured: messaging.configured, channel: messaging.channel, implicit: messaging.implicit },
21731
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 })) },
21732
- 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` },
21733
21979
  telegram: { state: hasEnv ? "from the environment" : existing === null ? "not set" : "saved", current, delivery: telegramDelivery },
21734
21980
  email,
21735
21981
  workers,
@@ -21746,7 +21992,8 @@ function settingsPage(chrome, existing, hasEnv, csrf, problem, messaging = null,
21746
21992
  workersCard(workers),
21747
21993
  updatesCard(updates, csrf),
21748
21994
  firstResult === null ? "" : `<p class="meta" data-first-result>${escape(firstResult)}</p>`,
21749
- pushCard === "" && messagingCard === "" && digestCard === "" ? "" : `<h2>Notifications</h2>`,
21995
+ pushCard === "" && messagingCard === "" && digestCard === "" && chatCard === "" ? "" : `<h2>Notifications</h2>`,
21996
+ chatCard,
21750
21997
  messagingCard,
21751
21998
  pushCard,
21752
21999
  digestCard,
@@ -21842,6 +22089,7 @@ function settingsTiles() {
21842
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>`;
21843
22090
  }
21844
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"/>`],
21845
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"/>`],
21846
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"/>`],
21847
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"/>`],
@@ -15,10 +15,10 @@ export declare function detectPreparation(repo: string): {
15
15
  } | null;
16
16
  /** Browser installation may write only this known file inside its project. */
17
17
  export declare function previewProjectInstructions(repo: string): {
18
- content?: never;
19
18
  ok: false;
20
19
  message: string;
21
20
  plan?: never;
21
+ content?: never;
22
22
  fingerprint?: never;
23
23
  installed?: never;
24
24
  } | {
@@ -30,10 +30,10 @@ export declare function previewProjectInstructions(repo: string): {
30
30
  installed: boolean;
31
31
  };
32
32
  export declare function addProjectInstructions(repo: string, fingerprint: string): {
33
- content?: never;
34
33
  ok: false;
35
34
  message: string;
36
35
  plan?: never;
36
+ content?: never;
37
37
  fingerprint?: never;
38
38
  installed?: never;
39
39
  } | import("./skills.js").InstallResult;