@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,549 @@
1
+ /* The inbox: everything open across the org, and the thread beside it. */
2
+
3
+ import { getInbox, getThread, post } from "../api.js";
4
+ import { ago, el, esc, markCurrent } from "../dom.js";
5
+ import { icon, iconHTML } from "../icons.js";
6
+ import { mdlite } from "../md.js";
7
+ import { refreshAll } from "../refresh.js";
8
+ import { S, openCount, writeHash } from "../state.js";
9
+
10
+ const LABEL_TONE = {
11
+ decision: "hot", blocked: "hot", will: "hot", review: "warm", submit: "warm",
12
+ idea: "cool", build: "cool", setup: "cool", data: "cool",
13
+ };
14
+
15
+ /* Bookkeeping. Real history, worth being able to see, but not what you opened the thread
16
+ to read — so a run of it folds away. */
17
+ const QUIET = new Set([
18
+ "labeled", "unlabeled", "assigned", "unassigned", "renamed",
19
+ "review-requested", "ready-for-review",
20
+ ]);
21
+
22
+ /* Whose work an item is. The inbox is org-wide, so without this the CTO's product PRs and
23
+ the CMO's are one undifferentiated list — which is the complaint.
24
+ In descending order of confidence: it lives in their brain, their own app wrote it, a peer
25
+ addressed it to them with a from-<handle> label, it is assigned to their app, or the
26
+ shared public identity wrote it in a repo they work in.
27
+ That last one matches both of them on purpose. Both agents push through the same robot, so
28
+ the honest answer is "one of these two" — better than hiding real work behind a filter. */
29
+ export function belongsTo(item, s) {
30
+ if (!s) return true;
31
+ if (item.repo === s.brain) return true;
32
+ const solo = s.soloBots ?? [];
33
+ if (solo.includes(item.author)) return true;
34
+ if ((item.labels ?? []).some((l) => l === "from-" + s.handle || l === s.handle)) return true;
35
+ if ((item.assignees ?? []).some((a) => solo.includes(a))) return true;
36
+ if ((s.sharedBots ?? []).includes(item.author) && (s.worksIn ?? []).includes(item.repo)) return true;
37
+ return false;
38
+ }
39
+
40
+ export function viewInbox(m) {
41
+ m.append(el("h1", { textContent: "Inbox" }));
42
+ const sub = el("p", { className: "sub", textContent: "Everything open across the org." });
43
+ m.append(sub);
44
+
45
+ const search = el("input", {
46
+ type: "search",
47
+ placeholder: "Filter by title, label, repo…",
48
+ value: S.query,
49
+ });
50
+ const scope = el("select");
51
+ scope.append(
52
+ el("option", { value: "", textContent: "Everything" }),
53
+ el("option", { value: "mine", textContent: "On " + (S.data.human.name ?? "me") }),
54
+ el("option", { value: "decision", textContent: "Decisions" }),
55
+ el("option", { value: "pr", textContent: "Open work (PRs)" }),
56
+ );
57
+ scope.value = S.inboxFilter;
58
+
59
+ /* Open only by default. An inbox is what is waiting on somebody, and burying that under
60
+ five months of finished work would be answering a different question. */
61
+ const state = el("select", { title: "Which items to list" });
62
+ state.append(
63
+ el("option", { value: "", textContent: "Open" }),
64
+ el("option", { value: "closed", textContent: "Recently closed" }),
65
+ el("option", { value: "all", textContent: "Open and closed" }),
66
+ );
67
+ state.value = S.inboxState;
68
+
69
+ const whose = el("select", {
70
+ title: "Whose work: their brain repo, anything their bot wrote, and anything addressed to them",
71
+ });
72
+ whose.append(
73
+ el("option", { value: "", textContent: "Everyone" }),
74
+ ...S.data.staff.map((s) => el("option", { value: s.handle, textContent: s.name })),
75
+ );
76
+ whose.value = S.inboxStaff;
77
+
78
+ const refresh = el("button", { className: "iconbtn", title: "Refresh from GitHub" });
79
+ refresh.innerHTML = '<span class="sync">' + iconHTML("refresh") + "</span>";
80
+ const newBtn = el("button", { className: "ghbtn", textContent: "New issue" });
81
+ newBtn.onclick = () => newIssueForm(viewer);
82
+ m.append(
83
+ el("div", { className: "row", style: "margin-bottom:16px" }, [
84
+ search, whose, state, scope, refresh, newBtn,
85
+ ]),
86
+ );
87
+
88
+ const split = el("div", { className: "split" });
89
+ const list = el("div", { className: "tree" });
90
+ const viewer = el("div", { className: "viewer" });
91
+ split.append(list, viewer);
92
+ m.append(split);
93
+
94
+ search.oninput = () => { S.query = search.value; writeHash(false); paint(); };
95
+ scope.onchange = () => { S.inboxFilter = scope.value; writeHash(false); paint(); };
96
+ whose.onchange = () => { S.inboxStaff = whose.value; writeHash(false); paint(); };
97
+ state.onchange = () => { S.inboxState = state.value; writeHash(false); paint(); };
98
+ refresh.onclick = () => { refresh.classList.add("spin"); load(true); };
99
+
100
+ if (S.inbox) { stampCount(); paint(); restore(); } else load(false);
101
+
102
+ /** A `?t=` in the URL names a thread. It used to be read into the state and then never
103
+ acted on, because only the already-loaded branch opened one. */
104
+ function restore() {
105
+ if (S.inboxOpen) openThread(S.inboxOpen);
106
+ }
107
+
108
+ async function load(force) {
109
+ list.replaceChildren(el("p", { className: "empty", textContent: "Asking GitHub…" }));
110
+ try {
111
+ S.inbox = await getInbox(force);
112
+ } catch (e) {
113
+ refresh.classList.remove("spin");
114
+ list.replaceChildren(
115
+ el("p", { className: "empty err", textContent: "Could not reach GitHub: " + e.message }),
116
+ );
117
+ return;
118
+ }
119
+ refresh.classList.remove("spin");
120
+ stampCount();
121
+ paint();
122
+ restore();
123
+ }
124
+
125
+ /* The sidebar badge is painted by the shell, which runs before this screen has asked
126
+ GitHub anything. Without this it stays empty until something else causes a render. */
127
+ function stampCount() {
128
+ const badge = document.querySelector("#inboxcount");
129
+ if (badge) badge.textContent = S.inbox ? String(openCount()) : "";
130
+ }
131
+
132
+ function paint() {
133
+ if (!S.inbox) return;
134
+ const q = S.query.trim().toLowerCase();
135
+ const human = S.data.human.github;
136
+
137
+ const whoseStaff = S.inboxStaff ? S.data.staff.find((s) => s.handle === S.inboxStaff) : null;
138
+ const mine = S.inbox.items.filter((i) => belongsTo(i, whoseStaff));
139
+ const isOpen = (i) => i.state === "OPEN";
140
+ const scoped = mine.filter(
141
+ S.inboxState === "closed" ? (i) => !isOpen(i) : S.inboxState === "all" ? () => true : isOpen,
142
+ );
143
+
144
+ let items = scoped.filter(
145
+ (i) => !q || (i.title + " " + i.repo + " " + i.labels.join(" ") + " #" + i.number)
146
+ .toLowerCase().includes(q),
147
+ );
148
+ if (S.inboxFilter === "mine") items = items.filter((i) => i.assignees.includes(human));
149
+ else if (S.inboxFilter === "decision") items = items.filter((i) => i.labels.includes("decision"));
150
+ else if (S.inboxFilter === "pr") items = items.filter((i) => i.kind === "pr");
151
+
152
+ const open = mine.filter(isOpen);
153
+ const onYou = open.filter((i) => i.assignees.includes(human)).length;
154
+ const prs = open.filter((i) => i.kind === "pr").length;
155
+ const shut = mine.length - open.length;
156
+ const where = whoseStaff
157
+ ? " for <b>" + esc(whoseStaff.name) + "</b>"
158
+ : " across " + (S.inbox.repos ?? []).length + " repos";
159
+ sub.innerHTML =
160
+ (S.inboxState === "closed"
161
+ ? shut + " closed in the last 45 days" + where
162
+ : open.length + " open" + where + " · <b>" + onYou + " on " +
163
+ esc(S.data.human.name ?? "you") + "</b> · " + prs + " open PRs" +
164
+ (S.inboxState === "all" ? " · " + shut + " closed" : "")) +
165
+ (S.inbox.fetchedAt ? ' <span class="meta">· checked ' + ago(S.inbox.fetchedAt) + "</span>" : "");
166
+
167
+ list.replaceChildren();
168
+ for (const e of S.inbox.errors ?? []) {
169
+ list.append(el("div", { className: "tdir err", textContent: e }));
170
+ }
171
+
172
+ // Newest first, flat. Grouping by repo buried a fresh comment under whichever project
173
+ // happened to sort first; the project moves onto the row instead.
174
+ items = items.slice().sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)));
175
+ if (!items.length) {
176
+ list.append(el("p", { className: "empty", textContent: "Nothing matches." }));
177
+ return;
178
+ }
179
+
180
+ for (const i of items) {
181
+ const shutState = i.state === "MERGED" ? "merged" : i.state === "OPEN" ? "" : "closed";
182
+ const b = el("button", { className: "irow" + (shutState ? " shut" : "") });
183
+ const yours = i.assignees.includes(human);
184
+ const chips = i.labels
185
+ .slice(0, 3)
186
+ .map((l) => '<span class="chip ' + (LABEL_TONE[l] ?? "") + '">' + esc(l) + "</span>")
187
+ .join("");
188
+ b.innerHTML =
189
+ '<div class="ititle">' + (yours ? '<span class="dot" title="assigned to you"></span>' : "") +
190
+ esc(i.title) + "</div>" +
191
+ '<div class="imeta">' +
192
+ '<span class="repo">' + esc(i.repo.split("/")[1]) + "</span>" +
193
+ (shutState
194
+ ? '<span class="ist ' + shutState + '" title="' + esc(i.state) + '">' +
195
+ iconHTML(shutState === "merged" ? "merged" : "issue-closed") + "</span>"
196
+ : "") +
197
+ '<span class="num">' + (i.kind === "pr" ? "PR " : "") + "#" + i.number + "</span>" +
198
+ (i.checks && i.checks !== "none"
199
+ ? '<span class="ck ' + i.checks + '">' + checkGlyph(i.checks) + "</span>" : "") +
200
+ chips +
201
+ '<span class="when">' + ago(i.updatedAt) + "</span>" +
202
+ "</div>";
203
+ b.dataset.ref = i.repo + "#" + i.number;
204
+ b.setAttribute("aria-current", String(S.inboxOpen?.repo === i.repo && S.inboxOpen?.number === i.number));
205
+ b.onclick = () => {
206
+ markCurrent(list, b);
207
+ S.inboxOpen = { repo: i.repo, number: i.number, kind: i.kind };
208
+ writeHash(false);
209
+ openThread(S.inboxOpen);
210
+ };
211
+ list.append(b);
212
+ }
213
+ }
214
+
215
+ /* Everything is already loaded, so opening a thread is a render, not a request. */
216
+ function openThread(ref) {
217
+ const item = (S.inbox?.items ?? []).find(
218
+ (i) => i.repo === ref.repo && i.number === ref.number,
219
+ );
220
+ if (!item) {
221
+ viewer.replaceChildren(
222
+ el("p", { className: "empty", textContent: "That one is no longer open. Refresh." }),
223
+ );
224
+ return;
225
+ }
226
+ const row = list.querySelector('[data-ref="' + item.repo + "#" + item.number + '"]');
227
+ if (row) markCurrent(list, row);
228
+
229
+ const head = el("div", { className: "thead" });
230
+ head.innerHTML =
231
+ '<div class="trow">' +
232
+ '<div class="meta">' + esc(item.repo) + " · " + (item.kind === "pr" ? "PR " : "") + "#" + item.number +
233
+ ' · <span class="' + (item.state === "OPEN" ? "ok" : "") + '">' + esc(item.state) + "</span>" +
234
+ " · " + esc(item.author) + " · " + ago(item.updatedAt) + "</div>" +
235
+ '<a class="ghbtn" href="' + esc(item.url) + '" target="_blank" rel="noopener">Open in GitHub</a>' +
236
+ "</div>" +
237
+ "<h3>" + esc(item.title) + "</h3>" +
238
+ (item.labels.length
239
+ ? '<div class="row" style="margin-top:8px">' +
240
+ item.labels.map((l) => '<span class="chip ' + (LABEL_TONE[l] ?? "") + '">' + esc(l) + "</span>").join("") +
241
+ "</div>"
242
+ : "");
243
+ viewer.replaceChildren(head);
244
+
245
+ viewer.append(comment(item.author, item.createdAt, item.body, true, item.repo));
246
+ for (const node of timeline(item, openThread)) viewer.append(node);
247
+ viewer.append(replyBox(item));
248
+ viewer.scrollTop = 0;
249
+ }
250
+
251
+ /* Replies and closes go out as the human, through their own gh. This is where a person
252
+ answers their agents, so it should be the same as typing it on the site. */
253
+ function replyBox(item) {
254
+ const box = el("div", { className: "reply" });
255
+ const ta = el("textarea", {
256
+ placeholder: "Reply as " + (S.data.human.github ?? "you") + "…",
257
+ rows: 3,
258
+ });
259
+ const status = el("span", { className: "meta" });
260
+
261
+ const send = el("button", { className: "ghbtn primary", textContent: "Comment" });
262
+ const closeBtn = el("button", {
263
+ className: "ghbtn",
264
+ textContent: item.state === "OPEN" ? "Close" : "Reopen",
265
+ });
266
+
267
+ const busy = (on, msg) => {
268
+ send.disabled = closeBtn.disabled = on;
269
+ status.textContent = msg ?? "";
270
+ status.className = "meta";
271
+ };
272
+ const failed = (e) => {
273
+ status.textContent = e.message;
274
+ status.className = "meta err";
275
+ };
276
+
277
+ send.onclick = async () => {
278
+ if (!ta.value.trim()) { ta.focus(); return; }
279
+ busy(true, "posting…");
280
+ try {
281
+ await post({ action: "comment", repo: item.repo, number: item.number, body: ta.value });
282
+ ta.value = "";
283
+ await reloadThread(item);
284
+ } catch (e) { failed(e); busy(false); }
285
+ };
286
+
287
+ closeBtn.onclick = async () => {
288
+ const closing = item.state === "OPEN";
289
+ // Closing is the one thing here that is awkward to undo from a phone later.
290
+ if (closing && !confirm("Close " + item.repo + " #" + item.number + "?")) return;
291
+ busy(true, closing ? "closing…" : "reopening…");
292
+ try {
293
+ await post({
294
+ action: closing ? "close" : "reopen",
295
+ repo: item.repo,
296
+ number: item.number,
297
+ body: closing ? ta.value : undefined,
298
+ });
299
+ await refreshAll(false);
300
+ } catch (e) { failed(e); busy(false); }
301
+ };
302
+
303
+ box.append(ta, el("div", { className: "row", style: "margin-top:9px" }, [send, closeBtn, status]));
304
+ return box;
305
+ }
306
+
307
+ async function reloadThread(item) {
308
+ // Re-read just this one, so a reply appears without paying for the whole org again.
309
+ try {
310
+ const fresh = await getThread(item.repo, item.number, item.kind);
311
+ if (fresh && !fresh.error) {
312
+ const at = S.inbox.items.findIndex(
313
+ (i) => i.repo === item.repo && i.number === item.number,
314
+ );
315
+ if (at >= 0) S.inbox.items[at] = { ...S.inbox.items[at], ...fresh };
316
+ }
317
+ } catch {
318
+ /* the comment posted; a stale pane is not worth an error */
319
+ }
320
+ paint();
321
+ openThread({ repo: item.repo, number: item.number, kind: item.kind });
322
+ }
323
+ }
324
+
325
+ /* ------------------------------- the thread ------------------------------ */
326
+
327
+ /**
328
+ * A thread's history, in GitHub's order: comments and reviews as cards, references as lines,
329
+ * and a run of bookkeeping folded behind one disclosure.
330
+ *
331
+ * A run of exactly one stays inline. Hiding "added the build label" behind a click costs
332
+ * more attention than reading it does.
333
+ */
334
+ function timeline(item, openThread) {
335
+ const out = [];
336
+ let quiet = [];
337
+
338
+ const flush = () => {
339
+ if (!quiet.length) return;
340
+ if (quiet.length === 1) out.push(eventLine(quiet[0], item, openThread));
341
+ else out.push(folded(quiet, item, openThread));
342
+ quiet = [];
343
+ };
344
+
345
+ for (const e of item.events ?? []) {
346
+ if (QUIET.has(e.type)) { quiet.push(e); continue; }
347
+ flush();
348
+ if (e.type === "comment") {
349
+ out.push(comment(e.actor, e.createdAt, e.body, false, item.repo));
350
+ } else if (e.type === "review" && (e.body ?? "").trim()) {
351
+ out.push(comment(e.actor, e.createdAt, e.body, false, item.repo, reviewWord(e.state)));
352
+ } else {
353
+ out.push(eventLine(e, item, openThread));
354
+ }
355
+ }
356
+ flush();
357
+ return out;
358
+ }
359
+
360
+ function folded(events, item, openThread) {
361
+ const box = el("div");
362
+ const btn = el("button", { className: "tevmore" });
363
+ btn.setAttribute("aria-expanded", "false");
364
+ btn.append(
365
+ icon("chevron", "caret"),
366
+ el("span", { textContent: events.length + " more events" }),
367
+ );
368
+ const body = el("div", { className: "tevquiet" }, events.map((e) => eventLine(e, item, openThread)));
369
+ body.hidden = true;
370
+ btn.onclick = () => {
371
+ body.hidden = !body.hidden;
372
+ btn.setAttribute("aria-expanded", String(!body.hidden));
373
+ };
374
+ box.append(btn, body);
375
+ return box;
376
+ }
377
+
378
+ /* One icon per kind of thing that can happen to a thread. Labels and people repeat on
379
+ purpose: "added the build label" and "removed" are the same kind of event, and the verb
380
+ next to it is what tells them apart. */
381
+ const GLYPH = {
382
+ "cross-referenced": "crossref", referenced: "commit", closed: "closed",
383
+ reopened: "reopened", merged: "merged",
384
+ review: "review", "review-requested": "review", "ready-for-review": "review",
385
+ labeled: "label", unlabeled: "label", assigned: "person", unassigned: "person",
386
+ renamed: "rename",
387
+ };
388
+
389
+ function reviewWord(state) {
390
+ return state === "APPROVED" ? "approved" :
391
+ state === "CHANGES_REQUESTED" ? "requested changes" : "reviewed";
392
+ }
393
+
394
+ /** One event as a line: a marker, what happened, and whatever it points at. */
395
+ function eventLine(e, item, openThread) {
396
+ const d = el("div", { className: "tev " + e.type });
397
+ const mark = el("span", { className: "tico" });
398
+ mark.innerHTML = iconHTML(GLYPH[e.type] ?? "dot");
399
+ d.append(mark);
400
+ const body = el("div", { className: "tbody" });
401
+ d.append(body);
402
+
403
+ const said = (html) => {
404
+ body.insertAdjacentHTML(
405
+ "beforeend",
406
+ "<b>" + esc(e.actor || "someone") + "</b> " + html +
407
+ ' <span class="twhen">· ' + esc(ago(e.createdAt)) + "</span>",
408
+ );
409
+ };
410
+
411
+ switch (e.type) {
412
+ case "cross-referenced":
413
+ said("mentioned this in");
414
+ body.append(xref(e.source, item, openThread));
415
+ break;
416
+
417
+ case "referenced": {
418
+ const where = e.commit?.repo && e.commit.repo !== item.repo ? " in " + esc(e.commit.repo) : "";
419
+ said("added a commit that references this" + where);
420
+ if (e.commit) {
421
+ body.append(
422
+ el("a", {
423
+ className: "tcommit",
424
+ href: e.commit.url,
425
+ target: "_blank",
426
+ rel: "noopener",
427
+ textContent: e.commit.subject || e.commit.sha,
428
+ title: e.commit.sha,
429
+ }),
430
+ );
431
+ }
432
+ break;
433
+ }
434
+
435
+ case "closed":
436
+ said(e.closer ? "closed this as completed by" : "closed this");
437
+ if (e.closer) body.append(xref({ ...e.closer, kind: "pr", state: "MERGED", repo: item.repo }, item, openThread));
438
+ break;
439
+
440
+ case "reopened": said("reopened this"); break;
441
+ case "merged": said("merged this"); break;
442
+ case "review": said(reviewWord(e.state) + " this"); break;
443
+ case "ready-for-review": said("marked this ready for review"); break;
444
+ case "review-requested": said("requested a review from <b>" + esc(e.assignee) + "</b>"); break;
445
+ case "labeled": said("added the <b>" + esc(e.label) + "</b> label"); break;
446
+ case "unlabeled": said("removed the <b>" + esc(e.label) + "</b> label"); break;
447
+ case "assigned": said("assigned <b>" + esc(e.assignee) + "</b>"); break;
448
+ case "unassigned": said("unassigned <b>" + esc(e.assignee) + "</b>"); break;
449
+ case "renamed": said("renamed this from “" + esc(e.from) + "”"); break;
450
+ default: said(e.type);
451
+ }
452
+ return d;
453
+ }
454
+
455
+ /** The issue or PR an event points at. Open it here if it is one of ours; GitHub if not. */
456
+ function xref(source, item, openThread) {
457
+ const state = String(source.state ?? "OPEN").toLowerCase();
458
+ const b = el("button", { className: "xref" });
459
+ const glyph =
460
+ state === "merged" ? "merged" :
461
+ state === "closed" ? "issue-closed" :
462
+ state === "draft" ? "draft" : "issue-open";
463
+ const st = el("span", { className: "st " + state, title: state });
464
+ st.innerHTML = iconHTML(glyph);
465
+ b.append(
466
+ st,
467
+ el("span", { className: "t", textContent: source.title || "#" + source.number }),
468
+ el("span", {
469
+ className: "n",
470
+ textContent: (source.repo && source.repo !== item.repo ? source.repo.split("/")[1] + " " : "") +
471
+ "#" + source.number,
472
+ }),
473
+ );
474
+ // Open it in the portal when it is something the inbox already holds; otherwise GitHub,
475
+ // because a closed issue is not in the inbox and there is nothing here to show.
476
+ b.onclick = () => {
477
+ const known = (S.inbox?.items ?? []).find(
478
+ (i) => i.repo === source.repo && i.number === source.number,
479
+ );
480
+ if (known) {
481
+ S.inboxOpen = { repo: known.repo, number: known.number, kind: known.kind };
482
+ writeHash(false);
483
+ openThread(S.inboxOpen);
484
+ return;
485
+ }
486
+ if (source.url) open(source.url, "_blank", "noopener");
487
+ };
488
+ return b;
489
+ }
490
+
491
+ function comment(author, when, body, isBody, repo, badge) {
492
+ const d = el("div", { className: "cmt" + (isBody ? " first" : "") });
493
+ const meta = el("div", { className: "meta" });
494
+ meta.append((author || "?") + " · " + new Date(when).toLocaleString());
495
+ if (badge) meta.append(el("span", { className: "pill", style: "margin-left:8px", textContent: badge }));
496
+ d.append(meta);
497
+ d.append(el("div", { className: "md cbody", innerHTML: mdlite(body || "_no description_", { repo }) }));
498
+ return d;
499
+ }
500
+
501
+ function checkGlyph(state) {
502
+ return iconHTML(state === "passing" ? "check" : state === "failing" ? "close" : "dot");
503
+ }
504
+
505
+ /* --------------------------------- new issue -------------------------------- */
506
+
507
+ function newIssueForm(viewer) {
508
+ const repos = (S.inbox?.repos ?? []).map((r) => r.owner + "/" + r.name);
509
+ const box = el("div");
510
+ const repo = el("select");
511
+ for (const r of repos) repo.append(el("option", { value: r, textContent: r }));
512
+ const title = el("input", { type: "search", placeholder: "Title", style: "flex:1;min-width:240px" });
513
+ const body = el("textarea", { placeholder: "Body (markdown)", rows: 8 });
514
+ const labels = el("input", { type: "search", placeholder: "Labels, comma separated" });
515
+ const status = el("span", { className: "meta" });
516
+ const create = el("button", { className: "ghbtn primary", textContent: "Create issue" });
517
+
518
+ create.onclick = async () => {
519
+ if (!title.value.trim()) { title.focus(); return; }
520
+ create.disabled = true;
521
+ status.textContent = "creating…";
522
+ status.className = "meta";
523
+ try {
524
+ const r = await post({
525
+ action: "create",
526
+ repo: repo.value,
527
+ title: title.value,
528
+ body: body.value,
529
+ labels: labels.value.split(",").map((s) => s.trim()).filter(Boolean),
530
+ });
531
+ status.innerHTML =
532
+ 'created · <a href="' + esc(r.url ?? "") + '" target="_blank" rel="noopener">open it</a>';
533
+ await refreshAll(true);
534
+ } catch (e) {
535
+ status.textContent = e.message;
536
+ status.className = "meta err";
537
+ }
538
+ create.disabled = false;
539
+ };
540
+
541
+ box.append(
542
+ el("h3", { style: "margin:0 0 12px;font:600 16px var(--sans)", textContent: "New issue" }),
543
+ el("div", { className: "row", style: "margin-bottom:9px" }, [repo, title]),
544
+ body,
545
+ labels,
546
+ el("div", { className: "row", style: "margin-top:10px" }, [create, status]),
547
+ );
548
+ viewer.replaceChildren(box);
549
+ }
@@ -0,0 +1,135 @@
1
+ /* The memory cards, in the brain's right pane.
2
+ *
3
+ * `mem:*` is everything, `mem:<section>` is one section, `fact:<slug>` is that fact's section
4
+ * with the fact itself lit up.
5
+ *
6
+ * That last one used to happen silently: clicking a fact's name narrowed 111 cards to a
7
+ * dozen and highlighted one, with nothing saying so and no way back. Hence the focus bar —
8
+ * it names where you are, and every part of it is a way out.
9
+ */
10
+
11
+ import { el, esc } from "../dom.js";
12
+ import { inline } from "../md.js";
13
+ import { S } from "../state.js";
14
+ import { matchesFact } from "./brain.js";
15
+
16
+ export function showMemory(viewer, s, key, pick) {
17
+ const section = key.startsWith("mem:") ? key.slice(4) : null;
18
+ const slug = key.startsWith("fact:") ? key.slice(5) : null;
19
+ const target = slug ? s.facts.find((f) => f.slug === slug) : null;
20
+ const q = S.fileQuery.trim().toLowerCase();
21
+
22
+ let facts = s.facts;
23
+ if (section && section !== "*") facts = facts.filter((f) => f.section === section);
24
+ else if (target) facts = facts.filter((f) => f.section === target.section);
25
+ else if (q) facts = facts.filter((f) => matchesFact(f, q));
26
+
27
+ const here = section && section !== "*" ? section : target ? target.section : null;
28
+
29
+ viewer.replaceChildren();
30
+
31
+ if (here) viewer.append(focusBar(here, target, pick));
32
+
33
+ viewer.append(
34
+ el("div", {
35
+ className: "meta",
36
+ style: "margin-bottom:5px",
37
+ textContent: "memory/INDEX.md · " + facts.length + " facts",
38
+ }),
39
+ );
40
+ viewer.append(
41
+ el("p", {
42
+ className: "factsub",
43
+ textContent: "Read in full at every boot. Deleting is the maintenance.",
44
+ }),
45
+ );
46
+
47
+ if (!facts.length) {
48
+ viewer.append(el("p", { className: "empty", textContent: "Nothing matches." }));
49
+ return;
50
+ }
51
+
52
+ const host = el("div");
53
+ let seen = null;
54
+ for (const f of facts) {
55
+ if (f.section !== seen && !here) {
56
+ seen = f.section;
57
+ host.append(el("div", { className: "sect", style: "padding-left:0", textContent: seen }));
58
+ }
59
+ const d = el("div", { className: "fact" + (slug === f.slug ? " lit" : "") });
60
+ d.id = "fact-" + f.slug;
61
+ d.innerHTML =
62
+ '<span class="slug" data-goto="' + esc(f.slug) + '" title="Show just this fact">' +
63
+ esc(f.slug) + "</span>" +
64
+ (f.provenance ? '<span class="tag ' + esc(f.provenance) + '">' + esc(f.provenance) + "</span>" : "") +
65
+ (f.note ? '<span class="notelink" data-note="' + esc(f.note) + '">' + esc(f.note) + "</span>" : "") +
66
+ '<p class="stmt">' + inline(f.statement) + "</p>" +
67
+ (f.consequence
68
+ ? '<p class="so"><b>So:</b> ' + inline(f.consequence) + "</p>"
69
+ : '<p class="so err">No <b>So:</b> — lint will flag this.</p>');
70
+ host.append(d);
71
+ }
72
+ viewer.append(host);
73
+
74
+ host.addEventListener("click", (e) => {
75
+ const note = e.target.closest?.("[data-note]");
76
+ if (note) {
77
+ pick("memory/" + note.dataset.note);
78
+ return;
79
+ }
80
+ const goto = e.target.closest?.("[data-goto]");
81
+ if (!goto) return;
82
+ // Clicking the fact you are already focused on is how you let go of it.
83
+ pick(slug === goto.dataset.goto ? "mem:" + (target?.section ?? "*") : "fact:" + goto.dataset.goto);
84
+ });
85
+
86
+ if (slug) {
87
+ const lit = host.querySelector(".lit");
88
+ if (lit?.scrollIntoView) lit.scrollIntoView({ block: "center" });
89
+ }
90
+ }
91
+
92
+ /** Where you are and how to leave. Every crumb is clickable and Escape clears the lot. */
93
+ function focusBar(section, target, pick) {
94
+ const bar = el("div", { className: "focusbar" });
95
+ bar.append(
96
+ el("button", { className: "crumb", textContent: "All facts", onclick: () => pick("mem:*") }),
97
+ el("span", { className: "sep", textContent: "›" }),
98
+ );
99
+ if (target) {
100
+ bar.append(
101
+ el("button", {
102
+ className: "crumb",
103
+ textContent: section,
104
+ onclick: () => pick("mem:" + section),
105
+ }),
106
+ el("span", { className: "sep", textContent: "·" }),
107
+ el("span", { className: "here", textContent: target.slug }),
108
+ );
109
+ } else {
110
+ bar.append(el("span", { className: "here", textContent: section }));
111
+ }
112
+ bar.append(
113
+ el("button", {
114
+ className: "x",
115
+ textContent: target ? "Show the whole section" : "Show all facts",
116
+ onclick: () => pick(target ? "mem:" + section : "mem:*"),
117
+ }),
118
+ );
119
+
120
+ /* Escape is what a person tries first, so it does the same thing the button does. Bound to
121
+ the document because the focus is wherever they clicked, and removed when this pane is
122
+ replaced — otherwise every repaint stacks another listener. */
123
+ const onKey = (e) => {
124
+ if (e.key !== "Escape") return;
125
+ if (/^(INPUT|TEXTAREA|SELECT)$/.test(e.target?.tagName ?? "")) return;
126
+ if (!bar.isConnected) {
127
+ removeEventListener("keydown", onKey);
128
+ return;
129
+ }
130
+ e.preventDefault();
131
+ pick("mem:*");
132
+ };
133
+ addEventListener("keydown", onKey);
134
+ return bar;
135
+ }