toolroll 0.9.85 → 0.9.87

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 (220) hide show
  1. package/CHANGELOG.md +83 -0
  2. package/dist/agent-fence.d.ts +6 -2
  3. package/dist/agent-fence.js +3 -2
  4. package/dist/agents-board.d.ts +12 -0
  5. package/dist/agents-board.js +26 -0
  6. package/dist/approval-rules-ui.d.ts +4 -1
  7. package/dist/approval-rules-ui.js +31 -15
  8. package/dist/assignment-ui.js +1 -1
  9. package/dist/assignment.js +4 -3
  10. package/dist/backup-ui.d.ts +5 -4
  11. package/dist/backup-ui.js +65 -32
  12. package/dist/browser/terminal.css +1 -1
  13. package/dist/browser/terminal.js +57 -54
  14. package/dist/browser/workspace.css +2 -2
  15. package/dist/browser/workspace.js +35 -68
  16. package/dist/browser-crew.js +1 -1
  17. package/dist/browser-workspace.d.ts +363 -177
  18. package/dist/browser-workspace.js +10 -9
  19. package/dist/chat-actions.d.ts +11 -1
  20. package/dist/chat-actions.js +53 -1
  21. package/dist/chat-channel.js +1 -0
  22. package/dist/chat-controls.d.ts +1 -1
  23. package/dist/chat-controls.js +1 -1
  24. package/dist/chat-quiet.js +3 -2
  25. package/dist/chat-settings-page.d.ts +29 -9
  26. package/dist/chat-settings-page.js +59 -28
  27. package/dist/check-levels-ui.d.ts +2 -1
  28. package/dist/check-levels-ui.js +67 -58
  29. package/dist/claim.d.ts +3 -0
  30. package/dist/claim.js +4 -1
  31. package/dist/contracts/chat-actions.d.ts +9 -0
  32. package/dist/contracts/chat-actions.js +3 -0
  33. package/dist/contracts/cli.d.ts +43 -0
  34. package/dist/contracts/cli.js +4 -0
  35. package/dist/contracts/console-api.d.ts +4 -1
  36. package/dist/contracts/console-api.js +4 -1
  37. package/dist/contracts/flow-propose.d.ts +4 -1
  38. package/dist/contracts/flow-propose.js +1 -1
  39. package/dist/contracts/flow.d.ts +41 -0
  40. package/dist/contracts/flow.js +4 -2
  41. package/dist/contracts/lead-tools.d.ts +21 -0
  42. package/dist/contracts/lead-tools.js +2 -0
  43. package/dist/contracts/store-json.d.ts +2 -0
  44. package/dist/contracts/trigger-payloads.d.ts +20 -0
  45. package/dist/contracts/trigger-payloads.js +4 -0
  46. package/dist/coordinator.js +1 -0
  47. package/dist/credentials-ui.d.ts +5 -3
  48. package/dist/credentials-ui.js +58 -31
  49. package/dist/deploy-gate.js +4 -0
  50. package/dist/design/kit.css +685 -0
  51. package/dist/design/kit.d.ts +208 -0
  52. package/dist/design/kit.js +177 -0
  53. package/dist/design/tokens.css +19 -1
  54. package/dist/discord-settings.js +8 -4
  55. package/dist/dispatch.d.ts +3 -0
  56. package/dist/dispatch.js +4 -1
  57. package/dist/export-ui.d.ts +6 -4
  58. package/dist/export-ui.js +18 -8
  59. package/dist/flow-browser.d.ts +103 -0
  60. package/dist/flow-browser.js +204 -0
  61. package/dist/flow-changed.d.ts +14 -0
  62. package/dist/flow-changed.js +53 -0
  63. package/dist/flow-code.d.ts +5 -1
  64. package/dist/flow-code.js +4 -1
  65. package/dist/flow-engine.js +16 -0
  66. package/dist/flow-gallery-ui.d.ts +41 -4
  67. package/dist/flow-gallery-ui.js +95 -63
  68. package/dist/flow-live.js +9 -1
  69. package/dist/flow-pull-request.js +15 -7
  70. package/dist/flow-share.js +3 -1
  71. package/dist/flow-starters-ui.d.ts +3 -9
  72. package/dist/flow-starters-ui.js +12 -23
  73. package/dist/flow-steps.d.ts +9 -1
  74. package/dist/flow-steps.js +55 -5
  75. package/dist/flow-triggers.js +55 -0
  76. package/dist/flow-unattended-words.d.ts +20 -0
  77. package/dist/flow-unattended-words.js +24 -0
  78. package/dist/flow-unattended.d.ts +9 -5
  79. package/dist/flow-unattended.js +45 -23
  80. package/dist/flows-cli.d.ts +19 -0
  81. package/dist/flows-cli.js +54 -5
  82. package/dist/flows-ui.d.ts +12 -6
  83. package/dist/flows-ui.js +152 -32
  84. package/dist/flows.js +5 -2
  85. package/dist/integrations-ui.d.ts +6 -4
  86. package/dist/integrations-ui.js +34 -40
  87. package/dist/integrations.d.ts +2 -2
  88. package/dist/integrations.js +4 -4
  89. package/dist/kits-ui.d.ts +8 -10
  90. package/dist/kits-ui.js +29 -33
  91. package/dist/kits.js +1 -1
  92. package/dist/knowledge-ui.js +76 -19
  93. package/dist/lead-tools.js +35 -6
  94. package/dist/lead-voice.js +16 -5
  95. package/dist/ledger-view.d.ts +2 -0
  96. package/dist/ledger-view.js +41 -38
  97. package/dist/models-ui.d.ts +8 -3
  98. package/dist/models-ui.js +82 -59
  99. package/dist/monitoring-ui.d.ts +4 -2
  100. package/dist/monitoring-ui.js +44 -13
  101. package/dist/needs-you.d.ts +1 -1
  102. package/dist/needs-you.js +8 -0
  103. package/dist/operate-remote-arguments.js +1 -0
  104. package/dist/operate-remote.js +1 -1
  105. package/dist/operate.d.ts +1 -1
  106. package/dist/operate.js +249 -6
  107. package/dist/passwordless.d.ts +1 -1
  108. package/dist/passwordless.js +1 -1
  109. package/dist/people-audit-ui.d.ts +2 -1
  110. package/dist/people-audit-ui.js +25 -23
  111. package/dist/policy-ui.d.ts +3 -2
  112. package/dist/policy-ui.js +27 -21
  113. package/dist/policy.d.ts +3 -0
  114. package/dist/policy.js +14 -1
  115. package/dist/project-delete-ui.d.ts +4 -2
  116. package/dist/project-delete-ui.js +135 -126
  117. package/dist/project-delete.js +7 -3
  118. package/dist/project-knowledge.d.ts +10 -10
  119. package/dist/publish.d.ts +11 -0
  120. package/dist/publish.js +16 -3
  121. package/dist/pull-request-flow.js +35 -4
  122. package/dist/pull-request-ui.d.ts +2 -1
  123. package/dist/pull-request-ui.js +39 -28
  124. package/dist/recipe-ui.d.ts +4 -2
  125. package/dist/recipe-ui.js +14 -4
  126. package/dist/release-adapters.d.ts +3 -0
  127. package/dist/release-adapters.js +22 -2
  128. package/dist/release-cli.js +81 -9
  129. package/dist/release.d.ts +21 -0
  130. package/dist/release.js +40 -4
  131. package/dist/releases.js +1 -1
  132. package/dist/request-budget-ui.d.ts +4 -2
  133. package/dist/request-budget-ui.js +30 -19
  134. package/dist/result-follow-ups.js +4 -0
  135. package/dist/retention-ui.d.ts +4 -3
  136. package/dist/retention-ui.js +15 -12
  137. package/dist/scope.d.ts +8 -0
  138. package/dist/scope.js +13 -0
  139. package/dist/serve.d.ts +0 -1
  140. package/dist/serve.js +51 -7
  141. package/dist/server/agents-computers.d.ts +9 -0
  142. package/dist/server/agents-computers.js +67 -0
  143. package/dist/server/chat.js +20 -18
  144. package/dist/server/chrome.d.ts +1 -1
  145. package/dist/server/chrome.js +19 -9
  146. package/dist/server/console.css +31 -2
  147. package/dist/server/flows.js +79 -36
  148. package/dist/server/home.d.ts +35 -0
  149. package/dist/server/home.js +104 -0
  150. package/dist/server/http.d.ts +3 -0
  151. package/dist/server/http.js +5 -0
  152. package/dist/server/kit-standalone.d.ts +22 -0
  153. package/dist/server/kit-standalone.js +69 -0
  154. package/dist/server/kit.d.ts +405 -0
  155. package/dist/server/kit.js +361 -0
  156. package/dist/server/pages.d.ts +2 -1
  157. package/dist/server/pages.js +213 -174
  158. package/dist/server/people-tokens.js +26 -85
  159. package/dist/server/render-history.d.ts +51 -0
  160. package/dist/server/render-history.js +228 -0
  161. package/dist/server/render-new-task.d.ts +29 -0
  162. package/dist/server/render-new-task.js +96 -0
  163. package/dist/server/render-next.d.ts +63 -0
  164. package/dist/server/render-next.js +109 -0
  165. package/dist/server/render-pages.d.ts +35 -190
  166. package/dist/server/render-pages.js +154 -563
  167. package/dist/server/render-people.d.ts +24 -3
  168. package/dist/server/render-people.js +93 -72
  169. package/dist/server/render-projects.d.ts +103 -0
  170. package/dist/server/render-projects.js +247 -0
  171. package/dist/server/render-results.d.ts +5 -24
  172. package/dist/server/render-results.js +48 -185
  173. package/dist/server/render-settings.d.ts +5 -74
  174. package/dist/server/render-settings.js +102 -493
  175. package/dist/server/render-tasks.d.ts +43 -5
  176. package/dist/server/render-tasks.js +131 -91
  177. package/dist/server/route-table.js +31 -5
  178. package/dist/server/settings.js +276 -255
  179. package/dist/server/task-views.d.ts +8 -0
  180. package/dist/server/task-views.js +90 -2
  181. package/dist/server/tasks.d.ts +18 -0
  182. package/dist/server/tasks.js +148 -41
  183. package/dist/server/terminals.js +1 -1
  184. package/dist/server/welcome.d.ts +22 -0
  185. package/dist/server/welcome.js +69 -0
  186. package/dist/settings-groups-ui.d.ts +279 -0
  187. package/dist/settings-groups-ui.js +615 -0
  188. package/dist/settings-kit.d.ts +42 -6
  189. package/dist/settings-kit.js +69 -7
  190. package/dist/settings-proofs.js +1 -0
  191. package/dist/setup-guide.d.ts +2 -2
  192. package/dist/skills-ui.js +59 -7
  193. package/dist/slack-settings.js +12 -5
  194. package/dist/spend-ui.d.ts +45 -26
  195. package/dist/spend-ui.js +124 -56
  196. package/dist/sso-ui.d.ts +5 -2
  197. package/dist/sso-ui.js +63 -31
  198. package/dist/storage-ui.d.ts +3 -2
  199. package/dist/storage-ui.js +56 -50
  200. package/dist/store.d.ts +182 -5
  201. package/dist/store.js +512 -38
  202. package/dist/surface.js +23 -3
  203. package/dist/task-archive.d.ts +10 -0
  204. package/dist/task-archive.js +16 -0
  205. package/dist/task-control.d.ts +1 -1
  206. package/dist/teams-settings.d.ts +2 -1
  207. package/dist/teams-settings.js +24 -41
  208. package/dist/telegram-settings.d.ts +0 -4
  209. package/dist/telegram-settings.js +52 -25
  210. package/dist/tidy.d.ts +130 -0
  211. package/dist/tidy.js +503 -0
  212. package/dist/toolroll-update-ui.d.ts +8 -4
  213. package/dist/toolroll-update-ui.js +55 -45
  214. package/dist/tools-ui.d.ts +5 -3
  215. package/dist/tools-ui.js +107 -42
  216. package/dist/work-index.d.ts +24 -0
  217. package/dist/work-index.js +56 -30
  218. package/dist/workspace-ui.d.ts +1 -1
  219. package/dist/workspace-ui.js +8 -4
  220. package/package.json +1 -1
@@ -1,57 +1,60 @@
1
1
  import { projectName } from "./project.js";
2
2
  import { exactWhenHtml } from "./when-html.js";
3
- import { html, postForm, styleElement } from "./html.js";
3
+ import { html, postForm } from "./html.js";
4
+ import { acts, btn, disclose, empty, infoLine, note, notice, pageHeader, select } from "./server/kit.js";
4
5
  /** The ledger's kinds of event, as the filter names them. */
5
6
  const EVENT_KINDS = [["work", "Work"], ["request", "Requests"], ["access", "Access"], ["sign-in", "Sign-ins"], ["policy", "Policy changes"]];
6
7
  function chainHtml(chain) {
7
8
  const status = chain.ok
8
- ? html `<p class="ledger-chain ok" data-ledger-chain="ok"><strong>Chain verified</strong> · ${chain.entries} entries through #${chain.through ?? 0} · head <code>${chain.head.slice(0, 16)}…</code>${chain.checkedAt === null ? "" : ` · checked in full ${chain.checkedAt.slice(11, 16)} UTC`}</p>`
9
- : html `<p class="ledger-chain problem" role="alert" data-ledger-chain="broken"><strong>Chain broken</strong> · ${chain.problem?.what ?? "the chain doesn't verify"}</p>`;
10
- const latest = chain.latest === null ? "" : html `<p class="meta ledger-checkpoint">Latest checkpoint (${chain.latest.at.slice(0, 16).replace("T", " ")} UTC), copy it somewhere outside this machine: <code class="ledger-checkpoint-value">${chain.latest.through}:${chain.latest.hash}</code></p>`;
11
- const make = chain.csrf === null ? "" : postForm("/ledger/checkpoint", html `<button type="submit">Make a checkpoint</button>`, { attrs: { class: "ledger-checkpoint-form" } });
9
+ ? infoLine(html `<span data-ledger-chain="ok">Chain verified · ${chain.entries} entries through #${chain.through ?? 0} · head <span class="k-mono">${chain.head.slice(0, 16)}…</span>${chain.checkedAt === null ? "" : ` · checked in full ${chain.checkedAt.slice(11, 16)} UTC`}</span>`)
10
+ : notice(html `<span data-ledger-chain="broken">Chain broken: ${chain.problem?.what ?? "the chain doesn't verify"}</span>`);
11
+ const latest = chain.latest === null ? "" : note(html `Latest checkpoint (${chain.latest.at.slice(0, 16).replace("T", " ")} UTC). Copy it somewhere outside this machine: <span class="k-mono h-select">${chain.latest.through}:${chain.latest.hash}</span>`);
12
+ const make = chain.csrf === null ? "" : postForm("/ledger/checkpoint", btn("Make a checkpoint", { sm: true, type: "submit", attrs: { "data-k-busy": "Making…" } }), { attrs: { class: "h-inline" } });
12
13
  const start = new Date(Date.parse(`${chain.today}T00:00:00Z`) - 29 * 86_400_000).toISOString().slice(0, 10);
13
- const exportForm = html `<details class="ledger-filter-panel"><summary>Audit export</summary><form method="get" action="/ledger/export" class="ledger-filters ledger-export" aria-label="Audit export"><label>From<input type="date" name="from" value="${start}" required></label><label>To<input type="date" name="to" value="${chain.today}" required></label><button type="submit">Download</button></form><p class="meta">Every entry in the range with its seal, and an evidence pack for each task it names. JSON.</p></details>`;
14
- return html `<section class="ledger-chain-panel" aria-label="Ledger integrity">${status}${latest}${make}${exportForm}</section>`;
14
+ const exportForm = disclose("Audit export", html `<form method="get" action="/ledger/export" class="h-filters h-export" aria-label="Audit export">\
15
+ <div class="k-field"><label class="k-label" for="ledger-from">From</label><input class="k-input" id="ledger-from" type="date" name="from" value="${start}" required></div>\
16
+ <div class="k-field"><label class="k-label" for="ledger-to">To</label><input class="k-input" id="ledger-to" type="date" name="to" value="${chain.today}" required></div>\
17
+ <div class="k-acts">${btn("Download", { sm: true, type: "submit" })}</div></form>${note("Every entry in the range with its seal, and a full record for each task it names. JSON.")}`);
18
+ return html `<section class="h-chain" aria-label="Audit log integrity">${status}${latest}${make}${exportForm}</section>`;
15
19
  }
16
- const LEDGER_CSS = `
17
- .ledger-heading{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap}.ledger-heading h1{margin:0}.ledger-caption{margin:8px 0 12px}
18
- .ledger-filter-panel{margin-bottom:12px}.ledger-filter-panel summary{font-size:13px;padding:6px 0;cursor:pointer}.ledger-filters{display:grid;grid-template-columns:repeat(5,minmax(0,1fr)) auto auto;gap:8px;align-items:end;margin:0 0 12px}.ledger-filters label{display:grid;gap:3px;min-width:0;font-size:12px}.ledger-filters input,.ledger-filters select{width:100%;min-width:0;box-sizing:border-box;margin:0;padding:6px 8px;font-size:13px}.ledger-filters button{padding:7px 12px}.ledger-filters>a{padding:8px 0;font-size:13px}
19
- .ledger-detail{display:block;color:var(--muted-foreground);font-size:12px}.ledger-list{border:1px solid var(--border);border-radius:8px;overflow:hidden}.ledger-event{display:grid;grid-template-columns:140px minmax(90px,1fr) minmax(160px,2fr) minmax(100px,1fr) minmax(100px,1fr);gap:8px;align-items:baseline;padding:7px 10px;border-bottom:1px solid var(--border);font-size:13px;line-height:1.4;overflow-wrap:anywhere}.ledger-event:last-child{border-bottom:0}.ledger-event .actor{color:inherit;font-weight:600;text-decoration:none}.ledger-time,.ledger-place,.ledger-result small{font-size:11px;color:var(--muted-foreground)}.ledger-time time{font-variant-numeric:tabular-nums}.ledger-place a{display:block}.ledger-result small{display:block}.ledger-labels{font-size:11px;color:var(--muted-foreground);font-weight:600}.ledger-empty{padding:12px}
20
- @media(max-width:1000px){.ledger-filters{grid-template-columns:repeat(3,minmax(0,1fr)) auto}.ledger-event{grid-template-columns:122px minmax(80px,1fr) minmax(130px,2fr) minmax(85px,1fr)}.ledger-place{grid-column:2/4}.ledger-result{grid-column:4;grid-row:1/3}.ledger-labels{display:none}}
21
- @media(max-width:600px){.ledger-filters{grid-template-columns:repeat(2,minmax(0,1fr))}.ledger-event{grid-template-columns:minmax(0,1fr) auto;gap:2px 8px;padding:8px 10px}.ledger-actor{grid-column:1;grid-row:1}.ledger-action{grid-column:1;grid-row:2}.ledger-result{grid-column:2;grid-row:1/3;text-align:right}.ledger-time{grid-column:1;grid-row:3}.ledger-place{grid-column:2;grid-row:3;text-align:right}.ledger-place a{display:inline}.ledger-heading>a{font-size:13px}}
22
- .ledger-chain-panel{margin:0 0 12px}.ledger-chain{margin:0 0 4px;font-size:13px}.ledger-chain.ok strong{color:var(--success)}.ledger-checkpoint code{overflow-wrap:anywhere;user-select:all}.ledger-checkpoint-form{margin:6px 0 0}.ledger-export{grid-template-columns:repeat(2,minmax(0,180px)) auto;margin-bottom:4px}
23
- `;
20
+ /** The audit log (`/ledger`): what people and tokens did, newest first, as quiet timed rows, with the filters, the CSV
21
+ * and the sealed export. A kit list page: the top bar, then rows between hairlines. */
24
22
  export function ledgerBody(rows, projects, params, chain) {
25
- const selected = (key, value) => params.get(key) === value ? html ` selected` : "";
26
23
  const more = rows.length > 50;
27
24
  const shown = rows.slice(0, 50);
28
- const fresh = new URLSearchParams();
25
+ const kept = new URLSearchParams();
29
26
  for (const key of ["project", "actor", "source", "outcome", "task"]) {
30
27
  const value = params.get(key);
31
28
  if (value)
32
- fresh.set(key, value);
29
+ kept.set(key, value);
33
30
  }
34
- const next = new URLSearchParams(fresh);
31
+ const next = new URLSearchParams(kept);
35
32
  if (shown.length)
36
33
  next.set("before", String(shown.at(-1).id));
37
- const csv = new URLSearchParams(fresh);
34
+ const csv = new URLSearchParams(kept);
38
35
  csv.set("format", "csv");
39
- return html `${styleElement(LEDGER_CSS)}<div class="ledger-heading"><h1>Action ledger</h1><a href="/ledger?${csv.toString()}" download>Export CSV</a></div>${chain === undefined ? "" : chainHtml(chain)}
40
- <p class="meta ledger-caption">${shown.length} actions on this page · CSV includes all matching actions · Times in UTC</p>
41
- <details class="ledger-filter-panel"><summary>Filters · ${fresh.size === 0 ? "all actions" : `${fresh.size} active`}</summary>
42
- <form method="get" action="/ledger" class="ledger-filters" aria-label="Filter action ledger">
43
- <label>Project<select name="project"><option value="">All accessible projects</option>${projects.map(repo => html `<option value="${repo}"${selected("project", repo)}>${projectName(repo)}</option>`)}</select></label>
44
- <label>Person or worker<input type="text" name="actor" value="${params.get("actor") ?? ""}" placeholder="Any actor"></label>
45
- <label>Events<select name="source"><option value="">All events</option>${EVENT_KINDS.map(([value, label]) => html `<option value="${value}"${selected("source", value)}>${label}</option>`)}</select></label>
46
- <label>Outcome<input type="text" name="outcome" value="${params.get("outcome") ?? ""}" placeholder="Any outcome"></label>
47
- <label>Task<input type="text" name="task" value="${params.get("task") ?? ""}" placeholder="Any task"></label>
48
- <button type="submit">Filter</button><a href="/ledger">Clear</a></form></details>
49
- <div class="ledger-list" aria-label="Action history"><div class="ledger-event ledger-labels" aria-hidden="true"><span>Time</span><span>Actor</span><span>Action</span><span>Project / work</span><span>Outcome / event</span></div>${shown.length === 0 ? html `<p class="ledger-empty">No actions match these filters yet.</p>` : shown.map(row => {
50
- const subject = row.runId !== null ? html `<a href="/r/${row.runId}">Run #${row.runId}</a>` : row.taskId !== null ? html `<a href="/t/${encodeURIComponent(row.taskId)}">${row.taskId}</a>` : "";
51
- const byActor = new URLSearchParams(fresh);
52
- byActor.set("actor", row.actor);
53
- return html `<article class="ledger-event" data-ledger-id="${row.id}"><div class="ledger-time">${exactWhenHtml(row.at)}</div><div class="ledger-actor"><a class="actor" href="/ledger?${byActor.toString()}" title="Filter by this actor">${row.actor}</a></div><div class="ledger-action">${row.action}${row.detail === null ? "" : html `<small class="ledger-detail">${row.detail}</small>`}</div><div class="ledger-place">${row.repo === null ? "Instance" : projectName(row.repo)} ${subject}</div><div class="ledger-result">${row.outcome}<small>${row.source}</small></div></article>`;
54
- })}</div>
55
- <p class="row"><a href="/ledger?${fresh.toString()}">Newest actions</a>${more ? html `<a rel="next" href="/ledger?${next.toString()}">Older actions</a>` : ""}</p>
56
- <p class="meta">Accepted requests record acceptance; worker events record results. History starts from this upgrade. Review evidence is on each task or run.</p>`;
36
+ const text = (id, label, name, placeholder) => html `<div class="k-field"><label class="k-label" for="${id}">${label}</label><input class="k-input" id="${id}" type="text" name="${name}" value="${params.get(name) ?? ""}" placeholder="${placeholder}"></div>`;
37
+ const filters = disclose(`Filters · ${kept.size === 0 ? "all actions" : `${kept.size} active`}`, html `<form method="get" action="/ledger" class="h-filters" aria-label="Filter the audit log">\
38
+ <div class="k-field"><label class="k-label" for="ledger-project">Project</label>${select({ id: "ledger-project", name: "project", wide: true, current: params.get("project") ?? "", items: [["", "All your projects"], ...projects.map(repo => [repo, projectName(repo)])] })}</div>\
39
+ ${text("ledger-actor", "Person or worker", "actor", "Anyone")}\
40
+ <div class="k-field"><label class="k-label" for="ledger-source">Events</label>${select({ id: "ledger-source", name: "source", wide: true, current: params.get("source") ?? "", items: [["", "All events"], ...EVENT_KINDS] })}</div>\
41
+ ${text("ledger-outcome", "Outcome", "outcome", "Any outcome")}${text("ledger-task", "Task", "task", "Any task")}\
42
+ <div class="k-acts">${btn("Filter", { sm: true, type: "submit" })}${btn("Clear", { sm: true, variant: "ghost", href: "/ledger" })}</div></form>`, kept.size > 0);
43
+ const list = shown.length === 0
44
+ ? empty({ title: kept.size === 0 ? "Nothing yet." : "No actions match these filters.", text: kept.size === 0 ? "Approvals, invites, tokens and sign-ins appear here." : "Clear the filters to see every action." })
45
+ : html `<div class="h-audit-list" aria-label="Action history">${shown.map(row => {
46
+ const subject = row.runId !== null ? html `<a class="k-link" href="/r/${row.runId}">Run #${row.runId}</a>` : row.taskId !== null ? html `<a class="k-link k-mono" href="/t/${encodeURIComponent(row.taskId)}">${row.taskId}</a>` : "";
47
+ const byActor = new URLSearchParams(kept);
48
+ byActor.set("actor", row.actor);
49
+ return html `<article class="h-audit" data-ledger-id="${row.id}"><span class="h-audit-time k-num">${exactWhenHtml(row.at)}</span>\
50
+ <span class="h-audit-what"><a class="h-actor" href="/ledger?${byActor.toString()}" title="Only this actor's actions">${row.actor}</a> ${row.action}${row.detail === null ? "" : html `<span class="h-audit-detail">${row.detail}</span>`}</span>\
51
+ <span class="h-audit-place">${row.repo === null ? "Instance" : projectName(row.repo)} ${subject}</span><span class="h-audit-out">${row.outcome} · ${row.source}</span></article>`;
52
+ })}</div>`;
53
+ const paging = kept.has("before") || params.has("before") || more
54
+ ? acts(params.has("before") ? btn("Newest actions", { sm: true, variant: "ghost", href: `/ledger?${kept.toString()}` }) : "", more ? btn("Older actions", { sm: true, variant: "ghost", href: `/ledger?${next.toString()}`, attrs: { rel: "next" } }) : "")
55
+ : "";
56
+ return html `<div class="h-page h-ledger" data-kit-page>${pageHeader("Audit log", {
57
+ ctx: "What people and tokens did, newest first. Times in UTC.",
58
+ right: btn("Export CSV", { sm: true, variant: "ghost", href: `/ledger?${csv.toString()}`, attrs: { download: true } }),
59
+ })}<div class="h-tools-row">${chain === undefined ? "" : chainHtml(chain)}${filters}</div>${list}<div class="h-foot-row">${paging}${note("Accepted requests record acceptance; worker events record results. History starts from the upgrade that added it. Review evidence is on each task or run. The CSV holds every matching action.")}</div></div>`;
57
60
  }
@@ -1,8 +1,11 @@
1
- /** Settings → Models: the CLIs on this computer, the default agent for each
2
- * role picked from live lists, and the models that just arrived. */
1
+ /** Settings → Models, in the kit's Settings frame (a sub-page of "Agents and models"): what is in use and the one
2
+ * choice (a preset, previewed before it applies), then the model for each role behind a disclosure, the CLIs on
3
+ * this computer and the models that just arrived. Saving is an instance operator's step-up. */
3
4
  import { type Html } from "./html.js";
5
+ import { type ApprovalFacts } from "./design/kit.js";
4
6
  import type { ModelOption, RuntimeState, SeenModel, WatchState } from "./model-catalog.js";
5
- export declare const MODELS_CSS = ".models{max-width:780px;min-width:0;overflow-wrap:anywhere}.models .card{padding:16px 20px;margin:12px 0;min-width:0}.models h2{margin:24px 0 8px}.models form{margin:0}.models label{display:grid;gap:6px}.models select,.models input[type=search]{box-sizing:border-box;width:100%;max-width:100%;min-width:0}.models button,.models summary,.models .button-link{min-height:44px}.models button{white-space:nowrap}.models summary{padding:12px 0;cursor:pointer}.models .tool{display:flex;gap:12px;align-items:center;justify-content:space-between;flex-wrap:wrap}.models .role{display:grid;grid-template-columns:minmax(0,1fr);gap:8px}.models .role-row{display:flex;gap:12px;align-items:end;flex-wrap:wrap}.models .role-row select{flex:1 1 240px;width:auto;min-width:0}.models .role-name{font-weight:600}.models .new{font-weight:600}.models ul{padding-left:20px}.models .meta{font-size:.86rem}.models .role-read{display:grid;gap:2px;padding:8px 0}.models .editor{display:grid;gap:14px}.models .editor .role select{width:100%}.models .tiers{border-top:1px solid var(--so-line)}.models .tiers .role{margin:0 0 14px}.models .mono{font-family:var(--font-mono,ui-monospace,monospace);font-size:.84rem}.models .preset{display:flex;gap:12px;align-items:center;justify-content:space-between;flex-wrap:wrap}.models .preset p{margin:4px 0 0}.models .preset>div{flex:1 1 280px;min-width:0}.models .button-link{display:inline-flex;align-items:center;padding:0 14px;border:1px solid var(--so-input-line);border-radius:8px;text-decoration:none;color:var(--so-ink);font-weight:600}.models table{width:100%;border-collapse:collapse}.models th,.models td{text-align:left;vertical-align:top;padding:8px 6px;border-bottom:1px solid var(--so-line)}.models .diff th{width:38%;font-weight:600}.models .diff ul{margin:4px 0 0}.models .preview{border-color:var(--so-input-line)}.models .preview form{display:grid;gap:10px;margin-top:12px}.models .preview .cancel{justify-self:start;display:inline-flex;align-items:center;min-height:44px}.models .sizing dt{font-weight:600;margin-top:8px}.models .sizing dd{margin:2px 0 0}.models .editor label:has(input[type=password]){margin-top:4px}@media(max-width:600px){.models .card{padding:14px}.models .diff th,.models .diff td{display:block;width:auto;border:0;padding:4px 0}.models .diff tr{display:block;border-bottom:1px solid var(--so-line);padding:6px 0}.models .recent td{display:block;border:0;padding:2px 0}.models .recent tr{display:block;border-bottom:1px solid var(--so-line);padding:6px 0}.models .recent thead{display:none}}";
7
+ /** The page's own layout on the kit: one-column preset cards, the role pickers in the form rows' control column. */
8
+ export declare const MODELS_CSS: string;
6
9
  /** One row of the editor: a role at the scope being edited. */
7
10
  export type RoleView = {
8
11
  /** The slot's form field (model-choices.ts slotKey). */
@@ -93,6 +96,8 @@ export type ModelsView = {
93
96
  taskId: string;
94
97
  title: string;
95
98
  }[];
99
+ /** What this request's approval shows (approvalFactsNow): a step-up asks for the password unless Passwordless is on. */
100
+ facts?: ApprovalFacts;
96
101
  };
97
102
  export declare function modelsHtml(view: ModelsView): Html;
98
103
  /** Filters one long picker as you type; the saved choice always stays listed. */
package/dist/models-ui.js CHANGED
@@ -1,22 +1,40 @@
1
- /** Settings → Models: the CLIs on this computer, the default agent for each
2
- * role picked from live lists, and the models that just arrived. */
3
- import { html, postForm, reauth } from "./html.js";
1
+ /** Settings → Models, in the kit's Settings frame (a sub-page of "Agents and models"): what is in use and the one
2
+ * choice (a preset, previewed before it applies), then the model for each role behind a disclosure, the CLIs on
3
+ * this computer and the models that just arrived. Saving is an instance operator's step-up. */
4
+ import { html, postForm } from "./html.js";
5
+ import { approvalAsk, termsOf } from "./design/kit.js";
6
+ import { acts, btn, chip, disclose, dock, facts as factList, field, formRow, group, input, note, notice, radios, settingsShell } from "./server/kit.js";
4
7
  import { priceWords } from "./model-catalog.js";
5
8
  import { whenUtc } from "./when-html.js";
6
9
  const when = (iso) => iso === null ? "never" : whenUtc(iso);
7
- export const MODELS_CSS = `.models{max-width:780px;min-width:0;overflow-wrap:anywhere}.models .card{padding:16px 20px;margin:12px 0;min-width:0}.models h2{margin:24px 0 8px}.models form{margin:0}.models label{display:grid;gap:6px}.models select,.models input[type=search]{box-sizing:border-box;width:100%;max-width:100%;min-width:0}.models button,.models summary,.models .button-link{min-height:44px}.models button{white-space:nowrap}.models summary{padding:12px 0;cursor:pointer}.models .tool{display:flex;gap:12px;align-items:center;justify-content:space-between;flex-wrap:wrap}.models .role{display:grid;grid-template-columns:minmax(0,1fr);gap:8px}.models .role-row{display:flex;gap:12px;align-items:end;flex-wrap:wrap}.models .role-row select{flex:1 1 240px;width:auto;min-width:0}.models .role-name{font-weight:600}.models .new{font-weight:600}.models ul{padding-left:20px}.models .meta{font-size:.86rem}.models .role-read{display:grid;gap:2px;padding:8px 0}.models .editor{display:grid;gap:14px}.models .editor .role select{width:100%}.models .tiers{border-top:1px solid var(--so-line)}.models .tiers .role{margin:0 0 14px}.models .mono{font-family:var(--font-mono,ui-monospace,monospace);font-size:.84rem}.models .preset{display:flex;gap:12px;align-items:center;justify-content:space-between;flex-wrap:wrap}.models .preset p{margin:4px 0 0}.models .preset>div{flex:1 1 280px;min-width:0}.models .button-link{display:inline-flex;align-items:center;padding:0 14px;border:1px solid var(--so-input-line);border-radius:8px;text-decoration:none;color:var(--so-ink);font-weight:600}.models table{width:100%;border-collapse:collapse}.models th,.models td{text-align:left;vertical-align:top;padding:8px 6px;border-bottom:1px solid var(--so-line)}.models .diff th{width:38%;font-weight:600}.models .diff ul{margin:4px 0 0}.models .preview{border-color:var(--so-input-line)}.models .preview form{display:grid;gap:10px;margin-top:12px}.models .preview .cancel{justify-self:start;display:inline-flex;align-items:center;min-height:44px}.models .sizing dt{font-weight:600;margin-top:8px}.models .sizing dd{margin:2px 0 0}.models .editor label:has(input[type=password]){margin-top:4px}@media(max-width:600px){.models .card{padding:14px}.models .diff th,.models .diff td{display:block;width:auto;border:0;padding:4px 0}.models .diff tr{display:block;border-bottom:1px solid var(--so-line);padding:6px 0}.models .recent td{display:block;border:0;padding:2px 0}.models .recent tr{display:block;border-bottom:1px solid var(--so-line);padding:6px 0}.models .recent thead{display:none}}`;
10
+ /** The page's own layout on the kit: one-column preset cards, the role pickers in the form rows' control column. */
11
+ export const MODELS_CSS = `.models{display:grid;gap:24px;min-width:0;overflow-wrap:anywhere}.models .k-radios{grid-template-columns:minmax(0,1fr);padding-top:12px}` +
12
+ `.models .k-secthead small{font-weight:400;color:var(--so-muted);font-size:12.5px}.models-presets{display:grid;gap:12px}.models-scope{display:flex;gap:8px;width:100%;max-width:300px;margin:0}.models-scope .k-selw{flex:1;min-width:0}.k-frow-c .models-scope .k-selw{max-width:none}.models-presets .k-acts{padding-top:4px}` +
13
+ `.models .role{display:grid;gap:6px;width:100%;max-width:300px;min-width:0}.models .role .k-input{max-width:none}.models-editor{display:grid;gap:12px;padding-top:4px}` +
14
+ `.models-editor > .k-field{max-width:300px}.models .k-grp{padding:16px 0 0}.models .k-disclose[open]>summary{margin-bottom:8px}.models-diff dd .k-mono{overflow-wrap:anywhere}` +
15
+ `.models-sizing{display:grid;gap:8px;padding-top:4px}.models-sizing p{margin:0;max-width:66ch}` +
16
+ `@media(max-width:760px){.models .role,.models-scope{max-width:none}.models-editor > .k-field{max-width:none}.models .k-btn.k-btn-primary.k-docked{display:none}}`;
17
+ const sec = (id, title, state, body) => html `<section class="k-sec" aria-labelledby="models-${id}"><div class="k-secthead"><h3 id="models-${id}">${title}${state ? html ` <small>· ${state}</small>` : ""}</h3></div>${body}</section>`;
18
+ const back = (view) => `/settings/models${view.scope.value === "installation" ? "" : `?scope=${encodeURIComponent(view.scope.value)}`}`;
19
+ const scopeQuery = (view) => view.scope.value === "installation" ? "" : `&scope=${encodeURIComponent(view.scope.value)}`;
20
+ const factsOf = (view) => view.facts ?? { passwordless: false, remembered: false };
21
+ /** The step-up's password, only when the rules ask for it (never under Passwordless). Named `password`, as the
22
+ * model posts read it; the server proves it again in the write. */
23
+ function password(view, id) {
24
+ return approvalAsk("step-up", factsOf(view)) === "password"
25
+ ? field({ id, label: "Your password", control: input({ id, name: "password", type: "password", attrs: { autocomplete: "current-password", required: true } }) })
26
+ : "";
27
+ }
8
28
  function toolRow(one, manage) {
9
- const state = one.installed === null ? "Version unknown"
10
- : one.behind ? html `${one.installed} · <strong>${one.latest} available</strong>`
11
- : `${one.installed} · Up to date`;
29
+ const state = one.installed === null ? "Version unknown" : one.behind ? `${one.installed} · ${one.latest} available` : `${one.installed} · up to date`;
12
30
  const action = manage && one.behind && one.updateCommand !== null
13
- ? postForm("/settings/models/update", html `<button type="submit">Update ${one.name}</button>`, { hidden: { tool: one.tool } })
14
- : one.behind ? html `<span class="meta">Update it the way you installed it</span>` : "";
15
- return html `<div class="card tool"><span><strong>${one.name}</strong> ${state}${one.problem === null ? "" : html `<br><span class="meta">${one.problem}</span>`}</span>${action}</div>`;
31
+ ? postForm("/settings/models/update", btn(`Update ${one.name}`, { sm: true, type: "submit" }), { hidden: { tool: one.tool } })
32
+ : one.behind ? html `<span class="k-note">Update it the way you installed it</span>` : html ``;
33
+ return formRow(one.name, one.problem === null ? state : `${state} · ${one.problem}`, action, "inline");
16
34
  }
17
- function roleForm(role, manage) {
35
+ function roleRow(role, manage) {
18
36
  if (!manage)
19
- return html `<div class="role-read"><span class="role-name">${role.label}</span><span>${role.words}</span></div>`;
37
+ return formRow(role.label, null, html `<span>${role.words}</span>`);
20
38
  const known = role.groups.some(group => group.options.some(option => `${group.provider}|${option.value}` === role.current));
21
39
  const saved = !known && role.current !== "" ? html `<option value="${role.current}" selected>${role.words} · current</option>` : "";
22
40
  const groups = role.groups.map(group => html `<optgroup label="${group.label}">${group.options.map(option => {
@@ -26,24 +44,23 @@ function roleForm(role, manage) {
26
44
  const unset = html `<option value="clear"${role.current === "" ? html ` selected` : ""}>${role.unset}</option>`;
27
45
  const count = role.groups.reduce((sum, group) => sum + group.options.length, 0);
28
46
  const id = `role-${role.key}`;
29
- return html `<div class="role"><label class="role-name" for="${id}">${role.label}</label>${count > 40 ? html `<input type="search" data-model-filter hidden aria-label="Search ${role.label.toLowerCase()} models" placeholder="Search ${count} models" autocomplete="off">` : ""}<select id="${id}" name="${role.key}">${unset}${saved}${groups}</select>${role.current === "" ? html `<span class="meta">${role.words}</span>` : ""}</div>`;
47
+ const control = html `<span class="role">${count > 40 ? html `<input class="k-input" type="search" data-model-filter hidden aria-label="Search ${role.label.toLowerCase()} models" placeholder="Search ${count} models" autocomplete="off">` : ""}\
48
+ <span class="k-selw wide"><select class="k-sel" id="${id}" name="${role.key}" aria-label="${role.label}">${unset}${saved}${groups}</select></span></span>`;
49
+ return formRow(role.label, role.current === "" ? role.words : null, control);
30
50
  }
31
51
  export function modelsHtml(view) {
32
52
  const manage = view.canManage && view.csrf !== "";
33
- const status = view.said === null ? "" : html `<p role="status">${view.said}</p>`;
34
- const problem = view.problem === null ? "" : html `<p class="problem" role="alert">${view.problem}</p>`;
35
- const tools = view.runtimes.length === 0
36
- ? html `<p class="meta">No Claude, Codex or Gemini CLI found on this computer yet.</p>`
37
- : view.runtimes.map(one => toolRow(one, manage));
38
- const check = manage
39
- ? postForm("/settings/models/check", html `<button type="submit" class="secondary">Check now</button>`, { attrs: { class: "inline" } })
40
- : "";
41
- const fresh = view.fresh.length === 0 ? "" :
42
- html `<h2>New models</h2><ul>${view.fresh.map(model => html `<li><span class="new">${model.name}</span> <span class="meta">${priceWords(model)}${model.releasedAt === null ? "" : ` · released ${model.releasedAt.slice(0, 10)}`}</span></li>`)}</ul>`;
43
- const watch = manage
44
- ? html `<details><summary>Automatic checks · ${view.watch.enabled ? "On" : "Off"}</summary>${postForm("/settings/models/watch", html `<p>Check every 6 hours and send a message when a new model or CLI update appears. It uses public lists and sends nothing about you or your projects.</p><button type="submit" class="secondary">${view.watch.enabled ? "Turn off" : "Turn on"}</button>`, { hidden: { enabled: view.watch.enabled ? "0" : "1" } })}</details>`
45
- : "";
46
- return html `<section class="models">${status}${problem}${previewCard(view, manage)}${editorSection(view, manage)}${presetsSection(view, manage)}${sizingSection(view)}${recentSection(view)}<h2>AI tools</h2>${tools}<p class="meta">Checked ${when(view.watch.checkedAt)} ${check}</p>${fresh}${view.chat === null ? "" : html `<h2>Chat</h2><p>${view.chat.words} · <a href="/settings/lead">Change</a></p>`}${watch}</section>`;
53
+ const outcome = view.problem !== null ? notice(view.problem) : view.said !== null ? html `<p class="k-note" role="status">${view.said}</p>` : "";
54
+ const preview = previewSection(view, manage);
55
+ const previewing = manage && view.preview !== null && view.preview.rows.length > 0;
56
+ const tools = view.runtimes.length === 0 ? note("No Claude, Codex or Gemini CLI found on this computer yet.") : view.runtimes.map(one => toolRow(one, manage));
57
+ const checks = manage ? html `${formRow("Check for new versions", `Checked ${when(view.watch.checkedAt)}.`, postForm("/settings/models/check", btn("Check now", { sm: true, type: "submit" })), "inline")}\
58
+ ${formRow("Automatic checks", `${view.watch.enabled ? "On" : "Off"}. Every 6 hours, with a message when a new model or CLI update appears. Uses public lists and sends nothing about you or your projects.`, postForm("/settings/models/watch", btn(view.watch.enabled ? "Turn off" : "Turn on", { sm: true, type: "submit" }), { hidden: { enabled: view.watch.enabled ? "0" : "1" } }), "inline")}`
59
+ : note(`Checked ${when(view.watch.checkedAt)}.`);
60
+ const fresh = view.fresh.length === 0 ? "" : sec("new", "New models", "", view.fresh.map(model => formRow(model.name, `${priceWords(model)}${model.releasedAt === null ? "" : ` · released ${model.releasedAt.slice(0, 10)}`}`, chip("New", "", { dot: false }), "inline")));
61
+ const chat = view.chat === null ? "" : formRow("Chat", view.chat.words, btn("Change", { sm: true, variant: "ghost", href: "/settings/lead" }), "inline");
62
+ const body = html `<div class="models">${outcome}${preview}${choiceSection(view, manage, previewing)}${recentSection(view)}${sec("tools", "AI tools on this computer", "", html `${tools}${checks}`)}${fresh}${chat}</div>`;
63
+ return settingsShell({ group: "agents", sub: true, title: "Models", lead: "Which model plans, builds and reviews each task. Changes apply to tasks filed from now on.", body });
47
64
  }
48
65
  /** Filters one long picker as you type; the saved choice always stays listed. */
49
66
  export function modelsScript() {
@@ -56,47 +73,53 @@ export function modelsScript() {
56
73
  select.value=chosen;});
57
74
  })})();`;
58
75
  }
59
- const scopeQuery = (view) => view.scope.value === "installation" ? "" : `&scope=${encodeURIComponent(view.scope.value)}`;
60
- const password = () => reauth(html `<label>Your Toolroll password<input type="password" name="password" autocomplete="current-password"></label>`);
61
- /** The roles at the scope being edited: one form, one save, one password. Sizes and tiers sit behind a disclosure. */
62
- function editorSection(view, manage) {
63
- const scopes = view.scope.options.length < 2 ? "" : html `<form method="get" action="/settings/models" class="models-scope"><label for="models-scope">Settings for</label><div class="role-row"><select id="models-scope" name="scope">${view.scope.options.map(one => html `<option value="${one.value}"${one.value === view.scope.value ? html ` selected` : ""}>${one.label}</option>`)}</select><button type="submit" class="secondary">Show</button></div></form>`;
76
+ /** What is in use and the one choice (a preset, previewed before it applies); the model for each role behind a
77
+ * disclosure, saved with one step-up; how tasks are sized, on demand. */
78
+ function choiceSection(view, manage, previewing) {
79
+ const inUse = view.inUse === null ? "a custom set" : view.inUse;
80
+ const scopes = view.scope.options.length < 2 ? "" : formRow("Settings for", "A project can follow these or choose its own.", html `<form method="get" action="/settings/models" class="models-scope">\
81
+ <span class="k-selw wide"><select class="k-sel" id="models-scope" name="scope" aria-label="Settings for">${view.scope.options.map(one => html `<option value="${one.value}"${one.value === view.scope.value ? html ` selected` : ""}>${one.label}</option>`)}</select></span>${btn("Show", { sm: true, type: "submit" })}</form>`);
82
+ const unresolved = view.unresolved === null ? "" : notice(`New tasks can't be filed here yet: ${view.unresolved}`);
83
+ const current = view.preview?.hidden["preset"] ?? view.presets.find(one => one.inUse)?.name ?? "";
84
+ const presetItems = view.presets.map(one => ({ value: one.name, label: one.label, text: `${one.words}${one.recommended ? " Recommended." : ""}${one.inUse ? " In use." : ""}` }));
85
+ const presets = manage
86
+ ? html `<form method="get" action="/settings/models" class="models-presets" id="models-preset">${view.scope.value === "installation" ? "" : html `<input type="hidden" name="scope" value="${view.scope.value}">`}\
87
+ ${radios({ name: "preset", legend: "Preset", current, items: presetItems })}${acts(btn("Review changes", { variant: previewing ? "outline" : "primary", type: "submit", docked: !previewing }))}</form>`
88
+ : factList(view.presets.map(one => [one.label, `${one.words}${one.inUse ? " In use." : ""}`]));
89
+ const newer = view.newer.length === 0 ? "" : html `${group("Newer models")}${view.newer.map(one => formRow(one.name, `Newer than ${one.from}, used by ${one.roles}.`, manage ? btn("Switch to it", { sm: true, href: `/settings/models?switch=${encodeURIComponent(one.from)}&to=${encodeURIComponent(one.to)}${scopeQuery(view)}` }) : html ``, "inline"))}`;
64
90
  const main = view.roles.filter(role => !role.tier), tiers = view.roles.filter(role => role.tier);
65
- const inUse = html `<p class="meta">In use: ${view.inUse === null ? "a custom set" : view.inUse}${view.scope.value === "installation" ? "" : html ` · ${view.scope.label}`}</p>`;
66
- const unresolved = view.unresolved === null ? "" : html `<p class="problem" role="alert">New tasks can't be filed here yet: ${view.unresolved}</p>`;
67
- const rows = html `${main.map(role => roleForm(role, manage))}<details class="tiers"${manage ? "" : html ` open`}><summary>By task size</summary>${tiers.map(role => roleForm(role, manage))}</details>`;
68
- const body = manage
69
- ? postForm("/settings/models/agent", html `${rows}${password()}<button type="submit">Save</button>`, { attrs: { class: "card editor" }, hidden: { scope: view.scope.value, digest: view.scope.digest } })
70
- : html `<div class="card editor">${rows}</div>`;
71
- return html `<h2>Models for new tasks</h2>${scopes}${inUse}${unresolved}${body}<p class="meta">Changes apply to tasks filed from now on. Approved and waiting tasks keep the models they were filed with.</p>`;
91
+ const rows = html `${main.map(role => roleRow(role, manage))}${tiers.length === 0 ? "" : html `${group("By task size")}${tiers.map(role => roleRow(role, manage))}`}`;
92
+ const editor = manage
93
+ ? postForm("/settings/models/agent", html `${rows}${note(`Approved and waiting tasks keep the models they were filed with. ${termsOf("step-up", factsOf(view))}`)}${password(view, "models-agent-pw")}${acts(btn("Save models", { type: "submit" }))}`, { attrs: { class: "models-editor" }, hidden: { scope: view.scope.value, digest: view.scope.digest } })
94
+ : html `<div class="models-editor">${rows}</div>`;
95
+ const docked = !manage || previewing ? "" : dock(btn("Review changes", { variant: "primary", type: "submit", attrs: { form: "models-preset" } }));
96
+ return html `${sec("choice", "Models for new tasks", `in use: ${inUse}${view.scope.value === "installation" ? "" : ` · ${view.scope.label}`}`, html `${scopes}${unresolved}${presets}${newer}`)}\
97
+ ${disclose("Model for each role", editor)}${sizingSection(view)}${docked}`;
72
98
  }
73
- /** A preset or a switch shows exactly what it changes, and applies only with a second, password-checked submit. */
74
- function previewCard(view, manage) {
99
+ /** A preset or a switch shows exactly what it changes, and applies only with a second, step-up submit. */
100
+ function previewSection(view, manage) {
75
101
  const preview = view.preview;
76
102
  if (preview === null || !manage)
77
- return html ``;
78
- const back = `/settings/models${view.scope.value === "installation" ? "" : `?scope=${encodeURIComponent(view.scope.value)}`}`;
103
+ return "";
79
104
  if (preview.rows.length === 0)
80
- return html `<div class="card preview" role="status"><h2>${preview.title}</h2><p>Already in use. Nothing would change.</p><a class="button-link" href="${back}">Back</a></div>`;
81
- const rows = preview.rows.map(row => html `<tr><th scope="row">${row.role}</th><td><span class="mono">${row.before}</span> → <span class="mono">${row.after}</span>${row.notes.length === 0 ? "" : html `<ul class="meta">${row.notes.map(note => html `<li>${note}</li>`)}</ul>`}</td></tr>`);
82
- return html `<div class="card preview"><h2>${preview.title}</h2><p>Review what changes for tasks filed from now on.</p><table class="diff"><tbody>${rows}</tbody></table>${postForm("/settings/models/apply", html `${password()}<button type="submit" class="primary">Apply ${preview.rows.length} change${preview.rows.length === 1 ? "" : "s"}</button><a class="cancel" href="${back}">Cancel</a>`, { hidden: preview.hidden })}</div>`;
83
- }
84
- function presetsSection(view, manage) {
85
- const presets = view.presets.map(one => html `<div class="card preset"><div><strong>${one.label}</strong>${one.recommended ? html ` <span class="meta">Default</span>` : ""}<p>${one.words}</p></div>${one.inUse ? html `<span class="meta">In use</span>` : manage ? html `<a class="button-link" href="/settings/models?preset=${one.name}${scopeQuery(view)}">Review changes</a>` : ""}</div>`);
86
- const newer = view.newer.length === 0 ? "" : html `<h2>Newer models</h2>${view.newer.map(one => html `<div class="card preset"><div><strong>${one.name}</strong><p>Newer than <span class="mono">${one.from}</span>, used by ${one.roles}.</p></div>${manage ? html `<a class="button-link" href="/settings/models?switch=${encodeURIComponent(one.from)}&to=${encodeURIComponent(one.to)}${scopeQuery(view)}">Switch to it</a>` : ""}</div>`)}`;
87
- return html `<h2>Presets</h2>${presets}${newer}`;
105
+ return sec("preview", preview.title, "", html `<p class="k-note" role="status">Already in use. Nothing would change.</p>${acts(btn("Back", { sm: true, href: back(view) }))}`);
106
+ const rows = factList(preview.rows.map(row => [row.role, html `<span class="k-mono">${row.before}</span> → <span class="k-mono">${row.after}</span>${row.notes.length === 0 ? "" : html `<br><span class="k-note">${row.notes.join(" ")}</span>`}`]));
107
+ const label = `Apply ${preview.rows.length} change${preview.rows.length === 1 ? "" : "s"}`;
108
+ const form = postForm("/settings/models/apply", html `${note(`Tasks filed from now on use these. ${termsOf("step-up", factsOf(view))}`)}${password(view, "models-apply-pw")}${acts(btn(label, { variant: "primary", type: "submit", docked: true }), btn("Cancel", { variant: "ghost", href: back(view) }))}`, { hidden: preview.hidden, attrs: { id: "models-apply", class: "models-editor" } });
109
+ return html `<div class="models-diff">${sec("preview", preview.title, "", html `${rows}${form}`)}</div>${dock(btn(label, { variant: "primary", type: "submit", attrs: { form: "models-apply" } }))}`;
88
110
  }
89
- /** How a task's size is decided, in plain words, and what routing does at filing. */
111
+ /** How a task's size is decided, in plain words, and what routing does at filing; on demand. */
90
112
  function sizingSection(view) {
91
- return html `<h2>How tasks are sized</h2><div class="card sizing"><p>Each task is sized once, when it is filed. ${view.sizing.classifier} If it doesn't answer within 5 seconds, the size comes from the description.</p><dl>
92
- <dt>Small</dt><dd>${view.sizing.small}. Built by the builder for small tasks, with no plan.</dd>
93
- <dt>Routine</dt><dd>${view.sizing.routine}. Planned and built by the planner and builder for routine tasks.</dd>
94
- <dt>Large or risky</dt><dd>Large: ${view.sizing.large}. Risky: ${view.sizing.risky} Planned and built by the large or risky models.</dd></dl>
95
- <details><summary>More about routing</summary><p>${view.routing}</p><p>Changing a model never re-sizes or re-routes a task that was already filed. One exception: an older build review saved without its own route uses the reviewer set here now.</p></details></div>${view.waiting.length === 0 ? "" : html `<details class="waiting"><summary>${view.waiting.length} plan${view.waiting.length === 1 ? "" : "s"} waiting for approval</summary><p class="meta">They keep the models they were filed with.</p><ul>${view.waiting.map(one => html `<li><a href="/t/${encodeURIComponent(one.taskId)}">${one.title}</a></li>`)}</ul></details>`}`;
113
+ const waiting = view.waiting.length === 0 ? "" : html `<p>${view.waiting.length} plan${view.waiting.length === 1 ? "" : "s"} waiting for approval keep the models they were filed with: ${view.waiting.map((one, index) => html `${index === 0 ? "" : ", "}<a class="k-link" href="/t/${encodeURIComponent(one.taskId)}">${one.title}</a>`)}.</p>`;
114
+ return disclose("How tasks are sized", html `<div class="models-sizing"><p>Each task is sized once, when it is filed. ${view.sizing.classifier} If it doesn't answer within 5 seconds, the size comes from the description.</p>${factList([
115
+ ["Small", `${view.sizing.small}. Built by the builder for small tasks, with no plan.`],
116
+ ["Routine", `${view.sizing.routine}. Planned and built by the planner and builder for routine tasks.`],
117
+ ["Large or risky", `Large: ${view.sizing.large}. Risky: ${view.sizing.risky} Planned and built by the large or risky models.`],
118
+ ])}<p>${view.routing}</p><p>Changing a model never re-sizes or re-routes a task that was already filed. One exception: an older build review saved without its own route uses the reviewer set here now.</p>${waiting}</div>`);
96
119
  }
97
120
  /** The model each recent run was set to, next to the model its provider reported. */
98
121
  function recentSection(view) {
99
122
  if (view.recent.length === 0)
100
- return html ``;
101
- return html `<h2>Recent work</h2><table class="recent"><thead><tr><th scope="col">Task</th><th scope="col">Chosen → ran on</th></tr></thead><tbody>${view.recent.map(one => html `<tr><td><a href="/t/${encodeURIComponent(one.taskId)}">${one.taskId}</a> <span class="meta">${one.role}</span></td><td><span class="mono">${one.chosen}</span> → <span class="mono">${one.ranOn}</span></td></tr>`)}</tbody></table>`;
123
+ return "";
124
+ return sec("recent", "Recent work", "", view.recent.map(one => formRow(one.role, `Chosen ${one.chosen} → ran on ${one.ranOn}`, html `<a class="k-link k-mono" href="/t/${encodeURIComponent(one.taskId)}">${one.taskId}</a>`, "inline")));
102
125
  }
@@ -2,18 +2,20 @@
2
2
  * Settings → Monitoring (v104): an instance operator points Toolroll at
3
3
  * the company's tools. One form, the status of each destination beside it,
4
4
  * and how Prometheus reads `/metrics`. The signing secret is shown once.
5
+ * Built from the revamp kit; the Settings frame adds the title.
5
6
  */
6
7
  import { type Html } from "./html.js";
7
8
  import type { MonitoringStatus } from "./store.js";
8
9
  import type { MonitoringSettings } from "./monitoring-settings.js";
9
- export declare const MONITORING_CSS: string;
10
+ /** The page is kit pieces now; nothing of the old look is left. Kept as an export for the shared stylesheet. */
11
+ export declare const MONITORING_CSS = "";
10
12
  export type MonitoringView = {
11
13
  settings: MonitoringSettings;
12
14
  status: MonitoringStatus[];
13
15
  head: number;
14
16
  origin: string | null;
15
17
  };
16
- export declare function monitoringHtml(view: MonitoringView, notice: {
18
+ export declare function monitoringHtml(view: MonitoringView, said: {
17
19
  said?: string | null;
18
20
  problem?: string | null;
19
21
  }): Html;
@@ -2,16 +2,16 @@
2
2
  * Settings → Monitoring (v104): an instance operator points Toolroll at
3
3
  * the company's tools. One form, the status of each destination beside it,
4
4
  * and how Prometheus reads `/metrics`. The signing secret is shown once.
5
+ * Built from the revamp kit; the Settings frame adds the title.
5
6
  */
6
- import { html, postForm, reauth } from "./html.js";
7
+ import { html, joinHtml } from "./html.js";
7
8
  import { targetOf } from "./monitoring.js";
8
9
  import { whenUtc } from "./when-html.js";
10
+ import { codeLine, disclose, doc, docHeader, formRow, icon, infoLine, input, note, notice, settingsSection, switchInput } from "./server/kit.js";
11
+ import { kitSectionForm, SECRET_OPT_OUTS } from "./settings-kit.js";
9
12
  const when = (at) => whenUtc(at);
10
- export const MONITORING_CSS = `.monitoring{max-width:720px;min-width:0}.monitoring fieldset{border:0;padding:0;margin:20px 0 0;min-width:0}` +
11
- `.monitoring legend{font-weight:600;font-size:.9375rem;margin-bottom:6px}.monitoring label{display:grid;gap:4px;margin:10px 0 0;font-size:.875rem}` +
12
- `.monitoring input[type=text],.monitoring input[type=url],.monitoring input[type=password]{width:100%;box-sizing:border-box}.monitoring .choice{display:flex;gap:8px;align-items:center}` +
13
- `.monitoring .status{margin:4px 0 0;font-size:.8125rem}.monitoring .status.problem{color:var(--danger)}.monitoring .step-up{margin-top:18px}.monitoring button[type=submit]{margin-top:14px}` +
14
- `.monitoring pre{white-space:pre-wrap;overflow-wrap:anywhere;font-size:.75rem}.monitoring details{margin-top:6px}`;
13
+ /** The page is kit pieces now; nothing of the old look is left. Kept as an export for the shared stylesheet. */
14
+ export const MONITORING_CSS = "";
15
15
  /** How a destination is doing, in one line. */
16
16
  function statusLine(view, sink, address) {
17
17
  // An empty field already says it's off.
@@ -20,20 +20,51 @@ function statusLine(view, sink, address) {
20
20
  // A status for another address (it just changed) says nothing about this one yet.
21
21
  const one = view.status.find(row => row.sink === sink && row.target === targetOf(address));
22
22
  if (one === undefined || (one.lastOkAt === null && one.lastError === null))
23
- return html `<p class="status meta" data-monitoring="${sink}" data-state="starting">Starting: the first delivery goes out within a few seconds.</p>`;
23
+ return html `<p class="k-note" data-monitoring="${sink}" data-state="starting">Starting: the first delivery goes out within a few seconds.</p>`;
24
24
  if (one.failures > 0 && one.lastError !== null) {
25
- return html `<p class="status problem" role="alert" data-monitoring="${sink}" data-state="failing">Failing: ${one.lastError}${one.nextTryAt === null ? "" : html ` · trying again ${when(one.nextTryAt)}`}</p>`;
25
+ return html `<p class="k-notice" role="alert" data-monitoring="${sink}" data-state="failing">${icon("alert", true)}<span>Failing: ${one.lastError}${one.nextTryAt === null ? "" : html ` · trying again ${when(one.nextTryAt)}`}</span></p>`;
26
26
  }
27
27
  const behind = Math.max(0, view.head - one.through);
28
- return html `<p class="status meta" data-monitoring="${sink}" data-state="ok">${sink === "traces" ? "Sending" : `Sent through entry #${one.through}`}${one.lastOkAt === null ? "" : html ` · last ${when(one.lastOkAt)}`}${behind > 0 && sink !== "traces" ? ` · ${behind} to go` : ""}</p>`;
28
+ return html `<p class="k-note" data-monitoring="${sink}" data-state="ok">${sink === "traces" ? "Sending" : `Sent through entry #${one.through}`}${one.lastOkAt === null ? "" : html ` · last ${when(one.lastOkAt)}`}${behind > 0 && sink !== "traces" ? ` · ${behind} to go` : ""}</p>`;
29
29
  }
30
- export function monitoringHtml(view, notice) {
30
+ export function monitoringHtml(view, said) {
31
31
  const s = view.settings;
32
- const note = notice.problem ? html `<p class="problem" role="alert">${notice.problem}</p>` : notice.said ? html `<p role="status">${notice.said}</p>` : "";
33
32
  const metrics = view.origin === null ? "/metrics" : `${view.origin}/metrics`;
34
- return html `<section class="monitoring">${note}${postForm("/settings/monitoring", html `<fieldset><legend>Audit stream</legend><p class="meta">Every ledger entry with its seal, as it happens, to your log system.</p><label>Webhook<input type="url" name="webhook" value="${s.webhook?.url ?? ""}" placeholder="https://logs.example.com/standing-orders" spellcheck="false"></label>${statusLine(view, "webhook", s.webhook?.url ?? null)}${s.webhook === null ? "" : html `<label class="choice"><input type="checkbox" name="rotate" value="1"> Make a new signing secret</label>`}<label>Folder (JSON Lines, a file per day)<input type="text" name="folder" value="${s.folder?.path ?? ""}" placeholder="/var/log/standing-orders" spellcheck="false"></label>${statusLine(view, "folder", s.folder?.path ?? null)}</fieldset><fieldset><legend>Traces</legend><p class="meta">Each run as an OpenTelemetry span: timings, model, tokens and cost. Never a prompt or code.</p><label>Collector (OTLP over HTTP)<input type="url" name="traces" value="${s.traces?.endpoint ?? ""}" placeholder="https://otel.example.com:4318" spellcheck="false"></label>${statusLine(view, "traces", s.traces?.endpoint ?? null)}<label>Header name (optional)<input type="text" name="header-name" value="${s.traces?.header?.name ?? ""}" placeholder="x-honeycomb-team" spellcheck="false"></label><label>Header value<input type="password" name="header-value" autocomplete="off" placeholder="${s.traces?.header ? "Saved; leave blank to keep" : "API key"}"></label></fieldset><div class="step-up">${reauth(html `<label>Your Toolroll password<input type="password" name="password" autocomplete="current-password"></label>`)}</div><button type="submit">Save</button>`)}<fieldset><legend>Metrics</legend><p class="meta">Prometheus reads <code>${metrics}</code> with an instance operator's API token (<a href="/settings/sessions">Sessions &amp; tokens</a>).</p><details><summary>Example</summary><pre>curl -H "Authorization: Bearer so_…" ${metrics}</pre></details></fieldset></section>`;
33
+ const audit = settingsSection({ id: "audit-stream", title: "Audit stream", lead: "Every ledger entry with its seal, as it happens, to your log system.",
34
+ body: joinHtml([
35
+ formRow("Webhook", "Blank: off.", input({ id: "monitoring-webhook", name: "webhook", type: "url", value: s.webhook?.url ?? "",
36
+ attrs: { placeholder: "https://logs.example.com/standing-orders", spellcheck: "false", "aria-label": "Webhook" } })),
37
+ statusLine(view, "webhook", s.webhook?.url ?? null),
38
+ s.webhook === null ? "" : formRow("Signing secret", "Make a new one. It is shown once, after Save.", switchInput({ id: "monitoring-rotate", name: "rotate", label: "Make a new signing secret", on: false, attrs: { value: "1" } }), "inline"),
39
+ formRow("Folder", "JSON Lines, a file per day. Blank: off.", input({ id: "monitoring-folder", name: "folder", value: s.folder?.path ?? "",
40
+ attrs: { placeholder: "/var/log/standing-orders", spellcheck: "false", "aria-label": "Folder" } })),
41
+ statusLine(view, "folder", s.folder?.path ?? null),
42
+ ]) });
43
+ const traces = settingsSection({ id: "traces", title: "Traces", lead: "Each run as an OpenTelemetry span: timings, model, tokens and cost. Never a prompt or code.",
44
+ body: joinHtml([
45
+ formRow("Collector", "OTLP over HTTP. Blank: off.", input({ id: "monitoring-traces", name: "traces", type: "url", value: s.traces?.endpoint ?? "",
46
+ attrs: { placeholder: "https://otel.example.com:4318", spellcheck: "false", "aria-label": "Collector" } })),
47
+ statusLine(view, "traces", s.traces?.endpoint ?? null),
48
+ formRow("Header name", "Optional.", input({ id: "monitoring-header-name", name: "header-name", value: s.traces?.header?.name ?? "",
49
+ attrs: { placeholder: "x-honeycomb-team", spellcheck: "false", "aria-label": "Header name" } })),
50
+ formRow("Header value", s.traces?.header ? "Saved. Leave blank to keep it." : null, input({ id: "monitoring-header-value", name: "header-value", type: "password",
51
+ attrs: { autocomplete: "off", spellcheck: "false", autocapitalize: "none", "data-so-secret": true, ...SECRET_OPT_OUTS, placeholder: s.traces?.header ? "Saved; leave blank to keep" : "API key", "aria-label": "Header value" } })),
52
+ ]) });
53
+ const metricsSection = settingsSection({ id: "metrics", title: "Metrics",
54
+ lead: html `Prometheus reads <code class="k-mono">${metrics}</code> with an instance operator's API token (<a class="k-link" href="/settings/sessions">Sessions and tokens</a>).`,
55
+ body: disclose("Example", codeLine(`curl -H "Authorization: Bearer so_…" ${metrics}`)) });
56
+ return joinHtml([
57
+ said.problem ? notice(said.problem) : said.said ? infoLine(said.said) : "",
58
+ // A new signing secret answers with a page of its own (shown once): post as a navigation.
59
+ kitSectionForm({ proof: "monitoring", action: "/settings/monitoring", id: "monitoring-form", gate: true, native: true, body: joinHtml([audit, traces]) }),
60
+ metricsSection,
61
+ ]);
35
62
  }
36
63
  /** The webhook's signing secret, once: a focused page with no script. */
37
64
  export function signingSecretHtml(url, secret) {
38
- return html `<h1>Signing secret</h1><div class="card"><p>Give this to the receiver at <span class="mono">${url}</span>. It's shown once.</p><p class="mono secret-value">${secret}</p><p class="meta">Each request carries <code>x-standing-orders-signature: t=&lt;time&gt;,v1=&lt;HMAC-SHA256 of "time.body"&gt;</code>. Check it, and that the time is recent.</p></div><p class="meta"><a href="/settings/monitoring">Back to Monitoring</a></p>`;
65
+ return doc(docHeader({ title: "Signing secret", back: ["/settings/monitoring", "Monitoring"] }), settingsSection({ id: "signing-secret", title: "Shown once", lead: html `Give this to the receiver at <span class="k-mono">${url}</span>. It isn't shown again.`,
66
+ body: joinHtml([
67
+ html `<p class="k-secret-val">${secret}</p>`,
68
+ note(html `Each request carries <code class="k-mono">x-standing-orders-signature: t=&lt;time&gt;,v1=&lt;HMAC-SHA256 of "time.body"&gt;</code>. Check it, and that the time is recent.`),
69
+ ]) }));
39
70
  }
@@ -45,7 +45,7 @@ export declare const ASKS: readonly Ask[];
45
45
  export declare const ASK_LABEL: Readonly<Record<Ask, string>>;
46
46
  /** A waiting row's chip: the specific thing asked, never its group's own word (no "Decide" chip under Decide).
47
47
  * Null: the group heading and the row's sentence already say it, so the row wears no chip. */
48
- export type AskChip = "Plan" | "Result" | "Mismatch" | "Plan changed" | "Failed" | "Builder offline";
48
+ export type AskChip = "Plan" | "Result" | "Mismatch" | "Plan changed" | "Failed" | "Builder offline" | "Question" | "On hold" | "Stopped" | "Sign in";
49
49
  /** `mismatch`: the saved report doesn't match its changes; `planChanged`: the plan changed after it was built. */
50
50
  export declare function askChipOf(read: {
51
51
  headline: string;
package/dist/needs-you.js CHANGED
@@ -66,6 +66,14 @@ export function askChipOf(read) {
66
66
  return "Plan";
67
67
  if (read.need === "start-builder" || read.need === "connect-builder")
68
68
  return "Builder offline";
69
+ if (read.need === "answer" || read.need === "questions")
70
+ return "Question";
71
+ if (read.need === "hold")
72
+ return "On hold";
73
+ if (read.need === "vanished" || read.need === "confirm-stopped" || read.need === "check-stopped")
74
+ return "Stopped";
75
+ if (read.need === "sign-in")
76
+ return "Sign in";
69
77
  return null;
70
78
  }
71
79
  export function resultHoldUpSentence(facts) {
@@ -19,6 +19,7 @@ export const REMOTE_ARGUMENTS = new Map([
19
19
  ['task add', args('title*', 'key report checks', { id: 'refused', replaces: 'task' })],
20
20
  ['task ask', args('id:task', 'person why')],
21
21
  ['task checks', args('task:task', 'level')],
22
+ ['task ship', args('task:task')],
22
23
  ['task add-tests', args('task:task')],
23
24
  ['task list', args('', 'state view limit cursor')],
24
25
  ['task show', args('id:task')],
@@ -37,7 +37,7 @@ const references = { kind: "references" }, listing = { kind: "listing" };
37
37
  export const REMOTE_SCOPES = new Map([
38
38
  ["status", listing], ["integrations", installation], ["ready", listing], ["grants", installation], ["sync", installation],
39
39
  ["gaps", repo], ["task add", repo],
40
- ...["ask", "checks", "add-tests", "show", "wait", "complete", "revise", "state", "block", "unblock", "next", "steer", "pause", "continue", "assign", "scope", "plan", "hold",
40
+ ...["ask", "checks", "ship", "add-tests", "show", "wait", "complete", "revise", "state", "block", "unblock", "next", "steer", "pause", "continue", "assign", "scope", "plan", "hold",
41
41
  "unhold", "require", "requeue", "review", "repair", "route", "reopen", "stop", "resume"].map(action => [`task ${action}`, references]),
42
42
  ["check-progress", references],
43
43
  ["task list", listing],
package/dist/operate.d.ts CHANGED
@@ -115,7 +115,7 @@ type Args = {
115
115
  * exports are behavior, not commentary — and the declared command guide
116
116
  * (surface.ts) is tested for exact equality against them.
117
117
  */
118
- export declare const TASK_ACTIONS: readonly ["add", "list", "show", "wait", "state", "block", "unblock", "next", "steer", "pause", "continue", "assign", "reopen", "scope", "approve", "hold", "unhold", "require", "requeue", "regate", "plan", "review", "accept", "repair", "route", "stop", "resume", "complete", "revise", "merge", "ask", "checks", "add-tests"];
118
+ export declare const TASK_ACTIONS: readonly ["add", "list", "show", "wait", "state", "block", "unblock", "next", "steer", "pause", "continue", "assign", "reopen", "scope", "approve", "hold", "unhold", "require", "requeue", "regate", "plan", "review", "accept", "repair", "route", "stop", "resume", "complete", "revise", "merge", "ask", "checks", "add-tests", "archive", "restore", "tidy", "ship"];
119
119
  export declare const PUBLISH_ACTIONS: readonly ["setup", "grant", "revoke", "status", "unblock", "rearm", "merge", "refire"];
120
120
  export declare const CONFIG_ACTIONS: readonly ["show", "set", "clear"];
121
121
  export declare const APPROVER_ACTIONS: readonly ["list", "add"];