toolroll 0.9.54 → 0.9.56

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 (350) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +29 -12
  3. package/dist/action-ledger.d.ts +2 -4
  4. package/dist/action-ledger.js +2 -10
  5. package/dist/agent-onboard.js +4 -1
  6. package/dist/approval-policy.d.ts +5 -5
  7. package/dist/approval-policy.js +4 -4
  8. package/dist/approval-rules-ui.d.ts +3 -2
  9. package/dist/approval-rules-ui.js +17 -22
  10. package/dist/assignment-ui.d.ts +3 -2
  11. package/dist/assignment-ui.js +4 -11
  12. package/dist/backup-ui.d.ts +2 -1
  13. package/dist/backup-ui.js +16 -27
  14. package/dist/brand-mark.d.ts +3 -2
  15. package/dist/brand-mark.js +4 -4
  16. package/dist/browser/workspace.css +1 -1
  17. package/dist/browser/workspace.js +25 -25
  18. package/dist/browser-shell.d.ts +2 -1
  19. package/dist/browser-shell.js +9 -9
  20. package/dist/browser-workspace.d.ts +14 -15
  21. package/dist/browser-workspace.js +0 -3
  22. package/dist/budget-alerts.js +3 -3
  23. package/dist/chat-actions.d.ts +23 -18
  24. package/dist/chat-actions.js +110 -88
  25. package/dist/chat-ask.d.ts +2 -2
  26. package/dist/chat-ask.js +5 -5
  27. package/dist/chat-channel.d.ts +15 -15
  28. package/dist/chat-channel.js +20 -20
  29. package/dist/chat-controls.d.ts +0 -4
  30. package/dist/chat-controls.js +0 -1
  31. package/dist/chat-core.js +1 -1
  32. package/dist/chat-delivery-state.js +2 -2
  33. package/dist/chat-delivery.d.ts +2 -2
  34. package/dist/chat-delivery.js +27 -34
  35. package/dist/chat-flow.js +3 -3
  36. package/dist/chat-messages.d.ts +1 -1
  37. package/dist/chat-messages.js +4 -4
  38. package/dist/chat-migration.d.ts +1 -1
  39. package/dist/chat-migration.js +11 -11
  40. package/dist/chat-polish.d.ts +5 -4
  41. package/dist/chat-polish.js +9 -10
  42. package/dist/chat-rooms.js +3 -3
  43. package/dist/chat-warmth.d.ts +2 -2
  44. package/dist/check-levels-ui.d.ts +2 -1
  45. package/dist/check-levels-ui.js +26 -53
  46. package/dist/cli.d.ts +3 -1
  47. package/dist/cli.js +15 -10
  48. package/dist/coding-shipping-ui.d.ts +2 -1
  49. package/dist/coding-shipping-ui.js +6 -6
  50. package/dist/coding-ui.d.ts +3 -1
  51. package/dist/coding-ui.js +30 -29
  52. package/dist/contracts/chat-actions.d.ts +32 -27
  53. package/dist/contracts/chat-actions.js +29 -11
  54. package/dist/contracts/chat-content.d.ts +1 -1
  55. package/dist/contracts/chat-content.js +1 -1
  56. package/dist/contracts/chat-tables.js +4 -4
  57. package/dist/contracts/cli.d.ts +1 -3
  58. package/dist/contracts/console-api.d.ts +7 -7
  59. package/dist/contracts/console-api.js +13 -12
  60. package/dist/contracts/flow-propose.d.ts +3 -3
  61. package/dist/contracts/flow.d.ts +132 -125
  62. package/dist/contracts/flow.js +27 -12
  63. package/dist/contracts/health.d.ts +1 -3
  64. package/dist/contracts/lead-context.d.ts +1 -1
  65. package/dist/contracts/lead-context.js +2 -2
  66. package/dist/contracts/lead-tools.d.ts +20 -18
  67. package/dist/contracts/lead-tools.js +15 -15
  68. package/dist/contracts/stage-output.d.ts +1 -1
  69. package/dist/contracts/stage-output.js +1 -1
  70. package/dist/contracts/store-json.d.ts +23 -22
  71. package/dist/contracts/store-json.js +17 -17
  72. package/dist/contracts/{teammate-turn.d.ts → subagent-turn.d.ts} +5 -5
  73. package/dist/contracts/{teammate-turn.js → subagent-turn.js} +7 -7
  74. package/dist/control-ui.d.ts +5 -4
  75. package/dist/control-ui.js +9 -34
  76. package/dist/converse.d.ts +23 -23
  77. package/dist/converse.js +28 -28
  78. package/dist/coordinator-proposals.d.ts +2 -2
  79. package/dist/coordinator-proposals.js +1 -1
  80. package/dist/coordinator.js +1 -1
  81. package/dist/credentials-ui.d.ts +5 -4
  82. package/dist/credentials-ui.js +23 -24
  83. package/dist/demo-chat.d.ts +3 -3
  84. package/dist/demo-chat.js +29 -76
  85. package/dist/deprecations.d.ts +12 -0
  86. package/dist/deprecations.js +20 -0
  87. package/dist/desktop-update-gate.js +8 -4
  88. package/dist/desktop-update.js +6 -1
  89. package/dist/discord-chat.js +6 -6
  90. package/dist/discord-settings.d.ts +3 -2
  91. package/dist/discord-settings.js +17 -42
  92. package/dist/dispatch.d.ts +1 -1
  93. package/dist/dispatch.js +1 -1
  94. package/dist/envelope.js +10 -1
  95. package/dist/evidence-pack.d.ts +2 -1
  96. package/dist/evidence-pack.js +45 -49
  97. package/dist/export-ui.d.ts +7 -2
  98. package/dist/export-ui.js +4 -10
  99. package/dist/export.js +7 -7
  100. package/dist/flow-engine.d.ts +2 -2
  101. package/dist/flow-engine.js +21 -21
  102. package/dist/flow-gallery-ui.d.ts +4 -3
  103. package/dist/flow-gallery-ui.js +28 -43
  104. package/dist/flow-insights.d.ts +1 -1
  105. package/dist/flow-share.js +13 -8
  106. package/dist/flow-starters-ui.d.ts +2 -1
  107. package/dist/flow-starters-ui.js +12 -15
  108. package/dist/flow-steps.d.ts +5 -5
  109. package/dist/flow-steps.js +19 -19
  110. package/dist/flow-triggers.d.ts +2 -1
  111. package/dist/flow-triggers.js +17 -15
  112. package/dist/flows-cli.js +1 -1
  113. package/dist/flows-ui.d.ts +4 -3
  114. package/dist/flows-ui.js +38 -49
  115. package/dist/flows.js +21 -20
  116. package/dist/guides.js +112 -6
  117. package/dist/html.d.ts +76 -0
  118. package/dist/html.js +173 -0
  119. package/dist/integrations-ui.d.ts +2 -1
  120. package/dist/integrations-ui.js +19 -23
  121. package/dist/invoke.js +1 -1
  122. package/dist/kits-ui.d.ts +5 -4
  123. package/dist/kits-ui.js +22 -24
  124. package/dist/kits.d.ts +12 -12
  125. package/dist/kits.js +31 -31
  126. package/dist/knowledge-ui.d.ts +6 -5
  127. package/dist/knowledge-ui.js +26 -25
  128. package/dist/{mate-cli.d.ts → lead-cli.d.ts} +11 -11
  129. package/dist/{mate-cli.js → lead-cli.js} +38 -38
  130. package/dist/lead-commitments.js +4 -4
  131. package/dist/lead-context.d.ts +12 -2
  132. package/dist/lead-context.js +15 -17
  133. package/dist/{mate-contract.d.ts → lead-contract.d.ts} +3 -3
  134. package/dist/{mate-contract.js → lead-contract.js} +7 -6
  135. package/dist/{mate-doors.d.ts → lead-doors.d.ts} +6 -6
  136. package/dist/{mate-doors.js → lead-doors.js} +24 -24
  137. package/dist/lead-follow.d.ts +5 -5
  138. package/dist/lead-follow.js +9 -9
  139. package/dist/lead-people.d.ts +5 -5
  140. package/dist/lead-people.js +14 -14
  141. package/dist/{mate-progress.d.ts → lead-progress.d.ts} +9 -9
  142. package/dist/{mate-progress.js → lead-progress.js} +3 -3
  143. package/dist/{mate-tools.d.ts → lead-tools.d.ts} +19 -19
  144. package/dist/{mate-tools.js → lead-tools.js} +109 -101
  145. package/dist/{mate.d.ts → lead.d.ts} +27 -27
  146. package/dist/{mate.js → lead.js} +102 -102
  147. package/dist/ledger-view.d.ts +2 -1
  148. package/dist/ledger-view.js +27 -29
  149. package/dist/limits-ui.d.ts +3 -2
  150. package/dist/limits-ui.js +8 -8
  151. package/dist/live-bus.d.ts +50 -8
  152. package/dist/live-bus.js +133 -50
  153. package/dist/mcp-connect.d.ts +1 -1
  154. package/dist/mcp-connect.js +3 -3
  155. package/dist/mcp-core.js +1 -1
  156. package/dist/mcp-oauth.js +35 -36
  157. package/dist/memory-pass.d.ts +2 -2
  158. package/dist/memory-pass.js +7 -7
  159. package/dist/models-ui.d.ts +2 -1
  160. package/dist/models-ui.js +25 -35
  161. package/dist/monitoring-ui.d.ts +4 -3
  162. package/dist/monitoring-ui.js +13 -26
  163. package/dist/notification-rows.d.ts +2 -2
  164. package/dist/openrouter-models.d.ts +2 -1
  165. package/dist/openrouter-models.js +5 -9
  166. package/dist/operate.d.ts +3 -3
  167. package/dist/operate.js +46 -41
  168. package/dist/people-audit-ui.d.ts +2 -1
  169. package/dist/people-audit-ui.js +13 -13
  170. package/dist/policy-ui.d.ts +3 -2
  171. package/dist/policy-ui.js +17 -23
  172. package/dist/policy.d.ts +1 -1
  173. package/dist/principal.d.ts +1 -1
  174. package/dist/principal.js +2 -2
  175. package/dist/project-delete-ui.d.ts +4 -3
  176. package/dist/project-delete-ui.js +16 -28
  177. package/dist/project-delete.d.ts +1 -1
  178. package/dist/project-delete.js +26 -26
  179. package/dist/project-memory.js +1 -1
  180. package/dist/project-tools.d.ts +1 -1
  181. package/dist/project-tools.js +1 -1
  182. package/dist/pull-request-ui.d.ts +2 -1
  183. package/dist/pull-request-ui.js +17 -32
  184. package/dist/recipe-ui.d.ts +8 -7
  185. package/dist/recipe-ui.js +39 -32
  186. package/dist/release-adapters.d.ts +60 -0
  187. package/dist/release-adapters.js +435 -0
  188. package/dist/release-cli.d.ts +24 -0
  189. package/dist/release-cli.js +181 -0
  190. package/dist/release-gate.d.ts +80 -0
  191. package/dist/release-gate.js +150 -0
  192. package/dist/release-links.d.ts +29 -0
  193. package/dist/release-links.js +102 -0
  194. package/dist/release.d.ts +331 -0
  195. package/dist/release.js +550 -0
  196. package/dist/reply-shape.d.ts +2 -1
  197. package/dist/reply-shape.js +5 -5
  198. package/dist/repository-context-ui.d.ts +2 -1
  199. package/dist/repository-context-ui.js +2 -7
  200. package/dist/request-budget-ui.d.ts +2 -1
  201. package/dist/request-budget-ui.js +16 -16
  202. package/dist/result-review.d.ts +2 -3
  203. package/dist/result-review.js +3 -9
  204. package/dist/retention-ui.d.ts +7 -1
  205. package/dist/retention-ui.js +7 -11
  206. package/dist/retention.js +4 -4
  207. package/dist/scope.d.ts +2 -2
  208. package/dist/scout-report.d.ts +1 -1
  209. package/dist/scout-report.js +1 -1
  210. package/dist/serve.d.ts +11 -2
  211. package/dist/serve.js +105 -99
  212. package/dist/server/chat.d.ts +7 -10
  213. package/dist/server/chat.js +984 -1021
  214. package/dist/server/chrome.d.ts +291 -0
  215. package/dist/server/chrome.js +747 -0
  216. package/dist/server/console.css +2819 -0
  217. package/dist/server/flows.d.ts +2 -6
  218. package/dist/server/flows.js +854 -773
  219. package/dist/server/guards.d.ts +1 -1
  220. package/dist/server/guards.js +8 -5
  221. package/dist/server/handler-context.d.ts +1 -1
  222. package/dist/server/handler-context.js +1 -1
  223. package/dist/server/handler-registry.d.ts +8 -6
  224. package/dist/server/handler-registry.js +14 -2
  225. package/dist/server/http.d.ts +274 -0
  226. package/dist/server/http.js +239 -0
  227. package/dist/server/live.d.ts +34 -0
  228. package/dist/server/live.js +309 -0
  229. package/dist/server/pages.d.ts +2 -6
  230. package/dist/server/pages.js +808 -789
  231. package/dist/server/people-tokens.d.ts +1 -6
  232. package/dist/server/people-tokens.js +581 -573
  233. package/dist/server/remote.d.ts +4 -7
  234. package/dist/server/remote.js +39 -70
  235. package/dist/server/render-chat.d.ts +402 -0
  236. package/dist/server/render-chat.js +927 -0
  237. package/dist/server/render-pages.d.ts +535 -0
  238. package/dist/server/render-pages.js +1277 -0
  239. package/dist/server/render-people.d.ts +32 -0
  240. package/dist/server/render-people.js +121 -0
  241. package/dist/server/render-results.d.ts +744 -0
  242. package/dist/server/render-results.js +2237 -0
  243. package/dist/server/render-settings.d.ts +154 -0
  244. package/dist/server/render-settings.js +580 -0
  245. package/dist/server/render-tasks.d.ts +632 -0
  246. package/dist/server/render-tasks.js +2345 -0
  247. package/dist/server/request-context.d.ts +46 -0
  248. package/dist/server/request-context.js +17 -0
  249. package/dist/server/route-table.d.ts +25 -3
  250. package/dist/server/route-table.js +167 -77
  251. package/dist/server/runtime.d.ts +28 -20
  252. package/dist/server/runtime.js +7 -1
  253. package/dist/server/session.d.ts +138 -0
  254. package/dist/server/session.js +94 -0
  255. package/dist/server/settings.d.ts +1 -5
  256. package/dist/server/settings.js +2186 -2121
  257. package/dist/server/task-views.d.ts +11 -4
  258. package/dist/server/task-views.js +26 -22
  259. package/dist/server/tasks.d.ts +9 -9
  260. package/dist/server/tasks.js +1489 -1420
  261. package/dist/server-telemetry.d.ts +4 -3
  262. package/dist/server-telemetry.js +4 -8
  263. package/dist/setup-guide.d.ts +2 -2
  264. package/dist/skills-ui.d.ts +4 -3
  265. package/dist/skills-ui.js +14 -17
  266. package/dist/skills.d.ts +1 -1
  267. package/dist/skills.js +2 -1
  268. package/dist/slack-chat.d.ts +2 -2
  269. package/dist/slack-chat.js +6 -6
  270. package/dist/slack-settings.d.ts +3 -2
  271. package/dist/slack-settings.js +17 -42
  272. package/dist/spend-ui.d.ts +5 -4
  273. package/dist/spend-ui.js +17 -40
  274. package/dist/spend.d.ts +14 -14
  275. package/dist/spend.js +31 -31
  276. package/dist/sso-ui.d.ts +3 -2
  277. package/dist/sso-ui.js +26 -27
  278. package/dist/storage-ui.d.ts +2 -1
  279. package/dist/storage-ui.js +14 -33
  280. package/dist/store-baseline.d.ts +19 -0
  281. package/dist/store-baseline.js +2873 -0
  282. package/dist/store.d.ts +197 -249
  283. package/dist/store.js +747 -3084
  284. package/dist/subagent-admin.d.ts +53 -0
  285. package/dist/{teammate-admin.js → subagent-admin.js} +37 -37
  286. package/dist/{teammate-desk.d.ts → subagent-desk.d.ts} +22 -23
  287. package/dist/{teammate-desk.js → subagent-desk.js} +26 -35
  288. package/dist/{teammate-memory.d.ts → subagent-memory.d.ts} +17 -17
  289. package/dist/{teammate-memory.js → subagent-memory.js} +41 -41
  290. package/dist/{teammate-question.d.ts → subagent-question.d.ts} +6 -6
  291. package/dist/{teammate-question.js → subagent-question.js} +17 -17
  292. package/dist/{teammate-tools.d.ts → subagent-tools.d.ts} +18 -18
  293. package/dist/{teammate-tools.js → subagent-tools.js} +36 -36
  294. package/dist/{teammate-week.d.ts → subagent-week.d.ts} +10 -10
  295. package/dist/{teammate-week.js → subagent-week.js} +23 -23
  296. package/dist/{teammate-work.d.ts → subagent-work.d.ts} +21 -21
  297. package/dist/{teammate-work.js → subagent-work.js} +50 -50
  298. package/dist/subagents-ui.d.ts +22 -0
  299. package/dist/subagents-ui.js +137 -0
  300. package/dist/{teammates.d.ts → subagents.d.ts} +11 -11
  301. package/dist/{teammates.js → subagents.js} +21 -21
  302. package/dist/subscription-chat.d.ts +13 -13
  303. package/dist/subscription-chat.js +19 -19
  304. package/dist/surface.d.ts +19 -2
  305. package/dist/surface.js +20 -33
  306. package/dist/task-composer.d.ts +2 -1
  307. package/dist/task-composer.js +7 -7
  308. package/dist/task-status.d.ts +4 -3
  309. package/dist/task-status.js +15 -16
  310. package/dist/team-cli.d.ts +1 -0
  311. package/dist/team-cli.js +14 -2
  312. package/dist/team-http.d.ts +5 -9
  313. package/dist/team-http.js +5 -63
  314. package/dist/team-leads.js +13 -13
  315. package/dist/team-runtime.d.ts +2 -2
  316. package/dist/team-runtime.js +8 -8
  317. package/dist/team-ui.d.ts +2 -1
  318. package/dist/team-ui.js +2 -5
  319. package/dist/teams-chat.js +6 -6
  320. package/dist/teams-settings.d.ts +3 -2
  321. package/dist/teams-settings.js +20 -31
  322. package/dist/telegram-flow.d.ts +3 -3
  323. package/dist/telegram-flow.js +3 -3
  324. package/dist/{telegram-mate.d.ts → telegram-lead.d.ts} +5 -5
  325. package/dist/{telegram-mate.js → telegram-lead.js} +27 -27
  326. package/dist/telegram-settings.d.ts +3 -2
  327. package/dist/telegram-settings.js +14 -25
  328. package/dist/telegram-status.js +1 -1
  329. package/dist/telegram-team.d.ts +1 -1
  330. package/dist/telegram-team.js +5 -5
  331. package/dist/telegram.d.ts +1 -1
  332. package/dist/telegram.js +9 -30
  333. package/dist/text-limits.d.ts +18 -18
  334. package/dist/text-limits.js +18 -18
  335. package/dist/toolroll-update-ui.d.ts +3 -2
  336. package/dist/toolroll-update-ui.js +35 -39
  337. package/dist/toolroll-update.d.ts +8 -0
  338. package/dist/toolroll-update.js +45 -8
  339. package/dist/tools-ui.d.ts +5 -2
  340. package/dist/tools-ui.js +31 -31
  341. package/dist/when-html.d.ts +11 -3
  342. package/dist/when-html.js +5 -6
  343. package/dist/workspace-ui.d.ts +4 -2
  344. package/dist/workspace-ui.js +10 -10
  345. package/package.json +1 -2
  346. package/dist/server/shared.d.ts +0 -3151
  347. package/dist/server/shared.js +0 -12001
  348. package/dist/teammate-admin.d.ts +0 -53
  349. package/dist/teammates-ui.d.ts +0 -20
  350. package/dist/teammates-ui.js +0 -166
@@ -0,0 +1,1277 @@
1
+ /** Everyday console pages: home, inbox, board, work, projects, fleet and the queue. */
2
+ import { html, htmlString, joinHtml, postForm, replaceMarkup } from "../html.js";
3
+ import {} from "../assignment.js";
4
+ import {} from "../board.js";
5
+ import { browserWorkActionHref } from "../browser-workspace.js";
6
+ import { CHECK_LEVEL_WORDS } from "../check-levels.js";
7
+ import {} from "../dispatch.js";
8
+ import {} from "../first-run.js";
9
+ import {} from "../gaps.js";
10
+ import { limitsHtml } from "../limits-ui.js";
11
+ import { ASK_LABEL, ASKS } from "../needs-you.js";
12
+ import {} from "../onboard.js";
13
+ import {} from "../peek.js";
14
+ import { projectName } from "../project.js";
15
+ import {} from "../proof.js";
16
+ import {} from "../quality.js";
17
+ import { isAlive as runnerAlive } from "../runner.js";
18
+ import {} from "../scope.js";
19
+ import {} from "../store.js";
20
+ import { runCostWords, spendLine, tally } from "../summary.js";
21
+ import {} from "../work-index.js";
22
+ import { WORK_VIEWS } from "../workspace-ui.js";
23
+ import { createHash } from "node:crypto";
24
+ import { closeSync, constants as fsConstants, lstatSync, openSync, readSync } from "node:fs";
25
+ import { homedir } from "node:os";
26
+ import { join } from "node:path";
27
+ import { buildsViews, CHEVRON_ICON, consoleSpend, FOLDER_PATHS, GRIP_HANDLE, isOverdue, projectChip, relativeAge, safePrUrl, screen, sentenceCase, strokeIcon, TO_FRONT_ICON, when, whenTime } from "./chrome.js";
28
+ import { reviewHref, taskHref } from "./http.js";
29
+ import { criterionMatrixSummary, incidentWords, PHASE_WORDS, phaseWords } from "./render-results.js";
30
+ import { acceptanceCeremonyHtml, agentsCeremonyHtml, consentClosedHtml, consentDoorOf, decisionAnswerCard, decisionAnswerScript, decisionOptionForms, dispatchHeadline, executionPlanHtml, permissionModeChoices, planContractHtml, profileWords, runtimeDetailsHtml, statusLineHtml, workDiagnosticsHtml } from "./render-tasks.js";
31
+ export const TASK_STATES = ["queued", "running", "done", "failed", "cancelled"];
32
+ export const INBOX_TABS = [
33
+ { id: "needs-you", label: "Needs you" }, { id: "ready", label: "Ready" }, { id: "running", label: "Running" }, { id: "all", label: "All" },
34
+ ];
35
+ export function parseInboxTab(raw) {
36
+ return INBOX_TABS.some(one => one.id === raw) ? raw : undefined;
37
+ }
38
+ /** What each tab holds, as a short fingerprint: a tab is unread when its
39
+ * fingerprint differs from the one this browser saw there last. */
40
+ export function inboxFingerprints(data) {
41
+ const print = (keys) => createHash("sha256").update([...keys].sort().join("\n")).digest("hex").slice(0, 8);
42
+ 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}`),
43
+ ...data.cancelledBlockers.map(one => `c${one.blockerId}`), ...data.gaps.map(one => `g${one.key}`)];
44
+ const ready = [...data.needsVerification.map(one => `v${one.taskId}`), ...(data.ready ?? []).map(one => `r${one.taskId}`)];
45
+ const running = (data.running ?? []).map(one => `u${one.taskId}`);
46
+ return { "needs-you": print(needs), ready: print(ready), running: print(running), all: print([...needs, ...ready, ...running]) };
47
+ }
48
+ export function inboxPage(chrome, data) {
49
+ /** The row's project, worn openly in the roll-up — null is UNPLACED,
50
+ * said as such, never a silent missing chip (finding 13). */
51
+ const chip = (repo) => !data.rollup ? null : repo === null || repo === undefined
52
+ ? html ` <span class="badge">Unplaced</span>`
53
+ : html ` <span class="badge">${projectName(repo)}</span>`;
54
+ const empty = data.decisions.length + data.approvals.length + data.requeueables.length +
55
+ data.cancelledBlockers.length + data.gaps.length + data.needsVerification.length === 0;
56
+ // The roll-up inbox keeps its links-only contract — acting means opening
57
+ // the project. A SELECTED project's inbox answers reversible options on
58
+ // the card itself (portfolio arc §2).
59
+ const decisions = data.decisions.length === 0
60
+ ? null
61
+ : html `<h2>Answer a question</h2><p class="hint">an agent stopped mid-build to ask — nothing proceeds until you answer</p>${joinHtml(data.decisions.map(decision => data.interactive && !data.rollup
62
+ ? decisionAnswerCard(decision, data.csrf, data.now, false)
63
+ : html `<a class="decide-card" href="/d/${decision.id}"><p class="q">${decision.question}</p><span class="mono meta">${decision.taskId}</span>${chip(decision.repo)}${isOverdue(decision, data.now) ? html ` <span class="badge badge-overdue">Overdue</span>` : null}</a>`), "\n")}`;
64
+ const approvals = data.approvals.length === 0
65
+ ? null
66
+ : html `<h2>Approve a scope</h2><p class="hint">scopes waiting for your approval — each binds to the exact wording you sign</p>${joinHtml(data.approvals.map(one => html `<a class="decide-card" href="${taskHref(one.taskId)}"><p class="q">${one.title}</p><span class="meta">${one.goal.length > 120 ? one.goal.slice(0, 120) + "…" : one.goal}</span><br><span class="mono meta">${one.taskId}</span>${chip(one.repo)} <span class="right meta">${one.closed == null ? html `review &amp; approve →` : html `needs attention: ${one.closed.toLowerCase()} →`}</span></a>`), "\n")}`;
67
+ const requeueables = data.requeueables.length === 0
68
+ ? null
69
+ : html `<h2>Retry stalled work</h2><p class="hint">failed builds waiting for a person — retry clears the incidents and requeues</p>${joinHtml(data.requeueables.map(one => html `<p class="row"><a href="${taskHref(one.taskId)}">${one.taskId}</a> ${one.title}${chip(one.repo)}${one.incidentCount > 0 ? html ` <span class="badge badge-failed">${one.incidentCount} incident${one.incidentCount > 1 ? "s" : ""}</span>` : null}${one.strikes > 0 ? html ` <span class="meta">${one.strikes} failed attempt${one.strikes > 1 ? "s" : ""}</span>` : null}${data.rollup
70
+ ? html `<span class="right meta">open its project to retry →</span></p>`
71
+ : html `<span class="right">${postForm(`${taskHref(one.taskId)}/requeue`, html `<button type="submit">Retry</button>`, { attrs: { class: "inline" }, returnTo: "inbox" })}</span></p>`}`), "\n")}`;
72
+ const needsVerification = data.needsVerification.length === 0
73
+ ? null
74
+ : html `<h2>Needs review</h2><p class="hint">finished, but the evidence needs your attention before accepting</p>${joinHtml(data.needsVerification.map(one => html `<p class="row"><a href="${taskHref(one.taskId)}">${one.taskId}</a> ${one.title}${chip(one.repo)} <span class="badge badge-failed">${one.verdict === "refuted" ? "conflicting evidence" : "missing evidence"}</span>${criterionMatrixSummary(one.matrix ?? [])}${one.repairChain == null ? null : html ` <span class="meta">— repair ${one.repairChain.outcome === "drafted" ? "drafted, awaiting approval" : one.repairChain.outcome}</span>`}</p>`), "\n")}`;
75
+ const cancelled = data.cancelledBlockers.length === 0
76
+ ? null
77
+ : html `<h2>Choose how waiting tasks continue</h2><p class="hint">these tasks were waiting for work that was cancelled — open one and choose what happens next</p>${joinHtml(data.cancelledBlockers.map(one => html `<p class="row"><a href="${taskHref(one.exampleDependent)}">${one.exampleDependent}</a>${chip(one.repo)} <span class="meta">${one.dependentCount > 1 ? `one of ${one.dependentCount} tasks waiting` : "waiting"} for cancelled task ${one.blockerId}${data.rollup && one.repo !== one.blockerRepo ? html ` · across projects${one.repo === null || one.repo === undefined ? null : html ` — waits in ${projectName(one.repo)}`}` : null}</span></p>`), "\n")}`;
78
+ const gaps = data.gaps.length === 0
79
+ ? null
80
+ : html `<h2>Supply a requirement</h2><p class="hint">approved work is ready except for these — fill one and its tasks start</p>${joinHtml(data.gaps.map(gap => html `<p class="row"><a href="/caps">${gap.key}</a> <span class="meta">frees ${gap.unblocks.length} task${gap.unblocks.length > 1 ? "s" : ""}</span><span class="right meta">how to fix →</span></p>`), "\n")}`;
81
+ // The first-run steps, until the first Ready result: each is done or
82
+ // shows the one action that does it.
83
+ const wizard = data.wizard === null
84
+ ? null
85
+ : html `<div class="card" data-first-run><p><strong>Get to your first result</strong></p>${joinHtml(data.wizard.map(step => html `<p class="row" data-step="${step.key}"><span class="mono" aria-hidden="true">${step.done ? "✓" : "○"}</span> <strong>${step.title}</strong>${step.checking ? html ` <span class="meta">checking…</span>` : step.action === null ? html ` <span class="meta">done</span>`
86
+ : step.action.kind === "link" ? html ` <a href="${step.action.href}">${step.action.label}</a>`
87
+ : html ` <code>${step.action.command}</code>`}</p>`), "\n")}</div>`;
88
+ const noWorker = data.worker.answering > 0
89
+ ? null
90
+ : html `<div class="card builder-notice" data-builder-status="${data.worker.registered === 0 ? "not-connected" : "disconnected"}">${data.worker.registered === 0
91
+ ? html `<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.`
92
+ : html `<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.`}</div>`;
93
+ // Console v2: Needs you · Ready · Running · All, as real links (Back and
94
+ // bookmarks work). Each section belongs to one tab; All shows every one.
95
+ const tab = data.tab ?? "all";
96
+ const shows = (one) => tab === "all" || tab === one;
97
+ const listRows = (rows) => joinHtml(rows.map(one => html `<p class="row"><a href="${taskHref(one.taskId)}">${one.title}</a>${chip(one.repo)} <span class="meta">${one.detail}</span></p>`), "\n");
98
+ const ready = (data.ready ?? []).length === 0 ? null : html `<h2>Ready to review</h2>${listRows(data.ready ?? [])}`;
99
+ const running = (data.running ?? []).length === 0 ? null : html `<h2>Running now</h2>${listRows(data.running ?? [])}`;
100
+ const asks = { decide: data.decisions.length + data.approvals.length, unblock: data.requeueables.length + data.cancelledBlockers.length + data.gaps.length };
101
+ const counts = { ...asks,
102
+ "needs-you": asks.decide + asks.unblock,
103
+ ready: data.needsVerification.length + (data.ready ?? []).length,
104
+ running: (data.running ?? []).length,
105
+ all: 0,
106
+ };
107
+ counts.all = counts["needs-you"] + counts.ready + counts.running;
108
+ // Each ask is a small heading with its count over its sections (their own headings one step down).
109
+ const askGroup = (ask, count, sections) => count === 0 ? null
110
+ : html `<section class="inbox-ask" data-ask="${ask}"><h2>${ASK_LABEL[ask]} <span class="count">${count}</span></h2>${replaceMarkup(joinHtml(sections), /<(\/?)h2>/g, (_match, slash) => slash === "/" ? html `</h3>` : html `<h3>`)}</section>`;
111
+ const tabs = data.tab === undefined ? null : html `<nav class="inbox-tabs" aria-label="Inbox views">${INBOX_TABS.map(one => html `<a href="/inbox?tab=${one.id}"${one.id === tab ? html ` aria-current="page"` : null} data-inbox-tab="${one.id}">${one.label}<span class="inbox-tab-count${one.id === "needs-you" && counts[one.id] > 0 ? " inbox-tab-count--needs" : ""}">${counts[one.id]}</span>${one.id !== tab && (data.unread ?? []).includes(one.id) ? html `<span class="inbox-unread" aria-label="new"></span>` : null}</a>`)}</nav>`;
112
+ const tabEmpty = data.tab !== undefined && tab !== "all" && counts[tab] === 0
113
+ ? html `<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>` : null;
114
+ return screen("inbox", joinHtml([
115
+ html `<h1>Inbox</h1>`,
116
+ html `<p class="meta">Everything that waits on you — empty means the fleet is working</p>`,
117
+ tabs,
118
+ noWorker,
119
+ wizard,
120
+ empty || !shows("needs-you") ? null : html `<p><a class="new-task" style="display:inline-block" href="/next">clear the queue → one thing at a time</a></p>`,
121
+ empty && data.wizard === null && shows("needs-you") && counts.all === 0 ? html `<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,
122
+ // What waits on a person, grouped by what it asks: Decide, then Review, then Unblock.
123
+ shows("needs-you") ? askGroup("decide", counts.decide, [approvals, decisions]) : null,
124
+ shows("ready") ? askGroup("review", counts.ready, [needsVerification, ready]) : null,
125
+ shows("needs-you") ? askGroup("unblock", counts.unblock, [requeueables, cancelled, gaps]) : null,
126
+ shows("running") ? running : null,
127
+ data.rollup
128
+ ? html `<p class="meta">Requirement gaps are checked one project at a time — open a project to see and fill its gaps · <a href="/projects">open a project</a></p>`
129
+ : null,
130
+ // Quick capture: the shortest path from "I want this done" to the
131
+ // approve card — title and goal here, the yes on the next screen. The
132
+ // one-shot form posts to the same guarded handler as the full page.
133
+ data.rollup ? null : html `<h2>Capture new work</h2>`,
134
+ data.rollup ? null : postForm("/tasks/add", joinHtml([
135
+ html `<label>What should get done<input type="text" name="title" placeholder="task title" maxlength="200"></label>`,
136
+ html `<label>What success looks like <span class="meta">(becomes the scope you approve on the next screen)</span><textarea name="goal" rows="2"></textarea></label>`,
137
+ html `<button type="submit">Queue it → approve its scope next</button>`,
138
+ ], "\n"), { attrs: { class: "card" }, projectRevision: data.revision }),
139
+ ], "\n"), {
140
+ chrome,
141
+ // The inline-answer enhancement rides only where its forms render; it
142
+ // touches one card and nothing else, so quick-capture input survives.
143
+ ...(data.interactive && !data.rollup && data.decisions.length > 0
144
+ ? { functional: { script: decisionAnswerScript(), fetches: true } }
145
+ : {}),
146
+ });
147
+ }
148
+ /** System: the machinery — workers, background service, workspaces. */
149
+ export function systemPage(chrome, data) {
150
+ const nowMs = data.now.getTime();
151
+ const runnerCards = data.runners
152
+ .filter(one => one.retiredAt === null)
153
+ .map(one => {
154
+ const age = nowMs - new Date(one.heartbeatAt).getTime();
155
+ const dot = age < 5 * 60_000 ? "dot-ok" : age < 60 * 60_000 ? "dot-warn" : "dot-off";
156
+ const said = age < 5 * 60_000 ? "alive" : age < 60 * 60_000 ? `quiet ${Math.round(age / 60_000)}m` : "not heard from";
157
+ const busy = data.building.filter(claim => claim.runner === one.name).length;
158
+ return html `<div class="stat-card"><span class="k"><span class="dot ${dot}"></span>${one.name}</span><span class="v">builder · ${said} · ${busy}/${one.capacity} building</span></div>`;
159
+ });
160
+ const worktreeCards = data.worktrees.map(tree => {
161
+ const leased = tree.leasedAt !== null && tree.releasedAt === null;
162
+ const dot = leased ? "dot-ok" : tree.verified ? "dot-off" : "dot-warn";
163
+ const state = leased ? "building" : tree.verified ? "free" : "needs review";
164
+ const name = tree.path.split("/").pop() ?? tree.path;
165
+ return html `<div class="stat-card"><span class="k"><span class="dot ${dot}${leased ? " pulse" : ""}"></span><span class="mono">${name}</span></span><span class="v">workspace · ${tree.branch} · ${state}</span></div>`;
166
+ });
167
+ const watchCard = data.episode === null
168
+ ? null
169
+ : html `<div class="stat-card"><span class="k"><span class="dot ${data.episode.endedAt === null ? "dot-ok pulse" : "dot-off"}"></span>Toolroll</span><span class="v">${data.episode.endedAt === null
170
+ ? html `running since ${whenTime(data.episode.startedAt)}`
171
+ : html `last run: ${data.episode.built} built, ${data.episode.broke} broke · ended ${whenTime(data.episode.endedAt)}`}</span></div>`;
172
+ const cards = [...runnerCards, watchCard, ...worktreeCards].filter((one) => one !== null);
173
+ const PHASE_SAID = {
174
+ plan: "planning sessions ask questions and draft the plan you approve",
175
+ build: "builds do the work, unattended, inside the approved scope",
176
+ repair: "repair turns mend a malformed handoff in the same session",
177
+ };
178
+ const agentLines = joinHtml(data.agents
179
+ .map(one => {
180
+ if (one.problem !== undefined) {
181
+ return html `<p class="row"><span class="mono">${one.phase}</span> <span class="badge badge-failed">Misconfigured</span> <span class="meta">${one.problem}</span></p>`;
182
+ }
183
+ const who = one.source === "project"
184
+ ? html `chosen for this project${one.setBy === null ? null : html ` by ${one.setBy}`}`
185
+ : one.source === "installation"
186
+ ? html `set for the whole installation${one.setBy === null ? null : html ` by ${one.setBy}`}`
187
+ : html `the default — nothing configured`;
188
+ const dollars = one.provider === "claude" ? null : html ` · <span title="this provider reports tokens, not dollars — its runs land as unmeasured spend">no dollar costs</span>`;
189
+ return html `<p class="row"><span class="mono">${one.phase}</span> <strong>${one.provider ?? ""}</strong>${one.model === null || one.model === undefined ? html ` <span class="meta">(its default model)</span>` : html ` · <span class="mono">${one.model}</span>`}<span class="right meta">${who}${dollars}</span></p><p class="meta" style="margin-top:0">${PHASE_SAID[one.phase] ?? ""}</p>`;
190
+ }), "\n");
191
+ const agentsCard = html `<h2>Agents</h2><p class="meta">Which AI provider runs each phase — changed from the terminal with your credentials (<code>toolroll config</code>), never by a browser click</p><div class="card">${agentLines}<p class="meta">Repair always stays on the provider that built — only its model can differ. A schedule's task filed under an approval moved from a routine is pinned to the agents approved then.</p></div>`;
192
+ return screen("system", joinHtml([
193
+ html `<h1>System</h1>`,
194
+ html `<p class="hint">builders execute tasks in isolated temporary copies of each project</p>`,
195
+ agentsCard,
196
+ cards.length === 0
197
+ ? html `<p class="meta">No builder is connected yet. On the machine where the project lives, open that folder and run <code>toolroll up</code>.</p>`
198
+ : html `<div class="cards">${cards}</div>`,
199
+ data.outboxPending > 0 ? html `<p class="meta">Notifications: ${data.outboxPending} pending delivery</p>` : null,
200
+ (data.externalWork ?? []).length === 0
201
+ ? null
202
+ : html `<h2>External work</h2>${joinHtml((data.externalWork ?? []).map(one => html `<p class="row"><span class="mono">${one.remoteRepo}</span> ${one.blocked !== null
203
+ ? html `<span class="badge badge-failed">Dispatch blocked</span> <span class="meta">the tracker connection needs repair — \`toolroll sync\` says why</span>`
204
+ : one.openEpisode !== null
205
+ ? html `<span class="badge badge-failed">Sync failing</span> <span class="meta">${one.openEpisode}</span>`
206
+ : html `<span class="meta">syncing normally</span>`}</p>`), "\n")}`,
207
+ ], "\n"), { chrome, refreshSeconds: data.building.length > 0 ? 10 : 60 });
208
+ }
209
+ /**
210
+ * The board: the pipeline as lanes, position as meaning — attention, then
211
+ * queued, then waiting, then building, then recently done. Read-only by
212
+ * construction (every card is a link, no forms, no nonces), which is what
213
+ * makes it safe to re-render itself while somebody watches.
214
+ */
215
+ export function boardBody(data, ciRed) {
216
+ const chip = (repo) => !data.all || repo === null ? null : html `<span class="badge">${projectName(repo)}</span>`;
217
+ const CAP = 30;
218
+ const age = (iso) => {
219
+ const minutes = Math.max(1, Math.round((data.now.getTime() - new Date(iso).getTime()) / 60_000));
220
+ if (minutes < 60)
221
+ return `${minutes}m`;
222
+ if (minutes < 48 * 60)
223
+ return `${Math.round(minutes / 60)}h`;
224
+ return `${Math.round(minutes / (24 * 60))}d`;
225
+ };
226
+ const lane = (key, title, hint, renderOne) => {
227
+ const cards = data.cards.filter(card => card.lane === key);
228
+ // The longest-stalled card leads the board: attention sorts by how
229
+ // long it has waited, everything else stays newest-first — except the
230
+ // queued lane, which reads in DISPATCH order: moved-up work first,
231
+ // exactly as the next free worker will pick it.
232
+ if (key === "attention") {
233
+ cards.sort((a, b) => (a.stalledSince ?? "9999").localeCompare(b.stalledSince ?? "9999"));
234
+ }
235
+ if (key === "queued") {
236
+ // Group: the shared queue first, then reserved columns by worker;
237
+ // dispatch order (rank) within each — never a cross-column rank race.
238
+ cards.sort((a, b) => {
239
+ const ka = a.assignedRunner ?? "";
240
+ const kb = b.assignedRunner ?? "";
241
+ if (ka !== kb)
242
+ return ka.localeCompare(kb);
243
+ return b.priority - a.priority;
244
+ });
245
+ }
246
+ const shown = cards.slice(0, CAP);
247
+ const more = cards.length - shown.length;
248
+ // A lane is a section that can fold (board pass): open when it holds
249
+ // cards, folded when empty, so a phone reads the counts first and a
250
+ // desktop column never spends its height on "nothing here".
251
+ return html `<details class="lane lane-${key}"${shown.length === 0 ? null : html ` open`}><summary><h2>${title} <span class="lane-count">${cards.length}${data.saturated ? "+" : ""}</span></h2></summary><p class="hint">${hint}</p>${shown.length === 0 ? html `<p class="meta lane-empty">nothing here</p>` : shown.map((card, index) => renderOne(card, index))}${more > 0 ? html `<a class="lane-more" href="/tasks">+${more} more in the task list</a>` : null}</details>`;
252
+ };
253
+ // The card's facts: mono key–value pairs under the title (board pass) —
254
+ // task, worker, runtime — the same grammar on every lane, so the eye
255
+ // learns one card. Chips carry the words (project, reservation).
256
+ const facts = (rows) => rows.length === 0
257
+ ? null
258
+ : html `<span class="facts">${rows.map(([k, v]) => html `<span class="fact"><span class="k">${k}</span><span class="v">${v}</span></span>`)}</span>`;
259
+ const chips = (parts) => {
260
+ const kept = parts.filter((one) => one !== null);
261
+ return kept.length === 0 ? null : html `<span class="chips">${joinHtml(kept, " ")}</span>`;
262
+ };
263
+ const plain = (card) => html `<a class="lane-card" href="${card.href}"><span class="id">${card.taskId}</span><span class="t">${card.title}</span><span class="why">${card.reason}</span>${facts([
264
+ ...(card.stalledSince === null ? [] : [["waiting", age(card.stalledSince)]]),
265
+ ])}${chips([
266
+ chip(card.repo),
267
+ ])}</a>`;
268
+ // The queued lane: ranks compare only within a COLUMN (queue-columns
269
+ // review, finding 14), so the badge is per column head — the shared
270
+ // queue's front card says "next up"; a reserved column's front card
271
+ // says whose turn it is. No cross-column comparison is claimed.
272
+ const queuedHeads = (() => {
273
+ const heads = new Map();
274
+ for (const card of data.cards.filter(one => one.lane === "queued")) {
275
+ const key = `${card.assignedRunner ?? ""}|${card.repo ?? ""}`;
276
+ if (!heads.has(key))
277
+ heads.set(key, card.taskId);
278
+ }
279
+ return heads;
280
+ })();
281
+ const queuedCard = (card) => html `<a class="lane-card" href="${card.href}"><span class="id">${card.taskId}</span><span class="t">${card.title}</span><span class="why">${card.reason}</span>${facts([
282
+ ["worker", card.assignedRunner === null ? "any free worker" : card.assignedRunner],
283
+ ])}${chips([
284
+ queuedHeads.get(`${card.assignedRunner ?? ""}|${card.repo ?? ""}`) === card.taskId
285
+ ? html `<span class="badge">${card.assignedRunner === null ? "next up" : `next for ${card.assignedRunner}`}</span>`
286
+ : null,
287
+ card.assignedRunner === null ? null : html `<span class="badge">Reserved</span>`,
288
+ chip(card.repo),
289
+ ])}</a>`;
290
+ const building = (card) => {
291
+ const claim = card.claim;
292
+ if (claim === null)
293
+ return plain(card);
294
+ const minutes = Math.max(1, Math.round((data.now.getTime() - new Date(claim.claimedAt).getTime()) / 60_000));
295
+ const workspace = claim.worktree === null ? null : (claim.worktree.split("/").pop() ?? claim.worktree);
296
+ // Chip copy: unknown phases show nothing rather than raw tokens.
297
+ const phase = claim.phase === null ? undefined : PHASE_WORDS[claim.phase];
298
+ // The live strip is the run's own phase and clock — never a percent:
299
+ // a build has no honest progress figure, only a stage and an elapsed.
300
+ const live = claim.model === null ? "preparing workspace" : (phase ?? "the agent is working");
301
+ return html `<a class="lane-card building" href="${card.href}"><span class="id">${card.taskId}</span><span class="t"><span class="dot dot-ok pulse"></span>${card.title}</span><span class="live-line"><span class="stage">${live}</span><span class="clock">${minutes}m</span></span>${facts([
302
+ ["worker", claim.runner],
303
+ ...(claim.model === null
304
+ ? []
305
+ : [["model", `${claim.provider !== null && claim.provider !== "claude" ? `${claim.provider} · ` : ""}${claim.model}`]]),
306
+ ...(claim.branch === null ? [] : [["branch", `${claim.branch}${workspace === null ? "" : ` · ${workspace}`}`]]),
307
+ ])}${chips([
308
+ card.attempt === null ? null : html `<span class="badge">Attempt ${card.attempt}</span>`,
309
+ chip(card.repo),
310
+ ])}</a>`;
311
+ };
312
+ const doneCards = data.done.length === 0
313
+ ? html `<p class="meta lane-empty">nothing finished yet</p>`
314
+ : data.done.map(row => {
315
+ const pr = row.prNumber === null
316
+ ? null
317
+ : html `<span class="badge badge-open">PR #${row.prNumber}</span>${ciRed(row.prNumber) ? html ` <span class="badge badge-failed">CI failing</span>` : null}`;
318
+ return html `<a class="lane-card" href="${taskHref(row.taskId)}"><span class="id">${row.taskId}</span><span class="t">${row.title}</span>${row.handoff === null ? null : html `<span class="why">${row.handoff.length > 120 ? row.handoff.slice(0, 120) + "…" : row.handoff}</span>`}${facts([
319
+ ...(row.ranMinutes === null ? [] : [["ran", `${row.ranMinutes}m`]]),
320
+ ["usage", runCostWords({ authMode: row.authMode, costUsd: row.costUsd, tokensIn: null, tokensOut: null })],
321
+ ])}${chips([
322
+ html `<span class="badge badge-done">${row.outcome === "no-change" ? "No change" : "Built"}</span>`,
323
+ pr,
324
+ data.all && row.repo !== null ? html `<span class="badge">${projectName(row.repo)}</span>` : null,
325
+ ])}</a>`;
326
+ });
327
+ const toggle = data.project === null && !data.all
328
+ ? null
329
+ : html `<p class="meta board-scope">${data.all
330
+ ? html `${data.project === null
331
+ ? html `<strong>All projects</strong>`
332
+ : html `<a href="/board">${projectName(data.project)}</a> · <strong>all projects</strong>`} — every project this server serves, each card wearing its project`
333
+ : html `<strong>${projectName(data.project)}</strong> · <a href="/board?scope=all">all projects</a>`}</p>`;
334
+ const ago = (minutes) => minutes < 60 ? `${minutes}m` : minutes < 48 * 60 ? `${Math.round(minutes / 60)}h` : `${Math.round(minutes / (24 * 60))}d`;
335
+ const deltaLine = data.delta === null
336
+ ? null
337
+ : html `<p class="meta"><strong>Since you last looked</strong> (${ago(data.delta.agoMinutes)} ago): ${joinHtml([
338
+ data.delta.built > 0 ? html `<span class="good">${data.delta.built} built</span>` : null,
339
+ data.delta.failed > 0 ? html `<span class="bad">${data.delta.failed} failed</span>` : null,
340
+ data.delta.questions > 0
341
+ ? html `${data.delta.questions} question${data.delta.questions > 1 ? "s" : ""} — <a href="/next">answer →</a>`
342
+ : null,
343
+ ].filter((one) => one !== null), html ` · `)}</p>`;
344
+ return joinHtml([
345
+ html `<h1>Board</h1>`,
346
+ data.all ? null : html `<p class="meta board-view"><strong>State</strong> · <a href="/board?view=order">order →</a> <span class="meta">drag to reorder, or to reserve a task for one worker</span></p>`,
347
+ deltaLine,
348
+ toggle,
349
+ html `<div class="board">`,
350
+ lane("attention", "needs you", "these wait for a person", plain),
351
+ lane("queued", "queued", "starts when a worker is free", queuedCard),
352
+ lane("waiting", "waiting", "paused until a time, another task, or a requirement is ready", plain),
353
+ lane("building", "building", "one agent per card, in its own workspace", building),
354
+ html `<details class="lane lane-done"${data.done.length === 0 ? null : html ` open`}><summary><h2><a href="/done">done recently</a></h2></summary><p class="hint">the most recent — the full list is under done</p>${doneCards}</details>`,
355
+ html `</div>`,
356
+ ], "\n");
357
+ }
358
+ /** Completed work: one row per done task, its final run and PR attached. */
359
+ export function donePage(chrome, rows, ciRed) {
360
+ const list = rows.length === 0
361
+ ? html `<p class="meta">No finished tasks yet.</p>`
362
+ : joinHtml(rows.map(row => {
363
+ const pr = row.prNumber === null
364
+ ? row.publicationState === null
365
+ ? null
366
+ : html ` <span class="badge">${sentenceCase(row.publicationState)}</span>`
367
+ : html ` <a href="${row.prUrl ?? "#"}" class="badge badge-open">PR #${row.prNumber}</a>${ciRed(row.prNumber) ? html ` <span class="badge badge-failed">CI failing</span>` : null}`;
368
+ const needsVerification = (row.proofVerdict === "short" || row.proofVerdict === "refuted") && !row.proofAccepted
369
+ ? html ` <span class="badge badge-failed">${row.proofVerdict === "refuted" ? "conflicting evidence" : "missing evidence"}</span>`
370
+ : null;
371
+ return html `<div class="card"><p><a href="${taskHref(row.taskId)}"><strong>${row.title}</strong></a>${row.outcome === "no-change" ? html ` <span class="badge">No change needed</span>` : null}${pr}${needsVerification}${criterionMatrixSummary(row.proofMatrix)}</p>${row.handoff === null ? null : html `<p class="meta">${row.handoff.length > 200 ? row.handoff.slice(0, 200) + "…" : row.handoff}</p>`}<p class="meta mono">${row.taskId} · ${whenTime(row.completedAt)}${row.ranMinutes === null ? null : html ` · ran ${row.ranMinutes}m`}${row.provider === null ? null : html ` · ${runCostWords({ authMode: row.authMode, costUsd: row.costUsd, tokensIn: null, tokensOut: null })}`} · <a href="${reviewHref(row.taskId)}">review →</a></p></div>`;
372
+ }), "\n");
373
+ return screen("done", joinHtml([
374
+ html `<h1>Done</h1>`,
375
+ buildsViews("done"),
376
+ html `<p class="hint">completed work in order of completion — each with its final build, the agent's conclusion, usage, and its pull request; <a href="/review">review</a> opens the same saved work and its checks</p>`,
377
+ list,
378
+ ], "\n"), { chrome });
379
+ }
380
+ export function homePage(chrome, data) {
381
+ const { summary } = data;
382
+ // The night's harvest is the page's reason to exist — strong figures in a
383
+ // sentence, colored by what they mean, never a metric-card grid.
384
+ const ledger = html `<p class="ledger"><span class="good"><b>${summary.built.length}</b> built</span> · <span${summary.failed.length > 0 ? html ` class="bad"` : null}><b>${summary.failed.length}</b> failed</span> · <b>${summary.refused.length}</b> refused${summary.cutDown.length > 0 ? html ` · <span class="bad"><b>${summary.cutDown.length}</b> cut down mid-flight</span>` : null}</p>`;
385
+ const decide = data.decisions.length === 0
386
+ ? html `<p class="meta">Nothing waits on you. No questions came up.</p>`
387
+ : joinHtml(data.decisions.map(decision => html `<a class="decide-card" href="/d/${decision.id}"><p class="q">${decision.question}</p><span class="mono meta">${decision.taskId}</span>${isOverdue(decision, data.now) ? html ` <span class="badge badge-overdue">Overdue</span>` : null}</a>`), "\n");
388
+ const incidents = data.incidents.length === 0
389
+ ? null
390
+ : html `<h2>Incidents</h2><p class="hint">builds that stopped and need a person — resolve here, or open the task to retry it</p>${joinHtml(data.incidents.map(one => html `<p class="row"><a href="${taskHref(one.taskId)}">${one.taskId}</a> — ${incidentWords(one.kind)}<span class="right">${postForm(`/i/${one.id}/resolve`, html `<button type="submit">Resolve</button>`, { attrs: { class: "inline" } })}</span></p>`), "\n")}`;
391
+ const stranded = data.stranded.length === 0
392
+ ? null
393
+ : html `<h2>Tasks waiting on failed work</h2><p class="hint">open a task and choose whether to try the failed work again, wait for something else, or continue without it</p>${joinHtml(data.stranded.map(one => html `<p class="row"><a href="${taskHref(one.id)}">${one.id}</a> waits on ${joinHtml(one.blockedBy.map(blocker => html `<a href="${taskHref(blocker)}">${blocker}</a>`), ", ")} <span class="right meta">choose what happens →</span></p>`), "\n")}`;
394
+ const gaps = data.gaps === null || data.gaps.length === 0
395
+ ? null
396
+ : html `<h2>Missing requirements</h2><p class="hint">tools or credentials builds need — checked before any money is spent</p>${joinHtml(data.gaps.map(gap => html `<p class="row"><a href="/caps">${gap.key}</a> — ${gap.state}<span class="right meta">how to fix →</span></p>`), "\n")}`;
397
+ // Live at the fidelity the moment deserves: fast while something builds,
398
+ // gentle when the page is just a briefing. GET-only, so refresh is safe.
399
+ const refresh = data.building.length > 0 ? 10 : 60;
400
+ // BUILDING RIGHT NOW: each live claim as a pulsing card — the one moment
401
+ // an operator actually watches this page, so it re-renders itself.
402
+ const building = data.building.length === 0
403
+ ? null
404
+ : html `<h2>Building now</h2><p class="hint">live builds — this page refreshes itself every 10 seconds while anything runs</p><div class="cards">${data.building.map(claim => html `<a class="stat-card" href="${taskHref(claim.taskId)}" style="text-decoration:none"><span class="k"><span class="dot dot-ok pulse"></span>${claim.taskId}</span><span class="v">${claim.runner} · ${Math.max(1, Math.round((data.now.getTime() - new Date(claim.claimedAt).getTime()) / 60_000))}m elapsed${claim.model === null ? null : html ` · ${claim.model}`}</span></a>`)}</div>`;
405
+ // THE FLEET: runners by heartbeat age, worktrees by lease state, the watch.
406
+ const nowMs = data.now.getTime();
407
+ const runnerCards = data.runners
408
+ .filter(one => one.retiredAt === null)
409
+ .map(one => {
410
+ const age = nowMs - new Date(one.heartbeatAt).getTime();
411
+ const dot = age < 5 * 60_000 ? "dot-ok" : age < 60 * 60_000 ? "dot-warn" : "dot-off";
412
+ const said = age < 5 * 60_000 ? "alive" : age < 60 * 60_000 ? `quiet ${Math.round(age / 60_000)}m` : "not heard from";
413
+ const busy = data.building.filter(claim => claim.runner === one.name).length;
414
+ return html `<div class="stat-card"><span class="k"><span class="dot ${dot}"></span>${one.name}</span><span class="v">builder · ${said} · ${busy}/${one.capacity} building</span></div>`;
415
+ });
416
+ const worktreeCards = data.worktrees.map(tree => {
417
+ const leased = tree.leasedAt !== null && tree.releasedAt === null;
418
+ const dot = leased ? "dot-ok" : tree.verified ? "dot-off" : "dot-warn";
419
+ const state = leased ? "building" : tree.verified ? "free" : "needs review";
420
+ const name = tree.path.split("/").pop() ?? tree.path;
421
+ return html `<div class="stat-card"><span class="k"><span class="dot ${dot}${leased ? " pulse" : ""}"></span><span class="mono">${name}</span></span><span class="v">workspace · ${tree.branch} · ${state}</span></div>`;
422
+ });
423
+ const watchCard = data.episode === null
424
+ ? null
425
+ : html `<div class="stat-card"><span class="k"><span class="dot ${data.episode.endedAt === null ? "dot-ok pulse" : "dot-off"}"></span>Toolroll</span><span class="v">${data.episode.endedAt === null
426
+ ? html `running since ${whenTime(data.episode.startedAt)}`
427
+ : html `last window: ${data.episode.built} built, ${data.episode.broke} broke · ended ${whenTime(data.episode.endedAt)}`}</span></div>`;
428
+ const fleetCards = [...runnerCards, watchCard, ...worktreeCards].filter((one) => one !== null);
429
+ const fleet = fleetCards.length === 0
430
+ ? html `<h2>System status</h2><p class="hint">No builder is connected yet. On the machine where the project lives, open that folder and run <code>toolroll up</code>.</p>`
431
+ : html `<h2>System status</h2><p class="hint">builders execute tasks in isolated temporary copies of each project</p><div class="cards">${fleetCards}</div>`;
432
+ const startHere = data.taskCount === 0
433
+ ? joinHtml([
434
+ html `<div class="card">`,
435
+ html `<p><strong>Nothing is queued yet — here is the whole loop:</strong></p>`,
436
+ html `<p>1. <a href="/tasks">Add a task</a> — plain words for work you want done${data.repo === null ? null : html ` in <span class="mono">${data.repo}</span>`}.</p>`,
437
+ html `<p>2. Open it and write its scope — the goal, and what it must not become. Approve exactly that.</p>`,
438
+ html `<p>3. Keep Toolroll running on the builder machine. Approved tasks build unattended, each on its own branch.</p>`,
439
+ html `<p class="meta">When an agent is unsure it stops and asks — those questions land here, under “waiting on you”.</p>`,
440
+ html `</div>`,
441
+ ], "\n")
442
+ : null;
443
+ return screen("activity", joinHtml([
444
+ html `<h1>Activity</h1>`,
445
+ buildsViews("activity"),
446
+ data.repo === null
447
+ ? null
448
+ : html `<p class="meta"><strong>${projectName(data.repo)}</strong> — the last 24 hours, honestly labeled: a rolling window, whatever your hours are</p>`,
449
+ startHere,
450
+ ledger,
451
+ html `<p class="meta">Spend: ${consoleSpend(summary)}</p>`,
452
+ data.outboxPending > 0 ? html `<p class="meta">Notifications: ${data.outboxPending} pending delivery</p>` : null,
453
+ building,
454
+ html `<h2>Needs your decision</h2><p class="hint">an agent stopped mid-build to ask — nothing proceeds until you answer</p>`,
455
+ decide,
456
+ incidents,
457
+ stranded,
458
+ gaps,
459
+ fleet,
460
+ ], "\n"), { chrome, refreshSeconds: refresh });
461
+ }
462
+ /** The one front door for new work. The common path is one prompt and one
463
+ * button; the detailed contract remains available in-place for templates,
464
+ * experts, and the rare task that should skip repository-aware planning. */
465
+ /** Where new work goes when no project is open: a choice from the projects
466
+ * this person already has, never a typed path; a new project is one link
467
+ * away. Same-named checkouts show their parent folder to tell them apart. */
468
+ export function projectPickerHtml(projects, chosen) {
469
+ if (projects.length === 0) {
470
+ return html `<label class="task-repo">Project folder <span class="meta">— no project is open, so the task must say where it belongs</span><input type="text" name="repo" value="${chosen}" required placeholder="/path/to/repository"></label><p class="meta task-repo-add">No projects yet. <a href="/projects?return=%2Ftasks%2Fnew">Add a project</a> to pick it here next time.</p>`;
471
+ }
472
+ const counts = new Map();
473
+ for (const one of projects)
474
+ counts.set(one.name, (counts.get(one.name) ?? 0) + 1);
475
+ const label = (one) => {
476
+ if ((counts.get(one.name) ?? 0) < 2)
477
+ return one.name;
478
+ const parent = one.path.split(/[\\/]/).filter(Boolean).slice(-2, -1)[0] ?? one.path;
479
+ return `${one.name} (${parent})`;
480
+ };
481
+ const selected = projects.some(one => one.path === chosen) ? chosen : projects[0].path;
482
+ return html `<label class="task-repo">Project<select name="repo" required>${projects.map(one => html `<option value="${one.path}" title="${one.path}"${one.path === selected ? html ` selected` : null}>${label(one)}</option>`)}</select></label><p class="meta task-repo-add"><a href="/projects?return=%2Ftasks%2Fnew">Add a project</a></p>`;
483
+ }
484
+ export function taskComposerHtml(data) {
485
+ const prefill = data.prefill ?? null;
486
+ const values = prefill?.values;
487
+ const after = values?.get("after") ?? "";
488
+ const candidates = data.candidates ?? (after === "" ? [] : [{ id: after, title: after }]);
489
+ const projectLabel = data.project === null ? "repository required" : projectName(data.project);
490
+ const showPicker = data.project === null || (data.projects !== undefined && data.projects.length > 1);
491
+ const quality = values?.get("quality-mode") ?? data.qualityDefault;
492
+ return postForm("/tasks/add", joinHtml([
493
+ prefill === null || values !== undefined
494
+ ? null
495
+ : html `<p class="meta" style="margin:.35rem .75rem .15rem">pre-filled from a template. Change anything; it still waits for your approval.</p>`,
496
+ html `<label class="task-prompt"><span class="visually-hidden">What should get done?</span><textarea name="title" rows="4" maxlength="200" required autofocus placeholder="Describe the outcome you want. The planner will inspect the repository and work out the implementation details.">${prefill === null ? "" : prefill.title}</textarea></label>`,
497
+ // The project is always a visible, changeable choice when the page knows
498
+ // the person's projects; the open project is simply preselected.
499
+ showPicker ? projectPickerHtml(data.projects ?? [], values?.get("repo") ?? data.project ?? "") : null,
500
+ html `<div class="task-composer-footer">`,
501
+ html `<div class="task-context">${showPicker || data.project === null ? null : html `<span class="task-context-chip" title="${data.project}">${projectLabel}</span>`}<span class="task-context-chip">planner inspects first</span></div>`,
502
+ html `<label class="task-quality"><span class="visually-hidden">quality mode</span><select name="quality-mode" aria-label="quality mode"><option value="default"${quality === "default" ? html ` selected` : null}>Default quality</option><option value="strict"${quality === "strict" ? html ` selected` : null}>Strict / release</option></select></label>`,
503
+ html `<button type="submit" class="task-submit">${prefill === null ? "Plan task" : "Continue"} →</button>`,
504
+ html `</div>`,
505
+ html `<details class="task-options"${prefill === null ? null : html ` open`}>`,
506
+ html `<summary><span>Edit details</span><small>optional · defaults are remembered</small></summary>`,
507
+ html `<div class="task-options-grid">`,
508
+ html `<label class="wide">Goal <span class="meta">— provide upfront for automatic approval of an unchanged plan</span><textarea name="goal" rows="3" placeholder="What success looks like">${prefill === null ? "" : prefill.goal}</textarea></label>`,
509
+ html `<label class="wide">Acceptance <span class="meta">— required with a goal; one per line: <code>statement | evidence,kinds | how</code></span><textarea name="acceptance" rows="3" placeholder="Requests over the limit return 429 | check">${prefill === null ? "" : prefill.acceptance}</textarea></label>`,
510
+ html `<label>Not this <span class="meta">— optional boundary</span><input type="text" name="not" value="${prefill === null ? "" : prefill.not}"></label>`,
511
+ html `<label>Likely touches <span class="meta">— paths, comma-separated</span><input type="text" name="touches" value="${prefill === null ? "" : prefill.touches}"></label>`,
512
+ html `<label class="wide task-check"><input type="checkbox" name="plan-first" value="1"${values === undefined || values.get("plan-first") === "1" ? html ` checked` : null}><span><strong>Let the planner inspect first</strong><small class="meta">Recommended. It drafts the goal, acceptance criteria, and implementation approach, and asks only when a missing answer materially changes the work.</small></span></label>`,
513
+ html `<label class="wide task-check"><input type="checkbox" name="scout" value="1"${values?.get("scout") === "1" ? html ` checked` : null}><span><strong>Research only</strong><small class="meta">Deliver a read-only report instead of changing the repository.</small></span></label>`,
514
+ html `<label>Task id <span class="meta">— optional</span><input type="text" name="id" value="${values?.get("id") ?? ""}" placeholder="made from the request"></label>`,
515
+ candidates.length === 0
516
+ ? null
517
+ : html `<label>Starts after <span class="meta">— optional</span><select name="after"><option value="">right away</option>${candidates.map(one => html `<option value="${one.id}"${one.id === after ? html ` selected` : null}>${one.id} — ${one.title}</option>`)}</select></label>`,
518
+ html `<fieldset class="permission-field"><legend>Agent permissions</legend>${permissionModeChoices("permission-mode", values?.get("permission-mode") === "bypassPermissions" ? "bypassPermissions" : values?.get("permission-mode") === "auto" ? "auto" : data.permissionDefault)}<p class="meta permission-note">Inherited from Settings. You can still change it on the proposed scope before approval.</p></fieldset>`,
519
+ html `</div>`,
520
+ html `</details>`,
521
+ ], "\n"), {
522
+ attrs: { class: "card task-composer" },
523
+ projectRevision: data.projectRevision === undefined && values?.get("projectRevision") == null ? null : String(data.projectRevision ?? values?.get("projectRevision") ?? ""),
524
+ hidden: { "planning-policy": "choice" },
525
+ });
526
+ }
527
+ export const TASK_GROUP_LABEL = { ...ASK_LABEL, building: 'Building', rest: 'Recent' };
528
+ export function workPage(chrome, data) {
529
+ const href = (view, cursor) => {
530
+ const query = new URLSearchParams();
531
+ if (data.projectFilter !== undefined)
532
+ query.set('project', data.projectFilter);
533
+ if (view !== 'all')
534
+ query.set('view', view);
535
+ if (cursor !== undefined)
536
+ query.set('cursor', cursor);
537
+ return `/work${query.size ? `?${query}` : ''}`;
538
+ };
539
+ const tabs = html `<nav class="work-views" aria-label="Task views">${WORK_VIEWS.map(one => html `<a href="${href(one.key)}"${one.key === data.view ? html ` class="active" aria-current="page"` : null}>${one.label}<span class="count">${data.work.totals[one.key]}</span></a>`)}</nav>`;
540
+ const rowHtml = (row) => {
541
+ const target = row.primaryAction?.target;
542
+ const actionHref = row.primaryAction?.code === 'open-result' && target?.runId != null
543
+ ? `/review?result=${encodeURIComponent(target.taskId)}&run=${target.runId}`
544
+ : browserWorkActionHref(row);
545
+ const action = actionHref === null || row.primaryAction === null ? null : html `<a class="work-action" data-primary-action href="${actionHref}">${row.primaryAction.label} →</a>`;
546
+ const project = data.multiProject || row.repo === null ? html `<span class="project-label">${row.repo === null ? 'Unplaced' : projectName(row.repo)}</span>` : null;
547
+ return html `<article class="work-row" data-task="${row.rootId}" data-work-status="${row.status.token}" data-work-views="${row.status.views.join(' ')}"><div class="work-row-main"><a class="work-title" href="${taskHref(row.rootId)}">${row.title}</a><p class="work-meta">${project}<span>${relativeAge(row.updatedAt, data.now)}</span></p></div><div class="work-row-status">${statusLineHtml(row.status)}${action}${row.familyProblem === null ? null : html `<p class="problem">${row.familyProblem}</p>`}${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 ? html `<p class="work-detail">${row.status.detail}</p>` : null}${workDiagnosticsHtml(row.status.diagnostics)}</div></article>`;
548
+ };
549
+ const current = WORK_VIEWS.find(one => one.key === data.view);
550
+ const list = data.work.items.length === 0
551
+ ? html `<div class="work-empty" data-work-empty="${data.view}"><p>${data.previous ? 'There are no more tasks on this page.' : current.empty}</p>${data.view === 'all' && !data.previous ? html `<p><a class="button-link" href="${chrome.chat === true ? '/chat' : '/tasks/new'}">${chrome.chat === true ? 'Start in chat' : 'Add a task'}</a></p>` : html `<a href="${href('all')}">See all tasks →</a>`}</div>`
552
+ : html `<div class="work-list">${data.work.items.map(rowHtml)}</div>`;
553
+ const pages = !data.previous && data.work.nextCursor === null ? null : html `<nav class="row work-pagination" aria-label="Task pages">${data.previous ? html `<a class="button-link" href="${href(data.view)}">First page</a>` : null}${data.work.nextCursor === null ? null : html `<a class="button-link" rel="next" href="${href(data.view, data.work.nextCursor)}">Next page</a>`}</nav>`;
554
+ const tools = html `<details class="work-tools"><summary>Work tools${CHEVRON_ICON}</summary><nav class="work-tools-menu">${[['/inbox', 'Inbox'], ['/board', 'Board'], ['/board?view=order', 'Order'], ['/tasks', 'Task list'], ['/recipes', 'Recipes'], ...(chrome.projectScoped ? [] : [['/workbench', 'Portfolio']]), ['/ledger', 'Action ledger']]
555
+ .map(([path, label]) => html `<a href="${path}">${label}</a>`)}</nav></details>`;
556
+ const toolLinks = [['/inbox', 'Inbox'], ['/board', 'Board'], ['/board?view=order', 'Order'], ['/tasks', 'Task list'], ['/recipes', 'Recipes'], ...(chrome.projectScoped ? [] : [['/workbench', 'Portfolio']])];
557
+ const view = {
558
+ kind: 'tasks',
559
+ tabs: WORK_VIEWS.map(one => ({ label: one.label, href: href(one.key), count: data.work.totals[one.key], active: one.key === data.view })),
560
+ needsYou: data.work.totals['needs-you'],
561
+ groups: data.view !== 'all' && data.view !== 'needs-you' ? null
562
+ : [...ASKS, ...(data.view === 'all' ? ['building', 'rest'] : [])]
563
+ .map(key => ({ key, label: TASK_GROUP_LABEL[key], count: data.work.groups[key] })).filter(one => one.count > 0),
564
+ rows: data.work.items.map(row => {
565
+ const target = row.primaryAction?.target;
566
+ const actionHref = row.primaryAction?.code === 'open-result' && target?.runId != null
567
+ ? `/review?result=${encodeURIComponent(target.taskId)}&run=${target.runId}`
568
+ : browserWorkActionHref(row);
569
+ 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;
570
+ return {
571
+ id: row.rootId, title: row.title, href: taskHref(row.rootId),
572
+ project: data.multiProject || row.repo === null ? (row.repo === null ? 'Unplaced' : projectName(row.repo)) : null,
573
+ age: relativeAge(row.updatedAt, data.now),
574
+ status: { label: row.status.label, tone: row.status.tone, token: row.status.token },
575
+ ask: row.ask, chip: row.chip, group: row.ask ?? (row.status.rank === 1 ? 'building' : 'rest'),
576
+ action: actionHref === null || row.primaryAction === null ? null : { label: row.primaryAction.label, href: actionHref },
577
+ detail: needsYouDetail ? row.status.detail : row.progress ?? null,
578
+ problem: row.familyProblem,
579
+ notes: (row.status.diagnostics ?? []).map(one => `${one.label} · ${one.detail}`),
580
+ };
581
+ }),
582
+ empty: data.work.items.length > 0 ? null : {
583
+ text: data.previous ? 'There are no more tasks on this page.' : current.empty,
584
+ action: data.view === 'all' && !data.previous ? { label: chrome.chat === true ? 'Start in chat' : 'Add a task', href: chrome.chat === true ? '/chat' : '/tasks/new' } : { label: 'See all tasks', href: href('all') },
585
+ },
586
+ pages: { first: data.previous ? href(data.view) : null, next: data.work.nextCursor === null ? null : href(data.view, data.work.nextCursor) },
587
+ tools: toolLinks.map(([path, label]) => ({ label: label, href: path })),
588
+ newTask: { label: 'New task', href: '/tasks/new' },
589
+ limits: data.limits ?? null,
590
+ };
591
+ return screen('work', html `<div class="work-head"><h1>Tasks</h1>${tools}</div>${limitsHtml(data.limits ?? null)}${tabs}${list}${pages}`, { chrome, workspace: { view } });
592
+ }
593
+ export function tasksPage(chrome, tasks, state, problem, repo = null, prefill = null, permissionDefault = "auto", qualityDefault = "default", replaced = new Map()) {
594
+ const filters = joinHtml(TASK_STATES.map(one => (one === state ? html `<strong>${one}</strong>` : html `<a href="/tasks?state=${one}">${one}</a>`)), " · ");
595
+ const rows = tasks.length === 0
596
+ ? html `<p class="meta">${state === null
597
+ ? "The queue is empty — add the first task below. It builds once you approve its scope."
598
+ : `Nothing is ${state}.`}</p>`
599
+ : joinHtml(tasks.map(task => html `<a class="row" href="${taskHref(task.id)}"><span class="mono">${task.id}</span> ${task.title} <span class="right badge badge-${task.state}">${task.state === "cancelled" && replaced.has(task.id) ? `replaced by ${replaced.get(task.id)}` : task.state}</span></a>`), "\n");
600
+ return screen("tasks", joinHtml([
601
+ html `<h1>Tasks</h1>`,
602
+ html `<p class="meta">Work you want done${repo === null ? null : html ` in <strong>${projectName(repo)}</strong>`} — a task builds unattended only after its scope is approved; open one to write or approve its scope</p>`,
603
+ repo === null ? null : html `<p class="meta path-words"><span class="mono">${repo}</span></p>`,
604
+ problem === null ? null : html `<div class="problem">${problem}</div>`,
605
+ html `<p class="meta">Filter: <a href="/tasks">all</a> · ${filters}</p>`,
606
+ rows,
607
+ html `<h2>Add a task</h2>`,
608
+ taskComposerHtml({ project: repo, prefill, permissionDefault, qualityDefault }),
609
+ ], "\n"), { chrome });
610
+ }
611
+ export function browsePage(chrome, data) {
612
+ const crumb = data.at === data.root ? projectName(data.root) : `${projectName(data.root)}${data.at.slice(data.root.length)}`;
613
+ const openForm = (path) => postForm("/projects/open", html `<button type="submit">Open</button>`, { attrs: { class: "inline" }, hidden: { path } });
614
+ return screen("projects", joinHtml([
615
+ html `<h1>Choose a folder</h1>`,
616
+ html `<p class="meta">git repositories float to the top and can be opened; anything else can be entered — only folders under ${data.roots.length === 1 ? html `<span class="mono">${projectName(data.root)}</span>` : "the configured roots"} are visible here</p>`,
617
+ data.roots.length > 1
618
+ ? html `<p class="meta">Roots: ${joinHtml(data.roots.map(one => html `<a href="/projects/browse?at=${encodeURIComponent(one)}" class="mono">${projectName(one)}</a>`), " · ")}</p>`
619
+ : null,
620
+ html `<p class="mono meta">${crumb}</p>`,
621
+ data.parent === null
622
+ ? null
623
+ : html `<p class="row"><a href="/projects/browse?at=${encodeURIComponent(data.parent)}">← up one level</a></p>`,
624
+ data.entries.length === 0
625
+ ? html `<p class="meta">No folders here</p>`
626
+ : joinHtml(data.entries.map(one => html `<p class="row"><a href="/projects/browse?at=${encodeURIComponent(one.path)}"><strong>${one.name}</strong></a>${one.git ? html ` <span class="badge badge-done">git</span>` : null}<span class="right">${one.git ? openForm(one.path) : html `<a class="meta" href="/projects/browse?at=${encodeURIComponent(one.path)}">enter →</a>`}</span></p>`), "\n"),
627
+ html `<p class="meta"><a href="/projects">← back to projects</a></p>`,
628
+ ], "\n"), { chrome });
629
+ }
630
+ /**
631
+ * The control-room rail (attended A1): every state that matters while a
632
+ * person supervises, ordered by intervention cost. The project chip is
633
+ * deliberately repeated on every row — a title is not a workspace, and
634
+ * switching context must never depend on remembering which repo is open.
635
+ */
636
+ export function workbenchRail(data) {
637
+ const workspace = (repo) => html `<span class="badge">${repo === null ? "Unplaced" : projectName(repo)}</span>`;
638
+ const row = (card, reason) => html `<a class="wb-row${card.taskId === data.selected ? " wb-selected" : ""}" href="/workbench?t=${encodeURIComponent(card.taskId)}"${card.taskId === data.selected ? html ` aria-current="true"` : null}><span class="wb-title">${card.title}</span><span class="wb-meta"><span class="mono meta">${card.taskId}</span>${workspace(card.repo)}</span><span class="wb-reason">${reason}</span></a>`;
639
+ const group = (title, cards, empty, render) => html `<section class="wb-group"><h2>${title} <span class="lane-count">${cards.length}</span></h2>${cards.length === 0 ? html `<p class="meta">${empty}</p>` : joinHtml(cards.slice(0, 100).map(render), "\n")}</section>`;
640
+ const parts = [];
641
+ parts.push(html `<div class="wb-rail-head"><div><span class="eyebrow">portfolio</span><h2>All projects</h2></div><a href="/projects">manage →</a></div>`);
642
+ parts.push(group("needs you", data.attention, "Nothing needs your input.", card => row(card, card.reason)));
643
+ parts.push(group("in progress", data.building, "No agent is working right now.", card => {
644
+ const claim = card.claim;
645
+ const phase = claim?.phase == null ? "working" : phaseWords(claim.phase);
646
+ return row(card, html `${phase}${claim?.provider ? html ` · ${claim.provider}` : null}${claim?.claimedAt ? html ` · <time data-elapsed-since="${claim.claimedAt}"></time>` : null}`);
647
+ }));
648
+ parts.push(group(html `blocked & waiting`, data.waiting, "Nothing is blocked or paused.", card => row(card, card.reason)));
649
+ parts.push(group("up next", data.queued, "The ready queue is empty.", card => row(card, html `${card.reason}${card.assignedRunner === null ? null : html ` · reserved for ${card.assignedRunner}`}`)));
650
+ if (data.done.length > 0) {
651
+ parts.push(html `<section class="wb-group"><h2>Just finished <span class="lane-count">${data.done.length}</span></h2>`);
652
+ parts.push(joinHtml(data.done.map(one => html `<a class="wb-row${one.taskId === data.selected ? " wb-selected" : ""}" href="/workbench?t=${encodeURIComponent(one.taskId)}"><span class="wb-title">${one.title}</span><span class="wb-meta"><span class="mono meta">${one.taskId}</span>${workspace(one.repo)} <span class="badge badge-${one.outcome === "built" || one.outcome === "no-change" ? "done" : "failed"}">${sentenceCase(one.outcome ?? "?")}</span></span></a>`), "\n"));
653
+ parts.push(html `</section>`);
654
+ }
655
+ if (data.saturated)
656
+ parts.push(html `<p class="meta">More exists — this rail is capped; the <a href="/board?scope=all">board</a> holds the rest</p>`);
657
+ return joinHtml(parts, "\n");
658
+ }
659
+ /**
660
+ * The portfolio overview (arc slice 1a): what waits on you, what the last
661
+ * 24 hours of run starts amounted to, what is running, and the terminal-run
662
+ * ledger — all of it across every admitted project, a project chip on every
663
+ * row. The caller has already applied admission and per-row visibility.
664
+ */
665
+ export function portfolioOverview(data) {
666
+ const pulses = new Map();
667
+ const pulseFor = (repo) => {
668
+ const key = repo ?? "";
669
+ const existing = pulses.get(key);
670
+ if (existing !== undefined)
671
+ return existing;
672
+ const made = { repo, attention: 0, building: 0, waiting: 0, queued: 0, done: 0 };
673
+ pulses.set(key, made);
674
+ return made;
675
+ };
676
+ for (const card of [...data.attention, ...data.building, ...data.waiting, ...data.queued]) {
677
+ pulseFor(card.repo)[card.lane] += 1;
678
+ }
679
+ for (const one of data.done)
680
+ pulseFor(one.repo).done += 1;
681
+ const pulseRows = [...pulses.values()].sort((a, b) => b.attention - a.attention || b.building - a.building || b.waiting - a.waiting ||
682
+ (a.repo === null ? 1 : b.repo === null ? -1 : projectName(a.repo).localeCompare(projectName(b.repo))));
683
+ const workspace = (repo) => repo === null ? "Unplaced work" : projectName(repo);
684
+ // A workspace card (board pass): the repo's name and one status word,
685
+ // its four counts, a bar of the same counts in proportion, and — for a
686
+ // real repo, with a session token — the one tap to its own board. The
687
+ // status word is the loudest true thing: needs you beats building beats
688
+ // waiting beats queued; a repo with nothing in flight is idle.
689
+ const statusOf = (one) => one.attention > 0
690
+ ? { word: "needs you", cls: "badge-open" }
691
+ : one.building > 0
692
+ ? { word: "building", cls: "badge-running" }
693
+ : one.waiting > 0
694
+ ? { word: "waiting", cls: "" }
695
+ : one.queued > 0
696
+ ? { word: "queued", cls: "" }
697
+ : { word: "idle", cls: "" };
698
+ const workspaceRows = joinHtml(pulseRows.map(one => {
699
+ const status = statusOf(one);
700
+ const total = one.attention + one.building + one.waiting + one.queued;
701
+ const seg = (cls, count) => count === 0 ? null : html `<span class="seg ${cls}" style="flex-grow:${count}"></span>`;
702
+ const boardForm = one.repo === null || data.csrf === ""
703
+ ? null
704
+ : postForm("/projects/open", html `<button type="submit">Board →</button>`, { attrs: { class: "inline" }, returnTo: "/board", hidden: { path: one.repo } });
705
+ const setUpForm = one.repo === null || data.csrf === ""
706
+ ? null
707
+ : postForm("/projects/open", html `<button>Set up →</button>`, { attrs: { class: "inline" }, returnTo: "/control", hidden: { path: one.repo } });
708
+ return html `<div class="workspace-card${one.attention > 0 ? " hot" : ""}"><div class="workspace-head"><span class="workspace-name">${workspace(one.repo)}</span><span class="badge ${status.cls}">${sentenceCase(status.word)}</span>${boardForm}${setUpForm}</div><div class="workspace-stats"><span class="pulse-stat${one.attention > 0 ? " hot" : ""}"><b>${one.attention}</b> need you</span><span class="pulse-stat"><b>${one.building}</b> live</span><span class="pulse-stat"><b>${one.waiting}</b> waiting</span><span class="pulse-stat"><b>${one.queued}</b> next</span></div><div class="workspace-bar${total === 0 ? " empty" : ""}" aria-hidden="true">${seg("attention", one.attention)}${seg("building", one.building)}${seg("waiting", one.waiting)}${seg("queued", one.queued)}</div></div>`;
709
+ }), "\n");
710
+ // ---- waits on you: everything a person must resolve, across projects ----
711
+ const waitCount = data.decisions.length + data.approvals.length + data.requeueables.length +
712
+ data.cancelledBlockers.length + data.gaps.length;
713
+ const decisionCards = joinHtml(data.decisions.map(one => decisionAnswerCard(one, data.csrf, data.now, true)), "\n");
714
+ const approvalCards = joinHtml(data.approvals.map(one => html `<a class="decide-card" href="${taskHref(one.taskId)}"><p class="q">${one.title}</p><span class="meta">${one.goal.length > 120 ? one.goal.slice(0, 120) + "…" : one.goal}</span><br><span class="mono meta">${one.taskId}</span>${projectChip(one.repo)} <span class="right meta">review and sign →</span></a>`), "\n");
715
+ const requeueRows = joinHtml(data.requeueables.map(one => html `<p class="row"><a href="${taskHref(one.taskId)}">${one.taskId}</a> ${one.title}${projectChip(one.repo)}${one.incidentCount > 0 ? html ` <span class="badge badge-failed">${one.incidentCount} incident${one.incidentCount > 1 ? "s" : ""}</span>` : null}${one.strikes > 0 ? html ` <span class="meta">${one.strikes} failed attempt${one.strikes > 1 ? "s" : ""}</span>` : null}<span class="right meta">open the task to retry →</span></p>`), "\n");
716
+ const cancelledRows = joinHtml(data.cancelledBlockers.map(one => html `<p class="row"><a href="${taskHref(one.exampleDependent)}">${one.exampleDependent}</a>${projectChip(one.repo)} <span class="meta">${one.dependentCount > 1 ? `one of ${one.dependentCount} tasks waiting` : "waiting"} for cancelled task ${one.blockerId}</span><span class="right meta">choose what happens →</span></p>`), "\n");
717
+ const gapRows = joinHtml(data.gaps.map(gap => html `<p class="row"><a href="/caps">${gap.key}</a>${data.gapsProject === null ? null : projectChip(data.gapsProject)} <span class="meta">frees ${gap.unblocks.length} task${gap.unblocks.length > 1 ? "s" : ""}</span><span class="right meta">how to fix →</span></p>`), "\n");
718
+ // ---- the last 24 hours: runs STARTED in the window, outcomes exhaustive ----
719
+ const groups = [
720
+ ["built", data.runs24.filter(one => one.outcome === "built").length],
721
+ ["no change", data.runs24.filter(one => one.outcome === "no-change").length],
722
+ ["failed", data.runs24.filter(one => one.outcome === "failed").length],
723
+ ["refused", data.runs24.filter(one => one.outcome === "refused").length],
724
+ ["parked", data.runs24.filter(one => one.outcome === "parked").length],
725
+ ["interrupted", data.runs24.filter(one => one.outcome === "interrupted").length],
726
+ ["unfinished", data.runs24.filter(one => one.outcome === null).length],
727
+ ];
728
+ const outcomeWords = groups.filter(([, count]) => count > 0).map(([word, count]) => `${count} ${word}`).join(" · ");
729
+ const summary = tally(data.runs24);
730
+ // ---- running: current live claims, project chips on ----
731
+ const liveRows = joinHtml(data.live.map(one => html `<p class="row"><a href="${taskHref(one.taskId)}">${one.taskId}</a>${projectChip(one.repo)} <span class="badge badge-running">Running</span> <span class="mono meta">${one.runner}${one.model === null ? null : html ` · ${one.model}`} · <time data-elapsed-since="${one.claimedAt}"></time></span></p>`), "\n");
732
+ // ---- the ledger: terminal runs started in the window, one chip each ----
733
+ const chipClass = (outcome) => outcome === "built" || outcome === "no-change" ? "badge-done" : outcome === "failed" ? "badge-failed" : "";
734
+ const prLink = (one) => {
735
+ if (one.prNumber === null)
736
+ return null;
737
+ // The URL-sink rule (audit IV-11): only a verified github pull
738
+ // URL earns an anchor; a corrupted row renders as text.
739
+ const safe = safePrUrl(one.prUrl);
740
+ return safe === null ? html ` · PR #${one.prNumber}` : html ` · <a href="${safe}">PR #${one.prNumber}</a>`;
741
+ };
742
+ const ledgerRows = joinHtml(data.ledger.map(one => html `<p class="row"><a href="/r/${one.runId}">${one.title}</a>${projectChip(one.repo)} <span class="badge ${chipClass(one.outcome)}">${sentenceCase(one.outcome)}</span> ${one.role === "scout" ? html `<a class="badge" href="${taskHref(one.taskId)}#report">Report</a> ` : null}<span class="mono meta">${one.provider === null ? "" : one.provider}${one.model === null ? null : html ` · ${one.model}`}${one.ranMinutes === null ? null : html ` · ${one.ranMinutes}m`} · ${runCostWords({ authMode: one.authMode, costUsd: one.costUsd, tokensIn: null, tokensOut: null })}${prLink(one)}</span></p>`), "\n");
743
+ return joinHtml([
744
+ html `<div class="control-room-head"><div><h1>Portfolio</h1><p class="meta">Every project and live build in one place</p></div><div class="actions"><a class="badge" href="/tasks/new">+ New task</a><a class="badge" href="/board?scope=all">Full board →</a></div></div>`,
745
+ data.saturated ? html `<div class="problem">This overview reached its 200-task display cap; the task list holds the rest.</div>` : null,
746
+ html `<h2>Waits on you</h2>`,
747
+ waitCount === 0
748
+ ? html `<div class="answered"><strong>Nothing needs you.</strong> <span class="meta">You can leave this open; live state updates in the rail.</span></div>`
749
+ : joinHtml([
750
+ data.decisions.length === 0 ? null : decisionCards,
751
+ data.approvals.length === 0 ? null : approvalCards,
752
+ data.requeueables.length === 0 ? null : requeueRows,
753
+ data.cancelledBlockers.length === 0 ? null : cancelledRows,
754
+ data.gaps.length === 0 ? null : gapRows,
755
+ html `<p class="meta"><a href="/next">clear the queue → one thing at a time</a></p>`,
756
+ ].filter((part) => part !== null), "\n"),
757
+ data.gapsProject === null
758
+ ? html `<p class="meta">Requirement gaps are checked one project at a time — open a project to see and fill its gaps · <a href="/projects">open a project</a></p>`
759
+ : null,
760
+ html `<h2>Project pulse</h2>`,
761
+ html `<p class="hint">one row per repository</p>`,
762
+ pulseRows.length === 0
763
+ ? html `<div class="card"><p><strong>No active work yet.</strong></p><p class="meta">Queue a task and its progress will show here across every workspace.</p></div>`
764
+ : html `<div class="workspace-pulse">${workspaceRows}</div>`,
765
+ html `<h2>The last 24 hours</h2>`,
766
+ html `<p class="hint">runs started in the last 24 hours</p>`,
767
+ data.runs24.length === 0
768
+ ? html `<p class="meta">No runs started in the window</p>`
769
+ : html `<p class="row"><span class="meta">runs started</span> <span class="mono">${data.runs24.length}</span></p><p class="row"><span class="meta">outcomes</span> <span class="mono">${outcomeWords}</span></p><p class="row"><span class="meta">spend</span> <span class="mono">${spendLine(summary)}</span></p>${
770
+ // Tokens stand on their own: invocations that reported usage —
771
+ // independent of whether cost was measured (spec §2; spendLine's
772
+ // mixed branch omits them).
773
+ summary.tokens > 0
774
+ ? html `<p class="row"><span class="meta">tokens</span> <span class="mono">${summary.tokens.toLocaleString()}</span></p>`
775
+ : null}`,
776
+ html `<h2>Running</h2>`,
777
+ data.live.length === 0 ? html `<p class="meta">No agent is working right now</p>` : liveRows,
778
+ html `<h2>Terminal runs started in the last 24 hours</h2>`,
779
+ data.ledger.length === 0 ? html `<p class="meta">None yet</p>` : ledgerRows,
780
+ ], "\n");
781
+ }
782
+ /**
783
+ * owner/name from a git remote URL — FULLY ANCHORED https/ssh github.com
784
+ * forms only (ghlist review, finding 2): a foreign host carrying
785
+ * "github.com" in its path, or a non-github host, must never read as a
786
+ * GitHub identity. Everything else is null.
787
+ */
788
+ export function githubIdentityOf(remoteUrl) {
789
+ const trimmed = remoteUrl.trim();
790
+ const https = /^https:\/\/github\.com\/([A-Za-z0-9-]+)\/([A-Za-z0-9._-]+?)(?:\.git)?\/?$/.exec(trimmed);
791
+ if (https !== null)
792
+ return `${https[1]}/${https[2]}`;
793
+ const ssh = /^(?:ssh:\/\/)?git@github\.com[:/]([A-Za-z0-9-]+)\/([A-Za-z0-9._-]+?)(?:\.git)?\/?$/.exec(trimmed);
794
+ if (ssh !== null)
795
+ return `${ssh[1]}/${ssh[2]}`;
796
+ return null;
797
+ }
798
+ /**
799
+ * The `origin` remote's url from a repository's OWN .git/config. BOUNDED
800
+ * I/O by construction (ghlist review, finding 1): the final component may
801
+ * not be a symlink (O_NOFOLLOW + lstat), must be a REGULAR file under a
802
+ * size cap, and at most 64 KiB are ever read — a sparse monster or a fifo
803
+ * planted as a "config" reads as null, never as a hang. Parsed LINE BY
804
+ * LINE with real section tracking (finding 2): a `[remote "origin"]`
805
+ * embedded inside some other value never opens the section. null when
806
+ * unreadable or origin-less; a worktree-style `.git` FILE (gitdir pointer)
807
+ * reads null too, which is honest — its identity lives elsewhere. The
808
+ * answer is ADVISORY metadata for offering a button: the /projects/open
809
+ * road re-proves path, ceiling, and git-ness before anything mutates, and
810
+ * a config that lies about its origin can only mislabel a repository the
811
+ * operator was already allowed to open.
812
+ */
813
+ export function originUrlOf(repoPath) {
814
+ const file = join(repoPath, ".git", "config");
815
+ let fd = null;
816
+ try {
817
+ const stat = lstatSync(file);
818
+ if (!stat.isFile() || stat.size > 1024 * 1024)
819
+ return null;
820
+ fd = openSync(file, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
821
+ const buffer = Buffer.alloc(64 * 1024);
822
+ const read = readSync(fd, buffer, 0, buffer.length, 0);
823
+ const config = buffer.toString("utf8", 0, read);
824
+ let inOrigin = false;
825
+ for (const line of config.split("\n")) {
826
+ if (/^\s*\[/.test(line)) {
827
+ inOrigin = /^\s*\[remote "origin"\]\s*$/.test(line);
828
+ continue;
829
+ }
830
+ if (!inOrigin)
831
+ continue;
832
+ const url = /^\s*url\s*=\s*(.+)$/.exec(line)?.[1];
833
+ if (url !== undefined)
834
+ return url.trim();
835
+ }
836
+ return null;
837
+ }
838
+ catch {
839
+ return null;
840
+ }
841
+ finally {
842
+ if (fd !== null) {
843
+ try {
844
+ closeSync(fd);
845
+ }
846
+ catch {
847
+ // already closed
848
+ }
849
+ }
850
+ }
851
+ }
852
+ /** The GitHub listing page: the account's repositories with ONE honest
853
+ * action each. Names, descriptions, and paths are gh/filesystem DATA —
854
+ * escaped at the sink like everything else. */
855
+ export function githubReposPage(chrome, data) {
856
+ const openForm = (path, label) => postForm("/projects/open", html `<button type="submit">${label}</button>`, { attrs: { class: "inline" }, hidden: { path } });
857
+ const cloneForm = (nameWithOwner) => postForm("/projects/onboard-preview", html `<button type="submit">Clone here →</button>`, { attrs: { class: "inline" }, hidden: { repo: nameWithOwner, root: "0" } });
858
+ const rows = !data.listed.ok
859
+ ? html `<p class="meta">${data.listed.message}</p>`
860
+ : data.listed.repos.length === 0
861
+ ? html `<p class="meta">The signed-in GitHub account has no repositories to list</p>`
862
+ : joinHtml(data.listed.repos.map(repo => {
863
+ const localPath = data.local.get(repo.nameWithOwner.toLowerCase()) ?? null;
864
+ const action = localPath !== null && data.openProject === localPath
865
+ ? html `<span class="badge badge-done">Open now</span>`
866
+ : localPath !== null
867
+ ? openForm(localPath, data.registered.has(localPath) ? "open →" : "add + open →")
868
+ : data.cloneReady
869
+ ? cloneForm(repo.nameWithOwner)
870
+ : html `<span class="meta">choose a projects folder first</span>`;
871
+ return joinHtml([
872
+ html `<div class="card project-card">`,
873
+ html `<div class="row"><strong>${repo.nameWithOwner}</strong>${repo.isPrivate ? html ` <span class="badge">Private</span>` : null}`,
874
+ html `<span class="right">${action}</span></div>`,
875
+ localPath === null
876
+ ? html `<p class="meta">Not on this machine yet${/^\d{4}-\d{2}-\d{2}T/.test(repo.updatedAt) ? html ` · pushed ${whenTime(repo.updatedAt)}` : null}</p>`
877
+ : html `<p class="meta mono" style="overflow-wrap:anywhere;margin:.2rem 0">${localPath}</p>`,
878
+ repo.description === "" ? null : html `<p class="meta">${repo.description}</p>`,
879
+ html `</div>`,
880
+ ], "\n");
881
+ }), "\n");
882
+ return screen("projects", joinHtml([
883
+ html `<h1>Your GitHub repositories</h1>`,
884
+ html `<p class="meta">Repositories available to the GitHub account signed in on this machine — open one you already have, or clone a new one after a quick preview.</p>`,
885
+ rows,
886
+ html `<p class="row" style="margin-top:.6rem"><a class="badge" href="/projects">← back to projects</a></p>`,
887
+ ], "\n"), { chrome });
888
+ }
889
+ export function projectsPage(chrome, recent, candidates, open, problem, _unscopedMode, browsable = false, onboard = null, peeks = {}, returnTo = "/", pullRequestsOn, checkLevelOf) {
890
+ // The onboarding card (repo onboarding, findings 1-39): preview first,
891
+ // then a password-confirmed clone into a configured root. Disabled
892
+ // states explain themselves in words (finding 28/39).
893
+ const onboardCard = onboard === null
894
+ ? null
895
+ : !onboard.enabled
896
+ ? html `<p class="meta">${onboard.why}</p>`
897
+ : joinHtml([
898
+ html `<details class="project-add-more"${onboard.record === null ? null : html ` open`}><summary>${onboard.record === null ? 'Paste a GitHub link' : 'Review repository'}</summary>`,
899
+ html `<p class="meta">Paste a GitHub repository — you will preview it before anything is downloaded. It goes into your saved projects folder and the builder connects automatically. Large-file (LFS) objects are not downloaded.</p>`,
900
+ onboard.record === null
901
+ ? postForm("/projects/onboard-preview", joinHtml([
902
+ html `<label>Repository <input type="text" name="repo" placeholder="owner/name or https://github.com/owner/name"></label>`,
903
+ onboard.roots.length > 1
904
+ ? html `<label>Into <select name="root">${onboard.roots.map((one, index) => html `<option value="${index}">${one}</option>`)}</select></label>`
905
+ : html `<input type="hidden" name="root" value="0"><p class="meta">Into ${onboard.roots[0] ?? ""}</p>`,
906
+ html `<button type="submit">Preview</button>`,
907
+ ], "\n"), { attrs: { class: "card" } })
908
+ : joinHtml([
909
+ html `<div class="card">`,
910
+ html `<p><strong>${onboard.record[1].nameWithOwner}</strong> <span class="meta">${onboard.record[1].diskUsageKib === null ? "size unknown" : `${Math.max(1, Math.round(onboard.record[1].diskUsageKib / 1024))} MiB`} — will land at ${onboard.record[1].target}</span></p>`,
911
+ postForm("/projects/onboard-confirm", joinHtml([
912
+ onboard.record[1].large
913
+ ? html `<label class="row"><input type="checkbox" name="big-ok" value="1"> this is a large repository (or its size is unknown) — clone it anyway</label>`
914
+ : null,
915
+ html `<label>Your password, typed again <input type="password" name="token" autocomplete="current-password"></label>`,
916
+ html `<div class="sticky-actions"><button type="submit">Clone and open</button></div>`,
917
+ ], "\n"), { hidden: { nonce: onboard.record[0] } }),
918
+ html `</div>`,
919
+ ], "\n"),
920
+ html `</details>`,
921
+ ], "\n");
922
+ // Opening a project is a POST (the session's scope changes); a card's
923
+ // name and counts are the same form, returning to the screen that count
924
+ // names — so every number on this page is a road, not a fact to admire.
925
+ const openForm = (path, label, destination = returnTo, className) => postForm("/projects/open", html `<button type="submit"${className === undefined ? null : html ` class="${className}"`}${className === "button-link" ? html ` style="min-height:44px"` : null}>${label}</button>`, { attrs: { class: "inline" }, returnTo: destination, hidden: { path } });
926
+ // A project switcher CARD (v30 UI): the name and path, an at-a-glance
927
+ // peek — what waits on a person, what is queued or running, what built
928
+ // in the last day — and the open action. A vertical stack that reads on
929
+ // a phone, not a dense row.
930
+ const peekChips = (path, peek) => {
931
+ if (peek === null)
932
+ return html `<span class="meta">not scanned</span>`;
933
+ const isOpen = open !== null && open === path;
934
+ const chip = (text, href, cls) => isOpen ? html `<a class="badge ${cls}" href="${href}">${text}</a>` : openForm(path, text, href, `badge ${cls}`);
935
+ const bits = [];
936
+ if (peek.waiting > 0)
937
+ bits.push(chip(`${peek.waiting} waiting on you`, "/", "badge-open"));
938
+ if (peek.running > 0)
939
+ bits.push(chip(`${peek.running} running`, "/runs", "badge-parked"));
940
+ if (peek.queued > 0)
941
+ bits.push(chip(`${peek.queued} queued`, "/board?view=order", "badge-queued"));
942
+ if (peek.doneRecently > 0)
943
+ bits.push(chip(`${peek.doneRecently} built today`, "/done", "badge-parked"));
944
+ return bits.length === 0 ? html `<span class="meta">quiet — nothing queued or waiting</span>` : joinHtml(bits, " ");
945
+ };
946
+ const projectCard = (one) => joinHtml([
947
+ html `<div class="card project-card">`,
948
+ html `<div class="row">${open !== null && open === one.path
949
+ ? html `<a class="project-name" href="${returnTo}"><strong>${one.name}</strong></a>`
950
+ : openForm(one.path, one.name, returnTo, "project-name")}`,
951
+ html `<span class="right">${open !== null && open === one.path ? html `<span class="badge badge-done">Open now</span>` : openForm(one.path, "Open", returnTo, "button-link")}</span></div>`,
952
+ html `<p class="meta mono" style="overflow-wrap:anywhere;margin:.2rem 0">${one.path}</p>`,
953
+ html `<p class="row" style="gap:.35rem;flex-wrap:wrap">${peekChips(one.path, one.peek)}</p>`,
954
+ html `<p class="meta row" style="justify-content:space-between;margin-bottom:0"><span>${one.note}</span><a href="/settings/knowledge?repo=${encodeURIComponent(one.path)}" style="display:inline-flex;align-items:center;min-height:44px">Knowledge</a></p>`,
955
+ html `</div>`,
956
+ ], "\n");
957
+ const cards = (items) => joinHtml(items.map(one => projectCard({ ...one, peek: peeks[one.path] ?? null })), "\n");
958
+ const recentItems = recent.map(one => ({ path: one.path, name: one.name, note: `last opened ${when(one.lastOpenedAt)}` }));
959
+ const candidateItems = candidates.map(path => ({ path, name: projectName(path), note: "seen in the queue" }));
960
+ // The rebuilt page's rows (shadcn/ui): the same projects, peeks and roads.
961
+ const home = homedir();
962
+ const rowOf = (path, name, openedAt) => {
963
+ const peek = peeks[path] ?? null;
964
+ return {
965
+ name, path, shortPath: path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path, open: open !== null && open === path, openedAt,
966
+ knowledgeHref: `/settings/knowledge?repo=${encodeURIComponent(path)}`,
967
+ ...(pullRequestsOn === undefined ? {} : { pullRequests: { href: `/settings/pull-requests?repo=${encodeURIComponent(path)}`, on: pullRequestsOn(path) } }),
968
+ ...(checkLevelOf === undefined ? {} : { checks: { href: `/settings/checks?repo=${encodeURIComponent(path)}`, level: CHECK_LEVEL_WORDS[checkLevelOf(path)] } }),
969
+ peek: peek === null ? null : [
970
+ ...(peek.waiting > 0 ? [{ label: `${peek.waiting} waiting on you`, href: "/", tone: "attention" }] : []),
971
+ ...(peek.running > 0 ? [{ label: `${peek.running} running`, href: "/runs", tone: "neutral" }] : []),
972
+ ...(peek.queued > 0 ? [{ label: `${peek.queued} queued`, href: "/board?view=order", tone: "neutral" }] : []),
973
+ ...(peek.doneRecently > 0 ? [{ label: `${peek.doneRecently} built today`, href: "/done", tone: "neutral" }] : []),
974
+ ],
975
+ };
976
+ };
977
+ // The two ways to ADD a project are the page's large, primary actions:
978
+ // browse this machine, or choose a GitHub repository. Manual path entry
979
+ // stays available as the clearly secondary expert road.
980
+ const addAction = (href, paths, title, detail) => html `<a class="project-add-action" href="${href}"><span class="project-add-icon">${strokeIcon(paths)}</span><span class="project-add-copy"><strong>${title}</strong><small>${detail}</small></span><span class="project-add-arrow" aria-hidden="true">→</span></a>`;
981
+ const addForms = joinHtml([
982
+ browsable || onboard !== null && !onboard.enabled && onboard.why.includes('--project-root')
983
+ ? null
984
+ : html `<p class="meta">Choose a projects folder once with <code>toolroll up --project-root &lt;dir&gt;</code>. Toolroll remembers it after that.</p>`,
985
+ onboardCard === null ? null : html `<div style="margin-top:.5rem">${onboardCard}</div>`,
986
+ html `<details class="project-add-more"><summary>Enter an exact path instead</summary>`,
987
+ html `${postForm("/projects/open", joinHtml([
988
+ html `<label>Path on this server<input type="text" name="path" placeholder="/Users/you/code/your-repo"></label>`,
989
+ html `<button type="submit">Open project</button>`,
990
+ ], "\n"), { attrs: { class: "card" }, returnTo })}</details>`,
991
+ ], "\n");
992
+ const addCard = joinHtml([
993
+ html `<div class="card project-add-card">`,
994
+ html `<h2 class="project-add-title">Add a project</h2>`,
995
+ html `<div class="project-add-actions">`,
996
+ browsable
997
+ ? addAction("/projects/browse", FOLDER_PATHS, "Choose a local folder", "Browse the project folders on this machine")
998
+ : null,
999
+ onboard === null
1000
+ ? null
1001
+ : addAction("/projects/github", html `<circle cx="6" cy="6" r="3"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M6 9v6"/><path d="M18 9a9 9 0 0 1-9 9"/>`, "Add from GitHub", "Choose from repositories available to your GitHub login"),
1002
+ html `</div>`,
1003
+ addForms,
1004
+ html `</div>`,
1005
+ ], "\n");
1006
+ // Arriving from New task with no project open: say what this step is for.
1007
+ const choosing = returnTo.startsWith("/tasks/new");
1008
+ return screen("projects", joinHtml([
1009
+ choosing ? html `<h1>New task</h1><p class="meta">Choose the project it belongs to.</p>` : html `<h1>Projects</h1>`,
1010
+ choosing ? null : html `<p class="meta">Add a folder or GitHub repository once. Its tasks and chat stay here.</p>`,
1011
+ problem === null ? null : html `<div class="problem">${problem}</div>`,
1012
+ recentItems.length === 0 && candidateItems.length === 0
1013
+ ? html `<div class="card"><p><strong>Nothing to open yet.</strong></p><p class="meta">Add one below — opening it registers it here for next time.</p></div>`
1014
+ : null,
1015
+ recentItems.length > 0 ? html `<h2>Recent</h2>${cards(recentItems)}` : null,
1016
+ candidateItems.length > 0 ? html `<h2>Available</h2>${cards(candidateItems)}` : null,
1017
+ addCard,
1018
+ ], "\n"), { chrome, workspace: { view: {
1019
+ kind: "projects", choosing, problem, returnTo,
1020
+ recent: recent.map(one => rowOf(one.path, one.name, one.lastOpenedAt)),
1021
+ available: candidates.map(path => rowOf(path, projectName(path), null)),
1022
+ add: { browse: browsable ? "/projects/browse" : null, github: onboard === null ? null : "/projects/github", html: htmlString(addForms) },
1023
+ } } });
1024
+ }
1025
+ /** The no-script "move to the front" sentinel: the form cannot name the
1026
+ * front of a partition, so the handler resolves it (slice 1b, fix 1). */
1027
+ export const QUEUE_FRONT = "__TOP__";
1028
+ export function queueCard(one, revision, queueRevision, workers, column) {
1029
+ // Presentation over queueScoped()'s shape only: state, scope, blockers,
1030
+ // and the reservation owner. Money is not in this query and is not
1031
+ // invented here — it stays on the task page, labeled.
1032
+ const state = one.taken
1033
+ ? html `<span class="badge">Being taken — keeps its claim</span>`
1034
+ : column === "anyone"
1035
+ ? html `<span class="badge">Queued</span>`
1036
+ : html `<span class="badge">Reserved for ${column}</span>`;
1037
+ const chips = html ` ${state}${one.dispatch === null || one.dispatch === undefined ? null : html ` <a class="badge" href="${taskHref(one.id)}" title="${one.dispatch.summary}">${dispatchHeadline(one.dispatch)}</a>`}`;
1038
+ const options = { attrs: { class: "inline" }, projectRevision: revision };
1039
+ const controls = one.taken
1040
+ ? null
1041
+ : html `${postForm("/queue/move", html `<button type="submit" class="icon-button" aria-label="move to the front">${TO_FRONT_ICON}</button>`, { ...options, hidden: { queueRevision, task: one.id, column, before: QUEUE_FRONT } })}${postForm("/queue/move", html `<select name="column" aria-label="reserve for"><option value="anyone"${column === "anyone" ? html ` selected` : null}>anyone</option>${workers.filter(worker => !worker.retired).map(worker => html `<option value="${worker.name}"${column === worker.name ? html ` selected` : null}>${worker.name}</option>`)}</select><button type="submit">Move</button>`, { ...options, hidden: { queueRevision, task: one.id } })}`;
1042
+ return html `<div class="card queue-card" data-task="${one.id}" data-taken="${one.taken ? "1" : "0"}"${one.dispatch === null || one.dispatch === undefined ? null : html ` data-dispatch-status="${one.dispatch.code}"`}><p class="row">${one.taken ? null : GRIP_HANDLE}<a href="${taskHref(one.id)}">${one.title}</a>${chips}</p><p class="row meta"><span class="mono">${one.id}</span> ${controls}</p></div>`;
1043
+ }
1044
+ /** The queue columns fragment — shared queue first, then each worker. */
1045
+ export function queueBody(tasks, workers, revision, queueRevision) {
1046
+ const columnOf = (runner) => tasks.filter(one => one.assignedRunner === runner);
1047
+ const projectChips = (rows) => {
1048
+ const repos = [...new Set(rows.map(one => one.repo).filter((one) => one !== null))];
1049
+ return joinHtml(repos.map(repo => html `<span class="badge">${repo.split("/").pop() ?? repo}</span>`), " ");
1050
+ };
1051
+ const shared = columnOf(null);
1052
+ const allWorkersBusy = workers.filter(one => !one.retired).length > 0 && workers.filter(one => !one.retired).every(one => columnOf(one.name).length > 0);
1053
+ const column = (title, key, head, rows, empty) => html `<section class="lane queue-column" data-column="${key}"><h2>${title}</h2>${head}<p class="meta">${projectChips(rows)}</p>${rows.length === 0
1054
+ ? html `<p class="meta lane-empty">${empty}</p>`
1055
+ : joinHtml(rows.map((one, index) => index === 0 && key === "anyone" && allWorkersBusy && !one.taken
1056
+ ? replaceMarkup(replaceMarkup(queueCard(one, revision, queueRevision, workers, key), /<\/p>\n/, () => html `</p>`), /<p class="row meta">/, () => html `<p class="meta">Every worker has reserved work — this waits until a column empties</p><p class="row meta">`)
1057
+ : queueCard(one, revision, queueRevision, workers, key)), "\n")}</section>`;
1058
+ const noteForm = (worker) => worker.retired
1059
+ ? html `<p class="meta">This worker is retired — drag these elsewhere, or register the name again</p>`
1060
+ : html `<p class="meta mono">${worker.building} building in this project · unattended capacity ${worker.capacity}</p>${postForm("/queue/note", html `<input type="text" name="note" value="${worker.note ?? ""}" data-initial="${worker.note ?? ""}" placeholder="what this worker is working through" aria-label="column note" maxlength="200"><button type="submit">Save</button>`, { attrs: { class: "row" }, projectRevision: revision, hidden: { runner: worker.name } })}<p class="meta">Takes from the shared queue when this column is empty</p>`;
1061
+ return html `<div class="lanes" data-queue-revision="${queueRevision}">${column("shared queue", "anyone", html `<p class="meta">Workers take from here when their column is empty — top card first</p>`, shared, "nothing waiting — every task is reserved or running")}${joinHtml(workers.map(worker => column(worker.name + (worker.retired ? " (retired)" : ""), worker.name, noteForm(worker), columnOf(worker.name), "nothing queued — this worker will take from the shared queue")), "\n")}</div>`;
1062
+ }
1063
+ /**
1064
+ * The fleet screen — one lane per runner, and the work in front of it.
1065
+ * Building claims pin to the top of their worker's lane (live — never
1066
+ * draggable); queued reservations sit below it (draggable to another
1067
+ * worker, re-reserving them). Every card wears its project chip, which is
1068
+ * the whole point: which agent is on which project is the page's answer.
1069
+ * Form-free like the board, except the per-worker note, so the lane stack
1070
+ * can re-render itself while somebody watches.
1071
+ */
1072
+ export function fleetBody(queued, building, runners, queueRevision, visibleRepo) {
1073
+ const chip = (repo) => repo === null ? null : html ` <span class="badge">${projectName(repo)}</span>`;
1074
+ const queuedCardOf = (one) => html `<div class="lane-card queue-card" data-task="${one.id}" data-taken="${one.taken ? "1" : "0"}"><p class="row">${one.taken ? null : GRIP_HANDLE}<a href="${taskHref(one.id)}">${one.title}</a></p><p class="row meta"><span class="mono">${one.id}</span>${chip(one.repo)}${one.approved ? null : html ` <span class="badge">Unapproved scope</span>`}${one.blockers > 0 ? html ` <span class="badge">Waits for ${one.blockers}</span>` : null}${one.taken ? html ` <span class="badge">Being taken</span>` : null}</p></div>`;
1075
+ const lanes = runners.map(runner => {
1076
+ const own = queued.filter(one => one.assignedRunner === runner.name && visibleRepo(one.repo));
1077
+ const live = building.filter(one => one.runner === runner.name);
1078
+ const retired = runner.retiredAt !== null;
1079
+ const head = retired
1080
+ ? html `<p class="meta">This worker is retired — drag these elsewhere, or register the name again</p>`
1081
+ : html `${postForm("/queue/note", html `<input type="text" name="note" class="runner-note" value="${runner.queueNote ?? ""}" data-initial="${runner.queueNote ?? ""}" placeholder="what this worker is working through" aria-label="column note" maxlength="200">`, { attrs: { class: "row" }, hidden: { from: "fleet", runner: runner.name } })}<p class="meta">${runnerAlive(runner, new Date()) ? "alive" : "quiet"} · ${live.length}/${runner.capacity} building</p>`;
1082
+ const buildingCards = joinHtml(live.map(claim => html `<div class="lane-card" data-taken="1"><p class="row"><span class="dot dot-ok pulse"></span> ${claim.taskId}</p><p class="row meta">building${chip(claim.repo ?? null)}${claim.model === null ? null : html ` · ${claim.model}`} · ${Math.max(1, Math.round((Date.now() - new Date(claim.claimedAt).getTime()) / 60_000))}m</p></div>`), "\n");
1083
+ const queuedCards = joinHtml(own.map(queuedCardOf), "\n");
1084
+ const empty = live.length === 0 && own.length === 0
1085
+ ? html `<p class="meta lane-empty">${retired ? "nothing left" : "idle — will take from the shared queue"}</p>`
1086
+ : null;
1087
+ return html `<section class="lane queue-column${live.length > 0 ? " lane-live" : ""}" data-column="${runner.name}"><h2>${runner.name}${retired ? " (retired)" : ""}</h2>${head}${buildingCards}${queuedCards}${empty}</section>`;
1088
+ });
1089
+ // The shared queue: anything reserved for nobody.
1090
+ const shared = queued.filter(one => one.assignedRunner === null && visibleRepo(one.repo));
1091
+ const sharedCards = joinHtml(shared.map(queuedCardOf), "\n");
1092
+ const sharedLane = html `<section class="lane queue-column" data-column="anyone"><h2>Shared queue</h2><p class="meta">Any free worker takes from here, top first</p>${sharedCards}${shared.length === 0 ? html `<p class="meta lane-empty">nothing waiting — every task is reserved or running</p>` : null}</section>`;
1093
+ return html `<div class="lanes" data-queue-revision="${queueRevision}">${sharedLane}${joinHtml(lanes, "\n")}</div>`;
1094
+ }
1095
+ /** Identical drag mechanics to the queue — the pointer events land on the worker's column. */
1096
+ export function fleetScript() {
1097
+ return (`(function(){var region=document.getElementById("fleet-region");if(!region)return;` +
1098
+ `var stamp=document.getElementById("fleet-region-stamp");var dragging=null;` +
1099
+ `function dirty(){if(region.contains(document.activeElement)&&document.activeElement!==document.body)return true;` +
1100
+ `var inputs=region.querySelectorAll("input[type=text]");for(var i=0;i<inputs.length;i++){` +
1101
+ `if(inputs[i].value!==(inputs[i].getAttribute("data-initial")||""))return true;}` +
1102
+ `return false;}` +
1103
+ `function paused(){return dragging!==null||dirty();}` +
1104
+ `var wait=12000;var last=Date.now();var busy=false;` +
1105
+ `function tell(){if(!stamp)return;if(paused()){stamp.textContent="paused while you edit";return;}` +
1106
+ `stamp.textContent="updated "+Math.round((Date.now()-last)/1000)+"s ago";}setInterval(tell,1000);` +
1107
+ `function cycle(){if(document.hidden||busy||paused()){setTimeout(cycle,wait);return;}busy=true;` +
1108
+ `fetch("/fleet?fragment=1",{redirect:"manual",cache:"no-store"})` +
1109
+ `.then(function(r){if(r.type==="opaqueredirect"||r.status===401||r.status===403){location.href="/login";return null;}` +
1110
+ `return r.ok?r.text():null;})` +
1111
+ `.then(function(t){if(t&&!paused()){region.innerHTML=t;last=Date.now();}})` +
1112
+ `.catch(function(){})` +
1113
+ `.then(function(){busy=false;tell();setTimeout(cycle,wait);});}setTimeout(cycle,wait);` +
1114
+ `region.addEventListener("pointerdown",function(e){var handle=e.target.closest(".queue-handle");if(!handle)return;` +
1115
+ `var card=handle.closest(".queue-card");if(!card||card.getAttribute("data-taken")==="1")return;` +
1116
+ `e.preventDefault();dragging={task:card.getAttribute("data-task"),card:card};card.style.opacity="0.5";});` +
1117
+ `region.addEventListener("pointermove",function(e){if(!dragging)return;e.preventDefault();` +
1118
+ `var over=document.elementFromPoint(e.clientX,e.clientY);if(!over)return;` +
1119
+ `var target=over.closest(".queue-card");var lane=over.closest(".queue-column");` +
1120
+ `region.querySelectorAll(".queue-card,.queue-column").forEach(function(n){n.style.outline="";});` +
1121
+ `if(target&&target!==dragging.card){target.style.outline="2px solid currentColor";}` +
1122
+ `else if(lane){lane.style.outline="2px dashed currentColor";}});` +
1123
+ `region.addEventListener("pointerup",function(e){if(!dragging)return;var drag=dragging;dragging=null;` +
1124
+ `drag.card.style.opacity="";region.querySelectorAll(".queue-card,.queue-column").forEach(function(n){n.style.outline="";});` +
1125
+ `var over=document.elementFromPoint(e.clientX,e.clientY);if(!over){tell();return;}` +
1126
+ `var target=over.closest(".queue-card");var lane=over.closest(".queue-column");if(!lane){tell();return;}` +
1127
+ `var column=lane.getAttribute("data-column");var before=target&&target!==drag.card?target.getAttribute("data-task"):"";` +
1128
+ `if(target&&target.getAttribute("data-taken")==="1"){before="";}` +
1129
+ `var wrap=region.querySelector("[data-queue-revision]");` +
1130
+ `var fields={respond:"fragment",csrf:(region.querySelector("input[name=csrf]")||{value:""}).value,` +
1131
+ `queueRevision:wrap?wrap.getAttribute("data-queue-revision"):"",task:drag.task,column:column,before:before};` +
1132
+ `var post=new URLSearchParams();Object.keys(fields).forEach(function(k){post.append(k,fields[k]);});` +
1133
+ `fetch("/queue/move",{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded"},body:post.toString(),redirect:"manual"})` +
1134
+ `.then(function(r){return r.ok?fetch("/fleet?fragment=1",{cache:"no-store"}).then(function(f){return f.ok?f.text():null;}):null;})` +
1135
+ `.then(function(t){if(t&&!paused()){region.innerHTML=t;last=Date.now();}else if(t===null){location.href="/fleet";}})` +
1136
+ `.catch(function(){})` +
1137
+ `.then(function(){tell();});});` +
1138
+ `})();`);
1139
+ }
1140
+ /** The /queue page's one nonce'd script: delegated pointer-event drag (it
1141
+ * survives every fragment swap — finding 17) plus a poller that re-checks
1142
+ * focus, dirty inputs, and an in-flight drag AT SWAP TIME, never only
1143
+ * before the fetch. Select dirtiness compares against data-initial
1144
+ * (finding 18); missing that, a select counts clean.
1145
+ */
1146
+ export function queueScript() {
1147
+ return (`(function(){var region=document.getElementById("queue-region");if(!region)return;` +
1148
+ `var stamp=document.getElementById("queue-region-stamp");var dragging=null;var ghost=null;` +
1149
+ `function dirty(){if(region.contains(document.activeElement)&&document.activeElement!==document.body)return true;` +
1150
+ `var inputs=region.querySelectorAll("input[type=text]");for(var i=0;i<inputs.length;i++){` +
1151
+ `if(inputs[i].value!==(inputs[i].getAttribute("data-initial")||""))return true;}` +
1152
+ `var selects=region.querySelectorAll("select");for(var j=0;j<selects.length;j++){` +
1153
+ `var base=selects[j].getAttribute("data-initial");if(base!==null&&selects[j].value!==base)return true;}` +
1154
+ `return false;}` +
1155
+ `function paused(){return dragging!==null||dirty();}` +
1156
+ // the poller: pause is re-checked at SWAP time
1157
+ `var wait=15000;var last=Date.now();var busy=false;` +
1158
+ `function tell(){if(!stamp)return;if(paused()){stamp.textContent="paused while you edit";return;}` +
1159
+ `stamp.textContent="updated "+Math.round((Date.now()-last)/1000)+"s ago";}setInterval(tell,1000);` +
1160
+ `function cycle(){if(document.hidden||busy||paused()){setTimeout(cycle,wait);return;}busy=true;` +
1161
+ `fetch("/queue?fragment=1",{redirect:"manual",cache:"no-store"})` +
1162
+ `.then(function(r){if(r.type==="opaqueredirect"||r.status===401||r.status===403){location.href="/login";return null;}` +
1163
+ `return r.ok?r.text():null;})` +
1164
+ `.then(function(t){if(t&&!paused()){region.innerHTML=t;last=Date.now();}})` +
1165
+ `.catch(function(){})` +
1166
+ `.then(function(){busy=false;tell();setTimeout(cycle,wait);});}setTimeout(cycle,wait);` +
1167
+ // the drag: delegated from the stable region element
1168
+ `region.addEventListener("pointerdown",function(e){var handle=e.target.closest(".queue-handle");if(!handle)return;` +
1169
+ `var card=handle.closest(".queue-card");if(!card||card.getAttribute("data-taken")==="1")return;` +
1170
+ `e.preventDefault();dragging={task:card.getAttribute("data-task"),card:card};card.style.opacity="0.5";});` +
1171
+ `region.addEventListener("pointermove",function(e){if(!dragging)return;e.preventDefault();` +
1172
+ `var over=document.elementFromPoint(e.clientX,e.clientY);if(!over)return;` +
1173
+ `var target=over.closest(".queue-card");var lane=over.closest(".queue-column");` +
1174
+ `region.querySelectorAll(".queue-card,.queue-column").forEach(function(n){n.style.outline="";});` +
1175
+ `if(target&&target!==dragging.card){target.style.outline="2px solid currentColor";}` +
1176
+ `else if(lane){lane.style.outline="2px dashed currentColor";}});` +
1177
+ `region.addEventListener("pointerup",function(e){if(!dragging)return;var drag=dragging;dragging=null;` +
1178
+ `drag.card.style.opacity="";region.querySelectorAll(".queue-card,.queue-column").forEach(function(n){n.style.outline="";});` +
1179
+ `var over=document.elementFromPoint(e.clientX,e.clientY);if(!over){tell();return;}` +
1180
+ `var target=over.closest(".queue-card");var lane=over.closest(".queue-column");if(!lane){tell();return;}` +
1181
+ `var column=lane.getAttribute("data-column");var before=target&&target!==drag.card?target.getAttribute("data-task"):"";` +
1182
+ `if(target&&target.getAttribute("data-taken")==="1"){before="";}` +
1183
+ `var wrap=region.querySelector("[data-queue-revision]");` +
1184
+ `var fields={respond:"fragment",csrf:(region.querySelector("input[name=csrf]")||{value:""}).value,projectRevision:(region.querySelector("input[name=projectRevision]")||{value:""}).value,` +
1185
+ `queueRevision:wrap?wrap.getAttribute("data-queue-revision"):"",task:drag.task,column:column,before:before};` +
1186
+ `var post=new URLSearchParams();Object.keys(fields).forEach(function(k){if(fields[k]!==""||k==="respond")post.append(k,fields[k]);});` +
1187
+ // A refused move (slice 1b, fix 2): the handler's typed text/plain 409
1188
+ // lands on the card as a problem row — textContent, never markup. Only
1189
+ // an authenticated plain-text 409 is inlined; an HTML refusal (the
1190
+ // stale-project page), a login bounce, or anything unexpected navigates.
1191
+ `function problem(task,text){var card=region.querySelector('.queue-card[data-task="'+task.replace(/["\\\\]/g,"")+'"]');if(!card)return false;` +
1192
+ `var old=card.querySelector(".queue-problem");if(old)old.remove();` +
1193
+ `var row=document.createElement("p");row.className="problem queue-problem";row.setAttribute("role","alert");row.textContent=text;card.appendChild(row);return true;}` +
1194
+ `fetch("/queue/move",{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded"},body:post.toString(),redirect:"manual"})` +
1195
+ `.then(function(r){if(r.ok)return fetch("/queue?fragment=1",{redirect:"manual",cache:"no-store"}).then(function(f){` +
1196
+ `if(f.type==="opaqueredirect"||f.status===401||f.status===403){location.href="/login";return false;}return f.ok?f.text():null;});` +
1197
+ `if(r.type==="opaqueredirect"||r.status===401||r.status===403){location.href="/login";return false;}` +
1198
+ `var kind=(r.headers&&r.headers.get?r.headers.get("content-type"):"")||"";` +
1199
+ `if(r.status===409&&kind.indexOf("text/plain")===0){return r.text().then(function(text){return problem(drag.task,text)?false:null;});}` +
1200
+ `return null;})` +
1201
+ `.then(function(t){if(t===false)return;if(t&&!paused()){region.innerHTML=t;last=Date.now();}else if(t===null){location.href="/board?view=order";}})` +
1202
+ `.catch(function(){})` +
1203
+ `.then(function(){tell();});});` +
1204
+ `})();`);
1205
+ }
1206
+ export function newTaskPage(chrome, project, projectRevision, problem, candidates = [], permissionDefault = "auto", qualityDefault = "default", projects = []) {
1207
+ return screen("New task", joinHtml([
1208
+ html `<section class="task-intake">`,
1209
+ html `<div class="task-intake-hero"><h1>What should get done?</h1></div>`,
1210
+ html `<p class="meta">Work you do often? <a href="/recipes">Use a saved recipe</a> or <a href="/recipes/new">create one</a>.</p>`,
1211
+ problem === null ? null : html `<div class="problem">${problem}</div>`,
1212
+ taskComposerHtml({
1213
+ project,
1214
+ projectRevision,
1215
+ candidates,
1216
+ permissionDefault,
1217
+ qualityDefault,
1218
+ projects,
1219
+ }),
1220
+ html `<p class="meta task-agent-note">Nothing builds until you approve the plan. <a href="/chat">Or start in chat.</a></p>`,
1221
+ html `</section>`,
1222
+ ], "\n"), { chrome });
1223
+ }
1224
+ /**
1225
+ * The triage flow: everything waiting on a person, one card at a time.
1226
+ * Full context ON the card, the act inline, and every act lands back here
1227
+ * — clearing the queue is taps, not navigation. Read state travels in the
1228
+ * URL (the bounded skip cursor), never in the session: two tabs cannot
1229
+ * fight, and a shared link shows the same queue.
1230
+ */
1231
+ export function nextPage(chrome, data) {
1232
+ const { item } = data;
1233
+ if (item === null) {
1234
+ const held = data.skipped.length;
1235
+ return screen("next", joinHtml([
1236
+ html `<h1>All clear</h1>`,
1237
+ held > 0
1238
+ ? html `<p>Nothing left except the ${held} you set aside. <a href="/next">Look at those again</a>, or come back later.</p>`
1239
+ : html `<p>Nothing needs you. The machine is either working or waiting on its own clocks.</p>`,
1240
+ html `<p class="meta"><a href="/board">the board</a> shows what is moving · <a href="/inbox">the inbox</a> lists everything at once</p>`,
1241
+ ], "\n"), { chrome });
1242
+ }
1243
+ const skipHref = `/next?skip=${encodeURIComponent([...data.skipped, item.key].join(","))}`;
1244
+ const header = html `<p class="meta next-pager"><span>${data.remaining === 1 ? "the last thing waiting on you" : `1 of ${data.remaining} waiting on you`}</span><a class="skip" href="${skipHref}">not now — next →</a></p>`;
1245
+ let card;
1246
+ if (item.kind === "decision") {
1247
+ const { decision } = item;
1248
+ card =
1249
+ html `<h1>${decision.taskId} <span class="meta">asked ${whenTime(decision.createdAt)}</span></h1><div class="recap">${decision.recap}</div><div class="question">${decision.question}</div>${decisionOptionForms(decision, "next")}`;
1250
+ }
1251
+ else if (item.kind === "approval" && !consentDoorOf(data.scope, data.route).open) {
1252
+ const door = consentDoorOf(data.scope, data.route);
1253
+ card =
1254
+ html `<h1>${item.approval.taskId}</h1><p>${item.approval.title}</p>${consentClosedHtml(item.approval.taskId, door, "next")}<p class="meta"><a href="${taskHref(item.approval.taskId)}">open the full task</a></p>`;
1255
+ }
1256
+ else if (item.kind === "approval") {
1257
+ const scope = data.scope;
1258
+ card =
1259
+ html `<h1>${item.approval.taskId}</h1><p>${item.approval.title}</p>${data.planDocument === null
1260
+ ? null
1261
+ : html `<div class="card"><p><strong>The plan</strong> <span class="meta">drafted by a planning session</span></p>${executionPlanHtml(data.planDocument, true)}${planContractHtml(data.planContract ?? null, "ceremony")}</div>`}${postForm(`${taskHref(item.approval.taskId)}/approve`, html `<p><strong>Approve exactly this:</strong></p>${data.deliverable === "report" ? html `<p class="meta"><span class="badge">Scout</span> a read-only session investigates this goal and delivers a report — no branch, nothing changes in the repository</p>` : null}${scope === null || scope.profileState !== "unresolved" ? null : profileWords(scope)}<p class="meta">Goal</p><p class="recap" style="margin-top:0">${scope?.goal ?? item.approval.goal}</p><p class="meta">Not this</p><p class="recap" style="margin-top:0">${scope?.outOfScope == null ? html `<em>no exclusions</em>` : scope.outOfScope}</p><p class="meta">Touches · ${scope === null || scope.touches.length === 0 ? "anything" : joinHtml(scope.touches, ", ")}</p>${scope === null ? null : acceptanceCeremonyHtml(scope.acceptance)}${
1262
+ // The AGENTS the yes freezes (v48): the same concise block the task
1263
+ // page and chat sign under, before the password — runtime limits one
1264
+ // tap away, never in the way.
1265
+ agentsCeremonyHtml(data.route)}${scope === null ? null : runtimeDetailsHtml(scope)}<label>Your password, typed again — a signed-in session alone cannot agree to work<input type="password" name="token" autocomplete="current-password"></label><div class="sticky-actions"><button type="submit">Approve this scope</button></div>`, { attrs: { class: "card approve-form" }, returnTo: "next", hidden: { nonce: data.nonce, digest: data.approvalDigest ?? item.approval.digest } })}<p class="meta"><a href="${taskHref(item.approval.taskId)}">open the full task</a> to edit the scope first</p>`;
1266
+ }
1267
+ else if (item.kind === "requeue") {
1268
+ card =
1269
+ html `<h1>${item.stalled.taskId}</h1><p>${item.stalled.title}</p><p class="meta">Stopped — ${item.stalled.incidentCount} incident(s)${item.stalled.strikes > 0 ? html ` after ${item.stalled.strikes} attempt(s)` : null}</p>${postForm(`${taskHref(item.stalled.taskId)}/requeue`, html `<p class="meta">Requeue resolves the incidents, clears the failed attempts, and puts it back in line</p><button type="submit">Retry this work</button>`, { attrs: { class: "card" }, returnTo: "next" })}<p class="meta"><a href="${taskHref(item.stalled.taskId)}">open the full task</a> to read the runs first</p>`;
1270
+ }
1271
+ else {
1272
+ const { gap } = item;
1273
+ card =
1274
+ html `<h1>Supply ${gap.key}</h1><p class="meta">${gap.state}</p><p>Filling this starts ${gap.unblocks.length} task(s): ${joinHtml(gap.unblocks.map(one => html `<span class="mono">${one}</span>`), ", ")}</p><div class="card"><p class="meta">Prove it filled from the terminal:</p><pre class="recap">${gap.verify}</pre></div>`;
1275
+ }
1276
+ return screen("next", joinHtml([header, card], "\n"), { chrome });
1277
+ }