toolroll 0.9.53 → 0.9.55

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 (254) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +5 -4
  3. package/dist/agent-onboard.js +4 -1
  4. package/dist/approval-rules-ui.d.ts +3 -2
  5. package/dist/approval-rules-ui.js +15 -20
  6. package/dist/assignment-ui.d.ts +3 -2
  7. package/dist/assignment-ui.js +4 -11
  8. package/dist/backup-ui.d.ts +2 -1
  9. package/dist/backup-ui.js +16 -27
  10. package/dist/brand-mark.d.ts +3 -2
  11. package/dist/brand-mark.js +4 -4
  12. package/dist/browser/workspace.css +1 -1
  13. package/dist/browser/workspace.js +25 -25
  14. package/dist/browser-shell.d.ts +2 -1
  15. package/dist/browser-shell.js +9 -9
  16. package/dist/browser-workspace.d.ts +2 -3
  17. package/dist/browser-workspace.js +0 -3
  18. package/dist/chat-ask.js +8 -8
  19. package/dist/chat-core.d.ts +171 -0
  20. package/dist/chat-core.js +288 -0
  21. package/dist/chat-decide.d.ts +10 -8
  22. package/dist/chat-decide.js +26 -21
  23. package/dist/chat-delivery-state.d.ts +51 -75
  24. package/dist/chat-delivery-state.js +140 -170
  25. package/dist/chat-delivery.d.ts +21 -2
  26. package/dist/chat-delivery.js +313 -194
  27. package/dist/chat-flow.d.ts +29 -1
  28. package/dist/chat-flow.js +24 -50
  29. package/dist/chat-messages.d.ts +319 -0
  30. package/dist/chat-messages.js +841 -0
  31. package/dist/chat-migration.d.ts +30 -0
  32. package/dist/chat-migration.js +802 -0
  33. package/dist/chat-polish.d.ts +5 -4
  34. package/dist/chat-polish.js +9 -10
  35. package/dist/chat-quiet.d.ts +3 -0
  36. package/dist/chat-quiet.js +5 -0
  37. package/dist/check-levels-ui.d.ts +2 -1
  38. package/dist/check-levels-ui.js +26 -53
  39. package/dist/cli.d.ts +3 -1
  40. package/dist/cli.js +9 -3
  41. package/dist/coding-shipping-ui.d.ts +2 -1
  42. package/dist/coding-shipping-ui.js +6 -6
  43. package/dist/coding-ui.d.ts +3 -1
  44. package/dist/coding-ui.js +30 -29
  45. package/dist/contracts/chat-content.d.ts +71 -2
  46. package/dist/contracts/chat-content.js +22 -3
  47. package/dist/contracts/chat-tables.d.ts +11 -4
  48. package/dist/contracts/chat-tables.js +165 -87
  49. package/dist/contracts/cli.d.ts +1 -3
  50. package/dist/contracts/console-api.d.ts +1 -1
  51. package/dist/contracts/console-api.js +6 -5
  52. package/dist/contracts/discord-callback.js +2 -1
  53. package/dist/contracts/health.d.ts +1 -3
  54. package/dist/contracts/slack-callback.js +3 -2
  55. package/dist/contracts/store-json.d.ts +1 -5
  56. package/dist/contracts/store-json.js +1 -4
  57. package/dist/contracts/teams-callback.js +2 -1
  58. package/dist/control-ui.d.ts +5 -4
  59. package/dist/control-ui.js +9 -34
  60. package/dist/credentials-ui.d.ts +5 -4
  61. package/dist/credentials-ui.js +23 -24
  62. package/dist/demo-chat.d.ts +3 -3
  63. package/dist/demo-chat.js +29 -76
  64. package/dist/discord-chat.js +19 -38
  65. package/dist/discord-settings.d.ts +3 -2
  66. package/dist/discord-settings.js +21 -55
  67. package/dist/discord.js +4 -14
  68. package/dist/envelope.js +10 -0
  69. package/dist/evidence-pack.d.ts +2 -1
  70. package/dist/evidence-pack.js +45 -49
  71. package/dist/export-ui.d.ts +7 -2
  72. package/dist/export-ui.js +4 -10
  73. package/dist/export.js +4 -4
  74. package/dist/flow-gallery-ui.d.ts +4 -3
  75. package/dist/flow-gallery-ui.js +28 -43
  76. package/dist/flow-starters-ui.d.ts +2 -1
  77. package/dist/flow-starters-ui.js +12 -15
  78. package/dist/flow-triggers.d.ts +1 -0
  79. package/dist/flow-triggers.js +16 -14
  80. package/dist/flows-ui.d.ts +4 -3
  81. package/dist/flows-ui.js +19 -30
  82. package/dist/flows.js +2 -1
  83. package/dist/guides.js +108 -3
  84. package/dist/html.d.ts +76 -0
  85. package/dist/html.js +173 -0
  86. package/dist/integrations-ui.d.ts +2 -1
  87. package/dist/integrations-ui.js +19 -23
  88. package/dist/kits-ui.d.ts +5 -4
  89. package/dist/kits-ui.js +18 -20
  90. package/dist/knowledge-ui.d.ts +6 -5
  91. package/dist/knowledge-ui.js +26 -25
  92. package/dist/lead-context.d.ts +11 -1
  93. package/dist/lead-context.js +6 -8
  94. package/dist/ledger-view.d.ts +2 -1
  95. package/dist/ledger-view.js +27 -29
  96. package/dist/limits-ui.d.ts +2 -1
  97. package/dist/limits-ui.js +7 -7
  98. package/dist/live-bus.d.ts +50 -8
  99. package/dist/live-bus.js +133 -50
  100. package/dist/mate-tools.js +3 -2
  101. package/dist/mcp-oauth.js +35 -36
  102. package/dist/models-ui.d.ts +2 -1
  103. package/dist/models-ui.js +25 -35
  104. package/dist/modes.d.ts +13 -1
  105. package/dist/modes.js +17 -1
  106. package/dist/monitoring-ui.d.ts +4 -3
  107. package/dist/monitoring-ui.js +13 -26
  108. package/dist/notification-rows.d.ts +30 -0
  109. package/dist/notification-rows.js +102 -0
  110. package/dist/openrouter-models.d.ts +2 -1
  111. package/dist/openrouter-models.js +5 -9
  112. package/dist/operate.js +16 -9
  113. package/dist/people-audit-ui.d.ts +2 -1
  114. package/dist/people-audit-ui.js +13 -13
  115. package/dist/plane-review.js +5 -11
  116. package/dist/policy-ui.d.ts +3 -2
  117. package/dist/policy-ui.js +17 -23
  118. package/dist/project-delete-ui.d.ts +4 -3
  119. package/dist/project-delete-ui.js +15 -27
  120. package/dist/project-delete.js +21 -26
  121. package/dist/pull-request-ui.d.ts +2 -1
  122. package/dist/pull-request-ui.js +17 -32
  123. package/dist/recipe-ui.d.ts +8 -7
  124. package/dist/recipe-ui.js +39 -32
  125. package/dist/release-adapters.d.ts +60 -0
  126. package/dist/release-adapters.js +435 -0
  127. package/dist/release-cli.d.ts +24 -0
  128. package/dist/release-cli.js +181 -0
  129. package/dist/release-gate.d.ts +80 -0
  130. package/dist/release-gate.js +150 -0
  131. package/dist/release-links.d.ts +29 -0
  132. package/dist/release-links.js +102 -0
  133. package/dist/release.d.ts +331 -0
  134. package/dist/release.js +550 -0
  135. package/dist/reply-shape.d.ts +2 -1
  136. package/dist/reply-shape.js +5 -5
  137. package/dist/repository-context-ui.d.ts +2 -1
  138. package/dist/repository-context-ui.js +2 -7
  139. package/dist/request-budget-ui.d.ts +2 -1
  140. package/dist/request-budget-ui.js +16 -16
  141. package/dist/result-review.d.ts +2 -3
  142. package/dist/result-review.js +3 -9
  143. package/dist/result-shots.js +2 -2
  144. package/dist/retention-ui.d.ts +7 -1
  145. package/dist/retention-ui.js +7 -11
  146. package/dist/retention.js +1 -1
  147. package/dist/serve.d.ts +11 -2
  148. package/dist/serve.js +69 -58
  149. package/dist/server/chat.d.ts +3 -6
  150. package/dist/server/chat.js +969 -1006
  151. package/dist/server/chrome.d.ts +291 -0
  152. package/dist/server/chrome.js +747 -0
  153. package/dist/server/console.css +2819 -0
  154. package/dist/server/flows.d.ts +2 -6
  155. package/dist/server/flows.js +840 -766
  156. package/dist/server/guards.d.ts +1 -1
  157. package/dist/server/guards.js +5 -2
  158. package/dist/server/handler-context.d.ts +1 -1
  159. package/dist/server/handler-context.js +1 -1
  160. package/dist/server/handler-registry.d.ts +8 -6
  161. package/dist/server/handler-registry.js +14 -2
  162. package/dist/server/http.d.ts +274 -0
  163. package/dist/server/http.js +239 -0
  164. package/dist/server/live.d.ts +34 -0
  165. package/dist/server/live.js +309 -0
  166. package/dist/server/pages.d.ts +2 -6
  167. package/dist/server/pages.js +808 -789
  168. package/dist/server/people-tokens.d.ts +1 -6
  169. package/dist/server/people-tokens.js +580 -572
  170. package/dist/server/remote.d.ts +4 -7
  171. package/dist/server/remote.js +37 -70
  172. package/dist/server/render-chat.d.ts +402 -0
  173. package/dist/server/render-chat.js +927 -0
  174. package/dist/server/render-pages.d.ts +535 -0
  175. package/dist/server/render-pages.js +1277 -0
  176. package/dist/server/render-people.d.ts +32 -0
  177. package/dist/server/render-people.js +121 -0
  178. package/dist/server/render-results.d.ts +744 -0
  179. package/dist/server/render-results.js +2237 -0
  180. package/dist/server/render-settings.d.ts +147 -0
  181. package/dist/server/render-settings.js +577 -0
  182. package/dist/server/render-tasks.d.ts +632 -0
  183. package/dist/server/render-tasks.js +2345 -0
  184. package/dist/server/request-context.d.ts +46 -0
  185. package/dist/server/request-context.js +17 -0
  186. package/dist/server/route-table.d.ts +25 -3
  187. package/dist/server/route-table.js +163 -75
  188. package/dist/server/runtime.d.ts +15 -7
  189. package/dist/server/runtime.js +7 -1
  190. package/dist/server/session.d.ts +138 -0
  191. package/dist/server/session.js +94 -0
  192. package/dist/server/settings.d.ts +1 -5
  193. package/dist/server/settings.js +2182 -2117
  194. package/dist/server/task-views.d.ts +11 -4
  195. package/dist/server/task-views.js +26 -22
  196. package/dist/server/tasks.d.ts +9 -9
  197. package/dist/server/tasks.js +1486 -1418
  198. package/dist/server-telemetry.d.ts +4 -3
  199. package/dist/server-telemetry.js +4 -8
  200. package/dist/skills-ui.d.ts +4 -3
  201. package/dist/skills-ui.js +14 -17
  202. package/dist/skills.d.ts +1 -1
  203. package/dist/skills.js +2 -1
  204. package/dist/slack-api.d.ts +5 -1
  205. package/dist/slack-api.js +2 -2
  206. package/dist/slack-chat.d.ts +9 -4
  207. package/dist/slack-chat.js +29 -52
  208. package/dist/slack-settings.d.ts +3 -2
  209. package/dist/slack-settings.js +23 -57
  210. package/dist/slack.js +6 -16
  211. package/dist/spend-ui.d.ts +2 -1
  212. package/dist/spend-ui.js +9 -32
  213. package/dist/sso-ui.d.ts +3 -2
  214. package/dist/sso-ui.js +26 -27
  215. package/dist/storage-ui.d.ts +2 -1
  216. package/dist/storage-ui.js +14 -33
  217. package/dist/store.d.ts +97 -364
  218. package/dist/store.js +207 -1531
  219. package/dist/surface.js +14 -3
  220. package/dist/task-composer.d.ts +2 -1
  221. package/dist/task-composer.js +7 -7
  222. package/dist/task-status.d.ts +4 -3
  223. package/dist/task-status.js +15 -16
  224. package/dist/team-http.d.ts +5 -9
  225. package/dist/team-http.js +5 -63
  226. package/dist/team-ui.d.ts +2 -1
  227. package/dist/team-ui.js +2 -5
  228. package/dist/teammate-question.js +12 -12
  229. package/dist/teammates-ui.d.ts +5 -4
  230. package/dist/teammates-ui.js +61 -90
  231. package/dist/teams-chat.js +12 -9
  232. package/dist/teams-settings.d.ts +3 -2
  233. package/dist/teams-settings.js +22 -33
  234. package/dist/teams.js +4 -5
  235. package/dist/telegram-flow.js +5 -5
  236. package/dist/telegram-settings.d.ts +3 -2
  237. package/dist/telegram-settings.js +14 -25
  238. package/dist/telegram.js +4 -4
  239. package/dist/toolroll-update-ui.d.ts +3 -2
  240. package/dist/toolroll-update-ui.js +35 -39
  241. package/dist/toolroll-update.d.ts +10 -0
  242. package/dist/toolroll-update.js +50 -3
  243. package/dist/tools-ui.d.ts +5 -2
  244. package/dist/tools-ui.js +31 -31
  245. package/dist/when-html.d.ts +11 -3
  246. package/dist/when-html.js +5 -6
  247. package/dist/workspace-revision.js +3 -1
  248. package/dist/workspace-ui.d.ts +4 -2
  249. package/dist/workspace-ui.js +10 -10
  250. package/package.json +1 -1
  251. package/dist/server/shared.d.ts +0 -3151
  252. package/dist/server/shared.js +0 -12001
  253. package/dist/slack-state.d.ts +0 -13
  254. package/dist/slack-state.js +0 -10
@@ -0,0 +1,2345 @@
1
+ /** Task pages: approval, agents, plans, controls, decisions and the task body. */
2
+ import { html, htmlString, joinHtml, postForm, replaceMarkup } from "../html.js";
3
+ import {} from "../activity-line.js";
4
+ import {} from "../agentconfig.js";
5
+ import { gateWords } from "../approval-policy.js";
6
+ import { assignmentCardOf, assignmentStatusOf, assignmentSummaryHtml } from "../assignment-ui.js";
7
+ import {} from "../assignment.js";
8
+ import { holdOwnerWords } from "../board.js";
9
+ import {} from "../browser-workspace.js";
10
+ import { permissionPlainWords } from "../chat-decide.js";
11
+ import { CHAT_TASK_ACTIONS } from "../chat-task-actions.js";
12
+ import {} from "../contracts/console-api.js";
13
+ import {} from "../dispatch.js";
14
+ import {} from "../evidence.js";
15
+ import { firstTaskJourney } from "../first-run.js";
16
+ import { starterForWork } from "../flow-starters.js";
17
+ import {} from "../gaps.js";
18
+ import { readAuthModeStrict } from "../keys.js";
19
+ import { failedAttemptSentence, retryNoteOf } from "../needs-you.js";
20
+ import { agentsSummary, chosenWords, makesNoPlan, postureWords, projectRoute, PHASES as ROUTE_PHASES, TASK_SIZES } from "../phase-routing.js";
21
+ import { parseExecutionPlanDocument } from "../plan.js";
22
+ import {} from "../planner-source.js";
23
+ import { projectName } from "../project.js";
24
+ import { dispatchStatusToken, manualReviewOnly, plainReasonWords, reviewConflict } from "../proof.js";
25
+ import { providerName } from "../provider-auth.js";
26
+ import { PROVIDER_IDS } from "../provider.js";
27
+ import { qualityModeTitle } from "../quality.js";
28
+ import {} from "../result-evidence-readers.js";
29
+ import { evidenceShortenedWords } from "../result-review.js";
30
+ import { acceptanceToLines, approvalOf, chainFromJson, scopeAuthorityOf } from "../scope.js";
31
+ import {} from "../store.js";
32
+ import { runCostWords } from "../summary.js";
33
+ import {} from "../task-control.js";
34
+ import { assignmentStageOf, demoChecksOf, stageOfCode, stageOfDispatch, STATUS_MORE, taskStatusOf } from "../task-status.js";
35
+ import { buildProgressOf, dispatchActionLabel, earlierAttemptsWords, receiptPublicationWords, resultStatusOf, REVIEW_TOKENS, workStatusOf } from "../workspace-ui.js";
36
+ import { createHash } from "node:crypto";
37
+ import { isOverdue, oneLineOf, projectChip, regionScript, safePrUrl, screen, SENSITIVE_INPUT, sentenceCase, shortDigest, transcriptScript, whenTime } from "./chrome.js";
38
+ import { reviewHref, taskChatHref, taskHref } from "./http.js";
39
+ import { coordinatorProposalsSection } from "./render-chat.js";
40
+ import { completionReceiptCard, criterionMatrixHtml, evidenceWords, homePhaseWords, incidentWords, needsCheck, pullRequestCardHtml, reasonWords, receiptStatusOf, repairChainHtml, reportItemsHtml, reviewRetryPanel, runNoun, runOutcomeBadge, semanticCoverageHtml, terminalDiffCard, verificationExplanation, verificationRecovered } from "./render-results.js";
41
+ import {} from "./session.js";
42
+ export function contractChangeHtml(change) {
43
+ const tag = html `<span class="contract-change-kind contract-change-${change.kind}">${change.kind}</span>`;
44
+ switch (change.field) {
45
+ case "goal":
46
+ return html `<li>${tag} <strong>goal</strong><div class="contract-before">was: ${change.before}</div><div class="contract-after">now: ${change.after}</div></li>`;
47
+ case "outOfScope":
48
+ return html `<li>${tag} <strong>not this</strong>${change.before === null ? "" : html `<div class="contract-before">was: ${change.before}</div>`}${change.after === null ? html `<div class="contract-after">now: <em>no exclusions</em></div>` : html `<div class="contract-after">now: ${change.after}</div>`}</li>`;
49
+ case "touches":
50
+ return html `<li>${tag} <strong>touches</strong> <span class="mono">${change.path}</span></li>`;
51
+ case "acceptance": {
52
+ const criterion = (one) => one === null ? "" : html `${one.statement} <span class="meta">[requires: ${one.evidence.join(", ")}]</span>${one.how === null ? "" : html `<div class="meta">how: ${one.how}</div>`}`;
53
+ return html `<li>${tag} <strong>criterion <code>${change.id}</code></strong>${change.kind === "changed" ? html ` <span class="meta">(${change.moved.join(", ")})</span>` : ""}${change.before === null ? "" : html `<div class="contract-before">${change.kind === "removed" ? "removed: " : "was: "}${criterion(change.before)}</div>`}${change.after === null ? "" : html `<div class="contract-after">${change.kind === "added" ? "added: " : "now: "}${criterion(change.after)}</div>`}</li>`;
54
+ }
55
+ }
56
+ }
57
+ /**
58
+ * The contract panel (contract handoff, task 1): whether the drafted plan
59
+ * reproduced the filed goal, exclusions, touches, and rubric exactly, or
60
+ * proposes an amendment — every addition, change, and removal listed, the
61
+ * planner's stated reason beside them, and the plain consequence that
62
+ * approving binds the AMENDED terms. "full" is the task page's card;
63
+ * "ceremony" is the restatement inside the approval form and /next.
64
+ */
65
+ export function planContractHtml(view, mode) {
66
+ if (view === null) {
67
+ return mode === "full" ? html `` : html `<p class="meta contract-note">no contract record for this draft — compare the scope above against what you filed before signing</p>`;
68
+ }
69
+ if ("problem" in view) {
70
+ return html `<div class="contract-panel contract-problem"><p class="approval-label">filed contract</p><p class="meta">${view.problem} · <a href="/r/${view.run}">run ${view.run}</a></p></div>`;
71
+ }
72
+ const stale = view.current ? "" : html `<p class="meta">The scope was edited after this draft landed — the record below describes the draft as the planner proposed it</p>`;
73
+ if (!view.filed) {
74
+ return mode === "full"
75
+ ? html `<div class="contract-panel contract-drafted"><p class="approval-label">filed contract</p><p class="meta">No scope was filed before planning — the planner drafted this contract from the title and the repository; review every term as new</p>${stale}</div>`
76
+ : html `<p class="meta contract-note">no scope was filed before planning — every term above is the planner's proposal</p>`;
77
+ }
78
+ if (view.revision === true) {
79
+ // A send-back: the terms were copied from the previous version, and the planner updated them with the notes.
80
+ if (view.changes.length === 0) {
81
+ return html `<div class="contract-panel contract-preserved"><p class="approval-label">updated plan</p><p><strong>Same terms as before</strong> <span class="meta">your notes fit the previous plan · <a href="/r/${view.run}">run ${view.run}</a></span></p>${stale}</div>`;
82
+ }
83
+ return joinHtml([
84
+ html `<div class="contract-panel contract-amended"${mode === "full" ? html ` id="contract-amendment"` : ""}><p class="approval-label">updated plan</p>`,
85
+ html `<p><strong>${view.changes.length} change${view.changes.length === 1 ? "" : "s"} from your notes</strong> <span class="meta">— approving accepts the updated terms ${mode === "full" ? "in the scope" : "above"} · <a href="/r/${view.run}">run ${view.run}</a></span></p>`,
86
+ view.amendment === null ? "" : html `<p class="recap contract-reason"><strong>Why:</strong> ${view.amendment}</p>`,
87
+ html `<ul class="recap contract-changes">${view.changes.map(contractChangeHtml)}</ul>`,
88
+ stale,
89
+ html `</div>`,
90
+ ]);
91
+ }
92
+ if (view.changes.length === 0) {
93
+ return html `<div class="contract-panel contract-preserved"><p class="approval-label">filed contract</p><p><strong>Preserved exactly</strong> <span class="meta">the plan reproduces the filed goal, exclusions, touches, and acceptance criteria — approving binds the terms you filed · <a href="/r/${view.run}">run ${view.run}</a></span></p>${stale}</div>`;
94
+ }
95
+ return joinHtml([
96
+ html `<div class="contract-panel contract-amended"${mode === "full" ? html ` id="contract-amendment"` : ""}><p class="approval-label">filed contract · amendment proposed</p>`,
97
+ html `<p><strong>${view.changes.length} change${view.changes.length === 1 ? "" : "s"} to what you filed</strong> <span class="meta">— approving binds the AMENDED terms shown ${mode === "full" ? "in the scope" : "above"}, not the ones you filed · <a href="/r/${view.run}">run ${view.run}</a></span></p>`,
98
+ view.amendment === null
99
+ ? html `<p class="meta">The planner stated no reason for the amendment</p>`
100
+ : html `<p class="recap contract-reason"><strong>Why:</strong> ${view.amendment}</p>`,
101
+ html `<ul class="recap contract-changes">${view.changes.map(contractChangeHtml)}</ul>`,
102
+ stale,
103
+ html `</div>`,
104
+ ]);
105
+ }
106
+ /** The exact path limits, one per line (UI polish 2026-09-13): a long
107
+ * comma run was the least readable term on a phone. Every path, verbatim. */
108
+ export function approvalPathsHtml(touches) {
109
+ if (touches.length === 0)
110
+ return html `<p>anything</p>`;
111
+ return html `<ul class="approval-paths">${touches.map(one => html `<li><span class="mono">${one}</span></li>`)}</ul>`;
112
+ }
113
+ /** The rubric, restated above the seal (v39) — the same claim the digest
114
+ * line already makes ("approval binds to this exact wording") extended to
115
+ * the acceptance terms: an id in Geist Mono (a machine fact the proof must
116
+ * answer by), a statement in Geist, the signed evidence kinds after
117
+ * it. `how` never renders here — it is advisory, never signed. Empty
118
+ * renders nothing: a grandfathered scope's ceremony is unchanged. */
119
+ export function acceptanceCeremonyHtml(criteria) {
120
+ if (criteria.length === 0)
121
+ return html ``;
122
+ return html `<p class="meta">Acceptance</p><ul class="recap acceptance-rubric">${criteria.map(c => html `<li><code>${c.id}</code> ${c.statement} <span class="meta">[requires: ${c.evidence.join(", ")}]</span></li>`)}</ul>`;
123
+ }
124
+ /** "claude" + "opus" → "Claude Opus"; "claude-opus-5-5" → "Claude Opus 5.5"; anything else keeps its exact id. */
125
+ export function agentNameWords(provider, model) {
126
+ const name = providerName(provider);
127
+ if (model === null || model === "" || model === "default")
128
+ return name;
129
+ const bare = model.startsWith(`${provider}-`) ? model.slice(provider.length + 1) : model;
130
+ const title = (word) => `${word[0].toUpperCase()}${word.slice(1)}`;
131
+ if (/^[a-z]+$/.test(bare))
132
+ return `${name} ${title(bare)}`;
133
+ const versioned = /^([a-z]+)-(\d+(?:-\d+)*)$/.exec(bare);
134
+ if (versioned !== null)
135
+ return `${name} ${title(versioned[1])} ${versioned[2].replace(/-/g, ".")}`;
136
+ return `${name} ${model}`;
137
+ }
138
+ /** The permission a sealed profile grants, in two or three words. */
139
+ export function permissionWordsOf(profile) {
140
+ if (profile === null || profile === undefined)
141
+ return null;
142
+ return profile.provider === "claude"
143
+ ? profile.permissionArgv === "bypassPermissions" ? "Full access" : "Auto permissions"
144
+ : profile.provider === "gemini"
145
+ ? profile.approvalArgv === "yolo" ? "Full access" : "Auto permissions"
146
+ : profile.sandboxMode === "danger-full-access" ? "Full access" : "Workspace sandbox";
147
+ }
148
+ /** One plain sentence for a revision (approval critique, Oct 2):
149
+ * "Fixes what build #9 missed: <criterion>." The notes, paths and lineage
150
+ * stay in the Details fold. */
151
+ export function revisionSentence(revision, acceptance) {
152
+ const build = `build #${revision.sourceRun}`;
153
+ if (revision.kind === "ci-repair")
154
+ return `Fixes the checks that failed in ${build}.`;
155
+ const clean = (text) => oneLineOf(text, 280).replace(/[.\s]+$/, "");
156
+ if (revision.kind === "criterion-repair") {
157
+ const ids = revision.comments.flatMap(one => (/Unmet: ([^.]+)\./.exec(one.note)?.[1] ?? "").split(",").map(id => id.trim()).filter(id => id !== ""));
158
+ const missed = ids.map(id => acceptance.find(one => one.id === id)?.statement ?? null).filter((one) => one !== null);
159
+ if (missed.length > 0)
160
+ return `Fixes what ${build} missed: ${missed.map(clean).join("; ")}.`;
161
+ }
162
+ const notes = revision.comments.map(one => clean(one.note)).filter(one => one !== "");
163
+ return notes.length === 0 ? `Revises ${build}.` : `Fixes what ${build} missed: ${notes.join("; ")}.`;
164
+ }
165
+ /** A planner's amendment, said plainly and kept in view: approving binds
166
+ * the amended terms, so this is never folded away. */
167
+ export function approvalAmendmentHtml(view) {
168
+ if (view === null || view === undefined || "problem" in view || !view.filed && view.revision !== true || view.changes.length === 0)
169
+ return html ``;
170
+ const line = (change) => {
171
+ switch (change.field) {
172
+ case "goal": return html `Goal was: ${change.before}`;
173
+ case "outOfScope": return change.after === null ? html `No longer rules anything out` : change.before === null ? html `Now rules out: ${change.after}` : html `Won't touch was: ${change.before}`;
174
+ case "touches": return html `${change.kind === "added" ? "Adds the path" : "Drops the path"} <span class="mono">${change.path}</span>`;
175
+ case "acceptance": return change.kind === "added" ? html `Adds a check: ${change.after?.statement ?? ""}` : change.kind === "removed" ? html `Drops a check: ${change.before?.statement ?? ""}` : html `Rewords a check: ${change.after?.statement ?? ""}`;
176
+ }
177
+ };
178
+ const count = `${view.changes.length} change${view.changes.length === 1 ? "" : "s"}`;
179
+ return joinHtml([
180
+ html `<div class="approval-amendment" id="contract-amendment">`,
181
+ html `<p><strong>${view.revision === true ? `The plan makes ${count} from your notes` : `The plan makes ${count} to what you filed`}</strong></p>`,
182
+ html `<ul>${view.changes.map(one => html `<li>${line(one)}</li>`)}</ul>`,
183
+ view.amendment === null ? "" : html `<p class="meta">Why: ${view.amendment}</p>`,
184
+ html `</div>`,
185
+ ]);
186
+ }
187
+ /** Whether a fragment renders nothing. */
188
+ const isEmptyHtml = (fragment) => htmlString(fragment) === "";
189
+ /** A fragment's markup for a JSON field of the browser workspace ("" when absent). */
190
+ const fragmentString = (fragment) => fragment === "" ? "" : htmlString(fragment);
191
+ /**
192
+ * The approval sheet (approval critique, Oct 2): the plan open, in plain
193
+ * rows — Goal, Changes, Won't touch, Done when — one line of who builds,
194
+ * one Approve & start beside the password, and every remaining signed
195
+ * term (lineage, routing, runtime limits, the seal) in one Details fold.
196
+ * Presentation only: the same hidden fields, nonce, digest and password
197
+ * post to the same route, and the task page and chat card share it.
198
+ */
199
+ export function approvalSheetHtml(input) {
200
+ const { scope, route } = input;
201
+ // A machine-drafted repair appends its brief to the signed goal; the row
202
+ // shows the goal and one sentence says what the repair fixes. The whole
203
+ // signed goal stays in Details.
204
+ const repairText = / — (repair exactly the unmet criteria named below; a comment cannot widen the scope\. Unmet: ([^.]+)\.|Collect only the missing observations for ([^.]+)\. .*|Diagnose the saved failed project check and repair within the original scope\. .*)$/s.exec(scope.goal);
205
+ const goalShown = repairText === null ? scope.goal : scope.goal.slice(0, repairText.index);
206
+ const statementsOf = (ids) => ids.split(",").map(id => id.trim()).map(id => scope.acceptance.find(one => one.id === id)?.statement ?? id).map(one => one.replace(/[.\s]+$/, "")).join("; ");
207
+ const repairBuild = input.revision?.sourceRun ?? input.repairChain?.sourceRun ?? null;
208
+ const builtBy = repairBuild === null ? "the last build" : `build #${repairBuild}`;
209
+ const sentence = input.revision !== null && (repairText === null || input.revision.comments.length > 0) ? revisionSentence(input.revision, scope.acceptance)
210
+ : repairText === null ? null
211
+ : repairText[2] !== undefined ? `Fixes what ${builtBy} missed: ${statementsOf(repairText[2])}.`
212
+ : repairText[3] !== undefined ? `Collects the evidence ${builtBy} was missing for: ${statementsOf(repairText[3])}. The saved code stays the same.`
213
+ : `Fixes the checks that failed in ${builtBy}.`;
214
+ const plan = input.planDocument === null ? null : parseExecutionPlanDocument(input.planDocument);
215
+ const milestones = plan !== null && plan.ok ? plan.document.milestones : [];
216
+ const permission = permissionWordsOf(scope.profile);
217
+ const money = (micro) => `$${(micro / 1_000_000).toFixed(2)}`;
218
+ const row = (label, body) => html `<div class="approval-row"><dt>${label}</dt><dd>${body}</dd></div>`;
219
+ const changes = joinHtml([
220
+ milestones.length > 0 ? html `<ol class="approval-steps">${milestones.map(one => html `<li>${one}</li>`)}</ol>` : "",
221
+ scope.touches.length > 0
222
+ ? html `<p>Only in these paths:</p>${approvalPathsHtml(scope.touches)}`
223
+ : html `<p${milestones.length > 0 ? html ` class="meta"` : ""}>Any file in the project.</p>`,
224
+ ]);
225
+ const doneWhen = scope.acceptance.length === 0
226
+ ? html `<p>You decide when you review the result.</p>`
227
+ : html `<ul class="approval-done">${scope.acceptance.map(one => html `<li>${one.statement}</li>`)}</ul>`;
228
+ // Who builds, in one line: the builder and planner by name, the cap, and full access when granted.
229
+ const legs = route?.projection?.legs ?? [];
230
+ const legOf = (phase) => legs.find(one => one.phase === phase);
231
+ const who = [];
232
+ const builder = legOf("build");
233
+ if (builder !== undefined)
234
+ who.push(`Builder ${agentNameWords(builder.provider, builder.model)}`);
235
+ else if (route?.legacy != null)
236
+ who.push(`Builder ${agentNameWords(route.legacy.provider, route.legacy.model)}`);
237
+ else if (scope.profile != null)
238
+ who.push(`Builder ${agentNameWords(scope.profile.provider, scope.profile.model)}`);
239
+ const planner = legOf("plan");
240
+ // A small change makes no plan: no planner is named (a person's chosen planner still is).
241
+ const sized = route?.projection?.size ?? null;
242
+ const unplanned = route?.projection != null && makesNoPlan(sized, route.projection.risk) && planner?.chosen === "recommended";
243
+ if (planner !== undefined && !unplanned)
244
+ who.push(`Planner ${agentNameWords(planner.provider, planner.model)}`);
245
+ // What the yes allows, in plain words, right above it: the agent's
246
+ // permissions, the per-attempt limit, and an earlier version still running.
247
+ const allowing = [];
248
+ const allowed = permissionPlainWords(scope.profile);
249
+ if (allowed !== null)
250
+ allowing.push(allowed);
251
+ allowing.push(scope.budgetMicrousd === null ? "no attempt limit" : `up to ${money(scope.budgetMicrousd)} per attempt`);
252
+ const earlier = earlierVersionsWords(input.earlier.active, input.earlier.running);
253
+ if (earlier !== null)
254
+ allowing.push(earlier);
255
+ const yoursToCheck = scope.acceptance.filter(one => one.evidence.includes("manual-review")).map(one => one.statement.trim().replace(/[.\s]+$/, ""));
256
+ const consent = joinHtml([
257
+ allowing.length === 0 ? "" : html `<p class="approval-allowing" data-approval-allowing>You’re allowing: ${allowing.join(" · ")}</p>`,
258
+ yoursToCheck.length === 0 ? "" : html `<p class="approval-you-check" data-approval-you-check>You’ll check: ${yoursToCheck.join("; ")}</p>`,
259
+ ]);
260
+ const after = input.deliverable === "report"
261
+ ? html `An agent investigates without changing the repository. You'll hear when its report is ready.`
262
+ : html `An agent starts in its own branch. You'll hear when it's ready to review.`;
263
+ const submit = html `Approve & start`;
264
+ // Everything else the digest binds, one tap away.
265
+ const detail = (title, body, attrs = html ``) => isEmptyHtml(body) ? html `` : html `<section class="approval-detail"${attrs}><h3>${title}</h3>${body}</section>`;
266
+ const revisionDetail = input.revision === null ? "" : detail("Earlier build", joinHtml([
267
+ html `<div class="revision-card" data-revision-feedback>`,
268
+ html `<p><a href="${input.revisionSourceHref}"${input.surface === "chat" ? html ` data-revision-source` : ""}>${input.surface === "chat" ? html `Original result: build #${input.revision.sourceRun} →` : html `build #${input.revision.sourceRun}`}</a></p>`,
269
+ input.revision.comments.length === 0 ? "" : html `<ul>${input.revision.comments.map(one => html `<li>${one.path === null ? "" : html `<span class="mono">${one.path}${one.line === null ? "" : `:${one.line}`}</span> · `}${one.note} <span class="meta">— ${one.author}</span></li>`)}</ul>`,
270
+ revisionLineageHtml(input.revision.lineage),
271
+ html `</div>`,
272
+ ]));
273
+ const contract = input.planContract;
274
+ const amendment = approvalAmendmentHtml(contract);
275
+ const contractDetail = input.planDocument === null || !isEmptyHtml(amendment) ? html ``
276
+ : contract === null || contract === undefined ? html `<p class="meta">No record compares this plan with what was filed. Check the rows above against your request.</p>`
277
+ : "problem" in contract ? html `<p class="meta">${contract.problem} · <a href="/r/${contract.run}">run ${contract.run}</a></p>`
278
+ : !contract.filed && contract.revision !== true ? html `<p class="meta">Nothing was filed before planning, so every term is the planner's proposal.</p>`
279
+ : html `<p class="meta">${contract.revision === true ? "Same terms as the previous version." : "The plan keeps exactly what you filed."} <a href="/r/${contract.run}">Planning run ${contract.run}</a></p>`;
280
+ const criteriaDetail = scope.acceptance.length === 0 ? html ``
281
+ : html `<ul class="approval-signed-criteria">${scope.acceptance.map(one => html `<li><code>${one.id}</code> ${one.statement} <span class="meta">shown by ${one.evidence.map(kind => EVIDENCE_PLAIN[kind] ?? kind).join(", ")}</span></li>`)}</ul>`;
282
+ const projection = route?.projection ?? null;
283
+ const agentsDetail = route === null || route === undefined ? html ``
284
+ : projection === null
285
+ ? route.legacy === null ? html `` : html `<p>${agentsSummaryWords(route)}</p>`
286
+ : joinHtml([
287
+ html `<p>${projection.summary}</p>`,
288
+ html `<p class="meta">Uses ${projection.postureWords}.</p>`,
289
+ projection.demands.length === 0 ? "" : html `<ul class="meta">${projection.demands.map(one => html `<li>${one}</li>`)}</ul>`,
290
+ html `<dl class="approval-roles">${projection.legs.map(leg => html `<div><dt>${ROLE_NOUN[leg.phase]}</dt><dd><span class="mono">${leg.provider} · ${leg.model}</span> <span class="meta">${chosenWords(leg)}</span><ul>${leg.reasons.map(reason => html `<li>${reason}</li>`)}${leg.problem === null ? "" : html `<li><strong>${leg.problem}</strong></li>`}</ul></dd></div>`)}</dl>`,
291
+ html `<p class="meta">These exact agents are part of what you approve. Changing any of them asks for a fresh approval.</p>`,
292
+ ]);
293
+ const limits = joinHtml([
294
+ profileWords(scope),
295
+ html `<p class="meta">Checks level: ${qualityModeTitle(scope.qualityMode ?? "default")}${permission === null ? "" : ` · ${permission}`}</p>`,
296
+ scope.budgetMicrousd === null ? "" : html `<p class="meta">Each build attempt has a ${money(scope.budgetMicrousd)} agent-reported usage cap. On a subscription this limits work; it is not an API charge.</p>`,
297
+ ]);
298
+ const details = joinHtml([
299
+ html `<details class="approval-details"><summary>Plan details</summary>`,
300
+ revisionDetail,
301
+ repairText === null ? "" : detail("Signed goal", html `<p>${scope.goal}</p>`),
302
+ detail("Plan record", contractDetail),
303
+ detail("Signed criteria", criteriaDetail),
304
+ detail("Why these agents", agentsDetail, html ` id="${input.surface === "task" ? "approval-agents" : "chat-approval-agents"}"`),
305
+ detail("Runtime limits", limits),
306
+ scope.candidate ? detail("Saved commit", html `<p><code class="approval-commit">${scope.candidate}</code></p>`) : "",
307
+ detail("Seal", html `<p class="meta">Approval binds to this exact wording. <span class="seal mono">signs ${shortDigest(scope.digest)}</span></p>`),
308
+ html `</details>`,
309
+ ]);
310
+ const passwordNote = input.surface === "task" ? "approval-password-note" : "chat-approval-password-note";
311
+ // Edit plan, in place (task page): the rows become fields and save
312
+ // through the scope's own route. The fields belong to a form after this
313
+ // one (forms never nest), so Approve never carries them.
314
+ const edit = input.edit;
315
+ const draft = edit?.draft ?? null;
316
+ const editForm = "plan-editor-form";
317
+ // Each field opens tall enough for what it holds (up to ten lines).
318
+ const field = (name, value, rows, label, hint = "") => html `<label class="approval-field"><span class="approval-field-label">${label}</span>${hint === "" ? "" : html `<span class="approval-field-hint">${hint}</span>`}<textarea name="${name}" rows="${Math.min(10, Math.max(rows, value.split("\n").reduce((sum, line) => sum + Math.max(1, Math.ceil(line.length / 80)), 0)))}" form="${editForm}">${value}</textarea></label>`;
319
+ // Done when, one line per requirement in the signed order; the server
320
+ // matches them back by position (the digest guard keeps that order).
321
+ const requirement = (name, value, label, placeholder = "") => html `<li><input type="text" name="${name}" value="${value}" aria-label="${label}"${placeholder === "" ? "" : html ` placeholder="${placeholder}"`} form="${editForm}"></li>`;
322
+ const requirements = draft?.getAll("requirement") ?? scope.acceptance.map(one => one.statement);
323
+ const added = draft?.get("requirement-new") ?? "";
324
+ const editor = edit === null ? null : joinHtml([
325
+ html `<details class="approval-edit" id="plan-editor"${draft === null && !edit.open ? "" : html ` open`}>`,
326
+ html `<summary class="approval-link"><span class="approval-edit-open">Edit plan</span><span class="approval-edit-close">Cancel</span></summary>`,
327
+ html `<div class="approval-editor">`,
328
+ edit.problem === null ? "" : html `<p class="problem" role="alert">${edit.problem}</p>`,
329
+ field("goal", draft?.get("goal") ?? goalShown, 3, "Goal"),
330
+ field("touches", draft?.get("touches") ?? scope.touches.join("\n"), 2, "Changes", "Only in these paths, one per line. Leave empty for any file."),
331
+ field("not", draft?.get("not") ?? scope.outOfScope ?? "", 2, "Won’t touch"),
332
+ html `<fieldset class="approval-field"><legend class="approval-field-label">Done when</legend>`,
333
+ html `<span class="approval-field-hint">Clear a line to drop it.</span><ul class="approval-requirements">`,
334
+ requirements.map((one, index) => requirement("requirement", one, `Requirement ${index + 1}`)),
335
+ requirement("requirement-new", added, "Add a requirement", "Add a requirement"),
336
+ html `</ul></fieldset>`,
337
+ html `<div class="approval-edit-act"><button type="submit" form="${editForm}">Save plan</button>`,
338
+ // A plan with written steps keeps its own editor for them.
339
+ edit.stepsHref === null ? "" : html `<a class="approval-link" href="${edit.stepsHref}">Edit steps</a>`, html `</div>`,
340
+ html `</div></details>`,
341
+ ]);
342
+ const keptMode = permissionModeOfProfile(scope.profile);
343
+ const editorForm = edit === null ? "" : postForm(edit.action, null, {
344
+ attrs: { id: editForm, class: "approval-editor-form" },
345
+ hidden: {
346
+ // A refused save keeps the version its draft was edited from, so a
347
+ // plan that changed meanwhile is never silently overwritten.
348
+ sawDigest: draft?.get("sawDigest") ?? scope.digest,
349
+ // The terms the editor doesn't show ride along unchanged: a repair's
350
+ // brief after its goal (it stays in Details), permissions, checks, cap.
351
+ "goal-brief": repairText === null ? null : repairText[0],
352
+ "permission-mode": keptMode,
353
+ "quality-mode": scope.qualityMode ?? "default",
354
+ // The cap rides in exact millionths ("none" keeps no limit).
355
+ "budget-microusd": scope.budgetMicrousd === null ? "none" : String(scope.budgetMicrousd),
356
+ },
357
+ });
358
+ return joinHtml([
359
+ postForm(input.action, joinHtml([
360
+ html `<input type="text" name="username" autocomplete="username" class="visually-hidden" tabindex="-1" aria-hidden="true">`,
361
+ html `<h2 class="approval-sheet-title">${input.deliverable === "report" ? "The investigation" : "The plan"}</h2>`,
362
+ sentence === null ? "" : html `<p class="approval-revision">${sentence}</p>`,
363
+ input.coordinator === null ? "" : html `<p class="approval-note">An agent filed this: <span class="mono">${input.coordinator.label}</span>${input.coordinator.filedAgo === null ? "" : html `, ${input.coordinator.filedAgo}`}. Nothing runs until you approve, and approving runs its request.</p>`,
364
+ input.deliverable === "report" ? html `<p class="approval-note">Read-only: it reports back and changes nothing in the repository.</p>` : "",
365
+ html `<dl class="approval-rows">`,
366
+ row("Goal", html `<p class="approval-goal">${goalShown}</p>`),
367
+ row("Changes", changes),
368
+ row("Won’t touch", html `<p>${scope.outOfScope === null ? "Nothing is ruled out." : scope.outOfScope}</p>`),
369
+ row("Done when", doneWhen),
370
+ html `</dl>`,
371
+ amendment,
372
+ // The size, said once and plainly beside who builds: "Small change: fast model, no plan".
373
+ projection === null ? "" : sizeLineHtml(projection),
374
+ who.length === 0 ? "" : html `<p class="approval-who">${who.join(" · ")}</p>`,
375
+ consent,
376
+ html `<div class="approval-act" id="${input.surface === "task" ? "approval-confirm" : "chat-approval-confirm"}">`,
377
+ html `<label class="approval-password"><span class="visually-hidden">Your password</span><input type="password" name="token" autocomplete="current-password" placeholder="Your password" aria-describedby="${passwordNote}"></label>`,
378
+ html `<p class="approval-password-note" id="${passwordNote}">Your password signs this approval.</p>`,
379
+ html `<button type="submit" data-primary-action>${submit}</button></div>`,
380
+ html `<p class="approval-after">${after}</p>`,
381
+ html `<div class="approval-secondary">${editor ?? html `<a class="approval-link" href="${input.editHref}">Edit plan</a>`}<a class="approval-link" href="${input.notNowHref}">Not now</a></div>`,
382
+ details,
383
+ ]), {
384
+ attrs: { class: "approve-form approval-sheet", id: input.surface === "task" ? "approve" : null, "data-sticky": input.sticky },
385
+ hidden: { nonce: input.nonce, digest: input.digest },
386
+ returnTo: input.returnTo,
387
+ }),
388
+ editorForm,
389
+ ]);
390
+ }
391
+ /** Earlier versions of a task still in flight, in plain words: queued
392
+ * while they only wait, running once one has started; null when none. */
393
+ export function earlierVersionsWords(active, running) {
394
+ if (active <= 0)
395
+ return null;
396
+ const still = running >= active ? "running" : running <= 0 ? "queued" : "queued or running";
397
+ return active === 1 ? `an earlier version is still ${still}` : `${active} earlier versions are still ${still}`;
398
+ }
399
+ /** An in-place plan edit that would change the agent's permissions: thrown to roll the save back. */
400
+ export class PermissionsWouldChange extends Error {
401
+ }
402
+ /** Evidence kinds in plain words, for the signed criteria in Details. */
403
+ export const EVIDENCE_PLAIN = { check: "the project check", screenshot: "screenshots", "changed-path": "changed files", "manual-review": "your own check" };
404
+ /** The permission choice a sealed profile carries, as the scope form names it. */
405
+ export function permissionModeOfProfile(profile) {
406
+ if (profile === null || profile === undefined)
407
+ return null;
408
+ return profile.provider === "claude"
409
+ ? profile.permissionArgv === "bypassPermissions" ? "bypassPermissions" : "auto"
410
+ : profile.provider === "gemini"
411
+ ? profile.approvalArgv === "yolo" ? "bypassPermissions" : "auto"
412
+ : profile.sandboxMode === "danger-full-access" ? "bypassPermissions" : "auto";
413
+ }
414
+ /** Done when, from the in-place editor: each line keeps its requirement's
415
+ * id by position, and its evidence and guidance while its words stand; a
416
+ * rewritten one is yours to check, like an added one; an emptied one is
417
+ * dropped. */
418
+ export function requirementsFromEditor(body, current) {
419
+ const used = new Set(current.map(one => one.id));
420
+ let next = 1;
421
+ while (used.has(`c${next}`))
422
+ next++;
423
+ const plain = (raw) => raw.replace(/\s+/g, " ").trim();
424
+ const kept = body.getAll("requirement").slice(0, current.length).flatMap((raw, index) => {
425
+ const statement = plain(raw);
426
+ const criterion = current[index];
427
+ if (statement === "")
428
+ return [];
429
+ return statement === plain(criterion.statement)
430
+ ? [{ id: criterion.id, statement: criterion.statement, evidence: [...criterion.evidence], how: criterion.how }]
431
+ : [{ id: criterion.id, statement, evidence: ["manual-review"], how: null }];
432
+ });
433
+ const added = plain(body.get("requirement-new") ?? "");
434
+ return added === "" ? kept : [...kept, { id: `c${next}`, statement: added, evidence: ["manual-review"], how: null }];
435
+ }
436
+ export function consentDoorOf(scope, route) {
437
+ if (scope === null)
438
+ return { open: false, title: "There is no scope to approve yet", why: "write the scope first", road: "scope" };
439
+ if (scope.profileState === "unresolved") {
440
+ return { open: false, title: "The agent setup isn’t ready yet", why: scope.unresolvedReason ?? "the scope cannot say exactly what would run", road: "scope" };
441
+ }
442
+ // THE SAME STRICT PROJECTION THE SEAL USES (v48 integrity): before any
443
+ // nonce, password field, or approve action renders, the stored scope
444
+ // must prove exactly — exact-key profile, chain, and route; safe and
445
+ // timer-safe numbers; a well-formed auth mode on every chain entry;
446
+ // build/repair parity; the profile as the chain's entry zero; the
447
+ // digest re-derived complete. What the seal would refuse, no surface
448
+ // offers.
449
+ const authority = scopeAuthorityOf(scope, { authMode: provider => readAuthModeStrict(provider) });
450
+ if (!authority.ok) {
451
+ if (authority.reason === "auth-mode") {
452
+ return { open: false, title: "The provider’s auth mode can’t be read", why: `${authority.problem} — restate it, then approve`, road: "agents" };
453
+ }
454
+ if (authority.reason === "unrouted") {
455
+ return { open: false, title: "This scope predates agent routing", why: "its approval no longer stands, and an approval now must name exactly which agent plans, builds, and revises — re-file the scope (edit and save it) to route it under today’s agents, then approve it", road: "scope" };
456
+ }
457
+ return { open: false, title: "The agents on file can’t be read", why: `${authority.problem} — re-file the scope (edit and save it) so it is routed again under today’s agents`, road: "scope" };
458
+ }
459
+ if (route === null || route === undefined)
460
+ return { open: true };
461
+ if (route.kind === "unreadable") {
462
+ return { open: false, title: "The agents on file can’t be read", why: `${route.problem} — re-file the scope (edit and save it) so it is routed again under today’s agents`, road: "scope" };
463
+ }
464
+ if (route.kind === "legacy") {
465
+ return { open: false, title: "This scope predates agent routing", why: "its approval no longer stands, and an approval now must name exactly which agent plans, builds, and revises — re-file the scope (edit and save it) to route it under today’s agents, then approve it", road: "scope" };
466
+ }
467
+ if (route.projection !== null && route.projection.problems.length > 0) {
468
+ return { open: false, title: "The agents on file can’t run", why: `${route.projection.problems.join("; ")} — change the agents, then approve`, road: "agents" };
469
+ }
470
+ return { open: true };
471
+ }
472
+ /** The closed door, rendered: no nonce, no password, no approve button —
473
+ * the reason and the one act that opens it. */
474
+ export function consentClosedHtml(taskId, door, surface) {
475
+ const href = `${taskHref(taskId)}#${door.road === "agents" ? "agents" : "scope"}`;
476
+ const act = door.road === "agents" ? "Change the agents →" : "Edit and re-file the scope →";
477
+ if (surface === "chat") {
478
+ return html `<section class="card chat-action-card consent-closed" id="task-chat-action"><span class="eyebrow">approval needs attention</span><h2>${door.title}</h2><p class="meta">${door.why}</p><a class="button-link" href="${href}">${act}</a></section>`;
479
+ }
480
+ if (surface === "next") {
481
+ return html `<div class="card approve-form consent-closed"><p><strong>${door.title}: approval is closed.</strong></p><p class="meta">${door.why}</p><p class="ceremony-road"><a class="button-link" href="${href}">${act}</a></p></div>`;
482
+ }
483
+ return html `<div class="card approve-form consent-closed" id="approve"><p><strong>This task is waiting on you: ${door.title.toLowerCase()}.</strong></p><p class="meta">${door.why}</p><p class="ceremony-road"><a class="button-link" href="${href}">${act.toLowerCase()}</a></p></div>`;
484
+ }
485
+ export function profileWords(scope) {
486
+ if (scope.profileState === "unresolved") {
487
+ return html `<p class="meta"><strong>Filed but unapprovable</strong> — ${scope.unresolvedReason ?? "the scope cannot say exactly what would run"}. Restate the scope to fix it.</p>`;
488
+ }
489
+ const profile = scope.profile ?? null;
490
+ if (profile === null) {
491
+ return (scope.digestVersion ?? 1) < 2
492
+ ? html `<p class="meta">Approved before routing was bound — pinned at upgrade to the configuration of that day</p>`
493
+ : html ``;
494
+ }
495
+ const repair = profile.repairModel === "inherit" ? "same model" : profile.repairModel;
496
+ const base = profile.provider === "claude"
497
+ ? html `<p class="meta">Runs on <span class="mono">claude · ${profile.model}</span> — ${profile.permissionArgv === "bypassPermissions"
498
+ ? "FULL permissions; claude runs with --dangerously-skip-permissions and nothing asks"
499
+ : profile.permissionArgv === "auto"
500
+ ? "safe unattended permissions; routine project commands and edits proceed, risky acts stop"
501
+ : "legacy acceptEdits; edits proceed, commands that ask are denied unattended"}, stops after ${profile.maxTurns} turns, ${Math.round(profile.timeoutSeconds / 60)} min ${profile.timeoutKind === "idle" ? "without progress" : "per attempt"}; repairs on ${repair}, ${profile.repairMaxTurns} turns / ${Math.round(profile.repairTimeoutSeconds / 60)} min</p>`
502
+ : profile.provider === "gemini"
503
+ ? html `<p class="meta">Runs on <span class="mono">gemini · ${profile.model}</span> — ${profile.approvalArgv === "yolo" ? "Full access via --approval-mode yolo; every tool auto-approved" : "Auto via --approval-mode auto_edit; edits auto-approved, other tools refused"}, no turn limit (${Math.round(profile.timeoutSeconds / 60)} min ${profile.timeoutKind === "idle" ? "without progress" : "per attempt"}), spend reported in tokens only; repairs on ${repair}, ${Math.round(profile.repairTimeoutSeconds / 60)} min</p>`
504
+ : html `<p class="meta">Runs on <span class="mono">${profile.provider} · ${profile.model}</span> — ${profile.sandboxMode === "danger-full-access" ? "FULL permissions via --dangerously-bypass-approvals-and-sandbox; nothing asks" : "workspace-write sandbox"}, no turn limit (${Math.round(profile.timeoutSeconds / 60)} min ${profile.timeoutKind === "idle" ? "without progress" : "per attempt"}); repairs on ${repair}, ${Math.round(profile.repairTimeoutSeconds / 60)} min</p>`;
505
+ return base;
506
+ }
507
+ export const ROLE_NOUN = { plan: "Planner", build: "Builder", repair: "Repair", review: "Reviewer" };
508
+ export function agentsStandingWords(view) {
509
+ return view.kind === "legacy"
510
+ ? view.legacy?.approved === true ? "approved earlier" : "not approved"
511
+ : view.kind === "unreadable"
512
+ ? "cannot be read"
513
+ : view.source === "approved"
514
+ ? "approved"
515
+ : view.source === "proposed"
516
+ ? "awaiting approval"
517
+ : "recommended";
518
+ }
519
+ /** The size line an approval leads its agents with — "Small change: fast
520
+ * model, no plan" — and, quietly, why it was sized so. */
521
+ export function sizeLineHtml(projection) {
522
+ if (projection.sizeWords === null)
523
+ return html ``;
524
+ return html `<p class="agents-size"><strong>${projection.sizeWords}.</strong>${projection.sizeReason === null ? "" : html ` <span class="meta">${projection.sizeReason}</span>`}</p>`;
525
+ }
526
+ /** What a size does, for a proposal card. */
527
+ export function sizeConsequence(size, risky) {
528
+ if (size === "small" && !risky)
529
+ return "Small change: fast model, no plan";
530
+ if (size === "large" || risky)
531
+ return `${size === "large" ? "Large" : "Risky"} change: strongest agents plan and build`;
532
+ return "Medium change: everyday agents";
533
+ }
534
+ /** The one plain-English line: who does what. */
535
+ export function agentsSummaryWords(view) {
536
+ if (view.projection !== null)
537
+ return view.projection.summary;
538
+ if (view.legacy !== null)
539
+ return `${view.legacy.provider} · ${view.legacy.model} builds and repairs (repair model ${view.legacy.repairModel}); the planner and reviewer come from configuration at run time`;
540
+ return view.problem ?? "the agents cannot be read";
541
+ }
542
+ /** A risk badge only when there is something to say: a risky change, or an
543
+ * older route sealed at elevated or high risk. */
544
+ export function riskBadgeHtml(projection) {
545
+ return projection.riskTitle === "Routine" ? html `` : html `<span class="badge">${sentenceCase(projection.riskTitle)}</span>`;
546
+ }
547
+ /** Neutral metadata badges: risk (when not routine), posture, and standing. */
548
+ export function agentsBadgesHtml(view) {
549
+ const p = view.projection;
550
+ return html `${p === null ? "" : riskBadgeHtml(p)}${p === null ? "" : html `<span class="badge">${sentenceCase(p.postureWords)}</span>`}<span class="badge">${sentenceCase(agentsStandingWords(view))}</span>`;
551
+ }
552
+ /** Volatile availability, per provider on the route — shown beside the
553
+ * agents, never inside the approval's terms. */
554
+ export function agentsAvailabilityHtml(projection) {
555
+ const seen = new Map();
556
+ for (const leg of projection.legs)
557
+ if (!seen.has(leg.provider))
558
+ seen.set(leg.provider, leg);
559
+ const items = [...seen.values()].map(leg => {
560
+ const state = leg.readiness === "ready" ? "ready" : leg.readiness === "unavailable" ? "unavailable" : "not yet checked";
561
+ const detail = leg.readiness === "unavailable" && leg.readinessReason !== null ? ` (${leg.readinessReason})` : "";
562
+ return html `<li class="agents-availability-${leg.readiness}"><span class="mono">${leg.provider}</span> ${state}${detail}${leg.readinessRunner === null ? "" : html ` <span class="meta">— ${leg.readinessRunner}${leg.observedAt === null ? "" : html `, ${whenTime(leg.observedAt)}`}</span>`}</li>`;
563
+ });
564
+ return html `<p class="meta agents-availability-label">Availability right now</p><ul class="agents-availability">${items}</ul>`;
565
+ }
566
+ /** The closed details: one row per role, with the reasons. */
567
+ export function agentsWhyHtml(projection, summaryLabel = "Why these agents") {
568
+ return joinHtml([
569
+ html `<details class="agents-why"><summary>${summaryLabel}</summary>`,
570
+ projection.demands.length === 0 ? "" : html `<ul class="agents-demands">${projection.demands.map(one => html `<li>${one}</li>`)}</ul>`,
571
+ html `<dl class="agents-roles">`,
572
+ projection.legs.map(leg => {
573
+ const chosen = chosenWords(leg);
574
+ return joinHtml([
575
+ html `<dt>${ROLE_NOUN[leg.phase]}</dt>`,
576
+ html `<dd><span class="mono">${leg.provider} · ${leg.model}</span> <span class="badge">${sentenceCase(chosen)}</span>`,
577
+ html `<ul class="agents-reasons">${leg.reasons.map(reason => html `<li>${reason}</li>`)}${leg.problem === null ? "" : html `<li><strong>${leg.problem}</strong></li>`}</ul></dd>`,
578
+ ]);
579
+ }),
580
+ html `</dl></details>`,
581
+ ]);
582
+ }
583
+ /** The ceremony's agents block: what the yes agrees to — the agents and
584
+ * their reasons. Availability is volatile and deliberately absent here. */
585
+ export function agentsCeremonyHtml(view) {
586
+ if (view === null || view === undefined)
587
+ return html ``;
588
+ if (view.projection === null) {
589
+ // A proven pre-routing approval: its sealed profile is the whole
590
+ // agents term, said in the same place with the same label.
591
+ if (view.legacy !== null) {
592
+ return html `<div class="agents-ceremony"><p class="approval-label">agents</p><p class="agents-summary">${agentsSummaryWords(view)}</p></div>`;
593
+ }
594
+ return html ``;
595
+ }
596
+ return joinHtml([
597
+ html `<div class="agents-ceremony"><p class="approval-label">agents</p>`,
598
+ sizeLineHtml(view.projection),
599
+ html `<p class="agents-summary">${view.projection.summary}</p>`,
600
+ html `<div class="agents-badges">${riskBadgeHtml(view.projection)}<span class="badge">${sentenceCase(view.projection.postureWords)}</span></div>`,
601
+ agentsWhyHtml(view.projection),
602
+ html `<p class="meta">These exact agents are part of what you approve; changing any of them asks for a fresh approval.</p></div>`,
603
+ ]);
604
+ }
605
+ /** The runtime limits the sealed profile binds — permissions, turn and
606
+ * time bounds, repairs — restated on the ceremony as
607
+ * a CLOSED disclosure (v48): a term the yes covers, one tap away, never a
608
+ * wall of switches between the reader and the password. An unresolved
609
+ * profile still speaks in the open: that is a refusal, not a detail. */
610
+ export function runtimeDetailsHtml(scope) {
611
+ if (scope.profileState === "unresolved")
612
+ return profileWords(scope);
613
+ const words = profileWords(scope);
614
+ if (isEmptyHtml(words))
615
+ return html ``;
616
+ return html `<details class="agents-runtime"><summary>Runtime limits</summary>${words}</details>`;
617
+ }
618
+ /** The task page's Agents card: the summary, availability, closed reasons,
619
+ * and — for an approver — closed change controls. Every change re-files
620
+ * the scope; an approval given under the earlier agents needs renewing. */
621
+ export function agentsCardHtml(taskId, view, canEdit) {
622
+ if (view === null || view === undefined)
623
+ return html ``;
624
+ const p = view.projection;
625
+ const sawDigest = view.digest ?? "";
626
+ const route = `${taskHref(taskId)}/route`;
627
+ const overrides = view.overrides.length === 0
628
+ ? ""
629
+ : html `<ul class="agents-overrides">${view.overrides.map(one => joinHtml([
630
+ html `<li><span>${ROLE_NOUN[one.phase]} → <span class="mono">${one.provider} · ${one.model}</span> <span class="meta">by ${one.by} ${whenTime(one.at)}</span></span>`,
631
+ canEdit && view.editable
632
+ ? postForm(route, html `<button type="submit" class="secondary" aria-label="clear the ${ROLE_NOUN[one.phase].toLowerCase()} choice">Clear</button>`, { attrs: { class: "agents-clear" }, hidden: { sawDigest, "clear-phase": one.phase } })
633
+ : "",
634
+ html `</li>`,
635
+ ]))}</ul>`;
636
+ // The controls (v48): a size choice that says what each size does, and
637
+ // — per role — ONLY the configured, role-valid agents the operator may
638
+ // pick from. Nothing is typed free-hand, no command line is quoted; the
639
+ // reasons above already say why the current agents were chosen.
640
+ const roleForms = ROUTE_PHASES.filter(phase => phase !== "review").map(phase => {
641
+ // Selectable choices are the role's own configured agents; a current
642
+ // agent the configuration no longer names is DISPLAY-ONLY — said
643
+ // beside the control, never an option the form could re-pick.
644
+ const options = view.choices[phase].filter(one => one.selectable);
645
+ const stale = view.choices[phase].find(one => one.current && !one.selectable) ?? null;
646
+ const staleNote = stale === null ? "" : html `<p class="meta agents-stale">Runs today on <span class="mono">${`${stale.provider} · ${stale.model}`}</span>, which is no longer in your configuration — pick a configured agent to replace it.</p>`;
647
+ if (options.length === 0) {
648
+ return html `<div class="agents-role-row"><span class="agents-role-name">${ROLE_NOUN[phase]}</span><p class="meta">No configured agent can take this role for this task.</p>${staleNote}</div>`;
649
+ }
650
+ return html `${postForm(route, joinHtml([
651
+ html `<label>${ROLE_NOUN[phase]}<select name="agent" aria-label="${ROLE_NOUN[phase].toLowerCase()} agent">`,
652
+ options.map(one => html `<option value="${`${one.provider}|${one.model}`}"${one.current ? html ` selected` : ""}>${`${one.provider} · ${one.model}`}${one.current ? " — current" : ""}</option>`),
653
+ html `</select></label><button type="submit" class="secondary">Use</button>`,
654
+ ]), { attrs: { class: "agents-form", "aria-label": `choose the ${ROLE_NOUN[phase].toLowerCase()}` }, hidden: { sawDigest, phase } })}${staleNote}`;
655
+ });
656
+ const change = !canEdit
657
+ ? ""
658
+ : !view.editable
659
+ ? html `<p class="meta">${view.editableWhy ?? "the agents cannot change right now"}</p>`
660
+ : joinHtml([
661
+ html `<details class="agents-change"><summary>Change agents</summary>`,
662
+ postForm(route, joinHtml([
663
+ html `<label>Size<select name="size" aria-label="task size">${TASK_SIZES.map(one => html `<option value="${one}"${one === (p?.size?.size ?? "medium") ? html ` selected` : ""}>${sizeConsequence(one, false)}</option>`)}</select></label>`,
664
+ // The hidden "no" says the form showed the box: unticked means not risky; a request without either keeps the flag.
665
+ html `<input type="hidden" name="risky" value="no"><label class="agents-risky"><input type="checkbox" name="risky" value="yes"${p?.size?.risky === true ? html ` checked` : ""}> Risky</label>`,
666
+ html `<button type="submit" class="secondary">Set size</button>`,
667
+ ]), { attrs: { class: "agents-form-risk" }, hidden: { sawDigest } }),
668
+ html `<div class="agents-role-forms">${roleForms}</div>`,
669
+ html `<p class="meta">Only agents you have configured are offered; each choice is recorded as you. ${view.replanOnPlanChange ? "Changing the planner asks for a new plan — the drafted one is not relabeled. " : ""}An approval given under the earlier agents needs renewing.</p>`,
670
+ overrides,
671
+ html `</details>`,
672
+ ]);
673
+ return joinHtml([
674
+ html `<section class="card agents-card" id="agents" aria-label="agents">`,
675
+ html `<div class="agents-head"><h3>Agents</h3><div class="agents-badges">${agentsBadgesHtml(view)}</div></div>`,
676
+ html `<p class="agents-summary">${agentsSummaryWords(view)}</p>`,
677
+ p === null
678
+ ? view.kind === "unreadable"
679
+ ? html `<p class="agents-halted">Nothing runs for this task until its scope is filed again and approved.</p>`
680
+ : ""
681
+ : joinHtml([
682
+ p.halted ? html `<p class="agents-halted">Paused: a provider these agents need is reported unavailable. Nothing else is used in its place — choose another agent below, or restore the provider and report readiness again.</p>` : "",
683
+ p.problems.length === 0 ? "" : html `<ul class="agents-problems">${p.problems.map(one => html `<li>${one}</li>`)}</ul>`,
684
+ agentsAvailabilityHtml(p),
685
+ agentsWhyHtml(p),
686
+ ]),
687
+ canEdit || overrides === "" ? change : overrides,
688
+ html `</section>`,
689
+ ]);
690
+ }
691
+ /** The chat's always-visible strip: the summary and a way to the details. */
692
+ export function agentsStripHtml(view, taskId) {
693
+ if (view === null)
694
+ return html ``;
695
+ // A broken setup is not secondary detail: keep its failure visible.
696
+ if (view.kind === "unreadable")
697
+ return html `<p class="task-chat-agents">${agentsSummaryWords(view)} <a href="${taskHref(taskId)}#agents">Review agent setup</a></p>`;
698
+ return html `<details class="task-chat-agents"><summary>Agent setup</summary><p>${agentsSummaryWords(view)}</p><a href="${taskHref(taskId)}#agents">View agents</a></details>`;
699
+ }
700
+ /** A ceremony nonce as stored: the sha256 hex of the value the form carried. */
701
+ export function nonceHashOf(value) {
702
+ return createHash("sha256").update(value).digest("hex");
703
+ }
704
+ /** The approve POST's refusal, in words a person can act on. */
705
+ export function approveRefusalWords(reason) {
706
+ if (reason === "requester" || reason === "person-required")
707
+ return gateWords({ verdict: "refuse", reason });
708
+ if (reason === "unrouted")
709
+ return "not approved: this scope predates agent routing and its old approval no longer stands — edit and re-file the scope so it is routed under today’s agents, then approve it";
710
+ if (reason === "profile-unresolved")
711
+ return "not approved: the scope cannot name an exact agent for every role — fix the agent setup and re-file it";
712
+ return `not approved: ${reason}`;
713
+ }
714
+ /** The browser's one-click approval binds not only the signed scope but the
715
+ * exact advisory plan revision shown beside it. The scope digest remains the
716
+ * durable authority; this composite makes a stale open tab fail when somebody
717
+ * edits the plan before approval. The empty middle slot keeps the bytes of
718
+ * forms already open (it once held other terms, always empty here). */
719
+ export function approvalFormDigest(scopeDigest, planSha) {
720
+ if (planSha === null)
721
+ return scopeDigest;
722
+ return createHash("sha256")
723
+ .update("standing-orders/browser-approval/v1\0", "utf8")
724
+ .update(scopeDigest, "utf8")
725
+ .update("\0", "utf8")
726
+ .update("", "utf8")
727
+ .update("\0", "utf8")
728
+ .update(planSha ?? "", "utf8")
729
+ .digest("hex");
730
+ }
731
+ /** The one task-level road from a truthful dispatch diagnosis to its nearest
732
+ * existing repair. This is navigation, never authority: every destination
733
+ * still owns its original confirmation, password, CSRF, and transactional
734
+ * checks. Keeping the map here also means the focused chat and task overview
735
+ * cannot send a person to different fixes for the same gate. */
736
+ export function taskRecoveryHref(taskId, diagnosis) {
737
+ if (diagnosis?.action === null || diagnosis?.action === undefined)
738
+ return null;
739
+ const task = taskHref(taskId);
740
+ switch (diagnosis.action) {
741
+ case "start-worker":
742
+ case "repair-dependency":
743
+ return `${task}#run-status`;
744
+ case "retry-task":
745
+ case "unhold":
746
+ case "write-scope":
747
+ return `${task}#task-actions`;
748
+ case "select-agent":
749
+ return `${task}#scope`;
750
+ case "approve-scope":
751
+ return `${task}#approve`;
752
+ case "answer-decision":
753
+ return `${task}#decisions`;
754
+ case "inspect-hold":
755
+ return `${task}#holds`;
756
+ case "repair-capability":
757
+ return "/caps";
758
+ case "place-task":
759
+ return "/projects";
760
+ case "open-result":
761
+ return task;
762
+ case "retry-review":
763
+ return `${task}#run-status`;
764
+ case "resume-run":
765
+ return `${task}#task-control`;
766
+ }
767
+ }
768
+ /** The resume ceremony's digest (v52): the exact run, its settlement, and
769
+ * the scope approval as it stands — a nonce minted over one set of facts
770
+ * cannot confirm a resume under another. */
771
+ export function resumeDigestOf(taskId, runId, settledAt, scope) {
772
+ return createHash("sha256")
773
+ .update(JSON.stringify([taskId, runId, settledAt, scope?.digest ?? null, scope?.approvedDigest ?? null, scope?.approvedAt ?? null, scope?.approvedBy ?? null]))
774
+ .digest("hex");
775
+ }
776
+ export const stopWhen = (iso) => iso.replace("T", " ").replace(/\.\d{3}Z$/, "Z");
777
+ /**
778
+ * The exact-run control (v52): ONE component on the task page, the
779
+ * focused chat, and the status fragment, so every surface names the same
780
+ * run and shows the same state. Stop is one guarded post naming the run;
781
+ * Stopping… is a disabled control with who asked and when; Resume opens
782
+ * the password ceremony; a stopped review points at Review again. Forms
783
+ * carry the CSRF token and the exact run id, nothing else.
784
+ */
785
+ /** Presentation only: the same projected words and action on all task
786
+ * surfaces. Approval keeps its action on the exact-terms disclosure. */
787
+ export function taskStatusCard(status, taskId, dispatch, runId, approvalAction = false) {
788
+ const task = taskHref(taskId);
789
+ const recovery = status.token === "waiting-decision" ? `${task}#task-questions` : status.token === "stopping" || status.token === "stopped"
790
+ ? `${task}#task-control`
791
+ : taskRecoveryHref(taskId, dispatch);
792
+ const href = status.action?.kind === "open-task"
793
+ ? recovery ?? `${task}#scope`
794
+ : statusActionHref(status, taskId, runId, null);
795
+ return joinHtml([
796
+ html `<section class="card task-journey" aria-label="task progress" data-work-status="${status.token}" data-task-status>`,
797
+ html `<h2>${status.label}</h2>`,
798
+ !approvalAction && status.action !== null && href !== null ? html `<a class="button-link task-journey-action" href="${href}" data-primary-action>${status.action.label}</a>` : "",
799
+ html `<details class="task-status-reason"><summary>Status details</summary><p class="meta">${status.detail}</p>${workDiagnosticsHtml(status.diagnostics)}</details></section>`,
800
+ ]);
801
+ }
802
+ export function checkProgressHtml(progress, id = "check-progress") {
803
+ if (progress === null)
804
+ return html `<p id="${id}" class="check-progress mono" data-check-progress hidden></p>`;
805
+ const state = !progress.final ? "live" : Object.values(progress.suites).some(one => one.state === "failed") ? "failed"
806
+ : Object.values(progress.suites).every(one => one.state === "passed") ? "passed" : "unknown";
807
+ return html `<p id="${id}" class="check-progress mono" data-check-progress data-final="${state}" role="status" aria-live="polite">${progress.line}</p>`;
808
+ }
809
+ export function taskControlDetailsHtml(control, taskId, csrf, surface, inert = false, stopElsewhere = false) {
810
+ const card = taskControlHtml(control, taskId, csrf, surface, inert, stopElsewhere);
811
+ return control.kind === "paused" || control.kind === "review-stopped" || control.kind === "stopping"
812
+ ? html `<details id="task-control-details"><summary>${control.kind === "paused" ? "Preserved work and resume" : "Stop details"}</summary>${card}</details>` : card;
813
+ }
814
+ /** `stopElsewhere`: the Building card already carries this exact run's Stop, so the control card keeps only its details.
815
+ * `csrf` is only a guard now: an empty one (a read-only surface) renders no forms; postForm writes the field. */
816
+ export function taskControlHtml(control, taskId, csrf, surface, inert = false, stopElsewhere = false) {
817
+ if (control.kind === "none")
818
+ return html ``;
819
+ const guarded = csrf !== "" && !inert;
820
+ const runForm = (path, className, button) => postForm(`${taskHref(taskId)}/${path}`, button, { attrs: { class: className }, returnTo: surface, hidden: { run: control.run } });
821
+ const role = (word) => word.role === "planner" ? "plan attempt" : word.role === "scout" ? "scouting attempt" : word.role === "reviewer" ? "review" : "build";
822
+ if (control.kind === "stop" && surface === "chat") {
823
+ return joinHtml([
824
+ html `<section class="card task-control" id="task-control" data-task-control="stop" data-control-run="${control.run}"><details class="chat-stop-confirm"><summary>Stop task</summary>`,
825
+ html `<p>${taskId} · ${role(control)} #${control.run}</p><p>${CHAT_TASK_ACTIONS.stop.detail}</p>`,
826
+ guarded ? runForm("stop", "task-stop-form", html `<button type="submit" class="danger">Stop task</button>`) : "",
827
+ html `</details></section>`,
828
+ ]);
829
+ }
830
+ if (control.kind === "stop") {
831
+ return joinHtml([
832
+ html `<section class="card task-control" id="task-control" data-task-control="stop" data-control-run="${control.run}" aria-label="stop this attempt">`,
833
+ html `<div class="task-control-copy"><details><summary>Stop details · ${role(control)} #${control.run}</summary><p class="meta">Stopping ends only this attempt's own processes. Its branch, uncommitted work, evidence, and decisions stay preserved; other tasks keep running.</p></details></div>`,
834
+ stopElsewhere ? "" : guarded
835
+ ? runForm("stop", "inline task-control-form task-stop-form", html `<button type="submit" class="danger task-control-button">Stop</button>`)
836
+ : html `<button type="button" class="danger task-control-button" disabled>Stop</button>`,
837
+ html `</section>`,
838
+ ]);
839
+ }
840
+ if (control.kind === "stopping") {
841
+ return joinHtml([
842
+ html `<section class="card task-control" id="task-control" data-task-control="stopping" data-control-run="${control.run}" aria-label="stopping this attempt" aria-busy="true">`,
843
+ html `<div class="task-control-copy"><span class="eyebrow">stop requested</span><strong><span class="live-dot" aria-hidden="true"></span>Stopping ${role(control)} #${control.run}…</strong>`,
844
+ html `<span class="meta">Asked by <span class="mono">${control.stop.requestedBy}</span> at ${stopWhen(control.stop.requestedAt)}. ${control.unsettledRun ? "Its own processes are being ended; this reads Paused once they are established gone." : control.detail ?? "The run ended; a recovery pass still needs to establish that its processes exited."}</span></div>`,
845
+ html `<button type="button" class="task-control-button" disabled aria-disabled="true">Stopping…</button>`,
846
+ html `</section>`,
847
+ ]);
848
+ }
849
+ if (control.kind === "paused") {
850
+ const settled = control.stop.settledAt === null ? "" : ` · settled ${stopWhen(control.stop.settledAt)} (${control.stop.settlement ?? "?"})`;
851
+ const kept = control.stop.settlement === "finished"
852
+ ? html `The attempt reached its own ending (${control.outcome ?? "?"}) before the stop took effect; that outcome stands.`
853
+ : html `${control.committed ? "Its commit is on the branch as a reviewable artifact; a" : "A"}ny uncommitted work is preserved${control.worktree === null ? "" : html ` in <span class="mono">${control.worktree}</span>`}. Resuming takes a fresh claim, re-proves the signed scope, and requires fresh proof — nothing is approved by resuming.`;
854
+ return joinHtml([
855
+ html `<section class="card task-control" id="task-control" data-task-control="paused" data-control-run="${control.run}" aria-label="paused attempt">`,
856
+ html `<div class="task-control-copy"><span class="eyebrow">paused</span><strong>${role(control)} #${control.run} was stopped by <span class="mono">${control.stop.requestedBy}</span></strong>`,
857
+ html `<span class="meta">Asked ${stopWhen(control.stop.requestedAt)}${settled}. ${kept}</span></div>`,
858
+ guarded
859
+ ? runForm("resume-arm", "inline task-control-form task-resume-form", html `<button type="submit" class="task-control-button primary">Resume</button>`)
860
+ : html `<button type="button" class="task-control-button" disabled>Resume</button>`,
861
+ html `</section>`,
862
+ ]);
863
+ }
864
+ return html `<details class="card task-control" id="task-control" data-task-control="review-stopped"><summary>Previous assessment stopped</summary><p>Run #${control.run} was stopped by ${control.stop.requestedBy}. The saved result is unchanged.</p></details>`;
865
+ }
866
+ /** The resume confirmation (v52): the exact run restated, what resuming
867
+ * does and does not do, the gate that would still keep work from
868
+ * starting, and the password typed again. */
869
+ export function resumeCeremonyPage(chrome, data) {
870
+ const back = html `<p class="meta"><a href="${data.returnTo === "chat" ? taskChatHref(data.taskId) : taskHref(data.taskId)}">Keep paused</a></p>`;
871
+ const control = data.control;
872
+ return screen("resume", joinHtml([
873
+ html `<h1>Resume task?</h1><p class="resume-target"><strong>${data.taskTitle}</strong> · ${data.taskId} · Run #${control.run}</p>`,
874
+ html `<div class="card resume-ceremony">`,
875
+ html `<p class="row">Lifts this stop’s hold. Other holds stay in place.</p>`,
876
+ html `<p class="row">Continues saved work${control.committed ? " and its commit" : ""} under the current approved scope. The next attempt needs fresh evidence; the earlier handoff cannot count as a new result.</p>`,
877
+ html `<p class="row">Resuming grants no new approval or publishing permission. Scope, budget, agents, verification, and review limits still apply.</p>`,
878
+ html `<details><summary>Stop record and saved work</summary><p>Run #${control.run} was stopped by <span class="mono">${control.stop.requestedBy}</span> at ${stopWhen(control.stop.requestedAt)}${control.stop.settledAt === null ? "" : ` and settled ${stopWhen(control.stop.settledAt)} (${control.stop.settlement ?? "?"})`}.</p>${control.worktree === null ? "" : html `<p class="mono">${control.worktree}</p>`}</details>`,
879
+ data.approved ? "" : html `<p class="row problem-words"><strong>The scope is not approved as it stands</strong> — resuming lifts the pause, but no worker spends until the scope is approved again</p>`,
880
+ data.gate === null ? "" : html `<p class="row"><strong>Before work starts:</strong> ${data.gate.summary} — ${data.gate.detail}</p>`,
881
+ html `</div>`,
882
+ postForm(`${taskHref(data.taskId)}/resume`, joinHtml([
883
+ html `<label>Confirm with your password<input type="password" name="token" autocomplete="current-password"></label>`,
884
+ html `<button type="submit">Resume task</button>`,
885
+ ], "\n"), { attrs: { class: "card resume-form" }, returnTo: data.returnTo, hidden: { nonce: data.nonceValue, run: control.run } }),
886
+ back,
887
+ ], "\n"), { chrome });
888
+ }
889
+ export function taskViewSwitch(taskId, active) {
890
+ return joinHtml([
891
+ html `<nav class="task-view-switch" aria-label="task view">`,
892
+ html `<a href="${taskHref(taskId)}"${active === "overview" ? html ` class="active" aria-current="page"` : ""}>Overview</a>`,
893
+ html `<a href="${taskChatHref(taskId)}"${active === "ask" ? html ` class="active" aria-current="page"` : ""}>Ask</a>`,
894
+ html `</nav>`,
895
+ ]);
896
+ }
897
+ export function publicationFactsOf(publication) {
898
+ return publication === null
899
+ ? null
900
+ : { state: publication.state, prNumber: publication.prNumber, prUrl: publication.prUrl, remoteState: publication.remoteState, lastCheckState: publication.lastCheckState };
901
+ }
902
+ /** Where a status's next act lives, for a row that links out. */
903
+ export function statusActionHref(status, taskId, runId, prUrl, repo) {
904
+ if (status.action === null)
905
+ return null;
906
+ switch (status.action.kind) {
907
+ case "open-result": return runId === null ? taskHref(taskId) : `/r/${runId}`;
908
+ case "open-review": return repo === undefined ? reviewHref(taskId) : reviewHref(taskId, runId);
909
+ case "open-run": return runId === null ? taskHref(taskId) : `/r/${runId}`;
910
+ case "open-pr": return safePrUrl(prUrl) ?? (repo === undefined ? reviewHref(taskId) : reviewHref(taskId, runId));
911
+ case "open-task":
912
+ default: return taskHref(taskId);
913
+ }
914
+ }
915
+ /** The one status line every surface renders: a dot in the tone, the
916
+ * label, and the `data-work-status` token tests and CSS key off. */
917
+ /** A not-yet-finished task's shared headline from its dispatch diagnosis (task-status.ts). */
918
+ export function dispatchHeadline(d) {
919
+ const read = stageOfDispatch(d);
920
+ return taskStatusOf({ stage: read.stage, ...(read.need === undefined ? {} : { need: read.need }) }).headline;
921
+ }
922
+ export function statusLineHtml(status, extra = "") {
923
+ return html `<span class="status-line" data-work-status="${status.token}" data-tone="${status.tone}"><i class="status-dot" aria-hidden="true"></i><span class="status-label">${status.label}</span>${extra}</span>`;
924
+ }
925
+ export function workDiagnosticsHtml(diagnostics) {
926
+ return joinHtml((diagnostics ?? []).map(one => html `<p class="work-detail" data-work-diagnostic="${one.token}"><strong>${one.label}</strong> · ${one.detail}</p>`));
927
+ }
928
+ /**
929
+ * The decision card everywhere a person may ANSWER (portfolio, the
930
+ * selected-project inbox; the task page joins in slice 3): a reversible
931
+ * option answers with one tap on the card, labeled with its own words —
932
+ * never a letter; an irreversible option is a LINK to the decision page,
933
+ * where the server-side confirm=yes guard lives. The roll-up inbox keeps
934
+ * its links-only cards and never renders this partial. The recommended
935
+ * option wears a neutral badge — recommendation is not urgency, and magenta
936
+ * stays on the card's outline.
937
+ */
938
+ export function decisionAnswerCard(decision, csrf, now, chip, returnTo = null) {
939
+ const detailsHref = `/d/${decision.id}${returnTo === null ? "" : `?return=${encodeURIComponent(returnTo)}`}`;
940
+ const options = decision.options
941
+ .map(option => {
942
+ const recommended = option.id === decision.recommendation
943
+ ? html ` <span class="badge">Recommended</span>`
944
+ : "";
945
+ if (!option.reversible) {
946
+ return html `<p class="decide-option"><a href="${detailsHref}">${option.label}</a> <span class="badge badge-overdue">Irreversible</span>${recommended} <span class="meta">${option.consequence}</span></p>`;
947
+ }
948
+ return postForm(`/d/${decision.id}/answer`, html `<button type="submit">${option.label}</button>${recommended} <span class="meta">${option.consequence} · reversible</span>`, {
949
+ attrs: { class: "decide-option decide-inline" },
950
+ returnTo,
951
+ hidden: { choice: option.id },
952
+ });
953
+ });
954
+ return joinHtml([
955
+ html `<div class="decide-card" data-decision-id="${decision.id}">`,
956
+ html `<p class="q">${decision.question}</p>`,
957
+ html `<details class="decision-context"><summary>Context</summary><p class="meta">${decision.recap}</p><span class="meta mono">${decision.taskId}</span></details>`,
958
+ html `<p class="meta">${chip ? projectChip(decision.repo) : ""}`,
959
+ html `${isOverdue(decision, now) ? html ` <span class="badge badge-overdue">Overdue</span>` : ""}`,
960
+ html ` <a href="${detailsHref}">View details →</a></p>`,
961
+ html `<div class="decide-options">${joinHtml(options, "\n")}</div></div>`,
962
+ ]);
963
+ }
964
+ /**
965
+ * The inline-answer enhancement (portfolio arc §2): submits a reversible
966
+ * option's form as the urlencoded POST the forms-only gate expects, follows
967
+ * the redirect, and — because an answered decision leaves the open list —
968
+ * replaces ONLY that card with the answered receipt parsed from the
969
+ * decision page's own rendering, or removes the card. Anything unexpected
970
+ * (auth, a page that is not the decision's) navigates instead of inserting.
971
+ * Nothing else on the page is touched: typed input elsewhere survives.
972
+ */
973
+ export function decisionAnswerScript() {
974
+ return (`document.addEventListener("submit",function(e){` +
975
+ `var f=e.target;if(!f||!f.classList||!f.classList.contains("decide-inline"))return;` +
976
+ `e.preventDefault();` +
977
+ `var card=f.closest("[data-decision-id]");` +
978
+ `var page=f.action.replace(/\\/answer$/,"");` +
979
+ `fetch(f.action,{method:"POST",credentials:"same-origin",` +
980
+ `headers:{"content-type":"application/x-www-form-urlencoded"},` +
981
+ `body:new URLSearchParams(new FormData(f)).toString()})` +
982
+ `.then(function(r){return r.text().then(function(t){return{r:r,t:t}})})` +
983
+ `.then(function(x){` +
984
+ `var landed="";try{landed=new URL(x.r.url).pathname}catch(err){}` +
985
+ `if(!x.r.ok||landed!==new URL(page,location.href).pathname){location.href=page;return}` +
986
+ `var doc=new DOMParser().parseFromString(x.t,"text/html");` +
987
+ `var receipt=doc.querySelector(".answered");` +
988
+ `if(!card){location.href=page;return}` +
989
+ `if(receipt){card.replaceChildren(document.importNode(receipt,true))}else{card.remove()}` +
990
+ `},function(){location.href=page})});`);
991
+ }
992
+ /** A plan is stored as inert Markdown for portability into the builder
993
+ * brief, but the console renders its known structure as a useful control
994
+ * surface. Historical free-form plans keep their safe plain-text fallback. */
995
+ export const MILESTONE_STATE_WORDS = {
996
+ pending: "Pending",
997
+ current: "In progress",
998
+ completed: "Completed",
999
+ blocked: "Blocked",
1000
+ };
1001
+ export function milestoneStateWord(state) {
1002
+ return MILESTONE_STATE_WORDS[state] ?? "Pending";
1003
+ }
1004
+ /** Adaptive execution plans (v44): the live milestone projection a running
1005
+ * (or finished) build has reported. A shared render used by BOTH the task
1006
+ * page and the focused chat's live region (c2) — labeled clearly as an
1007
+ * agent's own report, since a milestone claim is never completion proof
1008
+ * (Priority 2's proof contract is the only thing that adjudicates "done"). */
1009
+ export function milestoneProgressHtml(milestones) {
1010
+ if (milestones === null || milestones === undefined || milestones.length === 0)
1011
+ return html ``;
1012
+ const completed = milestones.filter(one => one.state === "completed").length;
1013
+ return joinHtml([
1014
+ html `<section class="card milestone-progress"><div class="milestone-progress-head"><div><span class="eyebrow">live execution</span><h2>Build progress</h2></div>`,
1015
+ html `<span class="milestone-progress-count">${completed} of ${milestones.length} complete</span></div><ul class="milestone-list">`,
1016
+ joinHtml(milestones.map(one => html `<li class="milestone milestone-${one.state}"><span class="milestone-badge">${milestoneStateWord(one.state)}</span> ${one.description}${one.note === null ? "" : html ` <span class="meta">— ${one.note}</span>`}</li>`), "\n"),
1017
+ html `</ul><p class="meta milestone-progress-note">Live checkpoints from the agent.</p></section>`,
1018
+ ]);
1019
+ }
1020
+ /** Adaptive execution plans (v44): the current plan revision's own reason
1021
+ * and evidence, any revision still awaiting an operator's accept/reject,
1022
+ * and the immutable history. A shared render used by BOTH the task page
1023
+ * and the focused chat's live region (c2), reading a document already
1024
+ * verified before a byte reached this function (c5). `csrf` is only a
1025
+ * guard now: an empty one renders no forms; postForm writes the field. */
1026
+ export function planRevisionLedgerHtml(ledger, taskId, csrf) {
1027
+ // Nothing to show yet unless a plan has actually been revised: a task
1028
+ // still on its synthetic revision-1 projection, with no pending proposal
1029
+ // and no persisted history, is exactly what the plan card already shows.
1030
+ if (ledger === null || ledger === undefined || ledger.current === null || (ledger.pending === null && ledger.history.length === 0))
1031
+ return html ``;
1032
+ const current = ledger.current;
1033
+ const changedAuthority = (field) => {
1034
+ if (field === "scopeDigest" || field === "signed-scope")
1035
+ return "the work you approved";
1036
+ if (field === "deliverable" || field === "publication-authority")
1037
+ return "what the task may deliver";
1038
+ return field.replace(/[-_]+/g, " ");
1039
+ };
1040
+ const pendingRevision = ledger.pending;
1041
+ const pending = pendingRevision === null
1042
+ ? ""
1043
+ : joinHtml([
1044
+ html `<div class="plan-revision-pending"><span class="eyebrow">decision needed</span><h3>The agent recommends a plan change</h3>`,
1045
+ html `<p>${pendingRevision.reason}</p>`,
1046
+ html `<p class="meta">Revision ${pendingRevision.revision}${pendingRevision.authorityKind === "authority-change" ? ` changes ${pendingRevision.changedFields.map(changedAuthority).join(" and ")} from what you approved, so work is paused until you decide.` : " only changes the route, not the approved outcome."}${pendingRevision.evidenceLink === null ? "" : ` Evidence: ${pendingRevision.evidenceLink}.`}</p>`,
1047
+ executionPlanHtml(pendingRevision.document, true),
1048
+ csrf === "" || pendingRevision.id === null
1049
+ ? ""
1050
+ : joinHtml([
1051
+ postForm(`${taskHref(taskId)}/accept-revision`, joinHtml([
1052
+ pendingRevision.authorityKind === "authority-change"
1053
+ ? html `<input type="password" name="token" placeholder="approval password" required autocomplete="current-password">`
1054
+ : "",
1055
+ html `<button type="submit">${pendingRevision.authorityKind === "authority-change" ? html `Approve changes &amp; continue` : html `Use revised plan &amp; continue`}</button>`,
1056
+ ]), { attrs: { class: "inline" }, hidden: { "revision-id": pendingRevision.id } }),
1057
+ postForm(`${taskHref(taskId)}/reject-revision`, html `<button type="submit" class="quiet">Keep current plan</button>`, { attrs: { class: "inline" }, hidden: { "revision-id": pendingRevision.id } }),
1058
+ ]),
1059
+ html `</div>`,
1060
+ ]);
1061
+ const history = ledger.history.length <= 1
1062
+ ? ""
1063
+ : joinHtml([
1064
+ html `<details class="plan-revision-history"><summary>Revision history (${ledger.history.length})</summary>`,
1065
+ joinHtml(ledger.history.map(one => html `<p class="row"><span class="mono">rev ${one.revision}</span> <span class="badge">${sentenceCase(one.status)}</span> <span class="meta">${one.author} · ${whenTime(one.createdAt)}</span> — ${one.reason}</p>`), "\n"),
1066
+ html `</details>`,
1067
+ ]);
1068
+ return joinHtml([
1069
+ html `<div class="card plan-revision"><span class="eyebrow">plan updated</span><h2>Using plan revision ${current.revision}</h2>`,
1070
+ html `<p class="meta">${current.reason}${current.evidenceLink === null ? "" : ` · evidence: ${current.evidenceLink}`}</p>`,
1071
+ pending,
1072
+ history,
1073
+ html `</div>`,
1074
+ ]);
1075
+ }
1076
+ export function executionPlanHtml(document, compact = false) {
1077
+ const parsed = parseExecutionPlanDocument(document);
1078
+ if (!parsed.ok)
1079
+ return html `<pre class="recap plan-doc">${document}</pre>`;
1080
+ const plan = parsed.document;
1081
+ const items = (values, ordered = false) => ordered ? html `<ol>${values.map(value => html `<li>${value}</li>`)}</ol>` : html `<ul>${values.map(value => html `<li>${value}</li>`)}</ul>`;
1082
+ return joinHtml([
1083
+ html `<div class="execution-plan${compact ? " execution-plan-compact" : ""}">`,
1084
+ html `<div class="execution-approach"><span class="approval-label">approach</span><p>${plan.approach}</p></div>`,
1085
+ html `<div class="execution-milestones"><span class="approval-label">milestones</span>${items(plan.milestones, true)}</div>`,
1086
+ html `<div class="execution-support"><div><span class="approval-label">dependencies</span>${items(plan.dependencies)}</div>`,
1087
+ html `<div><span class="approval-label">risks &amp; mitigations</span>${items(plan.risks)}</div></div>`,
1088
+ html `<div class="execution-proof"><span class="approval-label">proof of done</span>${items(plan.proof)}</div>`,
1089
+ html `</div>`,
1090
+ ]);
1091
+ }
1092
+ /**
1093
+ * A revision's lineage and ACTUAL terms, in the same words on the task
1094
+ * page, the approval card, and chat (contract handoff task 2): which
1095
+ * source and build it revises, the ancestry to its root, the terms the
1096
+ * child really carries (read from its own scope — never restated from the
1097
+ * brief), what is re-resolved for THIS approval, what never inherits, and
1098
+ * — when the task continues a repair chain — the attempts used against
1099
+ * the signed cap.
1100
+ */
1101
+ export function revisionLineageWords(lineage) {
1102
+ const words = [];
1103
+ if (lineage.problem !== undefined)
1104
+ words.push(`lineage verification gap: ${lineage.problem}; no automatic repair allowance can be inferred`);
1105
+ const chain = lineage.ancestors.length > 1 ? ` · lineage ${lineage.ancestors.join(" → ")}` : "";
1106
+ words.push(`revises ${lineage.sourceTask}${lineage.sourceRun === null ? "" : ` (build #${lineage.sourceRun})`}${chain}`);
1107
+ if (!lineage.sourceHadScope) {
1108
+ words.push("the source had no scope — nothing was inherited; the placeholder rubric waits for a real one");
1109
+ }
1110
+ else if (lineage.terms !== null) {
1111
+ const terms = lineage.terms;
1112
+ words.push(`inherited terms, as they stand now: ${qualityModeTitle(terms.qualityMode)} quality · ` +
1113
+ `${terms.permissionMode === "bypassPermissions" ? "full access" : "auto permissions"} · ` +
1114
+ `${terms.budgetMicrousd === null ? "no attempt cap" : `$${(terms.budgetMicrousd / 1_000_000).toFixed(2)} attempt cap`} · ` +
1115
+ `${terms.exclusions ? "its exclusions" : "no exclusions"} · ${terms.touches === 0 ? "any path" : `${terms.touches} path limit${terms.touches === 1 ? "" : "s"}`} · ` +
1116
+ `${terms.criteria} criteri${terms.criteria === 1 ? "on" : "a"}${terms.routeOverrides === 0 ? "" : ` · ${terms.routeOverrides} agent override${terms.routeOverrides === 1 ? "" : "s"}`}`);
1117
+ }
1118
+ words.push("re-resolved for this approval: the agents route — a yes on the source never covers it");
1119
+ words.push("never inherited: the source's approval, publication and merge grants");
1120
+ if (lineage.repair !== null) {
1121
+ const repair = lineage.repair;
1122
+ words.push(`repair chain rooted at ${repair.rootTask}: ${repair.attemptsUsed} attempt${repair.attemptsUsed === 1 ? "" : "s"} used` +
1123
+ `${repair.cap === null ? " — each further attempt needs your yes" : ` of ${repair.cap} automatic · ${repair.remaining} remaining`}` +
1124
+ `${repair.thisAttempt === null ? "" : ` · this is attempt ${repair.thisAttempt}`}${repair.via === null ? "" : ` · continued through ${repair.via}`}`);
1125
+ }
1126
+ return words;
1127
+ }
1128
+ export function revisionLineageHtml(lineage) {
1129
+ if (lineage === null)
1130
+ return html ``;
1131
+ return html `<ul class="meta revision-lineage">${revisionLineageWords(lineage).map(one => html `<li>${one}</li>`)}</ul>`;
1132
+ }
1133
+ export function taskBodyParts(data) {
1134
+ const { task, scope } = data;
1135
+ const stopControlsActive = data.control !== undefined && ["stopping", "paused", "review-stopped"].includes(data.control.kind);
1136
+ // The result's shared status (workspace package 1): the same projection
1137
+ // the Work row, the focused chat, and the review cockpit render for this
1138
+ // run, so a done task with failed or missing proof never wears a bare
1139
+ // "done" badge here while another surface says otherwise.
1140
+ // The receipt re-verified the stored bytes (repair 2026-09-14), so the
1141
+ // box wears the receipt's own status whenever a receipt exists — the
1142
+ // two never disagree on one page about damaged evidence.
1143
+ const resultStatus = task.state !== "done"
1144
+ ? null
1145
+ : data.completion?.receipt !== null && data.completion?.receipt !== undefined
1146
+ ? receiptStatusOf(data.completion.receipt)
1147
+ : resultStatusOf(data.completion === null || data.completion === undefined
1148
+ ? null
1149
+ : {
1150
+ runId: data.completion.runId,
1151
+ role: data.runs.find(one => one.id === data.completion?.runId)?.role ?? null,
1152
+ outcome: data.completion.outcome,
1153
+ verdict: data.completion.proofVerdict,
1154
+ reasons: data.completion.proofReasons,
1155
+ accepted: data.completion.proofAccepted,
1156
+ recordComplete: data.completion.hasHandoff && data.completion.hasTerminalDiff,
1157
+ review: data.completion.review ?? null,
1158
+ }, publicationFactsOf(data.publication ?? null));
1159
+ const status = data.status ?? workStatusOf({ id: task.id, title: task.title, repo: data.repo, state: task.state, updatedAt: task.updatedAt, dispatch: data.dispatch ?? null, result: null, publication: null, liveRunId: data.liveRunId ?? null, control: data.control ?? { kind: "none" } }, resultStatus ?? undefined);
1160
+ // A review in flight leads the status box too (review fixes, finding
1161
+ // 4): the box keeps its recorded-verdict sentence as history under the
1162
+ // review's own words, and reads neutral rather than ok/problem while the
1163
+ // machine is still deciding.
1164
+ const inReview = resultStatus !== null && REVIEW_TOKENS.has(resultStatus.token);
1165
+ const act = (verb, label, hidden = {}) => postForm(`${taskHref(task.id)}/${verb}`, html `<button type="submit">${label}</button>`, { attrs: { class: "inline" }, hidden });
1166
+ // The active attempt panel (slice 1c): the run whose lease is the task's
1167
+ // CURRENT claim, named by its one unambiguous identity — build number and
1168
+ // worker (runsFor mixes roles newest-first, so an "attempt N" ordinal is
1169
+ // undefined and not used). The live peek and transcript embed here,
1170
+ // polled from the RUN's own authenticated fragments; a serve that never
1171
+ // asserted its runner says so in the same words the run page uses; a
1172
+ // page carrying a password ceremony degrades to the static line.
1173
+ const liveRunId = data.liveRunId ?? null;
1174
+ const liveRun = liveRunId === null ? undefined : data.runs.find(one => one.id === liveRunId);
1175
+ const liveHistoryRunId = data.control?.kind === "stop" && data.control.role === "reviewer" && data.completion?.review?.reviewerAlive === true ? data.control.run : liveRunId;
1176
+ const degraded = data.degraded === "sensitive";
1177
+ const attemptPanel = (() => {
1178
+ if (liveRunId === null || liveRun === undefined)
1179
+ return "";
1180
+ const minutes = Math.max(0, Math.round((data.now.getTime() - new Date(liveRun.startedAt).getTime()) / 60_000));
1181
+ const head = html `<p><strong>Build #${liveRun.id} · ${liveRun.runner} · running <time data-elapsed-since="${liveRun.startedAt}">${minutes}m</time></strong></p>`;
1182
+ const door = html `<p class="row"><a href="/r/${liveRun.id}">full build view →</a></p>`;
1183
+ if (data.degraded !== undefined) {
1184
+ return html `<div class="card attempt-live" data-live-run="${liveRun.id}">${head}<p class="meta">${data.degraded === "sensitive" ? "This page carries a password ceremony, so the live view stays on the build page" : "The live view is on the build page"}</p>${door}</div>`;
1185
+ }
1186
+ // With the live file view off there is nothing live to show here: no panel (the build's own page says why).
1187
+ if (data.peekable !== true)
1188
+ return "";
1189
+ const peek = html `<p class="meta">What is changing right now</p><div id="run-peek"><p class="meta">Watching… the first look lands within 15 seconds</p></div><p class="meta" id="run-peek-stamp"></p>`;
1190
+ const transcript = liveRun.provider !== "claude"
1191
+ ? html `<p class="meta">The live transcript needs the claude harness for now — this build runs on ${liveRun.provider}</p>`
1192
+ : html `<p class="meta">What the agent is saying · display only — this is not evidence, and the machine running the agent could alter it</p><pre id="live-transcript" class="mono" style="max-height:18rem;overflow:auto;white-space:pre-wrap"></pre><p class="meta" id="live-transcript-state"></p>`;
1193
+ return html `<div class="card attempt-live" data-live-run="${liveRun.id}">${head}${peek}${transcript}${door}</div>`;
1194
+ })();
1195
+ // One truthful answer to the first question on a queued task: "will this
1196
+ // run?" The badge alone cannot distinguish approval, dependency,
1197
+ // capability, and worker gates. This card does, in priority order, and
1198
+ // gives the nearest concrete repair rather than making the operator infer
1199
+ // it from the rest of the page.
1200
+ const dispatchStatus = (() => {
1201
+ // data-work-status is the shared projection's token (package 1) —
1202
+ // the same one the Work row, chat, and cockpit carry for this task.
1203
+ const workToken = resultStatus?.token ?? data.dispatch?.code ?? "unknown";
1204
+ // A diagnosis under Task options: the exact technical reason, one tap
1205
+ // away from the shared headline (task-status.ts). Red only when that
1206
+ // headline is Failed.
1207
+ const headline = status.label;
1208
+ const box = (kind, title, detail, code, controls = "") => html `<div class="${kind === "problem" && !inReview && headline === "Failed" ? "problem" : "answered"} dispatch-status" id="run-status" data-dispatch-status="${code ?? title.toLowerCase().replace(/[^a-z0-9]+/g, "-")}" data-work-status="${workToken}"><div class="dispatch-copy"><strong>${inReview ? "Recorded checks" : title}</strong><span class="meta">${detail}</span></div>${controls}</div>`;
1209
+ if (task.state !== "done") {
1210
+ const diagnosis = data.dispatch ?? null;
1211
+ if (diagnosis === null)
1212
+ return box("problem", "Dispatch unknown", "Refresh this task before relying on its scheduler state.", "unknown");
1213
+ if (diagnosis.code === "running" && liveRun !== undefined) {
1214
+ return box("ok", diagnosis.summary, html `Worker <span class="mono">${liveRun.runner}</span> owns <a href="/r/${liveRun.id}">build #${liveRun.id}</a>.`, diagnosis.code);
1215
+ }
1216
+ const blocker = diagnosis.blockerTaskId === null
1217
+ ? null
1218
+ : (data.waitsFor ?? []).find(one => one.id === diagnosis.blockerTaskId);
1219
+ const action = (() => {
1220
+ switch (diagnosis.action) {
1221
+ case "start-worker": return "";
1222
+ case "write-scope": return html ` <a href="#scope">Write the success contract</a> or use <strong>plan first</strong>.`;
1223
+ case "select-agent": return html ` <a href="#scope">Choose an available provider and model</a>.`;
1224
+ case "approve-scope": return html ` <a href="#approve">Review and sign the exact scope</a>.`;
1225
+ case "answer-decision": return html ` <a href="#decisions">Answer the waiting question</a>.`;
1226
+ case "unhold": return html ` Use <strong>Remove hold</strong> when it can continue.`;
1227
+ // The reason is the detail itself; Retry is the status card's own act, said once.
1228
+ case "retry-task": return "";
1229
+ case "repair-capability": return html ` <a href="/caps">Repair the requirement</a>.`;
1230
+ case "repair-dependency":
1231
+ return blocker?.admitted === true
1232
+ ? html ` <a href="${taskHref(blocker.id)}">Review that task</a>.`
1233
+ : "";
1234
+ default: return "";
1235
+ }
1236
+ })();
1237
+ const repairControls = (() => {
1238
+ if (diagnosis.action !== "repair-dependency" || blocker == null || data.csrf === "")
1239
+ return "";
1240
+ const endpoint = `${taskHref(task.id)}/repair-dependency`;
1241
+ const repairForm = (className, operation, body) => postForm(endpoint, body, { attrs: { class: className }, hidden: { blocker: blocker.id, operation } });
1242
+ const retry = blocker.admitted && blocker.state === "failed"
1243
+ ? repairForm("dependency-repair-retry", "retry", html `<button type="submit">Try that task again</button>`)
1244
+ : "";
1245
+ const unlink = repairForm("dependency-repair-unlink", "unlink", html `<button type="submit" class="quiet">Continue without it</button>`);
1246
+ const standing = new Set((data.waitsFor ?? []).map(one => one.id));
1247
+ const replacements = (data.waitCandidates ?? []).filter(one => !standing.has(one.id));
1248
+ const replace = replacements.length === 0
1249
+ ? ""
1250
+ : repairForm("dependency-repair-replace", "replace", html `<label class="dependency-repair-label">Choose another task that must finish first<select name="replacement" aria-label="another task that must finish first">${replacements.map(one => html `<option value="${one.id}">${one.title}</option>`)}</select></label><button type="submit" class="quiet">Wait for selected task</button>`);
1251
+ return html `<div class="dependency-repair-actions" aria-label="ways to continue this task"><p class="meta dependency-repair-help">Choose another task that must finish first, or let this task continue without it.</p>${retry}${replace}${unlink}</div>`;
1252
+ })();
1253
+ const recoveryControl = (() => {
1254
+ if (stopControlsActive || diagnosis.action === null || diagnosis.action === "repair-dependency")
1255
+ return "";
1256
+ if (diagnosis.action === "start-worker") {
1257
+ const firstConnection = diagnosis.code === "no-worker-registered";
1258
+ return joinHtml([
1259
+ html `<details class="dispatch-recovery" open><summary>${dispatchActionLabel(diagnosis)}</summary><div class="dispatch-recovery-body">`,
1260
+ firstConnection
1261
+ ? html `<p>Toolroll is open, but this project has not been connected to a builder yet.</p><p>On the machine where the project lives, open that folder and run:</p>`
1262
+ : html `<p>Toolroll is open, but this project's builder stopped checking in. Reopen Toolroll on the machine where the project lives.</p><p>If you normally start it from a terminal, open the project folder and run:</p>`,
1263
+ html `<code class="dispatch-recovery-command">toolroll up</code>`,
1264
+ firstConnection
1265
+ ? html `<p class="meta">This is the normal start command: it opens the app, connects the project, and starts its builder. Keep Toolroll running; approved tasks begin automatically.</p>`
1266
+ : html `<p class="meta">This task resumes automatically when the builder reconnects. You do not need to file or approve it again.</p>`,
1267
+ html `<p><a href="/system">See connection status →</a></p></div></details>`,
1268
+ ]);
1269
+ }
1270
+ // Never a link to this page's own acts: Retry sits on the status card and below.
1271
+ const href = diagnosis.action === "retry-task" ? null : taskRecoveryHref(task.id, diagnosis);
1272
+ return href === null ? "" : html `<a class="button-link dispatch-action-link" href="${href}">${dispatchActionLabel(diagnosis)}</a>`;
1273
+ })();
1274
+ const positive = diagnosis.code === "running" || diagnosis.code === "ready" || diagnosis.code === "planning-ready" || diagnosis.code === "scouting-ready";
1275
+ const status = diagnosis.code === "ready" ? "ready-to-run" : diagnosis.code;
1276
+ const repairingDependency = diagnosis.action === "repair-dependency" && blocker !== null;
1277
+ const dependencyDetail = blocker?.admitted === true
1278
+ ? html `This task was waiting for <strong>${blocker.title ?? blocker.id}</strong>, but that task was ${blocker.state ?? "stopped"}.${action}`
1279
+ : "This task is waiting for other work that did not finish.";
1280
+ return box(positive ? "ok" : "problem", repairingDependency ? "Choose what happens next" : diagnosis.summary, repairingDependency ? dependencyDetail : html `${diagnosis.detail}${action}`, status, repairControls || recoveryControl);
1281
+ }
1282
+ if (task.state === "done") {
1283
+ const proof = data.completion ?? null;
1284
+ if (proof === null || resultStatus === null) {
1285
+ return box("problem", "Marked done without a build record", "The task is done, but no finished attempt is recorded, so there is nothing to verify.", "no-build-record");
1286
+ }
1287
+ if (data.assignment?.receipt?.runId === proof.runId) {
1288
+ const accepted = data.assignment.receipt.proofAcceptance;
1289
+ return joinHtml([
1290
+ html `<details class="dispatch-proof-details"><summary>Previous assessment</summary><div class="dispatch-proof-body">`,
1291
+ html `<p class="meta">Saved assessment history. Current task status and checks are shown above.</p>`,
1292
+ accepted === null ? "" : html `<p class="meta">Accepted with an exception by ${accepted.approver}. Check results are unchanged.${accepted.note === null ? "" : ` ${accepted.note}`}</p>`,
1293
+ proof.proofReasons.length === 0 ? "" : html `<ul>${[...new Set(proof.proofReasons.map(plainReasonWords))].map(reason => html `<li>${reason}</li>`)}</ul>`,
1294
+ criterionMatrixHtml(proof.proofMatrix, { compact: true, runId: proof.runId, links: proof.proofMatrixLinks, verdict: proof.proofVerdict }),
1295
+ semanticCoverageHtml(proof.proofMatrix, proof.qualityMode ?? "default"), html `</div></details>`,
1296
+ ]);
1297
+ }
1298
+ // v50: the independent review's own card — attempt counts, what is
1299
+ // running or queued, the latest failure in words, and the ONE
1300
+ // explicit act (Retry review) exactly when the allowance admits it.
1301
+ const reviewCard = reviewRetryPanel(task.id, proof.runId, proof.reviewRetry ?? null, {
1302
+ csrf: data.csrf,
1303
+ canAct: data.canRetryReview === true,
1304
+ returnTo: null,
1305
+ });
1306
+ const withReview = (...parts) => joinHtml([reviewCard, ...parts]);
1307
+ // A no-change conclusion never owes a proof — there is no diff to
1308
+ // check acceptance criteria or a changed-path claim against — so it
1309
+ // keeps reading on the two presence facts alone, exactly as before
1310
+ // the proof system existed (the "attested floor" this preserves).
1311
+ if (proof.outcome === "no-change") {
1312
+ const missing = [proof.hasHandoff ? null : "agent handoff", proof.hasTerminalDiff ? null : "terminal diff"]
1313
+ .filter((one) => one !== null);
1314
+ return withReview(missing.length > 0
1315
+ ? box("problem", resultStatus.label, html `<a href="/r/${proof.runId}">Build #${proof.runId}</a> concluded no change was needed, but its ${missing.join(" and ")} is missing.`, resultStatus.token)
1316
+ : box("ok", resultStatus.label, html `<a href="/r/${proof.runId}">Build #${proof.runId}</a> concluded no change was needed; its handoff and machine-captured diff are on record.`, resultStatus.token));
1317
+ }
1318
+ // A built run's verdict is the machine's own — computed once at
1319
+ // completion by adjudicate() (Priority 2), never re-derived here.
1320
+ // Acceptance changes the CLASS (problem → ok) and the words, never
1321
+ // the underlying token: the surfaces still agree on what happened.
1322
+ const accepted = proof.proofAccepted;
1323
+ const humanReview = manualReviewOnly({ verdict: proof.proofVerdict ?? "", reasons: proof.proofReasons, matrix: proof.proofMatrix });
1324
+ // Accepting contradictory or incomplete evidence is an explicit,
1325
+ // recorded exception—not the ordinary magenta approval ceremony.
1326
+ const acceptForm = accepted || data.csrf === ""
1327
+ ? ""
1328
+ : html `<details class="proof-exception"><summary>${humanReview ? "Accept result" : "Accept with exception"}</summary>${postForm(`${taskHref(task.id)}/accept-proof`, html `<input type="text" name="note" maxlength="500" placeholder="${humanReview ? "What did you verify?" : "Why is this safe to accept?"}" aria-label="${humanReview ? "review note" : "exception reason"}" required><button type="submit">${humanReview ? "Accept result" : "Accept with exception"}</button>`, {
1329
+ attrs: { class: "proof-exception-form" },
1330
+ hidden: { run: proof.runId },
1331
+ })}</details>`;
1332
+ // v40: the machine's own pre-fold verdict, restated when a review
1333
+ // moved it — "the machine attested it; reviewer:codex contradicted
1334
+ // c2" — never pretending the machine always disagreed.
1335
+ const machineNote = !reviewConflict(proof.proofMatrix, proof.machineVerdict, proof.proofVerdict)
1336
+ ? ""
1337
+ : html `<p class="meta">An independent review found conflicting evidence.</p>`;
1338
+ const chainHtml = repairChainHtml(proof.repairChain);
1339
+ // v51: semantic coverage sits beside the matrix, never inside the
1340
+ // verdict word — what the independent reviewer settled, under the
1341
+ // signed policy, with every context gap named.
1342
+ const coverageHtml = semanticCoverageHtml(proof.proofMatrix, proof.qualityMode ?? "default");
1343
+ const proofDetails = proof.proofMatrix.length === 0 && machineNote === "" && isEmptyHtml(chainHtml)
1344
+ ? ""
1345
+ : html `<details class="dispatch-proof-details"><summary>${proof.proofMatrix.length === 0 ? "Verification details" : `${proof.proofMatrix.length} requirement${proof.proofMatrix.length === 1 ? "" : "s"}`} · View details</summary><div class="dispatch-proof-body">${criterionMatrixHtml(proof.proofMatrix, { compact: true, runId: proof.runId, links: proof.proofMatrixLinks, verdict: proof.proofVerdict })}${coverageHtml}${machineNote}${chainHtml}</div></details>`;
1346
+ // The publication fact is separate from the evidence fact: a PR or
1347
+ // an observed merge is named from its record; nothing is "deployed".
1348
+ const publicationWords = (data.publication === null || data.publication === undefined ? "" : ` ${receiptPublicationWords(publicationFactsOf(data.publication))}`) +
1349
+ // Shortened records say so here too (repair 2026-09-14).
1350
+ (proof.receipt === null ? "" : ` ${evidenceShortenedWords(proof.receipt.facts.evidenceHealth)}`).replace(/ $/, "");
1351
+ // Damaged evidence (repair 2026-09-14): the box wears the receipt's
1352
+ // own problem words — never "ok" with a verdict that predates the damage.
1353
+ if (resultStatus.token === "evidence-damaged") {
1354
+ return withReview(box("problem", resultStatus.label, html `<a href="/r/${proof.runId}">Build #${proof.runId}</a> finished. ${resultStatus.detail}${publicationWords}`, "evidence-damaged"), html `<div class="proof-review-actions"><a class="button-link" href="/r/${proof.runId}">Review the evidence problems</a></div>`, proofDetails);
1355
+ }
1356
+ if (proof.proofVerdict === "verified" && !accepted) {
1357
+ const recovered = verificationRecovered(proof.proofReasons);
1358
+ return withReview(box("ok", resultStatus.label, recovered
1359
+ ? html `<a href="/r/${proof.runId}">Build #${proof.runId}</a> finished. <span data-automatic-recovery="succeeded">Toolroll ran the approved setup automatically, then the project check passed.</span>${publicationWords}`
1360
+ : html `<a href="/r/${proof.runId}">Build #${proof.runId}</a> finished as ${proof.outcome ?? "terminal"}, and the repository's approved verification command passed against it.${publicationWords}`, dispatchStatusToken("verified")), proofDetails);
1361
+ }
1362
+ if (proof.proofVerdict === "attested" && !accepted) {
1363
+ return withReview(box("ok", resultStatus.label, html `<a href="/r/${proof.runId}">Build #${proof.runId}</a> finished as ${proof.outcome ?? "terminal"}. No independent project check ran: the checks listed are the agent's own report, and each item below is labeled by source.${publicationWords}`, dispatchStatusToken("attested")), proofDetails);
1364
+ }
1365
+ // A refuted, short, or absent verdict — and an accepted one, which
1366
+ // stays visibly an exception: the words never say checks passed.
1367
+ const machineToken = proof.proofVerdict === "refuted" ? dispatchStatusToken("refuted") : dispatchStatusToken("short");
1368
+ return withReview(box(accepted ? "ok" : "problem", resultStatus.label, html `<a href="/r/${proof.runId}">Build #${proof.runId}</a> finished. ${verificationExplanation(proof.proofVerdict, proof.proofReasons)}${accepted ? html ` An approver accepted it by hand; that acceptance leaves the machine's verdict above unchanged.` : ""}${publicationWords}`, machineToken), html `<div class="proof-review-actions"><a class="button-link" href="${reviewHref(task.id)}">${accepted ? "Review the recorded exception" : resultStatus.action?.label ?? "Review evidence"}</a>${acceptForm}</div>`, proofDetails);
1369
+ }
1370
+ return box("problem", "Dispatch unknown", "Refresh this task before relying on its scheduler state.", "unknown");
1371
+ })();
1372
+ // Operator steering (arc 1): notes for the agent, each wearing exactly
1373
+ // where it stands — waiting, attached, proven delivered, or superseded.
1374
+ const steering = data.steering ?? [];
1375
+ const steerState = (one) => one.authorshipState !== "verified"
1376
+ ? "recorded before steering required a credential — never delivered"
1377
+ : one.supersededAt !== null
1378
+ ? "the task ended before this landed"
1379
+ : one.deliveredAt !== null
1380
+ ? `reached build #${one.attachedRun}`
1381
+ : one.attachedRun !== null
1382
+ ? `attached to build #${one.attachedRun} — delivery not yet proven`
1383
+ : "waiting for the next attempt";
1384
+ const steerRows = steering.length === 0 ? "" : joinHtml(steering.map(one => html `<p class="row"><span class="meta">${one.author} · ${whenTime(one.createdAt)} · ${steerState(one)}</span> ${one.note}</p>`), "\n");
1385
+ const steerForm = data.csrf === "" || task.state === "done" || task.state === "cancelled"
1386
+ ? ""
1387
+ : joinHtml([
1388
+ postForm(`${taskHref(task.id)}/steer`, html `<input type="text" name="note" placeholder="guidance for the next attempt" aria-label="steering note" style="width:100%;max-width:28rem"><button type="submit">Steer</button>`, { attrs: { class: "row" } }),
1389
+ html `<p class="meta">Lands when the next attempt starts — a running agent is not interrupted, and a note cannot widen the approved scope</p>`,
1390
+ ]);
1391
+ const steeringCard = steerRows === "" && steerForm === "" ? "" : html `<h2>Steering</h2>${steerRows}${steerForm}`;
1392
+ const holds = data.holds.length === 0
1393
+ ? ""
1394
+ : joinHtml([
1395
+ html `<h2>Holds</h2>`,
1396
+ joinHtml(data.holds.map(hold => html `<p class="row">${holdOwnerWords(hold.ownerKind)} — ${hold.reason}${hold.until === null ? "" : html ` <span class="meta">until ${whenTime(hold.until)}</span>`}</p>`), "\n"),
1397
+ html `<p class="meta">Only your hold can be lifted here — waits caused by questions, incidents, or retry delays clear on their own</p>`,
1398
+ ]);
1399
+ const approval = approvalOf(scope);
1400
+ // A refused save from the sheet's in-place editor reopens that editor with
1401
+ // its draft; the full scope form below stays as it was.
1402
+ const inlineScopeDraft = data.scopeDraft?.has("requirement-new") === true;
1403
+ const scopeDraft = inlineScopeDraft ? undefined : data.scopeDraft;
1404
+ const scopeCard = scope === null
1405
+ ? html `<p class="meta">No scope proposed — nothing builds this until one is approved</p>`
1406
+ : joinHtml([
1407
+ html `<div class="card">`,
1408
+ html `<p><strong>Goal</strong></p><p class="recap">${scope.goal}</p>`,
1409
+ scope.outOfScope === null ? "" : html `<p><strong>Not this</strong></p><p class="recap">${scope.outOfScope}</p>`,
1410
+ scope.touches.length === 0 ? "" : html `<p class="scope-paths"><strong>Touches</strong> ${scope.touches.join(", ")}</p>`,
1411
+ html `<p><strong>Quality</strong> ${qualityModeTitle(scope.qualityMode ?? "default")}</p>`,
1412
+ acceptanceCeremonyHtml(scope.acceptance),
1413
+ approval.approved
1414
+ ? html `<p class="meta scope-seal">Approved by ${approval.by} · ${whenTime(approval.at)} · <span class="seal">signs ${shortDigest(scope.digest)}</span><span class="so-sr-only"> — approval binds to this exact wording</span></p>`
1415
+ : html `<p class="meta"><span class="seal">signs ${shortDigest(scope.digest)}</span> — approval binds to this exact wording</p><p class="meta">Not approved${approval.reason === "changed" ? " — approved once, then rewritten" : ""}</p>`,
1416
+ html `</div>`,
1417
+ ], "\n");
1418
+ // The scout's report (mate arc §10): title, summary, the document inert,
1419
+ // and each follow-up as a filing the operator makes with one tap. A
1420
+ // report that exists but cannot be verified is a named problem, never a
1421
+ // blank — the same rule as the revision brief.
1422
+ const reportCard = data.report === null || data.report === undefined
1423
+ ? data.deliverable === "report"
1424
+ ? html `<div class="card"><p><strong>Scout task</strong> <span class="meta">delivers a report, never a branch — a read-only session investigates the goal and its report appears here when it finishes</span></p></div>`
1425
+ : ""
1426
+ : !data.report.ok
1427
+ ? html `<div class="card"><p><strong>The report</strong></p><p class="meta">${data.report.problem} · <a href="/r/${data.report.run}">run ${data.report.run}</a></p></div>`
1428
+ : joinHtml([
1429
+ html `<div class="card report">`,
1430
+ html `<p><strong>${data.report.report.title}</strong> <span class="meta">the scout's report · <a href="/r/${data.report.run}">run ${data.report.run}</a></span></p>`,
1431
+ html `<p class="report-summary">${data.report.report.summary}</p>`,
1432
+ reportItemsHtml(data.report.report.items, data.report.shots, data.report.run),
1433
+ html `<pre class="recap plan-doc">${data.report.report.report}</pre>`,
1434
+ ...(data.report.report.followUps.length === 0
1435
+ ? []
1436
+ : [
1437
+ html `<p><strong>Follow-ups the scout proposes</strong> <span class="meta">each files as a task in this repository; its scope still needs your approval</span></p>`,
1438
+ ...data.report.report.followUps.map((one, index) => joinHtml([
1439
+ html `<div class="follow-up"><p><strong>${one.title}</strong></p><p class="meta">${one.goal}</p>`,
1440
+ data.csrf === "" || data.repo === null
1441
+ ? html `<p class="meta">${data.repo === null ? "this task has no repository — file it by hand" : ""}</p>`
1442
+ : postForm(`${taskHref(task.id)}/follow-up`, html `<button type="submit">File this follow-up</button>`, { attrs: { class: "inline" }, hidden: { index } }),
1443
+ html `</div>`,
1444
+ ])),
1445
+ ]),
1446
+ html `</div>`,
1447
+ ], "\n");
1448
+ // The plan a planner drafted, when one exists: rendered inert above the
1449
+ // approval it proposes. The scope stays the contract; this is the road.
1450
+ // Adaptive execution plans (v44): once approved, the CURRENT revision's
1451
+ // text displays here — the same document the builder actually reads —
1452
+ // rather than the original artifact frozen at approval time; the edit
1453
+ // form and its staleness guard stay bound to that original artifact,
1454
+ // since editing is a pre-approval act this ledger does not touch.
1455
+ const currentRevisionForDisplay = data.planRevisions?.current ?? null;
1456
+ const displayedPlanDocument = currentRevisionForDisplay !== null && currentRevisionForDisplay.revision > 1
1457
+ ? currentRevisionForDisplay.document
1458
+ : data.planDocument;
1459
+ const displayedPlan = displayedPlanDocument === null ? null : parseExecutionPlanDocument(displayedPlanDocument);
1460
+ const planMilestoneCount = displayedPlan !== null && displayedPlan.ok ? displayedPlan.document.milestones.length : null;
1461
+ const planStanding = currentRevisionForDisplay !== null && currentRevisionForDisplay.revision > 1
1462
+ ? `approved · revision ${currentRevisionForDisplay.revision}`
1463
+ : approval.approved
1464
+ ? "approved"
1465
+ : "review before starting";
1466
+ // While the approval sheet is open it states any amendment; the plan
1467
+ // card does not repeat it (one #contract-amendment on the page).
1468
+ const sheetStatesContract = scope !== null && !approval.approved && data.plan === "drafted" && data.dispatch?.action !== "repair-dependency" &&
1469
+ !(data.revision != null && "problem" in data.revision) && scope.profileState !== "unresolved" && consentDoorOf(scope, data.route).open;
1470
+ const planCard = data.planDocument === null
1471
+ ? data.plan === "requested"
1472
+ ? data.revision != null && !("problem" in data.revision)
1473
+ ? html `<div class="card planner-status"><span class="planner-orb" aria-hidden="true"></span><p><strong>Updating the plan</strong><span class="meta">Adding your notes to the plan. You approve any change before it builds.</span></p></div>`
1474
+ : html `<div class="card planner-status"><span class="planner-orb" aria-hidden="true"></span><p><strong>Planning requested</strong><span class="meta">${data.planAuto ? "Automatic approval is enabled for a verified plan that preserves your filed contract. Amendments and unanswered questions still pause." : "The agent is inspecting the repository and drafting the goal, acceptance criteria, and approach. It will ask only if a missing answer changes the work."}</span></p></div>`
1475
+ : ""
1476
+ : joinHtml([
1477
+ approval.approved ? html `<details class="card planner-plan planner-plan-collapsed"><summary class="execution-plan-head">` : html `<section class="card planner-plan"><div class="execution-plan-head">`,
1478
+ html `<div><span class="eyebrow">execution plan</span><h2>${approval.approved ? `${planMilestoneCount ?? "Full"} step${planMilestoneCount === 1 ? "" : "s"} · open to review` : "How the agent will tackle this"}</h2>`,
1479
+ approval.approved ? html `<p class="meta">The agent can adapt this route when evidence changes; your approved outcome stays fixed.</p>` : "",
1480
+ html `</div><span class="plan-lock">${planStanding}</span>${approval.approved ? html `</summary><div class="planner-plan-body">` : html `</div>`}`,
1481
+ executionPlanHtml(displayedPlanDocument ?? data.planDocument),
1482
+ approval.approved || sheetStatesContract ? "" : planContractHtml(data.planContract ?? null, "full"),
1483
+ data.csrf === "" || data.planSha == null || approval.approved
1484
+ ? ""
1485
+ : html `<details class="plan-editor" id="plan-edit"><summary>Edit plan</summary>${postForm(`${taskHref(task.id)}/plan-edit`, joinHtml([
1486
+ html `<label>Plan details <span class="meta">Keep the five headings. Approval locks this version for the build.</span>`,
1487
+ html `<textarea name="plan-document" rows="14">${data.planDocument}</textarea></label>`,
1488
+ html `<button type="submit">Save plan</button>`,
1489
+ ]), { hidden: { "saw-plan": data.planSha } })}</details>`,
1490
+ approval.approved ? html `</div></details>` : html `</section>`,
1491
+ ]);
1492
+ const progressCard = milestoneProgressHtml(data.milestoneProgress);
1493
+ const revisionLedgerCard = planRevisionLedgerHtml(data.planRevisions, task.id, data.csrf);
1494
+ // The revision batch (M6.8), restated on the SAME screen as the approval
1495
+ // it belongs to: the approver sees exactly the comments the brief carries.
1496
+ // A brief that cannot be verified is a named problem, never a blank.
1497
+ const revisionCard = data.revision === null || data.revision === undefined
1498
+ ? ""
1499
+ : "problem" in data.revision
1500
+ ? html `<div class="card"><p><strong>Revision brief</strong></p><p class="meta">${data.revision.problem}</p></div>`
1501
+ : joinHtml([
1502
+ html `<div class="revision-card" data-revision-feedback>`,
1503
+ html `<p><strong>${data.revision.kind === "ci-repair" ? "CI repair" : data.revision.kind === "criterion-repair" ? "Criterion repair" : "Revision feedback"}</strong> <span class="meta">from <a href="/r/${data.revision.sourceRun}">build #${data.revision.sourceRun}</a></span></p>`,
1504
+ ...data.revision.comments.map(one => html `<p class="row"><span class="meta">${one.author}</span> ${one.path === null ? "" : html `<span class="mono">${one.path}${one.line === null ? "" : `:${one.line}`}</span> `}${one.note}</p>`),
1505
+ revisionLineageHtml(data.revision.lineage),
1506
+ html `</div>`,
1507
+ ], "\n");
1508
+ // The approval form restates every field the digest binds — an operator
1509
+ // approves what is on this form, not what is elsewhere on the page — and
1510
+ // requires the token typed again. The session got you here; only the
1511
+ // token agrees. The sheet shows the plan open in plain rows with one
1512
+ // Approve & start; the rest of the signed terms sit in its Details fold.
1513
+ // An unapprovable scope gets the problem and the edit road instead of a
1514
+ // password it cannot use.
1515
+ const consentDoor = consentDoorOf(scope, data.route);
1516
+ const revisionInApproval = scope !== null && !approval.approved && data.plan !== "requested" &&
1517
+ scope.profileState !== "unresolved" && consentDoor.open && data.dispatch?.action !== "repair-dependency" &&
1518
+ (data.revision == null || !("problem" in data.revision));
1519
+ const approveForm = scope === null || approval.approved || data.plan === "requested"
1520
+ ? ""
1521
+ : data.revision !== null && data.revision !== undefined && "problem" in data.revision
1522
+ ? html `<div class="card approve-form" id="approve"><p><strong>This task is waiting on you: approval is blocked.</strong></p><p class="meta">${data.revision.problem} — a revision approves only against a brief that verifies</p></div>`
1523
+ : scope.profileState === "unresolved"
1524
+ ? html `<div class="card approve-form" id="approve"><p><strong>This task is waiting on you: its scope cannot be approved yet.</strong></p>${profileWords(scope)}<p class="ceremony-road"><a class="button-link" href="#scope">Edit the scope to fix it →</a></p></div>`
1525
+ : !consentDoor.open
1526
+ ? consentClosedHtml(task.id, consentDoor, "task")
1527
+ : approvalSheetHtml({
1528
+ surface: "task",
1529
+ action: `${taskHref(task.id)}/approve`,
1530
+ csrf: data.csrf,
1531
+ nonce: data.nonce,
1532
+ digest: data.approvalDigest ?? scope.digest,
1533
+ returnTo: null,
1534
+ scope,
1535
+ planDocument: data.plan === "drafted" ? data.planDocument : null,
1536
+ planContract: data.plan === "drafted" ? data.planContract ?? null : null,
1537
+ revision: data.revision == null || "problem" in data.revision ? null : data.revision,
1538
+ revisionSourceHref: data.revision == null || "problem" in data.revision ? "" : `/r/${data.revision.sourceRun}`,
1539
+ repairChain: data.repairChain ?? null,
1540
+ route: data.route,
1541
+ coordinator: data.coordinator ?? null,
1542
+ deliverable: data.deliverable ?? "branch",
1543
+ editHref: data.csrf !== "" && data.planSha != null && data.planDocument !== null ? "#plan-edit" : "#scope",
1544
+ notNowHref: "/work",
1545
+ sticky: data.dispatch?.action === "approve-scope",
1546
+ earlier: { active: data.assignment?.earlierActive ?? 0, running: data.assignment?.earlierRunning ?? 0 },
1547
+ edit: data.csrf === "" ? null
1548
+ : { action: `${taskHref(task.id)}/scope`, draft: inlineScopeDraft ? data.scopeDraft ?? null : null, problem: inlineScopeDraft ? data.problem : null,
1549
+ open: data.editPlan === true, stepsHref: data.planSha != null && data.planDocument !== null ? "#plan-edit" : null },
1550
+ });
1551
+ const scopeForm = joinHtml([
1552
+ html `<details${scope === null || scopeDraft !== undefined ? html ` open` : ""}><summary>${scope === null ? "Write the scope" : "Edit the scope"}${approval.approved ? " (editing voids the approval)" : ""}</summary>`,
1553
+ postForm(`${taskHref(task.id)}/scope`, joinHtml([
1554
+ "",
1555
+ scopeDraft === undefined || data.problem === null ? "" : html `<div class="problem" role="alert" id="scope-error">${data.problem}</div>`,
1556
+ html `<label>Goal<textarea name="goal" rows="3"${scopeDraft === undefined ? "" : html ` autofocus aria-describedby="scope-error"`}>${scopeDraft?.get("goal") ?? scope?.goal ?? ""}</textarea></label>`,
1557
+ html `<label>Not this<textarea name="not" rows="2">${scopeDraft?.get("not") ?? scope?.outOfScope ?? ""}</textarea></label>`,
1558
+ html `<label>Touches <span class="meta">(one per line)</span><textarea name="touches" rows="2">${scopeDraft?.get("touches") ?? (scope?.touches ?? []).join("\n")}</textarea></label>`,
1559
+ html `<label>Acceptance <span class="meta">(required — one criterion per line: <code>statement | evidence,kinds | how</code>; evidence kinds are check, screenshot, changed-path, manual-review; id is optional and auto-numbered)</span><textarea name="acceptance" rows="3" placeholder="The button opens the settings panel | screenshot">${scopeDraft?.get("acceptance") ?? acceptanceToLines(scope?.acceptance ?? []).join("\n")}</textarea></label>`,
1560
+ (() => {
1561
+ const defaults = data.spendDefaults ?? null;
1562
+ const budgetPrefill = scope?.budgetMicrousd != null
1563
+ ? (scope.budgetMicrousd / 1_000_000).toFixed(2)
1564
+ : defaults?.buildPerRunMicrousd != null
1565
+ ? (defaults.buildPerRunMicrousd / 1_000_000).toFixed(2)
1566
+ : "";
1567
+ return html `<label>Agent-reported usage cap <span class="meta">(optional — leave blank for uncapped subscription work)</span><input type="number" name="budget-usd" step="0.01" min="0.01" value="${scopeDraft?.get("budget-usd") ?? budgetPrefill}" placeholder="no cap"></label><p class="meta">Claude expresses this limiter in API-equivalent dollars even on a membership. It does not switch the run to API billing.</p>`;
1568
+ })(),
1569
+ (() => {
1570
+ const profileMode = scope?.profile?.provider === "claude"
1571
+ ? scope.profile.permissionArgv === "bypassPermissions" ? "bypassPermissions" : "auto"
1572
+ : scope?.profile?.provider === "gemini"
1573
+ ? scope.profile.approvalArgv === "yolo" ? "bypassPermissions" : "auto"
1574
+ : scope?.profile?.provider === "codex" || scope?.profile?.provider === "openrouter"
1575
+ ? scope.profile.sandboxMode === "danger-full-access" ? "bypassPermissions" : "auto"
1576
+ : null;
1577
+ const selected = scopeDraft?.get("permission-mode") === "bypassPermissions" ? "bypassPermissions" : scopeDraft?.get("permission-mode") === "auto" ? "auto" : data.permissionMode ?? profileMode ?? data.permissionDefault ?? "auto";
1578
+ return html `<fieldset class="permission-field"><legend>Agent permissions</legend>${permissionModeChoices("permission-mode", selected)}<p class="meta permission-note">This task’s choice is sealed into its scope. Full access prevents permission prompts or sandbox limits from pausing supported unattended agents.</p></fieldset>`;
1579
+ })(),
1580
+ (() => {
1581
+ const selected = scopeDraft?.get("quality-mode") === "strict" ? "strict" : scopeDraft?.get("quality-mode") === "default" ? "default" : scope?.qualityMode ?? data.qualityMode ?? data.qualityDefault ?? "default";
1582
+ return html `<fieldset class="permission-field"><legend>Quality</legend>${qualityModeChoices("quality-mode", selected)}<p class="meta permission-note">This choice is signed into the scope. Inspect the saved work and actual check results when it is ready. Publication and deployment need their own authorization.</p></fieldset>`;
1583
+ })(),
1584
+ html `<button type="submit">Save scope</button>`,
1585
+ "",
1586
+ ], "\n"), { attrs: { class: "scope-editor" }, hidden: { sawDigest: scopeDraft?.get("sawDigest") ?? scope?.digest ?? "" } }),
1587
+ html `</details>`,
1588
+ ], "\n");
1589
+ // 41_237 → "41k": token counts read at a glance; exactness lives on the run page.
1590
+ const compactCount = (count) => count >= 1000 ? `${Math.round(count / 1000)}k` : String(count);
1591
+ // The attempt ledger (M5.5): every attempt with its provider, duration,
1592
+ // tokens, and dollars — or the honest word "unmeasured" — so a retry
1593
+ // storm reads as the spike it is instead of hiding inside a total.
1594
+ const minutesOf = (run) => {
1595
+ if (run.finishedAt === null)
1596
+ return null;
1597
+ const ms = new Date(run.finishedAt).getTime() - new Date(run.startedAt).getTime();
1598
+ return ms >= 0 ? `${Math.max(1, Math.round(ms / 60_000))}m` : null;
1599
+ };
1600
+ const tokensOf = (run) => run.tokensIn === null && run.tokensOut === null
1601
+ ? null
1602
+ : `${run.tokensIn === null ? "?" : compactCount(run.tokensIn)}/${run.tokensOut === null ? "?" : compactCount(run.tokensOut)} tok`;
1603
+ // Ledger rows (slice 1c): the same row grammar as the portfolio's terminal
1604
+ // ledger — identity, outcome chip, one mono meta run of provider · model ·
1605
+ // duration · tokens · measured-or-unmeasured cost.
1606
+ const runRows = joinHtml(data.runs
1607
+ .map(run => {
1608
+ const bits = [
1609
+ run.provider,
1610
+ run.model,
1611
+ minutesOf(run),
1612
+ tokensOf(run),
1613
+ runCostWords(run, run.id === liveRunId),
1614
+ run.parentRun !== null ? `↳ of #${run.parentRun}` : null,
1615
+ ].filter((bit) => bit !== null);
1616
+ return html `<p class="row"><a href="/r/${run.id}" class="mono">#${run.id}</a> ${runOutcomeBadge(run, run.id === liveHistoryRunId)}${run.reason === null ? "" : html ` <span class="meta">${reasonWords(run.reason)}</span>`} <span class="meta mono">${bits.join(" · ")}</span><span class="right meta mono">${whenTime(run.startedAt)}</span></p>`;
1617
+ }), "\n");
1618
+ const runs = data.runs.length === 0 ? "" : html `<h2>Attempts</h2>${runRows}`;
1619
+ // ---- the right rail (slice 1c) ------------------------------------------
1620
+ // Dollars stated per attempt set, measured or unmeasured in words — a
1621
+ // missing figure is never summed as $0.
1622
+ const spendWords = (rows) => {
1623
+ if (rows.length === 0)
1624
+ return "no attempt yet";
1625
+ const measured = rows.filter(one => one.costUsd !== null);
1626
+ const dollars = measured.reduce((sum, one) => sum + (one.costUsd ?? 0), 0);
1627
+ const subscription = measured.filter(one => one.authMode === "subscription");
1628
+ const subscriptionEquivalent = subscription.reduce((sum, one) => sum + (one.costUsd ?? 0), 0);
1629
+ const metered = measured.filter(one => one.authMode !== "subscription");
1630
+ const meteredDollars = metered.reduce((sum, one) => sum + (one.costUsd ?? 0), 0);
1631
+ // Tokens count only where an attempt reported them; a null report is
1632
+ // said, never summed as zero (commit-3 review, finding 2).
1633
+ const reported = rows.filter(one => one.tokensIn !== null || one.tokensOut !== null);
1634
+ const tokens = reported.reduce((sum, one) => sum + (one.tokensIn ?? 0) + (one.tokensOut ?? 0), 0);
1635
+ const tokenWords = reported.length === 0
1636
+ ? "tokens unreported"
1637
+ : reported.length < rows.length
1638
+ ? `${compactCount(tokens)} tokens from ${reported.length}/${rows.length} attempts`
1639
+ : `${compactCount(tokens)} tokens`;
1640
+ if (subscription.length > 0) {
1641
+ return [
1642
+ ...(metered.length > 0 ? [`$${meteredDollars.toFixed(2)} API-key usage`] : []),
1643
+ `$${subscriptionEquivalent.toFixed(2)} API-price equivalent from subscription usage (not an API charge)`,
1644
+ tokenWords,
1645
+ ...(measured.length < rows.length ? [`${rows.length - measured.length} attempt(s) unmeasured`] : []),
1646
+ ].join(" · ");
1647
+ }
1648
+ if (measured.length === rows.length)
1649
+ return `$${dollars.toFixed(2)} · ${tokenWords} · measured`;
1650
+ if (measured.length === 0) {
1651
+ return reported.length > 0
1652
+ ? `unmeasured — ${tokenWords}, no dollar figure reported`
1653
+ : rows.some(one => one.id === liveRunId)
1654
+ ? "unmeasured so far — the figure lands when the attempt finishes"
1655
+ : "not reported";
1656
+ }
1657
+ return `$${dollars.toFixed(2)} measured across ${measured.length}/${rows.length} attempts — ${rows.length - measured.length} unmeasured · ${tokenWords}`;
1658
+ };
1659
+ const thisAttempt = liveRun ?? data.runs[0];
1660
+ const prop = (key, value) => html `<p class="row"><span class="meta">${key}</span> <span class="mono">${value}</span></p>`;
1661
+ const economics = joinHtml([
1662
+ prop("this attempt", thisAttempt === undefined ? "no attempt yet" : spendWords([thisAttempt])),
1663
+ prop("task total", spendWords(data.runs)),
1664
+ ]);
1665
+ // "publishes as": push, open-PR, and merge are INDEPENDENT fields on the
1666
+ // grant — each phrased on its own; absent means exactly that.
1667
+ const publishesAs = (() => {
1668
+ const grant = data.grant ?? null;
1669
+ if (data.repo === null)
1670
+ return "no project — no publication grant can apply";
1671
+ if (grant === null)
1672
+ return "branch only — publishing is not set up";
1673
+ if (grant.publishOn === "complete") {
1674
+ return `pull requests to ${grant.githubRepo} into ${grant.base} when completed · ${grant.mergeWhenGreen === true ? "merges when checks pass" : "a person merges"} (${grant.mergeMethod ?? "squash"})`;
1675
+ }
1676
+ return [
1677
+ grant.capabilities.includes("push-branch") ? `may push ${grant.headPrefix}* to ${grant.githubRepo}` : "cannot push",
1678
+ grant.capabilities.includes("open-pr") ? `may open a PR against ${grant.base}${grant.draft ? " (draft)" : ""}` : null,
1679
+ grant.merge === true ? `may merge${grant.mergeMethod == null ? "" : ` (${grant.mergeMethod})`}` : "cannot merge",
1680
+ ].filter((part) => part !== null).join(" · ");
1681
+ })();
1682
+ const publishesRow = prop("publishes as", publishesAs);
1683
+ // The property list (task page pass): worker and attempt, queue place,
1684
+ // the scope's standing with its seal, publication, spend — the key
1685
+ // facts a reader scans before anything else, in one row grammar.
1686
+ const workerRow = liveRun !== undefined
1687
+ ? prop("worker", html `${liveRun.runner} · <a href="/r/${liveRun.id}">build #${liveRun.id}</a> running`)
1688
+ : data.runs[0] !== undefined
1689
+ ? prop("last attempt", html `<a href="/r/${data.runs[0].id}">${runNoun(data.runs[0])} #${data.runs[0].id}</a> · ${data.runs[0].role === "planner" && data.runs[0].reason === "plan-drafted" ? "planned" : data.runs[0].reason === "interrupted" ? "interrupted" : data.runs[0].id === liveHistoryRunId ? "running" : data.runs[0].outcome ?? "never finished"} · ${data.runs[0].runner}`)
1690
+ : "";
1691
+ const queueRow = data.position !== null && data.position !== undefined && task.state === "queued"
1692
+ ? prop("queue", html `${data.position.position} of ${data.position.total}${data.position.column === null ? " in the shared queue" : html ` in ${data.position.column}'s queue`} · <a href="/board?view=order">reorder</a>`)
1693
+ : "";
1694
+ const scopeRow = scope === null
1695
+ ? prop("scope", "none yet")
1696
+ : approval.approved
1697
+ ? prop("approved scope", html `<span class="seal">signs ${shortDigest(scope.digest)}</span> · ${qualityModeTitle(scope.qualityMode ?? "default")} · approved by ${approval.by} · ${whenTime(approval.at)}`)
1698
+ : prop("scope", approval.reason === "changed" ? "rewritten since its approval — needs a new yes" : "not approved");
1699
+ const strikesRow = data.strikes > 0 ? prop("strikes", `${data.strikes} failed attempt(s)`) : "";
1700
+ const propsCard = html `<div class="card props">${workerRow}${queueRow}${scopeRow}${publishesRow}${economics}${strikesRow}</div>`;
1701
+ // The same facts for the rebuilt page, row for row.
1702
+ const facts = [];
1703
+ const lastRun = data.runs[0];
1704
+ if (liveRun !== undefined)
1705
+ facts.push({ label: "Worker", parts: [`${liveRun.runner} · `, { label: `build #${liveRun.id}`, href: `/r/${liveRun.id}` }, " running"] });
1706
+ else if (lastRun !== undefined)
1707
+ facts.push({ label: "Last attempt", parts: [{ label: `${runNoun(lastRun)} #${lastRun.id}`, href: `/r/${lastRun.id}` },
1708
+ ` · ${lastRun.role === "planner" && lastRun.reason === "plan-drafted" ? "planned" : lastRun.reason === "interrupted" ? "interrupted" : lastRun.id === liveHistoryRunId ? "running" : lastRun.outcome ?? "never finished"} · ${lastRun.runner}`] });
1709
+ if (data.position !== null && data.position !== undefined && task.state === "queued") {
1710
+ facts.push({ label: "Queue", parts: [`${data.position.position} of ${data.position.total}${data.position.column === null ? " in the shared queue" : ` in ${data.position.column}'s queue`} · `, { label: "Reorder", href: "/board?view=order" }] });
1711
+ }
1712
+ facts.push(scope === null ? { label: "Scope", parts: ["none yet"] }
1713
+ : approval.approved ? { label: "Approved scope", parts: [{ seal: `signs ${scope.digest.length <= 12 ? scope.digest : `${scope.digest.slice(0, 12)}…`}` }, ` · ${qualityModeTitle(scope.qualityMode ?? "default")} · approved by ${approval.by}`, ...(approval.at === null ? [] : [" · ", { at: approval.at }])] }
1714
+ : { label: "Scope", parts: [approval.reason === "changed" ? "rewritten since its approval — needs a new yes" : "not approved"] });
1715
+ facts.push({ label: "Publishes as", parts: [publishesAs] });
1716
+ // v102: who asked for it, and what the project's approval rules need.
1717
+ if (data.filer != null)
1718
+ facts.push({ label: "Filed by", parts: [data.filer.name === null ? "automation" : data.filer.kind === "coordinator" ? `${data.filer.name}, through a coordinator` : data.filer.name] });
1719
+ if (data.approvalRules != null) {
1720
+ const votes = data.approvalRules.votes;
1721
+ facts.push({ label: "Approval rules", parts: [data.approvalRules.needsTwo && !approval.approved
1722
+ ? (votes.length === 0 ? "needs two approvers" : `approved by ${votes.join(", ")} · needs one more`)
1723
+ : data.approvalRules.words] });
1724
+ }
1725
+ // v103: everything an auditor asks about this task, printable or as JSON.
1726
+ facts.push({ label: "Audit", parts: [{ label: "Evidence pack", href: `${taskHref(task.id)}/evidence` }] });
1727
+ if (data.budgetHold != null)
1728
+ facts.push({ label: "Budget", parts: [`${data.budgetHold} · `, { label: "Spend", href: "/spend" }] });
1729
+ if (data.policyHold != null)
1730
+ facts.push({ label: "Policy", parts: [`${data.policyHold} · `, { label: "Policy", href: "/settings/policy" }] });
1731
+ // The pull-request card already says this once, with its action.
1732
+ if (data.publication !== null && data.publication !== undefined && data.pullRequest?.view == null) {
1733
+ const prHref = safePrUrl(data.publication.prUrl), pr = `PR #${data.publication.prNumber ?? "?"}`;
1734
+ facts.push({ label: "Published", parts: [prHref === null ? pr : { label: pr, href: prHref },
1735
+ ` · ${data.publication.state}${data.publication.remoteState !== null ? ` · ${data.publication.remoteState.toLowerCase()} on GitHub` : ""}${data.publication.lastCheckState !== null ? ` · CI ${data.publication.lastCheckState} at last observation` : " · no checks observed"}`] });
1736
+ }
1737
+ facts.push({ label: "This attempt", parts: [thisAttempt === undefined ? "no attempt yet" : spendWords([thisAttempt])] });
1738
+ facts.push({ label: "Task total", parts: [spendWords(data.runs)] });
1739
+ if (data.strikes > 0)
1740
+ facts.push({ label: "Strikes", parts: [`${data.strikes} failed attempt(s)`] });
1741
+ // Open decisions as the shared partial — answerable inline when the
1742
+ // page is not sensitive; link-only cards otherwise.
1743
+ const openDecisions = data.decisions.filter(one => one.state === "open" || one.state === "expired");
1744
+ const decisionRail = openDecisions.length === 0
1745
+ ? ""
1746
+ : html `<p id="task-questions"><strong>Questions</strong></p>${joinHtml(openDecisions
1747
+ .map(decision => degraded
1748
+ ? html `<div class="decide-card" data-decision-id="${decision.id}"><p class="q">${decision.question}</p><p class="meta">${oneLineOf(decision.recap, 160)}</p><p class="meta"><a href="/d/${decision.id}">the full question →</a></p></div>`
1749
+ : decisionAnswerCard({ ...decision, taskId: task.id, repo: data.repo }, data.csrf, data.now, false)), "\n")}`;
1750
+ const rail = joinHtml([decisionRail, propsCard].filter(part => part !== ""), "\n");
1751
+ // Spend by provider, from the same rows — dollars only where a provider
1752
+ // measured them, and the unmeasured said in words, never summed as $0.
1753
+ const spendCard = (() => {
1754
+ if (data.runs.length === 0)
1755
+ return "";
1756
+ const byProvider = new Map();
1757
+ for (const run of data.runs) {
1758
+ const entry = byProvider.get(run.provider) ?? { runs: 0, tokensIn: 0, tokensOut: 0, costUsd: 0, measured: 0 };
1759
+ entry.runs += 1;
1760
+ entry.tokensIn += run.tokensIn ?? 0;
1761
+ entry.tokensOut += run.tokensOut ?? 0;
1762
+ if (run.costUsd !== null) {
1763
+ entry.costUsd += run.costUsd;
1764
+ entry.measured += 1;
1765
+ }
1766
+ byProvider.set(run.provider, entry);
1767
+ }
1768
+ const lines = [...byProvider.entries()].map(([provider, spend]) => {
1769
+ const providerRuns = data.runs.filter(run => run.provider === provider);
1770
+ const subscriptionRuns = providerRuns.filter(run => run.authMode === "subscription" && run.costUsd !== null);
1771
+ const subscriptionEquivalent = subscriptionRuns.reduce((sum, run) => sum + (run.costUsd ?? 0), 0);
1772
+ const meteredRuns = providerRuns.filter(run => run.authMode !== "subscription" && run.costUsd !== null);
1773
+ const meteredCost = meteredRuns.reduce((sum, run) => sum + (run.costUsd ?? 0), 0);
1774
+ const dollars = subscriptionRuns.length > 0
1775
+ ? [
1776
+ ...(meteredRuns.length > 0 ? [`$${meteredCost.toFixed(2)} API-key usage`] : []),
1777
+ `$${subscriptionEquivalent.toFixed(2)} subscription API-price equivalent — not an API charge`,
1778
+ ...(spend.measured < spend.runs ? [`${spend.runs - spend.measured} unmeasured`] : []),
1779
+ ].join(" · ")
1780
+ : spend.measured === spend.runs
1781
+ ? `$${spend.costUsd.toFixed(2)}`
1782
+ : spend.measured === 0
1783
+ ? "dollar cost unmeasured"
1784
+ : `$${spend.costUsd.toFixed(2)} across ${spend.measured}/${spend.runs} measured`;
1785
+ return html `<p class="row"><span class="mono">${provider}</span> <span class="meta">${spend.runs} attempt(s) · ${compactCount(spend.tokensIn)} in / ${compactCount(spend.tokensOut)} out · ${dollars}</span></p>`;
1786
+ });
1787
+ return joinHtml(lines, "\n");
1788
+ })();
1789
+ const decisions = data.decisions.length === 0
1790
+ ? ""
1791
+ : html `<h2>Decisions</h2>${joinHtml(data.decisions.map(decision => html `<p class="row"><a href="/d/${decision.id}">${decision.question}</a> <span class="meta">${decision.state}${isOverdue(decision, data.now) ? " · overdue" : ""}</span></p>`), "\n")}`;
1792
+ const incidents = data.incidents.length === 0
1793
+ ? ""
1794
+ : html `<h2>Incidents</h2>${joinHtml(data.incidents.map(one => one.resolvedAt === null
1795
+ ? html `<p class="row">${incidentWords(one.kind)} ${postForm(`/i/${one.id}/resolve`, html `<button type="submit">Resolve</button>`, { attrs: { class: "inline" } })}</p>`
1796
+ : html `<p class="row meta">${incidentWords(one.kind)} — resolved by ${one.resolvedBy ?? "?"}</p>`), "\n")}`;
1797
+ const stalled = task.state === "failed" || data.incidents.some(one => one.resolvedAt === null);
1798
+ const dependencyChoiceNeeded = data.dispatch?.action === "repair-dependency";
1799
+ // The chain: what this task waits for, editable in place. Blockers
1800
+ // outside this console's view are named without state or link — the same
1801
+ // redaction the board applies. Adding and removing are ordinary
1802
+ // re-proved POSTs; the loop refusal comes back as the problem banner.
1803
+ const waitsFor = data.waitsFor ?? [];
1804
+ const candidates = (data.waitCandidates ?? []).filter(one => !waitsFor.some(existing => existing.id === one.id));
1805
+ const waitRows = waitsFor.length === 0 ? "" : joinHtml(waitsFor.map(one => html `<p class="row">${one.admitted ? html `<a href="${taskHref(one.id)}" class="mono">${one.id}</a>` : html `<span class="mono">${one.id}</span>`}${one.state === null ? "" : html ` <span class="badge badge-${one.state}">${sentenceCase(one.state)}</span>`}${postForm(`${taskHref(task.id)}/unblock`, html `<button type="submit">Don't wait for this</button>`, { attrs: { class: "inline" }, hidden: { on: one.id } })}</p>`), "\n");
1806
+ const waitAdd = data.csrf === "" || candidates.length === 0
1807
+ ? ""
1808
+ : postForm(`${taskHref(task.id)}/block`, html `<select name="on" aria-label="task to wait for">${candidates.map(one => html `<option value="${one.id}">${one.id} — ${one.title}</option>`)}</select><button type="submit">Wait for this task</button><span class="meta"> — this task starts only after it finishes</span>`, { attrs: { class: "row" } });
1809
+ const waitsForCard = waitRows === "" && waitAdd === ""
1810
+ ? ""
1811
+ : html `<h2>Waits for</h2>${waitRows === "" ? html `<p class="meta">Nothing — it starts when a worker is free</p>` : waitRows}${waitAdd}`;
1812
+ // The acts bar (task page pass): every verb in one row under the title,
1813
+ // the one that resolves this task's state first and primary — retry on a
1814
+ // stalled task, build-next in the queue, plan-first with no scope. The
1815
+ // hold's reason sits beside its button; cancel stays armed at the foot,
1816
+ // far from the primary. A live claim is never disturbed by an operator
1817
+ // hold (the hold governs the NEXT start), and requeue refuses while a
1818
+ // claim is live — so the words say exactly when each becomes real.
1819
+ const canPlan = data.plan === null && !approval.approved && !data.claimed && task.state === "queued" && (data.coordinator === null || data.coordinator === undefined);
1820
+ // A failed task's status card carries Retry itself (the same requeue); here it would be a second ink act.
1821
+ const cardRetries = task.state === "failed" && data.assignment != null && data.csrf !== "";
1822
+ const primaryAct = stopControlsActive || cardRetries ? null : stalled && !data.claimed
1823
+ ? { html: act("requeue", "Retry, keeping the branch and workspace"), why: "Resolves the incidents, clears the failed attempts and queues the task again. The branch and workspace are kept.", whyClass: "retry" }
1824
+ : canPlan
1825
+ ? { html: act("plan", "plan first"), why: "plan first sends an agent to read the repository, ask you questions, and propose a scope — nothing builds until you approve it", whyClass: "plan" }
1826
+ : data.plan === "requested"
1827
+ ? null
1828
+ : task.state === "queued" && !data.claimed && (data.position?.position ?? 1) > 1
1829
+ ? { html: act("next", "build this next"), why: "moves it to the front of its queue — the next free worker looks here first; approval is still required", whyClass: "next" }
1830
+ : null;
1831
+ const holdAct = postForm(`${taskHref(task.id)}/hold`, html `<input type="text" name="reason" class="inline" placeholder="reason (optional)" aria-label="hold reason"><button type="submit">Hold the next attempt</button>`, { attrs: { class: "inline act-hold" } });
1832
+ const canHold = task.state === "queued" || task.state === "running" || task.state === "failed";
1833
+ const actsBar = joinHtml([
1834
+ html `<span id="task-actions"></span><div class="acts-bar">`,
1835
+ // While a ceremony leads the page, no other act competes as primary.
1836
+ primaryAct === null ? "" : approveForm === "" ? html `<span class="primary">${primaryAct.html}</span>` : primaryAct.html,
1837
+ task.state === "queued" && (data.position?.position ?? 2) === 1 && task.priority > 0
1838
+ ? act("next", "back to filing order", { undo: "1" })
1839
+ : "",
1840
+ // A task with no scope is already unable to start. Showing a hold next
1841
+ // to "plan first" adds a second, unnecessary decision at the exact
1842
+ // moment the page should have one obvious action.
1843
+ stopControlsActive || canPlan || !canHold ? "" : holdAct,
1844
+ data.holds.some(hold => hold.ownerKind === "operator") ? act("unhold", "Remove hold") : "",
1845
+ html `</div>`,
1846
+ primaryAct === null ? "" : html `<p class="meta acts-why acts-why-${primaryAct.whyClass}">${primaryAct.why}</p>`,
1847
+ data.claimed && !stopControlsActive
1848
+ ? html `<p class="meta acts-why">a worker is building this right now — <em>Hold the next attempt</em> stops the one after it; cancel waits for the current build to finish${stalled ? "; retry becomes available after this attempt finishes" : ""}</p>`
1849
+ : "",
1850
+ ], "\n");
1851
+ // Cancel gets the same ceremony as an irreversible answer: armed behind
1852
+ // one deliberate tap, styled as the destructive act it is.
1853
+ const cancelForm = task.state === "queued" || task.state === "running" || task.state === "failed"
1854
+ ? postForm(`${taskHref(task.id)}/cancel`, joinHtml([
1855
+ data.coordinator == null ? "" : html `<label>Reason for cancellation<textarea name="reason" rows="3" maxlength="500" required>${data.cancelDraft ?? ""}</textarea></label>`,
1856
+ html `<button type="submit" class="danger">Confirm cancellation</button>`,
1857
+ ]))
1858
+ : "";
1859
+ const cancelAct = cancelForm === "" ? "" : html `<details class="arm-danger"${data.cancelDraft === undefined ? "" : html ` open`}><summary>Cancel task</summary>${cancelForm}</details>`;
1860
+ // Long sections fold, each with its count in the header: what needs
1861
+ // reading stays open; a ledger or a form folds until asked. The rebuilt
1862
+ // page receives the same bodies, so its folds carry the same forms.
1863
+ const sectionParts = [];
1864
+ const section = (title, body, open, count, id = title.replace(/\s+/g, "-")) => {
1865
+ if (body === "" || isEmptyHtml(body))
1866
+ return "";
1867
+ const heading = sentenceCase(title);
1868
+ // The section's own heading moves into its summary.
1869
+ const once = (fragment, words) => replaceMarkup(fragment, new RegExp(htmlString(html `<h2>${words}</h2>`).replace(/[.*+?^${}()|[\]\\]/g, "\\$&")), () => html ``);
1870
+ const inner = once(once(body, title), heading);
1871
+ sectionParts.push({ id, title: heading, html: htmlString(inner), open, count: count ?? null });
1872
+ return html `<details class="section" id="${id}"${open ? html ` open` : ""}><summary><h2>${heading}${count === undefined ? "" : html ` <span class="lane-count">${count}</span>`}</h2></summary>${inner}</details>`;
1873
+ };
1874
+ const receiptLeads = data.assignment == null && task.state === "done" && data.completion?.receipt != null;
1875
+ // Exact identity stays available in Task options; failures stay in the
1876
+ // status and property rail, and approval provenance stays in the ceremony.
1877
+ const identity = html `<p class="meta task-identity">Task ID <span class="mono">${task.id}</span>${data.rootTitle !== undefined && data.rootTitle !== task.title ? ` · Execution: ${task.title}` : ""}${data.repo === null ? "" : ` · ${projectName(data.repo)}`}${data.coordinator !== null && data.coordinator !== undefined
1878
+ ? html ` · filed by <span class="mono">${data.coordinator.label}</span>${data.coordinator.filedAgo === null ? "" : ` ${data.coordinator.filedAgo}`}`
1879
+ : data.filedVia === null || data.filedVia === undefined
1880
+ ? ""
1881
+ : ` · filed via ${data.filedVia}`}${data.deliverable === "report" ? html ` · <span class="badge">Scout</span>` : ""}</p>`;
1882
+ // A pull request that can be merged (or opened) owns the one primary action; the result stays in its section.
1883
+ const pullRequestActs = data.csrf !== "" && (data.pullRequest?.view?.canMerge === true || (data.pullRequest?.view == null && data.pullRequest?.offer != null));
1884
+ const pullRequestFact = data.pullRequest?.fact != null && data.pullRequest.view?.runId === data.assignment?.receipt?.runId ? data.pullRequest.fact : undefined;
1885
+ const assignmentOptions = data.assignment == null ? null : { workStatus: status, hideAction: (approveForm !== "" && data.dispatch?.action === "approve-scope") || pullRequestActs, problem: status.tone === "problem",
1886
+ planning: data.dispatch?.code === "running" && data.dispatch.role === "planner", ...(pullRequestFact === undefined ? {} : { pullRequest: pullRequestFact }),
1887
+ diagnostics: [...(status.diagnostics ?? []), ...(status.tone === "problem" && status.detail !== data.assignment.detail ? [status] : [])] };
1888
+ // Say it once: a pull request that couldn't open, or merged, is the status
1889
+ // card's own row (its reason or merge commit under Details), not a second card.
1890
+ const pullRequestCard = data.pullRequest == null || (pullRequestFact !== undefined && (data.pullRequest.view?.state === "failed" || data.pullRequest.view?.state === "merged")) ? "" : pullRequestCardHtml(data.pullRequest, data.csrf);
1891
+ // The result takes over from the task status as soon as it is ready.
1892
+ const statusHtml = html `${data.assignment != null && assignmentOptions !== null ? assignmentSummaryHtml(data.assignment, assignmentOptions) : receiptLeads ? completionReceiptCard(data.completion.receipt, task.id, "task", status) : taskStatusCard(status, task.id, data.dispatch ?? null, liveRunId, approveForm !== "" && data.dispatch?.action === "approve-scope")}${checkProgressHtml(data.checkProgress ?? null)}`;
1893
+ // The exact-run control (v52), directly under the scheduler's answer:
1894
+ // the one place a person stops or resumes THIS attempt.
1895
+ const controlHtml = taskControlDetailsHtml(data.control ?? { kind: "none" }, task.id, data.csrf, "task");
1896
+ const previousResult = data.completion?.receipt == null || receiptLeads ? null : {
1897
+ title: data.assignment != null && task.state === "done" ? "Result" : "Previous result",
1898
+ html: (completionReceiptCard(data.completion.receipt, task.id, "task", data.assignment == null ? receiptStatusOf(data.completion.receipt) : assignmentStatusOf(data.assignment), data.assignment ?? null, data.assignment == null)),
1899
+ };
1900
+ // External work wears its tracker on the page: the link, the last
1901
+ // observed state, and — when the tracker closed it and has been seen
1902
+ // open again — the authenticated reopen act. Done + closed is display
1903
+ // only: completed here stays completed.
1904
+ const mirrorCard = (() => {
1905
+ const mirror = data.mirror ?? null;
1906
+ if (mirror === null)
1907
+ return "";
1908
+ const link = mirror.backend === "github-issues"
1909
+ ? html `<a href="https://github.com/${mirror.remoteRepo}/issues/${mirror.remoteId}">${mirror.remoteRepo}#${mirror.remoteId}</a>`
1910
+ : html `<span class="mono">${mirror.remoteRepo}#${mirror.remoteId}</span>`;
1911
+ const state = task.state === "done" && mirror.remoteState !== "open"
1912
+ ? "completed here; closed on the tracker"
1913
+ : mirror.remoteState === "open"
1914
+ ? mirror.dispatchOk
1915
+ ? "open on the tracker"
1916
+ : "seen open again — reopen below to resume"
1917
+ : mirror.remoteState === "closed"
1918
+ ? "closed on the tracker"
1919
+ : "gone from the tracker";
1920
+ const reopenable = mirror.remoteState === "open" && !mirror.dispatchOk && mirror.closeGeneration !== null &&
1921
+ mirror.syncGeneration > mirror.closeGeneration && ["cancelled", "failed", "queued"].includes(task.state) && data.csrf !== "";
1922
+ return joinHtml([
1923
+ html `<div class="card"><p><strong>External work</strong> <span class="meta">${link} · ${state}</span></p>`,
1924
+ reopenable
1925
+ ? postForm(`${taskHref(task.id)}/reopen`, html `<input type="password" name="token" placeholder="your password" aria-label="your password" autocomplete="current-password"><button type="submit">Reopen — the approved scope stands</button>`, { attrs: { class: "row" } })
1926
+ : "",
1927
+ html `</div>`,
1928
+ ]);
1929
+ })();
1930
+ const problemHtml = data.problem === null || data.scopeDraft !== undefined ? "" : html `<div class="problem">${data.problem}</div>`;
1931
+ // The board sent them here saying "needs you" — the page must open by
1932
+ // saying WHY and pointing at the act, not read as a fact sheet
1933
+ // (operator finding: clicking a needs-you card landed with no context).
1934
+ const needsScopeCard = scope === null && data.plan === null && task.state === "queued" && data.dispatch?.code !== "needs-scope" && data.dispatch?.code !== "waiting-dependency" && !dependencyChoiceNeeded
1935
+ ? data.coordinator !== null && data.coordinator !== undefined
1936
+ // The quarantine speaks here too (round-2 finding 5): the planner
1937
+ // is as fenced as the builder on a coordinator filing, so "plan
1938
+ // first" would recommend a road that refuses.
1939
+ ? html `<div class="card"><p><strong>This task is waiting on you: an agent filed it, and it has no scope.</strong></p><p class="meta">Filed by <span class="mono">${data.coordinator.label}</span> — nothing plans, claims, or runs until you write a scope below and sign it. Your signature runs their request.</p></div>`
1940
+ : html `<div class="card task-scope-needed"><p><strong>No approved scope yet</strong></p><p class="meta"><strong>Plan first</strong> drafts it from the repository, or <a href="#scope">write it yourself</a>.</p></div>`
1941
+ : "";
1942
+ const approvalHtml = dependencyChoiceNeeded || approveForm === "" ? "" : data.dispatch?.action === "approve-scope"
1943
+ ? html `<section class="task-plan-review" aria-label="Approve the plan">${approveForm}</section>`
1944
+ : html `<details class="task-secondary-approval"><summary>Updated approval terms</summary>${approveForm}</details>`;
1945
+ const optionsHtml = html `${identity}${dispatchStatus}${dependencyChoiceNeeded ? "" : actsBar}`;
1946
+ const optionsOpen = data.assignment?.primaryAction?.code === "unhold" || data.assignment?.primaryAction?.code === "retry-task";
1947
+ const body = joinHtml([
1948
+ // The title is bare; the receipt or shared task status leads once.
1949
+ html `<div class="task-title-row"><h1 class="task-main-title">${data.rootTitle ?? task.title}</h1>${data.csrf === "" ? "" : taskViewSwitch(data.rootId ?? task.id, "overview")}</div>`,
1950
+ data.versionLabel == null ? "" : html `<p class="meta">${data.versionLabel} · <a href="${taskHref(data.rootId ?? task.id)}">Current work</a></p>`,
1951
+ (data.history ?? ""),
1952
+ statusHtml,
1953
+ controlHtml,
1954
+ previousResult === null ? "" : html `<details class="task-previous-result"><summary>${previousResult.title}</summary>${previousResult.html}</details>`,
1955
+ progressCard,
1956
+ revisionLedgerCard,
1957
+ planCard,
1958
+ mirrorCard,
1959
+ problemHtml,
1960
+ needsScopeCard,
1961
+ approvalHtml,
1962
+ html `<details class="task-status-details" id="task-diagnostics"${optionsOpen ? html ` open` : ""}><summary>Task options</summary>${optionsHtml}</details>`,
1963
+ // Evidence-first (M5.5): what needs you, then what happened — decisions
1964
+ // and incidents above the attempt ledger and spend, the mechanics
1965
+ // (scope, holds, acts) after. Only trustworthy facts moved up. The rail
1966
+ // (slice 1c) rides beside the main column on wide screens and above it
1967
+ // on narrow ones.
1968
+ html `<div class="task-layout"><div class="task-main">`,
1969
+ attemptPanel,
1970
+ data.coordinatorProposals == null || data.coordinatorProposals.rows.length === 0
1971
+ ? ""
1972
+ : section("proposals",
1973
+ // Confirm and dismiss come back to this task.
1974
+ html `<h2>Proposed by coordinators</h2>${coordinatorProposalsSection(data.coordinatorProposals.rows, data.coordinatorProposals.decisions, data.coordinatorProposals.now, false, taskHref(data.task.id))}`, true, data.coordinatorProposals.rows.length),
1975
+ section("decisions", decisions, true, data.decisions.length),
1976
+ section("incidents", incidents, true, data.incidents.length),
1977
+ data.publication === null || data.publication === undefined
1978
+ ? ""
1979
+ : html `<p class="row"><span class="meta">published</span> ${safePrUrl(data.publication.prUrl) === null ? html `<span class="mono">PR #${data.publication.prNumber ?? "?"}</span>` : html `<a href="${safePrUrl(data.publication.prUrl)}" class="mono">PR #${data.publication.prNumber ?? "?"}</a>`} <span class="meta">${data.publication.state}${data.publication.remoteState !== null ? ` · ${data.publication.remoteState.toLowerCase()} on GitHub` : ""}${data.publication.lastCheckState !== null
1980
+ ? ` · CI ${data.publication.lastCheckState} at last observation`
1981
+ : " · no checks observed"}</span></p>`,
1982
+ section("report", reportCard, true),
1983
+ data.assignment == null ? section("attempts", runs, true, data.runs.length) : section("Build activity", data.runs.length === 0 ? "" : runRows, false, data.runs.length, "attempts"),
1984
+ section("usage", spendCard, false),
1985
+ section("steering", steeringCard, (data.steering ?? []).length > 0, (data.steering ?? []).length),
1986
+ section("scope",
1987
+ // The recipe road rides with the scope it reuses (UI polish
1988
+ // 2026-09-13), off the title-to-action path.
1989
+ joinHtml([html `<h2>Scope</h2>`, scopeCard, data.repo !== null && data.scope !== null ? html `<p class="meta"><a href="/recipes/from-task?task=${encodeURIComponent(task.id)}">Reuse this scope as a recipe →</a></p>` : "", agentsCardHtml(task.id, data.route, data.canEditRoute === true), revisionInApproval ? "" : revisionCard, data.completion != null ? "" : repairChainHtml(data.repairChain ?? null), scopeForm], "\n"), scopeDraft !== undefined || (data.plan !== "requested" && approveForm === "" && !(scope === null && canPlan))),
1990
+ dependencyChoiceNeeded ? "" : section("waits for", waitsForCard, (data.waitsFor ?? []).length > 0, (data.waitsFor ?? []).length),
1991
+ section("holds", holds, true, data.holds.length),
1992
+ cancelAct,
1993
+ html `</div><aside class="task-rail">${rail}</aside></div>`,
1994
+ ], "\n");
1995
+ // Console v2: one thread in time order (the plan, the agent's notes and
1996
+ // results, its questions, the person's replies) and the metadata grouped
1997
+ // for a Details panel. Every form below keeps its own route and fields.
1998
+ const familyRuns = data.family?.runs ?? data.runs.map(run => ({ ...run, taskId: task.id }));
1999
+ const versionLabelOf = (taskId) => {
2000
+ const index = data.family?.versions.findIndex(one => one.id === taskId) ?? -1;
2001
+ return index <= 0 ? "" : ` · revision ${index}`;
2002
+ };
2003
+ const thread = [];
2004
+ const filedBy = data.filer?.name ?? data.coordinator?.label ?? null;
2005
+ const rootFiled = data.family?.root.createdAt ?? task.createdAt;
2006
+ thread.push({ key: "filed", at: rootFiled, kind: "filed", who: "person", author: filedBy ?? "", title: "Filed the task",
2007
+ text: (() => { const goal = data.family?.root.goal ?? scope?.goal ?? null; return goal === null ? null : oneLineOf(goal, 280); })(), link: null, html: "", more: null });
2008
+ const newestBuilt = familyRuns.filter(run => run.outcome === "built" && run.role !== "planner" && run.role !== "reviewer").sort((a, b) => b.id - a.id)[0];
2009
+ // Failed: the card says what went wrong in one line, and its one act is Retry itself (the same requeue), never a link to
2010
+ // this page; someone who can't retry here (a viewer, a live claim) gets no act at all.
2011
+ const statusCard = data.assignment != null && assignmentOptions !== null ? assignmentCardOf(data.assignment, assignmentOptions) : null;
2012
+ // A live build says which step it is on, or which step it is stuck on and why.
2013
+ const building = statusCard?.status.headline === "Building" && liveRun !== undefined && liveRun.role !== "planner" && liveRun.role !== "reviewer";
2014
+ // While a build runs, the attempts that stopped before it fold into one quiet line under its Building card. Only
2015
+ // there: with any other card (or none) they stay in the thread, never vanish.
2016
+ const earlierStopped = !building ? []
2017
+ : familyRuns.filter(run => run.id < liveRun.id && (run.role === "builder" || run.role === "scout") && run.outcome !== "built" && run.outcome !== "no-change" && run.id !== liveHistoryRunId);
2018
+ const foldedAttempts = new Set(earlierStopped.map(run => run.id));
2019
+ for (const run of familyRuns) {
2020
+ if (foldedAttempts.has(run.id))
2021
+ continue;
2022
+ const live = run.id === liveHistoryRunId || (run.outcome === null && run.id === liveRunId);
2023
+ const noun = runNoun(run);
2024
+ const href = { label: `${noun[0].toUpperCase()}${noun.slice(1)} #${run.id}`, href: `/r/${run.id}` };
2025
+ const revision = versionLabelOf(run.taskId);
2026
+ if (run.role === "planner") {
2027
+ thread.push({ key: `run-${run.id}`, at: run.finishedAt ?? run.startedAt, kind: "plan", who: "agent", author: run.runner,
2028
+ title: live ? "Planning" : run.reason === "plan-drafted" ? "Drafted a plan" : `Planning ended · ${run.outcome === null ? "never finished" : run.reason === null ? run.outcome : reasonWords(run.reason)}`,
2029
+ text: null, link: href, html: "", more: null });
2030
+ continue;
2031
+ }
2032
+ if (live) {
2033
+ thread.push({ key: `run-${run.id}`, at: run.startedAt, kind: "progress", who: "agent", author: run.runner,
2034
+ title: `${run.role === "reviewer" ? "Reviewing" : run.role === "scout" ? "Investigating" : "Building"}${revision}`,
2035
+ text: `${homePhaseWords(run)}.`, link: href, html: "", more: null });
2036
+ continue;
2037
+ }
2038
+ const title = run.role === "reviewer" ? "Reviewed the result"
2039
+ : run.outcome === "built" ? (run.role === "scout" ? "Report ready" : "Result ready")
2040
+ : run.outcome === "no-change" ? "Finished with no change"
2041
+ : run.outcome === "parked" ? "Stopped to ask"
2042
+ : run.outcome === null ? "Attempt never finished"
2043
+ : `${noun[0].toUpperCase()}${noun.slice(1)} ${run.outcome}`;
2044
+ thread.push({ key: `run-${run.id}`, at: run.finishedAt ?? run.startedAt, kind: "result", who: "agent", author: run.runner,
2045
+ title: `${title}${revision}`, text: run.handoff === null ? (run.outcome === "failed" ? failedAttemptSentence(run.reason) : run.reason === null || run.outcome === "built" ? null : reasonWords(run.reason)) : oneLineOf(run.handoff, 600),
2046
+ link: href, html: "",
2047
+ more: previousResult !== null && run.id === newestBuilt?.id && run.taskId === task.id ? { summary: STATUS_MORE, html: htmlString(previousResult.html) } : null });
2048
+ }
2049
+ // What the agent is working through now, and how its plan changed.
2050
+ const liveAt = liveRun?.startedAt ?? task.updatedAt;
2051
+ if (planCard !== "")
2052
+ thread.push({ key: "plan", at: familyRuns.filter(run => run.role === "planner").map(run => run.finishedAt ?? run.startedAt).sort().at(-1) ?? task.updatedAt,
2053
+ kind: "plan", who: "agent", author: "", title: "The plan", text: null, link: null, html: htmlString(planCard), more: null });
2054
+ if (!isEmptyHtml(progressCard))
2055
+ thread.push({ key: "progress", at: liveAt, kind: "progress", who: "agent", author: "", title: "Progress", text: null, link: null, html: htmlString(progressCard), more: null });
2056
+ if (!isEmptyHtml(revisionLedgerCard))
2057
+ thread.push({ key: "plan-revisions", at: liveAt, kind: "progress", who: "agent", author: "", title: "Plan changes", text: null, link: null, html: htmlString(revisionLedgerCard), more: null });
2058
+ // The change a person asked for: the revision's own brief.
2059
+ if (data.revision != null && !("problem" in data.revision)) {
2060
+ const feedback = data.revision;
2061
+ thread.push({ key: "revision", at: task.createdAt, kind: "reply", who: "person", author: feedback.comments[0]?.author ?? "You",
2062
+ title: feedback.kind === "ci-repair" ? "Asked for a CI repair" : feedback.kind === "criterion-repair" ? "Asked for a repair" : "Asked for changes",
2063
+ text: feedback.comments.map(one => `${one.path === null ? "" : `${one.path}${one.line === null ? "" : `:${one.line}`} — `}${one.note.trim()}`).filter(one => one !== "").join("\n") || null,
2064
+ link: { label: `From build #${feedback.sourceRun}`, href: `/r/${feedback.sourceRun}` }, html: "", more: null });
2065
+ }
2066
+ for (const decision of data.decisions) {
2067
+ if (decision.state === "open" || decision.state === "expired")
2068
+ continue;
2069
+ thread.push({ key: `question-${decision.id}`, at: decision.createdAt, kind: "question", who: "agent", author: "", title: "Asked",
2070
+ text: decision.question, link: { label: "Question", href: `/d/${decision.id}` }, html: "", more: null });
2071
+ if (decision.answeredAt !== null) {
2072
+ const chosen = decision.options.find(one => one.id === decision.choice)?.label ?? decision.choice;
2073
+ thread.push({ key: `answer-${decision.id}`, at: decision.answeredAt, kind: "reply", who: "person", author: decision.answeredBy ?? "You", title: "Answered",
2074
+ text: [chosen, decision.note].filter((one) => one !== null && one !== "").join(" — ") || null, link: null, html: "", more: null });
2075
+ }
2076
+ }
2077
+ // Open questions keep their answer card (and its #task-questions anchor).
2078
+ if (decisionRail !== "")
2079
+ thread.push({ key: "questions", at: openDecisions.map(one => one.createdAt).sort()[0] ?? task.updatedAt, kind: "question", who: "agent", author: "",
2080
+ title: openDecisions.length === 1 ? "Asked you a question" : `Asked you ${openDecisions.length} questions`, text: null, link: null, html: htmlString(decisionRail), more: null });
2081
+ for (const note of data.steering ?? []) {
2082
+ thread.push({ key: `steer-${note.id}`, at: note.createdAt, kind: "reply", who: "person", author: note.author, title: "Note to the agent", text: note.note, link: null, html: "", more: null });
2083
+ }
2084
+ thread.sort((a, b) => a.at === b.at ? 0 : a.at < b.at ? -1 : 1);
2085
+ // The Details panel: the same facts, grouped and said once.
2086
+ const lastRunForAgent = liveRun ?? familyRuns.filter(run => run.role !== "reviewer").sort((a, b) => b.id - a.id)[0];
2087
+ const work = [];
2088
+ work.push({ label: "Status", parts: [data.assignment != null && assignmentOptions !== null ? assignmentCardOf(data.assignment, assignmentOptions).status.headline : status.label] });
2089
+ if (data.repo !== null)
2090
+ work.push({ label: "Project", parts: [projectName(data.repo)] });
2091
+ work.push({ label: "Agent", parts: [lastRunForAgent !== undefined ? `${lastRunForAgent.provider}${lastRunForAgent.model === null ? "" : ` · ${lastRunForAgent.model}`} on ${lastRunForAgent.runner}`
2092
+ : data.route?.legacy != null ? `${data.route.legacy.provider} · ${data.route.legacy.model}` : "The project's default"] });
2093
+ if (scope !== null)
2094
+ work.push({ label: "Checks level", parts: [qualityModeTitle(scope.qualityMode ?? "default")] });
2095
+ for (const label of ["Worker", "Last attempt", "Queue", "Budget", "Policy", "Strikes"]) {
2096
+ const fact = facts.find(one => one.label === label);
2097
+ if (fact !== undefined)
2098
+ work.push(fact);
2099
+ }
2100
+ const links = [];
2101
+ if (data.family != null && data.family.root.id !== task.id)
2102
+ links.push({ label: "Parent", parts: [{ label: data.family.root.title, href: `${taskHref(data.family.root.id)}?version=${encodeURIComponent(data.family.root.id)}` }] });
2103
+ const blockers = data.waitsFor ?? [];
2104
+ if (blockers.length > 0)
2105
+ links.push({ label: "Blocked by", parts: blockers.flatMap((one, index) => [...(index === 0 ? [] : [", "]), one.admitted ? { label: one.title ?? one.id, href: taskHref(one.id) } : one.id]) });
2106
+ const laterVersions = (data.family?.versions ?? []).filter((one, index, all) => index > all.findIndex(version => version.id === task.id) && all.findIndex(version => version.id === task.id) >= 0);
2107
+ if (laterVersions.length > 0)
2108
+ links.push({ label: "Follow-ups", parts: laterVersions.flatMap((one, index) => [...(index === 0 ? [] : [", "]), { label: one.title, href: `${taskHref(data.family.root.id)}?version=${encodeURIComponent(one.id)}` }]) });
2109
+ const published = facts.find(one => one.label === "Published");
2110
+ if (published !== undefined)
2111
+ links.push({ ...published, label: "Pull request" });
2112
+ else if (data.pullRequest?.view != null) {
2113
+ const pr = data.pullRequest.view;
2114
+ const prHref = safePrUrl(pr.prUrl);
2115
+ links.push({ label: "Pull request", parts: [prHref === null ? `#${pr.prNumber ?? "?"}` : { label: `#${pr.prNumber ?? "?"}`, href: prHref }, ` · ${pr.label}`] });
2116
+ }
2117
+ const review = [];
2118
+ const approvedScope = facts.find(one => one.label === "Approved scope" || one.label === "Scope");
2119
+ if (approvedScope !== undefined)
2120
+ review.push({ ...approvedScope, label: approvedScope.label === "Approved scope" ? "Approvals" : "Scope" });
2121
+ review.push({ label: "Who reviews", parts: [facts.find(one => one.label === "Approval rules")?.parts[0] ?? "Any approver in this project"] });
2122
+ review.push({ label: "Publishes as", parts: [publishesAs] });
2123
+ const about = [];
2124
+ const filedFact = facts.find(one => one.label === "Filed by");
2125
+ about.push(filedFact ?? { label: "Filed by", parts: [filedBy ?? (data.filedVia == null ? "Not recorded" : `via ${data.filedVia}`)] });
2126
+ about.push({ label: "Filed", parts: [{ at: rootFiled }] });
2127
+ about.push({ label: "Updated", parts: [{ at: task.updatedAt }] });
2128
+ for (const label of ["Audit", "This attempt", "Task total"]) {
2129
+ const fact = facts.find(one => one.label === label);
2130
+ if (fact !== undefined)
2131
+ about.push({ ...fact, label: label === "Audit" ? "Audit" : label === "This attempt" ? "Usage, this attempt" : "Usage, all attempts" });
2132
+ }
2133
+ const details = [
2134
+ { title: "Work", facts: work }, { title: "Links", facts: links }, { title: "Review", facts: review }, { title: "About", facts: about },
2135
+ ];
2136
+ // The rebuilt page (shadcn/ui): the same parts in a calmer order — what
2137
+ // needs a person, then facts, then folds; the mechanics under Manage.
2138
+ const MANAGE = new Set(["steering", "waits-for", "holds"]);
2139
+ const finished = task.state === "done" || task.state === "cancelled";
2140
+ const failedCard = statusCard?.status.headline === "Failed";
2141
+ // A link to this very page, with no section to open, goes nowhere: a card never offers one.
2142
+ const bareSelfLink = (href) => {
2143
+ if (href.includes("#"))
2144
+ return false;
2145
+ const [path, query = ""] = href.split("?");
2146
+ const version = new URLSearchParams(query).get("version");
2147
+ return [data.assignment?.rootId, data.rootId, task.id].some(id => id != null && path === `/t/${encodeURIComponent(id)}`) && (version === null || version === task.id);
2148
+ };
2149
+ // Build again, in place, names its form after the card's own act.
2150
+ const rebuild = data.csrf !== "" && data.assignment?.primaryAction?.code === "retry-task" && data.assignment.need != null && "key" in data.assignment.need && data.assignment.need.key === "rebuild"
2151
+ ? { action: `${taskHref(data.assignment.rootId)}/requeue` } : null;
2152
+ const retry = failedCard && task.state === "failed" && !data.claimed && !stopControlsActive && data.csrf !== ""
2153
+ ? { action: `${taskHref(task.id)}/requeue`, ...(data.failure == null ? {} : { note: retryNoteOf(data.failure.suggestion) }) } : null;
2154
+ // Run checks happens in place: a status row's Run checks posts here and comes back; with nothing to run, the row offers nothing.
2155
+ const runChecks = data.runChecks ?? null;
2156
+ // A row's link to a result in Chat opens that result's own page instead: Chat would only lead back here.
2157
+ const resultPageOf = (href) => {
2158
+ const chat = href === null ? null : /^\/chat\?task=([A-Za-z0-9._~%-]{1,200})&result=([0-9]{1,15})(?:&tab=(changes|checks))?(#[A-Za-z0-9_-]{1,80})?$/.exec(href);
2159
+ return chat === null ? href : `/review?result=${chat[1]}&run=${chat[2]}${chat[3] === undefined ? "" : `&tab=${chat[3]}`}${chat[4] ?? ""}`;
2160
+ };
2161
+ const inPlaceChecks = (card) => {
2162
+ const status = data.demo === true ? demoChecksOf(card.status) : card.status;
2163
+ return { ...card, status: { ...status, details: status.details.map(one => one.action?.href?.endsWith("#follow-ups") === true ? { ...one, action: runChecks === null ? null : { label: one.action.label, href: runChecks.action } }
2164
+ : { ...one, href: resultPageOf(one.href), action: one.action === null ? null : { ...one.action, href: resultPageOf(one.action.href) } }) } };
2165
+ };
2166
+ const steps = building ? buildProgressOf(data.milestoneProgress ?? null) : null;
2167
+ const progress = steps === null ? null : { line: steps.line,
2168
+ stuck: steps.stuck === null ? null : { ...steps.stuck, action: data.csrf === "" ? null : { label: "Send the agent a note", href: "#steering" } } };
2169
+ // The Building card carries Stop for the build it describes (the same form), in place of a second card below it.
2170
+ const stop = building && data.control?.kind === "stop" && data.control.run === liveRun.id && data.csrf !== ""
2171
+ ? { action: `${taskHref(task.id)}/stop`, run: data.control.run } : null;
2172
+ // The Building card keeps its way to the build's own record (/r/<id>), whatever act it shows.
2173
+ const record = building ? { label: `Build #${liveRun.id} record`, href: `/r/${liveRun.id}` } : null;
2174
+ const earlier = earlierStopped.length === 0 ? null : {
2175
+ summary: earlierAttemptsWords(earlierStopped.length),
2176
+ attempts: [...earlierStopped].sort((a, b) => a.id - b.id).map(run => ({ label: `${runNoun(run)[0].toUpperCase()}${runNoun(run).slice(1)} #${run.id}`, href: `/r/${run.id}`,
2177
+ text: run.outcome === null ? "Never finished." : run.outcome === "failed" ? failedAttemptSentence(run.reason) : run.reason === null ? null : (words => `${words.charAt(0).toUpperCase()}${words.slice(1)}.`)(reasonWords(run.reason)) })),
2178
+ };
2179
+ const view = {
2180
+ kind: "task",
2181
+ id: task.id,
2182
+ title: data.rootTitle ?? task.title,
2183
+ project: data.repo === null ? null : projectName(data.repo),
2184
+ scout: data.deliverable === "report",
2185
+ tabs: data.csrf === "" ? [] : [
2186
+ { label: "Overview", href: taskHref(data.rootId ?? task.id), active: true },
2187
+ { label: "Ask", href: taskChatHref(data.rootId ?? task.id), active: false },
2188
+ ],
2189
+ version: data.versionLabel == null ? null : { label: data.versionLabel, current: { label: "Current work", href: taskHref(data.rootId ?? task.id) } },
2190
+ status: statusCard === null ? null : inPlaceChecks(failedCard ? { ...statusCard, action: null, status: { ...statusCard.status, sentence: data.failure?.line ?? statusCard.status.sentence } }
2191
+ : progress !== null || record !== null ? { ...statusCard, action: statusCard.action?.href === record?.href ? null : statusCard.action, status: { ...statusCard.status, sentence: progress?.line ?? statusCard.status.sentence } }
2192
+ : statusCard.action !== null && rebuild === null && bareSelfLink(statusCard.action.href) ? { ...statusCard, action: null } : statusCard),
2193
+ statusHtml: htmlString(statusHtml),
2194
+ failure: failedCard && data.failure != null ? { line: data.failure.line, evidence: data.failure.evidence, suggestion: data.failure.suggestion, link: data.failure.link } : null,
2195
+ retry,
2196
+ runChecks,
2197
+ progress,
2198
+ // One quiet line under the step: what the agent did last, and when.
2199
+ activity: liveRun === undefined ? null : data.activity ?? null,
2200
+ stop,
2201
+ record,
2202
+ earlier,
2203
+ approval: fragmentString(approvalHtml),
2204
+ confirmStopped: data.csrf !== "" && data.assignment?.primaryAction?.code === "confirm-stopped" && data.assignment.primaryAction.target.runId !== null
2205
+ ? { action: `${taskHref(data.assignment.rootId)}/confirm-stopped`, run: data.assignment.primaryAction.target.runId, checked: needsCheck(data.assignment) } : null,
2206
+ rebuild,
2207
+ // The plan, progress and plan changes are thread entries now; the rest still needs a person here.
2208
+ lead: [
2209
+ { key: "history", html: data.history === undefined ? "" : htmlString(data.history) }, { key: "control", html: htmlString(stop === null ? controlHtml : taskControlDetailsHtml(data.control, task.id, data.csrf, "task", false, true)) }, { key: "problem", html: fragmentString(problemHtml) },
2210
+ { key: "pull-request", html: fragmentString(pullRequestCard) },
2211
+ { key: "needs-scope", html: fragmentString(needsScopeCard) },
2212
+ { key: "mirror", html: fragmentString(mirrorCard) }, { key: "attempt", html: fragmentString(attemptPanel) },
2213
+ ].filter(one => one.html !== ""),
2214
+ // Open questions are answered in the thread.
2215
+ questions: "",
2216
+ facts,
2217
+ sections: [
2218
+ ...(previousResult === null || thread.some(one => one.more !== null) ? [] : [{ id: "result", title: previousResult.title, html: htmlString(previousResult.html), open: false, count: null }]),
2219
+ // Questions are in the thread; the scope and the ledgers fold here until asked.
2220
+ ...sectionParts.filter(one => !MANAGE.has(one.id) && one.id !== "decisions").map(one => one.id === "scope" && scopeDraft === undefined && (finished || approval.approved) ? { ...one, open: false } : one),
2221
+ ],
2222
+ manage: [
2223
+ ...sectionParts.filter(one => MANAGE.has(one.id)),
2224
+ { id: "task-diagnostics", title: "Task options", html: htmlString(optionsHtml), open: optionsOpen, count: null },
2225
+ ],
2226
+ cancel: cancelForm === "" ? null : { html: htmlString(cancelForm), open: data.cancelDraft !== undefined },
2227
+ journey: data.guide !== true || task.state === "cancelled" || data.status === undefined ? null : (() => {
2228
+ const planning = data.plan === "requested" && approveForm === "";
2229
+ const read = data.assignment != null ? assignmentStageOf(data.assignment, { token: data.status.token }, planning) : stageOfCode(data.status.token, { planning });
2230
+ return firstTaskJourney(read, scope !== null && approvalOf(scope).approved, task.state === "done");
2231
+ })(),
2232
+ // "Do this every time…": the starter flow that does this kind of work on its own, one yes away.
2233
+ thread,
2234
+ details,
2235
+ chatHref: data.csrf === "" ? null : taskChatHref(data.rootId ?? task.id),
2236
+ everyTime: data.repo === null || data.csrf === "" ? null : (() => {
2237
+ const starter = starterForWork(`${task.title}\n${scope?.goal ?? ""}`);
2238
+ return { href: `/settings/flows?repo=${encodeURIComponent(data.repo)}&starter=${starter.id}#starter-${starter.id}`, starter: starter.name };
2239
+ })(),
2240
+ };
2241
+ return { html: body, view };
2242
+ }
2243
+ export function taskBody(data) {
2244
+ return taskBodyParts(data).html;
2245
+ }
2246
+ export function taskPage(chrome, data) {
2247
+ // The sensitive-page composition guard (slice 1c): this page may carry a
2248
+ // password ceremony. sendScreen() would keep a functional script on such
2249
+ // a page — so the decision is made HERE, from the rendered body itself:
2250
+ // when the body (or the chrome's list pane) shows a password input, the
2251
+ // page re-renders degraded — no poller, static attempt line, link-only
2252
+ // decisions — and ships no functional script at all.
2253
+ const first = taskBodyParts(data);
2254
+ const sensitive = SENSITIVE_INPUT.test(htmlString(first.html)) || (chrome.listPane !== undefined && SENSITIVE_INPUT.test(htmlString(chrome.listPane)));
2255
+ if (sensitive) {
2256
+ const degraded = taskBodyParts({ ...data, degraded: "sensitive" });
2257
+ return screen(`task \u00b7 ${data.task.id}`, degraded.html, { chrome, workspace: { view: degraded.view } });
2258
+ }
2259
+ const liveRunId = data.liveRunId ?? null;
2260
+ const liveRun = liveRunId === null ? undefined : data.runs.find(one => one.id === liveRunId);
2261
+ const script = (liveRun === undefined ? "" : regionScript("check-progress", "check", `/r/${liveRun.id}`)) +
2262
+ (liveRun !== undefined && data.peekable === true ? regionScript("run-peek", "peek", `/r/${liveRun.id}`) : "") +
2263
+ (liveRun !== undefined && data.peekable === true && liveRun.provider === "claude" ? transcriptScript(`/r/${liveRun.id}`) : "") +
2264
+ (data.csrf !== "" && data.decisions.some(one => one.state === "open" || one.state === "expired") ? decisionAnswerScript() : "");
2265
+ return screen(`task \u00b7 ${data.task.id}`, first.html, {
2266
+ chrome,
2267
+ workspace: { view: first.view },
2268
+ ...(script === "" ? {} : { functional: { script, fetches: true } }),
2269
+ });
2270
+ }
2271
+ /** One accessible two-choice control everywhere permissions are selected.
2272
+ * The words describe behavior; the raw flag stays secondary detail. */
2273
+ export function permissionModeChoices(name, selected) {
2274
+ const choice = (value, title, detail) => html `<label class="permission-choice"><input type="radio" name="${name}" value="${value}"${value === selected ? html ` checked` : ""}><span><strong>${title}</strong><small>${detail}</small></span></label>`;
2275
+ return joinHtml([
2276
+ html `<div class="permission-toggle" role="radiogroup" aria-label="agent permissions">`,
2277
+ choice("auto", "Auto", "Asks before risky actions."),
2278
+ choice("bypassPermissions", "Full access", "Never asks and can change files anywhere on this computer. Trusted repositories only."),
2279
+ html `</div>`,
2280
+ ]);
2281
+ }
2282
+ /** Quality selects the configured agent route; permissions remain separate. */
2283
+ export function qualityModeChoices(name, selected) {
2284
+ const choice = (value, title, detail) => html `<label class="permission-choice"><input type="radio" name="${name}" value="${value}"${value === selected ? html ` checked` : ""}><span><strong>${title}</strong><small>${detail}</small></span></label>`;
2285
+ return joinHtml([
2286
+ html `<div class="permission-toggle" role="radiogroup" aria-label="quality mode">`,
2287
+ choice("default", "Default", "Everyday agents and the repository check."),
2288
+ choice("strict", "Strict / release", "Strongest agents. Release approval stays separate."),
2289
+ html `</div>`,
2290
+ ]);
2291
+ }
2292
+ /**
2293
+ * A decision's answer forms — one source of truth for the decision screen
2294
+ * and the triage flow. The consequence reads BEFORE the button that buys
2295
+ * it; irreversible options arm behind one deliberate tap AND the server
2296
+ * independently requires the confirm field. `returnTo` is allow-listed by
2297
+ * the answer handler, never an arbitrary URL. `csrf` is unused (postForm
2298
+ * writes the field); kept for callers.
2299
+ */
2300
+ export function decisionOptionForms(decision, returnTo) {
2301
+ return joinHtml(decision.options
2302
+ .map(option => {
2303
+ const recommended = option.id === decision.recommendation;
2304
+ const inner = postForm(`/d/${decision.id}/answer`, joinHtml([
2305
+ "",
2306
+ recommended ? html `<p class="meta" style="margin:0 0 .375rem"><span class="badge">Recommended</span></p>` : "",
2307
+ html `<p class="consequence">${option.consequence}</p>`,
2308
+ html `<button type="submit">${option.label}${option.reversible ? "" : html ` <span class="badge badge-overdue">Irreversible</span>`}</button>`,
2309
+ html `<input type="text" name="note" placeholder="optional note — travels with this answer" aria-label="optional note">`,
2310
+ "",
2311
+ ], "\n"), {
2312
+ attrs: { class: `option${recommended ? " recommended" : ""}` },
2313
+ returnTo,
2314
+ hidden: { choice: option.id, confirm: option.reversible ? null : "yes" },
2315
+ });
2316
+ return option.reversible
2317
+ ? inner
2318
+ : html `<details class="arm-danger"><summary>${option.label} — irreversible, tap to arm</summary>${inner}</details>`;
2319
+ }), "\n");
2320
+ }
2321
+ export function decisionPage(chrome, decision, taskId, artifacts, who, now, returnTo = null) {
2322
+ const options = decisionOptionForms(decision, returnTo);
2323
+ const answered = decision.state === "answered"
2324
+ ? html `<div class="answered">Answered: <strong>${decision.choice ?? ""}</strong> by ${decision.answeredBy ?? ""}${decision.note === null ? "" : ` — ${decision.note}`}</div>`
2325
+ : "";
2326
+ const evidence = artifacts.length === 0
2327
+ ? ""
2328
+ : joinHtml([
2329
+ html `<div class="evidence"><strong>Evidence</strong>`,
2330
+ joinHtml(artifacts.map(artifact => html `<a href="/d/${decision.id}/evidence/${artifact.id}">${evidenceWords(artifact.kind)}${artifact.truncated ? " (truncated)" : ""} · ${artifact.bytesStored} bytes</a>`), "\n"),
2331
+ html `</div>`,
2332
+ ]);
2333
+ return screen(`decide · ${taskId}`, joinHtml([
2334
+ html `<h1>${taskId} <span class="badge badge-${decision.state}">${decision.state}</span>${isOverdue(decision, now) ? html ` <span class="badge badge-overdue">Overdue</span>` : ""}${decision.deadline === null ? "" : html ` <span class="meta">deadline ${decision.deadline}</span>`}</h1>`,
2335
+ html `<div class="recap">${decision.recap}</div>`,
2336
+ html `<div class="question">${decision.question}</div>`,
2337
+ decision.state === "answered" ? answered : options,
2338
+ evidence,
2339
+ html `<p class="meta"><a href="${returnTo === null ? "/" : returnTo}">← ${returnTo === null ? "everything waiting" : "back to the task chat"}</a></p>`,
2340
+ ], "\n"), { chrome });
2341
+ }
2342
+ export function taskOf(store, decision) {
2343
+ const run = store.getRun(decision.run);
2344
+ return run === null ? "?" : store.externalIdFor(run.taskRef) ?? "?";
2345
+ }