toolroll 0.9.54 → 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 (199) hide show
  1. package/CHANGELOG.md +9 -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-polish.d.ts +5 -4
  19. package/dist/chat-polish.js +9 -10
  20. package/dist/check-levels-ui.d.ts +2 -1
  21. package/dist/check-levels-ui.js +26 -53
  22. package/dist/cli.d.ts +3 -1
  23. package/dist/cli.js +9 -3
  24. package/dist/coding-shipping-ui.d.ts +2 -1
  25. package/dist/coding-shipping-ui.js +6 -6
  26. package/dist/coding-ui.d.ts +3 -1
  27. package/dist/coding-ui.js +30 -29
  28. package/dist/contracts/cli.d.ts +1 -3
  29. package/dist/contracts/console-api.d.ts +1 -1
  30. package/dist/contracts/console-api.js +6 -5
  31. package/dist/contracts/health.d.ts +1 -3
  32. package/dist/control-ui.d.ts +5 -4
  33. package/dist/control-ui.js +9 -34
  34. package/dist/credentials-ui.d.ts +5 -4
  35. package/dist/credentials-ui.js +23 -24
  36. package/dist/demo-chat.d.ts +3 -3
  37. package/dist/demo-chat.js +29 -76
  38. package/dist/discord-settings.d.ts +3 -2
  39. package/dist/discord-settings.js +17 -42
  40. package/dist/envelope.js +10 -0
  41. package/dist/evidence-pack.d.ts +2 -1
  42. package/dist/evidence-pack.js +45 -49
  43. package/dist/export-ui.d.ts +7 -2
  44. package/dist/export-ui.js +4 -10
  45. package/dist/flow-gallery-ui.d.ts +4 -3
  46. package/dist/flow-gallery-ui.js +28 -43
  47. package/dist/flow-starters-ui.d.ts +2 -1
  48. package/dist/flow-starters-ui.js +12 -15
  49. package/dist/flow-triggers.d.ts +1 -0
  50. package/dist/flow-triggers.js +16 -14
  51. package/dist/flows-ui.d.ts +4 -3
  52. package/dist/flows-ui.js +19 -30
  53. package/dist/flows.js +2 -1
  54. package/dist/guides.js +108 -3
  55. package/dist/html.d.ts +76 -0
  56. package/dist/html.js +173 -0
  57. package/dist/integrations-ui.d.ts +2 -1
  58. package/dist/integrations-ui.js +19 -23
  59. package/dist/kits-ui.d.ts +5 -4
  60. package/dist/kits-ui.js +18 -20
  61. package/dist/knowledge-ui.d.ts +6 -5
  62. package/dist/knowledge-ui.js +26 -25
  63. package/dist/lead-context.d.ts +11 -1
  64. package/dist/lead-context.js +6 -8
  65. package/dist/ledger-view.d.ts +2 -1
  66. package/dist/ledger-view.js +27 -29
  67. package/dist/limits-ui.d.ts +2 -1
  68. package/dist/limits-ui.js +7 -7
  69. package/dist/live-bus.d.ts +50 -8
  70. package/dist/live-bus.js +133 -50
  71. package/dist/mcp-oauth.js +35 -36
  72. package/dist/models-ui.d.ts +2 -1
  73. package/dist/models-ui.js +25 -35
  74. package/dist/monitoring-ui.d.ts +4 -3
  75. package/dist/monitoring-ui.js +13 -26
  76. package/dist/openrouter-models.d.ts +2 -1
  77. package/dist/openrouter-models.js +5 -9
  78. package/dist/operate.js +11 -7
  79. package/dist/people-audit-ui.d.ts +2 -1
  80. package/dist/people-audit-ui.js +13 -13
  81. package/dist/policy-ui.d.ts +3 -2
  82. package/dist/policy-ui.js +17 -23
  83. package/dist/project-delete-ui.d.ts +4 -3
  84. package/dist/project-delete-ui.js +15 -27
  85. package/dist/pull-request-ui.d.ts +2 -1
  86. package/dist/pull-request-ui.js +17 -32
  87. package/dist/recipe-ui.d.ts +8 -7
  88. package/dist/recipe-ui.js +39 -32
  89. package/dist/release-adapters.d.ts +60 -0
  90. package/dist/release-adapters.js +435 -0
  91. package/dist/release-cli.d.ts +24 -0
  92. package/dist/release-cli.js +181 -0
  93. package/dist/release-gate.d.ts +80 -0
  94. package/dist/release-gate.js +150 -0
  95. package/dist/release-links.d.ts +29 -0
  96. package/dist/release-links.js +102 -0
  97. package/dist/release.d.ts +331 -0
  98. package/dist/release.js +550 -0
  99. package/dist/reply-shape.d.ts +2 -1
  100. package/dist/reply-shape.js +5 -5
  101. package/dist/repository-context-ui.d.ts +2 -1
  102. package/dist/repository-context-ui.js +2 -7
  103. package/dist/request-budget-ui.d.ts +2 -1
  104. package/dist/request-budget-ui.js +16 -16
  105. package/dist/result-review.d.ts +2 -3
  106. package/dist/result-review.js +3 -9
  107. package/dist/retention-ui.d.ts +7 -1
  108. package/dist/retention-ui.js +7 -11
  109. package/dist/serve.d.ts +11 -2
  110. package/dist/serve.js +69 -58
  111. package/dist/server/chat.d.ts +3 -6
  112. package/dist/server/chat.js +969 -1006
  113. package/dist/server/chrome.d.ts +291 -0
  114. package/dist/server/chrome.js +747 -0
  115. package/dist/server/console.css +2819 -0
  116. package/dist/server/flows.d.ts +2 -6
  117. package/dist/server/flows.js +840 -766
  118. package/dist/server/guards.d.ts +1 -1
  119. package/dist/server/guards.js +5 -2
  120. package/dist/server/handler-context.d.ts +1 -1
  121. package/dist/server/handler-context.js +1 -1
  122. package/dist/server/handler-registry.d.ts +8 -6
  123. package/dist/server/handler-registry.js +14 -2
  124. package/dist/server/http.d.ts +274 -0
  125. package/dist/server/http.js +239 -0
  126. package/dist/server/live.d.ts +34 -0
  127. package/dist/server/live.js +309 -0
  128. package/dist/server/pages.d.ts +2 -6
  129. package/dist/server/pages.js +808 -789
  130. package/dist/server/people-tokens.d.ts +1 -6
  131. package/dist/server/people-tokens.js +580 -572
  132. package/dist/server/remote.d.ts +4 -7
  133. package/dist/server/remote.js +37 -70
  134. package/dist/server/render-chat.d.ts +402 -0
  135. package/dist/server/render-chat.js +927 -0
  136. package/dist/server/render-pages.d.ts +535 -0
  137. package/dist/server/render-pages.js +1277 -0
  138. package/dist/server/render-people.d.ts +32 -0
  139. package/dist/server/render-people.js +121 -0
  140. package/dist/server/render-results.d.ts +744 -0
  141. package/dist/server/render-results.js +2237 -0
  142. package/dist/server/render-settings.d.ts +147 -0
  143. package/dist/server/render-settings.js +577 -0
  144. package/dist/server/render-tasks.d.ts +632 -0
  145. package/dist/server/render-tasks.js +2345 -0
  146. package/dist/server/request-context.d.ts +46 -0
  147. package/dist/server/request-context.js +17 -0
  148. package/dist/server/route-table.d.ts +25 -3
  149. package/dist/server/route-table.js +163 -75
  150. package/dist/server/runtime.d.ts +15 -7
  151. package/dist/server/runtime.js +7 -1
  152. package/dist/server/session.d.ts +138 -0
  153. package/dist/server/session.js +94 -0
  154. package/dist/server/settings.d.ts +1 -5
  155. package/dist/server/settings.js +2181 -2117
  156. package/dist/server/task-views.d.ts +11 -4
  157. package/dist/server/task-views.js +26 -22
  158. package/dist/server/tasks.d.ts +9 -9
  159. package/dist/server/tasks.js +1486 -1418
  160. package/dist/server-telemetry.d.ts +4 -3
  161. package/dist/server-telemetry.js +4 -8
  162. package/dist/skills-ui.d.ts +4 -3
  163. package/dist/skills-ui.js +14 -17
  164. package/dist/skills.d.ts +1 -1
  165. package/dist/skills.js +2 -1
  166. package/dist/slack-settings.d.ts +3 -2
  167. package/dist/slack-settings.js +17 -42
  168. package/dist/spend-ui.d.ts +2 -1
  169. package/dist/spend-ui.js +9 -32
  170. package/dist/sso-ui.d.ts +3 -2
  171. package/dist/sso-ui.js +26 -27
  172. package/dist/storage-ui.d.ts +2 -1
  173. package/dist/storage-ui.js +14 -33
  174. package/dist/surface.js +14 -3
  175. package/dist/task-composer.d.ts +2 -1
  176. package/dist/task-composer.js +7 -7
  177. package/dist/task-status.d.ts +4 -3
  178. package/dist/task-status.js +15 -16
  179. package/dist/team-http.d.ts +5 -9
  180. package/dist/team-http.js +5 -63
  181. package/dist/team-ui.d.ts +2 -1
  182. package/dist/team-ui.js +2 -5
  183. package/dist/teammates-ui.d.ts +5 -4
  184. package/dist/teammates-ui.js +61 -90
  185. package/dist/teams-settings.d.ts +3 -2
  186. package/dist/teams-settings.js +20 -31
  187. package/dist/telegram-settings.d.ts +3 -2
  188. package/dist/telegram-settings.js +14 -25
  189. package/dist/toolroll-update-ui.d.ts +3 -2
  190. package/dist/toolroll-update-ui.js +35 -39
  191. package/dist/tools-ui.d.ts +5 -2
  192. package/dist/tools-ui.js +31 -31
  193. package/dist/when-html.d.ts +11 -3
  194. package/dist/when-html.js +5 -6
  195. package/dist/workspace-ui.d.ts +4 -2
  196. package/dist/workspace-ui.js +10 -10
  197. package/package.json +1 -1
  198. package/dist/server/shared.d.ts +0 -3151
  199. package/dist/server/shared.js +0 -12001
@@ -7,7 +7,7 @@
7
7
  import { brandMarkHtml } from "./brand-mark.js";
8
8
  import { BLANK, GALLERY, GALLERY_GROUPS, galleryDiagram, galleryToolsOf, OUTDATED_COMMANDS, SEND_RESULT, sendsAlready } from "./flow-gallery.js";
9
9
  import { choiceTargets } from "./flows.js";
10
- const e = (value) => String(value ?? "").replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
10
+ import { html, joinHtml, postForm } from "./html.js";
11
11
  export const GALLERY_CSS = `.gallery{max-width:1120px;min-width:0}.gallery h2{margin:28px 0 10px;font-size:.9375rem}.gallery h2:first-of-type{margin-top:8px}` +
12
12
  `.gallery-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:12px}` +
13
13
  `.gallery-card{display:flex;flex-direction:column;gap:8px;padding:16px;border:1px solid var(--so-line);border-radius:10px;background:var(--so-paper);min-width:0}` +
@@ -53,30 +53,28 @@ export function mainPath(definition) {
53
53
  const stepName = (stage) => stage.kind === "choose" ? "You choose" : stage.kind === "approval" ? "You approve" : stage.kind === "send" ? "Sent to you" : stage.title;
54
54
  /** A flow's steps in order, joined by arrows; a person's decisions carry the accent. */
55
55
  export function stepStrip(definition) {
56
- return `<ol class="gallery-steps" aria-label="Steps">${mainPath(definition).map((one, i) => {
57
- const name = e(stepName(one));
58
- return `<li>${i === 0 ? "" : `<span aria-hidden="true">→</span>`}${one.kind === "choose" || one.kind === "approval" ? `<b data-person>${name}</b>` : name}</li>`;
59
- }).join("")}</ol>`;
56
+ return html `<ol class="gallery-steps" aria-label="Steps">${mainPath(definition).map((one, i) => {
57
+ const name = stepName(one);
58
+ return html `<li>${i === 0 ? "" : html `<span aria-hidden="true">→</span>`}${one.kind === "choose" || one.kind === "approval" ? html `<b data-person>${name}</b>` : name}</li>`;
59
+ })}</ol>`;
60
60
  }
61
- const needsList = (template) => template.needs.length === 0 ? (template.tools ?? []).length > 0 ? "" : `<ul class="gallery-needs"><li>Nothing to connect</li></ul>`
62
- : `<ul class="gallery-needs" aria-label="Needs">${template.needs.map(one => `<li>${e(one)}</li>`).join("")}</ul>`;
61
+ const needsList = (template) => template.needs.length === 0 ? (template.tools ?? []).length > 0 ? "" : html `<ul class="gallery-needs"><li>Nothing to connect</li></ul>`
62
+ : html `<ul class="gallery-needs" aria-label="Needs">${template.needs.map(one => html `<li>${one}</li>`)}</ul>`;
63
63
  /** A tool's state in words, as Settings → Integrations badges it; nothing without a project. */
64
64
  const STATE = { connected: ["connected", "Connected"], open: ["not-set-up", "Not connected"], taken: ["not-set-up", "Added another way"] };
65
- const toolState = (tool) => tool.state === null ? "" : `<span class="integration-state integration-state--${STATE[tool.state][0]}"><i aria-hidden="true"></i>${STATE[tool.state][1]}</span>`;
65
+ const toolState = (tool) => tool.state === null ? "" : html `<span class="integration-state integration-state--${STATE[tool.state][0]}"><i aria-hidden="true"></i>${STATE[tool.state][1]}</span>`;
66
66
  const toolMark = (tool) => brandMarkHtml(tool.id.replace(/-desktop$/, ""), tool.label, tool.state === "connected");
67
67
  const toolsList = (tools) => tools.length === 0 ? ""
68
- : `<ul class="gallery-tools" aria-label="Tools">${tools.map(one => `<li data-tool="${e(one.id)}" data-state="${one.state ?? "unknown"}">${toolMark(one)}<strong>${e(one.label)}</strong>${toolState(one)}</li>`).join("")}</ul>`;
68
+ : html `<ul class="gallery-tools" aria-label="Tools">${tools.map(one => html `<li data-tool="${one.id}" data-state="${one.state ?? "unknown"}">${toolMark(one)}<strong>${one.label}</strong>${toolState(one)}</li>`)}</ul>`;
69
69
  /** The gallery: every template, grouped, each with one "Use this". `repo` carries a chosen project to the template's page. */
70
70
  export function galleryHtml(input) {
71
71
  const href = (id) => `/flows/new/${id}${input.repo === null ? "" : `?repo=${encodeURIComponent(input.repo)}`}`;
72
72
  const card = (template) => {
73
73
  const definition = galleryDiagram(template);
74
- return `<article class="gallery-card" data-template="${e(template.id)}"><h3>${e(template.name)}</h3><p class="gallery-promise">${e(template.promise)}</p>` +
75
- stepStrip(definition) + toolsList(galleryToolsOf(template, definition, input.repo === null ? null : input.connections ?? null)) + needsList(template) +
76
- (input.canUse ? `<a class="button-link" href="${e(href(template.id))}" aria-label="Use ${e(template.name)}">Use this</a>` : "") + `</article>`;
74
+ return html `<article class="gallery-card" data-template="${template.id}"><h3>${template.name}</h3><p class="gallery-promise">${template.promise}</p>${stepStrip(definition)}${toolsList(galleryToolsOf(template, definition, input.repo === null ? null : input.connections ?? null))}${needsList(template)}${input.canUse && html `<a class="button-link" href="${href(template.id)}" aria-label="Use ${template.name}">Use this</a>`}</article>`;
77
75
  };
78
- const groups = GALLERY_GROUPS.map(group => `<h2 id="gallery-${group.id}">${e(group.label)}</h2><div class="gallery-grid" data-group="${group.id}">${GALLERY.filter(one => one.group === group.id).map(card).join("")}</div>`).join("");
79
- return `<section class="gallery">${groups}${input.canUse ? `<p class="gallery-blank meta"><a href="${e(href(BLANK.id))}">Start from a blank flow</a></p>` : `<p class="meta">An approver creates flows.</p>`}</section>`;
76
+ const groups = GALLERY_GROUPS.map(group => html `<h2 id="gallery-${group.id}">${group.label}</h2><div class="gallery-grid" data-group="${group.id}">${GALLERY.filter(one => one.group === group.id).map(card)}</div>`);
77
+ return html `<section class="gallery">${groups}${input.canUse ? html `<p class="gallery-blank meta"><a href="${href(BLANK.id)}">Start from a blank flow</a></p>` : html `<p class="meta">An approver creates flows.</p>`}</section>`;
80
78
  }
81
79
  /** A template's page: its questions, then what it will do and never do, then Create. */
82
80
  export function galleryUseHtml(input) {
@@ -86,43 +84,30 @@ export function galleryUseHtml(input) {
86
84
  const missing = tools.filter(one => one.state !== "connected" && one.state !== "taken");
87
85
  // Each tool with its state; one the project lacks offers its own Connect (the Tools page's, with the same password), and comes back here.
88
86
  const connect = (tool) => {
89
- const head = `<p class="gallery-tool-head">${toolMark(tool)} <strong>${e(tool.label)}</strong> ${toolState(tool)}</p>`;
87
+ const head = html `<p class="gallery-tool-head">${toolMark(tool)} <strong>${tool.label}</strong> ${toolState(tool)}</p>`;
90
88
  // Added another way, it is there: nothing to connect.
91
89
  if (tool.state === "connected" || tool.state === "taken" || input.csrf === "")
92
- return `<div class="gallery-connect" data-tool="${e(tool.id)}" data-state="${tool.state}">${head}</div>`;
90
+ return html `<div class="gallery-connect" data-tool="${tool.id}" data-state="${String(tool.state)}">${head}</div>`;
93
91
  const how = tool.id === "figma-desktop" ? "Open the Figma desktop app and turn on its Dev Mode MCP server in Preferences." : `You sign in on ${tool.label}, then come back here.`;
94
- return `<div class="gallery-connect" data-tool="${e(tool.id)}" data-state="open">${head}<form method="post" action="/settings/tools/connect">` +
95
- `<input type="hidden" name="csrf" value="${e(input.csrf)}"><input type="hidden" name="repo" value="${e(input.repo)}"><input type="hidden" name="shown" value="${e(input.repo)}"><input type="hidden" name="template" value="${e(template.id)}">` +
96
- `<p class="meta">${e(how)}</p><label>Your Toolroll password<input type="password" name="password" autocomplete="current-password" required></label>` +
97
- `<button name="service" value="${e(tool.id)}">Connect ${e(tool.label)}</button></form></div>`;
92
+ return html `<div class="gallery-connect" data-tool="${tool.id}" data-state="open">${head}${postForm("/settings/tools/connect", html `<p class="meta">${how}</p><label>Your Toolroll password<input type="password" name="password" autocomplete="current-password" required></label><button name="service" value="${tool.id}">Connect ${tool.label}</button>`, { hidden: { repo: input.repo, shown: input.repo, template: template.id } })}</div>`;
98
93
  };
99
- const toolsBlock = tools.length === 0 ? "" : `<section class="gallery-tools-use" aria-label="Tools">${tools.map(connect).join("")}</section>`;
94
+ const toolsBlock = tools.length === 0 ? "" : html `<section class="gallery-tools-use" aria-label="Tools">${tools.map(connect)}</section>`;
100
95
  // Made without a tool, the preview names the zone that needs it.
101
- const zones = (tool) => tool.zones.map(zone => `“${e(zone)}”`).join(" and ");
102
- const needing = missing.filter(one => one.zones.length > 0).map(one => `<p class="meta" data-needs-tool="${e(one.id)}">${zones(one)} need${one.zones.length === 1 ? "s" : ""} ${e(one.label)}, which isn't connected.</p>`).join("") +
103
- tools.filter(one => one.state === "taken" && one.zones.length > 0).map(one => `<p class="meta" data-tool-taken="${e(one.id)}">${zones(one)} use${one.zones.length === 1 ? "s" : ""} ${e(one.label)}, added another way.</p>`).join("");
96
+ const zones = (tool) => joinHtml(tool.zones.map(zone => html `“${zone}”`), " and ");
97
+ const needing = html `${missing.filter(one => one.zones.length > 0).map(one => html `<p class="meta" data-needs-tool="${one.id}">${zones(one)} need${one.zones.length === 1 ? "s" : ""} ${one.label}, which isn't connected.</p>`)}${tools.filter(one => one.state === "taken" && one.zones.length > 0).map(one => html `<p class="meta" data-tool-taken="${one.id}">${zones(one)} use${one.zones.length === 1 ? "s" : ""} ${one.label}, added another way.</p>`)}`;
104
98
  const field = (ask) => {
105
99
  const value = input.answers[ask.key] ?? ask.default;
106
- const hint = ask.hint === undefined ? "" : ` <small>${e(ask.hint)}</small>`;
100
+ const hint = ask.hint === undefined ? "" : html ` <small>${ask.hint}</small>`;
107
101
  if (ask.key === "outdated")
108
- return `<label>${e(ask.label)}<select name="outdated">${Object.entries(OUTDATED_COMMANDS).map(([id, one]) => `<option value="${id}"${id === value ? " selected" : ""}>${e(one.label)}</option>`).join("")}</select></label>`;
109
- return `<label>${e(ask.label)}${hint}<input name="${ask.key}" value="${e(value)}" required maxlength="${ask.key === "command" ? 500 : ask.key === "team" ? 12 : 100}"${ask.key === "command" ? ' spellcheck="false" autocapitalize="off"' : ask.key === "team" ? ' spellcheck="false" autocapitalize="characters"' : ""}></label>`;
102
+ return html `<label>${ask.label}<select name="outdated">${Object.entries(OUTDATED_COMMANDS).map(([id, one]) => html `<option value="${id}"${id === value && html ` selected`}>${one.label}</option>`)}</select></label>`;
103
+ return html `<label>${ask.label}${hint}<input name="${ask.key}" value="${value}" required maxlength="${ask.key === "command" ? 500 : ask.key === "team" ? 12 : 100}"${ask.key === "command" ? html ` spellcheck="false" autocapitalize="off"` : ask.key === "team" ? html ` spellcheck="false" autocapitalize="characters"` : ""}></label>`;
110
104
  };
111
105
  const project = input.projects.length > 1
112
- ? `<label>Project<select name="repo">${input.projects.map(one => `<option value="${e(one.path)}"${one.path === input.repo ? " selected" : ""}>${e(one.name)}</option>`).join("")}</select></label>`
113
- : `<input type="hidden" name="repo" value="${e(input.repo)}">`;
114
- const preview = input.preview === null ? "" : `<section class="gallery-preview" aria-labelledby="gallery-will">` +
115
- `<h2 id="gallery-will">What it will do</h2><ul>${input.preview.built.does.map(one => `<li>${e(one)}</li>`).join("")}</ul>` +
116
- `<p class="gallery-never">${e(input.preview.built.never)}</p>${needing}` +
117
- (input.preview.startsFrom.length === 0 ? `<p class="meta">Cards start when you add them.</p>` : input.preview.startsFrom.map(one => `<p class="meta">Starts from: ${e(one)}</p>`).join("")) +
118
- `<details><summary>Every step</summary><ol>${input.preview.steps.map(one => `<li>${e(one.replace(/^\d+\.\s/, ""))}</li>`).join("")}</ol></details></section>`;
119
- return `<section class="gallery-use" data-gallery-template="${e(template.id)}">` +
120
- (input.problem === null ? "" : `<p class="problem" role="alert">${e(input.problem)}</p>`) + (input.said ? `<p role="status">${e(input.said)}</p>` : "") +
121
- `<p>${e(template.promise)}</p>${input.diagram === null ? "" : stepStrip(input.diagram)}${needsList(template)}${toolsBlock}` +
122
- `<form method="post" action="/flows/new/${e(template.id)}" data-gallery-use><input type="hidden" name="csrf" value="${e(input.csrf)}">` +
123
- `<input type="hidden" name="previewed" value="${e(input.preview?.digest ?? "")}">${project}${template.asks.map(field).join("")}` +
124
- `<label>Name<input name="name" value="${e(input.name)}" maxlength="80"></label>` +
125
- (sendsAlready(template) ? "" : `<label class="gallery-check"><input type="checkbox" name="${SEND_RESULT.key}" value="yes"${input.answers[SEND_RESULT.key] === "yes" ? " checked" : ""} data-send-result><span>${e(SEND_RESULT.title)}<small>When a card finishes, what was done comes to you in your chat apps.</small></span></label>`) + preview +
126
- `<p class="gallery-actions">${input.preview === null ? "" : `<button type="submit" name="intent" value="create"${missing.length > 0 ? ' class="secondary" data-without-tools' : ""}>Create flow</button>`}<button type="submit" name="intent" value="preview" class="secondary">${input.preview === null ? "Preview" : "Update preview"}</button></p>` +
127
- `</form><p class="meta"><a href="/flows/new">All templates</a></p></section>`;
106
+ ? html `<label>Project<select name="repo">${input.projects.map(one => html `<option value="${one.path}"${one.path === input.repo && html ` selected`}>${one.name}</option>`)}</select></label>`
107
+ : html `<input type="hidden" name="repo" value="${input.repo}">`;
108
+ const preview = input.preview === null ? "" : html `<section class="gallery-preview" aria-labelledby="gallery-will"><h2 id="gallery-will">What it will do</h2><ul>${input.preview.built.does.map(one => html `<li>${one}</li>`)}</ul><p class="gallery-never">${input.preview.built.never}</p>${needing}${input.preview.startsFrom.length === 0 ? html `<p class="meta">Cards start when you add them.</p>` : input.preview.startsFrom.map(one => html `<p class="meta">Starts from: ${one}</p>`)}<details><summary>Every step</summary><ol>${input.preview.steps.map(one => html `<li>${one.replace(/^\d+\.\s/, "")}</li>`)}</ol></details></section>`;
109
+ const sendResult = !sendsAlready(template) && html `<label class="gallery-check"><input type="checkbox" name="${SEND_RESULT.key}" value="yes"${input.answers[SEND_RESULT.key] === "yes" && html ` checked`} data-send-result><span>${SEND_RESULT.title}<small>When a card finishes, what was done comes to you in your chat apps.</small></span></label>`;
110
+ const actions = html `<p class="gallery-actions">${input.preview !== null && html `<button type="submit" name="intent" value="create"${missing.length > 0 && html ` class="secondary" data-without-tools`}>Create flow</button>`}<button type="submit" name="intent" value="preview" class="secondary">${input.preview === null ? "Preview" : "Update preview"}</button></p>`;
111
+ const form = postForm(`/flows/new/${template.id}`, html `${project}${template.asks.map(field)}<label>Name<input name="name" value="${input.name}" maxlength="80"></label>${sendResult}${preview}${actions}`, { attrs: { "data-gallery-use": true }, hidden: { previewed: input.preview?.digest ?? "" } });
112
+ return html `<section class="gallery-use" data-gallery-template="${template.id}">${input.problem !== null && html `<p class="problem" role="alert">${input.problem}</p>`}${input.said ? html `<p role="status">${input.said}</p>` : ""}<p>${template.promise}</p>${input.diagram !== null && stepStrip(input.diagram)}${needsList(template)}${toolsBlock}${form}<p class="meta"><a href="/flows/new">All templates</a></p></section>`;
128
113
  }
@@ -4,6 +4,7 @@
4
4
  * that's on links to its flow; one that can't run here says why.
5
5
  */
6
6
  import type { StarterView } from "./flow-starters.js";
7
+ import { type Html } from "./html.js";
7
8
  export declare const STARTERS_CSS: string;
8
9
  export declare function startersHtml(input: {
9
10
  repo: string;
@@ -17,4 +18,4 @@ export declare function startersHtml(input: {
17
18
  suggested: string | null;
18
19
  said: string | null;
19
20
  problem: string | null;
20
- }): string;
21
+ }): Html;
@@ -1,4 +1,4 @@
1
- const e = (value) => String(value ?? "").replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
1
+ import { html, postForm } from "./html.js";
2
2
  export const STARTERS_CSS = `.starters{max-width:760px;min-width:0}.starters h2{margin:24px 0 4px;font-size:.9375rem}` +
3
3
  `.starter{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:4px 16px;align-items:start;padding:14px 0;border-top:1px solid var(--so-line);min-width:0}` +
4
4
  `.starter:first-of-type{border-top:0}.starter>:not(.starter-action){grid-column:1}.starter h3{margin:0;font-size:.9375rem;display:flex;flex-wrap:wrap;gap:6px 10px;align-items:center}` +
@@ -13,23 +13,20 @@ export const STARTERS_CSS = `.starters{max-width:760px;min-width:0}.starters h2{
13
13
  `@media (max-width:560px){.starter{grid-template-columns:minmax(0,1fr)}.starter .starter-action{grid-column:1;grid-row:auto;margin-top:10px}` +
14
14
  `.starter .starter-action button,.starter .starter-action .button-link{width:100%;justify-content:center}.starters select{font-size:16px}}`;
15
15
  export function startersHtml(input) {
16
- const note = input.problem ? `<p class="problem" role="alert">${e(input.problem)}</p>` : input.said ? `<p role="status">${e(input.said)}</p>` : "";
16
+ const note = input.problem ? html `<p class="problem" role="alert">${input.problem}</p>` : input.said ? html `<p role="status">${input.said}</p>` : "";
17
17
  const selector = input.projects.length > 1
18
- ? `<form class="starters-project" method="get" action="/settings/flows"><label>Project<select name="repo">${input.projects.map(one => `<option value="${e(one.path)}"${one.path === input.repo ? " selected" : ""}>${e(one.name)}</option>`).join("")}</select></label><button>Show</button></form>` : "";
18
+ ? html `<form class="starters-project" method="get" action="/settings/flows"><label>Project<select name="repo">${input.projects.map(one => html `<option value="${one.path}"${one.path === input.repo && html ` selected`}>${one.name}</option>`)}</select></label><button>Show</button></form>` : "";
19
19
  // The one asked about ("Do this every time…") comes first, marked.
20
20
  const ordered = [...input.starters].sort((a, b) => Number(b.id === input.suggested) - Number(a.id === input.suggested));
21
21
  const rows = ordered.map(one => {
22
- const action = one.on !== null ? `<p class="starter-action"><a class="button-link starter-open" href="/flows/${one.on.flow}">Open flow</a></p>`
22
+ const action = one.on !== null ? html `<p class="starter-action"><a class="button-link starter-open" href="/flows/${one.on.flow}">Open flow</a></p>`
23
23
  : one.blocked !== null || !input.canSwitch ? ""
24
- : `<form method="post" action="/settings/flows/on" class="starter-action"><input type="hidden" name="csrf" value="${e(input.csrf)}"><input type="hidden" name="repo" value="${e(input.repo)}">` +
25
- `<input type="hidden" name="starter" value="${e(one.id)}"><button type="submit">Switch on</button></form>`;
26
- return `<div class="starter" id="starter-${e(one.id)}" data-starter="${e(one.id)}" data-suggested="${one.id === input.suggested && one.on === null}">` +
27
- `<h3>${e(one.name)}${one.on === null ? "" : ` <span class="starter-on"><i aria-hidden="true"></i>On</span>`}</h3>` +
28
- `<p>${e(one.summary)}</p>` +
29
- // What the yes agrees to, in full, before it; nothing to agree to once it's on or can't be.
30
- (one.on === null && one.blocked === null ? `<ul>${one.does.map(line => `<li>${e(line)}</li>`).join("")}</ul><p class="starter-never">${e(one.never)}</p>` : "") +
31
- (one.on === null && one.blocked !== null ? `<p class="starter-blocked">${e(one.blocked)}</p>` : "") + action + `</div>`;
32
- }).join("");
33
- const nobody = !input.canSwitch && input.starters.some(one => one.on === null) ? `<p class="meta">An approver switches these on.</p>` : "";
34
- return `<section class="starters">${note}${selector}<h2>Starter flows</h2>${rows}${nobody}<p class="meta"><a href="/flows">All flows</a></p></section>`;
24
+ : postForm("/settings/flows/on", html `<button type="submit">Switch on</button>`, { attrs: { class: "starter-action" }, hidden: { repo: input.repo, starter: one.id } });
25
+ // What the yes agrees to, in full, before it; nothing to agree to once it's on or can't be.
26
+ const agrees = one.on === null && one.blocked === null && html `<ul>${one.does.map(line => html `<li>${line}</li>`)}</ul><p class="starter-never">${one.never}</p>`;
27
+ const head = html `<h3>${one.name}${one.on === null ? "" : html ` <span class="starter-on"><i aria-hidden="true"></i>On</span>`}</h3>`;
28
+ return html `<div class="starter" id="starter-${one.id}" data-starter="${one.id}" data-suggested="${String(one.id === input.suggested && one.on === null)}">${head}<p>${one.summary}</p>${agrees}${one.on === null && one.blocked !== null && html `<p class="starter-blocked">${one.blocked}</p>`}${action}</div>`;
29
+ });
30
+ const nobody = !input.canSwitch && input.starters.some(one => one.on === null) ? html `<p class="meta">An approver switches these on.</p>` : "";
31
+ return html `<section class="starters">${note}${selector}<h2>Starter flows</h2>${rows}${nobody}<p class="meta"><a href="/flows">All flows</a></p></section>`;
35
32
  }
@@ -208,6 +208,7 @@ export declare const FORM_PATH = "/hooks/form/";
208
208
  /** Share a button as a secret link anyone can fill in, without signing in. The link is shown once; only its hash is kept. */
209
209
  export declare function shareFlowButton(store: Store, trigger: FlowTriggerRow, now: Date, dir: string | null): TriggerMade;
210
210
  export declare function stopSharingFlowButton(store: Store, trigger: FlowTriggerRow, now: Date): void;
211
+ /** The page's bytes (the public route writes them itself, with its own headers). */
211
212
  export type FormAnswer = {
212
213
  status: number;
213
214
  html: string;
@@ -1,4 +1,5 @@
1
1
  import { adapterPolicy } from "./server/route-policy.js";
2
+ import { html, htmlString, postForm, styleElement } from "./html.js";
2
3
  /**
3
4
  * Flow triggers (v82): what starts cards in a flow without someone adding
4
5
  * them by hand.
@@ -1107,11 +1108,7 @@ export function shareFlowButton(store, trigger, now, dir) {
1107
1108
  export function stopSharingFlowButton(store, trigger, now) {
1108
1109
  store.updateFlowTrigger(trigger.id, { hookHash: null }, now);
1109
1110
  }
1110
- const esc = (value) => value.replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
1111
- /** The public page: the button's name and its questions, nothing about the flow, the project or anyone in it. */
1112
- function formHtml(label, body) {
1113
- return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="robots" content="noindex">
1114
- <title>${esc(label)}</title><style>
1111
+ const FORM_CSS = `
1115
1112
  :root{color-scheme:light dark;--bg:#f7f7f5;--card:#fff;--text:#1c1d1b;--muted:#666a63;--line:#dcded8;--accent:#2d5a45}
1116
1113
  @media (prefers-color-scheme:dark){:root{--bg:#141614;--card:#1d201d;--text:#ecefe9;--muted:#9ea39a;--line:#343934;--accent:#8fd0ae}}
1117
1114
  *{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font:16px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif}
@@ -1119,18 +1116,23 @@ main{max-width:560px;margin:0 auto;padding:40px 16px}h1{font-size:1.5rem;margin:
1119
1116
  label{display:grid;gap:6px;margin:0 0 16px;font-weight:600}input,textarea{font:inherit;color:inherit;background:transparent;border:1px solid var(--line);border-radius:10px;padding:10px 12px;width:100%}
1120
1117
  textarea{min-height:96px;resize:vertical}button{font:inherit;font-weight:600;border:0;border-radius:10px;padding:12px 18px;min-height:44px;background:var(--accent);color:var(--bg);cursor:pointer}
1121
1118
  p{color:var(--muted)}.trap{position:absolute;left:-10000px;width:1px;height:1px;overflow:hidden}a{color:var(--accent)}
1122
- </style></head><body><main><h1>${esc(label)}</h1><div class="card">${body}</div></main></body></html>`;
1119
+ `;
1120
+ /** The public page: the button's name and its questions, nothing about the flow, the project or anyone in it. */
1121
+ function formHtml(label, body) {
1122
+ return htmlString(html `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="robots" content="noindex">
1123
+ <title>${label}</title>${styleElement(FORM_CSS)}</head><body><main><h1>${label}</h1><div class="card">${body}</div></main></body></html>`);
1123
1124
  }
1124
1125
  /** GET a shared form: its questions. */
1125
1126
  export function flowFormPage(store, token, now) {
1126
1127
  const trigger = /^[A-Za-z0-9_-]{20,64}$/.test(token) ? store.flowTriggerByHook(hookHash(token)) : null;
1127
1128
  const config = trigger === null ? null : triggerConfigOf(trigger);
1128
1129
  if (trigger === null || config?.kind !== "button")
1129
- return { status: 404, html: formHtml("Not found", "<p>This form isn't available. Ask whoever shared it for a new link.</p>") };
1130
+ return { status: 404, html: formHtml("Not found", html `<p>This form isn't available. Ask whoever shared it for a new link.</p>`) };
1130
1131
  if (!adapterPolicy({ caller: "anonymous", capability: "none" }, undefined, source => source === "flow" && store.getFlow(trigger.flow) !== null).ok)
1131
1132
  return { status: 403, html: "" };
1132
- const fields = config.questions.map((question, index) => `<label>${esc(question)}${index === 0 ? `<input name="a${index}" required maxlength="200" autocomplete="off">` : `<textarea name="a${index}" maxlength="3000"></textarea>`}</label>`).join("");
1133
- return { status: 200, html: formHtml(config.label, `<form method="post">${fields}<div class="trap" aria-hidden="true"><label>Leave this empty<input name="website" tabindex="-1" autocomplete="off"></label></div><input type="hidden" name="t" value="${now.getTime()}"><button>Send</button></form>`) };
1133
+ const fields = config.questions.map((question, index) => html `<label>${question}${index === 0 ? html `<input name="a${index}" required maxlength="200" autocomplete="off">` : html `<textarea name="a${index}" maxlength="3000"></textarea>`}</label>`);
1134
+ // Signed out by nature: no session, so no CSRF; the trap, the shown time and the hourly limit guard it. It posts back to its own address.
1135
+ return { status: 200, html: formHtml(config.label, postForm("", html `${fields}<div class="trap" aria-hidden="true"><label>Leave this empty<input name="website" tabindex="-1" autocomplete="off"></label></div><input type="hidden" name="t" value="${now.getTime()}"><button>Send</button>`, { signedOut: true })) };
1134
1136
  }
1135
1137
  /** POST a shared form: at most one card per submission, keys refused, a limit per hour, and quiet about what a bot did wrong. */
1136
1138
  export function receiveFlowForm(store, token, fields, now) {
@@ -1140,8 +1142,8 @@ export function receiveFlowForm(store, token, fields, now) {
1140
1142
  return flowFormPage(store, token, now);
1141
1143
  if (!adapterPolicy({ caller: "anonymous", capability: "none" }, undefined, source => source === "flow" && store.getFlow(trigger.flow) !== null).ok)
1142
1144
  return { status: 403, html: "" };
1143
- const again = `<p><a href="${esc(FORM_PATH + token)}">Send another</a></p>`;
1144
- const thanks = formHtml(config.label, `<p>Thanks — it's been sent.</p>${again}`);
1145
+ const again = html `<p><a href="${FORM_PATH + token}">Send another</a></p>`;
1146
+ const thanks = formHtml(config.label, html `<p>Thanks — it's been sent.</p>${again}`);
1145
1147
  // A filled trap, or a page posted faster than a person could, gets thanks and makes nothing.
1146
1148
  const form = readFormSubmission(fields, config.questions.length);
1147
1149
  const shown = Number(form.t);
@@ -1149,14 +1151,14 @@ export function receiveFlowForm(store, token, fields, now) {
1149
1151
  return { status: 200, html: thanks };
1150
1152
  const recent = store.handle.prepare("SELECT COUNT(*) AS n FROM flow_trigger_event WHERE trigger = ? AND key LIKE 'form:%' AND at > ?").get(trigger.id, new Date(now.getTime() - 3_600_000).toISOString());
1151
1153
  if (Number(recent?.["n"] ?? 0) >= FORM_CARDS_PER_HOUR)
1152
- return { status: 429, html: formHtml(config.label, `<p>Too many sent in the last hour. Try again later.</p>`) };
1154
+ return { status: 429, html: formHtml(config.label, html `<p>Too many sent in the last hour. Try again later.</p>`) };
1153
1155
  const answers = form.answers.map((answer, index) => (answer ?? "").replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, "").trim().slice(0, index === 0 ? 200 : 3000));
1154
1156
  if (answers[0] === "")
1155
- return { status: 400, html: formHtml(config.label, `<p>Answer “${esc(config.questions[0])}” first.</p>${again}`) };
1157
+ return { status: 400, html: formHtml(config.label, html `<p>Answer “${config.questions[0]}” first.</p>${again}`) };
1156
1158
  const details = config.questions.slice(1).map((question, index) => answers[index + 1] === "" ? null : `${question}\n${answers[index + 1]}`).filter(one => one !== null).join("\n\n");
1157
1159
  const made = makeCard(store, trigger, config, { key: `form:${now.toISOString()}:${randomUUID().slice(0, 8)}`, title: answers[0], description: `From the “${config.label}” form:\n\n${details}`.trim(), source: { kind: "form", label: `Form: ${config.label}`, url: null } }, "Form", now);
1158
1160
  if (made.made !== "added")
1159
- return { status: 400, html: formHtml(config.label, `<p>${made.note?.includes("key or password") ? "That looks like it holds a key or password. Take it out and send again." : "That couldn't be sent."}</p>${again}`) };
1161
+ return { status: 400, html: formHtml(config.label, html `<p>${made.note?.includes("key or password") ? "That looks like it holds a key or password. Take it out and send again." : "That couldn't be sent."}</p>${again}`) };
1160
1162
  store.updateFlowTrigger(trigger.id, { lastAt: now.toISOString(), lastOutcome: "Someone sent the form." }, now);
1161
1163
  return { status: 200, html: thanks };
1162
1164
  }
@@ -3,9 +3,10 @@
3
3
  import type { BrowserFlowView } from "./browser-workspace.js";
4
4
  import type { FlowRow, Store } from "./store.js";
5
5
  import { type FlowFile, type FlowImportPlan } from "./flow-share.js";
6
+ import { type Html } from "./html.js";
6
7
  export declare const FLOWS_CSS = ".flows{max-width:880px;min-width:0}.flows .card{padding:16px 18px;margin:12px 0}.flows .flow-row{display:flex;justify-content:space-between;align-items:baseline;gap:12px;flex-wrap:wrap}.flows .flow-row h2{font-size:1.05rem;margin:0}.flows .flow-counts{font-size:.85rem;color:var(--so-muted)}.flows form{display:grid;gap:10px;margin:0}.flows label{display:grid;gap:6px}.flows input,.flows select{box-sizing:border-box;width:100%;max-width:100%}.flows button{justify-self:start;min-height:44px}.flows .flow-fallback ol{padding-left:20px}.flows summary{cursor:pointer;min-height:44px;display:flex;align-items:center;font-weight:600}.flows .flow-buttons{display:flex;flex-wrap:wrap;align-items:center;gap:8px;margin:10px 0 0}.flows .flow-import{margin-top:18px;padding-top:14px;border-top:1px solid var(--so-line)}.flows .flow-import h3{font-size:.95rem;margin:0}.flows .flow-terms{list-style:none;padding:0;margin:8px 0 16px;display:grid;gap:10px}.flows .flow-terms li{white-space:pre-line;overflow-wrap:anywhere}.flows .flow-terms pre{white-space:pre-wrap;overflow-wrap:anywhere;font-size:.8rem;margin:6px 0 0}.flows .flow-import-preview h2{font-size:1rem;margin:20px 0 4px}.flows .flow-untrusted{font-weight:600}.flows button.primary{background:var(--primary);color:var(--primary-foreground);border-color:var(--primary)}.flows fieldset{border:0;padding:0;margin:0;display:grid;gap:10px;min-width:0}.flows legend{font-weight:600;padding:0;margin-bottom:6px}.flows textarea[hidden]{display:none}.flows button.secondary{background:transparent;color:var(--foreground);border:1px solid var(--border)}@media(max-width:600px){.flows input,.flows select{font-size:16px}}@media(max-width:760px){.flows .card{padding:10px 12px;margin:8px 0;position:relative}.flows .flow-row{row-gap:2px}.flows .flow-row h2 a::after{content:\"\";position:absolute;inset:0;border-radius:inherit}.flows .flow-counts{line-height:1.35}}";
7
8
  /** The flows a person can open, with how many cards wait in each, and the new-flow form. */
8
- export declare function flowsListHtml(store: Store, flows: readonly FlowRow[], projects: readonly string[], csrf: string, canCreate: boolean, problem: string | null): string;
9
+ export declare function flowsListHtml(store: Store, flows: readonly FlowRow[], projects: readonly string[], canCreate: boolean, problem: string | null): Html;
9
10
  /** Reads the chosen flow file into the form (the server reads it again, whole, before anything is made). */
10
11
  export declare const FLOW_IMPORT_SCRIPT = "(function(){var form=document.querySelector('[data-flow-import]');if(!form)return;var input=form.querySelector('[data-flow-file]'),text=form.querySelector('[data-flow-document]'),note=form.querySelector('[data-flow-file-note]'),address=form.querySelector('[name=url]');function say(words){note.textContent=words;note.hidden=words==='';}var reading=null;input.addEventListener('change',function(){text.value='';say('');var file=input.files&&input.files[0];if(!file){reading=null;return;}if(file.size>262144){say('That file is too big: a flow file is at most 256 KB.');input.value='';reading=null;return;}reading=file.text().then(function(value){text.value=value;});});form.addEventListener('submit',function(event){if(reading!==null&&text.value===''){event.preventDefault();reading.then(function(){form.requestSubmit();});return;}if(text.value===''&&address.value.trim()===''){event.preventDefault();say('Choose a flow file or give its address.');}});})();";
11
12
  /** An import's preview: the plan in plain words, what it asks for, and one Import button after the terms. */
@@ -18,7 +19,7 @@ export declare function flowImportHtml(input: {
18
19
  values: Record<string, string>;
19
20
  previewed: string;
20
21
  problem: string | null;
21
- }): string;
22
+ }): Html;
22
23
  /** One flow's canvas for one person. */
23
24
  export declare function flowView(store: Store, flow: FlowRow, viewer: {
24
25
  name: string;
@@ -31,4 +32,4 @@ export declare function flowView(store: Store, flow: FlowRow, viewer: {
31
32
  toolHome?: string;
32
33
  }): BrowserFlowView;
33
34
  /** The page the canvas replaces when scripts can't run: each zone and its cards, in order. */
34
- export declare function flowFallbackHtml(view: BrowserFlowView): string;
35
+ export declare function flowFallbackHtml(view: BrowserFlowView): Html;
package/dist/flows-ui.js CHANGED
@@ -17,53 +17,42 @@ import { callWords, receiptWords } from "./teammate-tools.js";
17
17
  import { undoFor } from "./teammate-week.js";
18
18
  import { flowCardTaskLine } from "./flow-card-task.js";
19
19
  import { readFlowSecrets } from "./flow-secrets.js";
20
- const e = (value) => String(value ?? "").replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
20
+ import { html, joinHtml, postForm } from "./html.js";
21
21
  const projectName = (repo) => repo.split(/[\\/]/).filter(Boolean).pop() ?? repo;
22
22
  export const FLOWS_CSS = `.flows{max-width:880px;min-width:0}.flows .card{padding:16px 18px;margin:12px 0}.flows .flow-row{display:flex;justify-content:space-between;align-items:baseline;gap:12px;flex-wrap:wrap}.flows .flow-row h2{font-size:1.05rem;margin:0}.flows .flow-counts{font-size:.85rem;color:var(--so-muted)}.flows form{display:grid;gap:10px;margin:0}.flows label{display:grid;gap:6px}.flows input,.flows select{box-sizing:border-box;width:100%;max-width:100%}.flows button{justify-self:start;min-height:44px}.flows .flow-fallback ol{padding-left:20px}.flows summary{cursor:pointer;min-height:44px;display:flex;align-items:center;font-weight:600}.flows .flow-buttons{display:flex;flex-wrap:wrap;align-items:center;gap:8px;margin:10px 0 0}.flows .flow-import{margin-top:18px;padding-top:14px;border-top:1px solid var(--so-line)}.flows .flow-import h3{font-size:.95rem;margin:0}.flows .flow-terms{list-style:none;padding:0;margin:8px 0 16px;display:grid;gap:10px}.flows .flow-terms li{white-space:pre-line;overflow-wrap:anywhere}.flows .flow-terms pre{white-space:pre-wrap;overflow-wrap:anywhere;font-size:.8rem;margin:6px 0 0}.flows .flow-import-preview h2{font-size:1rem;margin:20px 0 4px}.flows .flow-untrusted{font-weight:600}.flows button.primary{background:var(--primary);color:var(--primary-foreground);border-color:var(--primary)}.flows fieldset{border:0;padding:0;margin:0;display:grid;gap:10px;min-width:0}.flows legend{font-weight:600;padding:0;margin-bottom:6px}.flows textarea[hidden]{display:none}.flows button.secondary{background:transparent;color:var(--foreground);border:1px solid var(--border)}@media(max-width:600px){.flows input,.flows select{font-size:16px}}@media(max-width:760px){.flows .card{padding:10px 12px;margin:8px 0;position:relative}.flows .flow-row{row-gap:2px}.flows .flow-row h2 a::after{content:"";position:absolute;inset:0;border-radius:inherit}.flows .flow-counts{line-height:1.35}}`;
23
23
  /** The flows a person can open, with how many cards wait in each, and the new-flow form. */
24
- export function flowsListHtml(store, flows, projects, csrf, canCreate, problem) {
24
+ export function flowsListHtml(store, flows, projects, canCreate, problem) {
25
25
  const rows = flows.map(flow => {
26
26
  const cards = store.flowCards(flow.id, false);
27
27
  const definition = flowDefinitionOf(flow);
28
28
  const waiting = cards.filter(card => ["approval", "choose"].includes(definition?.stages.find(one => one.id === card.stage)?.kind ?? "")).length;
29
29
  const buttons = store.flowTriggers(flow.id).filter(one => one.state === "active").flatMap(one => { const config = triggerConfigOf(one); return config?.kind === "button" ? [{ id: one.id, label: config.label }] : []; });
30
30
  const trouble = troubleWords(flowInsights(store, flow, new Date(), 7));
31
- return `<article class="card"><div class="flow-row"><h2><a href="/flows/${flow.id}">${e(flow.name)}</a></h2><span class="flow-counts">${e(projectName(flow.repo))} · ${cards.length} card${cards.length === 1 ? "" : "s"} in progress${waiting > 0 ? ` · ${waiting} waiting for a decision` : ""}${trouble === null ? "" : ` · ${e(trouble)}`}</span></div>` +
32
- (buttons.length === 0 ? "" : `<p class="flow-buttons">${buttons.map(one => `<a class="button-link" href="/flows/${flow.id}?start=${one.id}">${e(one.label)}</a>`).join(" ")}</p>`) + `</article>`;
33
- }).join("");
34
- const projectField = projects.length === 1 ? `<input type="hidden" name="repo" value="${e(projects[0])}">` : `<label>Project<select name="repo">${projects.map(repo => `<option value="${e(repo)}">${e(projectName(repo))}</option>`).join("")}</select></label>`;
31
+ return html `<article class="card"><div class="flow-row"><h2><a href="/flows/${flow.id}">${flow.name}</a></h2><span class="flow-counts">${projectName(flow.repo)} · ${cards.length} card${cards.length === 1 ? "" : "s"} in progress${waiting > 0 ? ` · ${waiting} waiting for a decision` : ""}${trouble === null ? "" : ` · ${trouble}`}</span></div>${buttons.length > 0 && html `<p class="flow-buttons">${joinHtml(buttons.map(one => html `<a class="button-link" href="/flows/${flow.id}?start=${one.id}">${one.label}</a>`), " ")}</p>`}</article>`;
32
+ });
33
+ const projectField = projects.length === 1 ? html `<input type="hidden" name="repo" value="${projects[0]}">` : html `<label>Project<select name="repo">${projects.map(repo => html `<option value="${repo}">${projectName(repo)}</option>`)}</select></label>`;
35
34
  // A flow file (flow-share.ts): chosen here or fetched from a gist, previewed in plain words before anything is made.
36
- const importForm = `<form method="post" action="/flows/import" class="flow-import" data-flow-import><input type="hidden" name="csrf" value="${e(csrf)}"><h3>Or import a flow file</h3>${projectField}<label>File<input type="file" accept=".json,application/json" data-flow-file></label><textarea name="document" hidden data-flow-document></textarea><label>Or its address<input name="url" type="url" inputmode="url" placeholder="https://gist.github.com/…"></label><p class="problem" role="alert" data-flow-file-note hidden></p><button>Preview import</button></form>`;
35
+ const importForm = postForm("/flows/import", html `<h3>Or import a flow file</h3>${projectField}<label>File<input type="file" accept=".json,application/json" data-flow-file></label><textarea name="document" hidden data-flow-document></textarea><label>Or its address<input name="url" type="url" inputmode="url" placeholder="https://gist.github.com/…"></label><p class="problem" role="alert" data-flow-file-note hidden></p><button>Preview import</button>`, { attrs: { class: "flow-import", "data-flow-import": true } });
37
36
  // A new flow starts from the gallery (Flows → New): templates grouped by what they're for.
38
- const create = canCreate && projects.length > 0 ? `<p class="flow-buttons"><a class="button-link" href="/flows/new">New flow</a></p>${importForm}` : "";
37
+ const create = canCreate && projects.length > 0 && html `<p class="flow-buttons"><a class="button-link" href="/flows/new">New flow</a></p>${importForm}`;
39
38
  // No flows yet: one click makes a working example with a sample question in it.
40
39
  const example = flows.length === 0 && canCreate && projects.length > 0
41
- ? `<form method="post" action="/flows/example" class="card flow-example"><input type="hidden" name="csrf" value="${e(csrf)}"><h2>See a flow work</h2><p class="meta">Claude drafts a reply to a sample customer question; you approve it here or in your chat app.</p>${projects.length === 1 ? `<input type="hidden" name="repo" value="${e(projects[0])}">` : `<label>Project<select name="repo">${projects.map(repo => `<option value="${e(repo)}">${e(projectName(repo))}</option>`).join("")}</select></label>`}<button>Try an example</button></form>`
42
- : "";
43
- const intro = `<p class="meta">A flow is your process drawn as zones. Cards move through them: agents do the work, people approve, and the team hears about it.</p>` +
44
- (canCreate && projects.length > 0 ? `<p class="flow-chat">Describe how work should move and your lead drafts the flow for you to confirm, or start from a template. <a href="/chat?draft=${encodeURIComponent("Make a flow for ")}">Describe it in chat</a></p>` : "") +
45
- `<p class="flow-chat">AI teammates can decide and handle cards for you, within rules you write. <a href="/teammates">Teammates</a> · or set one up with its flow in one click: <a href="/kits">Starter kits</a></p>`;
46
- return `<section class="flows">${problem === null ? "" : `<p class="problem" role="alert">${e(problem)}</p>`}${intro}${create}${rows || example || '<p class="meta">No flows yet.</p>'}</section>`;
40
+ ? postForm("/flows/example", html `<h2>See a flow work</h2><p class="meta">Claude drafts a reply to a sample customer question; you approve it here or in your chat app.</p>${projectField}<button>Try an example</button>`, { attrs: { class: "card flow-example" } })
41
+ : null;
42
+ const intro = html `<p class="meta">A flow is your process drawn as zones. Cards move through them: agents do the work, people approve, and the team hears about it.</p>${canCreate && projects.length > 0 && html `<p class="flow-chat">Describe how work should move and your lead drafts the flow for you to confirm, or start from a template. <a href="/chat?draft=${encodeURIComponent("Make a flow for ")}">Describe it in chat</a></p>`}<p class="flow-chat">AI teammates can decide and handle cards for you, within rules you write. <a href="/teammates">Teammates</a> · or set one up with its flow in one click: <a href="/kits">Starter kits</a></p>`;
43
+ return html `<section class="flows">${problem !== null && html `<p class="problem" role="alert">${problem}</p>`}${intro}${create}${rows.length > 0 ? rows : example ?? html `<p class="meta">No flows yet.</p>`}</section>`;
47
44
  }
48
45
  /** Reads the chosen flow file into the form (the server reads it again, whole, before anything is made). */
49
46
  export const FLOW_IMPORT_SCRIPT = `(function(){var form=document.querySelector('[data-flow-import]');if(!form)return;var input=form.querySelector('[data-flow-file]'),text=form.querySelector('[data-flow-document]'),note=form.querySelector('[data-flow-file-note]'),address=form.querySelector('[name=url]');function say(words){note.textContent=words;note.hidden=words==='';}var reading=null;input.addEventListener('change',function(){text.value='';say('');var file=input.files&&input.files[0];if(!file){reading=null;return;}if(file.size>${FLOW_FILE_MAX_BYTES}){say('That file is too big: a flow file is at most ${FLOW_FILE_MAX_BYTES / 1024} KB.');input.value='';reading=null;return;}reading=file.text().then(function(value){text.value=value;});});form.addEventListener('submit',function(event){if(reading!==null&&text.value===''){event.preventDefault();reading.then(function(){form.requestSubmit();});return;}if(text.value===''&&address.value.trim()===''){event.preventDefault();say('Choose a flow file or give its address.');}});})();`;
50
47
  /** An import's preview: the plan in plain words, what it asks for, and one Import button after the terms. */
51
48
  export function flowImportHtml(input) {
52
49
  const { plan, file } = input;
53
- const fields = file.parameters.map(one => `<label><span>${e(one.about)}${one.optional === true ? ' <span class="meta">(optional)</span>' : ""}</span><input name="param.${e(one.id)}" value="${e(input.values[one.id] ?? plan?.values[one.id] ?? one.default ?? "")}" maxlength="200" autocomplete="off"></label>`).join("");
54
- const list = (items) => items.length === 0 ? "" : `<ul class="flow-terms">${items.map(term => `<li>${e(term)}</li>`).join("")}</ul>`;
50
+ const fields = file.parameters.map(one => html `<label><span>${one.about}${one.optional === true && html ` <span class="meta">(optional)</span>`}</span><input name="param.${one.id}" value="${input.values[one.id] ?? plan?.values[one.id] ?? one.default ?? ""}" maxlength="200" autocomplete="off"></label>`);
51
+ const list = (items) => items.length === 0 ? "" : html `<ul class="flow-terms">${items.map(term => html `<li>${term}</li>`)}</ul>`;
55
52
  // The steps, what they mean, the triggers and the scripts; each script's code one tap away, as on the Scripts panel.
56
- const terms = plan === null ? "" : (plan.parts.about === null ? "" : `<p>${e(plan.parts.about)}</p>`) +
57
- `<p class="flow-untrusted">Its instructions come from the file, not from you: read what each step is asked before you import.</p>` +
58
- list(plan.parts.steps) +
59
- (plan.parts.triggers.length === 0 ? "" : `<h2>Triggers</h2>${list(plan.parts.triggers)}`) +
60
- (plan.parts.scripts.length + plan.parts.kept.length === 0 ? "" : `<h2>Scripts</h2><ul class="flow-terms">${plan.scripts.add.map((one, index) => `<li>${e(plan.parts.scripts[index])}${one.body === undefined ? "" : `<details><summary>Show the script</summary><pre>${e(one.body)}</pre></details>`}</li>`).join("")}${plan.parts.kept.map(one => `<li>${e(one)}</li>`).join("")}</ul>`) +
61
- `<h2>Good to know</h2>${list([...plan.parts.notes, ...(plan.parts.needs === null ? [] : [plan.parts.needs])])}`;
62
- return `<section class="flows flow-import-preview">${input.problem === null ? "" : `<p class="problem" role="alert">${e(input.problem)}</p>`}` +
63
- `<p class="meta">Into ${e(projectName(input.repo))}. Nothing is made until you import it.</p>${terms}` +
64
- `<form method="post" action="/flows/import" class="card"><input type="hidden" name="csrf" value="${e(input.csrf)}"><input type="hidden" name="repo" value="${e(input.repo)}"><input type="hidden" name="previewed" value="${e(input.previewed)}"><textarea name="document" hidden>${e(input.document)}</textarea>` +
65
- (fields === "" ? "" : `<fieldset class="flow-import-fields"><legend>Fill in</legend>${fields}</fieldset>`) +
66
- `<p class="flow-buttons">${plan === null ? "" : '<button name="confirm" value="yes" class="primary">Import flow</button>'}${fields === "" ? "" : `<button name="confirm" value="" class="${plan === null ? "" : "secondary"}">Update preview</button>`}<a href="/flows">Cancel</a></p></form></section>`;
53
+ const terms = plan === null ? "" : html `${plan.parts.about !== null && html `<p>${plan.parts.about}</p>`}<p class="flow-untrusted">Its instructions come from the file, not from you: read what each step is asked before you import.</p>${list(plan.parts.steps)}${plan.parts.triggers.length > 0 && html `<h2>Triggers</h2>${list(plan.parts.triggers)}`}${plan.parts.scripts.length + plan.parts.kept.length > 0 && html `<h2>Scripts</h2><ul class="flow-terms">${plan.scripts.add.map((one, index) => html `<li>${plan.parts.scripts[index]}${one.body !== undefined && html `<details><summary>Show the script</summary><pre>${one.body}</pre></details>`}</li>`)}${plan.parts.kept.map(one => html `<li>${one}</li>`)}</ul>`}<h2>Good to know</h2>${list([...plan.parts.notes, ...(plan.parts.needs === null ? [] : [plan.parts.needs])])}`;
54
+ const form = postForm("/flows/import", html `<textarea name="document" hidden>${input.document}</textarea>${fields.length > 0 && html `<fieldset class="flow-import-fields"><legend>Fill in</legend>${fields}</fieldset>`}<p class="flow-buttons">${plan !== null && html `<button name="confirm" value="yes" class="primary">Import flow</button>`}${fields.length > 0 && html `<button name="confirm" value="" class="${plan === null ? "" : "secondary"}">Update preview</button>`}<a href="/flows">Cancel</a></p>`, { attrs: { class: "card" }, hidden: { repo: input.repo, previewed: input.previewed } });
55
+ return html `<section class="flows flow-import-preview">${input.problem !== null && html `<p class="problem" role="alert">${input.problem}</p>`}<p class="meta">Into ${projectName(input.repo)}. Nothing is made until you import it.</p>${terms}${form}</section>`;
67
56
  }
68
57
  const historyText = (event, title, sorts = new Set()) => {
69
58
  const who = event.actor === "flow" ? "" : ` by ${event.actor}`;
@@ -230,8 +219,8 @@ export function flowView(store, flow, viewer, selectedCard, setup = { dir: null,
230
219
  }
231
220
  /** The page the canvas replaces when scripts can't run: each zone and its cards, in order. */
232
221
  export function flowFallbackHtml(view) {
233
- return `<section class="flows flow-fallback"><p class="meta">${e(view.flow.project)}</p><ol>${view.stages.map(stage => {
222
+ return html `<section class="flows flow-fallback"><p class="meta">${view.flow.project}</p><ol>${view.stages.map(stage => {
234
223
  const cards = view.cards.filter(card => card.stage === stage.id && card.state === "active");
235
- return `<li><strong>${e(stage.title)}</strong> · ${e(FLOW_KIND_WORDS[stage.kind].label)}${cards.length === 0 ? "" : `<ul>${cards.map(card => `<li>${e(card.title)}${card.waiting === null ? "" : ` — ${e(card.waiting)}`}</li>`).join("")}</ul>`}</li>`;
236
- }).join("")}</ol></section>`;
224
+ return html `<li><strong>${stage.title}</strong> · ${FLOW_KIND_WORDS[stage.kind].label}${cards.length > 0 && html `<ul>${cards.map(card => html `<li>${card.title}${card.waiting !== null && ` — ${card.waiting}`}</li>`)}</ul>`}</li>`;
225
+ })}</ol></section>`;
237
226
  }
package/dist/flows.js CHANGED
@@ -49,6 +49,7 @@
49
49
  */
50
50
  import { TEXT_LIMITS } from "./text-limits.js";
51
51
  import { createHash } from "node:crypto";
52
+ import { projectName } from "./project.js";
52
53
  import { parseContract, readVersioned } from "./contracts/contract.js";
53
54
  import { stageReferenceProblems } from "./contracts/stage-output.js";
54
55
  import { CHOICES_MAX, CHOICES_MIN, FLOW_ALIASES, FLOW_COLORS, FLOW_DEFINITION_VERSION, FLOW_END, FLOW_MERGE_METHODS, FLOW_STAGE_KINDS, flowDefinitionSchema, flowStepsSchema, HEADERS_MAX, LONGEST_WAIT_MINUTES, savedFlowDefinitionSchema, SCRIPT_LANGUAGES, SCRIPT_NAME, sortKeyOf, SURE_AT_MAX, SURE_AT_MIN, ZONE_ID, } from "./contracts/flow.js";
@@ -869,7 +870,7 @@ export function flowTerms(definition, previous) {
869
870
  if (stage.kind === "task" && stage.planning !== "auto")
870
871
  lines.push(stage.planning === "required" ? "Plans first." : "Builds without a plan.");
871
872
  if (stage.kind === "task" && stage.repo !== undefined)
872
- lines.push(`Builds in another project: ${stage.repo}`);
873
+ lines.push(`Builds in project: ${projectName(stage.repo)}`);
873
874
  if (stage.kind === "draft")
874
875
  lines.push(`Claude writes: ${plain(stage.instructions ?? "")}`, `Then → ${to(stage.next)}`);
875
876
  else if (stage.kind === "request" && stage.request !== undefined) {
package/dist/guides.js CHANGED
@@ -65,7 +65,7 @@ ${AUTHORITY_LINE}
65
65
  terminal, answer from the \`console\` guide (\`skills get console\`):
66
66
  exact screens, exact verbs, and which acts are theirs alone.
67
67
  - Take work as a registered runner: \`claim <id> --runner <name> --token
68
- <t>\` → \`heartbeat <lease>\` while working → \`release <lease>\`. A
68
+ <t>\` → \`heartbeat <lease>\` while working → \`worker release <lease>\`. A
69
69
  replayed claim (same \`--key\`) answers with \`replayed: true\` — do not
70
70
  repeat first-time side effects on it.
71
71
  - Respect refusals — each is an ANSWER, never an error to retry blindly:
@@ -189,7 +189,7 @@ registers it; the token is shown once). Work moves through leases:
189
189
  not repeat first-time side effects.
190
190
  - \`heartbeat <lease>\` while working — it extends the lease. A lease
191
191
  that runs out is reaped and the task returns to the queue.
192
- - \`release <lease>\` when done. If the answer is \`fenced\`, STOP: a newer
192
+ - \`worker release <lease>\` when done. If the answer is \`fenced\`, STOP: a newer
193
193
  lease superseded yours and the work is no longer yours — do not write,
194
194
  commit, or report anything further for it.
195
195
  - \`reap\` releases every expired lease (safe to run any time).
@@ -416,7 +416,112 @@ and crew agent reads from it before working:
416
416
  and its date, and prefer the newest active decision.
417
417
  `,
418
418
  };
419
- export const GUIDES = [operating, runner, steering, externalWork, console_, knowledge];
419
+ const release = {
420
+ name: "release",
421
+ title: "Releasing Toolroll itself",
422
+ oneLiner: "toolroll release <branch>: one command from a gated commit to npm and Homebrew; the owner's approval is the only human step",
423
+ content: `# Releasing Toolroll itself
424
+
425
+ ${AUTHORITY_LINE}
426
+
427
+ \`toolroll release <branch> --repo <gate checkout>\` runs the whole release
428
+ of a pushed branch. The owner approving the release scope is the only
429
+ step that needs a person; the rest runs on its own, each waiting step
430
+ with a time limit.
431
+
432
+ ## Before you start
433
+
434
+ - The branch is pushed, with an open pull request into main.
435
+ - package.json has the new version, and the lead wrote its CHANGELOG.md
436
+ entry (\`## <version>\`). No tag or npm package has that version yet.
437
+ - \`gh\` is signed in, the console, worker and both CLI names
438
+ (\`toolroll\`, \`standing-orders\`) run one build, and the gate checkout
439
+ is a clone whose origin is the GitHub repository. Credentials are never
440
+ asked for in chat or written to the release journal.
441
+
442
+ ## What it does, in order
443
+
444
+ 1. **gate**: files task \`release-<version>-<sha7>\` pinned to the
445
+ branch's exact commit with \`--checks full\`, regardless of the project's default.
446
+ 2. **approval**: waits for the owner to approve that exact scope
447
+ (\`toolroll task approve <id> --digest <d>\`, or the console). A
448
+ mode, an AI teammate or the lead approving it stops the release: the
449
+ yes must come from an active account with the approver role. Its standing
450
+ is checked again before completion and deployment.
451
+ 3. **check**: requires a sealed, passing **Full** check on that commit.
452
+ Quick passes and Off checks are refused. Failures stay visible; nothing reruns them.
453
+ 4. **complete**: marks that exact result Complete.
454
+ 5. **quiet**: waits until nothing is building.
455
+ 6. **deploy**: the candidate's own deployment
456
+ (\`scripts/deploy-browser.mjs\` from the builder's checkout): drain,
457
+ backup, rehearsal, swap, health. The staging path is saved before it
458
+ starts, and the runtime its final JSON names is kept. It goes on only
459
+ when the console and worker run that runtime and it records the gated
460
+ commit; matching versions alone do not count.
461
+ 7. **link**: points \`toolroll\` and \`standing-orders\` at
462
+ \`<runtime>/bin.js\`, each link replaced whole. Where they pointed is
463
+ saved first; if either name doesn't answer as the new build, both go
464
+ back. It goes on only when the console, worker and both names run the
465
+ gated commit. A rerun relinks without deploying again.
466
+ 8. **ci**: waits for every pull-request check on the gated commit:
467
+ Ubuntu on Node 22 and 24, Linux and Windows containment. Pending,
468
+ missing, skipped, cancelled or failed never merges.
469
+ 9. **merge**: squash-merges with the head pinned to the gated commit,
470
+ once main has nothing the branch lacks, then deletes the remote branch
471
+ only if its head still matches. A resumed merge also finishes this cleanup.
472
+ 10. **tree**: main's merge commit must have exactly the gated tree, or
473
+ nothing is tagged.
474
+ 11. **tag**: pushes \`v<version>\` on that merge commit. In publish.yml,
475
+ macOS Node 22 and 24 must both pass before npm and GitHub publication.
476
+ macos.yml keeps the nightly matrix.
477
+ 12. **publish**: waits for npm, the GitHub release and the tag's macOS
478
+ and publish checks.
479
+ 13. **homebrew**: updates the tap's formula, waits for checks to register
480
+ and every registered check to pass, then merges. No checks means waiting,
481
+ within the Homebrew time limit; a failed or skipped check stops the release.
482
+
483
+ ## When it stops
484
+
485
+ A time limit, a failure or Ctrl-C ends with the step and the reason,
486
+ and the command to continue: the same \`toolroll release <branch> --repo
487
+ <checkout>\`. Its journal (\`releases/<branch>.json\` in the state folder,
488
+ private) records what each step was about to do before it does it, so a
489
+ rerun finds the task, completion, merge, tag and tap change already made
490
+ instead of making them twice.
491
+
492
+ - \`drift\`: the branch or pull request moved after gating. Release the
493
+ new commit with \`--new\` (refused once merging began).
494
+ - \`check-level\`: the result has no Full check. File the gate with Full
495
+ checks and obtain a passing result; Quick and Off cannot authorize a release.
496
+ - \`check-failed\`: fix the branch and release the new commit with \`--new\`.
497
+ - \`ci-failed\`: fix the failed GitHub check, then rerun. Nothing was merged.
498
+ - \`publish-failed\` / \`homebrew-failed\`: fix the failed workflow before
499
+ resuming. A failed tag check cannot finish publication or update Homebrew.
500
+ - \`base-moved\`: main gained a commit the branch doesn't have, so the
501
+ merge wouldn't be the checked tree. Nothing was merged: update the
502
+ branch and release it with \`--new\`.
503
+ - \`tree-mismatch\`: main changed what was checked. Nothing was tagged;
504
+ the deployed build stays.
505
+ - \`timeout\`: rerun to keep waiting; \`--limit <step>=<minutes>\`
506
+ changes one step's limit.
507
+ - An interrupted deployment resumes with \`deploy-browser.mjs --stage <saved
508
+ path> --phase recover\` before any new attempt, including \`--new\`.
509
+ Failed recovery stops there; the original deployment journal remains available.
510
+ - \`link\`: a CLI name didn't answer as the new build, so both were put
511
+ back. The service already runs the new build; fix the name and rerun.
512
+ - \`gate-held\`: the deployment stopped before its swap but left new work
513
+ paused, and its journal can't say so by itself (typically killed while
514
+ preparing). \`toolroll release --release-gate <id>\` shows the proof that
515
+ the swap never began: the pause is that deployment's, its journal is
516
+ before the swap, the database schema, service definition and running
517
+ service are still the previous build's, and no deployment is running.
518
+ Add \`--yes\` to lift that one pause, then rerun the release. Missing or
519
+ contradicting evidence refuses and leaves the pause in place.
520
+
521
+ A runner's lease is returned with \`toolroll worker release <lease>\`.
522
+ `,
523
+ };
524
+ export const GUIDES = [operating, runner, steering, externalWork, console_, knowledge, release];
420
525
  export function guideNamed(name) {
421
526
  return GUIDES.find(one => one.name === name) ?? null;
422
527
  }