toolroll 0.9.94 → 0.9.95

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 (191) hide show
  1. package/CHANGELOG.md +12 -1
  2. package/dist/agent-fence.d.ts +6 -0
  3. package/dist/agent-fence.js +2 -2
  4. package/dist/assignment-status.d.ts +1 -1
  5. package/dist/assignment-ui.js +2 -0
  6. package/dist/attest.d.ts +3 -0
  7. package/dist/attest.js +10 -0
  8. package/dist/browser/terminal.js +1 -1
  9. package/dist/browser/workspace.css +1 -1
  10. package/dist/browser/workspace.js +24 -24
  11. package/dist/browser-crew.js +2 -0
  12. package/dist/browser-workspace.d.ts +5 -0
  13. package/dist/build-review.js +2 -0
  14. package/dist/builder.d.ts +7 -2
  15. package/dist/builder.js +183 -79
  16. package/dist/card-catalog.d.ts +209 -0
  17. package/dist/card-catalog.js +133 -0
  18. package/dist/card-cli.d.ts +43 -0
  19. package/dist/card-cli.js +44 -0
  20. package/dist/card-render.d.ts +26 -0
  21. package/dist/card-render.js +223 -0
  22. package/dist/card-sources.d.ts +68 -0
  23. package/dist/card-sources.js +234 -0
  24. package/dist/chat-actions.d.ts +95 -8
  25. package/dist/chat-actions.js +449 -45
  26. package/dist/chat-approval.d.ts +13 -0
  27. package/dist/chat-approval.js +39 -9
  28. package/dist/chat-card-part.d.ts +11 -0
  29. package/dist/chat-card-part.js +18 -0
  30. package/dist/chat-card.d.ts +46 -0
  31. package/dist/chat-card.js +99 -0
  32. package/dist/chat-channel.d.ts +2 -0
  33. package/dist/chat-channel.js +26 -14
  34. package/dist/chat-controls.d.ts +4 -0
  35. package/dist/chat-controls.js +4 -0
  36. package/dist/chat-decide.d.ts +3 -14
  37. package/dist/chat-decide.js +43 -54
  38. package/dist/chat-delivery.js +13 -4
  39. package/dist/chat-flow.d.ts +0 -27
  40. package/dist/chat-flow.js +31 -2
  41. package/dist/chat-quiet.js +1 -1
  42. package/dist/chat-task-actions.d.ts +3 -2
  43. package/dist/chat-task-actions.js +19 -5
  44. package/dist/chat-terms.d.ts +14 -2
  45. package/dist/chat-terms.js +35 -0
  46. package/dist/check-failure.d.ts +2 -0
  47. package/dist/check-failure.js +34 -2
  48. package/dist/check-repair.d.ts +47 -0
  49. package/dist/check-repair.js +240 -0
  50. package/dist/claim.d.ts +6 -0
  51. package/dist/claim.js +13 -2
  52. package/dist/cli-help.d.ts +1 -1
  53. package/dist/cli-help.js +2 -0
  54. package/dist/cli.js +4 -0
  55. package/dist/coding-provider.d.ts +1 -1
  56. package/dist/coding-provider.js +9 -5
  57. package/dist/coding-workspace.d.ts +1 -1
  58. package/dist/coding-workspace.js +4 -4
  59. package/dist/contracts/chat-actions.d.ts +83 -0
  60. package/dist/contracts/chat-actions.js +18 -0
  61. package/dist/contracts/chat-card.d.ts +91 -0
  62. package/dist/contracts/chat-card.js +54 -0
  63. package/dist/contracts/chat-content.d.ts +191 -0
  64. package/dist/contracts/chat-content.js +4 -0
  65. package/dist/contracts/console-api.d.ts +2 -0
  66. package/dist/contracts/console-api.js +2 -0
  67. package/dist/contracts/flow-propose.d.ts +8 -1
  68. package/dist/contracts/flow-propose.js +3 -1
  69. package/dist/contracts/flow-send.d.ts +126 -0
  70. package/dist/contracts/flow-send.js +3 -0
  71. package/dist/contracts/lead-tools.d.ts +173 -0
  72. package/dist/contracts/lead-tools.js +15 -0
  73. package/dist/contracts/store-json.d.ts +16 -0
  74. package/dist/discord-chat.js +14 -0
  75. package/dist/dispatch.d.ts +1 -1
  76. package/dist/dispatch.js +7 -1
  77. package/dist/dispose.d.ts +19 -0
  78. package/dist/dispose.js +47 -7
  79. package/dist/eval-oracle.js +4 -1
  80. package/dist/evidence-pair.d.ts +53 -0
  81. package/dist/evidence-pair.js +128 -0
  82. package/dist/exhaustion.d.ts +17 -4
  83. package/dist/exhaustion.js +44 -4
  84. package/dist/export.js +1 -1
  85. package/dist/failing-first.d.ts +31 -0
  86. package/dist/failing-first.js +44 -0
  87. package/dist/fallback-schema.d.ts +8 -0
  88. package/dist/fallback-schema.js +40 -0
  89. package/dist/fallback.d.ts +138 -0
  90. package/dist/fallback.js +605 -0
  91. package/dist/findings.d.ts +224 -0
  92. package/dist/findings.js +449 -0
  93. package/dist/fleet-schema.d.ts +17 -0
  94. package/dist/fleet-schema.js +135 -0
  95. package/dist/flow-browser.js +2 -2
  96. package/dist/flow-code.d.ts +2 -2
  97. package/dist/flow-code.js +9 -5
  98. package/dist/flow-result-card.d.ts +22 -0
  99. package/dist/flow-result-card.js +55 -0
  100. package/dist/flow-send.js +12 -9
  101. package/dist/flow-steps.js +1 -1
  102. package/dist/flow-triggers.js +1 -1
  103. package/dist/flow-unattended.d.ts +26 -0
  104. package/dist/flow-unattended.js +12 -3
  105. package/dist/flows-ui.js +10 -2
  106. package/dist/follow-through-brief.d.ts +12 -0
  107. package/dist/follow-through-brief.js +31 -0
  108. package/dist/follow-through-cli.d.ts +23 -0
  109. package/dist/follow-through-cli.js +153 -0
  110. package/dist/git-incident.d.ts +16 -0
  111. package/dist/git-incident.js +69 -6
  112. package/dist/handover-note.d.ts +33 -0
  113. package/dist/handover-note.js +106 -0
  114. package/dist/invoke.d.ts +20 -1
  115. package/dist/invoke.js +108 -44
  116. package/dist/lead-cli.js +26 -24
  117. package/dist/lead-contract.js +1 -0
  118. package/dist/lead-doors.d.ts +4 -0
  119. package/dist/lead-doors.js +2 -2
  120. package/dist/lead-progress.js +2 -0
  121. package/dist/lead-tools.js +111 -32
  122. package/dist/lead.js +1 -1
  123. package/dist/local-owner.js +5 -0
  124. package/dist/modes.d.ts +6 -6
  125. package/dist/modes.js +3 -1
  126. package/dist/needs-you.d.ts +5 -2
  127. package/dist/needs-you.js +14 -2
  128. package/dist/operate-remote-arguments.js +1 -0
  129. package/dist/operate-remote.js +1 -1
  130. package/dist/operate.d.ts +1 -1
  131. package/dist/operate.js +165 -14
  132. package/dist/plan-login.d.ts +14 -0
  133. package/dist/plan-login.js +58 -0
  134. package/dist/planner.d.ts +1 -1
  135. package/dist/planner.js +2 -1
  136. package/dist/project-delete-ui.d.ts +12 -0
  137. package/dist/project-delete-ui.js +18 -1
  138. package/dist/project-delete.js +6 -0
  139. package/dist/project-knowledge.d.ts +6 -6
  140. package/dist/project-tools.d.ts +4 -4
  141. package/dist/project-tools.js +23 -7
  142. package/dist/protected-storage-look.d.ts +1 -0
  143. package/dist/protected-storage-look.js +13 -0
  144. package/dist/protected-storage.d.ts +70 -39
  145. package/dist/protected-storage.js +196 -147
  146. package/dist/provider-capture.d.ts +34 -0
  147. package/dist/provider-capture.js +53 -0
  148. package/dist/release-carry.js +10 -1
  149. package/dist/release.js +9 -1
  150. package/dist/scope.d.ts +22 -3
  151. package/dist/scope.js +71 -20
  152. package/dist/scout.d.ts +1 -1
  153. package/dist/scout.js +2 -1
  154. package/dist/serve.js +2 -0
  155. package/dist/server/chat.js +2 -2
  156. package/dist/server/console.css +50 -0
  157. package/dist/server/render-chat.js +14 -1
  158. package/dist/server/render-follow-through.d.ts +11 -0
  159. package/dist/server/render-follow-through.js +68 -0
  160. package/dist/server/render-results.js +1 -1
  161. package/dist/server/render-tasks.d.ts +3 -0
  162. package/dist/server/render-tasks.js +24 -5
  163. package/dist/server/route-table.js +3 -0
  164. package/dist/server/settings.js +37 -12
  165. package/dist/server/task-views.js +5 -0
  166. package/dist/server/tasks.js +76 -0
  167. package/dist/settings-groups-ui.d.ts +1 -0
  168. package/dist/settings-groups-ui.js +3 -1
  169. package/dist/settings-kit.js +3 -2
  170. package/dist/settings-proofs.js +3 -1
  171. package/dist/setup-guide.d.ts +2 -2
  172. package/dist/slack-chat.js +14 -1
  173. package/dist/steer-mailbox.d.ts +8 -1
  174. package/dist/steer-mailbox.js +164 -6
  175. package/dist/store.d.ts +11 -4
  176. package/dist/store.js +93 -30
  177. package/dist/surface.js +24 -4
  178. package/dist/task-control.d.ts +2 -1
  179. package/dist/task-status.js +5 -0
  180. package/dist/teardown-git.d.ts +2 -0
  181. package/dist/teardown-git.js +42 -16
  182. package/dist/telegram-lead.js +23 -6
  183. package/dist/telegram.js +12 -1
  184. package/dist/toolroll-update.js +2 -0
  185. package/dist/work-index.js +22 -7
  186. package/dist/work-summary.d.ts +1 -1
  187. package/dist/work-summary.js +5 -2
  188. package/dist/workspace-ui.d.ts +3 -1
  189. package/dist/workspace-ui.js +10 -1
  190. package/dist/worktree.js +23 -1
  191. package/package.json +2 -1
@@ -0,0 +1,223 @@
1
+ import { html, joinHtml } from "./html.js";
2
+ const noteLines = (block) => [...block.items.map(one => `• ${one}`), ...(block.more > 0 ? [moreWords(block.more)] : [])];
3
+ const fit = (text, width) => text.length <= width ? text : `${text.slice(0, Math.max(1, width - 1))}…`;
4
+ /** Only parts filled from a named source are Toolroll's records; the line says so only when a card has one, and says
5
+ * which parts, so the lead's own words beside them never borrow that standing. */
6
+ const sourced = (card) => card.blocks.some(one => one.kind !== "text" && one.kind !== "divider");
7
+ /** A part marked as not Toolroll's records: the lead's note, or words an agent reported. */
8
+ const marked = (card) => card.blocks.some(one => one.kind === "text" || ((one.kind === "table" || one.kind === "notes") && one.origin === "report"));
9
+ const asOfWords = (card) => `${marked(card) ? "Parts not marked otherwise are from Toolroll's records" : "From Toolroll's records"} · ${card.asOf.slice(0, 16).replace("T", " ")} UTC`;
10
+ const footWords = (card) => sourced(card) ? [asOfWords(card)] : [];
11
+ const reportWords = "Reported by the agent, not Toolroll's records";
12
+ /** A Text part is the lead's (a model's) words: always labelled as such, on every app, never as a record. */
13
+ export const LEAD_NOTE_WORDS = "The lead's note, not Toolroll's records";
14
+ const moreWords = (more) => `and ${more} more`;
15
+ /** A part's heading, unless it only repeats the card's title. */
16
+ const headOf = (card, heading) => heading === card.title ? [] : [heading];
17
+ /** A table as aligned lines, narrow enough for a phone. */
18
+ function tableLines(block, maxWidth = 58) {
19
+ const widths = block.columns.map((column, at) => Math.max(column.label.length, ...block.rows.map(row => row[at].length)));
20
+ const budget = maxWidth - 2 * (widths.length - 1);
21
+ while (widths.reduce((a, b) => a + b, 0) > budget && Math.max(...widths) > 6)
22
+ widths[widths.indexOf(Math.max(...widths))] -= 1;
23
+ const line = (cells) => cells.map((one, at) => {
24
+ const shown = fit(one, widths[at]);
25
+ return block.columns[at].numeric ? shown.padStart(widths[at]) : shown.padEnd(widths[at]);
26
+ }).join(" ").trimEnd();
27
+ return [line(block.columns.map(one => one.label)), ...block.rows.map(line), ...(block.more > 0 ? [moreWords(block.more)] : [])];
28
+ }
29
+ /** A chart as bars of text: every number stays readable. */
30
+ function chartLines(block) {
31
+ const top = Math.max(...block.points.map(one => one.value), 0);
32
+ const name = Math.min(18, Math.max(...block.points.map(one => one.label.length)));
33
+ return block.points.map(one => `${fit(one.label, name).padEnd(name)} ${"█".repeat(top === 0 ? 0 : Math.round(one.value / top * 14)).padEnd(14)} ${one.shown}`);
34
+ }
35
+ /** The whole card as plain text: the version every app can show. */
36
+ export function cardPlainText(card) {
37
+ const parts = card.blocks.flatMap((block) => {
38
+ switch (block.kind) {
39
+ case "text": return [`${LEAD_NOTE_WORDS}:\n${block.text}`];
40
+ case "divider": return [];
41
+ case "facts": return [block.items.map(one => `${one.label}: ${one.value}`).join("\n")];
42
+ case "table": return [[...headOf(card, block.heading), ...(block.origin === "report" ? [reportWords] : []), ...tableLines(block)].join("\n")];
43
+ case "notes": return [[...headOf(card, block.heading), ...(block.origin === "report" ? [reportWords] : []), ...noteLines(block)].join("\n")];
44
+ case "chart": return [[...headOf(card, block.heading), ...chartLines(block)].join("\n")];
45
+ }
46
+ });
47
+ const under = card.actions.length === 0 ? [] : [`Confirm below: ${card.actions.map(one => one.label).join(", ")}`];
48
+ return [card.title, ...parts, ...footWords(card), ...under].join("\n\n");
49
+ }
50
+ // ---- Slack ---------------------------------------------------------------------------------------------------------
51
+ /** In a Slack code block only these need care: nothing inside can become a mention or a link, and it can't close early. */
52
+ const slackCode = (text) => text.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("`", "'");
53
+ const SLACK_CHART_LABEL = 20;
54
+ const SLACK_CHART_TITLE = 50;
55
+ function slackChart(block) {
56
+ const unique = new Set(block.points.map(one => one.label)).size === block.points.length;
57
+ if (block.heading.length > SLACK_CHART_TITLE || !unique || block.points.some(one => one.label.length > SLACK_CHART_LABEL))
58
+ return null;
59
+ if (block.chart === "pie") {
60
+ if (block.points.some(one => one.value <= 0))
61
+ return null;
62
+ return { type: "data_visualization", title: block.heading, chart: { type: "pie", segments: block.points.map(({ label, value }) => ({ label, value })) } };
63
+ }
64
+ return { type: "data_visualization", title: block.heading, chart: { type: block.chart, series: [{ name: block.heading.slice(0, SLACK_CHART_LABEL), data: block.points.map(({ label, value }) => ({ label, value })) }], axis_config: { categories: block.points.map(one => one.label) } } };
65
+ }
66
+ /** The card as Block Kit, native where Slack has it: one data table and two charts a message, the rest as code blocks. */
67
+ export function slackCardBlocks(card, buttons) {
68
+ const out = [{ type: "header", text: { type: "plain_text", text: fit(card.title, 150), emoji: false } }];
69
+ const code = (lines) => ({ type: "section", text: { type: "mrkdwn", text: `\`\`\`${slackCode(lines.join("\n")).slice(0, 2900)}\`\`\``, verbatim: true } });
70
+ let tables = 0, charts = 0;
71
+ for (const block of card.blocks) {
72
+ if (block.kind === "text") {
73
+ out.push({ type: "context", elements: [{ type: "plain_text", text: LEAD_NOTE_WORDS, emoji: false }] });
74
+ out.push({ type: "section", text: { type: "plain_text", text: block.text, emoji: false } });
75
+ }
76
+ else if (block.kind === "divider")
77
+ out.push({ type: "divider" });
78
+ else if (block.kind === "notes") {
79
+ if (block.origin === "report")
80
+ out.push({ type: "context", elements: [{ type: "plain_text", text: reportWords, emoji: false }] });
81
+ out.push({ type: "section", text: { type: "plain_text", text: noteLines(block).join("\n").slice(0, 2900), emoji: false } });
82
+ }
83
+ else if (block.kind === "facts")
84
+ out.push({ type: "section", fields: block.items.map(one => ({ type: "plain_text", text: `${one.label}: ${one.value || " "}`, emoji: false })) });
85
+ else if (block.kind === "table") {
86
+ if (block.origin === "report")
87
+ out.push({ type: "context", elements: [{ type: "plain_text", text: reportWords, emoji: false }] });
88
+ if (tables === 0 && block.rows.length > 0) {
89
+ tables++;
90
+ const cell = (text) => ({ type: "raw_text", text: text === "" ? " " : text });
91
+ out.push({ type: "data_table", caption: block.heading, rows: [block.columns.map(one => cell(one.label)), ...block.rows.map(row => row.map(cell))] });
92
+ if (block.more > 0)
93
+ out.push({ type: "context", elements: [{ type: "plain_text", text: moreWords(block.more), emoji: false }] });
94
+ }
95
+ else
96
+ out.push(code([...headOf(card, block.heading), ...tableLines(block)]));
97
+ }
98
+ else {
99
+ const native = charts < 2 ? slackChart(block) : null;
100
+ if (native !== null) {
101
+ charts++;
102
+ out.push(native);
103
+ }
104
+ else
105
+ out.push(code([...headOf(card, block.heading), ...chartLines(block)]));
106
+ }
107
+ }
108
+ const under = card.actions.length === 0 ? "" : ` · Confirm below: ${card.actions.map(one => one.label).join(", ")}`;
109
+ const foot = `${footWords(card).join("")}${under}`.replace(/^ · /, "");
110
+ if (foot !== "")
111
+ out.push({ type: "context", elements: [{ type: "plain_text", text: fit(foot, 300), emoji: false }] });
112
+ if (buttons.length > 0)
113
+ out.push({ type: "actions", elements: buttons });
114
+ return out;
115
+ }
116
+ // ---- Discord -------------------------------------------------------------------------------------------------------
117
+ const discordCode = (text) => text.replaceAll("`", "'");
118
+ /** The card as one embed: facts as fields, tables and charts as code blocks. `plain` escapes Discord's own syntax. */
119
+ export function discordCardEmbed(card, plain) {
120
+ const fields = [];
121
+ const text = [];
122
+ for (const block of card.blocks) {
123
+ if (block.kind === "text")
124
+ text.push(`${plain(LEAD_NOTE_WORDS)}:\n${plain(block.text)}`);
125
+ else if (block.kind === "facts")
126
+ fields.push(...block.items.map(one => ({ name: plain(one.label), value: plain(one.value) || "​", inline: true })));
127
+ else if (block.kind === "notes")
128
+ fields.push({ name: plain(block.heading), value: `${block.origin === "report" ? `${reportWords}\n` : ""}${plain(noteLines(block).join("\n")).slice(0, 960)}` });
129
+ else if (block.kind === "table" || block.kind === "chart") {
130
+ const lines = block.kind === "table" ? tableLines(block, 50) : chartLines(block);
131
+ const origin = block.kind === "table" && block.origin === "report" ? `${reportWords}\n` : "";
132
+ fields.push({ name: plain(block.heading), value: `${origin}\`\`\`\n${discordCode(lines.join("\n")).slice(0, 960)}\n\`\`\`` });
133
+ }
134
+ }
135
+ const under = card.actions.length === 0 ? "" : ` · Confirm below: ${card.actions.map(one => one.label).join(", ")}`;
136
+ const foot = `${footWords(card).join("")}${under}`.replace(/^ · /, "");
137
+ return { title: plain(card.title).slice(0, 256), ...(text.length === 0 ? {} : { description: text.join("\n\n").slice(0, 4096) }), fields: fields.slice(0, 25), ...(foot === "" ? {} : { footer: { text: foot.slice(0, 2048) } }), color: 0x297b70 };
138
+ }
139
+ // ---- Telegram ------------------------------------------------------------------------------------------------------
140
+ /** The card as one Telegram message: plain words, tables and charts as `pre` entities, so nothing in a value is parsed. */
141
+ export function telegramCardMessage(card) {
142
+ const entities = [];
143
+ let text = "";
144
+ const add = (words, type) => {
145
+ if (text !== "")
146
+ text += "\n\n";
147
+ if (type !== undefined)
148
+ entities.push({ type, offset: text.length, length: words.length });
149
+ text += words;
150
+ };
151
+ add(card.title, "bold");
152
+ for (const block of card.blocks) {
153
+ if (block.kind === "text") {
154
+ add(LEAD_NOTE_WORDS, "bold");
155
+ add(block.text);
156
+ }
157
+ else if (block.kind === "facts")
158
+ add(block.items.map(one => `${one.label}: ${one.value}`).join("\n"));
159
+ else if (block.kind === "notes") {
160
+ for (const one of headOf(card, block.heading))
161
+ add(one, "bold");
162
+ if (block.origin === "report")
163
+ add(reportWords);
164
+ add(noteLines(block).join("\n"));
165
+ }
166
+ else if (block.kind === "table") {
167
+ for (const one of headOf(card, block.heading))
168
+ add(one, "bold");
169
+ if (block.origin === "report")
170
+ add(reportWords);
171
+ add(tableLines(block, 40).join("\n"), "pre");
172
+ }
173
+ else if (block.kind === "chart") {
174
+ for (const one of headOf(card, block.heading))
175
+ add(one, "bold");
176
+ add(chartLines(block).join("\n"), "pre");
177
+ }
178
+ }
179
+ for (const one of footWords(card))
180
+ add(one);
181
+ if (card.actions.length > 0)
182
+ add(`Confirm below: ${card.actions.map(one => one.label).join(", ")}`);
183
+ // Telegram's own limit: past it, the whole plain version, cut, with no entities that could point past the end.
184
+ return text.length <= 4000 ? { text, entities } : { text: cardPlainText(card).slice(0, 4000), entities: [] };
185
+ }
186
+ // ---- web chat ------------------------------------------------------------------------------------------------------
187
+ const CHART_COLORS = 6;
188
+ function webChart(block) {
189
+ const top = Math.max(...block.points.map(one => one.value), 0) || 1;
190
+ const summary = block.points.map(one => `${one.label}: ${one.shown}`).join(", ");
191
+ if (block.chart === "bar") {
192
+ return html `<ul class="tc-bars" aria-label="${block.heading}">${joinHtml(block.points.map(one => html `<li><span class="tc-bar-label">${one.label}</span><span class="tc-bar-track"><span class="tc-bar-fill" style="width:${Math.max(1, Math.round(one.value / top * 100))}%"></span></span><span class="tc-bar-value">${one.shown}</span></li>`))}</ul>`;
193
+ }
194
+ if (block.chart === "pie") {
195
+ const total = block.points.reduce((sum, one) => sum + one.value, 0) || 1;
196
+ let at = 0;
197
+ const slices = block.points.map((one, index) => {
198
+ const share = one.value / total * 100, from = at;
199
+ at += share;
200
+ return html `<circle class="tc-slice tc-c${index % CHART_COLORS}" r="15.915" cx="20" cy="20" fill="none" stroke-width="7" pathLength="100" stroke-dasharray="${share.toFixed(3)} ${(100 - share).toFixed(3)}" stroke-dashoffset="${(25 - from).toFixed(3)}"></circle>`;
201
+ });
202
+ return html `<div class="tc-pie"><svg viewBox="0 0 40 40" role="img" aria-label="${`${block.heading}. ${summary}`}">${joinHtml(slices)}</svg><ul class="tc-legend">${joinHtml(block.points.map((one, index) => html `<li><span class="tc-key tc-c${index % CHART_COLORS}"></span>${one.label}<strong>${one.shown}</strong></li>`))}</ul></div>`;
203
+ }
204
+ const width = 300, height = 90, step = block.points.length > 1 ? width / (block.points.length - 1) : 0;
205
+ const xy = block.points.map((one, index) => [Math.round(index * step), Math.round(height - one.value / top * (height - 6) - 2)]);
206
+ const line = xy.map(([x, y]) => `${x},${y}`).join(" ");
207
+ return html `<figure class="tc-line"><svg viewBox="0 0 ${width} ${height}" preserveAspectRatio="none" role="img" aria-label="${`${block.heading}. ${summary}`}">${block.chart === "area" ? html `<polygon class="tc-area" points="0,${height} ${line} ${width},${height}"></polygon>` : ""}<polyline class="tc-stroke" points="${line}" fill="none" vector-effect="non-scaling-stroke"></polyline></svg><figcaption>${summary}</figcaption></figure>`;
208
+ }
209
+ const partHead = (card, heading) => joinHtml(headOf(card, heading).map(one => html `<h4>${one}</h4>`));
210
+ /** The card as the web chat's own markup; every value is escaped by `html`. */
211
+ export function webCardHtml(card) {
212
+ const blocks = card.blocks.map(block => {
213
+ switch (block.kind) {
214
+ case "text": return html `<div class="tc-lead"><p class="tc-note">${LEAD_NOTE_WORDS}</p><p class="tc-text">${block.text}</p></div>`;
215
+ case "divider": return html `<hr class="tc-rule">`;
216
+ case "facts": return html `<dl class="tc-facts">${joinHtml(block.items.map(one => html `<div><dt>${one.label}</dt><dd>${one.value}</dd></div>`))}</dl>`;
217
+ case "table": return html `<div class="tc-part">${partHead(card, block.heading)}${block.origin === "report" ? html `<p class="tc-note">${reportWords}</p>` : ""}<div class="tc-scroll"><table class="tc-table"><thead><tr>${joinHtml(block.columns.map(one => html `<th scope="col" class="${one.numeric ? "tc-num" : ""}">${one.label}</th>`))}</tr></thead><tbody>${joinHtml(block.rows.map(row => html `<tr>${joinHtml(row.map((one, at) => html `<td class="${block.columns[at].numeric ? "tc-num" : ""}">${one}</td>`))}</tr>`))}</tbody></table></div>${block.more > 0 ? html `<p class="tc-note">${moreWords(block.more)}</p>` : ""}</div>`;
218
+ case "notes": return html `<div class="tc-part">${partHead(card, block.heading)}${block.origin === "report" ? html `<p class="tc-note">${reportWords}</p>` : ""}<ul class="tc-notes">${joinHtml(block.items.map(one => html `<li>${one}</li>`))}</ul>${block.more > 0 ? html `<p class="tc-note">${moreWords(block.more)}</p>` : ""}</div>`;
219
+ case "chart": return html `<div class="tc-part">${partHead(card, block.heading)}${webChart(block)}</div>`;
220
+ }
221
+ });
222
+ return html `<div class="tc-card">${joinHtml(blocks)}${joinHtml(footWords(card).map(one => html `<p class="tc-note">${one}</p>`))}</div>`;
223
+ }
@@ -0,0 +1,68 @@
1
+ import type { CardSourceName } from "./card-catalog.js";
2
+ import type { Integration } from "./integrations.js";
3
+ import { type Store } from "./store.js";
4
+ export type CardReader = {
5
+ store: Store;
6
+ now: Date;
7
+ who: {
8
+ name: string;
9
+ repos: readonly string[];
10
+ };
11
+ /** Where saved evidence lives; absent, the handover note can't be read here. */
12
+ evidenceRoot?: string;
13
+ /** Reads a chat turn already has; absent, the source says it can't be read here. */
14
+ integrations?: () => readonly Integration[];
15
+ providers?: (repos: readonly string[]) => readonly {
16
+ project: string;
17
+ provider: string;
18
+ model: string;
19
+ roles: readonly string[];
20
+ state: string;
21
+ }[];
22
+ workers?: (repos: readonly string[]) => readonly {
23
+ name: string;
24
+ state: string;
25
+ capacity: number;
26
+ running: number;
27
+ }[];
28
+ };
29
+ export type CardQuery = {
30
+ repo: string | null;
31
+ task?: string;
32
+ state?: string;
33
+ limit?: number;
34
+ flow?: number;
35
+ days?: number;
36
+ };
37
+ export type CardColumn = {
38
+ id: string;
39
+ label: string;
40
+ numeric?: true;
41
+ };
42
+ export type CardFact = {
43
+ id: string;
44
+ label: string;
45
+ value: string;
46
+ };
47
+ export type CardPoint = {
48
+ label: string;
49
+ value: number;
50
+ shown: string;
51
+ };
52
+ export type CardData = {
53
+ heading: string; /** `report`: saved model or repository words, shown as such. */
54
+ origin?: "records" | "report";
55
+ notes?: readonly string[];
56
+ repos: readonly string[];
57
+ columns: readonly CardColumn[];
58
+ rows: readonly Record<string, string>[];
59
+ more: number;
60
+ facts: readonly CardFact[];
61
+ points: readonly CardPoint[];
62
+ };
63
+ type Read = CardData | {
64
+ problem: string;
65
+ };
66
+ /** One source, read now as this person. */
67
+ export declare function readCardSource(reader: CardReader, name: CardSourceName, query: CardQuery): Read;
68
+ export {};
@@ -0,0 +1,234 @@
1
+ /**
2
+ * The named data a card can show (docs/plans/toolroll-cards.md): Toolroll fills these from its own records, with the same
3
+ * reach as the page or lead tool each one mirrors, so a model chooses what to show and never what it says.
4
+ *
5
+ * Every value goes through publicChatText here, once, before it can reach any platform.
6
+ */
7
+ import { basename } from "node:path";
8
+ import { publicChatText } from "./chat-display.js";
9
+ import { CARD_LIMITS } from "./contracts/chat-card.js";
10
+ import { familyInReach, findingLine, findingsInReach, findingSourceWords, findingStandingWords } from "./findings.js";
11
+ import { flowInsights } from "./flow-insights.js";
12
+ import { handoverLines, handoverNoteOf } from "./handover-note.js";
13
+ import { projectOutcomes } from "./project-outcomes.js";
14
+ import { monthOf } from "./spend.js";
15
+ import { spendReport, spendRows } from "./spend-report.js";
16
+ import { refArchived } from "./store.js";
17
+ const clean = (value, cap = CARD_LIMITS.cell) => publicChatText(String(value), cap);
18
+ const dollars = (micro) => `$${(micro / 1_000_000).toFixed(2)}`;
19
+ const ago = (from, now) => {
20
+ const hours = Math.max(0, Math.round((now.getTime() - Date.parse(from)) / 3_600_000));
21
+ return hours < 48 ? `${hours}h` : `${Math.round(hours / 24)}d`;
22
+ };
23
+ const projectName = (repo) => repo === null ? "Toolroll" : clean(basename(repo), 40);
24
+ /** An account limited to some projects sees the instance-wide pages' data no more than the console shows it. */
25
+ const instanceWide = (reader) => reader.store.accountOf(reader.who.name)?.projects === null;
26
+ const INSTANCE_ONLY = { problem: "That needs an account with access to every project, as the page does." };
27
+ const count = (points) => points.filter(([, value]) => value > 0).slice(0, CARD_LIMITS.points).map(([label, value]) => ({ label: clean(label, CARD_LIMITS.label), value, shown: String(value) }));
28
+ const reposOf = (reader, query) => query.repo === null ? reader.who.repos : [query.repo];
29
+ function tasks(reader, query) {
30
+ const { store } = reader, repos = reposOf(reader, query), limit = query.limit ?? 10;
31
+ const states = query.state === undefined ? {} : { states: [query.state] };
32
+ const all = store.taskFamiliesAdmitted(repos, false, { limit: 500, order: "created", ...states }).filter(one => !refArchived(store, one.current.refId));
33
+ const counts = new Map();
34
+ for (const one of all)
35
+ counts.set(one.current.state, (counts.get(one.current.state) ?? 0) + 1);
36
+ const shownCount = all.length >= 500 ? "Latest 500 tasks" : "Tasks";
37
+ return {
38
+ heading: query.state === undefined ? shownCount : `${query.state.charAt(0).toUpperCase()}${query.state.slice(1)} tasks`, repos,
39
+ columns: [{ id: "title", label: "Task" }, { id: "state", label: "State" }, { id: "project", label: "Project" }, { id: "age", label: "Updated" }, { id: "strikes", label: "Failed attempts", numeric: true }],
40
+ rows: all.slice(0, limit).map(one => ({ title: clean(one.root.title), state: clean(one.current.state), project: projectName(one.current.repo), age: `${ago(one.current.updatedAt, reader.now)} ago`, strikes: String(store.lookupRef(one.current.id)?.strikes ?? 0) })),
41
+ more: Math.max(0, all.length - limit),
42
+ facts: [{ id: "total", label: "Total", value: String(all.length) }, ...[...counts].map(([state, value]) => ({ id: state, label: `${state.charAt(0).toUpperCase()}${state.slice(1)}`, value: String(value) }))],
43
+ points: count([...counts].sort((a, b) => b[1] - a[1])),
44
+ };
45
+ }
46
+ function results(reader, query) {
47
+ const repos = reposOf(reader, query).slice(0, 8), limit = query.limit ?? 10;
48
+ const access = { principal: "operator", repos: [...reader.who.repos] };
49
+ const reports = repos.map(repo => ({ repo, report: projectOutcomes(reader.store, repo, reader.now, access) }));
50
+ const rows = reports.flatMap(({ repo, report }) => report.families.map(one => ({ one, repo }))).sort((a, b) => (b.one.finishedAt ?? "").localeCompare(a.one.finishedAt ?? ""));
51
+ const sum = (read) => reports.reduce((total, one) => total + read(one.report), 0);
52
+ const recorded = reports.flatMap(one => one.report.cost.recordedUsd === null ? [] : [one.report.cost.recordedUsd]);
53
+ const ready = sum(report => report.ready.count), done = sum(report => report.completed.count), revisions = sum(report => report.revisions.count);
54
+ return {
55
+ heading: "Results, last 60 days", repos,
56
+ columns: [{ id: "title", label: "Task" }, { id: "state", label: "State" }, { id: "checks", label: "Checks" }, { id: "project", label: "Project" }, { id: "cost", label: "Cost", numeric: true }],
57
+ rows: rows.slice(0, limit).map(({ one, repo }) => ({ title: clean(one.title), state: clean(one.state), checks: clean(one.checks), project: projectName(repo), cost: one.totalUsd === null ? "not recorded" : `$${one.totalUsd.toFixed(2)}` })),
58
+ more: Math.max(0, rows.length - limit),
59
+ facts: [{ id: "ready", label: "Ready", value: String(ready) }, { id: "completed", label: "Completed", value: String(done) }, { id: "revisions", label: "Revisions", value: String(revisions) },
60
+ ...(recorded.length === 0 ? [] : [{ id: "cost", label: "Recorded cost", value: `$${recorded.reduce((a, b) => a + b, 0).toFixed(2)}` }])],
61
+ points: count([["Ready", ready], ["Completed", done]]),
62
+ };
63
+ }
64
+ function flows(reader, query) {
65
+ const { store } = reader, repos = reposOf(reader, query), limit = query.limit ?? 10;
66
+ const list = store.listFlows(repos).filter(one => one.state === "active" && reader.who.repos.includes(one.repo) && store.accountCanAccess(reader.who.name, one.repo));
67
+ const seen = list.map(flow => { const cards = store.flowCards(flow.id, true); return { flow, active: cards.filter(one => one.state === "active").length, finished: cards.filter(one => one.state !== "active").length }; });
68
+ return {
69
+ heading: "Flows", repos,
70
+ columns: [{ id: "name", label: "Flow" }, { id: "project", label: "Project" }, { id: "active", label: "Active cards", numeric: true }, { id: "finished", label: "Finished cards", numeric: true }],
71
+ rows: seen.slice(0, limit).map(one => ({ name: clean(one.flow.name), project: projectName(one.flow.repo), active: String(one.active), finished: String(one.finished) })),
72
+ more: Math.max(0, seen.length - limit),
73
+ facts: [{ id: "flows", label: "Flows", value: String(seen.length) }, { id: "active", label: "Active cards", value: String(seen.reduce((sum, one) => sum + one.active, 0)) }],
74
+ points: count(seen.map(one => [one.flow.name, one.active])),
75
+ };
76
+ }
77
+ function flowZones(reader, query) {
78
+ const { store } = reader, days = query.days ?? 30;
79
+ if (query.flow === undefined)
80
+ return { problem: "Name the flow: flow_zones needs flow, its number from get_flows." };
81
+ const flow = store.getFlow(query.flow);
82
+ if (flow === null || flow.state !== "active" || !reader.who.repos.includes(flow.repo) || !store.accountCanAccess(reader.who.name, flow.repo))
83
+ return { problem: "No such flow in your projects." };
84
+ const seen = flowInsights(store, flow, reader.now, days), limit = query.limit ?? CARD_LIMITS.rows;
85
+ return {
86
+ heading: clean(`${flow.name}, last ${days} days`, CARD_LIMITS.heading), repos: [flow.repo],
87
+ columns: [{ id: "step", label: "Step" }, { id: "entered", label: "Arrived", numeric: true }, { id: "failed", label: "Failed", numeric: true }, { id: "sentBack", label: "Sent back", numeric: true }, { id: "here", label: "Here now", numeric: true }, { id: "typical", label: "Typical stay", numeric: true }],
88
+ rows: seen.zones.slice(0, limit).map(one => ({ step: clean(one.title), entered: String(one.entered), failed: String(one.failed), sentBack: String(one.sentBack), here: String(one.here), typical: one.typicalMinutes === null ? "none yet" : `${Math.round(one.typicalMinutes)} min` })),
89
+ more: Math.max(0, seen.zones.length - limit),
90
+ facts: [{ id: "started", label: "Started", value: String(seen.cards.started) }, { id: "finished", label: "Finished", value: String(seen.cards.finished) }, { id: "active", label: "Active", value: String(seen.cards.active) }],
91
+ points: count(seen.zones.map(one => [one.title, one.entered])),
92
+ };
93
+ }
94
+ function spend(reader, query) {
95
+ const { store, now } = reader;
96
+ // As /spend: an instance operator's page.
97
+ if (!store.isInstanceOperator(reader.who.name))
98
+ return { problem: "Only an instance operator sees spend." };
99
+ const month = monthOf(now), report = spendReport(spendRows(store.handle, month.from, month.to)), limit = query.limit ?? 10;
100
+ const cost = (line) => line.apiMicrousd + line.estimatedMicrousd;
101
+ const lines = [...report.byProject].filter(one => query.repo === null || one.key === query.repo).sort((a, b) => cost(b) - cost(a));
102
+ return {
103
+ heading: clean(`Spend, ${month.name}`, CARD_LIMITS.heading), repos: reader.who.repos,
104
+ columns: [{ id: "project", label: "Project" }, { id: "api", label: "API dollars", numeric: true }, { id: "estimated", label: "Estimated", numeric: true }, { id: "work", label: "Pieces of work", numeric: true }],
105
+ rows: lines.slice(0, limit).map(one => ({ project: projectName(one.key), api: dollars(one.apiMicrousd), estimated: dollars(one.estimatedMicrousd), work: String(one.count) })),
106
+ more: Math.max(0, lines.length - limit),
107
+ facts: [{ id: "api", label: "API dollars", value: dollars(report.totals.apiMicrousd) }, { id: "estimated", label: "Estimated", value: dollars(report.totals.estimatedMicrousd) }, { id: "work", label: "Pieces of work", value: String(report.totals.count) }],
108
+ points: lines.filter(one => cost(one) > 0).slice(0, CARD_LIMITS.points).map(one => ({ label: projectName(one.key), value: cost(one) / 1_000_000, shown: dollars(cost(one)) })),
109
+ };
110
+ }
111
+ function projects(reader, query) {
112
+ const { store, now } = reader, repos = reposOf(reader, query), limit = query.limit ?? 10;
113
+ const seen = repos.map(repo => {
114
+ const families = store.taskFamiliesAdmitted([repo], false, { limit: 500, order: "created" }).filter(one => !refArchived(store, one.current.refId));
115
+ const of = (state) => families.filter(one => one.current.state === state).length;
116
+ return { repo, running: of("running"), queued: of("queued"), failed: of("failed"), mode: store.activeMode(repo, now)?.name ?? "none" };
117
+ });
118
+ return {
119
+ heading: "Projects", repos,
120
+ columns: [{ id: "project", label: "Project" }, { id: "running", label: "Running", numeric: true }, { id: "queued", label: "Queued", numeric: true }, { id: "failed", label: "Failed", numeric: true }, { id: "mode", label: "Automatic approvals" }],
121
+ rows: seen.slice(0, limit).map(one => ({ project: projectName(one.repo), running: String(one.running), queued: String(one.queued), failed: String(one.failed), mode: clean(one.mode) })),
122
+ more: Math.max(0, seen.length - limit),
123
+ facts: [{ id: "projects", label: "Projects", value: String(seen.length) }, { id: "running", label: "Running", value: String(seen.reduce((sum, one) => sum + one.running, 0)) }, { id: "failed", label: "Failed", value: String(seen.reduce((sum, one) => sum + one.failed, 0)) }],
124
+ points: count(seen.map(one => [projectName(one.repo), one.running + one.queued])),
125
+ };
126
+ }
127
+ function builders(reader, query) {
128
+ if (!instanceWide(reader))
129
+ return INSTANCE_ONLY;
130
+ if (reader.workers === undefined)
131
+ return { problem: "Builders can't be read here." };
132
+ const repos = reposOf(reader, query), limit = query.limit ?? 10, list = reader.workers(repos);
133
+ return {
134
+ heading: "Builders", repos,
135
+ columns: [{ id: "name", label: "Builder" }, { id: "state", label: "State" }, { id: "capacity", label: "Capacity", numeric: true }, { id: "running", label: "Running", numeric: true }],
136
+ rows: list.slice(0, limit).map(one => ({ name: clean(one.name), state: clean(one.state), capacity: String(one.capacity), running: String(one.running) })),
137
+ more: Math.max(0, list.length - limit),
138
+ facts: [{ id: "builders", label: "Builders", value: String(list.length) }, { id: "capacity", label: "Capacity", value: String(list.reduce((sum, one) => sum + one.capacity, 0)) }],
139
+ points: count(list.map(one => [one.name, one.running])),
140
+ };
141
+ }
142
+ function mode(reader, query) {
143
+ if (!instanceWide(reader))
144
+ return INSTANCE_ONLY;
145
+ const { store, now } = reader, repos = reposOf(reader, query), limit = query.limit ?? 10;
146
+ const seen = repos.map(repo => ({ repo, mode: store.activeMode(repo, now) }));
147
+ return {
148
+ heading: "Automatic approvals", repos,
149
+ columns: [{ id: "project", label: "Project" }, { id: "mode", label: "Mode" }, { id: "by", label: "Signed by" }, { id: "until", label: "Ends" }],
150
+ rows: seen.slice(0, limit).map(one => ({ project: projectName(one.repo), mode: clean(one.mode?.name ?? "none"), by: clean(one.mode?.signedBy ?? ""), until: one.mode === null ? "" : clean(one.mode.absoluteExpiry.slice(0, 10)) })),
151
+ more: Math.max(0, seen.length - limit),
152
+ facts: [{ id: "on", label: "Projects with a mode", value: String(seen.filter(one => one.mode !== null).length) }, { id: "off", label: "Without", value: String(seen.filter(one => one.mode === null).length) }],
153
+ points: [],
154
+ };
155
+ }
156
+ function integrations(reader, query) {
157
+ if (!instanceWide(reader))
158
+ return INSTANCE_ONLY;
159
+ if (reader.integrations === undefined)
160
+ return { problem: "Integrations can't be read here." };
161
+ const list = reader.integrations(), limit = query.limit ?? CARD_LIMITS.rows;
162
+ const counts = new Map();
163
+ for (const one of list)
164
+ counts.set(one.state, (counts.get(one.state) ?? 0) + 1);
165
+ return {
166
+ heading: "Integrations", repos: reader.who.repos,
167
+ columns: [{ id: "name", label: "Integration" }, { id: "kind", label: "Kind" }, { id: "state", label: "State" }, { id: "account", label: "Account" }],
168
+ rows: list.slice(0, limit).map(one => ({ name: clean(one.name), kind: clean(one.group), state: clean(one.state), account: clean(one.account ?? "") })),
169
+ more: Math.max(0, list.length - limit),
170
+ facts: [...counts].slice(0, CARD_LIMITS.facts).map(([state, value]) => ({ id: state, label: clean(state, CARD_LIMITS.label), value: String(value) })),
171
+ points: count([...counts]),
172
+ };
173
+ }
174
+ function providers(reader, query) {
175
+ if (reader.providers === undefined)
176
+ return { problem: "Providers can't be read here." };
177
+ const repos = reposOf(reader, query).slice(0, 8), limit = query.limit ?? 10, list = reader.providers(repos);
178
+ return {
179
+ heading: "Agents and sign-ins", repos,
180
+ columns: [{ id: "provider", label: "Provider" }, { id: "model", label: "Model" }, { id: "roles", label: "Roles" }, { id: "state", label: "State" }, { id: "project", label: "Project" }],
181
+ rows: list.slice(0, limit).map(one => ({ provider: clean(one.provider), model: clean(one.model), roles: clean(one.roles.join(", ")), state: clean(one.state), project: projectName(one.project) })),
182
+ more: Math.max(0, list.length - limit),
183
+ facts: [{ id: "agents", label: "Agents", value: String(list.length) }],
184
+ points: [],
185
+ };
186
+ }
187
+ /** A finding's state as a person reads it in a card (never the stored token). */
188
+ const FINDING_STATE_WORDS = { open: "With the agent", accepted: "Accepted", rejected: "Rejected", "with-person": "Needs you", closed: "Closed" };
189
+ function findings(reader, query) {
190
+ const limit = query.limit ?? 10;
191
+ const list = findingsInReach(reader.store, reader.who, { ...(query.task === undefined ? {} : { task: query.task }), repos: reposOf(reader, query) });
192
+ if (list === null)
193
+ return { problem: "No such task in your projects." };
194
+ const counts = new Map();
195
+ for (const one of list)
196
+ counts.set(one.state, (counts.get(one.state) ?? 0) + 1);
197
+ return {
198
+ heading: query.task === undefined ? "Findings waiting" : "Findings", origin: "report", repos: [...new Set(list.map(one => one.repo))],
199
+ columns: [{ id: "id", label: "Finding", numeric: true }, { id: "where", label: "Where" }, { id: "state", label: "State" }, { id: "from", label: "From" }, { id: "task", label: "Task" }],
200
+ rows: list.slice(0, limit).map(one => ({ id: String(one.id), where: clean(one.file === null ? "" : `${one.file}${one.line === null ? "" : `:${one.line}`}`), state: FINDING_STATE_WORDS[one.state] ?? clean(one.state), from: clean(findingSourceWords(one.source)), task: clean(one.taskId) })),
201
+ more: Math.max(0, list.length - limit),
202
+ notes: list.map(one => clean(`${findingLine(one)} (${findingStandingWords(one)})`, CARD_LIMITS.note)),
203
+ facts: [{ id: "total", label: "Findings", value: String(list.length) }, ...[...counts].map(([state, value]) => ({ id: state, label: clean(state, CARD_LIMITS.label), value: String(value) }))],
204
+ points: count([...counts]),
205
+ };
206
+ }
207
+ function handover(reader, query) {
208
+ if (query.task === undefined)
209
+ return { problem: "Name the task: handover needs task." };
210
+ const repos = familyInReach(reader.store, reader.who, query.task);
211
+ if (repos === null)
212
+ return { problem: "No such task in your projects." };
213
+ if (reader.evidenceRoot === undefined)
214
+ return { problem: "The handover note can't be read here. Use toolroll task handover." };
215
+ const note = handoverNoteOf(reader.store, reader.evidenceRoot, query.task, reader.now);
216
+ if (note === null)
217
+ return { problem: "No such task in your projects." };
218
+ return {
219
+ heading: clean(`Handover: ${query.task}`, CARD_LIMITS.heading), origin: "report", repos,
220
+ columns: [{ id: "what", label: "Part" }, { id: "count", label: "Count", numeric: true }],
221
+ rows: [["Accepted decisions", note.acceptedDecisions.length], ["Rejected approaches", note.rejectedApproaches.length], ["Open findings", note.openFindings.length], ["Checks", note.verification.length]].map(([what, value]) => ({ what: String(what), count: String(value) })),
222
+ more: 0,
223
+ notes: handoverLines(note).map(one => clean(one, CARD_LIMITS.note)).filter(one => one !== ""),
224
+ facts: [{ id: "revision", label: "Revision", value: note.revision === null ? "none yet" : clean(note.revision.head.slice(0, 12)) }, { id: "next", label: "Next", value: clean(note.nextAction) }],
225
+ points: [],
226
+ };
227
+ }
228
+ const READERS = { tasks, results, flows, flow_zones: flowZones, spend, projects, builders, mode, integrations, providers, findings, handover };
229
+ /** One source, read now as this person. */
230
+ export function readCardSource(reader, name, query) {
231
+ if (query.repo !== null && !reader.who.repos.includes(query.repo))
232
+ return { problem: "That project is outside your access." };
233
+ return READERS[name](reader, query);
234
+ }