toolroll 0.9.5 → 0.9.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/dist/actor.d.ts +21 -0
  3. package/dist/actor.js +48 -0
  4. package/dist/assignment-adapters.d.ts +1 -0
  5. package/dist/assignment-presentation.d.ts +13 -2
  6. package/dist/assignment-presentation.js +22 -9
  7. package/dist/assignment-ui.d.ts +10 -15
  8. package/dist/assignment-ui.js +19 -18
  9. package/dist/assignment.d.ts +9 -0
  10. package/dist/assignment.js +62 -13
  11. package/dist/browser/workspace.css +2 -2
  12. package/dist/browser/workspace.js +29 -24
  13. package/dist/browser-workspace.d.ts +120 -12
  14. package/dist/builder.d.ts +1 -0
  15. package/dist/builder.js +33 -13
  16. package/dist/chat-acceptance.js +2 -2
  17. package/dist/chat-actions.js +2 -1
  18. package/dist/chat-delivery.js +2 -0
  19. package/dist/chat-quiet.js +30 -19
  20. package/dist/chat-task-actions.d.ts +13 -0
  21. package/dist/chat-task-actions.js +19 -1
  22. package/dist/check-levels-ui.d.ts +21 -0
  23. package/dist/check-levels-ui.js +70 -0
  24. package/dist/check-levels.d.ts +67 -0
  25. package/dist/check-levels.js +171 -0
  26. package/dist/claim.d.ts +22 -0
  27. package/dist/claim.js +43 -0
  28. package/dist/cli.d.ts +1 -1
  29. package/dist/cli.js +4 -0
  30. package/dist/demo-chat.d.ts +0 -5
  31. package/dist/demo-chat.js +14 -3
  32. package/dist/dispatch.js +7 -2
  33. package/dist/dispose.d.ts +8 -0
  34. package/dist/dispose.js +7 -1
  35. package/dist/export.js +3 -2
  36. package/dist/first-run.d.ts +34 -0
  37. package/dist/first-run.js +37 -1
  38. package/dist/flow-gallery-ui.d.ts +30 -0
  39. package/dist/flow-gallery-ui.js +85 -0
  40. package/dist/flow-gallery.d.ts +111 -0
  41. package/dist/flow-gallery.js +445 -0
  42. package/dist/flow-share.d.ts +102 -0
  43. package/dist/flow-share.js +614 -0
  44. package/dist/flow-starters.d.ts +2 -0
  45. package/dist/flow-starters.js +1 -1
  46. package/dist/flow-steps.js +7 -0
  47. package/dist/flow-triggers.js +2 -0
  48. package/dist/flows-cli.d.ts +56 -0
  49. package/dist/flows-cli.js +109 -3
  50. package/dist/flows-ui.d.ts +15 -1
  51. package/dist/flows-ui.js +31 -6
  52. package/dist/guides.js +27 -2
  53. package/dist/lead-context.js +6 -2
  54. package/dist/lead-status.d.ts +7 -0
  55. package/dist/lead-status.js +11 -2
  56. package/dist/mate-doors.d.ts +0 -15
  57. package/dist/mate-doors.js +5 -1
  58. package/dist/mate-tools.js +9 -4
  59. package/dist/models-ui.js +1 -1
  60. package/dist/operate.d.ts +4 -1
  61. package/dist/operate.js +601 -85
  62. package/dist/project-concurrency.d.ts +62 -0
  63. package/dist/project-concurrency.js +116 -0
  64. package/dist/project-delete-ui.d.ts +7 -0
  65. package/dist/project-delete-ui.js +19 -2
  66. package/dist/project-delete.js +4 -0
  67. package/dist/pull-request-flow.d.ts +9 -0
  68. package/dist/pull-request-flow.js +21 -3
  69. package/dist/result-follow-ups.d.ts +101 -0
  70. package/dist/result-follow-ups.js +333 -0
  71. package/dist/scope.js +11 -0
  72. package/dist/serve.d.ts +211 -2
  73. package/dist/serve.js +1070 -204
  74. package/dist/store.d.ts +76 -3
  75. package/dist/store.js +311 -22
  76. package/dist/surface.js +31 -8
  77. package/dist/tailnet.d.ts +11 -0
  78. package/dist/tailnet.js +47 -0
  79. package/dist/task-outcome-cli.js +2 -2
  80. package/dist/task-status.d.ts +186 -0
  81. package/dist/task-status.js +335 -0
  82. package/dist/team-cli.js +3 -0
  83. package/dist/telegram-progress.js +62 -100
  84. package/dist/telegram-status.js +21 -8
  85. package/dist/verification-evidence.js +21 -1
  86. package/dist/webhooks.js +5 -0
  87. package/dist/work-index.js +60 -11
  88. package/dist/work-summary.d.ts +0 -4
  89. package/dist/work-summary.js +2 -0
  90. package/dist/workspace-ui.d.ts +10 -0
  91. package/dist/workspace-ui.js +33 -2
  92. package/package.json +1 -1
package/dist/serve.js CHANGED
@@ -1,4 +1,7 @@
1
+ import { withActor } from "./actor.js";
1
2
  import { repositoryContext, repositoryContextRead } from './repository-context.js';
3
+ import { ADD_TESTS_ACTION, followUpChecksOf, requestFollowUpChecks, fileAddTestsTask, resultCheckLevel } from './result-follow-ups.js';
4
+ import { CHECK_LEVEL_WORDS, isCheckLevel, liveQuickCommand, projectCheckLevel, quickVerifyKey, setProjectCheckLevel, suggestQuickCommand } from './check-levels.js';
2
5
  import { repositoryContextHtml } from './repository-context-ui.js';
3
6
  import { browserAssetsAvailable, browserWorkspaceDocument, serveBrowserAsset } from './browser-shell.js';
4
7
  import { browserCrewOf, browserCrewFromIndex, browserWorkActionHref, browserProjectsOf, browserNavigationOf } from './browser-workspace.js';
@@ -19,13 +22,16 @@ import { CHAT_ACTIONS, sharedActionPayload, sharedActionNeedsReview, sharedActio
19
22
  import { changeSkills, githubSkill, importSkill, readSkillsSnapshot, reviseSkillTest, skillTestResult, skillsView, testSkill } from "./project-skills.js";
20
23
  import { skillsHtml, skillsScript, skillsSnapshotHtml, skillTestFeedbackHtml, SKILLS_CSS } from "./skills-ui.js";
21
24
  import { toolsHtml, TOOLS_CSS } from "./tools-ui.js";
22
- import { flowFallbackHtml, flowsListHtml, flowView, FLOWS_CSS } from "./flows-ui.js";
25
+ import { flowFallbackHtml, flowImportHtml, flowsListHtml, flowView, FLOW_IMPORT_SCRIPT, FLOWS_CSS } from "./flows-ui.js";
26
+ import { exportFlow, fetchFlowFile, FlowFileError, importFlow, parseFlowFile, planFlowImport } from "./flow-share.js";
23
27
  import { BLANK_SOUL, TEAMMATE_CSS, teammatePageHtml, teammatesListHtml } from "./teammates-ui.js";
24
28
  import { grantTool, revokeTool, rulesFromForm, setToolRules } from "./teammate-tools.js";
25
29
  import { addKitGithubTrigger, addKitSample, kitInstalled, kitOf, setUpKit } from "./kits.js";
26
30
  import { CONNECT_CALLBACK, connectionsOf, finishConnect, oneClickOf, startConnect } from "./mcp-connect.js";
27
31
  import { KITS_CSS, kitPageHtml, kitsGalleryHtml } from "./kits-ui.js";
28
32
  import { STARTERS_CSS, startersHtml } from "./flow-starters-ui.js";
33
+ import { GALLERY_CSS, galleryHtml, galleryUseHtml } from "./flow-gallery-ui.js";
34
+ import { galleryDefaults, galleryDiagram, galleryTemplateOf, previewGallery, useGalleryTemplate } from "./flow-gallery.js";
29
35
  import { starterForWork, starterOf, startersFor, switchOnStarter } from "./flow-starters.js";
30
36
  import { createTeammateFrom, labelOf, nameOf, saveSoul, setTeammateState, teammateSettings, sendTeammateSummaries } from "./teammate-admin.js";
31
37
  import { editMemory, forgetMemory, tellTeammate } from "./teammate-memory.js";
@@ -55,15 +61,18 @@ import { handleTeamHttp } from './team-http.js';
55
61
  import { teamWorkspaceHtml } from './team-ui.js';
56
62
  import { createTeamRuntime } from './team-runtime.js';
57
63
  import { prepareWorkspaceRevision, WorkspaceValidatorCache } from "./workspace-revision.js";
58
- import { workIndexPage, workCountsByProject, WorkIndexCursorError } from "./work-index.js";
64
+ import { workIndexPage, workCountsByProject, WorkIndexCursorError, WORK_INDEX_MAX_LIMIT } from "./work-index.js";
59
65
  import { openWorkDecisionOf } from "./work-summary.js";
60
66
  import { assignmentOf, checkAssignmentAsOperator } from './assignment.js';
61
67
  import { demoChatHtml, demoThreadHtml, DEMO_CHAT_SCRIPT } from "./demo-chat.js";
62
68
  import { pullRequestSettingsHtml, PULL_REQUEST_SETTINGS_CSS } from './pull-request-ui.js';
69
+ import { checkSettingsHtml, CHECK_SETTINGS_CSS } from './check-levels-ui.js';
70
+ import { hasDisguisedText } from './decision.js';
63
71
  import { checkPublishing, completeAndOpenPullRequest, mergeAsPerson, pullRequestBlocker, publishingOf, saveMergeSettings, savePublishing, newestPullRequestOf, MERGE_METHODS } from './pull-request-flow.js';
64
72
  import { leadBriefHtml, LEAD_CONTEXT_CSS } from './lead-context.js';
65
73
  import { assignmentCatchUp } from './assignment-brief.js';
66
74
  import { assignmentCardOf, assignmentStatusOf, assignmentSummaryHtml, assignmentWithEvidence, ASSIGNMENT_CSS } from './assignment-ui.js';
75
+ import { assignmentStageOf, pullRequestFactOf, stageOfCode, stageOfDispatch, statusDetailsHtml, statusIconSvg, statusWhyHtml, taskStatusOf, TASK_STATUS_CSS } from './task-status.js';
67
76
  import { assignmentPresentationOf, historicalAssessmentReason, shortenedMaterialReason } from './assignment-presentation.js';
68
77
  import { ledgerBody } from "./ledger-view.js";
69
78
  import { EVIDENCE_PACK_CSS, evidencePack, evidencePackHtml, exportDay, ledgerExportChunks } from "./evidence-pack.js";
@@ -125,7 +134,7 @@ import { projectAuthority } from "./project-access.js";
125
134
  * repo, only runs whose task belongs to that repo (or to no repo yet).
126
135
  */
127
136
  import { createConnectionChecker } from "./provider-connection.js";
128
- import { liftAuthPause, noteSignInProbe, signInNotices } from "./provider-auth.js";
137
+ import { liftAuthPause, noteSignInProbe, providerName, signInNotices } from "./provider-auth.js";
129
138
  import { openRouterModelsCache, openRouterPickerScript } from "./openrouter-models.js";
130
139
  import { checkModels, isNewModel, livePin, modelOptions, modelWords, RUNTIME_TOOLS, runtimeStates, seenModels, setWatch, updateRuntime, watchState } from "./model-catalog.js";
131
140
  import { MODELS_CSS, modelsHtml, modelsScript } from "./models-ui.js";
@@ -152,7 +161,7 @@ import { starterRecipes, savedRecipes, findRecipe, importRecipe, exportRecipe, c
152
161
  import { recipeFromForm, recipeLibraryHtml, recipeEditorHtml, workflowPreviewHtml, recipeScript, RECIPE_CSS, recipeDefinitionPreviewHtml, recipeRunHtml, recipeAnswersFromForm } from "./recipe-ui.js";
153
162
  import { EVIDENCE_CAPS, readVerifiedArtifact, readVerifiedReport, readVerifiedProofForRun, storeEvidence, writeEvidenceFile, scanForSecrets } from "./evidence.js";
154
163
  import { GOAL_ASSESSMENT_PENDING, reviewConflict, manualReviewOnly, manualReviewCriterionOf, personCheckWords, plainReasonWords, dispatchStatusToken, passFraction, semanticCoverage, coverageWords, coverageStateWords } from "./proof.js";
155
- import { WORK_VIEWS, REVIEW_TOKENS, parseWorkView, resultStatusOf, receiptHeadingOf, receiptPublicationWords, reviewFactsOf, workStatusOf, primaryDestinationOf, needsPerson, dispatchActionLabel, } from "./workspace-ui.js";
164
+ import { WORK_VIEWS, REVIEW_TOKENS, parseWorkView, resultStatusOf, resultHeadlineOf, receiptHeadingOf, receiptPublicationWords, reviewFactsOf, workStatusOf, primaryDestinationOf, needsPerson, dispatchActionLabel, } from "./workspace-ui.js";
156
165
  import { PRICED_BUILD_MODELS } from "./pricing.js";
157
166
  import { RESULT_TABS, RESULT_REVIEW_SCRIPT, isRevisionFeedback, evidenceResultStatusOf, evidenceShortenedWords, parseResultTab, resultLeadOf, evidenceProblemDetailsOf, evidenceHealthOf, resultFactsAttributes, resultFactsAttributeMap, resultReturnTarget, commentSourceKey, REQUEST_TOKEN, revisionBatchOf, parseRevisionBatch, revisionSourceOf, } from "./result-review.js";
158
167
  import { parseReport } from "./scout-report.js";
@@ -176,7 +185,7 @@ import { verifiedAuthor, LEAD_THREAD, isDigestTime } from "./store.js";
176
185
  import { digestTimes } from "./digest-times.js";
177
186
  import { updateRepos, addRepos, removeRepos } from "./repos.js";
178
187
  import { run as execRun } from "./exec.js";
179
- import { findFirstTasks, firstResultWords, firstRunSteps, firstTaskSuggestions, SANDBOX_COMMAND } from "./first-run.js";
188
+ import { findFirstTasks, firstResultWords, firstRunSteps, firstTaskJourney, firstTaskSuggestions, HOW_IT_WORKS, SANDBOX_COMMAND, signInCommandFor, START_COMMAND } from "./first-run.js";
180
189
  /** A user agent, reduced to safe display words — never echoed raw. */
181
190
  function oneLineUa(raw) {
182
191
  const text = Array.isArray(raw) ? (raw[0] ?? "") : (raw ?? "");
@@ -224,6 +233,7 @@ import { RETENTION_KINDS, lastSweepAt, parsePeriod, retentionPlan } from "./rete
224
233
  import { EXPORT_CSS, dataExportHtml } from "./export-ui.js";
225
234
  import { buildExport, exportZip } from "./export.js";
226
235
  import { PROJECT_DELETE_CSS, projectDeleteConfirmHtml, projectSettingsHtml } from "./project-delete-ui.js";
236
+ import { parseProjectConcurrency, PROJECT_CONCURRENCY_DEFAULT, projectConcurrency, saveProjectConcurrency } from "./project-concurrency.js";
227
237
  import { deleteProject, holdingsWords, projectHoldings, projectRunning } from "./project-delete.js";
228
238
  import { POLICY_CSS, policyHtml } from "./policy-ui.js";
229
239
  import { checkPolicy, parseList, policyParts } from "./policy.js";
@@ -251,6 +261,13 @@ import { PACKAGE_VERSION } from "./version.js";
251
261
  import { updateNoticeWords } from "./update-notice.js";
252
262
  import { whenHtml } from "./when-html.js";
253
263
  const SESSION_COOKIE = "standing-orders_session";
264
+ /** Where `up`'s one-time sign-in link points, and how long it works. */
265
+ export const SIGN_IN_LINK_PATH = "/login/once/";
266
+ export const SIGN_IN_LINK_MS = 10 * 60_000;
267
+ /** The installation fact that the lead was turned on by default, once (its value: the provider). */
268
+ const LEAD_BY_DEFAULT_FACT = "lead-on-by-default";
269
+ /** The installation fact that the phone card was put away. */
270
+ const PHONE_CARD_FACT = "phone-card-dismissed";
254
271
  const BODY_CAP = 16 * 1024;
255
272
  // URL encoding can triple UTF-8 bytes. Admit the existing bounded task
256
273
  // fields (including paths and rubric) before canonical text validation.
@@ -514,6 +531,30 @@ export function createDecisionServer(options) {
514
531
  const ssoOffer = () => { const settings = ssoSettings(); return settings === null ? null : { label: settings.label, operatorsOnly: settings.passwords === "operators" }; };
515
532
  /** A step-up within this long of the provider checking someone needs no password. */
516
533
  const SSO_FRESH_MS = 10 * 60_000;
534
+ // The one-time sign-in link `up` opens in the browser: single use, ten minutes, from this computer only. Only a hash
535
+ // of the code is kept, in memory; the code itself is never written to a log or the ledger.
536
+ const signInLinks = new Map();
537
+ const linkKey = (code) => createHash("sha256").update(code).digest("hex");
538
+ function mintSignInLink(account) {
539
+ const known = store.accountOf(account);
540
+ if (known === null || known.role !== "approver" || known.revokedAt !== null)
541
+ return null;
542
+ for (const [key, one] of signInLinks)
543
+ if (one.expires < Date.now())
544
+ signInLinks.delete(key);
545
+ while (signInLinks.size >= 20)
546
+ signInLinks.delete(signInLinks.keys().next().value);
547
+ const code = randomBytes(32).toString("base64url");
548
+ signInLinks.set(linkKey(code), { account, expires: Date.now() + SIGN_IN_LINK_MS });
549
+ return `${SIGN_IN_LINK_PATH}${code}`;
550
+ }
551
+ /** This computer, and nothing in front of it: a loopback peer that names a loopback address, with no forwarding proxy. */
552
+ const fromThisComputer = (request) => {
553
+ const peer = request.socket.remoteAddress ?? "";
554
+ const loopback = peer === "127.0.0.1" || peer === "::1" || peer === "::ffff:127.0.0.1";
555
+ return loopback && request.headers["x-forwarded-for"] === undefined && request.headers["forwarded"] === undefined
556
+ && /^(localhost|127\.0\.0\.1|\[::1\]):[0-9]{1,5}$/.test(request.headers.host ?? "");
557
+ };
517
558
  async function providerFor(issuer) {
518
559
  if (ssoProvider !== null && ssoProvider.issuer === issuer && Date.now() - ssoProvider.at < 10 * 60_000)
519
560
  return { ok: true, provider: ssoProvider.provider };
@@ -606,7 +647,18 @@ export function createDecisionServer(options) {
606
647
  const projectViewOf = (repo, who) => ({
607
648
  repo, name: projectName(repo), holdings: projectHoldings(store, repo), running: projectRunning(store, repo, clock()),
608
649
  canDelete: who.via === "cookie" && store.isInstanceOperator(who.name),
650
+ builds: projectBuildsOf(repo, who),
609
651
  });
652
+ /** Settings → Project → Builds at once: the saved number, its workers' capacity, and what builds now. */
653
+ const projectBuildsOf = (repo, who) => {
654
+ const capacities = store.listRunners().filter(one => one.retiredAt === null && one.repos.includes(repo)).map(one => one.capacity);
655
+ return {
656
+ setting: (() => { const file = store.databaseFile(); return file === null ? PROJECT_CONCURRENCY_DEFAULT : projectConcurrency(file, repo); })(),
657
+ capacity: capacities.length === 0 ? null : Math.max(...capacities),
658
+ building: store.runningBuildsByRepo(clock()).get(repo) ?? 0,
659
+ canChange: who.via === "cookie" && who.role === "approver" && store.accountCanAccess(who.name, repo),
660
+ };
661
+ };
610
662
  /** Codex's own MCP servers for a project (`codex mcp list --json`), for "Found on this computer"; null when Codex can't say. */
611
663
  const codexServers = async (repo) => {
612
664
  try {
@@ -694,7 +746,7 @@ export function createDecisionServer(options) {
694
746
  const server = createServer((request, response) => {
695
747
  void handle(request, response).catch(error => {
696
748
  // Every unhandled error is logged (v99): the path and the message, never the request's body or query.
697
- logEvent("error", "serve.error", { method: request.method, path: new URL(request.url ?? "/", "http://placeholder").pathname, error: error instanceof Error ? error.message : String(error) });
749
+ logEvent("error", "serve.error", { method: request.method, path: redactedPath(new URL(request.url ?? "/", "http://placeholder").pathname), error: error instanceof Error ? error.message : String(error) });
698
750
  if (envValue(process.env, "SERVE_DEBUG") === "1")
699
751
  console.error("SERVE ERROR:", error);
700
752
  if (!response.headersSent) {
@@ -729,15 +781,43 @@ export function createDecisionServer(options) {
729
781
  const cookieSecure = publicOrigin === null ? "" : "; Secure";
730
782
  /** Where Google may send someone back to (v89): the public https address, or this computer's own. */
731
783
  const consoleOrigin = (host) => host === undefined ? null : publicOrigin !== null && host === publicOrigin.host ? publicOrigin.origin : /^(localhost|127\.0\.0\.1|\[::1\]):[0-9]{1,5}$/.test(host) ? `http://${host}` : null;
784
+ /** This computer's tailnet names, the last ones read (onboarding). */
785
+ let tailnet = [];
786
+ if (options.tailnetNames !== undefined) {
787
+ const readTailnet = options.tailnetNames;
788
+ const refresh = () => { void readTailnet().then(names => { tailnet = names; }, () => { }); };
789
+ refresh();
790
+ const timer = setInterval(refresh, 5 * 60_000);
791
+ timer.unref?.();
792
+ server.on("close", () => clearInterval(timer));
793
+ }
794
+ const servedPort = () => {
795
+ const address = server.address();
796
+ return typeof address === "object" && address !== null ? address.port : null;
797
+ };
732
798
  /** The names this server answers as. Anything else is a rebind, refused. */
733
799
  const allowedHost = (host) => {
734
800
  if (host === undefined)
735
801
  return false;
736
- const address = server.address();
737
- const port = typeof address === "object" && address !== null ? address.port : null;
738
- const locals = port === null ? [] : [`localhost:${port}`, `127.0.0.1:${port}`, `[::1]:${port}`];
802
+ const port = servedPort();
803
+ const locals = port === null ? [] : [`localhost:${port}`, `127.0.0.1:${port}`, `[::1]:${port}`, ...tailnet.map(name => `${name}:${port}`)];
739
804
  return [...locals, ...(options.allowedHosts ?? []), ...(publicOrigin === null ? [] : [publicOrigin.host])].includes(host);
740
805
  };
806
+ /** The exact command that starts this console again as it runs now, plus a listening address or one more allowed host. */
807
+ const consoleCommand = (change) => {
808
+ const port = servedPort() ?? 4180;
809
+ const address = server.address();
810
+ const bound = typeof address === "object" && address !== null ? address.address : "127.0.0.1";
811
+ const host = change.host ?? (bound === "127.0.0.1" || bound === "::1" ? null : bound === "::" ? "0.0.0.0" : bound);
812
+ const allowed = [...(options.allowedHosts ?? []), ...(change.allow === undefined ? [] : [change.allow])];
813
+ return [`toolroll ${options.upConsole === true ? "up" : "serve"}`, ...(host === null ? [] : [`--host ${host}`]), ...(port === 4180 ? [] : [`--port ${port}`]),
814
+ ...(allowed.length === 0 ? [] : [`--allow-host ${allowed.join(",")}`])].join(" ");
815
+ };
816
+ /** The wrong-host page's facts: the address opened, where the console answers, and the exact command that admits it. */
817
+ const wrongHost = (host) => {
818
+ const opened = host !== undefined && /^[A-Za-z0-9.-]{1,253}(:[0-9]{1,5})?$|^\[[0-9A-Fa-f:.]{2,45}\](:[0-9]{1,5})?$/.test(host) ? host : null;
819
+ return { opened, served: `127.0.0.1:${servedPort() ?? 4180}`, command: opened === null ? null : consoleCommand({ allow: opened }) };
820
+ };
741
821
  /**
742
822
  * The one gate every POST passes — parse elsewhere, authorize here. A
743
823
  * refusal names its status; null means proceed. Duplicated security fields
@@ -1064,7 +1144,7 @@ export function createDecisionServer(options) {
1064
1144
  if (hook.pathname.startsWith("/hooks/"))
1065
1145
  return flowHook(request, response, hook);
1066
1146
  if (!allowedHost(request.headers.host)) {
1067
- return respond(response, 421, "text/plain; charset=utf-8", "wrong host");
1147
+ return respond(response, 421, "text/html; charset=utf-8", wrongHostPage(wrongHost(request.headers.host)));
1068
1148
  }
1069
1149
  const url = new URL(request.url ?? "/", "http://placeholder");
1070
1150
  // A token in a URL is a token in history, logs, and referers. Refused
@@ -1146,6 +1226,33 @@ export function createDecisionServer(options) {
1146
1226
  }
1147
1227
  return page(response, 200, loginPage(null, loginReturn(url.searchParams.get("return")), ssoOffer()));
1148
1228
  }
1229
+ // The one-time link `up` opens (onboarding): it signs the browser on this computer in and lands on Chat. Spent on
1230
+ // the first visit whatever happens; the ledger notes the sign-in, never the link.
1231
+ if (url.pathname.startsWith(SIGN_IN_LINK_PATH) && method === "GET") {
1232
+ const code = url.pathname.slice(SIGN_IN_LINK_PATH.length);
1233
+ const held = /^[A-Za-z0-9_-]{43}$/.test(code) ? signInLinks.get(linkKey(code)) : undefined;
1234
+ if (held !== undefined)
1235
+ signInLinks.delete(linkKey(code));
1236
+ response.setHeader("Referrer-Policy", "no-referrer");
1237
+ if (!fromThisComputer(request)) {
1238
+ recordSignIn(held?.account ?? "unknown account", "sign-in refused", "one-time link", "opened from another device");
1239
+ return page(response, 403, loginPage("That sign-in link works only in a browser on the computer running Toolroll. Sign in with your password.", "/chat", ssoOffer()));
1240
+ }
1241
+ if (held === undefined || held.expires < Date.now()) {
1242
+ return page(response, 410, loginPage("That sign-in link has been used or has expired. Sign in with your password, or run toolroll up again.", "/chat", ssoOffer()));
1243
+ }
1244
+ const account = store.accountOf(held.account);
1245
+ if (account === null || account.role !== "approver" || account.revokedAt !== null)
1246
+ return page(response, 403, loginPage("That account can't sign in here any more.", "/chat", ssoOffer()));
1247
+ if (!passwordAllowed(held.account))
1248
+ return page(response, 403, loginPage(`Sign in with ${ssoSettings()?.label ?? "your identity provider"}.`, "/chat", ssoOffer()));
1249
+ const id = randomBytes(32).toString("hex");
1250
+ sessions.set(id, { ...arrival(request), name: held.account, csrf: randomBytes(32).toString("hex"), role: account.role, generation: account.generation, createdAt: Date.now(), sawBoardAt: null, lastSeen: Date.now(),
1251
+ project: defaultProject, projectRevision: 1 });
1252
+ recordSignIn(held.account, "signed in", "one-time link");
1253
+ response.setHeader("Set-Cookie", `${SESSION_COOKIE}=${id}; HttpOnly; SameSite=Strict; Path=/${cookieSecure}`);
1254
+ return redirect(response, "/chat");
1255
+ }
1149
1256
  // v100: sign-in with the identity provider. ?reauth=1 asks it to check the person again (a step-up);
1150
1257
  // ?link=1 links the signed-in account to it. The browser that starts it holds the visit's state.
1151
1258
  if (url.pathname === "/login/sso" && method === "GET") {
@@ -1425,7 +1532,7 @@ export function createDecisionServer(options) {
1425
1532
  if (method === "GET" || method === "POST")
1426
1533
  return freshIdentitySignIn.run({ actor: requestFacts.sso?.fresh === true ? who.name : null }, () => requestContext.run(requestFacts, async () => {
1427
1534
  const taskTextForm = url.pathname === "/tasks/add" || /^\/t\/[^/]+\/scope$/.test(url.pathname);
1428
- const body = method === "POST" ? await form(request, url.pathname === "/settings/skills/import" ? 2 * 1024 * 1024 : taskTextForm ? TASK_FORM_BODY_CAP : BODY_CAP) : null;
1535
+ const body = method === "POST" ? await form(request, url.pathname === "/settings/skills/import" ? 2 * 1024 * 1024 : url.pathname === "/flows/import" ? 1024 * 1024 : taskTextForm ? TASK_FORM_BODY_CAP : BODY_CAP) : null;
1429
1536
  const target = actionTarget(url, who, request, body);
1430
1537
  const execute = async () => {
1431
1538
  if (!projectRequestAllowed(url, who, request, response))
@@ -1590,8 +1697,8 @@ export function createDecisionServer(options) {
1590
1697
  const write = new Set(["/settings/flows/on", "/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"]);
1591
1698
  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)$");
1592
1699
  const resource = request.method === "GET"
1593
- ? /^\/(?: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)
1594
- : /^\/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);
1700
+ ? /^\/(?: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|\/export|\/runs\/[0-9]{1,15}\/[0-9]{1,15})?$/.test(path) || path === "/flows/new" || /^\/flows\/new\/[a-z-]{1,40}$/.test(path)
1701
+ : /^\/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\/new\/[a-z-]{1,40}$/.test(path) || path === "/flows/import" || /^\/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|checks|add-tests)$/.test(path);
1595
1702
  if (!(request.method === "GET" ? read : write).has(path) && task === null && !resource) {
1596
1703
  refuse(response, who, 403, "This area requires instance access. Your account operates within its assigned projects.", "/projects");
1597
1704
  return false;
@@ -1616,6 +1723,20 @@ export function createDecisionServer(options) {
1616
1723
  return true;
1617
1724
  }
1618
1725
  // ---- reads ---------------------------------------------------------------
1726
+ /** A gallery template's page (Flows → New → Use this): its questions, the preview of these answers, and Create. */
1727
+ function sendGalleryUse(response, who, template, projects, repo, given, name, notice) {
1728
+ const answers = given ?? galleryDefaults(store, template, repo);
1729
+ let preview = null, problem = notice;
1730
+ try {
1731
+ preview = previewGallery(store, template, repo, answers, who.name, clock());
1732
+ }
1733
+ catch (error) {
1734
+ problem = error instanceof Error ? error.message : "That can't be made here.";
1735
+ }
1736
+ const html = galleryUseHtml({ template, projects: projects.map(path => ({ path, name: projectName(path) })), repo, answers, name, preview, problem,
1737
+ csrf: who.via === "cookie" ? who.session.csrf : "", diagram: preview?.built.definition ?? galleryDiagram(template) });
1738
+ return sendScreen(response, problem !== null && preview === null && notice !== null ? 400 : 200, screen(template.name, `<p><a href="/flows/new">New flow</a></p><h1>${escape(template.name)}</h1>${html}`, { chrome: chromeFor(repo, "flows") }));
1739
+ }
1619
1740
  async function handleGet(url, who, request, response) {
1620
1741
  const now = clock();
1621
1742
  let project = projectOf(who, request);
@@ -1715,7 +1836,9 @@ export function createDecisionServer(options) {
1715
1836
  url.pathname !== "/menu" &&
1716
1837
  url.pathname !== "/recipes" &&
1717
1838
  // A flow names its own project; the list spans every project.
1718
- url.pathname !== "/flows" && !/^\/flows\/[0-9]{1,15}(\/insights|\/runs\/[0-9]{1,15}\/[0-9]{1,15})?$/.test(url.pathname) &&
1839
+ url.pathname !== "/flows" && url.pathname !== "/flows/import" && !/^\/flows\/[0-9]{1,15}(\/insights|\/export|\/runs\/[0-9]{1,15}\/[0-9]{1,15})?$/.test(url.pathname) &&
1840
+ // The gallery (Flows → New) asks for its project on each template's page.
1841
+ url.pathname !== "/flows/new" && !/^\/flows\/new\/[a-z-]{1,40}$/.test(url.pathname) &&
1719
1842
  // Teammates (v92) name their own project, like flows.
1720
1843
  !url.pathname.startsWith("/teammates") &&
1721
1844
  // New work names its project in the form (a dropdown of known
@@ -1728,6 +1851,7 @@ export function createDecisionServer(options) {
1728
1851
  url.pathname !== "/projects/browse" && url.pathname !== "/projects/github" && url.pathname !== "/workbench" &&
1729
1852
  url.pathname !== "/fleet" &&
1730
1853
  url.pathname !== "/chat" && url.pathname !== "/chat/mate/status" && url.pathname !== "/chat/task-status" && url.pathname !== "/chat/stream" &&
1854
+ url.pathname !== "/lead/status" && url.pathname !== "/onboarding/phone/dismiss" &&
1731
1855
  !url.pathname.startsWith("/chat/demo/") &&
1732
1856
  !/^\/chat\/action\/[0-9]{1,15}$/.test(url.pathname) &&
1733
1857
  !url.pathname.startsWith("/settings") && url.pathname !== "/logout" && url.pathname !== "/people" && url.pathname !== "/ledger" && url.pathname !== "/ledger/export" && url.pathname !== "/metrics" && url.pathname !== "/spend" && url.pathname !== "/spend/budget" &&
@@ -1880,7 +2004,7 @@ export function createDecisionServer(options) {
1880
2004
  const cancelled = store
1881
2005
  .listCancelledBlockersScoped(project, 10, admission)
1882
2006
  .filter(one => visible(one.repo) && visible(one.blockerRepo));
1883
- return sendScreen(response, 200, inboxPage(chromeFor(project, "inbox"), {
2007
+ const inboxData = {
1884
2008
  csrf: who.via === "cookie" ? who.session.csrf : "",
1885
2009
  revision: who.via === "cookie" ? who.session.projectRevision : 0,
1886
2010
  rollup,
@@ -1922,7 +2046,26 @@ export function createDecisionServer(options) {
1922
2046
  return { answering: answering.length, registered: runners.length, lastHeard };
1923
2047
  })(),
1924
2048
  now,
1925
- }));
2049
+ // Console v2: results ready to review and work running now, from the same admitted work index.
2050
+ ...(() => {
2051
+ const row = (one) => ({ taskId: one.rootId, title: one.title, detail: one.status.detail, repo: one.repo });
2052
+ const needsYou = workIndexPage(store, now, workAccess(), { view: "needs-you", limit: 40, project });
2053
+ const runningNow = workIndexPage(store, now, workAccess(), { view: "running", limit: 40, project });
2054
+ return { ready: needsYou.items.filter(one => one.assignmentState === "ready-to-check").map(row), running: runningNow.items.map(row) };
2055
+ })(),
2056
+ };
2057
+ // Which tabs hold something new since this browser last looked (the dots on a phone).
2058
+ const tab = parseInboxTab(url.searchParams.get("tab")) ?? "all";
2059
+ const prints = inboxFingerprints(inboxData);
2060
+ const seenWords = /(?:^|;\s*)so-inbox-seen=([0-9a-f]{8}(?:\.[0-9a-f]{8}){3})(?:;|$)/.exec(request.headers.cookie ?? "")?.[1]?.split(".") ?? null;
2061
+ const order = ["needs-you", "ready", "running", "all"];
2062
+ const seen = Object.fromEntries(order.map((one, index) => [one, seenWords?.[index] ?? ""]));
2063
+ const unread = order.filter(one => seenWords !== null && seen[one] !== prints[one]);
2064
+ for (const one of order)
2065
+ if (tab === "all" || one === tab)
2066
+ seen[one] = prints[one];
2067
+ response.setHeader("Set-Cookie", `so-inbox-seen=${order.map(one => seen[one] || prints[one]).join(".")}; SameSite=Lax; Path=/; Max-Age=31536000`);
2068
+ return sendScreen(response, 200, inboxPage(chromeFor(project, "inbox"), { ...inboxData, tab, unread }));
1926
2069
  }
1927
2070
  if (url.pathname === "/work") {
1928
2071
  // The Work destination (workspace package 1): every task in view as
@@ -2597,7 +2740,7 @@ export function createDecisionServer(options) {
2597
2740
  }
2598
2741
  : null;
2599
2742
  const csrf = who.via === "cookie" ? who.session.csrf : "";
2600
- return sendScreen(response, 200, tasksPage(chromeFor(project, "tasks"), familyTasksInView(project, wanted === null ? undefined : wanted).slice(0, 200), wanted, csrf, null, project, prefill, store.permissionDefault().mode, store.qualityDefault().mode));
2743
+ return sendScreen(response, 200, tasksPage(chromeFor(project, "tasks"), familyTasksInView(project, wanted === null ? undefined : wanted).slice(0, 200), wanted, csrf, null, project, prefill, store.permissionDefault().mode, store.qualityDefault().mode, store.replacements()));
2601
2744
  }
2602
2745
  if (url.pathname === "/queue") {
2603
2746
  const csrf = who.via === "cookie" ? who.session.csrf : "";
@@ -2941,10 +3084,31 @@ export function createDecisionServer(options) {
2941
3084
  }
2942
3085
  return sendScreen(response, 200, capsPage(chromeFor(project, "caps"), store.listCapabilities(project), computeGaps(store, project, now), project, now));
2943
3086
  }
3087
+ // Flows → New: the gallery, and each template's page.
3088
+ if (url.pathname === "/flows/new") {
3089
+ const projects = [...new Set([...(admissionList() ?? []), ...managedRepos(), ...store.knownRepos()])].filter(visible);
3090
+ const canUse = who.via === "cookie" && who.role === "approver" && projects.length > 0;
3091
+ const repo = url.searchParams.get("repo");
3092
+ return sendScreen(response, 200, screen("New flow", `<p><a href="/flows">Flows</a></p><h1>New flow</h1>${galleryHtml({ repo: repo !== null && projects.includes(repo) ? repo : null, canUse })}`, { chrome: chromeFor(project, "flows") }));
3093
+ }
3094
+ const galleryRead = /^\/flows\/new\/([a-z-]{1,40})$/.exec(url.pathname);
3095
+ if (galleryRead !== null) {
3096
+ const template = galleryTemplateOf(galleryRead[1]);
3097
+ const projects = [...new Set([...(admissionList() ?? []), ...managedRepos(), ...store.knownRepos()])].filter(visible);
3098
+ if (template === null)
3099
+ return refuse(response, who, 404, "There's no template by that name.", "/flows/new");
3100
+ if (who.via !== "cookie" || who.role !== "approver")
3101
+ return refuse(response, who, 403, "Sign in as an approver to create flows.", "/flows/new");
3102
+ if (projects.length === 0)
3103
+ return refuse(response, who, 409, "Add a project first.", "/projects");
3104
+ const asked = url.searchParams.get("repo");
3105
+ const repo = asked !== null && projects.includes(asked) ? asked : project !== null && projects.includes(project) ? project : projects[0];
3106
+ return sendGalleryUse(response, who, template, projects, repo, null, template.name, null);
3107
+ }
2944
3108
  if (url.pathname === "/flows") {
2945
3109
  const projects = [...new Set([...(admissionList() ?? []), ...managedRepos(), ...store.knownRepos()])].filter(visible);
2946
3110
  const flows = store.listFlows(projects);
2947
- return sendScreen(response, 200, screen("Flows", `<h1>Flows</h1>${flowsListHtml(store, flows, projects, who.via === "cookie" ? who.session.csrf : "", who.via === "cookie" && who.role === "approver", url.searchParams.get("problem"))}`, { chrome: chromeFor(project, "flows") }));
3111
+ return sendScreen(response, 200, screen("Flows", `<h1>Flows</h1>${flowsListHtml(store, flows, projects, who.via === "cookie" ? who.session.csrf : "", who.via === "cookie" && who.role === "approver", url.searchParams.get("problem"))}`, { chrome: chromeFor(project, "flows"), functional: { script: FLOW_IMPORT_SCRIPT } }));
2948
3112
  }
2949
3113
  // starter kits — the gallery, and each kit's checklist in a project.
2950
3114
  if (url.pathname === "/kits" || /^\/kits\/[a-z-]{1,40}$/.test(url.pathname)) {
@@ -2990,6 +3154,22 @@ export function createDecisionServer(options) {
2990
3154
  return json(404, { said: "No such run." });
2991
3155
  return json(200, { card: run.card, entry: run.entry, script: run.script, version: run.scriptVersion, state: run.state, result: run.result, exitCode: run.exitCode, durationMs: run.durationMs, at: run.finishedAt ?? run.startedAt, log: run.log ?? "" });
2992
3156
  }
3157
+ const flowExport = /^\/flows\/([1-9][0-9]{0,9})\/export$/.exec(url.pathname);
3158
+ if (flowExport !== null) {
3159
+ // A flow as a file (flow-share.ts): zones, paths, trigger settings and scripts; never secrets, addresses, names or cards.
3160
+ const flow = store.getFlow(Number(flowExport[1]));
3161
+ if (flow === null || flow.state !== "active" || !visible(flow.repo))
3162
+ return refuse(response, who, 404, "No such flow in your projects.", "/flows");
3163
+ let exported;
3164
+ try {
3165
+ exported = exportFlow(store, flow, options.configDir ?? null);
3166
+ }
3167
+ catch (error) {
3168
+ return refuse(response, who, 409, error instanceof Error ? error.message : "This flow can't be exported.", `/flows/${flow.id}`);
3169
+ }
3170
+ response.setHeader("Content-Disposition", `attachment; filename="${exported.fileName}"`);
3171
+ return respond(response, 200, "application/json; charset=utf-8", exported.json);
3172
+ }
2993
3173
  const flowLive = /^\/flows\/([1-9][0-9]{0,9})\/live$/.exec(url.pathname);
2994
3174
  if (flowLive !== null) {
2995
3175
  // The live canvas (v88): a nudge the moment the flow changes, and who
@@ -3426,7 +3606,11 @@ export function createDecisionServer(options) {
3426
3606
  }
3427
3607
  // The first run belongs to the lead conversation, for someone who can act on it.
3428
3608
  const firstRun = focusTask === null && who.role === "approver" ? chatFirstRun(now, chatProject) : undefined;
3429
- const withFirstRun = (shown) => firstRun === undefined ? shown : { ...shown, workspace: { ...shown.workspace, firstRun } };
3609
+ const phone = focusTask === null && who.via === "cookie" ? phoneCard(who, now) : undefined;
3610
+ // Console v2: the landing's live view (who is working, four counts, Catch up in tabs).
3611
+ const home = focusTask === null && chatProject === null && !roomId ? chatHomeOf(who, repos, now) : null;
3612
+ const withFirstRun = (shown) => firstRun === undefined && phone === undefined && home === null ? shown
3613
+ : { ...shown, workspace: { ...shown.workspace, ...(firstRun === undefined ? {} : { firstRun }), ...(phone === undefined ? {} : { phone }), ...(home === null ? {} : { home }) } };
3430
3614
  if (enabled.ok && mateSession !== null && principal !== null && !ceilingStale) {
3431
3615
  {
3432
3616
  const said = takeMateNote(who.session.csrf, mateSession.id);
@@ -3453,6 +3637,7 @@ export function createDecisionServer(options) {
3453
3637
  }
3454
3638
  return sendScreen(response, 200, withFirstRun(chatPage(chromeFor(null, "chat", undefined, "all"), {
3455
3639
  settingsOpen: settingsView,
3640
+ firstRunShown: firstRun !== undefined,
3456
3641
  enabled,
3457
3642
  pending,
3458
3643
  latched,
@@ -3675,7 +3860,9 @@ export function createDecisionServer(options) {
3675
3860
  repo: chosen, projects: projects.map(path => ({ path, name: projectName(path) })), starters: startersFor(store, chosen), csrf: who.via === "cookie" ? who.session.csrf : "",
3676
3861
  canSwitch: who.via === "cookie" && who.role === "approver" && !store.isDemo(), suggested: starterOf(url.searchParams.get("starter") ?? "")?.id ?? null,
3677
3862
  said: url.searchParams.get("said"), problem: url.searchParams.get("problem")
3678
- });
3863
+ }) +
3864
+ // Every template, beside the starters: Use this opens its page for this project.
3865
+ `<h2 class="gallery-heading">Templates</h2>${galleryHtml({ repo: chosen, canUse: who.via === "cookie" && who.role === "approver" && !store.isDemo() })}`;
3679
3866
  return sendScreen(response, 200, screen("Flows", `<p><a href="/settings">Settings</a></p><h1>Flows</h1>${content}`, { chrome: chromeFor(chosen || project, "settings") }));
3680
3867
  }
3681
3868
  // Settings → Integrations: which integrations work. The list is the last checks; a render never waits on one.
@@ -3737,6 +3924,18 @@ export function createDecisionServer(options) {
3737
3924
  said: url.searchParams.get("said"), problem: url.searchParams.get("problem") });
3738
3925
  return sendScreen(response, 200, screen("Pull requests", `<p><a href="/projects">Projects</a></p><h1>Pull requests</h1>${html}`, { chrome: chromeFor(project, "projects") }));
3739
3926
  }
3927
+ // Settings → Projects → Checks: how much checks after each build, and the quick command beside the full one.
3928
+ if (url.pathname === "/settings/checks") {
3929
+ const repo = url.searchParams.get("repo") ?? "";
3930
+ const known = [...new Set([...managedRepos(), ...store.knownRepos(), ...store.listProjects().map(one => one.path)])];
3931
+ if (who.via !== "cookie" || !known.includes(repo) || !visible(repo))
3932
+ return refuse(response, who, 404, "No such project.", "/projects");
3933
+ const quick = liveQuickCommand(store, repo);
3934
+ const html = checkSettingsHtml({ repo, name: projectName(repo), csrf: who.session.csrf, canChange: who.role === "approver" && !store.isDemo(),
3935
+ level: projectCheckLevel(store, repo).level, full: store.liveVerifyCommand(repo), quick, suggestion: quick === null ? suggestQuickCommand(repo) : null,
3936
+ said: url.searchParams.get("said"), problem: url.searchParams.get("problem") });
3937
+ return sendScreen(response, 200, screen("Checks", `<p><a href="/projects">Projects</a></p><h1>Checks</h1>${html}`, { chrome: chromeFor(project, "projects") }));
3938
+ }
3740
3939
  // Sprint 8: Settings → Backups, how the last backup went and the schedule. An instance operator's page.
3741
3940
  if (url.pathname === "/settings/backups") {
3742
3941
  const databaseFile = store.databaseFile();
@@ -3830,6 +4029,27 @@ export function createDecisionServer(options) {
3830
4029
  }
3831
4030
  return sendScreen(response, 200, screen("Learning", `<p><a href="/settings">Settings</a></p><h1>Learning</h1>${selector}${content}`, { chrome: chromeFor(chosen || project, "settings") }));
3832
4031
  }
4032
+ // Settings → Lead (onboarding): what runs the lead in one line, and the full form under Advanced.
4033
+ if (url.pathname === "/settings/lead") {
4034
+ if (who.via !== "cookie" || who.role !== "approver")
4035
+ return refuse(response, who, 403, "An approver sets up the lead.", "/settings");
4036
+ if (localSignIn === null)
4037
+ await Promise.race([checkLocalAgents(), new Promise(done => setTimeout(done, 6_000).unref?.())]);
4038
+ const config = store.getChatConfig();
4039
+ const facts = {
4040
+ keyFacts: ["anthropic-api", "openrouter-api"].map(one => {
4041
+ const found = chatKeyFor(one);
4042
+ return { provider: one, state: found === null ? "none" : found.source, tail: found === null || found.source === "environment" ? null : redactToken(found.key) };
4043
+ }),
4044
+ openrouterModels: (await chatCatalog())?.map(one => one.id) ?? null,
4045
+ liveModels: [...modelOptions(store, "claude", now, providerHome), ...modelOptions(store, "codex", now, providerHome)],
4046
+ csrf: who.session.csrf,
4047
+ returnTo: "/settings/lead",
4048
+ };
4049
+ const signedIn = localSignIn?.states.claude === "connected" ? "Claude Code" : localSignIn?.states.codex === "connected" ? "Codex" : null;
4050
+ return sendScreen(response, 200, screen("Lead", leadSettingsHtml({ config, facts, words: leadWords(), signedIn, command: agentSignInCommand(),
4051
+ said: url.searchParams.get("said") }), { chrome: chromeFor(project, "settings") }));
4052
+ }
3833
4053
  if (url.pathname === "/settings/telegram") {
3834
4054
  // Any approver pairs their OWN phone here; the bot token stays on /settings.
3835
4055
  if (who.via !== "cookie" || who.role !== "approver")
@@ -3913,7 +4133,7 @@ export function createDecisionServer(options) {
3913
4133
  return at === null || since === null ? null : firstResultWords(since, at);
3914
4134
  })(), (() => {
3915
4135
  const chosen = store.notificationPreference(who.name);
3916
- return { mode: chosen.mode, digestAt: chosen.digestAt };
4136
+ return { mode: chosen.mode, digestAt: chosen.digestAt, projects: notificationProjects(store, who.name) };
3917
4137
  })()));
3918
4138
  }
3919
4139
  if (url.pathname === "/push/key") {
@@ -3938,6 +4158,18 @@ export function createDecisionServer(options) {
3938
4158
  const back = decisionReturn === null ? null : safeChatReturn(decisionReturn);
3939
4159
  return sendScreen(response, 200, decisionPage(chromeFor(project, "none"), decision, taskId, store.evidenceFor(decision.id), who, now, back));
3940
4160
  }
4161
+ // While no agent is signed in, Chat asks this computer again on its own (onboarding): a fresh, non-spending sign-in
4162
+ // check at most every few seconds, and the lead turns on as soon as one is found.
4163
+ if (url.pathname === "/lead/status") {
4164
+ if (who.via !== "cookie")
4165
+ return respond(response, 403, "application/json", JSON.stringify({ error: "sign in" }));
4166
+ if (Date.now() - leadRecheckedAt > 4_000) {
4167
+ leadRecheckedAt = Date.now();
4168
+ await Promise.race([checkLocalAgents(true), new Promise(done => setTimeout(done, 8_000).unref?.())]);
4169
+ }
4170
+ response.setHeader("cache-control", "no-store");
4171
+ return respond(response, 200, "application/json", JSON.stringify({ lead: store.getChatConfig() === null ? "off" : "on", agent: localSignIn?.signedIn ?? null, command: agentSignInCommand() }));
4172
+ }
3941
4173
  if (url.pathname === "/chat/task-status") {
3942
4174
  if (who.via !== "cookie")
3943
4175
  return respond(response, 403, "text/plain; charset=utf-8", "sign in to see this task");
@@ -3963,7 +4195,7 @@ export function createDecisionServer(options) {
3963
4195
  function firstRunStepsNow(now) {
3964
4196
  if (restricted() || store.isDemo() || store.firstSuccessAt(now) !== null)
3965
4197
  return null;
3966
- return firstRunSteps({ agentSignedIn: agentSignedIn(now), projects: managedRepos().length, hasTask: store.hasAnyWork(), firstResultAt: null });
4198
+ return firstRunSteps({ agentSignedIn: agentSignedIn(now), projects: managedRepos().length, hasTask: store.hasAnyWork(), firstResultAt: null, signInCommand: agentSignInCommand() });
3967
4199
  }
3968
4200
  /**
3969
4201
  * Whether a coding agent is signed in where the work runs. This machine's
@@ -3983,17 +4215,54 @@ export function createDecisionServer(options) {
3983
4215
  return options.localRunner !== undefined || registered.length === 0 || workers.some(one => one.host === here) ? localAgentSignedIn() : false;
3984
4216
  }
3985
4217
  let localSignIn = null;
3986
- let localSignInChecking = false;
4218
+ let localSignInChecking = null;
4219
+ let leadRecheckedAt = 0;
4220
+ /** Ask this computer's agent CLIs again (no model, nothing spent); `fresh` skips their 30-second cache. */
4221
+ function checkLocalAgents(fresh = false) {
4222
+ if (localSignInChecking !== null)
4223
+ return localSignInChecking;
4224
+ localSignInChecking = Promise.all(["claude", "codex"].map(one => connectionCheck(one, fresh).then(value => value.state, () => "unverified")))
4225
+ .then(([claude, codex]) => {
4226
+ localSignIn = { at: Date.now(), signedIn: [claude, codex].some(state => state === "connected" || state === "key-works" || state === "key-present"), states: { claude: claude, codex: codex } };
4227
+ leadByDefault();
4228
+ })
4229
+ .catch(() => { })
4230
+ .finally(() => { localSignInChecking = null; });
4231
+ return localSignInChecking;
4232
+ }
3987
4233
  function localAgentSignedIn() {
3988
- if (!localSignInChecking && (localSignIn === null || Date.now() - localSignIn.at > 30_000)) {
3989
- localSignInChecking = true;
3990
- void Promise.all(["claude", "codex"].map(one => connectionCheck(one).then(value => value.state, () => "unverified")))
3991
- .then(states => { localSignIn = { at: Date.now(), signedIn: states.some(state => state === "connected" || state === "key-works" || state === "key-present") }; })
3992
- .catch(() => { })
3993
- .finally(() => { localSignInChecking = false; });
3994
- }
4234
+ if (localSignInChecking === null && (localSignIn === null || Date.now() - localSignIn.at > 30_000))
4235
+ void checkLocalAgents();
3995
4236
  return localSignIn?.signedIn ?? null;
3996
4237
  }
4238
+ /**
4239
+ * The lead on by default (onboarding): with no lead set up yet, the agent CLI signed in on this computer runs it
4240
+ * (Claude Code first, else Codex) on its membership with today's safe defaults: the CLI's own default model, 50 turns
4241
+ * a day, no dollar spend, and every action it proposes still a card a person confirms. Once only, ever: a lead
4242
+ * someone turned off stays off, and Settings → Lead changes it.
4243
+ */
4244
+ function leadByDefault() {
4245
+ if (options.leadByDefault !== true || store.isDemo() || store.getChatConfig() !== null || store.installationFact(LEAD_BY_DEFAULT_FACT) !== null)
4246
+ return;
4247
+ const provider = localSignIn?.states.claude === "connected" ? "claude-subscription" : localSignIn?.states.codex === "connected" ? "codex-subscription" : null;
4248
+ if (provider === null)
4249
+ return;
4250
+ const now = clock();
4251
+ store.setChatConfig({ provider, model: "default", dailyTurns: 50, weeklyCeilingMicrousd: 0, priceInMicrousd: 0, priceOutMicrousd: 0 }, "toolroll", now);
4252
+ store.recordInstallationFact(LEAD_BY_DEFAULT_FACT, provider, now);
4253
+ }
4254
+ // Look once at start, so the lead is on by the time the browser opens.
4255
+ if (options.leadByDefault === true && !store.isDemo() && store.getChatConfig() === null)
4256
+ void checkLocalAgents();
4257
+ /** What the lead runs on, in words for one line ("your Claude Code sign-in"); null for a direct API or no lead. */
4258
+ function leadWords() {
4259
+ const config = store.getChatConfig();
4260
+ return config?.provider === "claude-subscription" ? "your Claude Code sign-in" : config?.provider === "codex-subscription" ? "your Codex sign-in" : null;
4261
+ }
4262
+ /** The one command that gets an agent signed in here, for this operating system: sign in when its CLI is installed, else install Claude Code and sign in. */
4263
+ function agentSignInCommand() {
4264
+ return signInCommandFor(localSignIn?.states ?? null, options.platform ?? process.platform);
4265
+ }
3997
4266
  /** First tasks per project: the last ones found (safe generic ones until then), re-read in the background at most
3998
4267
  * every ten minutes. A render never waits on `gh` or `git grep`. */
3999
4268
  const firstTasks = new Map();
@@ -4017,10 +4286,43 @@ export function createDecisionServer(options) {
4017
4286
  return undefined;
4018
4287
  const taskFiled = steps.find(one => one.key === "task")?.done ?? true;
4019
4288
  const repo = project ?? managedRepos()[0] ?? null;
4289
+ const agent = steps.find(one => one.key === "agent");
4290
+ const lead = leadWords();
4020
4291
  return {
4021
4292
  steps,
4022
4293
  suggestions: taskFiled || repo === null ? [] : firstTasksFor(repo),
4023
- sandbox: steps.find(one => one.key === "agent" && !one.done && !one.checking) ? SANDBOX_COMMAND : null,
4294
+ sandbox: agent !== undefined && !agent.done && !agent.checking ? SANDBOX_COMMAND : null,
4295
+ intro: HOW_IT_WORKS,
4296
+ lead: lead === null ? null : { words: `The lead uses ${lead}`, href: "/settings/lead" },
4297
+ // With no lead yet and no agent signed in, the page asks this computer again on its own.
4298
+ recheck: store.getChatConfig() === null && agent !== undefined && !agent.done ? "/lead/status" : null,
4299
+ };
4300
+ }
4301
+ /** The first task's way to Ready shows until the first Ready result, and stays on the installation's first task. */
4302
+ function guided(rootId, now) {
4303
+ if (store.isDemo())
4304
+ return false;
4305
+ if (store.firstSuccessAt(now) === null)
4306
+ return true;
4307
+ const first = store.handle.prepare("SELECT id FROM task ORDER BY created_at, rowid LIMIT 1").get();
4308
+ return first !== undefined && String(first["id"]) === rootId;
4309
+ }
4310
+ /** After the first Ready result, once (onboarding): the phone, by a chat app or the console over Tailscale. */
4311
+ function phoneCard(who, now) {
4312
+ if (who.role !== "approver" || store.isDemo() || store.firstSuccessAt(now) === null || store.installationFact(PHONE_CARD_FACT) !== null)
4313
+ return undefined;
4314
+ const botId = options.telegramTokenFile === undefined ? null : loadBotToken(process.env, options.telegramTokenFile)?.botId ?? null;
4315
+ if (botId !== null && store.liveTelegramBindings(botId).length > 0)
4316
+ return undefined;
4317
+ const port = servedPort() ?? 4180;
4318
+ const address = server.address();
4319
+ const bound = typeof address === "object" && address !== null ? address.address : "127.0.0.1";
4320
+ const name = tailnet[0] ?? null;
4321
+ const reachable = bound !== "127.0.0.1" && bound !== "::1";
4322
+ return {
4323
+ chatApps: [{ label: "Telegram", href: "/settings/telegram" }, { label: "Slack", href: "/settings/slack" }, { label: "Discord", href: "/settings/discord" }, { label: "Teams", href: "/settings/teams" }],
4324
+ tailnet: name === null ? null : { address: `http://${name}:${port}/`, restart: reachable ? null : consoleCommand({ host: "0.0.0.0" }) },
4325
+ dismissHref: "/onboarding/phone/dismiss",
4024
4326
  };
4025
4327
  }
4026
4328
  // ---- fleet chat (v13) ----------------------------------------------------
@@ -4218,7 +4520,7 @@ export function createDecisionServer(options) {
4218
4520
  return { ok: false, code: "empty", why: "add a project first — chat will include it automatically" };
4219
4521
  const config = store.getChatConfig();
4220
4522
  if (config === null)
4221
- return { ok: false, code: "unconfigured", why: "chat is not configured yet — set it up below, or from the terminal: toolroll config set chat" };
4523
+ return { ok: false, code: "unconfigured", why: "the lead is off — turn it on in Settings → Lead, or from the terminal: toolroll config set chat" };
4222
4524
  if (isSubscriptionChatProvider(config.provider)) {
4223
4525
  return {
4224
4526
  ok: true,
@@ -4642,6 +4944,8 @@ export function createDecisionServer(options) {
4642
4944
  ...(s.chrome.signIn === undefined ? {} : { signIn: s.chrome.signIn }),
4643
4945
  ...(s.chrome.update === undefined ? {} : { update: s.chrome.update }),
4644
4946
  ...(extras.firstRun === undefined ? {} : { firstRun: extras.firstRun }),
4947
+ ...(extras.phone === undefined ? {} : { phone: extras.phone }),
4948
+ ...(extras.home == null ? {} : { home: extras.home }),
4645
4949
  };
4646
4950
  if (requestFacts.workspaceRead) {
4647
4951
  const validator = requestFacts.workspaceValidator;
@@ -4695,6 +4999,38 @@ export function createDecisionServer(options) {
4695
4999
  * package 1): Work's own Needs-you membership, cached per viewer and
4696
5000
  * project, so the rail, the phone tab, the scope bar, the project cards,
4697
5001
  * the chat overview, and the Needs-you tab never disagree. */
5002
+ /** The Chat landing's live view: each agent at work now with its task and
5003
+ * phase, four counts, plan-window use (never dollars: subscriptions don't
5004
+ * bill per run) and Catch up's items, each tagged with its tab. Reads only. */
5005
+ function chatHomeOf(who, repos, now) {
5006
+ const access = { principal: "operator", repos: admissionList(), includeUnplaced: visible(null) };
5007
+ const admitted = (repo) => repo === null ? visible(null) : repos.includes(repo);
5008
+ const agents = store.liveRuns(now).filter(run => admitted(run.repo)).map(run => {
5009
+ const root = familyOf(run.taskId)?.root ?? null;
5010
+ return { runId: run.id, taskId: root?.id ?? run.taskId, title: root?.title ?? run.title, href: taskHref(root?.id ?? run.taskId),
5011
+ agent: `${providerName(run.provider)} on ${run.runner}`, phase: homePhaseWords(run), project: run.repo === null ? null : projectName(run.repo), since: run.startedAt };
5012
+ });
5013
+ const all = workIndexPage(store, now, access, { view: "all", limit: WORK_INDEX_MAX_LIMIT });
5014
+ const needs = workIndexPage(store, now, access, { view: "needs-you", limit: WORK_INDEX_MAX_LIMIT });
5015
+ const done = workIndexPage(store, now, access, { view: "completed", limit: WORK_INDEX_MAX_LIMIT });
5016
+ const weekAgo = new Date(now.getTime() - 7 * 86_400_000).toISOString();
5017
+ const counts = [
5018
+ { key: "working", label: "Working now", value: agents.length, href: "/work?view=running" },
5019
+ { key: "waiting", label: "Waiting on you", value: needs.items.filter(one => one.assignmentState === "needs-decision").length, href: "/work?view=needs-you" },
5020
+ { key: "ready", label: "Ready to review", value: needs.items.filter(one => one.assignmentState === "ready-to-check").length, href: "/work?view=needs-you" },
5021
+ { key: "done", label: "Done this week", value: done.items.filter(one => (one.completion?.at ?? one.updatedAt) >= weekAgo).length, href: "/work?view=completed" },
5022
+ ];
5023
+ const tabOf = (state) => state === "needs-decision" ? "needs-you" : state === "ready-to-check" ? "ready" : state === "working" || state === "checking" ? "running" : "finished";
5024
+ const catchUp = all.items.filter(one => tabOf(one.assignmentState) !== "finished" || one.updatedAt >= weekAgo).slice(0, 40).map(one => ({
5025
+ id: one.rootId, title: one.title, href: taskHref(one.rootId), project: one.repo === null ? null : projectName(one.repo), tab: tabOf(one.assignmentState),
5026
+ label: one.status.label, tone: one.status.tone, detail: one.status.detail, at: one.updatedAt,
5027
+ }));
5028
+ const windows = who.via === "cookie" && store.isInstanceOperator(who.name) ? limitsView(store.providerLimits(), [], { project: projectName, teammate: id => `Teammate ${id}` }, now) : null;
5029
+ return {
5030
+ agents, counts, catchUp, allHref: "/work",
5031
+ planUse: (windows?.tiles ?? []).map(one => ({ name: one.name, window: one.window, percent: one.percent, detail: one.detail, tone: one.tone })),
5032
+ };
5033
+ }
4698
5034
  function needsYouBadge(project) {
4699
5035
  const actor = requestContext.getStore()?.actor;
4700
5036
  const key = `${actor ?? ""}:${actor === undefined ? "" : store.accountOf(actor)?.generation}:${project ?? ""}`;
@@ -4891,6 +5227,7 @@ export function createDecisionServer(options) {
4891
5227
  verdict: verdict?.verdict ?? null,
4892
5228
  reasons: verdict?.reasons ?? [],
4893
5229
  accepted: store.proofAcceptance(latest.id) !== null,
5230
+ checkLevel: resultCheckLevel(store, latest.id, now),
4894
5231
  review: reviewFactsFor(latest.id),
4895
5232
  ...(latest.outcome !== "no-change"
4896
5233
  ? {}
@@ -4907,8 +5244,6 @@ export function createDecisionServer(options) {
4907
5244
  const result = receipt === undefined && task.state === "done" && latest !== null && (latest.outcome === "built" || latest.outcome === "no-change")
4908
5245
  ? completionReceiptView(store, latest, store.artifactsFor(latest.id), evidenceRoot, reviewFactsFor(latest.id)) : receipt;
4909
5246
  const status = workStatusOf(facts, result == null ? undefined : receiptStatusOf(result));
4910
- if (store.lookupRef(task.id)?.revisionOf !== null && live !== null && status.tone === "live")
4911
- status.label = "Revising";
4912
5247
  const earlierLive = task.family === undefined ? [] : earlierLiveVersions(task.family, now);
4913
5248
  if (earlierLive.length > 0 && !status.views.includes("running"))
4914
5249
  status.views = [...status.views, "running"];
@@ -5020,7 +5355,7 @@ export function createDecisionServer(options) {
5020
5355
  peeks[one.path] = peekOf(one.path);
5021
5356
  for (const path of candidates)
5022
5357
  peeks[path] = peekOf(path);
5023
- 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));
5358
+ 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, who.via === "cookie" ? (path) => projectCheckLevel(store, path).level : undefined));
5024
5359
  }
5025
5360
  /** The task screen, shared by the GET and by every refusal that re-renders it. */
5026
5361
  /**
@@ -5143,6 +5478,7 @@ export function createDecisionServer(options) {
5143
5478
  const assignment = freshAssignment(recordedAssignment, completion?.receipt ?? null);
5144
5479
  const progressRun = runs.find(one => runIsLive(one))?.id ?? completion?.runId ?? null;
5145
5480
  return {
5481
+ guide: guided(family?.root.id ?? taskId, now),
5146
5482
  assignment,
5147
5483
  checkProgress: progressRun === null ? null : store.checkProgress(progressRun),
5148
5484
  task: found,
@@ -5434,6 +5770,7 @@ export function createDecisionServer(options) {
5434
5770
  id: family.root.id,
5435
5771
  executionId: task.id,
5436
5772
  family,
5773
+ guide: guided(family.root.id, now),
5437
5774
  history: familyHistory(family),
5438
5775
  title: family.root.title,
5439
5776
  state: task.state,
@@ -5495,8 +5832,12 @@ export function createDecisionServer(options) {
5495
5832
  const publishing = publishingOf(store, repo);
5496
5833
  const target = publishing.on ? publishing.base : null;
5497
5834
  const view = newestPullRequestOf(store, family === null ? [taskId] : family.versions.map(one => one.id));
5498
- if (view !== null)
5499
- return { taskId: rootId, view: { ...view, canMerge: view.canMerge && who.role === "approver" && who.via === "cookie" }, offer: null, target };
5835
+ if (view !== null) {
5836
+ const publication = store.publicationForRun(view.runId);
5837
+ const fact = publication === null ? null : { ...pullRequestFactOf({ ...publication, merged: view.state === "merged" }),
5838
+ note: view.state === "merged" ? `${view.detail}${view.mergeCommit === null ? "" : ` Merge commit ${view.mergeCommit.slice(0, 12)}.`}` : null };
5839
+ return { taskId: rootId, view: { ...view, canMerge: view.canMerge && who.role === "approver" && who.via === "cookie" }, offer: null, target, fact };
5840
+ }
5500
5841
  if (!publishing.on || who.via !== "cookie" || who.role !== "approver")
5501
5842
  return null;
5502
5843
  const assignment = assignmentOf(store, taskId, clock(), { principal: "operator", repos: admissionList(), includeUnplaced: visible(null) }, evidenceRoot);
@@ -5521,6 +5862,12 @@ export function createDecisionServer(options) {
5521
5862
  if (cancelDraft !== undefined)
5522
5863
  presentedData.cancelDraft = cancelDraft;
5523
5864
  presentedData.pullRequest = taskPullRequestOf(taskId, who);
5865
+ // The thread reads the whole family: the original, its revisions, and every attempt across them.
5866
+ if (family !== null && family.problem === null) {
5867
+ presentedData.family = { root: { id: family.root.id, title: family.root.title, createdAt: family.root.createdAt, goal: store.getScope(family.root.id)?.goal ?? null },
5868
+ versions: family.versions.map(one => ({ id: one.id, title: one.title, state: one.state })),
5869
+ runs: family.versions.flatMap(version => version.id === data.task.id ? data.runs.map(run => ({ ...run, taskId: version.id })) : store.runsFor(version.refId).map(run => ({ ...run, taskId: version.id }))) };
5870
+ }
5524
5871
  const paneProject = restricted() ? store.lookupRef(taskId)?.repo ?? null : who.via === "cookie" ? who.session.project : null;
5525
5872
  const page = taskPage(paneProject === null && !unscopedMode
5526
5873
  ? chromeFor(paneProject, "tasks")
@@ -5528,10 +5875,13 @@ export function createDecisionServer(options) {
5528
5875
  // The task's own conversation, docked beside the page (v77).
5529
5876
  const focus = taskChatFocus(family?.root.id ?? taskId, clock(), who, { mintNonce: false });
5530
5877
  const docked = focus === null ? null : dockedConversation(who, focus, null, clock(), taskHref(focus.id));
5531
- // The panel is the Ask view here, so the page drops its own Ask tab.
5532
- const taskView = page.workspace?.view?.kind === "task" ? { ...page.workspace.view, tabs: [] } : page.workspace?.view;
5878
+ // The conversation is the page's own thread here, so the page drops its Ask tab;
5879
+ // only an approver is offered a road to message the agent.
5880
+ const taskView = page.workspace?.view?.kind === "task" ? { ...page.workspace.view, ...(docked === null ? {} : { tabs: [] }), ...(who.role === "approver" ? {} : { chatHref: null }) } : page.workspace?.view;
5533
5881
  if (docked !== null)
5534
5882
  page.workspace = { ...page.workspace, ...(taskView === undefined ? {} : { view: taskView }), conversation: docked, pageHtml: page.body };
5883
+ else if (taskView !== undefined && page.workspace !== undefined)
5884
+ page.workspace = { ...page.workspace, view: taskView };
5535
5885
  return sendScreen(response, status, page);
5536
5886
  }
5537
5887
  /** The routine screen: the standing order restated, its verbs, its ledger. */
@@ -6354,14 +6704,45 @@ export function createDecisionServer(options) {
6354
6704
  return redirect(response, `/teammates?said=${encodeURIComponent(done.said)}`);
6355
6705
  return redirect(response, `${back}?${done.ok ? "said" : "problem"}=${encodeURIComponent(done.said)}${action === "note" ? "#memory" : ""}`);
6356
6706
  }
6707
+ // Flows → New → a template: preview these answers, or create exactly what was previewed.
6708
+ const galleryPost = /^\/flows\/new\/([a-z-]{1,40})$/.exec(url.pathname);
6709
+ if (galleryPost !== null) {
6710
+ const template = galleryTemplateOf(galleryPost[1]);
6711
+ const projects = [...new Set([...(admissionList() ?? []), ...managedRepos(), ...store.knownRepos()])].filter(visible);
6712
+ if (template === null)
6713
+ return refuse(response, who, 404, "There's no template by that name.", "/flows/new");
6714
+ if (who.via !== "cookie" || who.role !== "approver" || store.isDemo())
6715
+ return refuse(response, who, 403, "Sign in as an approver to create flows.", "/flows/new");
6716
+ const repo = body.get("repo") ?? "";
6717
+ if (!projects.includes(repo))
6718
+ return refuse(response, who, 404, "Choose one of your projects.", "/flows/new");
6719
+ const answers = Object.fromEntries(template.asks.map(ask => [ask.key, body.get(ask.key) ?? ""]));
6720
+ const name = (body.get("name") ?? "").replace(/[\u0000-\u001f\u007f]/g, "").trim().slice(0, 80) || template.name;
6721
+ if (body.get("intent") !== "create")
6722
+ return sendGalleryUse(response, who, template, projects, repo, answers, name, null);
6723
+ // What's made is what was previewed: changed answers are previewed again first.
6724
+ let digest = null;
6725
+ try {
6726
+ digest = previewGallery(store, template, repo, answers, who.name, clock()).digest;
6727
+ }
6728
+ catch {
6729
+ digest = null;
6730
+ }
6731
+ if (digest === null || digest !== body.get("previewed"))
6732
+ return sendGalleryUse(response, who, template, projects, repo, answers, name, digest === null ? null : "Your answers changed. Check the preview, then create the flow.");
6733
+ const used = useGalleryTemplate(store, template, repo, answers, { name, by: who.name, now: clock(), dir: options.configDir ?? null });
6734
+ if (!used.ok)
6735
+ return sendGalleryUse(response, who, template, projects, repo, answers, name, used.said);
6736
+ return redirect(response, `/flows/${used.flow}`);
6737
+ }
6357
6738
  const flowPost = /^\/flows\/([1-9][0-9]{0,9})\/(save|cards|archive|triggers|linear-key|hooks-address|scripts|secrets)$/.exec(url.pathname) ?? /^\/flows\/([1-9][0-9]{0,9})\/cards\/([1-9][0-9]{0,9})\/(move|decide|cancel|comment|assign|watch)$/.exec(url.pathname);
6358
6739
  const triggerPost = /^\/flows\/([1-9][0-9]{0,9})\/triggers\/([1-9][0-9]{0,9})\/(pause|resume|remove|check|press|renew|secret|share|unshare)$/.exec(url.pathname);
6359
- if (url.pathname === "/flows/new" || url.pathname === "/flows/example" || flowPost !== null || triggerPost !== null) {
6740
+ if (url.pathname === "/flows/new" || url.pathname === "/flows/example" || url.pathname === "/flows/import" || flowPost !== null || triggerPost !== null) {
6360
6741
  const now = clock();
6361
6742
  const answer = (status, payload) => respond(response, status, "application/json; charset=utf-8", JSON.stringify(payload));
6362
6743
  const projects = [...new Set([...(admissionList() ?? []), ...managedRepos(), ...store.knownRepos()])].filter(visible);
6363
6744
  if (who.via !== "cookie" || who.role !== "approver")
6364
- return url.pathname === "/flows/new" || url.pathname === "/flows/example" ? refuse(response, who, 403, "Sign in as an approver to create flows.", "/flows") : answer(403, { ok: false, said: "Sign in as an approver to change flows." });
6745
+ return url.pathname === "/flows/new" || url.pathname === "/flows/example" || url.pathname === "/flows/import" ? refuse(response, who, 403, "Sign in as an approver to create flows.", "/flows") : answer(403, { ok: false, said: "Sign in as an approver to change flows." });
6365
6746
  if (url.pathname === "/flows/example") {
6366
6747
  // A first look (v88): the Email replies template with one sample question, which Claude drafts a reply to straight away.
6367
6748
  const repo = body.get("repo") ?? "";
@@ -6372,6 +6753,52 @@ export function createDecisionServer(options) {
6372
6753
  addCardToFlow(store, store.getFlow(id), { title: "Do you ship to Canada?", description: "Hi! I'm thinking of ordering but I live in Toronto. Do you ship there, and how long does it take? — sam@example.com", stage: null }, who.name, now);
6373
6754
  return redirect(response, `/flows/${id}`);
6374
6755
  }
6756
+ if (url.pathname === "/flows/import") {
6757
+ // A flow file, chosen or fetched from a gist: previewed in plain words, then made with its triggers off and its scripts held.
6758
+ const repo = body.get("repo") ?? "";
6759
+ const back = (problem) => redirect(response, `/flows?problem=${encodeURIComponent(problem)}`);
6760
+ if (!projects.includes(repo))
6761
+ return back("Choose one of your projects.");
6762
+ let document = body.get("document") ?? "";
6763
+ const address = (body.get("url") ?? "").trim();
6764
+ let file;
6765
+ try {
6766
+ if (document.trim() === "" && address !== "")
6767
+ document = await fetchFlowFile(address, options.flowFetch);
6768
+ if (document.trim() === "")
6769
+ return back("Choose a flow file or give its address.");
6770
+ file = parseFlowFile(document);
6771
+ }
6772
+ catch (error) {
6773
+ if (error instanceof FlowFileError)
6774
+ return back(error.message);
6775
+ throw error;
6776
+ }
6777
+ const given = Object.fromEntries(file.parameters.flatMap(one => body.has(`param.${one.id}`) ? [[one.id, body.get(`param.${one.id}`) ?? ""]] : []));
6778
+ // What was previewed: the file and every value it resolved to (a default the first preview filled in included).
6779
+ const previewedOf = (plan) => createHash("sha256").update(`${document}\0${JSON.stringify(plan?.values ?? given)}`).digest("hex").slice(0, 32);
6780
+ const page = (plan, problem, status) => sendScreen(response, status, screen(`Import ${file.name}`, `<p><a href="/flows">Flows</a></p><h1>Import ${escape(file.name)}</h1>${flowImportHtml({ plan, file, document, repo, csrf: who.session.csrf, values: given, previewed: previewedOf(plan), problem })}`, { chrome: chromeFor(repo, "flows") }));
6781
+ let plan;
6782
+ try {
6783
+ plan = planFlowImport(store, repo, file, given, who.name);
6784
+ }
6785
+ catch (error) {
6786
+ if (error instanceof FlowFileError)
6787
+ return page(null, error.message, 400);
6788
+ throw error;
6789
+ }
6790
+ if (body.get("confirm") === "yes") {
6791
+ if (body.get("previewed") !== previewedOf(plan))
6792
+ return page(plan, "What you filled in changed. Check the preview again, then import.", 409);
6793
+ try {
6794
+ return redirect(response, `/flows/${importFlow(store, plan, who.name, now, options.configDir ?? null).id}`);
6795
+ }
6796
+ catch (error) {
6797
+ return page(plan, error instanceof Error ? error.message : "That flow couldn't be imported.", 400);
6798
+ }
6799
+ }
6800
+ return page(plan, null, 200);
6801
+ }
6375
6802
  if (url.pathname === "/flows/new") {
6376
6803
  const repo = body.get("repo") ?? "";
6377
6804
  if (!projects.includes(repo))
@@ -6467,6 +6894,8 @@ export function createDecisionServer(options) {
6467
6894
  }
6468
6895
  if (action === "scripts") {
6469
6896
  // The project's script library, reached from any of its flows.
6897
+ if (body.get("approve") === "yes")
6898
+ return store.approveFlowScript(flow.repo, body.get("name") ?? "") ? settle("Approved. Zones that run it go on.") : answer(404, { ok: false, said: "That script isn't waiting for approval." });
6470
6899
  if (body.get("remove") === "yes")
6471
6900
  return store.removeFlowScript(flow.repo, body.get("name") ?? "") ? settle("Script removed. Zones that ran it wait until it's back.") : answer(404, { ok: false, said: "There's no script by that name." });
6472
6901
  const saved = saveScript(store, flow.repo, { name: body.get("name"), about: body.get("about"), body: body.get("body"), timeoutMinutes: body.get("timeoutMinutes"), language: body.get("language"), file: body.get("file") }, who.name, now);
@@ -6591,6 +7020,26 @@ export function createDecisionServer(options) {
6591
7020
  return back("said", `${label}: ${spendUsd(saved.limitMicrousd)} a month${saved.hardStop ? ", API work stops at 100%" : ", alerts only"}.`);
6592
7021
  }
6593
7022
  // Delete a project, in two steps: its name, then what goes and the password. An instance operator; the ledger keeps who and what.
7023
+ // Settings → Project → Builds at once: an approver for the project sets it; the ledger keeps before → after.
7024
+ if (url.pathname === "/settings/project/concurrency") {
7025
+ const repo = body.get("repo") ?? "";
7026
+ const known = [...new Set([...(admissionList() ?? []), ...managedRepos(), ...store.knownRepos()])].filter(visible);
7027
+ if (!known.includes(repo))
7028
+ return refuse(response, who, 403, "That project is outside your access.", "/settings/project");
7029
+ if (who.via !== "cookie" || who.role !== "approver" || !store.accountCanAccess(who.name, repo))
7030
+ return refuse(response, who, 403, "An approver for this project sets how many tasks build at once.", "/settings/project");
7031
+ const back = (key, words) => redirect(response, `/settings/project?repo=${encodeURIComponent(repo)}&${key}=${encodeURIComponent(words)}`);
7032
+ const n = parseProjectConcurrency(body.get("concurrency") ?? "");
7033
+ if (n === null)
7034
+ return back("problem", "Builds at once is a whole number from 1 to 64. Nothing changed.");
7035
+ const databaseFile = store.databaseFile();
7036
+ if (databaseFile === null)
7037
+ return back("problem", "This database keeps no settings files. Nothing changed.");
7038
+ const changed = saveProjectConcurrency(databaseFile, repo, n);
7039
+ store.recordProjectConcurrency(who.name, repo, changed.before, changed.after, now);
7040
+ store.bumpWake();
7041
+ return back("said", changed.before === changed.after ? "Nothing changed." : `Saved. Up to ${n} at once.`);
7042
+ }
6594
7043
  if (url.pathname === "/settings/project/delete") {
6595
7044
  if (who.via !== "cookie" || !store.isInstanceOperator(who.name))
6596
7045
  return refuse(response, who, 403, "An instance operator deletes projects.", "/settings/project");
@@ -6892,6 +7341,48 @@ export function createDecisionServer(options) {
6892
7341
  }
6893
7342
  return back("problem", "That change isn't available.");
6894
7343
  }
7344
+ // Checks for one project: the level, or the quick command (approved like `verify set --quick`). Each takes
7345
+ // the password and lands in the ledger.
7346
+ if (url.pathname === "/settings/checks") {
7347
+ const repo = body.get("repo") ?? "";
7348
+ const known = [...new Set([...managedRepos(), ...store.knownRepos(), ...store.listProjects().map(one => one.path)])];
7349
+ if (who.via !== "cookie" || !known.includes(repo) || !visible(repo))
7350
+ return refuse(response, who, 404, "No such project.", "/projects");
7351
+ if (who.role !== "approver" || store.isDemo())
7352
+ return refuse(response, who, 403, "An approver sets a project's checks.", "/projects");
7353
+ const back = (key, words) => redirect(response, `/settings/checks?repo=${encodeURIComponent(repo)}&${key}=${encodeURIComponent(words)}`);
7354
+ if (!authenticateApprover(store, who.name, body.get("password") ?? "", repo).ok)
7355
+ return back("problem", "Enter your Toolroll password to change checks.");
7356
+ const act = body.get("act");
7357
+ if (act === "level") {
7358
+ const level = body.get("level");
7359
+ if (!isCheckLevel(level))
7360
+ return back("problem", "Choose Quick, Full or Off.");
7361
+ const changed = setProjectCheckLevel(store, repo, level, who.name, now);
7362
+ return back("said", changed.changed ? `Checks are ${CHECK_LEVEL_WORDS[level]}.` : "Saved.");
7363
+ }
7364
+ if (act === "quick") {
7365
+ const command = (body.get("command") ?? "").trim();
7366
+ const seconds = Number(body.get("timeout") ?? "180");
7367
+ if (command === "" || command.length > 2000 || hasDisguisedText(command))
7368
+ return back("problem", "Enter the quick command: one line of up to 2000 characters.");
7369
+ if (/([A-Za-z0-9_-]*(?:token|secret|password|passwd|apikey|api_key|authorization|bearer|credential)[A-Za-z0-9_-]*\s*[=:]\s*)(?![$"']?\$)\S+/i.test(command) || /\/\/[^\s/@]+:[^\s/@]+@/.test(command)) {
7370
+ return back("problem", "That command seems to hold a credential. Use an environment variable instead.");
7371
+ }
7372
+ if (!Number.isInteger(seconds) || seconds < 1 || seconds > 3600)
7373
+ return back("problem", "Choose a time limit from 1 to 3600 seconds.");
7374
+ store.setVerifyCommand({ repo: quickVerifyKey(repo), command, timeoutMs: seconds * 1000, approvedBy: who.name }, now);
7375
+ store.recordAction({ at: now.toISOString(), actor: who.name, repo, taskId: null, runId: null, action: "quick check approved", outcome: "approved", source: "policy", detail: command.slice(0, 200) });
7376
+ return back("said", "Quick check approved.");
7377
+ }
7378
+ if (act === "quick-clear") {
7379
+ const cleared = store.clearVerifyCommand(quickVerifyKey(repo), who.name, now);
7380
+ if (cleared)
7381
+ store.recordAction({ at: now.toISOString(), actor: who.name, repo, taskId: null, runId: null, action: "quick check removed", outcome: "removed", source: "policy" });
7382
+ return back("said", cleared ? "Quick check removed. Quick builds run the full check." : "There was no quick check.");
7383
+ }
7384
+ return back("problem", "That change isn't available.");
7385
+ }
6895
7386
  // Sprint 8: back up now. An instance operator; the outcome is kept like a scheduled one's.
6896
7387
  if (url.pathname === "/settings/backups/now") {
6897
7388
  const databaseFile = store.databaseFile();
@@ -7540,6 +8031,16 @@ export function createDecisionServer(options) {
7540
8031
  : after.digestAt === null ? "Evening digest off." : `Evening digest at ${after.digestAt}.`;
7541
8032
  return redirect(response, `/settings?said=${encodeURIComponent(said)}`);
7542
8033
  }
8034
+ if (url.pathname === "/settings/notifications/mute") {
8035
+ // Each person's own pings: a muted project stays in Tasks and the evening digest.
8036
+ const repo = body.get("repo") ?? "";
8037
+ const project = notificationProjects(store, who.name).find(one => one.repo === repo);
8038
+ if (project === undefined)
8039
+ return refuse(response, who, 400, "that project isn't one you can see", "/settings");
8040
+ const muted = body.get("pings") !== "on";
8041
+ store.setProjectMuted(who.name, repo, muted, now);
8042
+ return redirect(response, `/settings?said=${encodeURIComponent(muted ? `${project.name} muted. It still shows in Tasks and your evening digest.` : `${project.name} pings you again.`)}#notifications`);
8043
+ }
7543
8044
  if (url.pathname === "/settings/telegram-digest" && options.telegramTokenFile !== undefined) {
7544
8045
  // The cadence is a closed list of minutes — never a free number from
7545
8046
  // a form; "off" clears it. Any approver session may set it.
@@ -7747,7 +8248,7 @@ export function createDecisionServer(options) {
7747
8248
  // under a ceiling. Unscoped mode keeps its historic unplaced filings.
7748
8249
  if (!unscopedMode && effective === "") {
7749
8250
  const csrf = who.via === "cookie" ? who.session.csrf : "";
7750
- return sendScreen(response, 400, tasksPage(chromeFor(project, "tasks"), familyTasksInView(project).slice(0, 200), null, csrf, "name a repository — no project is open, so the task must say where it belongs", project, null, store.permissionDefault().mode, store.qualityDefault().mode));
8251
+ return sendScreen(response, 400, tasksPage(chromeFor(project, "tasks"), familyTasksInView(project).slice(0, 200), null, csrf, "name a repository — no project is open, so the task must say where it belongs", project, null, store.permissionDefault().mode, store.qualityDefault().mode, store.replacements()));
7751
8252
  }
7752
8253
  let repo = effective;
7753
8254
  let admitted = unscopedMode ? null : admissionList() ?? [];
@@ -7756,7 +8257,7 @@ export function createDecisionServer(options) {
7756
8257
  const canonical = (await authorizedProject(liveCeiling(), effective)) ? canonicalProject(effective) : null;
7757
8258
  if (canonical === null || canonical === undefined) {
7758
8259
  const csrf = who.via === "cookie" ? who.session.csrf : "";
7759
- return sendScreen(response, 403, tasksPage(chromeFor(project, "tasks"), familyTasksInView(project).slice(0, 200), null, csrf, `${effective} is outside what this server was configured to show`, project, null, store.permissionDefault().mode, store.qualityDefault().mode));
8260
+ return sendScreen(response, 403, tasksPage(chromeFor(project, "tasks"), familyTasksInView(project).slice(0, 200), null, csrf, `${effective} is outside what this server was configured to show`, project, null, store.permissionDefault().mode, store.qualityDefault().mode, store.replacements()));
7760
8261
  }
7761
8262
  repo = canonical;
7762
8263
  admitted = [canonical];
@@ -7813,7 +8314,7 @@ export function createDecisionServer(options) {
7813
8314
  });
7814
8315
  if (!made.ok) {
7815
8316
  const csrf = who.via === "cookie" ? who.session.csrf : "";
7816
- return sendScreen(response, made.reason === "backlog-full" ? 429 : 400, tasksPage(chromeFor(project, "tasks"), familyTasksInView(project).slice(0, 200), null, csrf, made.message, project, { title, goal, not: notThis, touches: body.get("touches") ?? "", acceptance: body.get("acceptance") ?? "", values: body }, store.permissionDefault().mode, store.qualityDefault().mode));
8317
+ return sendScreen(response, made.reason === "backlog-full" ? 429 : 400, tasksPage(chromeFor(project, "tasks"), familyTasksInView(project).slice(0, 200), null, csrf, made.message, project, { title, goal, not: notThis, touches: body.get("touches") ?? "", acceptance: body.get("acceptance") ?? "", values: body }, store.permissionDefault().mode, store.qualityDefault().mode, store.replacements()));
7817
8318
  }
7818
8319
  const actionContext = requestContext.getStore();
7819
8320
  if (actionContext !== undefined)
@@ -8288,7 +8789,7 @@ export function createDecisionServer(options) {
8288
8789
  if (run === null || runTask === null || familyOf(runTask)?.root.id !== (familyOf(act.taskId)?.root.id ?? act.taskId)) {
8289
8790
  return taskScreen(response, who, act.taskId, "That pull request isn't part of this task.", 409);
8290
8791
  }
8291
- const merged = await mergeAsPerson(store, { runId: run.id, name: who.name, password: body.get("token") ?? "", ...(options.publishExec === undefined ? {} : { exec: options.publishExec }), clock });
8792
+ const merged = await mergeAsPerson(store, { runId: run.id, name: who.name, password: body.get("token") ?? "", ...(body.get("anyway") === "1" ? { anyway: true } : {}), ...(options.publishExec === undefined ? {} : { exec: options.publishExec }), clock });
8292
8793
  if (!merged.ok)
8293
8794
  return taskScreen(response, who, act.taskId, merged.message, merged.reason === "password" ? 403 : 409);
8294
8795
  return redirect(response, `${taskHref(act.taskId)}#merge`);
@@ -8472,6 +8973,24 @@ export function createDecisionServer(options) {
8472
8973
  store.retirePushSubscription(mine.id, `removed by ${who.name}`, clock());
8473
8974
  return redirect(response, `/settings?said=${encodeURIComponent("removed — that device stops receiving pushes")}`);
8474
8975
  }
8976
+ if (url.pathname === "/onboarding/phone/dismiss") {
8977
+ if (who.via !== "cookie" || who.role !== "approver")
8978
+ return refuse(response, who, 403, "An approver puts this away.", "/chat");
8979
+ store.recordInstallationFact(PHONE_CARD_FACT, who.name, now);
8980
+ return body.get("quiet") === "1" ? respond(response, 204, "text/plain; charset=utf-8", "") : redirect(response, "/chat");
8981
+ }
8982
+ // Turn the lead on with the agent signed in on this computer: its membership spends no dollars, so like starting a
8983
+ // conversation it needs no second password; the full form (Advanced) still asks for one.
8984
+ if (url.pathname === "/settings/lead/on") {
8985
+ if (who.via !== "cookie" || who.role !== "approver")
8986
+ return refuse(response, who, 403, "An approver turns the lead on.", "/settings/lead");
8987
+ await checkLocalAgents(true);
8988
+ const provider = localSignIn?.states.claude === "connected" ? "claude-subscription" : localSignIn?.states.codex === "connected" ? "codex-subscription" : null;
8989
+ if (provider === null)
8990
+ return redirect(response, chatReturnWithSaid("/settings/lead", `No agent is signed in on this computer yet. Run ${agentSignInCommand()}, then try again.`));
8991
+ store.setChatConfig({ provider, model: "default", dailyTurns: 50, weeklyCeilingMicrousd: 0, priceInMicrousd: 0, priceOutMicrousd: 0 }, who.name, now);
8992
+ return redirect(response, "/chat");
8993
+ }
8475
8994
  if (url.pathname === "/chat/config") {
8476
8995
  // The console's own door into `config set chat` (operator request:
8477
8996
  // chat lives mainly in the web UI). Same ceremony weight as the CLI
@@ -8480,7 +8999,7 @@ export function createDecisionServer(options) {
8480
8999
  // touches a form or this database — environment only.
8481
9000
  if (who.via !== "cookie")
8482
9001
  return refuse(response, who, 403, "chat setup is a browser surface");
8483
- const back = safeChatReturn(body.get("return"));
9002
+ const back = body.get("return") === "/settings/lead" ? "/settings/lead" : safeChatReturn(body.get("return"));
8484
9003
  const password = body.get("token") ?? "";
8485
9004
  if (password === "" || !authenticateApprover(store, who.name, password).ok) {
8486
9005
  return redirect(response, chatReturnWithSaid(back, "configuring chat spend takes your password, typed again"));
@@ -8746,6 +9265,14 @@ export function createDecisionServer(options) {
8746
9265
  return said(409, "That result is no longer available for this task. Review the task before sending.");
8747
9266
  }
8748
9267
  const resultContext = viewedRun === null ? "" : ` The operator is viewing result #${viewedRun.id} from execution ${store.externalIdFor(viewedRun.taskRef)}. Use get_result for that exact execution and run when responding to feedback; do not substitute another result.`;
9268
+ // The task composer's mode (console v2): fixed words for this turn only.
9269
+ // It steers what the lead proposes and grants nothing: every action
9270
+ // still arrives as a card the person confirms under its own rules.
9271
+ const modeValue = body.get("mode");
9272
+ if (modeValue !== null && (focusTask === null || body.getAll("mode").length !== 1 || !Object.hasOwn(TASK_COMPOSER_MODES, modeValue))) {
9273
+ return said(400, "Choose Build, Plan only or Just answer, then send again. Your draft is saved.");
9274
+ }
9275
+ const modeContext = modeValue === null ? "" : ` ${TASK_COMPOSER_MODES[modeValue]}`;
8749
9276
  const enabled = chatEnablement();
8750
9277
  if (!enabled.ok)
8751
9278
  return said(409, enabled.why);
@@ -8767,7 +9294,7 @@ export function createDecisionServer(options) {
8767
9294
  }
8768
9295
  const opened = store.openMateThread(who.name, principal.ceilingDigest, now, chatScopeOf(focusTask, chatProject));
8769
9296
  const onProgress = beginLiveTurn(opened.thread.id);
8770
- void runMateTurn({ store, who: principal, session: mateSession, thread: opened.thread, config: enabled.config, key: enabled.key, message, onProgress, ...(requestId === null ? {} : { requestId }), ...(focusTask === null && chatProject !== null ? { context: `Current project: ${projectName(chatProject)} (${chatProject}). Keep this conversation about that project unless the operator explicitly asks to broaden it; use it as the repo for project tools.` } : {}), ...(focusTask === null ? {} : { context: `Current task: ${focusTask.id}. Read it with get_task before answering or proposing changes. Read its currentExecution next and bind new actions to that exact execution. Never replace the target of a prior proposal with a newer revision. Keep this turn about that task unless the operator explicitly asks to broaden it.${resultContext}` }), fetcher: chatFetcher, ...(options.subscriptionChatRunner === undefined ? {} : { subscriptionRunner: options.subscriptionChatRunner }), clock, evidenceRoot })
9297
+ void runMateTurn({ store, who: principal, session: mateSession, thread: opened.thread, config: enabled.config, key: enabled.key, message, onProgress, ...(requestId === null ? {} : { requestId }), ...(focusTask === null && chatProject !== null ? { context: `Current project: ${projectName(chatProject)} (${chatProject}). Keep this conversation about that project unless the operator explicitly asks to broaden it; use it as the repo for project tools.` } : {}), ...(focusTask === null ? {} : { context: `Current task: ${focusTask.id}. Read it with get_task before answering or proposing changes. Read its currentExecution next and bind new actions to that exact execution. Never replace the target of a prior proposal with a newer revision. Keep this turn about that task unless the operator explicitly asks to broaden it.${resultContext}${modeContext}` }), fetcher: chatFetcher, ...(options.subscriptionChatRunner === undefined ? {} : { subscriptionRunner: options.subscriptionChatRunner }), clock, evidenceRoot })
8771
9298
  .then(outcome => {
8772
9299
  if (!outcome.ok)
8773
9300
  noteMate(who.session.csrf, "turn" in outcome ? outcome.turn : null, outcome.message);
@@ -9277,6 +9804,33 @@ export function createDecisionServer(options) {
9277
9804
  return refuse(response, who, result.status, result.message, back);
9278
9805
  return redirect(response, revisionDestination(result.id, back));
9279
9806
  }
9807
+ // Follow-ups on a result: Run checks on its exact commit (a worker runs the
9808
+ // approved command once and seals the log), or file a task to add tests.
9809
+ const followUp = /^\/r\/([0-9]{1,15})\/(checks|add-tests)$/.exec(url.pathname);
9810
+ if (followUp !== null) {
9811
+ const id = Number(followUp[1]);
9812
+ const found = store.getRun(id);
9813
+ if (found === null || !visible(taskRepoOf(found.taskRef)))
9814
+ return refuse(response, who, 404, "no such run");
9815
+ const back = resultReturnTarget(body.get("return"), id);
9816
+ if (who.via !== "cookie")
9817
+ return refuse(response, who, 403, "Sign in with a browser session to do this.", back);
9818
+ if (followUp[2] === "checks") {
9819
+ if (who.role !== "approver")
9820
+ return refuse(response, who, 403, "An approver runs checks.", back);
9821
+ if (store.isDemo())
9822
+ return refuse(response, who, 403, "The demo runs no checks.", back);
9823
+ const level = body.get("level") === "full" ? "full" : "quick";
9824
+ const asked = requestFollowUpChecks(store, { runId: id, level, actor: who.name }, now);
9825
+ if (!asked.ok)
9826
+ return refuse(response, who, 409, asked.message, back);
9827
+ return redirect(response, `${back.split("#")[0]}#follow-ups`);
9828
+ }
9829
+ const filed = fileAddTestsTask(store, evidenceRoot, { runId: id, actor: who.name, filedVia: "console", ...(admissionList() === null ? {} : { admittedRepos: admissionList() }) }, now);
9830
+ if (!filed.ok)
9831
+ return refuse(response, who, 409, filed.message, back);
9832
+ return redirect(response, taskHref(filed.id));
9833
+ }
9280
9834
  const draftRepair = /^\/r\/([0-9]{1,15})\/draft-repair$/.exec(url.pathname);
9281
9835
  if (draftRepair !== null) {
9282
9836
  // CI repair, suggestion-first (M8.18): a red episode never spawns an
@@ -9950,7 +10504,7 @@ export function createDecisionServer(options) {
9950
10504
  }
9951
10505
  case "cancel": {
9952
10506
  const reason = body.get("reason") ?? undefined;
9953
- const cancelled = store.cancelTask(taskId, now, reason);
10507
+ const cancelled = withActor({ account: who.name, lead: false }, () => store.cancelTask(taskId, now, reason));
9954
10508
  if (!cancelled.ok) {
9955
10509
  if (cancelled.reason === "reason-required" || cancelled.reason === "bad-reason") {
9956
10510
  return taskScreen(response, who, taskId, cancelled.reason === "reason-required"
@@ -10878,6 +11432,7 @@ export function createDecisionServer(options) {
10878
11432
  return [{ id: one.id, title: one.title, state: one.state, approved: approvalOf(store.getScope(one.id)).approved, standing: status.label, tone: status.tone }];
10879
11433
  }),
10880
11434
  sourceDigest: scope?.digest ?? null,
11435
+ followUps: followUpsFor(store, evidenceRoot, run, now),
10881
11436
  route: store.runRoute(run.id),
10882
11437
  // Editor links (arc 6): the deployment capability, THIS machine's
10883
11438
  // runner owning the run, the session's own device-side yes, and a
@@ -11057,7 +11612,7 @@ export function createDecisionServer(options) {
11057
11612
  });
11058
11613
  return server;
11059
11614
  });
11060
- return server;
11615
+ return Object.assign(server, { mintSignInLink });
11061
11616
  }
11062
11617
  // ---- path plumbing ---------------------------------------------------------
11063
11618
  /**
@@ -11242,7 +11797,8 @@ export function editorFileHref(worktree, path, line) {
11242
11797
  * chat (v39, extending Priority 2's "one surface, six places" rule from
11243
11798
  * the verdict word to the per-criterion state). */
11244
11799
  function matrixStateBadge(state) {
11245
- const cls = state === "pass" ? "badge-done" : state === "failed" ? "badge-failed" : state === "missing" ? "badge-failed" : "badge-manual-review";
11800
+ // Red belongs to a Failed headline alone (task-status.ts); an unmet requirement is an amber note.
11801
+ const cls = state === "pass" ? "badge-done" : state === "failed" ? "badge-note" : state === "missing" ? "badge-note" : "badge-manual-review";
11246
11802
  const word = state === "pass" ? "pass" : state === "missing" ? "missing" : state === "failed" ? "failed" : "manual review";
11247
11803
  return `<span class="badge ${cls}" data-matrix-state="${escape(state)}">${escape(word)}</span>`;
11248
11804
  }
@@ -11328,7 +11884,7 @@ function criterionMatrixHtml(matrix, options = {}) {
11328
11884
  if (matrix.length === 0)
11329
11885
  return "";
11330
11886
  const kinds = {
11331
- check: "Checks", "changed-path": "Changed files", screenshot: "Screenshots", "manual-review": "Human review",
11887
+ check: "Checks", "changed-path": "Changed files", screenshot: "Screenshots", "manual-review": "You check",
11332
11888
  };
11333
11889
  const evidenceLink = (a) => {
11334
11890
  const text = `<code>${escape(a.ref)}</code>`;
@@ -11344,8 +11900,9 @@ function criterionMatrixHtml(matrix, options = {}) {
11344
11900
  const state = row.state;
11345
11901
  const awaitingAssessment = row.assessment?.evidenceState === "pass" && row.review === null;
11346
11902
  const confirmed = row.assessment !== undefined && state === "pass";
11347
- const label = awaitingAssessment ? "Not assessed" : confirmed ? "Confirmed" : state === "pass" ? "Met" : state === "manual-review" ? "You check" : state === "missing" ? "Not shown yet" : "Not met";
11348
- const cls = awaitingAssessment ? "" : state === "pass" ? "badge-done" : state === "manual-review" ? "badge-manual-review" : "badge-failed";
11903
+ // Plain words (task-status brief): the retired assessment step is not a state a person acts on.
11904
+ const label = awaitingAssessment ? "Met" : confirmed ? "Confirmed" : state === "pass" ? "Met" : state === "manual-review" ? "You check" : state === "missing" ? "Not shown yet" : "Not met";
11905
+ const cls = awaitingAssessment ? "badge-done" : state === "pass" ? "badge-done" : state === "manual-review" ? "badge-manual-review" : "badge-note";
11349
11906
  const warnings = [];
11350
11907
  // Only replace the known boilerplate. Other recorded failure details
11351
11908
  // remain verbatim, so concision cannot hide a different problem.
@@ -11744,7 +12301,7 @@ function agentsStripHtml(view, taskId) {
11744
12301
  return `<details class="task-chat-agents"><summary>Agent setup</summary><p>${escape(agentsSummaryWords(view))}</p><a href="${taskHref(taskId)}#agents">View agents</a></details>`;
11745
12302
  }
11746
12303
  /** The demo's one-line promise, on every page. */
11747
- const DEMO_BANNER = "Demo: a scripted lead and sample projects. Nothing calls a model, reaches outside or spends.";
12304
+ const DEMO_BANNER = `Demo: a scripted lead and sample projects. Nothing calls a model, reaches outside or spends. For your own project, run ${START_COMMAND} in its folder.`;
11748
12305
  /** Every character that could open a tag or an attribute, dead at the sink. */
11749
12306
  function escape(text) {
11750
12307
  return text
@@ -11978,6 +12535,10 @@ ${THEME_DARK}
11978
12535
  background: color-mix(in srgb, var(--muted) 58%, var(--card));
11979
12536
  color: color-mix(in srgb, var(--destructive) 68%, var(--foreground)); border-color: var(--border);
11980
12537
  }
12538
+ .badge-note {
12539
+ background: color-mix(in srgb, var(--muted) 58%, var(--card));
12540
+ color: var(--so-warning, #ab6400); border-color: var(--border);
12541
+ }
11981
12542
  .badge-overdue {
11982
12543
  background: color-mix(in srgb, var(--muted) 58%, var(--card)); color: var(--foreground);
11983
12544
  border-color: var(--border);
@@ -12734,6 +13295,8 @@ ${THEME_DARK}
12734
13295
  @keyframes demo-pulse { 50% { opacity: .35; } }
12735
13296
  @media (prefers-reduced-motion: reduce) { .demo-steps li.now::before { animation: none; } }
12736
13297
  .demo-state { display: flex; align-items: center; gap: .5rem; margin: 0 !important; }
13298
+ .demo-chat .demo-handoff { border-color: var(--so-input-line); }
13299
+ .demo-command { margin: .4rem 0 .5rem; padding: .6rem .8rem; border-radius: calc(var(--radius) - 3px); background: var(--so-raised); font: 500 .8125rem/1.5 var(--font-mono); overflow-wrap: anywhere; white-space: pre-wrap; }
12737
13300
  .demo-chat .badge.demo-ready { background: var(--so-info-soft); color: var(--so-info); }
12738
13301
  .demo-chat .badge.demo-complete { background: var(--so-success-soft); color: var(--so-success); }
12739
13302
  .demo-pass { color: var(--so-success); font-weight: 500; }
@@ -12818,8 +13381,9 @@ ${THEME_DARK}
12818
13381
  .dispatch-status[data-dispatch-status="proof-refuted"] .dispatch-copy > strong::before,
12819
13382
  .dispatch-status[data-dispatch-status="needs-verification"] .dispatch-copy > strong::before {
12820
13383
  content: ""; display: inline-block; width: .45rem; height: .45rem; margin-right: .45rem; border-radius: 50%; vertical-align: .08rem;
12821
- background: var(--destructive);
13384
+ background: var(--so-warning, #ab6400);
12822
13385
  }
13386
+ .problem.dispatch-status[data-dispatch-status="proof-refuted"] .dispatch-copy > strong::before { background: var(--destructive); }
12823
13387
  .dispatch-status[data-dispatch-status="needs-verification"] .dispatch-copy > strong::before { background: var(--muted-foreground); }
12824
13388
  /* A review in flight (review fixes): the box reads neutral — neither the
12825
13389
  green of a settled verdict nor the red of a problem — with the dot in
@@ -13320,7 +13884,7 @@ ${THEME_DARK}
13320
13884
  .pull-request-head strong { font-size: 1.0625rem; font-weight: 600; color: var(--foreground); display: inline-flex; align-items: center; gap: .5rem; }
13321
13885
  .pull-request-head strong::before { content: ""; width: .5rem; height: .5rem; border-radius: 999px; background: var(--muted-foreground); }
13322
13886
  .pull-request--ok .pull-request-head strong::before { background: var(--success, #15803d); }
13323
- .pull-request--problem .pull-request-head strong::before { background: var(--destructive, #b91c1c); }
13887
+ .pull-request--problem .pull-request-head strong::before { background: var(--so-warning, #ab6400); }
13324
13888
  .pull-request-act { display: flex; flex-wrap: wrap; align-items: flex-end; gap: .5rem .75rem; margin: .25rem 0 0; }
13325
13889
  .pull-request-act .meta { flex: 1 1 100%; }
13326
13890
  .pull-request-act label { display: grid; gap: .25rem; flex: 1 1 12rem; margin: 0; }
@@ -14513,7 +15077,15 @@ button.pick-file { min-height: 1.75rem; padding: 0 .55rem; font-size: .75rem; }
14513
15077
  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}`;
14514
15078
  /** The page CSS this module writes itself (not the imported modules'), for the stylesheet contract tests. */
14515
15079
  export const PAGE_CSS = STYLE + THEME_CONTROLS_CSS;
14516
- 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 + STARTERS_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}');
15080
+ /** The Inbox tabs (console v2): a segmented track of real links; the dot marks a tab with something new, on phones only. */
15081
+ const INBOX_TABS_CSS = '.inbox-tabs{display:inline-flex;gap:2px;max-width:100%;overflow-x:auto;margin:4px 0 14px;padding:2px;border-radius:12px;background:var(--so-raised);scrollbar-width:none}' +
15082
+ '.inbox-tabs a{position:relative;display:inline-flex;align-items:center;gap:6px;min-height:28px;padding:0 10px;border-radius:8px;color:var(--so-muted);font-size:13px;font-weight:500;text-decoration:none;white-space:nowrap}' +
15083
+ '.inbox-tabs a[aria-current="page"]{background:var(--so-paper);color:var(--so-ink);box-shadow:var(--so-pill-shadow)}' +
15084
+ '.inbox-tab-count{min-width:18px;padding:0 5px;border-radius:9999px;font:500 11px/18px var(--so-mono,ui-monospace,monospace);font-variant-numeric:tabular-nums;text-align:center}' +
15085
+ '.inbox-tab-count--needs{background:var(--so-signal);color:var(--so-on-signal)}' +
15086
+ '.inbox-unread{display:none;position:absolute;top:4px;right:3px;width:6px;height:6px;border-radius:50%;background:var(--so-signal)}' +
15087
+ '@media (max-width:760px){.inbox-tabs{display:flex;width:100%}.inbox-tabs a{flex:1;justify-content:center;min-height:44px;padding:0 6px}.inbox-unread{display:block}}';
15088
+ const WORKSPACE_STYLE = styleAsset(STYLE + INBOX_TABS_CSS + 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 + STARTERS_CSS + GALLERY_CSS + SSO_CSS + CREDENTIALS_CSS + KNOWLEDGE_CSS + MODELS_CSS + CHAT_POLISH_CSS + TRANSITIONS_CSS + WORKSPACE_MOTION_CSS + ASSIGNMENT_CSS + TASK_STATUS_CSS + LEAD_CONTEXT_CSS + PULL_REQUEST_SETTINGS_CSS + CHECK_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}');
14517
15089
  /**
14518
15090
  * The board's liveness: fetch this page's own fragment on a timer and swap
14519
15091
  * it in place — no flicker, no scroll reset, no long-lived stream to manage,
@@ -15063,6 +15635,37 @@ function loginPage(problem, returnTo = "/", sso = null) {
15063
15635
  `</div></div>`,
15064
15636
  ].join("\n"), { nav: false });
15065
15637
  }
15638
+ /**
15639
+ * The page for an address the console doesn't answer to (onboarding): what was opened, where it answers, and the exact
15640
+ * command that admits this address. It stands alone (styles inline, no script, no font), because every other asset
15641
+ * would be refused at this address too; and it holds nothing a stranger's page doesn't already know.
15642
+ */
15643
+ export function wrongHostPage(facts) {
15644
+ const code = (text) => `<code>${escape(text)}</code>`;
15645
+ return [
15646
+ `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">`,
15647
+ `<meta name="color-scheme" content="light dark"><title>Toolroll isn't set up for this address</title>`,
15648
+ `<style>`,
15649
+ `:root{color-scheme:light dark;--ground:#efefef;--paper:#fff;--ink:#171717;--muted:#666;--line:#e6e6e6;--soft:#f2f2f2}`,
15650
+ `@media (prefers-color-scheme:dark){:root{--ground:#0b0b0b;--paper:#161616;--ink:#ededed;--muted:#a1a1a1;--line:#262626;--soft:#1f1f1f}}`,
15651
+ `*{box-sizing:border-box}body{margin:0;min-height:100vh;display:grid;place-items:center;padding:24px 16px;background:var(--ground);color:var(--ink);`,
15652
+ `font:14px/1.6 ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif}`,
15653
+ `main{width:100%;max-width:520px;background:var(--paper);border:1px solid var(--line);border-radius:12px;padding:28px}`,
15654
+ `.mark{font-weight:600;letter-spacing:-.01em;color:var(--muted);margin:0 0 16px}h1{font-size:18px;line-height:1.375;margin:0 0 8px;letter-spacing:-.01em}`,
15655
+ `p{margin:0 0 12px}code{font:12.5px/1.5 ui-monospace,"SF Mono",Menlo,Consolas,monospace;background:var(--soft);border-radius:5px;padding:1px 5px;overflow-wrap:anywhere}`,
15656
+ `pre{margin:0 0 16px;background:var(--soft);border:1px solid var(--line);border-radius:8px;padding:12px 14px;white-space:pre-wrap;overflow-wrap:anywhere}pre code{background:none;padding:0}`,
15657
+ `.meta{color:var(--muted);font-size:13px;margin:0}`,
15658
+ `</style></head><body><main>`,
15659
+ `<p class="mark">Toolroll</p>`,
15660
+ `<h1>Toolroll isn't set up for this address</h1>`,
15661
+ facts.opened === null
15662
+ ? `<p>It answers at ${code(facts.served)} on the computer running it.</p>`
15663
+ : `<p>You opened it at ${code(facts.opened)}. It answers at ${code(facts.served)} on the computer running it.</p>`,
15664
+ facts.command === null ? "" : `<p>To use this address, stop Toolroll (Ctrl-C) and start it again with:</p><pre><code>${escape(facts.command)}</code></pre>`,
15665
+ `<p class="meta">On that computer, ${code(`http://${facts.served}/`)} always works. Other addresses need your say-so, so no other website can reach it.</p>`,
15666
+ `</main></body></html>`,
15667
+ ].join("\n");
15668
+ }
15066
15669
  /** The first-account page (setup review): shown only while no approver exists. */
15067
15670
  function signupPage(problem, attemptsLeft) {
15068
15671
  return shell("Toolroll", [
@@ -15086,12 +15689,22 @@ function signupPage(problem, attemptsLeft) {
15086
15689
  `</div></div>`,
15087
15690
  ].join("\n"), { nav: false });
15088
15691
  }
15089
- /**
15090
- * The inbox (v3): only things stalling progress without a person, one card
15091
- * per underlying stall, each with its verb inline or one step away. No
15092
- * auto-refresh — approval links lead to the step-up screen, and a page
15093
- * that might hold typed input never re-renders itself.
15094
- */
15692
+ const INBOX_TABS = [
15693
+ { id: "needs-you", label: "Needs you" }, { id: "ready", label: "Ready" }, { id: "running", label: "Running" }, { id: "all", label: "All" },
15694
+ ];
15695
+ export function parseInboxTab(raw) {
15696
+ return INBOX_TABS.some(one => one.id === raw) ? raw : undefined;
15697
+ }
15698
+ /** What each tab holds, as a short fingerprint: a tab is unread when its
15699
+ * fingerprint differs from the one this browser saw there last. */
15700
+ export function inboxFingerprints(data) {
15701
+ const print = (keys) => createHash("sha256").update([...keys].sort().join("\n")).digest("hex").slice(0, 8);
15702
+ const needs = [...data.decisions.map(one => `d${one.id}`), ...data.approvals.map(one => `a${one.taskId}@${one.proposedAt}`), ...data.requeueables.map(one => `q${one.taskId}:${one.strikes}`),
15703
+ ...data.cancelledBlockers.map(one => `c${one.blockerId}`), ...data.gaps.map(one => `g${one.key}`)];
15704
+ const ready = [...data.needsVerification.map(one => `v${one.taskId}`), ...(data.ready ?? []).map(one => `r${one.taskId}`)];
15705
+ const running = (data.running ?? []).map(one => `u${one.taskId}`);
15706
+ return { "needs-you": print(needs), ready: print(ready), running: print(running), all: print([...needs, ...ready, ...running]) };
15707
+ }
15095
15708
  function inboxPage(chrome, data) {
15096
15709
  /** The row's project, worn openly in the roll-up — null is UNPLACED,
15097
15710
  * said as such, never a silent missing chip (finding 13). */
@@ -15185,19 +15798,41 @@ function inboxPage(chrome, data) {
15185
15798
  ? `<strong>No builder is connected yet.</strong> Toolroll is open, but no machine is connected to do project work. On the machine where the project lives, open that folder and run <span class="mono">toolroll up</span>. Keep Toolroll running; approved work starts automatically.`
15186
15799
  : `<strong>Builder disconnected.</strong> ${data.worker.registered} builder${data.worker.registered === 1 ? " is" : "s are"} configured, last checked in ${data.worker.lastHeard === null ? "never" : whenTime(data.worker.lastHeard)}. Reopen Toolroll on that machine. Queued work starts automatically when a builder reconnects.`) +
15187
15800
  `</div>`;
15801
+ // Console v2: Needs you · Ready · Running · All, as real links (Back and
15802
+ // bookmarks work). Each section belongs to one tab; All shows every one.
15803
+ const tab = data.tab ?? "all";
15804
+ const shows = (one) => tab === "all" || tab === one;
15805
+ const listRows = (rows) => rows.map(one => `<p class="row"><a href="${taskHref(one.taskId)}">${escape(one.title)}</a>${chip(one.repo)} <span class="meta">${escape(one.detail)}</span></p>`).join("\n");
15806
+ const ready = (data.ready ?? []).length === 0 ? "" : `<h2>ready to review</h2>` + listRows(data.ready ?? []);
15807
+ const running = (data.running ?? []).length === 0 ? "" : `<h2>running now</h2>` + listRows(data.running ?? []);
15808
+ const counts = {
15809
+ "needs-you": data.decisions.length + data.approvals.length + data.requeueables.length + data.cancelledBlockers.length + data.gaps.length,
15810
+ ready: data.needsVerification.length + (data.ready ?? []).length,
15811
+ running: (data.running ?? []).length,
15812
+ all: 0,
15813
+ };
15814
+ counts.all = counts["needs-you"] + counts.ready + counts.running;
15815
+ const tabs = data.tab === undefined ? "" : `<nav class="inbox-tabs" aria-label="Inbox views">` + INBOX_TABS.map(one => `<a href="/inbox?tab=${one.id}"${one.id === tab ? ` aria-current="page"` : ""} data-inbox-tab="${one.id}">${one.label}` +
15816
+ `<span class="inbox-tab-count${one.id === "needs-you" && counts[one.id] > 0 ? " inbox-tab-count--needs" : ""}">${counts[one.id]}</span>` +
15817
+ `${one.id !== tab && (data.unread ?? []).includes(one.id) ? `<span class="inbox-unread" aria-label="new"></span>` : ""}</a>`).join("") + `</nav>`;
15818
+ const tabEmpty = data.tab !== undefined && tab !== "all" && counts[tab] === 0
15819
+ ? `<div class="card"><p class="meta">${tab === "needs-you" ? "Nothing needs you." : tab === "ready" ? "No results are waiting for review." : "Nothing is running."}</p></div>` : "";
15188
15820
  return screen("inbox", [
15189
15821
  `<h1>Inbox</h1>`,
15190
15822
  `<p class="meta">everything that waits on you \u2014 empty means the fleet is working</p>`,
15823
+ tabs,
15191
15824
  noWorker,
15192
15825
  wizard,
15193
- empty ? "" : `<p><a class="new-task" style="display:inline-block" href="/next">clear the queue \u2192 one thing at a time</a></p>`,
15194
- empty && data.wizard === null ? `<div class="card"><p><strong>Nothing needs you.</strong></p><p class="meta">The queue is either working or waiting on its own timers. <a href="/board">Watch the board</a> or <a href="/activity">read the activity report</a>.</p></div>` : "",
15195
- decisions,
15196
- approvals,
15197
- requeueables,
15198
- needsVerification,
15199
- cancelled,
15200
- gaps,
15826
+ empty || !shows("needs-you") ? "" : `<p><a class="new-task" style="display:inline-block" href="/next">clear the queue \u2192 one thing at a time</a></p>`,
15827
+ empty && data.wizard === null && shows("needs-you") && counts.all === 0 ? `<div class="card"><p><strong>Nothing needs you.</strong></p><p class="meta">The queue is either working or waiting on its own timers. <a href="/board">Watch the board</a> or <a href="/activity">read the activity report</a>.</p></div>` : tabEmpty,
15828
+ shows("needs-you") ? decisions : "",
15829
+ shows("needs-you") ? approvals : "",
15830
+ shows("needs-you") ? requeueables : "",
15831
+ shows("ready") ? needsVerification : "",
15832
+ shows("ready") ? ready : "",
15833
+ shows("running") ? running : "",
15834
+ shows("needs-you") ? cancelled : "",
15835
+ shows("needs-you") ? gaps : "",
15201
15836
  data.rollup
15202
15837
  ? `<p class="meta">requirement gaps are checked one project at a time \u2014 open a project to see and fill its gaps · <a href="/projects">open a project</a></p>`
15203
15838
  : "",
@@ -15622,6 +16257,11 @@ function approvalFormDigest(scopeDigest, raceDigest, planSha) {
15622
16257
  .digest("hex");
15623
16258
  }
15624
16259
  const taskChatHref = (taskId) => `/chat?task=${encodeURIComponent(taskId)}`;
16260
+ export const TASK_COMPOSER_MODES = {
16261
+ build: "The operator chose Build: if they ask for a change to the result, read it with get_result and propose a revision of this task with propose_review (operation revise) for them to confirm. Nothing builds until they confirm the card.",
16262
+ plan: "The operator chose Plan only: answer with a short plan for what they ask. Do not propose a revision, a new task or any other action this turn.",
16263
+ answer: "The operator chose Just answer: answer from what you can read. Do not propose any action this turn.",
16264
+ };
15625
16265
  /** A project's own lead thread (v77). */
15626
16266
  const projectChatHref = (repo) => `/chat?project=${encodeURIComponent(repo)}`;
15627
16267
  /** The one task-level road from a truthful dispatch diagnosis to its nearest
@@ -15903,6 +16543,7 @@ function taskChatLiveRegion(focus, csrf, fragment = false, inert = false, approv
15903
16543
  ? `<details class="task-secondary-approval"><summary>Updated approval terms</summary>${approvalContent}</details>` : approvalContent;
15904
16544
  const polling = !inert && ((focus.approval === null || focus.plan === "requested") && focus.state !== "done" && focus.state !== "cancelled" || focus.control.kind === "stopping" || focus.control.kind === "stop");
15905
16545
  return (`<section id="task-chat-live" aria-live="polite" data-task="${escape(focus.id)}" data-execution="${escape(focus.executionId)}" data-source="/chat/task-status?task=${encodeURIComponent(focus.id)}" data-poll="${polling ? "1" : "0"}" data-approval="${escape(focus.approval?.digest ?? "")}" data-plan="${escape(focus.plan ?? "")}">` +
16546
+ (focus.guide === true && focus.state !== "cancelled" ? firstTaskJourneyHtml(focus) : "") +
15906
16547
  `<div class="task-live-summary">` +
15907
16548
  (focus.assignment !== null ? assignmentSummaryHtml(focus.assignment, { workStatus: focus.status, hideAction: focus.approval !== null && focus.dispatch?.action === "approve-scope", ...(receiptLeads ? { resultHref: chatResultHref(focus.id, focus.result.runId) } : {}) }) + (receiptLeads ? completionReceiptCard(focus.result, focus.id, "chat", focus.status, focus.assignment, false) : "") : receiptLeads ? completionReceiptCard(focus.result, focus.id, "chat", focus.status) : taskStatusCard(focus.status, focus.id, focus.dispatch, focus.liveRun?.id ?? null, focus.approval !== null && focus.dispatch?.action === "approve-scope")) +
15908
16549
  checkProgressHtml(focus.checkProgress) +
@@ -15926,6 +16567,16 @@ function taskChatLiveRegion(focus, csrf, fragment = false, inert = false, approv
15926
16567
  (focus.publication === null ? "" : `<p class="chat-publication meta">Published as ${safePrUrl(focus.publication.prUrl) === null ? `<span class="mono">PR #${focus.publication.prNumber ?? "?"}</span>` : `<a href="${escape(safePrUrl(focus.publication.prUrl))}">PR #${focus.publication.prNumber ?? "?"}</a>`} · ${escape(focus.publication.state)}${focus.publication.lastCheckState === null ? "" : ` · CI ${escape(focus.publication.lastCheckState)}`}</p>`) +
15927
16568
  `</section>`);
15928
16569
  }
16570
+ /** The first task's way to Ready, filled in as it moves (onboarding): the same stage every surface reads. */
16571
+ function firstTaskJourneyHtml(focus) {
16572
+ const planning = focus.plan === "requested" && focus.approval === null;
16573
+ const read = focus.assignment !== null ? assignmentStageOf(focus.assignment, { token: focus.status.token }, planning) : stageOfCode(focus.status.token, { planning });
16574
+ const steps = firstTaskJourney(read, focus.scope === "approved", focus.state === "done");
16575
+ const current = steps.find(one => one.state === "current" || one.state === "stuck");
16576
+ return `<ol class="first-task-journey" aria-label="Where this task is${current === undefined ? ": Ready" : `: ${escape(current.label)}`}" data-first-task-journey>` +
16577
+ steps.map(one => `<li data-step="${one.key}" data-state="${one.state}"${one.state === "current" || one.state === "stuck" ? ` aria-current="step"` : ""}><span class="first-task-journey-mark" aria-hidden="true"></span><span>${escape(one.label)}</span></li>`).join("") +
16578
+ `</ol>`;
16579
+ }
15929
16580
  function taskChatHeading(focus) {
15930
16581
  return (`<div class="chat-head task-chat-head"><div>` +
15931
16582
  `<p class="meta chat-task-back"><a href="${taskHref(focus.id)}">← task overview</a></p>` +
@@ -15975,7 +16626,7 @@ function chatFleetOverview(snapshot, projects, csrf, interactive) {
15975
16626
  const dispatch = task.dispatch;
15976
16627
  rows.push(`<a class="chat-overview-item decision" href="${taskHref(task.rootId ?? task.id)}" data-dispatch-status="${escape(dispatch.code)}">` +
15977
16628
  `<span class="chat-overview-icon">${strokeIcon(`<path d="M12 8v4"/><path d="M12 16h.01"/><circle cx="12" cy="12" r="9"/>`)}</span>` +
15978
- `<span class="chat-overview-copy"><strong>${escape(task.title)}</strong><span>${escape(projectOf(task.repoIndex))} · ${escape(task.id)} · ${escape(dispatch.summary.toLowerCase())}</span></span>` +
16629
+ `<span class="chat-overview-copy"><strong>${escape(task.title)}</strong><span>${escape(projectOf(task.repoIndex))} · ${escape(task.id)} · ${escape(dispatchHeadline(dispatch))}</span></span>` +
15979
16630
  `<span class="chat-overview-arrow" aria-hidden="true">→</span></a>`);
15980
16631
  }
15981
16632
  for (const task of snapshot.tasks.filter(one => one.state === "failed").slice(0, Math.max(0, 3 - rows.length))) {
@@ -16185,55 +16836,83 @@ function chatLimitsHtml(facts) {
16185
16836
  rows.push(`<span>this week ${chatMoney(facts.weekly.spent)} of ${chatMoney(facts.weekly.ceiling)}</span>`);
16186
16837
  return `<details class="chat-limits"><summary>Model &amp; limits<span class="meta">${escape(facts.subscription ? "membership" : facts.provider)}</span></summary><div class="chat-budget">${rows.join("")}</div></details>`;
16187
16838
  }
16839
+ /** Settings → Lead: one line for what runs the lead and one action; the full form, turning it off and stored keys under Advanced. */
16840
+ function leadSettingsHtml(data) {
16841
+ const { config, facts } = data;
16842
+ const hidden = `<input type="hidden" name="csrf" value="${escape(facts.csrf)}"><input type="hidden" name="return" value="/settings/lead">`;
16843
+ const summary = config === null
16844
+ ? data.signedIn !== null
16845
+ ? `<p>The lead is off.</p><form method="post" action="/settings/lead/on">${hidden}<button type="submit">Use your ${escape(data.signedIn)} sign-in</button></form>`
16846
+ : `<p>The lead is off. Sign in an agent on this computer to turn it on:</p><p><code>${escape(data.command)}</code></p>`
16847
+ : data.words !== null
16848
+ ? `<p>The lead uses ${escape(data.words)}.</p><p class="meta">${config.model === "default" ? "Its default model" : `Model ${escape(config.model)}`} · up to ${config.dailyTurns} turns a day · no dollar spend. Every action it proposes waits for you to confirm it.</p>`
16849
+ : `<p>The lead uses the ${escape(config.provider === "anthropic-api" ? "Anthropic" : "OpenRouter")} API with ${escape(config.model)}.</p><p class="meta">Up to ${chatMoney(config.weeklyCeilingMicrousd)} a week · up to ${config.dailyTurns} turns a day.</p>`;
16850
+ const forget = facts.keyFacts.filter(one => one.state === "stored").map(one => `<form method="post" action="/chat/config" class="inline">${hidden}<input type="hidden" name="forget-key" value="${escape(one.provider)}">` +
16851
+ `<input type="password" name="token" placeholder="your password" autocomplete="current-password" aria-label="Your password"><button type="submit" class="secondary">Forget the stored ${escape(one.provider)} key</button></form>`).join("");
16852
+ return [
16853
+ `<p><a href="/settings">Settings</a></p><h1>Lead</h1>`,
16854
+ data.said === null ? "" : `<p class="problem" role="status">${escape(data.said)}</p>`,
16855
+ `<section class="card lead-settings" data-lead-settings>${summary}</section>`,
16856
+ `<details class="lead-advanced" data-lead-advanced><summary>Advanced</summary>`,
16857
+ leadConfigForm(config, facts),
16858
+ config === null ? "" : `<form method="post" action="/chat/config" class="inline">${hidden}<input type="hidden" name="off" value="1">` +
16859
+ `<input type="password" name="token" placeholder="your password" autocomplete="current-password" aria-label="Your password"><button type="submit" class="secondary">Turn the lead off</button></form>`,
16860
+ forget,
16861
+ `</details>`,
16862
+ ].join("\n");
16863
+ }
16864
+ /** The lead's full settings (Settings → Lead → Advanced): provider, model, limits and a direct API key. */
16865
+ function leadConfigForm(current, data) {
16866
+ const anthropicModels = PRICED_MODELS.filter(one => !one.includes("/"));
16867
+ const openrouterModels = data.openrouterModels ?? PRICED_MODELS.filter(one => one.includes("/"));
16868
+ const currentSubscription = current !== null && isSubscriptionChatProvider(current.provider);
16869
+ const labels = new Map((data.liveModels ?? []).map(one => [one.value, one.label]));
16870
+ const models = [...new Set(["default", ...labels.keys(), ...anthropicModels, ...openrouterModels, ...(current === null ? [] : [current.model])])];
16871
+ return [
16872
+ `<form method="post" action="/chat/config" class="card">`,
16873
+ `<input type="hidden" name="csrf" value="${escape(data.csrf)}">`,
16874
+ `<input type="hidden" name="return" value="${escape(data.returnTo)}">`,
16875
+ `<label>provider<select name="provider">`,
16876
+ `<option value="codex-subscription"${current?.provider === "codex-subscription" ? " selected" : ""}>Codex membership (logged-in CLI)</option>`,
16877
+ `<option value="claude-subscription"${current?.provider === "claude-subscription" ? " selected" : ""}>Anthropic membership (logged-in CLI)</option>`,
16878
+ `<option value="anthropic-api"${current?.provider === "anthropic-api" ? " selected" : ""}>anthropic-api (direct API)</option>`,
16879
+ `<option value="openrouter-api"${current?.provider === "openrouter-api" ? " selected" : ""}>openrouter-api (direct API)</option>`,
16880
+ `</select></label>`,
16881
+ `<label>model <span class="meta">(use default for your membership's current model; direct API models need a pinned price)</span>` +
16882
+ `<input name="model" list="chat-models" value="${escape(current?.model ?? "default")}"><datalist id="chat-models">` +
16883
+ `${models.map(model => `<option value="${escape(model)}">${escape(labels.get(model) ?? "")}</option>`).join("")}</datalist></label>`,
16884
+ data.openrouterModels === null
16885
+ ? `<p class="meta">with OPENROUTER_API_KEY in the serve environment, this list becomes OpenRouter's full live catalog — each model priced by the party that bills it</p>`
16886
+ : `<p class="meta">${data.openrouterModels.length} models live from OpenRouter's catalog; saving pins today's price — re-save to re-pin</p>`,
16887
+ currentSubscription
16888
+ ? `<p class="meta"><strong>no dollar maximum.</strong> Membership chat uses the plan attached to the logged-in CLI; the conversation stays live until you end it, and the daily turn limit still applies.</p>`
16889
+ : `<label>weekly ceiling <span class="meta">(direct API only; leave blank when choosing a membership)</span>` +
16890
+ `<input type="text" name="weekly-usd" inputmode="decimal" style="width:8rem" value="${current === null ? "" : (current.weeklyCeilingMicrousd / 1_000_000).toFixed(2)}"></label>`,
16891
+ `<label>daily turns <span class="meta">(default 50)</span>` +
16892
+ `<input type="text" name="daily-turns" inputmode="numeric" style="width:8rem" value="${current === null ? "" : String(current.dailyTurns)}"></label>`,
16893
+ currentSubscription
16894
+ ? `<p class="meta">Authenticate on this machine first with ${current?.provider === "codex-subscription" ? `<span class="mono">codex login</span>` : `the <span class="mono">claude</span> CLI`}. Toolroll reuses that cached login and never stores it.</p>`
16895
+ : `<label>API key <span class="meta">(${data.keyFacts
16896
+ .map(one => one.state === "none"
16897
+ ? `${escape(one.provider)}: none yet`
16898
+ : one.state === "environment"
16899
+ ? `${escape(one.provider)}: from the environment`
16900
+ : `${escape(one.provider)}: stored ${escape(one.tail ?? "")}`)
16901
+ .join(" · ")})</span>` +
16902
+ `<input type="password" name="key" placeholder="direct API only — leave empty to keep" autocomplete="off"></label>`,
16903
+ `<label>your password <span class="meta">(typed again to change the provider)</span>` +
16904
+ `<input type="password" name="token" autocomplete="current-password"></label>`,
16905
+ `<button type="submit">${current === null ? "turn chat on" : "save"}</button>`,
16906
+ `</form>`,
16907
+ currentSubscription
16908
+ ? `<p class="meta">The membership provider runs without repository tools in a temporary directory; Toolroll remains the only layer that can turn a proposed action into a confirmation card.</p>`
16909
+ : `<p class="meta">a pasted key is written once to a mode-0600 file beside the database — never INTO the database, never shown again beyond its last characters; an environment variable (` +
16910
+ `<span class="mono">ANTHROPIC_API_KEY</span> / <span class="mono">OPENROUTER_API_KEY</span>) always wins when set</p>`,
16911
+ ].join("\n");
16912
+ }
16188
16913
  function chatPage(chrome, data) {
16189
- const configForm = (current) => {
16190
- const anthropicModels = PRICED_MODELS.filter(one => !one.includes("/"));
16191
- const openrouterModels = data.openrouterModels ?? PRICED_MODELS.filter(one => one.includes("/"));
16192
- const currentSubscription = current !== null && isSubscriptionChatProvider(current.provider);
16193
- const labels = new Map((data.liveModels ?? []).map(one => [one.value, one.label]));
16194
- const models = [...new Set(["default", ...labels.keys(), ...anthropicModels, ...openrouterModels, ...(current === null ? [] : [current.model])])];
16195
- return [
16196
- `<form method="post" action="/chat/config" class="card">`,
16197
- `<input type="hidden" name="csrf" value="${escape(data.csrf)}">`,
16198
- `<input type="hidden" name="return" value="${escape(data.focusTask === null ? "/chat" : taskChatHref(data.focusTask.id))}">`,
16199
- `<label>provider<select name="provider">`,
16200
- `<option value="codex-subscription"${current?.provider === "codex-subscription" ? " selected" : ""}>Codex membership (logged-in CLI)</option>`,
16201
- `<option value="claude-subscription"${current?.provider === "claude-subscription" ? " selected" : ""}>Anthropic membership (logged-in CLI)</option>`,
16202
- `<option value="anthropic-api"${current?.provider === "anthropic-api" ? " selected" : ""}>anthropic-api (direct API)</option>`,
16203
- `<option value="openrouter-api"${current?.provider === "openrouter-api" ? " selected" : ""}>openrouter-api (direct API)</option>`,
16204
- `</select></label>`,
16205
- `<label>model <span class="meta">(use default for your membership's current model; direct API models need a pinned price)</span>` +
16206
- `<input name="model" list="chat-models" value="${escape(current?.model ?? "default")}"><datalist id="chat-models">` +
16207
- `${models.map(model => `<option value="${escape(model)}">${escape(labels.get(model) ?? "")}</option>`).join("")}</datalist></label>`,
16208
- data.openrouterModels === null
16209
- ? `<p class="meta">with OPENROUTER_API_KEY in the serve environment, this list becomes OpenRouter's full live catalog — each model priced by the party that bills it</p>`
16210
- : `<p class="meta">${data.openrouterModels.length} models live from OpenRouter's catalog; saving pins today's price — re-save to re-pin</p>`,
16211
- currentSubscription
16212
- ? `<p class="meta"><strong>no dollar maximum.</strong> Membership chat uses the plan attached to the logged-in CLI; the conversation stays live until you end it, and the daily turn limit still applies.</p>`
16213
- : `<label>weekly ceiling <span class="meta">(direct API only; leave blank when choosing a membership)</span>` +
16214
- `<input type="text" name="weekly-usd" inputmode="decimal" style="width:8rem" value="${current === null ? "" : (current.weeklyCeilingMicrousd / 1_000_000).toFixed(2)}"></label>`,
16215
- `<label>daily turns <span class="meta">(default 50)</span>` +
16216
- `<input type="text" name="daily-turns" inputmode="numeric" style="width:8rem" value="${current === null ? "" : String(current.dailyTurns)}"></label>`,
16217
- currentSubscription
16218
- ? `<p class="meta">Authenticate on this machine first with ${current?.provider === "codex-subscription" ? `<span class="mono">codex login</span>` : `the <span class="mono">claude</span> CLI`}. Toolroll reuses that cached login and never stores it.</p>`
16219
- : `<label>API key <span class="meta">(${data.keyFacts
16220
- .map(one => one.state === "none"
16221
- ? `${escape(one.provider)}: none yet`
16222
- : one.state === "environment"
16223
- ? `${escape(one.provider)}: from the environment`
16224
- : `${escape(one.provider)}: stored ${escape(one.tail ?? "")}`)
16225
- .join(" · ")})</span>` +
16226
- `<input type="password" name="key" placeholder="direct API only — leave empty to keep" autocomplete="off"></label>`,
16227
- `<label>your password <span class="meta">(typed again to change the provider)</span>` +
16228
- `<input type="password" name="token" autocomplete="current-password"></label>`,
16229
- `<button type="submit">${current === null ? "turn chat on" : "save"}</button>`,
16230
- `</form>`,
16231
- currentSubscription
16232
- ? `<p class="meta">The membership provider runs without repository tools in a temporary directory; Toolroll remains the only layer that can turn a proposed action into a confirmation card.</p>`
16233
- : `<p class="meta">a pasted key is written once to a mode-0600 file beside the database — never INTO the database, never shown again beyond its last characters; an environment variable (` +
16234
- `<span class="mono">ANTHROPIC_API_KEY</span> / <span class="mono">OPENROUTER_API_KEY</span>) always wins when set</p>`,
16235
- ].join("\n");
16236
- };
16914
+ const formFacts = { keyFacts: data.keyFacts, openrouterModels: data.openrouterModels, ...(data.liveModels === undefined ? {} : { liveModels: data.liveModels }), csrf: data.csrf,
16915
+ returnTo: data.focusTask === null ? "/chat" : taskChatHref(data.focusTask.id) };
16237
16916
  const parts = [
16238
16917
  data.focusTask === null
16239
16918
  ? chatHeading("", data.projects.length, data.enabled.ok)
@@ -16246,14 +16925,17 @@ function chatPage(chrome, data) {
16246
16925
  const code = data.enabled.code;
16247
16926
  // The sandbox shows no conversation at all: chat evidence is a real
16248
16927
  // subscription-backed plane, never a seeded transcript (v48 authority repair).
16249
- parts.push(code === "demo"
16250
- ? `<div class="card" id="latest"><p><strong>Chat isn’t available in demo mode</strong></p><p class="meta">Demo data never contacts an external model. Start Toolroll with a real project to use chat.</p></div>`
16251
- : `<div class="card" id="latest"><p><strong>chat is off.</strong></p><p class="meta">${escape(data.enabled.why)}</p></div>`);
16252
- // The ceiling refusals need a restart to fix; configuration does not —
16253
- // it is a first-class act of this console (operator request).
16254
- if (data.canManage && (code === "unconfigured" || code === "unpriced" || code === "no-key")) {
16255
- parts.push(`<h2>${code === "unconfigured" ? "set it up" : "reconfigure"}</h2>`, configForm(data.config));
16256
- }
16928
+ // With no lead yet, the first run says what happens next (it turns on with the signed-in agent, or shows the one
16929
+ // command to run); otherwise one line, and its settings live in Settings → Lead (onboarding).
16930
+ const settingsLink = data.canManage ? ` <a href="/settings/lead">Settings → Lead</a>` : "";
16931
+ if (!(code === "unconfigured" && data.firstRunShown === true))
16932
+ parts.push(code === "demo"
16933
+ ? `<div class="card" id="latest"><p><strong>Chat isn’t available in demo mode</strong></p><p class="meta">Demo data never contacts an external model. Start Toolroll with a real project to use chat: <code>${escape(START_COMMAND)}</code> in your repository.</p></div>`
16934
+ : code === "unconfigured"
16935
+ ? `<div class="card" id="latest" data-lead-off><p><strong>The lead is off.</strong></p><p class="meta">${data.canManage ? `Turn it on in${settingsLink}.` : "An approver can turn it on."}</p></div>`
16936
+ : code === "unpriced" || code === "no-key"
16937
+ ? `<div class="card" id="latest"><p><strong>The lead can’t run yet.</strong></p><p class="meta">${escape(data.enabled.why)}${data.canManage ? ` ·${settingsLink}` : ""}</p></div>`
16938
+ : `<div class="card" id="latest"><p><strong>chat is off.</strong></p><p class="meta">${escape(data.enabled.why)}</p></div>`);
16257
16939
  return screen("chat", chatWorkspace(parts.join("\n"), data.projects, data.csrf, true, data.focusTask, data.resultPanel ?? null), { chrome, functional: { script: CHAT_UI_SCRIPT + (data.focusTask === null ? "" : RESULT_REVIEW_SCRIPT), fetches: data.focusTask !== null } });
16258
16940
  }
16259
16941
  const config = data.enabled.config;
@@ -16309,15 +16991,7 @@ function chatPage(chrome, data) {
16309
16991
  parts.push(`<h2>ask</h2>`, `<form method="post" action="/chat" class="card">`, `<input type="hidden" name="csrf" value="${escape(data.csrf)}">`, `<label>message<textarea name="message" rows="3" maxlength="2000"></textarea></label>`, `<label>your password <span class="meta">(every message — chat spends)</span><input type="password" name="token" autocomplete="current-password"></label>`, `<button type="submit">ask</button>`, `</form>`);
16310
16992
  }
16311
16993
  if (data.canManage) {
16312
- parts.push(`<details id="chat-settings"${data.settingsOpen ? " open" : ""}><summary class="meta">Chat settings</summary>`, configForm(data.config), `<form method="post" action="/chat/config" class="inline">`, `<input type="hidden" name="csrf" value="${escape(data.csrf)}">`, `<input type="hidden" name="return" value="${escape(data.focusTask === null ? "/chat" : taskChatHref(data.focusTask.id))}">`, `<input type="hidden" name="off" value="1">`, `<input type="password" name="token" placeholder="your password" autocomplete="current-password">`, `<button type="submit">turn chat off</button>`, `</form>`, data.keyFacts
16313
- .filter(one => one.state === "stored")
16314
- .map(one => `<form method="post" action="/chat/config" class="inline">` +
16315
- `<input type="hidden" name="csrf" value="${escape(data.csrf)}">` +
16316
- `<input type="hidden" name="return" value="${escape(data.focusTask === null ? "/chat" : taskChatHref(data.focusTask.id))}">` +
16317
- `<input type="hidden" name="forget-key" value="${escape(one.provider)}">` +
16318
- `<input type="password" name="token" placeholder="your password" autocomplete="current-password">` +
16319
- `<button type="submit">forget the stored ${escape(one.provider)} key</button></form>`)
16320
- .join("\n"), `</details>`);
16994
+ parts.push(`<p class="meta" id="chat-settings"><a href="/settings/lead">Lead settings</a> · provider, model and limits</p>`);
16321
16995
  }
16322
16996
  if (data.recent.length > 0) {
16323
16997
  parts.push(`<details class="chat-turn-history"><summary>Conversation activity <span class="meta">${data.recent.length} recent turns</span></summary>`);
@@ -16434,7 +17108,7 @@ function proposalCardParts(view, csrf, inert, decision, returnTo = null) {
16434
17108
  : "inspect first when the work needs repository context";
16435
17109
  what =
16436
17110
  `<h3>${escape(text("title"))}</h3><p class="proposal-summary">${escape(text("goal"))}</p>` +
16437
- facts(["project", `<span class="mono">${escape(repoId)}</span>`], ["deliverable", payload["report"] === true ? "report only" : "branch"], ["planning", planningWords], ["out of scope", escape(text("not"))], ["may touch", Array.isArray(payload["touches"]) ? payload["touches"].map(one => `<span class="mono">${escape(one)}</span>`).join("<br>") : ""]);
17111
+ facts(["project", `<span class="mono">${escape(repoId)}</span>`], ["deliverable", payload["report"] === true ? "report only" : "branch"], ["planning", planningWords], ["checks", isCheckLevel(payload["checks"]) ? { quick: "Quick checks", full: "Full checks", off: "Off — built, not checked" }[payload["checks"]] : ""], ["out of scope", escape(text("not"))], ["may touch", Array.isArray(payload["touches"]) ? payload["touches"].map(one => `<span class="mono">${escape(one)}</span>`).join("<br>") : ""]);
16438
17112
  }
16439
17113
  else if (view.kind === "action") {
16440
17114
  const action = sharedActionPayload(payload);
@@ -16797,7 +17471,7 @@ function matePage(chrome, data) {
16797
17471
  : `<p class="meta">recent turns: ${data.recent
16798
17472
  .map(turn => `<span class="mono">#${turn.id}</span> ${escape(turn.state)}${turn.failureReason === null ? "" : ` · ${escape(turn.failureReason)}`} · ${subscription ? "membership" : chatMoney(turn.settledMicrousd ?? turn.reservedMicrousd)}`)
16799
17473
  .join(" · ")}</p>`,
16800
- `<p class="meta"><a href="/chat?settings=1#chat-settings">Chat settings</a> · provider, model and limits</p>`,
17474
+ `<p class="meta"><a href="/settings/lead">Lead settings</a> · provider, model and limits</p>`,
16801
17475
  `</details>`,
16802
17476
  ].join("\n");
16803
17477
  const conversation = [
@@ -17212,6 +17886,11 @@ function statusActionHref(status, taskId, runId, prUrl, repo) {
17212
17886
  }
17213
17887
  /** The one status line every surface renders: a dot in the tone, the
17214
17888
  * label, and the `data-work-status` token tests and CSS key off. */
17889
+ /** A not-yet-finished task's shared headline from its dispatch diagnosis (task-status.ts). */
17890
+ function dispatchHeadline(d) {
17891
+ const read = stageOfDispatch(d);
17892
+ return taskStatusOf({ stage: read.stage, ...(read.need === undefined ? {} : { need: read.need }) }).headline;
17893
+ }
17215
17894
  function statusLineHtml(status, extra = "") {
17216
17895
  return `<span class="status-line" data-work-status="${escape(status.token)}" data-tone="${escape(status.tone)}"><i class="status-dot" aria-hidden="true"></i><span class="status-label">${escape(status.label)}</span>${extra}</span>`;
17217
17896
  }
@@ -17265,7 +17944,7 @@ function workPage(chrome, data) {
17265
17944
  const actionHref = row.primaryAction?.code === 'open-result' && target?.runId != null
17266
17945
  ? `/review?result=${encodeURIComponent(target.taskId)}&run=${target.runId}${row.repo === null ? '' : '&project=' + encodeURIComponent(row.repo)}`
17267
17946
  : browserWorkActionHref(row);
17268
- const needsYouDetail = row.status.views.includes('needs-you') && !['write-scope', 'approve-scope'].includes(row.primaryAction?.code ?? '') && row.status.detail !== row.status.label && row.status.detail !== row.familyProblem;
17947
+ const needsYouDetail = (row.status.views.includes('needs-you') || row.status.label === 'Failed') && !['write-scope', 'approve-scope'].includes(row.primaryAction?.code ?? '') && row.status.label !== 'Ready for review' && row.status.detail !== row.status.label && row.status.detail !== row.familyProblem;
17269
17948
  return {
17270
17949
  id: row.rootId, title: row.title, href: taskHref(row.rootId),
17271
17950
  project: data.multiProject || row.repo === null ? (row.repo === null ? 'Unplaced' : projectName(row.repo)) : null,
@@ -17288,7 +17967,7 @@ function workPage(chrome, data) {
17288
17967
  };
17289
17968
  return screen('work', `<div class="work-head"><h1>Tasks</h1>${tools}</div>${limitsHtml(data.limits ?? null)}${tabs}${list}${pages}`, { chrome, workspace: { view } });
17290
17969
  }
17291
- function tasksPage(chrome, tasks, state, csrf, problem, repo = null, prefill = null, permissionDefault = "auto", qualityDefault = "default") {
17970
+ function tasksPage(chrome, tasks, state, csrf, problem, repo = null, prefill = null, permissionDefault = "auto", qualityDefault = "default", replaced = new Map()) {
17292
17971
  const filters = TASK_STATES.map(one => (one === state ? `<strong>${one}</strong>` : `<a href="/tasks?state=${one}">${one}</a>`)).join(" · ");
17293
17972
  const rows = tasks.length === 0
17294
17973
  ? `<p class="meta">${state === null
@@ -17296,7 +17975,7 @@ function tasksPage(chrome, tasks, state, csrf, problem, repo = null, prefill = n
17296
17975
  : `Nothing is ${escape(state)}.`}</p>`
17297
17976
  : tasks
17298
17977
  .map(task => `<a class="row" href="${taskHref(task.id)}"><span class="mono">${escape(task.id)}</span> ` +
17299
- `${escape(task.title)} <span class="right badge badge-${escape(task.state)}">${escape(task.state)}</span></a>`)
17978
+ `${escape(task.title)} <span class="right badge badge-${escape(task.state)}">${escape(task.state === "cancelled" && replaced.has(task.id) ? `replaced by ${replaced.get(task.id)}` : task.state)}</span></a>`)
17300
17979
  .join("\n");
17301
17980
  return screen("tasks", [
17302
17981
  "<h1>Tasks</h1>",
@@ -17967,7 +18646,7 @@ function githubReposPage(chrome, data) {
17967
18646
  `<p class="row" style="margin-top:.6rem"><a class="badge" href="/projects">← back to projects</a></p>`,
17968
18647
  ].join("\n"), { chrome });
17969
18648
  }
17970
- function projectsPage(chrome, recent, candidates, open, csrf, problem, _unscopedMode, browsable = false, onboard = null, peeks = {}, returnTo = "/", pullRequestsOn) {
18649
+ function projectsPage(chrome, recent, candidates, open, csrf, problem, _unscopedMode, browsable = false, onboard = null, peeks = {}, returnTo = "/", pullRequestsOn, checkLevelOf) {
17971
18650
  // The onboarding card (repo onboarding, findings 1-39): preview first,
17972
18651
  // then a password-confirmed clone into a configured root. Disabled
17973
18652
  // states explain themselves in words (finding 28/39).
@@ -18058,6 +18737,7 @@ function projectsPage(chrome, recent, candidates, open, csrf, problem, _unscoped
18058
18737
  name, path, shortPath: path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path, open: open !== null && open === path, openedAt,
18059
18738
  knowledgeHref: `/settings/knowledge?repo=${encodeURIComponent(path)}`,
18060
18739
  ...(pullRequestsOn === undefined ? {} : { pullRequests: { href: `/settings/pull-requests?repo=${encodeURIComponent(path)}`, on: pullRequestsOn(path) } }),
18740
+ ...(checkLevelOf === undefined ? {} : { checks: { href: `/settings/checks?repo=${encodeURIComponent(path)}`, level: CHECK_LEVEL_WORDS[checkLevelOf(path)] } }),
18061
18741
  peek: peek === null ? null : [
18062
18742
  ...(peek.waiting > 0 ? [{ label: `${peek.waiting} waiting on you`, href: "/", tone: "attention" }] : []),
18063
18743
  ...(peek.running > 0 ? [{ label: `${peek.running} running`, href: "/runs", tone: "neutral" }] : []),
@@ -18208,6 +18888,10 @@ function loginReturn(raw) {
18208
18888
  const query = parsed.searchParams.toString();
18209
18889
  return `${parsed.pathname}${query === "" ? "" : `?${query}`}`;
18210
18890
  }
18891
+ /** A request path fit for a log: the one-time sign-in link's code is left out. */
18892
+ export function redactedPath(path) {
18893
+ return path.startsWith(SIGN_IN_LINK_PATH) ? `${SIGN_IN_LINK_PATH}…` : path;
18894
+ }
18211
18895
  /** The sign-in page that comes back to `path` afterwards, or plain /login when the path is not one to come back to. */
18212
18896
  function loginHref(path) {
18213
18897
  const back = loginReturn(path);
@@ -18331,7 +19015,7 @@ function queueCard(one, csrf, revision, queueRevision, workers, column) {
18331
19015
  ? `<span class="badge">queued</span>`
18332
19016
  : `<span class="badge">reserved for ${escape(column)}</span>`;
18333
19017
  const chips = ` ${state}` +
18334
- `${one.dispatch === null || one.dispatch === undefined ? "" : ` <a class="badge" href="${taskHref(one.id)}">${escape(one.dispatch.summary.toLowerCase())}</a>`}`;
19018
+ `${one.dispatch === null || one.dispatch === undefined ? "" : ` <a class="badge" href="${taskHref(one.id)}" title="${escape(one.dispatch.summary)}">${escape(dispatchHeadline(one.dispatch))}</a>`}`;
18335
19019
  const hidden = `<input type="hidden" name="csrf" value="${escape(csrf)}">` +
18336
19020
  `<input type="hidden" name="projectRevision" value="${revision}">` +
18337
19021
  `<input type="hidden" name="queueRevision" value="${queueRevision}">` +
@@ -18802,7 +19486,11 @@ function taskBodyParts(data) {
18802
19486
  // data-work-status is the shared projection's token (package 1) —
18803
19487
  // the same one the Work row, chat, and cockpit carry for this task.
18804
19488
  const workToken = resultStatus?.token ?? data.dispatch?.code ?? "unknown";
18805
- const box = (kind, title, detail, status, controls = "") => `<div class="${kind === "problem" && !inReview ? "problem" : "answered"} dispatch-status" id="run-status" data-dispatch-status="${escape(status ?? title.toLowerCase().replace(/[^a-z0-9]+/g, "-"))}" data-work-status="${escape(workToken)}">` +
19489
+ // A diagnosis under Task options: the exact technical reason, one tap
19490
+ // away from the shared headline (task-status.ts). Red only when that
19491
+ // headline is Failed.
19492
+ const headline = status.label;
19493
+ const box = (kind, title, detail, code, controls = "") => `<div class="${kind === "problem" && !inReview && headline === "Failed" ? "problem" : "answered"} dispatch-status" id="run-status" data-dispatch-status="${escape(code ?? title.toLowerCase().replace(/[^a-z0-9]+/g, "-"))}" data-work-status="${escape(workToken)}">` +
18806
19494
  `<div class="dispatch-copy"><strong>${escape(inReview ? "Recorded checks" : title)}</strong><span class="meta">${detail}</span></div>${controls}</div>`;
18807
19495
  if (task.state !== "done") {
18808
19496
  const diagnosis = data.dispatch ?? null;
@@ -19487,7 +20175,7 @@ function taskBodyParts(data) {
19487
20175
  facts.push({ label: "Queue", parts: [`${data.position.position} of ${data.position.total}${data.position.column === null ? " in the shared queue" : ` in ${data.position.column}'s queue`} · `, { label: "Reorder", href: "/board?view=order" }] });
19488
20176
  }
19489
20177
  facts.push(scope === null ? { label: "Scope", parts: ["none yet"] }
19490
- : approval.approved ? { label: "Approved scope", parts: [{ seal: `signs ${scope.digest.length <= 12 ? scope.digest : `${scope.digest.slice(0, 12)}…`}` }, ` · ${qualityModeTitle(scope.qualityMode ?? "default")} · approved by ${approval.by} · ${when(approval.at)}`] }
20178
+ : approval.approved ? { label: "Approved scope", parts: [{ seal: `signs ${scope.digest.length <= 12 ? scope.digest : `${scope.digest.slice(0, 12)}…`}` }, ` · ${qualityModeTitle(scope.qualityMode ?? "default")} · approved by ${approval.by}`, ...(approval.at === null ? [] : [" · ", { at: approval.at }])] }
19491
20179
  : { label: "Scope", parts: [approval.reason === "changed" ? "rewritten since its approval — needs a new yes" : "not approved"] });
19492
20180
  facts.push({ label: "Publishes as", parts: [publishesAs] });
19493
20181
  // v102: who asked for it, and what the project's approval rules need.
@@ -19689,7 +20377,13 @@ function taskBodyParts(data) {
19689
20377
  : ` · filed via ${escape(data.filedVia)}`}${data.deliverable === "report" ? ` · <span class="badge">scout</span>` : ""}</p>`;
19690
20378
  // A pull request that can be merged (or opened) owns the one primary action; the result stays in its section.
19691
20379
  const pullRequestActs = data.csrf !== "" && (data.pullRequest?.view?.canMerge === true || (data.pullRequest?.view == null && data.pullRequest?.offer != null));
19692
- 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] : [])] };
20380
+ const pullRequestFact = data.pullRequest?.fact != null && data.pullRequest.view?.runId === data.assignment?.receipt?.runId ? data.pullRequest.fact : undefined;
20381
+ const assignmentOptions = data.assignment == null ? null : { workStatus: status, hideAction: (approveForm !== "" && data.dispatch?.action === "approve-scope") || pullRequestActs, problem: status.tone === "problem",
20382
+ planning: data.dispatch?.code === "running" && data.dispatch.role === "planner", ...(pullRequestFact === undefined ? {} : { pullRequest: pullRequestFact }),
20383
+ diagnostics: [...(status.diagnostics ?? []), ...(status.tone === "problem" && status.detail !== data.assignment.detail ? [status] : [])] };
20384
+ // Say it once: a pull request that couldn't open, or merged, is the status
20385
+ // card's own row (its reason or merge commit under Details), not a second card.
20386
+ const pullRequestCard = data.pullRequest == null || (pullRequestFact !== undefined && (data.pullRequest.view?.state === "failed" || data.pullRequest.view?.state === "merged")) ? "" : pullRequestCardHtml(data.pullRequest, data.csrf);
19693
20387
  // The result takes over from the task status as soon as it is ready.
19694
20388
  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);
19695
20389
  // The exact-run control (v52), directly under the scheduler's answer:
@@ -19799,6 +20493,135 @@ function taskBodyParts(data) {
19799
20493
  cancelAct,
19800
20494
  `</div><aside class="task-rail">${rail}</aside></div>`,
19801
20495
  ].join("\n");
20496
+ // Console v2: one thread in time order (the plan, the agent's notes and
20497
+ // results, its questions, the person's replies) and the metadata grouped
20498
+ // for a Details panel. Every form below keeps its own route and fields.
20499
+ const familyRuns = data.family?.runs ?? data.runs.map(run => ({ ...run, taskId: task.id }));
20500
+ const versionLabelOf = (taskId) => {
20501
+ const index = data.family?.versions.findIndex(one => one.id === taskId) ?? -1;
20502
+ return index <= 0 ? "" : ` · revision ${index}`;
20503
+ };
20504
+ const thread = [];
20505
+ const filedBy = data.filer?.name ?? data.coordinator?.label ?? null;
20506
+ const rootFiled = data.family?.root.createdAt ?? task.createdAt;
20507
+ thread.push({ key: "filed", at: rootFiled, kind: "filed", who: "person", author: filedBy ?? "", title: "Filed the task",
20508
+ text: (() => { const goal = data.family?.root.goal ?? scope?.goal ?? null; return goal === null ? null : oneLineOf(goal, 280); })(), link: null, html: "", more: null });
20509
+ const newestBuilt = familyRuns.filter(run => run.outcome === "built" && run.role !== "planner" && run.role !== "reviewer").sort((a, b) => b.id - a.id)[0];
20510
+ for (const run of familyRuns) {
20511
+ const live = run.id === liveHistoryRunId || (run.outcome === null && run.id === liveRunId);
20512
+ const noun = runNoun(run);
20513
+ const href = { label: `${noun[0].toUpperCase()}${noun.slice(1)} #${run.id}`, href: `/r/${run.id}` };
20514
+ const revision = versionLabelOf(run.taskId);
20515
+ if (run.role === "planner") {
20516
+ thread.push({ key: `run-${run.id}`, at: run.finishedAt ?? run.startedAt, kind: "plan", who: "agent", author: run.runner,
20517
+ title: live ? "Planning" : run.reason === "plan-drafted" ? "Drafted a plan" : `Planning ended · ${run.outcome === null ? "never finished" : run.reason === null ? run.outcome : reasonWords(run.reason)}`,
20518
+ text: null, link: href, html: "", more: null });
20519
+ continue;
20520
+ }
20521
+ if (live) {
20522
+ thread.push({ key: `run-${run.id}`, at: run.startedAt, kind: "progress", who: "agent", author: run.runner,
20523
+ title: `${run.role === "reviewer" ? "Reviewing" : run.role === "scout" ? "Investigating" : "Building"}${revision}`,
20524
+ text: `${homePhaseWords(run)}.`, link: href, html: "", more: null });
20525
+ continue;
20526
+ }
20527
+ const title = run.role === "reviewer" ? "Reviewed the result"
20528
+ : run.outcome === "built" ? (run.role === "scout" ? "Report ready" : "Result ready")
20529
+ : run.outcome === "no-change" ? "Finished with no change"
20530
+ : run.outcome === "parked" ? "Stopped to ask"
20531
+ : run.outcome === null ? "Attempt never finished"
20532
+ : `${noun[0].toUpperCase()}${noun.slice(1)} ${run.outcome}`;
20533
+ thread.push({ key: `run-${run.id}`, at: run.finishedAt ?? run.startedAt, kind: "result", who: "agent", author: run.runner,
20534
+ title: `${title}${revision}`, text: run.handoff === null ? (run.reason === null || run.outcome === "built" ? null : reasonWords(run.reason)) : oneLineOf(run.handoff, 600),
20535
+ link: href, html: "",
20536
+ more: previousResult !== null && run.id === newestBuilt?.id && run.taskId === task.id ? { summary: "Result details", html: previousResult.html } : null });
20537
+ }
20538
+ // What the agent is working through now, and how its plan changed.
20539
+ const liveAt = liveRun?.startedAt ?? task.updatedAt;
20540
+ if (planCard !== "")
20541
+ thread.push({ key: "plan", at: familyRuns.filter(run => run.role === "planner").map(run => run.finishedAt ?? run.startedAt).sort().at(-1) ?? task.updatedAt,
20542
+ kind: "plan", who: "agent", author: "", title: "The plan", text: null, link: null, html: planCard, more: null });
20543
+ if (progressCard !== "")
20544
+ thread.push({ key: "progress", at: liveAt, kind: "progress", who: "agent", author: "", title: "Progress", text: null, link: null, html: progressCard, more: null });
20545
+ if (revisionLedgerCard !== "")
20546
+ thread.push({ key: "plan-revisions", at: liveAt, kind: "progress", who: "agent", author: "", title: "Plan changes", text: null, link: null, html: revisionLedgerCard, more: null });
20547
+ // The change a person asked for: the revision's own brief.
20548
+ if (data.revision != null && !("problem" in data.revision)) {
20549
+ const feedback = data.revision;
20550
+ thread.push({ key: "revision", at: task.createdAt, kind: "reply", who: "person", author: feedback.comments[0]?.author ?? "You",
20551
+ title: feedback.kind === "ci-repair" ? "Asked for a CI repair" : feedback.kind === "criterion-repair" ? "Asked for a repair" : "Asked for changes",
20552
+ text: feedback.comments.map(one => `${one.path === null ? "" : `${one.path}${one.line === null ? "" : `:${one.line}`} — `}${one.note}`).join("\n"),
20553
+ link: { label: `From build #${feedback.sourceRun}`, href: `/r/${feedback.sourceRun}` }, html: "", more: null });
20554
+ }
20555
+ for (const decision of data.decisions) {
20556
+ if (decision.state === "open" || decision.state === "expired")
20557
+ continue;
20558
+ thread.push({ key: `question-${decision.id}`, at: decision.createdAt, kind: "question", who: "agent", author: "", title: "Asked",
20559
+ text: decision.question, link: { label: "Question", href: `/d/${decision.id}` }, html: "", more: null });
20560
+ if (decision.answeredAt !== null) {
20561
+ const chosen = decision.options.find(one => one.id === decision.choice)?.label ?? decision.choice;
20562
+ thread.push({ key: `answer-${decision.id}`, at: decision.answeredAt, kind: "reply", who: "person", author: decision.answeredBy ?? "You", title: "Answered",
20563
+ text: [chosen, decision.note].filter((one) => one !== null && one !== "").join(" — ") || null, link: null, html: "", more: null });
20564
+ }
20565
+ }
20566
+ // Open questions keep their answer card (and its #task-questions anchor).
20567
+ if (decisionRail !== "")
20568
+ thread.push({ key: "questions", at: openDecisions.map(one => one.createdAt).sort()[0] ?? task.updatedAt, kind: "question", who: "agent", author: "",
20569
+ title: openDecisions.length === 1 ? "Asked you a question" : `Asked you ${openDecisions.length} questions`, text: null, link: null, html: decisionRail, more: null });
20570
+ for (const note of data.steering ?? []) {
20571
+ thread.push({ key: `steer-${note.id}`, at: note.createdAt, kind: "reply", who: "person", author: note.author, title: "Note to the agent", text: note.note, link: null, html: "", more: null });
20572
+ }
20573
+ thread.sort((a, b) => a.at === b.at ? 0 : a.at < b.at ? -1 : 1);
20574
+ // The Details panel: the same facts, grouped and said once.
20575
+ const lastRunForAgent = liveRun ?? familyRuns.filter(run => run.role !== "reviewer").sort((a, b) => b.id - a.id)[0];
20576
+ const work = [];
20577
+ work.push({ label: "Status", parts: [data.assignment != null && assignmentOptions !== null ? assignmentCardOf(data.assignment, assignmentOptions).status.headline : status.label] });
20578
+ if (data.repo !== null)
20579
+ work.push({ label: "Project", parts: [projectName(data.repo)] });
20580
+ work.push({ label: "Agent", parts: [lastRunForAgent !== undefined ? `${lastRunForAgent.provider}${lastRunForAgent.model === null ? "" : ` · ${lastRunForAgent.model}`} on ${lastRunForAgent.runner}`
20581
+ : data.route?.legacy != null ? `${data.route.legacy.provider} · ${data.route.legacy.model}` : "The project's default"] });
20582
+ if (scope !== null)
20583
+ work.push({ label: "Checks level", parts: [qualityModeTitle(scope.qualityMode ?? "default")] });
20584
+ for (const label of ["Worker", "Last attempt", "Queue", "Budget", "Policy", "Strikes"]) {
20585
+ const fact = facts.find(one => one.label === label);
20586
+ if (fact !== undefined)
20587
+ work.push(fact);
20588
+ }
20589
+ const links = [];
20590
+ if (data.family != null && data.family.root.id !== task.id)
20591
+ links.push({ label: "Parent", parts: [{ label: data.family.root.title, href: `${taskHref(data.family.root.id)}?version=${encodeURIComponent(data.family.root.id)}` }] });
20592
+ const blockers = data.waitsFor ?? [];
20593
+ if (blockers.length > 0)
20594
+ links.push({ label: "Blocked by", parts: blockers.flatMap((one, index) => [...(index === 0 ? [] : [", "]), one.admitted ? { label: one.title ?? one.id, href: taskHref(one.id) } : one.id]) });
20595
+ const laterVersions = (data.family?.versions ?? []).filter((one, index, all) => index > all.findIndex(version => version.id === task.id) && all.findIndex(version => version.id === task.id) >= 0);
20596
+ if (laterVersions.length > 0)
20597
+ links.push({ label: "Follow-ups", parts: laterVersions.flatMap((one, index) => [...(index === 0 ? [] : [", "]), { label: one.title, href: `${taskHref(data.family.root.id)}?version=${encodeURIComponent(one.id)}` }]) });
20598
+ const published = facts.find(one => one.label === "Published");
20599
+ if (published !== undefined)
20600
+ links.push({ ...published, label: "Pull request" });
20601
+ else if (data.pullRequest?.view != null) {
20602
+ const pr = data.pullRequest.view;
20603
+ const prHref = safePrUrl(pr.prUrl);
20604
+ links.push({ label: "Pull request", parts: [prHref === null ? `#${pr.prNumber ?? "?"}` : { label: `#${pr.prNumber ?? "?"}`, href: prHref }, ` · ${pr.label}`] });
20605
+ }
20606
+ const review = [];
20607
+ const approvedScope = facts.find(one => one.label === "Approved scope" || one.label === "Scope");
20608
+ if (approvedScope !== undefined)
20609
+ review.push({ ...approvedScope, label: approvedScope.label === "Approved scope" ? "Approvals" : "Scope" });
20610
+ review.push({ label: "Who reviews", parts: [facts.find(one => one.label === "Approval rules")?.parts[0] ?? "Any approver in this project"] });
20611
+ review.push({ label: "Publishes as", parts: [publishesAs] });
20612
+ const about = [];
20613
+ const filedFact = facts.find(one => one.label === "Filed by");
20614
+ about.push(filedFact ?? { label: "Filed by", parts: [filedBy ?? (data.filedVia == null ? "Not recorded" : `via ${data.filedVia}`)] });
20615
+ about.push({ label: "Filed", parts: [{ at: rootFiled }] });
20616
+ about.push({ label: "Updated", parts: [{ at: task.updatedAt }] });
20617
+ for (const label of ["Audit", "This attempt", "Task total"]) {
20618
+ const fact = facts.find(one => one.label === label);
20619
+ if (fact !== undefined)
20620
+ about.push({ ...fact, label: label === "Audit" ? "Audit" : label === "This attempt" ? "Usage, this attempt" : "Usage, all attempts" });
20621
+ }
20622
+ const details = [
20623
+ { title: "Work", facts: work }, { title: "Links", facts: links }, { title: "Review", facts: review }, { title: "About", facts: about },
20624
+ ];
19802
20625
  // The rebuilt page (shadcn/ui): the same parts in a calmer order — what
19803
20626
  // needs a person, then facts, then folds; the mechanics under Manage.
19804
20627
  const MANAGE = new Set(["steering", "waits-for", "holds"]);
@@ -19817,25 +20640,35 @@ function taskBodyParts(data) {
19817
20640
  status: data.assignment != null && assignmentOptions !== null ? assignmentCardOf(data.assignment, assignmentOptions) : null,
19818
20641
  statusHtml,
19819
20642
  approval: approvalHtml,
20643
+ // The plan, progress and plan changes are thread entries now; the rest still needs a person here.
19820
20644
  lead: [
19821
20645
  { key: "history", html: data.history ?? "" }, { key: "control", html: controlHtml }, { key: "problem", html: problemHtml },
19822
- { key: "pull-request", html: data.pullRequest == null ? "" : pullRequestCardHtml(data.pullRequest, data.csrf) },
19823
- { key: "needs-scope", html: needsScopeCard }, { key: "progress", html: progressCard }, { key: "revisions", html: revisionLedgerCard },
19824
- { key: "plan", html: planCard }, { key: "mirror", html: mirrorCard }, { key: "contest", html: contestCard }, { key: "attempt", html: attemptPanel },
20646
+ { key: "pull-request", html: pullRequestCard },
20647
+ { key: "needs-scope", html: needsScopeCard },
20648
+ { key: "mirror", html: mirrorCard }, { key: "contest", html: contestCard }, { key: "attempt", html: attemptPanel },
19825
20649
  ].filter(one => one.html !== ""),
19826
- questions: decisionRail,
20650
+ // Open questions are answered in the thread.
20651
+ questions: "",
19827
20652
  facts,
19828
20653
  sections: [
19829
- ...(previousResult === null ? [] : [{ id: "result", title: previousResult.title, html: previousResult.html, open: false, count: null }]),
19830
- // A finished task leads with its result; its scope folds until asked.
19831
- ...sectionParts.filter(one => !MANAGE.has(one.id)).map(one => one.id === "scope" && finished && data.scopeDraft === undefined ? { ...one, open: false } : one),
20654
+ ...(previousResult === null || thread.some(one => one.more !== null) ? [] : [{ id: "result", title: previousResult.title, html: previousResult.html, open: false, count: null }]),
20655
+ // Questions are in the thread; the scope and the ledgers fold here until asked.
20656
+ ...sectionParts.filter(one => !MANAGE.has(one.id) && one.id !== "decisions").map(one => one.id === "scope" && data.scopeDraft === undefined && (finished || approval.approved) ? { ...one, open: false } : one),
19832
20657
  ],
19833
20658
  manage: [
19834
20659
  ...sectionParts.filter(one => MANAGE.has(one.id)),
19835
20660
  { id: "task-diagnostics", title: "Task options", html: optionsHtml, open: optionsOpen, count: null },
19836
20661
  ],
19837
20662
  cancel: cancelForm === "" ? null : { html: cancelForm, open: data.cancelDraft !== undefined },
20663
+ journey: data.guide !== true || task.state === "cancelled" || data.status === undefined ? null : (() => {
20664
+ const planning = data.plan === "requested" && approveForm === "";
20665
+ const read = data.assignment != null ? assignmentStageOf(data.assignment, { token: data.status.token }, planning) : stageOfCode(data.status.token, { planning });
20666
+ return firstTaskJourney(read, scope !== null && approvalOf(scope).approved, task.state === "done");
20667
+ })(),
19838
20668
  // "Do this every time…": the starter flow that does this kind of work on its own, one yes away.
20669
+ thread,
20670
+ details,
20671
+ chatHref: data.csrf === "" ? null : taskChatHref(data.rootId ?? task.id),
19839
20672
  everyTime: data.repo === null || data.csrf === "" ? null : (() => {
19840
20673
  const starter = starterForWork(`${task.title}\n${scope?.goal ?? ""}`);
19841
20674
  return { href: `/settings/flows?repo=${encodeURIComponent(data.repo)}&starter=${starter.id}#starter-${starter.id}`, starter: starter.name };
@@ -20071,11 +20904,11 @@ function cockpitStatusOf(view) {
20071
20904
  // The receipt's own status (package 3): the same projection the chat
20072
20905
  // receipt and the run page print, read from the shared detail.
20073
20906
  if (view.run === null || view.detail === null)
20074
- return view.assignment === null ? resultStatusOf(null, null) : assignmentPresentationOf(view.assignment).status;
20907
+ return view.assignment === null ? resultHeadlineOf(resultStatusOf(null, null)) : assignmentPresentationOf(view.assignment).status;
20075
20908
  const receiptStatus = receiptStatusOf(view.detail.receipt);
20076
20909
  if (view.assignment !== null)
20077
20910
  return assignmentStatusOf(assignmentWithEvidence(view.assignment, receiptStatus, view.run.id));
20078
- return receiptStatus;
20911
+ return resultHeadlineOf(receiptStatus);
20079
20912
  }
20080
20913
  /** Turn verifier records into one sentence a project owner can act on.
20081
20914
  * A command-not-found result means the check could not start, not that the
@@ -20368,7 +21201,8 @@ function pullRequestCardHtml(pr, csrf) {
20368
21201
  const view = pr.view;
20369
21202
  const url = safePrUrl(view.prUrl);
20370
21203
  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>`;
20371
- const tone = view.state === "ready" || view.state === "merged" ? "ok" : view.state === "failing" || view.state === "failed" || view.state === "closed" ? "problem" : "waiting";
21204
+ // A pull request's trouble never undoes the result: amber, never red.
21205
+ const tone = view.state === "ready" || view.state === "merged" ? "ok" : view.state === "failing" || view.state === "failed" ? "problem" : "waiting";
20372
21206
  const revision = view.revisionTask === null || view.state !== "failing" ? "" : ` <a href="${taskHref(view.revisionTask)}">Open revision</a>`;
20373
21207
  const merged = view.mergeCommit === null ? "" : `<p class="meta">Merge commit <span class="mono">${escape(view.mergeCommit.slice(0, 12))}</span></p>`;
20374
21208
  const target = pr.target === null ? "the base branch" : pr.target;
@@ -20377,7 +21211,7 @@ function pullRequestCardHtml(pr, csrf) {
20377
21211
  `<input type="hidden" name="csrf" value="${escape(csrf)}"><input type="hidden" name="run" value="${view.runId}">` +
20378
21212
  `<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>` +
20379
21213
  `<label>Password<input type="password" name="token" autocomplete="current-password" required></label>` +
20380
- `<button type="submit">Merge</button></form>`;
21214
+ (view.fullCheck === null ? `<button type="submit">Merge</button>` : `<input type="hidden" name="anyway" value="1"><button type="submit" class="secondary">Merge anyway</button>`) + `</form>`;
20381
21215
  return `<section class="card pull-request pull-request--${tone}" id="merge" data-pull-request="${escape(view.state)}">` +
20382
21216
  `<div class="pull-request-head"><strong>${escape(view.label)}</strong>${link}</div>` +
20383
21217
  `<p class="meta">${escape(view.detail)}${revision}</p>${merged}${act}</section>`;
@@ -20420,6 +21254,18 @@ const PHASE_WORDS = {
20420
21254
  "capturing-evidence": "capturing evidence",
20421
21255
  committing: "committing",
20422
21256
  };
21257
+ /** An agent's phase on the home card, from the machine's own vocabulary. */
21258
+ function homePhaseWords(run) {
21259
+ if (run.role === "planner")
21260
+ return "Planning";
21261
+ if (run.role === "reviewer")
21262
+ return "Reviewing";
21263
+ if (run.role === "scout")
21264
+ return "Investigating";
21265
+ const words = { "agent-running": "Writing the change", "validating-handoff": "Checking its handoff", "capturing-evidence": "Saving its evidence",
21266
+ committing: "Committing", "verifying-proof": "Running the checks", "correcting-proof": "Correcting its evidence" };
21267
+ return run.phase === null ? "Starting" : words[run.phase] ?? "Working";
21268
+ }
20423
21269
  function phaseWords(phase) {
20424
21270
  return PHASE_WORDS[phase] ?? "the agent is working";
20425
21271
  }
@@ -21076,6 +21922,34 @@ function evidenceBundleCard(view, run, publication = null, review = null) {
21076
21922
  * the operator's acceptance alone — shared with the review cockpit's
21077
21923
  * header chip so the two surfaces never name one state differently. */
21078
21924
  /** The receipt's facts as the shared projection reads them. */
21925
+ /** A result's follow-ups: checks run on its commit since it finished, and its tests task. */
21926
+ function followUpsFor(store, evidenceRoot, run, now) {
21927
+ if (run.role !== "builder" || run.finishedAt === null || run.headRevision === null)
21928
+ return null;
21929
+ const repo = store.refById(run.taskRef)?.repo ?? null;
21930
+ const tests = store.handle.prepare("SELECT outcome FROM action_ledger WHERE run_id = ? AND action = ? ORDER BY id DESC LIMIT 1").get(run.id, ADD_TESTS_ACTION);
21931
+ return { repo, checks: followUpChecksOf(store, run.id, now, evidenceRoot), testsTask: tests === undefined ? null : String(tests["outcome"]),
21932
+ quick: repo !== null && liveQuickCommand(store, repo) !== null, full: repo !== null && store.liveVerifyCommand(repo) !== null };
21933
+ }
21934
+ const FOLLOW_UP_WORDS = { waiting: "waiting for a worker", running: "running", passed: "passed", failed: "failed", "not-run": "didn't run" };
21935
+ /** Run checks and Add tests, under the result's Checks: what ran since, then the two acts. */
21936
+ function followUpsHtml(followUps, runId, o) {
21937
+ const rows = followUps.checks.map(one => `<li data-follow-up-check="${one.state}">${escape(CHECK_LEVEL_WORDS[one.level])} checks ${escape(FOLLOW_UP_WORDS[one.state])}` +
21938
+ `${one.exitCode === null || one.state === "passed" ? "" : ` (exit ${one.exitCode})`} on <span class="mono">${escape(one.head.slice(0, 7))}</span>` +
21939
+ `<span class="meta"> · ${one.why === "pull-request" ? "for the pull request" : escape(one.actor)}${one.note === null ? "" : ` · ${escape(one.note)}`}</span>` +
21940
+ `${one.logArtifactId === null ? "" : ` <a href="/r/${runId}/evidence/${one.logArtifactId}">Log</a>`}</li>`).join("");
21941
+ const hidden = `<input type="hidden" name="csrf" value="${escape(o.csrf)}"><input type="hidden" name="return" value="${escape(o.returnTo)}">`;
21942
+ const canCheck = followUps.quick || followUps.full;
21943
+ const checkActs = !canCheck || o.csrf === "" ? "" : `<form method="post" action="/r/${runId}/checks" class="follow-up-act">${hidden}` +
21944
+ (followUps.quick && followUps.full ? `<button type="submit" name="level" value="quick">Run quick checks</button><button type="submit" name="level" value="full" class="secondary">Run full checks</button>`
21945
+ : `<button type="submit" name="level" value="${followUps.quick ? "quick" : "full"}">Run checks</button>`) + `</form>`;
21946
+ const tests = followUps.testsTask !== null ? `<p class="meta">Tests task: <a href="${taskHref(followUps.testsTask)}">${escape(followUps.testsTask)}</a></p>`
21947
+ : o.csrf === "" ? "" : `<form method="post" action="/r/${runId}/add-tests" class="follow-up-act">${hidden}<button type="submit" class="secondary">Add tests</button></form>`;
21948
+ return `<section class="result-section follow-ups" id="follow-ups" data-follow-ups><strong>Follow-ups</strong>` +
21949
+ (rows === "" ? "" : `<ul class="follow-up-list">${rows}</ul>`) +
21950
+ (canCheck || followUps.repo === null ? "" : `<p class="meta">No approved check to run. <a href="/settings/checks?repo=${encodeURIComponent(followUps.repo)}">Set one up</a>.</p>`) +
21951
+ (checkActs === "" && tests === "" ? "" : `<div class="follow-up-acts">${checkActs}${tests}</div>`) + `</section>`;
21952
+ }
21079
21953
  function receiptStatusOf(view, review = view.review) {
21080
21954
  // The evidence health rides the status (repair 2026-09-14): a result
21081
21955
  // whose stored records are damaged is never called ready on the strength
@@ -21144,7 +22018,8 @@ function completionReceiptCard(view, taskId, place, standing = receiptStatusOf(v
21144
22018
  lead +
21145
22019
  `<p class="receipt-summary">${escape(conciseOutcomeOf(view.summary ?? (view.outcome === "no-change" ? "The agent found that no repository change was needed." : "The build finished without a concise handoff.")))}</p>` +
21146
22020
  (headline ? `<div class="receipt-actions"><a class="button-link" href="${escape(resultHref)}" data-open-result data-primary-action>${escape(status.action?.label ?? "Open result")}</a></div>` : "") +
21147
- (view.publication?.state === "failed" ? `<p class="problem">${escape(facts.publicationWords)}</p>` : "") +
22021
+ // A pull request that couldn't open never undoes the result: an amber note, not red.
22022
+ (view.publication?.state === "failed" ? `<p class="receipt-note">${statusIconSvg("note")} Pull request couldn't open. The commit is safe locally.</p>` : "") +
21148
22023
  caveats +
21149
22024
  coverage +
21150
22025
  `<details class="receipt-details"><summary>Result details</summary>` +
@@ -21195,16 +22070,20 @@ function resultPanelParts(detail, o) {
21195
22070
  const { run, receipt, proof, terminal, handoff } = detail;
21196
22071
  const facts = receipt.facts;
21197
22072
  const current = detail.assignment?.receipt?.runId === run.id ? detail.assignment : null;
21198
- const presentation = current == null ? null : assignmentPresentationOf(current, { additionalAttention: [...facts.evidenceProblems, ...receipt.caveats] });
21199
- let status = presentation?.status ?? receiptStatusOf(receipt);
22073
+ const followUps = detail.followUps ?? null;
22074
+ const resultLinks = { result: o.hrefFor("summary"), checks: o.hrefFor("checks"), pullRequest: `${taskHref(detail.rootId ?? detail.taskId)}#merge`,
22075
+ ...(followUps !== null && (followUps.quick || followUps.full) && o.csrf !== "" ? { runChecks: `${o.hrefFor("checks")}#follow-ups` } : {}) };
22076
+ const presentation = current == null ? null : assignmentPresentationOf(current, { additionalAttention: [...facts.evidenceProblems, ...receipt.caveats],
22077
+ ...(detail.publication === null ? {} : { pullRequest: pullRequestFactOf({ ...detail.publication, lastCheckState: detail.ciFailing ? "failing" : detail.publication.lastCheckState }) }),
22078
+ evidence: { damaged: facts.evidenceHealth.damaged, missing: facts.evidenceHealth.missing, shortened: facts.evidenceHealth.shortened }, links: resultLinks });
22079
+ const taskStatus = presentation?.taskStatus ?? null;
22080
+ const status = presentation?.status ?? resultHeadlineOf(receiptStatusOf(receipt));
21200
22081
  const stored = receiptStatusOf(receipt, null);
21201
22082
  const humanReview = manualReviewOnly(proof === null ? null : { ...proof, verdict: proof.verdict ?? "" });
21202
22083
  const directAssessment = proof?.matrix.some(row => row.assessment !== undefined) === true;
21203
22084
  const awaitingGoalReview = directAssessment && proof?.reasons.length === 1 && proof.reasons[0] === GOAL_ASSESSMENT_PENDING;
21204
22085
  const assessmentReasons = directAssessment ? new Set(proof.matrix.flatMap(row => row.review ? [`${row.review.author} ${row.review.judgement === "contradicts" ? "contradicts" : "needs more evidence for"} criterion "${row.id}": ${row.review.note}`] : [])) : new Set();
21205
22086
  const physicalReasons = receipt.reasons.filter(reason => !assessmentReasons.has(reason));
21206
- if (directAssessment && status.token === "verification-needed" && proof?.verdict === "short" && proof.matrix.some(row => row.review?.judgement === "cannot-tell") && physicalReasons.length === 0)
21207
- status = { ...status, label: "More evidence needed", tone: "attention" };
21208
22087
  const runId = run.id;
21209
22088
  const shots = proof?.screenshots ?? [];
21210
22089
  const shown = shots.filter(one => one.problem === null);
@@ -21238,10 +22117,11 @@ function resultPanelParts(detail, o) {
21238
22117
  attention.push(...(handoff?.followUps ?? []).map(one => `Follow-up: ${one}`));
21239
22118
  // Publication risks stay in the open (repair 2026-09-14); the routine
21240
22119
  // publication fact lives with the build details below.
21241
- if (publication !== null && publication.state === "failed") {
22120
+ // With the shared status, the pull request is its own quiet row (its reason under Details).
22121
+ if (publication !== null && publication.state === "failed" && taskStatus === null) {
21242
22122
  attention.push(`Publication failed after ${publication.attempts} attempt${publication.attempts === 1 ? "" : "s"}${publication.lastError === null ? "" : ` — ${oneLineOf(publication.lastError, 200)}`}. No pull request or merge is recorded.`);
21243
22123
  }
21244
- if (detail.ciFailing && publication !== null)
22124
+ if (detail.ciFailing && publication !== null && taskStatus === null)
21245
22125
  attention.push(`CI is failing on PR #${publication.prNumber} at the last check.`);
21246
22126
  // Requirements only a person can confirm: plain words and one Accept,
21247
22127
  // in neutral ink — nothing failed. Accepting records the person's
@@ -21433,7 +22313,7 @@ function resultPanelParts(detail, o) {
21433
22313
  }
21434
22314
  if (proof.proofProblem !== null)
21435
22315
  checkParts.push(`<p class="problem">Verification details are unavailable: ${escape(proof.proofProblem)}</p>`);
21436
- checkParts.push(proof.checkLog === null ? `<p class="meta" data-cockpit-source="machine">No automated check was configured for this build.</p>` : checkLogHtml(proof.checkLog, runId, ' data-cockpit-source="machine"'));
22316
+ checkParts.push(proof.checkLog === null ? `<p class="meta" data-cockpit-source="machine">${current?.receipt?.checks.level === "off" ? "Checks were off for this build." : "No automated check was configured for this build."}</p>` : checkLogHtml(proof.checkLog, runId, ' data-cockpit-source="machine"'));
21437
22317
  checkParts.push(proof.proof === null || proof.proof.checks.length === 0
21438
22318
  ? directAssessment ? "" : `<p class="meta" data-cockpit-source="agent">The agent reported no checks.</p>`
21439
22319
  : `<details class="cockpit-proof-group" data-cockpit-source="agent"><summary>Agent checks · ${proof.proof.checks.length}</summary><div class="result-section"><ul>` +
@@ -21451,6 +22331,8 @@ function resultPanelParts(detail, o) {
21451
22331
  ? directAssessment ? "" : `<p class="meta" data-cockpit-source="caveats">No caveats were reported.</p>`
21452
22332
  : `<details class="cockpit-proof-group" data-cockpit-source="caveats"><summary>Agent caveats · ${receipt.caveats.length}</summary><ul>${receipt.caveats.map(one => `<li>${escape(one)}</li>`).join("")}</ul></details>`);
21453
22333
  }
22334
+ if (followUps !== null && run.role === "builder")
22335
+ checkParts.push(followUpsHtml(followUps, runId, o));
21454
22336
  // The handoff's own account of its checks — the agent's words, labeled
21455
22337
  // as such, whether or not a proof exists.
21456
22338
  if (!directAssessment && handoff !== null && handoff.verification.length > 0)
@@ -21517,11 +22399,13 @@ function resultPanelParts(detail, o) {
21517
22399
  const html = (`<section class="card result-panel" id="result" data-result-panel data-result-place="${o.place}" data-result-lead="${lead}" data-result-task="${escape(detail.rootId ?? detail.taskId)}" data-result-user="${escape(o.user)}"${resultFactsAttributes(facts)}>` +
21518
22400
  (o.back === null ? "" : `<p class="result-back"><a href="${escape(o.back.href)}" data-result-back>← ${escape(o.back.label)}</a></p>`) +
21519
22401
  (detail.history ?? "") +
21520
- `<header class="result-head"><div><h2>${escape(heading)}</h2></div>${o.headStatus === false ? "" : statusLineHtml(status.token === "verification-needed" && !directAssessment ? { ...status, label: "Verification needed" } : status)}</header>` +
22402
+ // One headline: with the shared status, its words lead (task-status.ts); "Changes saved" would compete with them.
22403
+ (taskStatus !== null && o.headStatus !== false
22404
+ ? `<header class="result-head"><div data-headline-tone="${taskStatus.tone}"><h2 class="status-headline" data-work-status="${escape(status.token)}"><i aria-hidden="true"></i>${escape(taskStatus.headline)}</h2><p class="status-sentence">${escape(taskStatus.sentence)}</p></div></header>`
22405
+ : `<header class="result-head"><div><h2>${escape(heading)}</h2></div>${o.headStatus === false ? "" : statusLineHtml(status)}</header>`) +
21521
22406
  `<p class="result-summary">${escape(outcome)}</p>` +
21522
22407
  (current == null || o.place === "review" && (current.state === "ready-to-check" || current.state === "complete") ? "" :
21523
- `<div class="verdict" data-current-outcome>${verdictChipsHtml(current, facts)}` +
21524
- (current.receipt?.checks.status === "failed" || current.receipt?.checks.status === "unavailable" ? `<p class="problem">${escape(current.detail)}</p>` : "") + `</div>`) +
22408
+ `<div class="verdict" data-current-outcome data-headline="${escape(taskStatus.headline)}">${statusDetailsHtml(taskStatus)}${statusWhyHtml(taskStatus)}</div>`) +
21525
22409
  attentionHtml +
21526
22410
  youCheckHtml +
21527
22411
  action +
@@ -21536,7 +22420,7 @@ function resultPanelParts(detail, o) {
21536
22420
  const panel = {
21537
22421
  attributes: { "data-result-panel": "", "data-result-place": o.place, "data-result-lead": lead, "data-result-task": detail.rootId ?? detail.taskId, "data-result-user": o.user, ...resultFactsAttributeMap(facts) },
21538
22422
  heading, outcome,
21539
- verdict: current == null ? null : verdictChipsOf(current, facts),
22423
+ status: taskStatus,
21540
22424
  reviewHistory: REVIEW_TOKENS.has(status.token) ? status.detail : null,
21541
22425
  attention: attention.filter(one => !shortenedMaterialReason(one)).map(plainReasonWords),
21542
22426
  youCheck,
@@ -21551,40 +22435,6 @@ function resultPanelParts(detail, o) {
21551
22435
  };
21552
22436
  return { html, panel };
21553
22437
  }
21554
- const CHIP_ICONS = { check: `<path d="M20 6 9 17l-5-5"/>`, x: `<path d="M18 6 6 18M6 6l12 12"/>` };
21555
- /** The chips as data: the server list below and the rebuilt result page. */
21556
- function verdictChipsOf(current, facts) {
21557
- const chips = [];
21558
- const checks = current.receipt?.checks.status ?? null;
21559
- if (checks === "passed")
21560
- chips.push({ tone: "success", label: "Checks passed", icon: "check", title: null });
21561
- else if (checks === "failed")
21562
- chips.push({ tone: "danger", label: "Checks failed", icon: "x", title: null });
21563
- else if (checks === "unavailable")
21564
- chips.push({ tone: "warning", label: "Check unavailable", icon: null, title: null });
21565
- const publication = { none: ["neutral", "Branch only"], intended: ["info", "Publishing"], pushed: ["info", "Pushed"], opened: ["info", "Pull request open"], failed: ["danger", "Publish failed"] };
21566
- const [tone, label] = publication[facts.publicationState] ?? ["neutral", facts.publicationState];
21567
- chips.push({ tone, label, icon: null, title: facts.publicationWords });
21568
- const health = facts.evidenceHealth;
21569
- if (health.damaged > 0)
21570
- chips.push({ tone: "danger", label: "Damaged evidence", icon: null, title: null });
21571
- else if (health.missing > 0)
21572
- chips.push({ tone: "warning", label: "Missing evidence", icon: null, title: null });
21573
- else if (health.shortened > 0)
21574
- chips.push({ tone: "warning", label: "Partial output", icon: null, title: "Some saved output was shortened when stored" });
21575
- return { chips, by: current.completion === null ? null : current.completion.actor.replace(/^(?:operator|coordinator|lead):/, "") };
21576
- }
21577
- /** The verdict at a glance: checks, where the work lives, and whether any
21578
- * saved output is partial, as chips instead of sentences. The same facts
21579
- * stay available in words on the Checks tab and in Build details. */
21580
- function verdictChipsHtml(current, facts) {
21581
- const { chips, by } = verdictChipsOf(current, facts);
21582
- const items = chips.map(one => {
21583
- const label = `${one.icon === null ? "" : strokeIcon(CHIP_ICONS[one.icon])}${one.label}`;
21584
- return `<li class="verdict-chip verdict-chip--${one.tone}" title="${escape(one.title ?? label)}">${label}</li>`;
21585
- });
21586
- return `<ul class="verdict-chips" aria-label="Result at a glance">${items.join("")}</ul>` + (by === null ? "" : `<p class="meta verdict-by">Completed by ${escape(by)}</p>`);
21587
- }
21588
22438
  /** The outcome in one bounded sentence (repair 2026-09-14): the handoff's
21589
22439
  * first sentence, cut at a word when it runs past 180 characters. The
21590
22440
  * full conclusion stays available behind the Summary's disclosure. */
@@ -22003,6 +22853,13 @@ function telegramDeliveryWords(store, bot) {
22003
22853
  return `Telegram pushes new messages to ${state.url}${state.problem === null ? "." : `, but ${state.problem.charAt(0).toLowerCase()}${state.problem.slice(1)}.`}`;
22004
22854
  return `Toolroll asks Telegram for new messages every few seconds${state?.problem ? ` (${state.problem})` : ""}.`;
22005
22855
  }
22856
+ /** The projects this person can see, by name, and whether they muted each one's pings. */
22857
+ function notificationProjects(store, account) {
22858
+ const muted = new Set(store.mutedProjects(account));
22859
+ return store.listProjects().filter(one => store.accountCanAccess(account, one.path))
22860
+ .map(one => ({ repo: one.path, name: one.name, muted: muted.has(one.path) }))
22861
+ .sort((a, b) => a.name.localeCompare(b.name));
22862
+ }
22006
22863
  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) {
22007
22864
  const permissionCard = permissionDefault === null
22008
22865
  ? ""
@@ -22047,6 +22904,14 @@ function settingsPage(chrome, existing, hasEnv, csrf, problem, messaging = null,
22047
22904
  `<p class="meta">One message: what finished, what waits, what failed.</p>`,
22048
22905
  `<button type="submit">Save</button>`,
22049
22906
  `</form>`,
22907
+ ...((chatNotices.projects ?? []).length === 0 ? [] : [
22908
+ "<h3>Projects</h3>",
22909
+ `<ul class="card">${chatNotices.projects.map(one => `<li><form method="post" action="/settings/notifications/mute">` +
22910
+ `<input type="hidden" name="csrf" value="${escape(csrf)}"><input type="hidden" name="repo" value="${escape(one.repo)}">` +
22911
+ (one.muted ? "" : `<input type="hidden" name="pings" value="on">`) +
22912
+ `${escape(one.name)} <span class="meta">· ${one.muted ? "muted" : "pings on"}</span> <button type="submit">${one.muted ? "Unmute" : "Mute"}</button></form></li>`).join("")}</ul>`,
22913
+ `<p class="meta">Muted projects still show in Tasks and the evening digest.</p>`,
22914
+ ]),
22050
22915
  ].join("\n");
22051
22916
  const digestCard = digest === null || csrf === "" || chatNotices?.mode === "quiet"
22052
22917
  ? ""
@@ -22328,6 +23193,7 @@ function settingsTiles() {
22328
23193
  const SETTINGS_TILE_ICONS = [
22329
23194
  ["/settings/flows", "Flows", `<rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/><path d="M10 6.5h4a3 3 0 0 1 3 3V14"/>`],
22330
23195
  ["/settings/integrations", "Integrations", `<path d="M9 2v6M15 2v6"/><path d="M6 8h12v4a6 6 0 0 1-12 0z"/><path d="M12 18v4"/>`],
23196
+ ["/settings/lead", "Lead", `<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/><path d="M8 9h8M8 13h5"/>`],
22331
23197
  ["/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"/>`],
22332
23198
  ["/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"/>`],
22333
23199
  ["/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"/>`],