@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,436 @@
1
+ /* The link graph: one node per memory section, fanning out into its facts. */
2
+
3
+ import { el, esc } from "../dom.js";
4
+ import { icon } from "../icons.js";
5
+ import { inline } from "../md.js";
6
+ import { render } from "../router.js";
7
+ import { S, staff } from "../state.js";
8
+
9
+ /* An edge key joins two node ids, so it needs a character no id can contain — a group id
10
+ is a section name, and spaces and punctuation are all in play. Written as an escape
11
+ rather than the literal character it used to be, which made every tool treat this file
12
+ as binary and quietly refuse to grep it. */
13
+ const SEP = "\u0000";
14
+
15
+ export function viewGraph(m) {
16
+ const s = staff();
17
+ m.append(el("h1", { textContent: s.handle.toUpperCase() + " · graph" }));
18
+ m.append(el("p", { className: "sub", innerHTML:
19
+ "One node per group, sized by what it holds. <b>Click a group to fan its facts outwards</b>; " +
20
+ "click it again to fold them back. <b>Click a fact</b> to read it." }));
21
+
22
+ const wrap = el("div", { className: "graphwrap" });
23
+ wrap.append(el("canvas", { id: "graph" }));
24
+ m.append(wrap);
25
+ requestAnimationFrame(() => drawGraph(wrap.querySelector("#graph"), wrap, s));
26
+ }
27
+
28
+ /* A two-level graph.
29
+ *
30
+ * Group nodes are placed deterministically on a ring and do not drift: a top level that
31
+ * rearranges itself every time you open something is impossible to build a mental map of.
32
+ * Opening a group fans its members OUTWARD, away from the centre, so they never land in
33
+ * among the other groups. Only members open the sheet; a group click just opens or folds. */
34
+ function drawGraph(canvas, wrap, s) {
35
+ const factBySlug = new Map(s.facts.map((f) => [f.slug, f]));
36
+ const kindOf = (id) => factBySlug.has(id) ? "fact"
37
+ : id.startsWith("#") ? "issue" : s.notes.includes(id) ? "note" : "file";
38
+ const groupOf = (id) => {
39
+ const f = factBySlug.get(id);
40
+ if (f) return f.section || "Ungrouped";
41
+ const k = kindOf(id);
42
+ return k === "note" ? "Notes" : k === "issue" ? "Issues" : "Files";
43
+ };
44
+
45
+ const ids = new Set(s.facts.map((f) => f.slug));
46
+ for (const l of s.links) { ids.add(l.from); ids.add(l.to); }
47
+
48
+ const members = new Map();
49
+ for (const id of ids) {
50
+ const g = groupOf(id);
51
+ if (!members.has(g)) members.set(g, []);
52
+ members.get(g).push(id);
53
+ }
54
+ const groups = [...members.keys()].sort((a, b) => members.get(b).length - members.get(a).length);
55
+
56
+ const PALETTE = ["#7cc7a4","#7aa2f7","#c99bd6","#d8a657","#6fc3c9","#e07b7b","#b3c46b","#e0a1c0","#9d9ff5","#8fb0d9"];
57
+ const colour = new Map(groups.map((g, i) => [g, PALETTE[i % PALETTE.length]]));
58
+
59
+ // The ring grows with the number of groups so they never crowd, and each group keeps its
60
+ // angle for the life of the view. That angle is also the direction its members fan out in.
61
+ const RING = Math.max(340, groups.length * 84);
62
+ // Each group owns one slice of the circle. A fan wider than its own slice sweeps across
63
+ // the neighbours, which is exactly what it was doing.
64
+ const SECTOR = (Math.PI * 2) / groups.length;
65
+ const groupNodes = new Map(groups.map((g, i) => {
66
+ const angle = (i / groups.length) * Math.PI * 2 - Math.PI / 2;
67
+ return [g, { id: g, group: g, isGroup: true, angle, count: members.get(g).length,
68
+ x: Math.cos(angle) * RING, y: Math.sin(angle) * RING, vx: 0, vy: 0, pinned: true }];
69
+ }));
70
+
71
+ const radius = (n) => n.isGroup ? 16 + Math.sqrt(n.count) * 3.6 : 5.5;
72
+
73
+ /* Fan a group's members outward along its own angle, in shells, so a big group becomes a
74
+ wedge pointing away from the middle rather than a ball sitting on its neighbours. */
75
+ function fanAnchor(g, i) {
76
+ const gn = groupNodes.get(g);
77
+ const n = members.get(g).length;
78
+ const perShell = Math.max(4, Math.ceil(Math.sqrt(n) * 1.15));
79
+ const shell = Math.floor(i / perShell);
80
+ const inShell = i % perShell;
81
+ const count = Math.min(perShell, n - shell * perShell);
82
+ // Never wider than the group's own slice, so one fan can never reach into another.
83
+ const spread = SECTOR * 0.66;
84
+ const t = count === 1 ? 0 : (inShell / (count - 1) - 0.5) * spread;
85
+ const a = gn.angle + t;
86
+ // Well clear of the ring, and each shell steps a long way further out.
87
+ const r = RING + radius(gn) + 210 + shell * 96;
88
+ return { x: Math.cos(a) * r, y: Math.sin(a) * r };
89
+ }
90
+
91
+ const memberNodes = new Map();
92
+ for (const g of groups) {
93
+ members.get(g).forEach((id, i) => {
94
+ const gn = groupNodes.get(g);
95
+ memberNodes.set(id, { id, group: g, isGroup: false, kind: kindOf(id),
96
+ x: gn.x, y: gn.y, vx: 0, vy: 0, home: fanAnchor(g, i), pinned: false });
97
+ });
98
+ }
99
+
100
+ const expanded = new Set();
101
+ const hidden = new Set();
102
+ const resolve = (id) => (expanded.has(groupOf(id)) ? memberNodes.get(id) : groupNodes.get(groupOf(id)));
103
+ // Group nodes stay on screen when open, as a hollow marker, so there is always something
104
+ // to click to fold them back.
105
+ const shown = () => groups.filter((g) => !hidden.has(g))
106
+ .flatMap((g) => (expanded.has(g) ? [groupNodes.get(g), ...members.get(g).map((id) => memberNodes.get(id))] : [groupNodes.get(g)]));
107
+
108
+ function activeEdges() {
109
+ const acc = new Map();
110
+ for (const l of s.links) {
111
+ const a = resolve(l.from), b = resolve(l.to);
112
+ if (!a || !b || a === b) continue;
113
+ if (hidden.has(a.group) || hidden.has(b.group)) continue;
114
+ const key = a.id < b.id ? a.id + SEP + b.id : b.id + SEP + a.id;
115
+ const e = acc.get(key) ?? { a, b, weight: 0, authored: 0 };
116
+ e.weight++; if (!l.inferred) e.authored++;
117
+ acc.set(key, e);
118
+ }
119
+ return [...acc.values()];
120
+ }
121
+
122
+ let edges = [];
123
+ let alpha = 1;
124
+ const degree = new Map();
125
+ function recompute() {
126
+ edges = activeEdges();
127
+ degree.clear();
128
+ for (const e of edges) {
129
+ degree.set(e.a, (degree.get(e.a) ?? 0) + e.weight);
130
+ degree.set(e.b, (degree.get(e.b) ?? 0) + e.weight);
131
+ }
132
+ alpha = 1;
133
+ }
134
+ const neighbours = (n) => edges.filter((e) => e.a === n || e.b === n).map((e) => (e.a === n ? e.b : e.a));
135
+
136
+ function toggle(g) {
137
+ if (expanded.has(g)) {
138
+ expanded.delete(g);
139
+ // Fold members back to their group so reopening reads as unfolding, not teleporting.
140
+ for (const id of members.get(g)) {
141
+ const mn = memberNodes.get(id), gn = groupNodes.get(g);
142
+ mn.x = gn.x; mn.y = gn.y; mn.vx = mn.vy = 0; mn.pinned = false;
143
+ }
144
+ if (selected && groupOf(selected.id) === g) { selected = null; paintSheet(); }
145
+ } else {
146
+ expanded.add(g);
147
+ }
148
+ recompute();
149
+ }
150
+
151
+ const css = (v) => getComputedStyle(document.documentElement).getPropertyValue(v).trim();
152
+ let tx = 0, ty = 0, scale = 0.8;
153
+ let selected = null, hover = null, drag = null, panning = false, lx = 0, ly = 0, moved = 0;
154
+ const ctx = canvas.getContext("2d");
155
+
156
+ const search = el("input", { type: "search", placeholder: "Find a fact..." });
157
+ search.oninput = () => {
158
+ const q = search.value.trim().toLowerCase();
159
+ if (!q) return;
160
+ const id = [...ids].find((x) => x.toLowerCase().includes(q));
161
+ if (!id) return;
162
+ if (!expanded.has(groupOf(id))) toggle(groupOf(id));
163
+ selected = memberNodes.get(id);
164
+ centreOn(selected);
165
+ paintSheet();
166
+ };
167
+ wrap.append(el("div", { className: "gtools" }, [
168
+ search,
169
+ el("button", { textContent: "open all", onclick: () => { for (const g of groups) if (!expanded.has(g)) toggle(g); } }),
170
+ el("button", { textContent: "close all", onclick: () => { for (const g of [...expanded]) toggle(g); } }),
171
+ el("button", { textContent: "fit", onclick: fit }),
172
+ ]));
173
+
174
+ const legend = el("div", { className: "legend" });
175
+ for (const g of groups) {
176
+ const row = el("div", { className: "on" });
177
+ row.innerHTML = '<i style="background:' + colour.get(g) + '"></i>' +
178
+ esc(g.length > 26 ? g.slice(0, 25) + "..." : g) +
179
+ ' <b style="color:var(--ink-faint);font-weight:400">' + members.get(g).length + "</b>";
180
+ row.onclick = () => {
181
+ hidden.has(g) ? hidden.delete(g) : hidden.add(g);
182
+ row.className = hidden.has(g) ? "" : "on";
183
+ recompute();
184
+ };
185
+ legend.append(row);
186
+ }
187
+ wrap.append(legend);
188
+
189
+ /* The sheet is for facts only. A group click expands; it never opens a panel. */
190
+ let sheet = null;
191
+ function paintSheet() {
192
+ sheet?.remove();
193
+ sheet = null;
194
+ if (!selected || selected.isGroup) return;
195
+ const n = selected;
196
+ const f = factBySlug.get(n.id);
197
+ sheet = el("aside", { className: "sheet" });
198
+ const shut = el("button", { className: "x", title: "Close",
199
+ onclick: () => { selected = null; paintSheet(); } });
200
+ shut.append(icon("close"));
201
+ sheet.append(shut);
202
+ sheet.insertAdjacentHTML("beforeend",
203
+ '<div class="skind" style="color:' + colour.get(n.group) + '">' + esc(n.group) + "</div>" +
204
+ "<h4>" + esc(n.id) + "</h4>" +
205
+ (f ? "<p>" + inline(f.statement) + "</p>" +
206
+ (f.consequence ? '<p class="so"><b>So:</b> ' + inline(f.consequence) + "</p>" : "")
207
+ : '<p class="meta">' + esc(n.kind) + " referenced by this brain</p>"));
208
+
209
+ const links = neighbours(n);
210
+ if (links.length) {
211
+ sheet.append(el("div", { className: "sect", style: "padding:16px 0 4px", textContent: links.length + " linked" }));
212
+ for (const other of links.slice(0, 20)) {
213
+ sheet.append(el("button", { className: "lk", textContent: other.id, onclick: () => {
214
+ if (other.isGroup) { if (!expanded.has(other.id)) toggle(other.id); return; }
215
+ selected = other; centreOn(other); paintSheet();
216
+ } }));
217
+ }
218
+ }
219
+ const row = el("div", { className: "row", style: "margin-top:18px" });
220
+ if (f) row.append(el("button", { className: "lk", style: "width:auto", textContent: "open in memory",
221
+ onclick: () => { S.openFile = "fact:" + n.id; S.fileQuery = ""; S.view = "brain"; render(); } }));
222
+ if (n.kind === "note") row.append(el("button", { className: "lk", style: "width:auto", textContent: "open note",
223
+ onclick: () => { S.openFile = "memory/notes/" + n.id; S.fileQuery = ""; S.view = "brain"; render(); } }));
224
+ sheet.append(row);
225
+ wrap.append(sheet);
226
+ }
227
+
228
+ function size() {
229
+ const r = canvas.getBoundingClientRect();
230
+ const dpr = devicePixelRatio || 1;
231
+ canvas.width = r.width * dpr; canvas.height = r.height * dpr;
232
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
233
+ return r;
234
+ }
235
+ let rect = size();
236
+ addEventListener("resize", () => { rect = size(); });
237
+
238
+ function centreOn(n) { tx = -n.x * scale; ty = -n.y * scale; }
239
+ function fit() {
240
+ const vis = shown();
241
+ if (!vis.length) return;
242
+ const xs = vis.map((n) => n.x), ys = vis.map((n) => n.y);
243
+ const minx = Math.min(...xs), miny = Math.min(...ys);
244
+ const w = Math.max(...xs) - minx || 1, h = Math.max(...ys) - miny || 1;
245
+ scale = Math.min(1.6, Math.max(0.12, Math.min(rect.width / (w + 260), rect.height / (h + 260))));
246
+ tx = -(minx + w / 2) * scale;
247
+ ty = -(miny + h / 2) * scale;
248
+ }
249
+
250
+ function step() {
251
+ if (alpha < 0.004) return;
252
+ alpha *= 0.988;
253
+ const vis = shown();
254
+ const loose = vis.filter((n) => !n.isGroup);
255
+
256
+ // Only members are simulated. Groups hold the frame.
257
+ for (let i = 0; i < loose.length; i++) {
258
+ const a = loose[i];
259
+ for (let j = i + 1; j < loose.length; j++) {
260
+ const b = loose[j];
261
+ const dx = b.x - a.x, dy = b.y - a.y;
262
+ const d2 = dx * dx + dy * dy || 0.01;
263
+ if (d2 > 40000) continue;
264
+ const d = Math.sqrt(d2);
265
+ const overlap = radius(a) + radius(b) + 15 - d;
266
+ const f = 900 / d2 + (overlap > 0 ? overlap * 0.42 : 0);
267
+ const ux = dx / d, uy = dy / d;
268
+ a.vx -= ux * f; a.vy -= uy * f; b.vx += ux * f; b.vy += uy * f;
269
+ }
270
+ // Keep clear of every group node, so a fan never drifts back over the ring.
271
+ for (const g of groups) {
272
+ const gn = groupNodes.get(g);
273
+ const dx = a.x - gn.x, dy = a.y - gn.y;
274
+ const d = Math.hypot(dx, dy) || 0.01;
275
+ const gap = radius(gn) + 26;
276
+ if (d < gap) { const f = (gap - d) * 0.5; a.vx += (dx / d) * f; a.vy += (dy / d) * f; }
277
+ }
278
+ }
279
+ for (const e of edges) {
280
+ // Only members of the same group attract. A cross-group spring drags a fact out of its
281
+ // own fan and across the picture, which is most of what made this unreadable.
282
+ if (e.a.isGroup || e.b.isGroup || e.a.group !== e.b.group) continue;
283
+ const dx = e.b.x - e.a.x, dy = e.b.y - e.a.y;
284
+ const d = Math.hypot(dx, dy) || 0.01;
285
+ const f = (d - 120) * 0.004;
286
+ const ux = dx / d, uy = dy / d;
287
+ e.a.vx += ux * f; e.a.vy += uy * f; e.b.vx -= ux * f; e.b.vy -= uy * f;
288
+ }
289
+ for (const n of loose) {
290
+ // The fan position is the strong term: it is what keeps a group's facts together and
291
+ // out on their own side of the picture.
292
+ n.vx += (n.home.x - n.x) * 0.09;
293
+ n.vy += (n.home.y - n.y) * 0.09;
294
+ if (n.pinned || n === drag) { n.vx = n.vy = 0; continue; }
295
+ n.x += (n.vx *= 0.72); n.y += (n.vy *= 0.72);
296
+ }
297
+ }
298
+
299
+ function draw() {
300
+ if (!document.body.contains(canvas)) return;
301
+ step();
302
+ ctx.clearRect(0, 0, rect.width, rect.height);
303
+ ctx.save();
304
+ ctx.translate(rect.width / 2 + tx, rect.height / 2 + ty);
305
+ ctx.scale(scale, scale);
306
+
307
+ const near = selected ? new Set([selected, ...neighbours(selected)]) : null;
308
+ const labelBoxes = [];
309
+
310
+ // A faint spoke from an open group to its own fan, so the ownership is obvious.
311
+ for (const g of groups) {
312
+ if (!expanded.has(g) || hidden.has(g)) continue;
313
+ const gn = groupNodes.get(g);
314
+ ctx.strokeStyle = colour.get(g); ctx.globalAlpha = 0.14;
315
+ ctx.lineWidth = 1 / scale;
316
+ for (const id of members.get(g)) {
317
+ const mn = memberNodes.get(id);
318
+ ctx.beginPath(); ctx.moveTo(gn.x, gn.y); ctx.lineTo(mn.x, mn.y); ctx.stroke();
319
+ }
320
+ }
321
+ ctx.globalAlpha = 1;
322
+
323
+ for (const e of edges) {
324
+ const lit = !near || (near.has(e.a) && near.has(e.b));
325
+ ctx.strokeStyle = near && lit ? css("--accent") : css("--line");
326
+ ctx.globalAlpha = near ? (lit ? 0.95 : 0.07) : 0.62;
327
+ ctx.lineWidth = Math.min(6, 0.8 + Math.log2(e.weight + 1) * 1.25) / Math.max(1, scale * 0.8);
328
+ ctx.beginPath(); ctx.moveTo(e.a.x, e.a.y); ctx.lineTo(e.b.x, e.b.y); ctx.stroke();
329
+ }
330
+ ctx.globalAlpha = 1;
331
+
332
+ const painted = shown().sort((a, b) => (b.isGroup ? b.count : 0) - (a.isGroup ? a.count : 0));
333
+ for (const n of painted) {
334
+ const r = radius(n);
335
+ const dim = near && !near.has(n);
336
+ ctx.globalAlpha = dim ? 0.13 : 1;
337
+
338
+ if (n.isGroup) {
339
+ const open = expanded.has(n.id);
340
+ ctx.beginPath(); ctx.arc(n.x, n.y, r, 0, 7);
341
+ if (open) {
342
+ // Hollow while open: the group is still here, and still the thing you click to fold.
343
+ ctx.fillStyle = css("--panel"); ctx.fill();
344
+ ctx.strokeStyle = colour.get(n.group); ctx.lineWidth = 2.4 / scale; ctx.stroke();
345
+ ctx.fillStyle = colour.get(n.group);
346
+ } else {
347
+ ctx.fillStyle = colour.get(n.group); ctx.fill();
348
+ ctx.fillStyle = css("--bg");
349
+ }
350
+ ctx.font = "600 " + Math.max(9, r * 0.6) + "px ui-monospace,monospace";
351
+ ctx.textAlign = "center"; ctx.textBaseline = "middle";
352
+ ctx.fillText(String(n.count), n.x, n.y + 0.5);
353
+ ctx.textBaseline = "alphabetic";
354
+ const text = n.id.length > 24 ? n.id.slice(0, 23) + "..." : n.id;
355
+ const fs = Math.max(10, 12 / scale);
356
+ ctx.font = "600 " + fs + "px -apple-system,system-ui,sans-serif";
357
+ const w = ctx.measureText(text).width;
358
+ const bx = n.x - w / 2, by = n.y + r + 6, bh = fs * 1.25;
359
+ const clashes = labelBoxes.some((o) =>
360
+ bx < o.x + o.w + 8 && bx + w + 8 > o.x && by < o.y + o.h + 4 && by + bh + 4 > o.y);
361
+ if (!clashes || n === hover) {
362
+ labelBoxes.push({ x: bx, y: by, w, h: bh });
363
+ ctx.textAlign = "center";
364
+ ctx.fillStyle = css(n === hover ? "--ink" : "--ink-dim");
365
+ ctx.fillText(text, n.x, by + fs);
366
+ }
367
+ ctx.textAlign = "left";
368
+ } else {
369
+ ctx.fillStyle = colour.get(n.group) ?? css("--ink-faint");
370
+ ctx.beginPath(); ctx.arc(n.x, n.y, r, 0, 7); ctx.fill();
371
+ if (n === selected || n === hover || scale > 1.05) {
372
+ ctx.fillStyle = css(n === selected || n === hover ? "--ink" : "--ink-dim");
373
+ ctx.font = Math.max(9, 10.5 / scale) + "px ui-monospace,monospace";
374
+ ctx.fillText(n.id.length > 26 ? n.id.slice(0, 25) + "..." : n.id, n.x + r + 4, n.y + 3.5);
375
+ }
376
+ }
377
+ if (n === selected || n === hover) {
378
+ ctx.globalAlpha = 1;
379
+ ctx.strokeStyle = css("--ink"); ctx.lineWidth = 1.5 / scale;
380
+ ctx.beginPath(); ctx.arc(n.x, n.y, r + 4, 0, 7); ctx.stroke();
381
+ }
382
+ }
383
+ ctx.restore();
384
+ requestAnimationFrame(draw);
385
+ }
386
+ recompute();
387
+ fit();
388
+ draw();
389
+
390
+ const toWorld = (ev) => {
391
+ const r = canvas.getBoundingClientRect();
392
+ return { x: (ev.clientX - r.left - r.width / 2 - tx) / scale,
393
+ y: (ev.clientY - r.top - r.height / 2 - ty) / scale };
394
+ };
395
+ const at = (ev) => {
396
+ const p = toWorld(ev);
397
+ // Members first: an open group's node must never swallow a click meant for a fact.
398
+ const vis = shown();
399
+ return vis.filter((n) => !n.isGroup).find((n) => Math.hypot(n.x - p.x, n.y - p.y) < radius(n) + 6)
400
+ ?? vis.filter((n) => n.isGroup).find((n) => Math.hypot(n.x - p.x, n.y - p.y) < radius(n) + 5);
401
+ };
402
+
403
+ canvas.onpointerdown = (ev) => {
404
+ canvas.setPointerCapture?.(ev.pointerId);
405
+ moved = 0; lx = ev.clientX; ly = ev.clientY;
406
+ const n = at(ev);
407
+ if (n) { drag = n; if (!n.isGroup) n.pinned = true; alpha = Math.max(alpha, 0.3); }
408
+ else { panning = true; canvas.classList.add("grabbing"); }
409
+ };
410
+ canvas.onpointermove = (ev) => {
411
+ moved += Math.abs(ev.clientX - lx) + Math.abs(ev.clientY - ly);
412
+ if (drag) { const p = toWorld(ev); drag.x = p.x; drag.y = p.y; drag.vx = drag.vy = 0; }
413
+ else if (panning) { tx += ev.clientX - lx; ty += ev.clientY - ly; }
414
+ else {
415
+ const h = at(ev);
416
+ if (h !== hover) { hover = h; canvas.classList.toggle("onnode", !!h); }
417
+ }
418
+ lx = ev.clientX; ly = ev.clientY;
419
+ };
420
+ canvas.onpointerup = (ev) => {
421
+ canvas.releasePointerCapture?.(ev.pointerId);
422
+ if (moved < 4) {
423
+ const n = at(ev);
424
+ if (!n) { selected = null; paintSheet(); }
425
+ else if (n.isGroup) toggle(n.id); // groups only ever open or fold
426
+ else { selected = n === selected ? null : n; paintSheet(); }
427
+ }
428
+ drag = null; panning = false;
429
+ canvas.classList.remove("grabbing");
430
+ };
431
+ canvas.ondblclick = (ev) => { const n = at(ev); if (n && !n.isGroup) { n.pinned = false; alpha = Math.max(alpha, 0.4); } };
432
+ canvas.onwheel = (ev) => {
433
+ ev.preventDefault();
434
+ scale = Math.min(3.5, Math.max(0.1, scale * (ev.deltaY < 0 ? 1.12 : 0.89)));
435
+ };
436
+ }
@@ -0,0 +1,158 @@
1
+ /* Is the scaffolding still there, has the agent been running, and what does lint say. */
2
+
3
+ import { post } from "../api.js";
4
+ import { ago, el, esc, kb } from "../dom.js";
5
+ import { inline } from "../md.js";
6
+ import { render } from "../router.js";
7
+ import { S, staff } from "../state.js";
8
+ import { checklist } from "./checklist.js";
9
+
10
+ const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
11
+
12
+ /** The common cron shapes in plain words; anything unusual is shown as it was written. */
13
+ export function cronText(spec) {
14
+ if (!spec) return "no schedule";
15
+ const p = String(spec).trim().split(/\s+/);
16
+ if (p.length !== 5 || !/^\d+$/.test(p[0]) || !/^\d+$/.test(p[1])) return String(spec);
17
+ const [min, hour, dom, mon, dow] = p;
18
+ const time = hour.padStart(2, "0") + ":" + min.padStart(2, "0") + " UTC";
19
+ if (dom !== "*" || mon !== "*") return String(spec);
20
+ if (dow === "*") return time + " · every day";
21
+ if (dow === "1-5") return time + " · Mon–Fri";
22
+ return time + " · " + dow.split(",").map((d) => DAYS[Number(d) % 7] ?? d).join(", ");
23
+ }
24
+
25
+ export function viewHealth(m) {
26
+ const s = staff();
27
+ const rig = s.rig ?? {};
28
+ m.append(el("h1", { textContent: s.name + " · health" }));
29
+
30
+ /* `roster doctor` runs over the whole org, so it is drawn once at the top rather than
31
+ pretended to be per-staff. The comment this replaced said doctor "does not exist yet". */
32
+ const org = el("div", { className: "card", style: "margin-bottom:16px" });
33
+ org.append(el("h3", { textContent: "The org, from roster doctor" }));
34
+ const rows = el("div");
35
+ org.append(rows);
36
+ m.append(org);
37
+ checklist(rows, {});
38
+ const errs = s.problems.filter((p) => p.level === "error");
39
+ const warns = s.problems.filter((p) => p.level === "warning");
40
+ m.append(el("p", { className: "sub", innerHTML:
41
+ "<span class='" + (errs.length ? "err" : "ok") + "'>" + errs.length + " errors</span> · " +
42
+ "<span class='warn'>" + warns.length + " warnings</span> · memory checks are the same ones " +
43
+ "<code>roster lint</code> runs; the rest is read off the checkout." }));
44
+
45
+ /* The rig: is the scaffolding still there, and has the agent actually been running. This
46
+ is everything answerable without the GitHub API — installation grants, secrets and
47
+ rulesets are `roster doctor`'s job, and it does not exist yet. */
48
+ const kv = (k, v, sub, bad) => {
49
+ const d = el("div", { className: "kv" + (bad ? " bad" : "") });
50
+ d.innerHTML = '<div class="k">' + esc(k) + "</div><div class=\"v\">" + v +
51
+ (sub ? "<small>" + esc(sub) + "</small>" : "") + "</div>";
52
+ return d;
53
+ };
54
+
55
+ const g = el("div", { className: "grid", style: "margin-bottom:6px" });
56
+ g.append(kv("Runs", esc(cronText(s.schedule)),
57
+ (rig.workflows ?? []).length + " workflows: " + (rig.workflows ?? []).join(", "),
58
+ !(rig.workflows ?? []).length));
59
+ g.append(rig.lastCommit
60
+ ? kv("Last commit", esc(ago(rig.lastCommit.date)), rig.lastCommit.subject)
61
+ : kv("Last commit", "never", "no git history here", true));
62
+ g.append(rig.lastMemoryCommit
63
+ ? kv("Last thought", esc(ago(rig.lastMemoryCommit.date)), rig.lastMemoryCommit.subject)
64
+ : kv("Last thought", "never", "memory/ has never been committed to", true));
65
+ g.append(kv("Memory", s.facts.length + " facts",
66
+ kb(rig.memoryBytes ?? 0) + " index · " + s.notes.length + " notes, " + kb(rig.notesBytes ?? 0) +
67
+ " · read in full every boot"));
68
+
69
+ const authored = s.links.filter((l) => !l.inferred).length;
70
+ g.append(kv("Links", authored + " authored", (s.links.length - authored) + " more inferred"));
71
+ g.append(kv("Charter", rig.hasCharter === false ? "missing" : "CHARTER.md",
72
+ rig.hasManifest === false ? "staff.yaml is missing too" : "staff.yaml present",
73
+ rig.hasCharter === false || rig.hasManifest === false));
74
+ if (s.statusIssue && s.brain) {
75
+ g.append(kv("Status", '<a class="ref" href="https://github.com/' + esc(s.brain) + "/issues/" +
76
+ s.statusIssue + '" target="_blank" rel="noopener">#' + s.statusIssue + "</a>",
77
+ "the pinned issue this staff member keeps"));
78
+ }
79
+ if ((rig.missingSurfaces ?? []).length) {
80
+ g.append(kv("Surfaces", esc(rig.missingSurfaces.length + " missing"),
81
+ rig.missingSurfaces.join(", ") + " — declared in staff.yaml, not on disk", true));
82
+ }
83
+ m.append(g);
84
+
85
+ m.append(el("div", { className: "hsect", textContent: "Memory problems" }));
86
+
87
+ if (!s.problems.length) {
88
+ m.append(el("p", { className: "empty", textContent: "Nothing to fix." }));
89
+ return;
90
+ }
91
+
92
+ /* The portal's one power over an agent is that it acts as the human, so "fix this" is an
93
+ issue in their own repo — the same thing you would have typed on GitHub. */
94
+ const allStatus = el("span", { className: "meta" });
95
+ const all = el("button", { className: "ghbtn primary",
96
+ textContent: "Ask " + s.handle.toUpperCase() + " to fix all " + s.problems.length });
97
+ all.onclick = () => askToFix(s, s.problems, all, allStatus);
98
+ m.append(el("div", { className: "row", style: "margin-bottom:12px" }, [all, allStatus]));
99
+
100
+ for (const p of s.problems) {
101
+ const d = el("div", { className: "card" });
102
+ d.innerHTML = '<div class="row"><span class="' + (p.level === "error" ? "err" : "warn") +
103
+ '" style="font:11px var(--mono)">' + p.level + "</span>" +
104
+ '<span class="pill">' + esc(p.rule) + "</span>" +
105
+ (p.line ? '<span class="meta">INDEX.md:' + p.line + "</span>" : "") + "</div>" +
106
+ '<p style="margin:8px 0 0">' + inline(p.message) + "</p>";
107
+
108
+ const status = el("span", { className: "meta" });
109
+ const btn = el("button", { className: "ghbtn", textContent: "Ask " + s.handle.toUpperCase() + " to fix" });
110
+ btn.onclick = () => askToFix(s, [p], btn, status);
111
+ const actions = el("div", { className: "row fix" }, [btn, status]);
112
+ if (p.slug) {
113
+ const go = el("button", { className: "ghbtn", textContent: "Show the fact" });
114
+ go.onclick = () => { S.openFile = "fact:" + p.slug; S.fileQuery = ""; S.view = "brain"; render(); };
115
+ actions.append(go);
116
+ }
117
+ d.append(actions);
118
+ m.append(d);
119
+ }
120
+ }
121
+
122
+ /** Open one issue asking this staff member to fix what lint found. */
123
+ export async function askToFix(s, problems, btn, status) {
124
+ if (!s.brain) {
125
+ status.textContent = "no brain repo declared in staff.yaml";
126
+ status.className = "meta err";
127
+ return;
128
+ }
129
+ const one = problems.length === 1 ? problems[0] : null;
130
+ const title = one
131
+ ? "Memory lint: " + one.rule + (one.line ? " at INDEX.md:" + one.line : "")
132
+ : "Memory lint: " + problems.length + " problems in memory/INDEX.md";
133
+ const body = [
134
+ "`roster lint` flags " + (one ? "this" : "these") + " in `memory/INDEX.md`. Opened from the portal.",
135
+ "",
136
+ "| rule | where | what |",
137
+ "|---|---|---|",
138
+ ...problems.map((p) => "| `" + p.rule + "` | " + (p.line ? "INDEX.md:" + p.line : "—") + " | " +
139
+ String(p.message).replace(/\|/g, "\\|") + " |"),
140
+ "",
141
+ "Fix in your next session and close this.",
142
+ ].join("\n");
143
+
144
+ if (!confirm("Open an issue in " + s.brain + "?")) return;
145
+ btn.disabled = true;
146
+ status.textContent = "opening…";
147
+ status.className = "meta";
148
+ try {
149
+ const r = await post({ action: "create", repo: s.brain, title, body });
150
+ status.innerHTML = 'opened · <a href="' + esc(r.url ?? "") + '" target="_blank" rel="noopener">' +
151
+ esc((r.url ?? "").split("/").slice(-2).join(" #").replace("issues #", "#") || "the issue") + "</a>";
152
+ status.className = "meta ok";
153
+ } catch (e) {
154
+ status.textContent = e.message;
155
+ status.className = "meta err";
156
+ btn.disabled = false;
157
+ }
158
+ }