@nanocollective/roster 0.1.0-alpha.1

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 (89) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +129 -0
  3. package/dist/cli.js +5679 -0
  4. package/docs/README.md +99 -0
  5. package/docs/agents.md +163 -0
  6. package/docs/architecture.md +121 -0
  7. package/docs/commands.md +223 -0
  8. package/docs/concepts.md +112 -0
  9. package/docs/cost.md +61 -0
  10. package/docs/developing.md +147 -0
  11. package/docs/doctor-codes.md +74 -0
  12. package/docs/export.md +113 -0
  13. package/docs/extending.md +97 -0
  14. package/docs/getting-started.md +134 -0
  15. package/docs/hosting.md +72 -0
  16. package/docs/manual-steps.md +163 -0
  17. package/docs/memory.md +71 -0
  18. package/docs/org-yaml.md +143 -0
  19. package/docs/portal.md +342 -0
  20. package/docs/prompts.md +133 -0
  21. package/docs/security.md +122 -0
  22. package/docs/session-workflow.md +112 -0
  23. package/docs/staff-yaml.md +163 -0
  24. package/docs/troubleshooting.md +189 -0
  25. package/docs/upgrading.md +83 -0
  26. package/docs/writing-a-charter.md +83 -0
  27. package/package.json +60 -0
  28. package/templates/brain/.github/workflows/%%STAFF%%-daily.yaml +33 -0
  29. package/templates/brain/.github/workflows/%%STAFF%%-mention.yaml +65 -0
  30. package/templates/brain/.github/workflows/%%STAFF%%-pr-mention.yaml +50 -0
  31. package/templates/brain/CHARTER.md +49 -0
  32. package/templates/brain/README.md +18 -0
  33. package/templates/brain/drafts/README.md +7 -0
  34. package/templates/brain/log/decisions.md +6 -0
  35. package/templates/brain/memory/INDEX.md +28 -0
  36. package/templates/brain/staff.yaml +44 -0
  37. package/templates/brain/strategy/README.md +7 -0
  38. package/templates/briefs/amend.md +60 -0
  39. package/templates/briefs/charter.md +47 -0
  40. package/templates/briefs/discover.md +61 -0
  41. package/templates/briefs/voice.md +53 -0
  42. package/templates/ops/.github/workflows/session.yaml +333 -0
  43. package/templates/ops/agents.mjs +143 -0
  44. package/templates/ops/compose.mjs +333 -0
  45. package/templates/ops/org/guardrails.md +14 -0
  46. package/templates/ops/org/operating.md +82 -0
  47. package/templates/ops/org/voice.md +40 -0
  48. package/templates/ops/prompts/_identity.md +14 -0
  49. package/templates/ops/prompts/_paths.md +15 -0
  50. package/templates/ops/prompts/daily.md +82 -0
  51. package/templates/ops/prompts/mention.md +53 -0
  52. package/templates/ops/prompts/pr-mention.md +57 -0
  53. package/templates/ops/runner-plan.mjs +65 -0
  54. package/templates/portal/css/base.css +104 -0
  55. package/templates/portal/css/brain.css +106 -0
  56. package/templates/portal/css/diff.css +28 -0
  57. package/templates/portal/css/graph.css +34 -0
  58. package/templates/portal/css/health.css +41 -0
  59. package/templates/portal/css/inbox.css +79 -0
  60. package/templates/portal/css/layout.css +98 -0
  61. package/templates/portal/css/markdown.css +54 -0
  62. package/templates/portal/css/setup.css +106 -0
  63. package/templates/portal/index.html +55 -0
  64. package/templates/portal/js/api.js +74 -0
  65. package/templates/portal/js/app.js +282 -0
  66. package/templates/portal/js/dialog.js +70 -0
  67. package/templates/portal/js/dom.js +106 -0
  68. package/templates/portal/js/icons.js +94 -0
  69. package/templates/portal/js/md.js +386 -0
  70. package/templates/portal/js/refresh.js +59 -0
  71. package/templates/portal/js/router.js +20 -0
  72. package/templates/portal/js/state.js +160 -0
  73. package/templates/portal/js/textdiff.js +96 -0
  74. package/templates/portal/js/views/app.js +128 -0
  75. package/templates/portal/js/views/brain.js +260 -0
  76. package/templates/portal/js/views/changed.js +157 -0
  77. package/templates/portal/js/views/checklist.js +87 -0
  78. package/templates/portal/js/views/docs.js +84 -0
  79. package/templates/portal/js/views/files.js +95 -0
  80. package/templates/portal/js/views/graph.js +436 -0
  81. package/templates/portal/js/views/health.js +158 -0
  82. package/templates/portal/js/views/inbox.js +549 -0
  83. package/templates/portal/js/views/memory.js +135 -0
  84. package/templates/portal/js/views/org.js +175 -0
  85. package/templates/portal/js/views/paste.js +142 -0
  86. package/templates/portal/js/views/prompt.js +412 -0
  87. package/templates/portal/js/views/repos.js +92 -0
  88. package/templates/portal/js/views/setup.js +344 -0
  89. package/templates/portal/js/views/staff.js +290 -0
@@ -0,0 +1,412 @@
1
+ /* What a staff member is actually sent, and the files it was made of.
2
+ *
3
+ * The composition is `roster prompt <handle> --kind daily` on the CLI, and it was the one
4
+ * thing about these agents you could not see without a terminal. Composed by the tenant's own
5
+ * compose.mjs on the server, so there is no second implementation to drift.
6
+ *
7
+ * The distinction the screen exists to make: most of a charter is NOT in the prompt. The
8
+ * prompt tells the agent to go and read it. Editing CHARTER.md changes behaviour without
9
+ * changing a byte of the composed text, and nothing said so before.
10
+ */
11
+
12
+ import { getFile, post } from "../api.js";
13
+ import { askText } from "../dialog.js";
14
+ import { el, esc, grow, kb, toClipboard } from "../dom.js";
15
+ import { icon } from "../icons.js";
16
+ import { mdlite } from "../md.js";
17
+ import { S, staff, writeHash } from "../state.js";
18
+ import { diffStat, unifiedDiff } from "../textdiff.js";
19
+ import { renderDiff } from "./changed.js";
20
+
21
+ const KINDS = [
22
+ ["daily", "Daily", "the scheduled run"],
23
+ ["mention", "Mention", "someone typed @them in an issue"],
24
+ ["pr-mention", "PR mention", "someone typed @them on a pull request"],
25
+ ];
26
+
27
+ export function viewPrompt(m) {
28
+ const s = staff();
29
+ if (!S.promptKind) S.promptKind = "daily";
30
+
31
+ m.append(el("h1", { textContent: s.name + " · prompt" }));
32
+ const sub = el("p", { className: "sub", textContent: "Composing…" });
33
+ m.append(sub);
34
+
35
+ const kind = el("select");
36
+ for (const [id, label, why] of KINDS) {
37
+ kind.append(el("option", { value: id, textContent: label + " — " + why }));
38
+ }
39
+ kind.value = S.promptKind;
40
+ kind.onchange = () => {
41
+ S.promptKind = kind.value;
42
+ S.promptOpen = null;
43
+ writeHash(false);
44
+ load();
45
+ };
46
+ /* The point of the screen: you can see the prompt, and you can get help changing it
47
+ without first working out which of eight files to open. */
48
+ const help = el("button", { className: "ghbtn primary", textContent: "Copy a brief for changing this" });
49
+ help.onclick = () => copyAmend("");
50
+ const copy = el("button", { className: "ghbtn", textContent: "Copy the prompt" });
51
+ copy.onclick = () => {
52
+ if (view?.composed) toClipboard(view.composed, copy, "Copy the prompt");
53
+ };
54
+ const note = el("span", { className: "meta" });
55
+ m.append(el("div", { className: "row", style: "margin-bottom:16px" }, [kind, help, copy, note]));
56
+
57
+ /* Build the paste-ready brief on the server, where compose.mjs lives, and copy it.
58
+ A finding pre-fills the box rather than skipping it: what it wrote is a starting point,
59
+ and "and keep it in operating.md" is exactly the sort of thing you want to add. */
60
+ async function copyAmend(want, btn) {
61
+ const target = btn ?? help;
62
+ const label = target.textContent;
63
+ const asked = await askText({
64
+ title: "What do you want changed?",
65
+ hint:
66
+ "This goes at the top of a brief carrying the whole prompt and every file it is " +
67
+ "made of. Say it the way you would say it to a person.",
68
+ value: want,
69
+ placeholder: "stop opening decision issues for anything reversible",
70
+ confirm: "Copy the brief",
71
+ });
72
+ if (!asked) return;
73
+ target.disabled = true;
74
+ target.textContent = "building…";
75
+ try {
76
+ const url =
77
+ "/api/amend?staff=" + encodeURIComponent(s.handle) +
78
+ "&kind=" + encodeURIComponent(S.promptKind) +
79
+ "&want=" + encodeURIComponent(asked);
80
+ const text = await (await fetch(url, { cache: "no-store" })).text();
81
+ target.disabled = false;
82
+ toClipboard(text, target, label);
83
+ note.textContent = "paste it into whatever agent you use";
84
+ } catch (e) {
85
+ target.disabled = false;
86
+ target.textContent = label;
87
+ note.textContent = e.message;
88
+ note.className = "meta err";
89
+ }
90
+ }
91
+
92
+ const split = el("div", { className: "split" });
93
+ const tree = el("div", { className: "tree" });
94
+ const viewer = el("div", { className: "viewer" });
95
+ split.append(tree, viewer);
96
+ m.append(split);
97
+
98
+ let view = null;
99
+ load();
100
+
101
+ async function load() {
102
+ tree.replaceChildren(el("p", { className: "empty", textContent: "Composing…" }));
103
+ viewer.replaceChildren();
104
+ try {
105
+ const url = "/api/prompt?staff=" + encodeURIComponent(s.handle) + "&kind=" + S.promptKind;
106
+ view = await (await fetch(url, { cache: "no-store" })).json();
107
+ } catch (e) {
108
+ tree.replaceChildren(el("p", { className: "empty err", textContent: e.message }));
109
+ return;
110
+ }
111
+ if (view.error) {
112
+ sub.textContent = "This prompt does not compose.";
113
+ tree.replaceChildren();
114
+ viewer.replaceChildren(
115
+ el("div", { className: "notice" }, [
116
+ "compose.mjs refused: " + view.error,
117
+ ]),
118
+ );
119
+ return;
120
+ }
121
+ paint();
122
+ }
123
+
124
+ function paint() {
125
+ const chars = view.composed.length;
126
+ const words = view.composed.split(/\s+/).filter(Boolean).length;
127
+ sub.innerHTML =
128
+ "What <b>" + esc(s.name) + "</b> is sent on a <b>" + esc(S.promptKind) +
129
+ "</b> run: " + words.toLocaleString() + " words, " + chars.toLocaleString() +
130
+ " characters, assembled from " + view.layers.filter((l) => !l.missing).length +
131
+ " files. This is the text, not a description of it.";
132
+
133
+ tree.replaceChildren();
134
+
135
+ const composed = group("The prompt");
136
+ composed.append(row({ key: "composed", label: "Composed", right: kb(chars) }));
137
+ tree.append(composed);
138
+
139
+ const inlined = group("Inlined, in order");
140
+ for (const l of view.layers) inlined.append(layerRow(l));
141
+ tree.append(inlined);
142
+
143
+ if (view.problems?.length) {
144
+ const bad = group("Problems", String(view.problems.length));
145
+ for (const p of view.problems) bad.append(problemRow(p));
146
+ tree.append(bad);
147
+ }
148
+
149
+ const named = group("Named, not inlined");
150
+ named.append(
151
+ el("p", {
152
+ className: "treenote",
153
+ textContent:
154
+ "The prompt tells the agent to open these. Editing one changes what it does without changing the prompt.",
155
+ }),
156
+ );
157
+ for (const l of view.runtime) named.append(layerRow(l));
158
+ tree.append(named);
159
+
160
+ open(S.promptOpen ?? "composed");
161
+ }
162
+
163
+ /* A finding is only half useful. The other half is the sentence that goes into the brief,
164
+ which is why every one of them carries a `want`. */
165
+ function problemRow(p) {
166
+ const b = el("div", { className: "prob " + p.level });
167
+ b.append(el("div", { className: "ptitle", textContent: p.title }));
168
+ b.append(el("div", { className: "pdetail", textContent: p.detail }));
169
+ const fix = el("button", { className: "ghbtn", textContent: "Copy a prompt to fix this" });
170
+ fix.onclick = () => copyAmend(p.want, fix);
171
+ const row = el("div", { className: "row", style: "margin-top:8px" }, [fix]);
172
+ if (p.path) {
173
+ row.append(
174
+ el("button", {
175
+ className: "ghbtn",
176
+ textContent: "Open the file",
177
+ onclick: () => open(p.path),
178
+ }),
179
+ );
180
+ }
181
+ b.append(row);
182
+ return b;
183
+ }
184
+
185
+ function group(title, right) {
186
+ const g = el("div", { className: "navgroup" });
187
+ const h = el("div", { className: "ghead", textContent: title });
188
+ if (right) h.append(el("i", { textContent: right }));
189
+ g.append(h);
190
+ return g;
191
+ }
192
+
193
+ function layerRow(l) {
194
+ return row({
195
+ key: l.path,
196
+ label: l.rel.replace(/^staff:/, ""),
197
+ right: l.missing ? "absent" : kb(l.bytes),
198
+ note: l.repo,
199
+ dim: l.missing,
200
+ });
201
+ }
202
+
203
+ function row({ key, label, right, note, dim }) {
204
+ const b = el("button", { className: "tfile tlayer" + (dim ? " dim" : ""), title: key });
205
+ b.dataset.key = key;
206
+ const t = el("span", { className: "t" });
207
+ t.append(el("span", { className: "lname", textContent: label }));
208
+ if (note) t.append(el("span", { className: "lrepo", textContent: note }));
209
+ b.append(t);
210
+ if (right) b.append(el("i", { textContent: right }));
211
+ b.setAttribute("aria-current", String((S.promptOpen ?? "composed") === key));
212
+ b.onclick = () => open(key);
213
+ return b;
214
+ }
215
+
216
+ /* Returns a promise: `showLayer` fetches, and anything that wants to add to the pane after
217
+ opening a file has to wait for it. Prepending before the fetch resolved is why the
218
+ "what your edit did" strip appeared and then vanished. */
219
+ function open(key) {
220
+ S.promptOpen = key;
221
+ writeHash(false);
222
+ for (const o of tree.querySelectorAll(".tfile")) {
223
+ o.setAttribute("aria-current", String(o.dataset.key === key));
224
+ }
225
+ if (key === "composed") return showComposed();
226
+ const layer = [...view.layers, ...view.runtime].find((l) => l.path === key);
227
+ return layer ? showLayer(layer) : undefined;
228
+ }
229
+
230
+ /* ------------------------------ the prompt ----------------------------- */
231
+
232
+ function showComposed() {
233
+ viewer.replaceChildren();
234
+ const head = el("div", { className: "row", style: "margin-bottom:12px" });
235
+ head.append(
236
+ el("span", {
237
+ className: "meta",
238
+ textContent: "composed by " + S.data.name + "'s own compose.mjs · " + S.promptKind,
239
+ }),
240
+ );
241
+ const toggle = el("button", { className: "ghbtn", textContent: "Show source" });
242
+ head.append(toggle);
243
+ viewer.append(head);
244
+
245
+ const body = el("div", { className: "md doc" });
246
+ body.innerHTML = mdlite(view.composed);
247
+ viewer.append(body);
248
+
249
+ let source = false;
250
+ toggle.onclick = () => {
251
+ source = !source;
252
+ toggle.textContent = source ? "Show rendered" : "Show source";
253
+ if (source) {
254
+ body.className = "";
255
+ body.replaceChildren(el("pre", { className: "code", textContent: view.composed }));
256
+ } else {
257
+ body.className = "md doc";
258
+ body.replaceChildren();
259
+ body.innerHTML = mdlite(view.composed);
260
+ }
261
+ };
262
+ }
263
+
264
+ /** What the edit did, once, at the top of the pane: the commit, and the composed diff. */
265
+ function showEffect(saved, before, after) {
266
+ const box = el("div", { className: "effect" });
267
+ const head = el("div", { className: "row" });
268
+ head.append(
269
+ el("span", {
270
+ className: saved.pushed ? "meta ok" : "meta warn",
271
+ textContent: saved.pushed
272
+ ? "committed and pushed · " + saved.sha
273
+ : "committed " + saved.sha + ", but the push failed: " + (saved.note ?? ""),
274
+ }),
275
+ );
276
+
277
+ const stat = diffStat(before, after);
278
+ const patch = unifiedDiff(before, after, "the " + S.promptKind + " prompt");
279
+ if (!patch) {
280
+ head.append(
281
+ el("span", { className: "meta", textContent: "· the composed prompt is unchanged" }),
282
+ );
283
+ box.append(head);
284
+ viewer.prepend(box);
285
+ return;
286
+ }
287
+
288
+ const toggle = el("button", { className: "tevmore" });
289
+ toggle.setAttribute("aria-expanded", "false");
290
+ toggle.append(
291
+ icon("chevron", "caret"),
292
+ el("span", {
293
+ textContent:
294
+ "the " + S.promptKind + " prompt: +" + stat.added + " −" + stat.removed + " lines",
295
+ }),
296
+ );
297
+ const body = el("div", {}, renderDiff(patch, null));
298
+ body.hidden = true;
299
+ toggle.onclick = () => {
300
+ body.hidden = !body.hidden;
301
+ toggle.setAttribute("aria-expanded", String(!body.hidden));
302
+ };
303
+ box.append(head, toggle, body);
304
+ viewer.prepend(box);
305
+ }
306
+
307
+ /* ------------------------------ one layer ------------------------------ */
308
+
309
+ async function showLayer(layer) {
310
+ viewer.replaceChildren(el("p", { className: "empty", textContent: "Loading…" }));
311
+ if (layer.missing) {
312
+ viewer.replaceChildren(
313
+ el("p", {
314
+ className: "empty",
315
+ textContent: layer.optional
316
+ ? layer.rel + " is optional and this staff member does not have one."
317
+ : layer.rel + " is missing.",
318
+ }),
319
+ );
320
+ return;
321
+ }
322
+
323
+ let text;
324
+ try {
325
+ text = await getFile(layer.path);
326
+ } catch (e) {
327
+ viewer.replaceChildren(el("p", { className: "empty err", textContent: e.message }));
328
+ return;
329
+ }
330
+ render(layer, text);
331
+ }
332
+
333
+ function render(layer, text) {
334
+ viewer.replaceChildren();
335
+
336
+ const head = el("div", { className: "row", style: "margin-bottom:12px" });
337
+ head.append(
338
+ el("span", { className: "meta", textContent: layer.path + " · " + kb(text.length) }),
339
+ );
340
+ const editBtn = el("button", { className: "ghbtn", textContent: "Edit" });
341
+ if (layer.editable) head.append(editBtn);
342
+ viewer.append(head);
343
+
344
+ const body = el("div", { className: "md doc" });
345
+ body.innerHTML = mdlite(text);
346
+ viewer.append(body);
347
+
348
+ editBtn.onclick = () => edit(layer, text);
349
+ }
350
+
351
+ /* Editing writes to a repo the agents run from, so the confirm names the repo and says
352
+ who picks it up. `org/` reaches every staff member; a brain file reaches one. */
353
+ function edit(layer, text) {
354
+ viewer.replaceChildren();
355
+ const shared = /^(org|prompts)\//.test(layer.rel);
356
+ const reach = shared
357
+ ? "every staff member on their next run"
358
+ : s.name + " on their next run";
359
+
360
+ viewer.append(
361
+ el("div", { className: "row", style: "margin-bottom:10px" }, [
362
+ el("span", { className: "meta", textContent: layer.path }),
363
+ ]),
364
+ );
365
+
366
+ const ta = el("textarea", { value: text, className: "editor" });
367
+ viewer.append(ta);
368
+ grow(ta);
369
+ ta.addEventListener("input", () => grow(ta));
370
+
371
+ const status = el("span", { className: "meta" });
372
+ const save = el("button", { className: "ghbtn primary", textContent: "Save and commit" });
373
+ const cancel = el("button", { className: "ghbtn", textContent: "Cancel" });
374
+ viewer.append(
375
+ el("div", { className: "row", style: "margin-top:10px" }, [save, cancel, status]),
376
+ );
377
+ viewer.append(
378
+ el("p", {
379
+ className: "editnote",
380
+ textContent: "Commits to " + layer.repo + " and pushes. Picked up by " + reach + ".",
381
+ }),
382
+ );
383
+
384
+ cancel.onclick = () => render(layer, text);
385
+
386
+ save.onclick = async () => {
387
+ if (ta.value === text) {
388
+ status.textContent = "nothing changed";
389
+ return;
390
+ }
391
+ if (!confirm("Commit " + layer.path + " to " + layer.repo + " and push?\n\n" +
392
+ "Picked up by " + reach + ".")) return;
393
+ save.disabled = cancel.disabled = true;
394
+ status.textContent = "committing…";
395
+ status.className = "meta";
396
+ const before = view.composed;
397
+ try {
398
+ const r = await post({ path: layer.path, text: ta.value }, "/api/save");
399
+ // Recompose: the point of editing a layer is what it does to the prompt, and a file
400
+ // diff answers a question nobody asked. A line added to one fragment can land three
401
+ // times or not at all.
402
+ await load();
403
+ await open(layer.path);
404
+ showEffect(r, before, view.composed);
405
+ } catch (e) {
406
+ status.textContent = e.message;
407
+ status.className = "meta err";
408
+ save.disabled = cancel.disabled = false;
409
+ }
410
+ };
411
+ }
412
+ }
@@ -0,0 +1,92 @@
1
+ /* Which repos the staff operate in.
2
+ *
3
+ * `org.yaml` carries a `repos:` list, and a role on each: `product` is one they contribute to
4
+ * but do not own, which is what fills `works_in` on the next hire. Hand-editing that means
5
+ * spelling a repo name exactly right in a YAML file, which is the kind of thing that fails
6
+ * quietly and reads as the tool being broken. */
7
+
8
+ import { addRepo, listRepos } from "../api.js";
9
+ import { el } from "../dom.js";
10
+
11
+ /**
12
+ * @param {{org: string, known: string[], onAdded?: () => void}} opts
13
+ */
14
+ export function repoPicker(opts) {
15
+ const box = el("div", { className: "manual" });
16
+ box.append(
17
+ el("b", { textContent: "Which repos the staff work in" }),
18
+ el("p", {
19
+ textContent:
20
+ "Repos marked as products are the ones staff contribute to without owning. A new hire " +
21
+ "picks them up automatically.",
22
+ }),
23
+ );
24
+
25
+ const out = el("div", { style: "margin-top:9px" });
26
+ box.append(out);
27
+ out.replaceChildren(el("p", { className: "sub", textContent: "Reading your repos…" }));
28
+
29
+ listRepos(opts.org)
30
+ .then((data) => {
31
+ if (data.error) throw new Error(data.error);
32
+ const already = new Set(opts.known ?? []);
33
+ const candidates = (data.repos ?? []).filter((r) => !already.has(r.name));
34
+
35
+ if (!candidates.length) {
36
+ out.replaceChildren(
37
+ el("p", {
38
+ className: "sub",
39
+ textContent: (data.repos ?? []).length
40
+ ? "Everything your gh can see in this org is already listed."
41
+ : "Your gh cannot see any repos in this org. You can add them by hand in org.yaml.",
42
+ }),
43
+ );
44
+ return;
45
+ }
46
+
47
+ const list = el("div", { className: "repolist" });
48
+ for (const repo of candidates) {
49
+ const row = el("div", { className: "repo" });
50
+ const add = el("button", { className: "btn", textContent: "Add" });
51
+ add.onclick = async () => {
52
+ add.disabled = true;
53
+ add.textContent = "Adding…";
54
+ try {
55
+ await addRepo(repo.name, "product");
56
+ add.textContent = "Added";
57
+ row.classList.add("added");
58
+ opts.onAdded?.();
59
+ } catch (err) {
60
+ add.disabled = false;
61
+ add.textContent = "Add";
62
+ row.append(el("p", { className: "err", textContent: String(err.message || err) }));
63
+ }
64
+ };
65
+ row.append(
66
+ el("div", {}, [
67
+ el("b", { textContent: repo.name }),
68
+ el("span", { className: "meta", textContent: repo.visibility }),
69
+ ...(repo.description
70
+ ? [el("p", { className: "sub", textContent: repo.description })]
71
+ : []),
72
+ ]),
73
+ add,
74
+ );
75
+ list.append(row);
76
+ }
77
+ out.replaceChildren(list);
78
+ })
79
+ .catch((err) => {
80
+ out.replaceChildren(
81
+ el("p", {
82
+ className: "sub",
83
+ textContent:
84
+ "Could not list repos (" +
85
+ String(err.message || err) +
86
+ "). Add them by hand in org.yaml; nothing else is blocked by this.",
87
+ }),
88
+ );
89
+ });
90
+
91
+ return box;
92
+ }