@nanocollective/roster 0.1.0-alpha.2 → 0.1.0-alpha.4
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.
- package/dist/cli.js +848 -164
- package/docs/README.md +10 -5
- package/docs/agents.md +320 -9
- package/docs/commands.md +5 -6
- package/docs/concepts.md +27 -9
- package/docs/cost.md +3 -2
- package/docs/doctor-codes.md +13 -4
- package/docs/export.md +2 -1
- package/docs/extending.md +11 -2
- package/docs/getting-started.md +89 -84
- package/docs/images/brain.jpg +0 -0
- package/docs/images/org.jpg +0 -0
- package/docs/images/prompt.jpg +0 -0
- package/docs/images/setup-org.jpg +0 -0
- package/docs/images/setup-plan.jpg +0 -0
- package/docs/images/staff.jpg +0 -0
- package/docs/manual-steps.md +36 -13
- package/docs/memory.md +9 -6
- package/docs/org-yaml.md +37 -9
- package/docs/portal.md +197 -31
- package/docs/prompts.md +50 -11
- package/docs/security.md +19 -7
- package/docs/session-workflow.md +8 -10
- package/docs/staff-yaml.md +3 -5
- package/docs/troubleshooting.md +17 -14
- package/docs/writing-a-charter.md +18 -17
- package/package.json +1 -1
- package/templates/brain/.github/workflows/%%STAFF%%-daily.yaml +1 -0
- package/templates/brain/.github/workflows/%%STAFF%%-mention.yaml +10 -4
- package/templates/brain/staff.yaml +0 -1
- package/templates/ops/.github/workflows/session.yaml +9 -26
- package/templates/ops/agents.mjs +121 -6
- package/templates/ops/compose.mjs +61 -4
- package/templates/ops/org/operating.md +0 -6
- package/templates/ops/prompts/_identity.md +8 -1
- package/templates/ops/prompts/mention.md +16 -2
- package/templates/portal/css/base.css +122 -8
- package/templates/portal/css/brain.css +8 -1
- package/templates/portal/css/diff.css +6 -2
- package/templates/portal/css/health.css +21 -2
- package/templates/portal/css/inbox.css +93 -5
- package/templates/portal/css/layout.css +26 -4
- package/templates/portal/css/markdown.css +23 -3
- package/templates/portal/css/setup.css +11 -6
- package/templates/portal/index.html +7 -1
- package/templates/portal/js/api.js +33 -0
- package/templates/portal/js/app.js +33 -7
- package/templates/portal/js/dialog.js +47 -4
- package/templates/portal/js/dom.js +25 -0
- package/templates/portal/js/icons.js +8 -1
- package/templates/portal/js/lightbox.js +273 -0
- package/templates/portal/js/md.js +23 -6
- package/templates/portal/js/mention.js +264 -0
- package/templates/portal/js/refresh.js +136 -6
- package/templates/portal/js/state.js +47 -5
- package/templates/portal/js/views/checklist.js +20 -7
- package/templates/portal/js/views/docs.js +94 -4
- package/templates/portal/js/views/files.js +58 -14
- package/templates/portal/js/views/health.js +163 -35
- package/templates/portal/js/views/inbox.js +882 -96
- package/templates/portal/js/views/memory.js +16 -1
- package/templates/portal/js/views/org.js +142 -62
- package/templates/portal/js/views/prompt.js +50 -63
- package/templates/portal/js/views/setup.js +37 -13
- package/templates/portal/js/views/staff.js +62 -2
- package/templates/portal/js/yaml.js +134 -0
- package/templates/brain/.github/workflows/%%STAFF%%-pr-mention.yaml +0 -50
- package/templates/ops/prompts/pr-mention.md +0 -57
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/* Typing `@` should offer the org.
|
|
2
|
+
*
|
|
3
|
+
* A mention is not decoration here: a staff member's workflow gates on their `@handle`, so a
|
|
4
|
+
* reply that misspells it is a reply nobody is woken by, and a reply that spells a bot's login
|
|
5
|
+
* where a handle belongs looks right and does nothing. Both failures are silent, which is the
|
|
6
|
+
* kind this portal exists to stop. The roster is small and it is already on the page, so it is
|
|
7
|
+
* offered rather than remembered.
|
|
8
|
+
*
|
|
9
|
+
* Everyone is here, and each row says what mentioning it actually does: a staff handle wakes
|
|
10
|
+
* them, a human gets a notification, an App gets neither and is listed so you can recognise the
|
|
11
|
+
* name rather than address it.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { el, esc } from "./dom.js";
|
|
15
|
+
import { humansOf, S } from "./state.js";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Everyone a mention actually reaches, in the order they are worth offering.
|
|
19
|
+
*
|
|
20
|
+
* Staff first: waking one is the reason to type `@` in this app at all. Then the humans, who
|
|
21
|
+
* get a notification.
|
|
22
|
+
*
|
|
23
|
+
* **The Apps are deliberately not here.** `@some-org-cto` is a login, not an inbox: GitHub
|
|
24
|
+
* delivers nothing for mentioning a GitHub App, and an agent wakes on its own handle and not
|
|
25
|
+
* on the name of the identity it posts as. A list you pick from should contain only things
|
|
26
|
+
* that do something; an entry that says "notifies nobody" is a trap with a label on it, and it
|
|
27
|
+
* pushed the entries that work off the bottom of the box.
|
|
28
|
+
*
|
|
29
|
+
* Nothing here knows what any of these people are called. Every row comes off `org.yaml` and
|
|
30
|
+
* the manifests, so an org with a head of ops and a designer gets a head of ops and a designer.
|
|
31
|
+
*/
|
|
32
|
+
export function mentionable() {
|
|
33
|
+
const out = [];
|
|
34
|
+
|
|
35
|
+
for (const s of S.data?.staff ?? []) {
|
|
36
|
+
const at = String(s.mention ?? "@" + s.handle);
|
|
37
|
+
out.push({
|
|
38
|
+
text: at,
|
|
39
|
+
label: s.name ?? s.handle,
|
|
40
|
+
note: "wakes them, usually within a minute",
|
|
41
|
+
kind: "staff",
|
|
42
|
+
terms: [at.replace(/^@/, ""), s.handle, s.name ?? ""],
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
for (const h of humansOf()) {
|
|
47
|
+
if (!h.github) continue;
|
|
48
|
+
out.push({
|
|
49
|
+
text: "@" + h.github,
|
|
50
|
+
label: h.name ?? h.github,
|
|
51
|
+
note: h.role ? h.role + " · notifies them" : "notifies them",
|
|
52
|
+
kind: "human",
|
|
53
|
+
terms: [h.github, h.name ?? ""],
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** What is being typed after an `@`, or null. Only at a word boundary: an email is not a mention. */
|
|
61
|
+
export function queryAt(text, caret) {
|
|
62
|
+
const before = String(text ?? "").slice(0, caret);
|
|
63
|
+
const m = /(?:^|[\s(<[{>,;:"'`])@([A-Za-z0-9][\w.-]*)?$/.exec(before);
|
|
64
|
+
if (!m) return null;
|
|
65
|
+
const query = m[1] ?? "";
|
|
66
|
+
return { query, start: caret - query.length - 1 };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The matches for a query, best first. Prefix beats substring; order beats neither. */
|
|
70
|
+
export function matches(query, all = mentionable()) {
|
|
71
|
+
const q = query.toLowerCase();
|
|
72
|
+
if (!q) return all;
|
|
73
|
+
const rank = (c) => {
|
|
74
|
+
const terms = c.terms.map((t) => String(t).toLowerCase()).filter(Boolean);
|
|
75
|
+
if (terms.some((t) => t.startsWith(q))) return 0;
|
|
76
|
+
if (terms.some((t) => t.includes(q))) return 1;
|
|
77
|
+
return 2;
|
|
78
|
+
};
|
|
79
|
+
return all
|
|
80
|
+
.map((c, i) => ({ c, i, r: rank(c) }))
|
|
81
|
+
.filter((x) => x.r < 2)
|
|
82
|
+
.sort((a, b) => a.r - b.r || a.i - b.i)
|
|
83
|
+
.map((x) => x.c);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Wire a textarea up to the list.
|
|
88
|
+
*
|
|
89
|
+
* Attaching adds listeners and nothing else: the popup is built the first time somebody types
|
|
90
|
+
* an `@`, so a screen that renders fifty boxes pays for none of them, and a DOM without
|
|
91
|
+
* `getComputedStyle` (the test shim) never reaches the part that needs one.
|
|
92
|
+
*/
|
|
93
|
+
export function attachMentions(ta) {
|
|
94
|
+
if (!ta?.addEventListener) return;
|
|
95
|
+
|
|
96
|
+
let pop = null;
|
|
97
|
+
let items = [];
|
|
98
|
+
let at = 0;
|
|
99
|
+
let anchor = null;
|
|
100
|
+
|
|
101
|
+
const close = () => {
|
|
102
|
+
if (pop) pop.hidden = true;
|
|
103
|
+
anchor = null;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const open = (found, q) => {
|
|
107
|
+
items = found;
|
|
108
|
+
at = 0;
|
|
109
|
+
anchor = q;
|
|
110
|
+
if (!pop) {
|
|
111
|
+
pop = el("div", { className: "mentions" });
|
|
112
|
+
pop.setAttribute("role", "listbox");
|
|
113
|
+
// mousedown, not click: the textarea must not lose the caret before the pick lands.
|
|
114
|
+
pop.addEventListener("mousedown", (e) => {
|
|
115
|
+
const row = e.target.closest?.("[data-at]");
|
|
116
|
+
if (!row) return;
|
|
117
|
+
e.preventDefault();
|
|
118
|
+
pick(items[Number(row.dataset.at)]);
|
|
119
|
+
});
|
|
120
|
+
host(ta).append(pop);
|
|
121
|
+
}
|
|
122
|
+
pop.hidden = false;
|
|
123
|
+
paint();
|
|
124
|
+
place();
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const paint = () => {
|
|
128
|
+
pop.replaceChildren(
|
|
129
|
+
...items.map((c, i) => {
|
|
130
|
+
const row = el("button", { className: "mrow " + c.kind, type: "button" });
|
|
131
|
+
row.dataset.at = String(i);
|
|
132
|
+
row.setAttribute("aria-selected", String(i === at));
|
|
133
|
+
row.innerHTML =
|
|
134
|
+
'<span class="mat">' + esc(c.text) + "</span>" +
|
|
135
|
+
'<span class="mname">' + esc(c.label) + "</span>" +
|
|
136
|
+
'<span class="mnote">' + esc(c.note) + "</span>";
|
|
137
|
+
return row;
|
|
138
|
+
}),
|
|
139
|
+
);
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const move = (by) => {
|
|
143
|
+
at = (at + by + items.length) % items.length;
|
|
144
|
+
paint();
|
|
145
|
+
pop.children[at]?.scrollIntoView?.({ block: "nearest" });
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const pick = (choice) => {
|
|
149
|
+
if (!choice || !anchor) return;
|
|
150
|
+
const caret = ta.selectionStart ?? ta.value.length;
|
|
151
|
+
const after = ta.value.slice(caret);
|
|
152
|
+
// A space, unless there already is one: nobody wants to type it and nobody wants two.
|
|
153
|
+
const tail = /^\s/.test(after) ? "" : " ";
|
|
154
|
+
ta.value = ta.value.slice(0, anchor.start) + choice.text + tail + after;
|
|
155
|
+
const to = anchor.start + choice.text.length + tail.length;
|
|
156
|
+
ta.setSelectionRange?.(to, to);
|
|
157
|
+
close();
|
|
158
|
+
ta.focus?.();
|
|
159
|
+
// So the box's own listeners — the draft, the grow, the "this is what wakes them" line —
|
|
160
|
+
// see the text that is now in it.
|
|
161
|
+
if (typeof Event === "function") ta.dispatchEvent(new Event("input", { bubbles: true }));
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const look = () => {
|
|
165
|
+
const caret = ta.selectionStart ?? 0;
|
|
166
|
+
const q = queryAt(ta.value, caret);
|
|
167
|
+
if (!q) return close();
|
|
168
|
+
const found = matches(q.query);
|
|
169
|
+
if (!found.length) return close();
|
|
170
|
+
open(found, q);
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
ta.addEventListener("input", look);
|
|
174
|
+
ta.addEventListener("click", close);
|
|
175
|
+
ta.addEventListener("blur", close);
|
|
176
|
+
ta.addEventListener("keydown", (e) => {
|
|
177
|
+
if (!anchor || !pop || pop.hidden) return;
|
|
178
|
+
if (e.key === "ArrowDown") { e.preventDefault(); move(1); return; }
|
|
179
|
+
if (e.key === "ArrowUp") { e.preventDefault(); move(-1); return; }
|
|
180
|
+
if (e.key === "Enter" || e.key === "Tab") {
|
|
181
|
+
// ⌘⏎ is "send", and it means that whether or not this list is open.
|
|
182
|
+
if (e.metaKey || e.ctrlKey) return;
|
|
183
|
+
e.preventDefault();
|
|
184
|
+
// Nothing else on the page may see it: in a dialog, a stray Enter submits.
|
|
185
|
+
e.stopPropagation();
|
|
186
|
+
pick(items[at]);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
if (e.key === "Escape") {
|
|
190
|
+
// Dismiss the list, not the dialog around it.
|
|
191
|
+
e.preventDefault();
|
|
192
|
+
e.stopPropagation();
|
|
193
|
+
close();
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
/** Where the popup lives: the first positioned ancestor, so a modal keeps it on top. */
|
|
198
|
+
function host(node) {
|
|
199
|
+
const parent = node.parentElement ?? node.parentNode;
|
|
200
|
+
if (!parent) return node;
|
|
201
|
+
if (typeof getComputedStyle === "function" && getComputedStyle(parent).position === "static") {
|
|
202
|
+
parent.style.position = "relative";
|
|
203
|
+
}
|
|
204
|
+
return parent;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Under the caret, not under the box.
|
|
209
|
+
*
|
|
210
|
+
* Measured with a mirror: a div with the textarea's own metrics, holding the text up to the
|
|
211
|
+
* caret and a marker after it. There is no other way to ask a textarea where its caret is.
|
|
212
|
+
* If any of that is unavailable, it falls back to the bottom of the box, which is worse but
|
|
213
|
+
* never wrong.
|
|
214
|
+
*/
|
|
215
|
+
function place() {
|
|
216
|
+
const under = ta.offsetTop + ta.offsetHeight;
|
|
217
|
+
let top = under;
|
|
218
|
+
let left = ta.offsetLeft;
|
|
219
|
+
let line = 18;
|
|
220
|
+
if (typeof getComputedStyle === "function" && ta.parentElement) {
|
|
221
|
+
const style = getComputedStyle(ta);
|
|
222
|
+
const mirror = el("div", { className: "mmirror" });
|
|
223
|
+
for (const prop of [
|
|
224
|
+
"fontFamily", "fontSize", "fontWeight", "fontStyle", "letterSpacing", "textTransform",
|
|
225
|
+
"lineHeight", "paddingTop", "paddingRight", "paddingBottom", "paddingLeft",
|
|
226
|
+
"borderTopWidth", "borderRightWidth", "borderBottomWidth", "borderLeftWidth",
|
|
227
|
+
]) {
|
|
228
|
+
mirror.style[prop] = style[prop];
|
|
229
|
+
}
|
|
230
|
+
mirror.style.width = ta.offsetWidth + "px";
|
|
231
|
+
mirror.style.left = ta.offsetLeft + "px";
|
|
232
|
+
mirror.style.top = ta.offsetTop + "px";
|
|
233
|
+
mirror.textContent = ta.value.slice(0, ta.selectionStart ?? 0);
|
|
234
|
+
const mark = el("span", { textContent: "" });
|
|
235
|
+
mirror.append(mark);
|
|
236
|
+
ta.parentElement.append(mirror);
|
|
237
|
+
line = Number.parseFloat(style.lineHeight) || 18;
|
|
238
|
+
top = ta.offsetTop + mark.offsetTop - (ta.scrollTop ?? 0) + line;
|
|
239
|
+
left = ta.offsetLeft + mark.offsetLeft;
|
|
240
|
+
mirror.remove();
|
|
241
|
+
// A caret near the bottom of a long box would put the list off the card.
|
|
242
|
+
if (top > under) top = under;
|
|
243
|
+
}
|
|
244
|
+
pop.style.top = Math.round(top) + "px";
|
|
245
|
+
pop.style.left = Math.round(left) + "px";
|
|
246
|
+
|
|
247
|
+
/* Both edges, measured after placing, because until it is placed it has no size.
|
|
248
|
+
A `<dialog>` scrolls its own box, so anything hanging outside it is not merely ugly:
|
|
249
|
+
it is clipped, and the entries you were about to pick are the ones that go missing. */
|
|
250
|
+
const host = pop.offsetParent ?? pop.parentElement;
|
|
251
|
+
const wide = host?.clientWidth ?? 0;
|
|
252
|
+
if (wide && left + pop.offsetWidth > wide - 8) {
|
|
253
|
+
pop.style.left = Math.max(0, Math.round(wide - pop.offsetWidth - 8)) + "px";
|
|
254
|
+
}
|
|
255
|
+
const tall = host?.clientHeight ?? 0;
|
|
256
|
+
if (tall && top + pop.offsetHeight > tall - 8) {
|
|
257
|
+
// Above the line being typed if it fits there, which is where an editor would put it.
|
|
258
|
+
// Otherwise as low as it can sit and still be whole.
|
|
259
|
+
const above = top - line - pop.offsetHeight;
|
|
260
|
+
pop.style.top =
|
|
261
|
+
Math.round(above >= 0 ? above : Math.max(0, tall - pop.offsetHeight - 8)) + "px";
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
@@ -7,10 +7,75 @@
|
|
|
7
7
|
* Its own module because both the shell and the inbox need it, and importing the shell from
|
|
8
8
|
* a view would make the module graph a ring. */
|
|
9
9
|
|
|
10
|
-
import { getOrg, getSync } from "./api.js";
|
|
10
|
+
import { getInbox, getOrg, getSync } from "./api.js";
|
|
11
11
|
import { $, ago, el, esc } from "./dom.js";
|
|
12
12
|
import { render } from "./router.js";
|
|
13
|
-
import { S } from "./state.js";
|
|
13
|
+
import { openCount, openPrCount, S } from "./state.js";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The inbox, fetched at most once at a time.
|
|
17
|
+
*
|
|
18
|
+
* Two things want it the moment the page opens: the sidebar, which counts what is waiting on
|
|
19
|
+
* you, and the Inbox screen, if that is where the URL landed. Both used to ask separately, and
|
|
20
|
+
* the second one paid a second round trip through every repo on GitHub to learn what the first
|
|
21
|
+
* was already finding out.
|
|
22
|
+
*
|
|
23
|
+
* So the request is shared. `force` always goes to GitHub — it is the refresh button — and
|
|
24
|
+
* everything else takes whatever is in flight or already loaded.
|
|
25
|
+
*/
|
|
26
|
+
let inflight = null;
|
|
27
|
+
|
|
28
|
+
export function ensureInbox(force) {
|
|
29
|
+
if (!force && S.inbox) return Promise.resolve(S.inbox);
|
|
30
|
+
if (!force && inflight) return inflight;
|
|
31
|
+
const asked = getInbox(force)
|
|
32
|
+
.then((data) => {
|
|
33
|
+
S.inbox = data;
|
|
34
|
+
if (inflight === asked) inflight = null;
|
|
35
|
+
return data;
|
|
36
|
+
})
|
|
37
|
+
.catch((err) => {
|
|
38
|
+
if (inflight === asked) inflight = null;
|
|
39
|
+
throw err;
|
|
40
|
+
});
|
|
41
|
+
inflight = asked;
|
|
42
|
+
return asked;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The two sidebar counts, wherever they are asked for.
|
|
47
|
+
*
|
|
48
|
+
* They live here rather than on the Inbox screen because they are the shell's, and because the
|
|
49
|
+
* whole point of this pass is that they are right before anybody has opened that screen. While
|
|
50
|
+
* the first fetch is out they show a placeholder rather than nothing: an empty badge reads as
|
|
51
|
+
* "zero", and zero is a different claim from "still counting".
|
|
52
|
+
*/
|
|
53
|
+
export function stampCounts() {
|
|
54
|
+
const waiting = !S.inbox;
|
|
55
|
+
for (const [sel, n] of [
|
|
56
|
+
["#inboxcount", openCount],
|
|
57
|
+
["#prcount", openPrCount],
|
|
58
|
+
]) {
|
|
59
|
+
const slot = document.querySelector(sel);
|
|
60
|
+
if (!slot) continue;
|
|
61
|
+
slot.textContent = waiting ? "" : String(n());
|
|
62
|
+
slot.classList.toggle("wait", waiting);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Count what is waiting on you without opening the screen that lists it.
|
|
68
|
+
*
|
|
69
|
+
* The badges used to fill in only once something else caused a render with an inbox loaded,
|
|
70
|
+
* which in practice meant "after you visit the Inbox". A sidebar that says nothing until you
|
|
71
|
+
* look at it is not a sidebar.
|
|
72
|
+
*/
|
|
73
|
+
export function countInBackground() {
|
|
74
|
+
stampCounts();
|
|
75
|
+
return ensureInbox(false)
|
|
76
|
+
.then(stampCounts)
|
|
77
|
+
.catch(() => stampCounts());
|
|
78
|
+
}
|
|
14
79
|
|
|
15
80
|
export async function refreshAll(soft) {
|
|
16
81
|
const bar = $("#refreshall");
|
|
@@ -21,14 +86,54 @@ export async function refreshAll(soft) {
|
|
|
21
86
|
S.sync = await getSync();
|
|
22
87
|
S.data = await getOrg();
|
|
23
88
|
S.inbox = null; // the inbox re-fetches itself on next paint
|
|
89
|
+
inflight = null;
|
|
24
90
|
S.loadedAt = new Date();
|
|
25
91
|
if (!soft) render();
|
|
26
92
|
stampLoaded();
|
|
93
|
+
// The badges are now claiming a count from before the sync. Re-ask, and say so meanwhile.
|
|
94
|
+
countInBackground();
|
|
95
|
+
} finally {
|
|
96
|
+
bar?.classList.remove("spin");
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The same thing, for coming back to the tab.
|
|
102
|
+
*
|
|
103
|
+
* Asked for by a focus event rather than by a person, so it has to earn every visible effect.
|
|
104
|
+
* A full refresh drops the inbox, which sends the next paint to GitHub for four seconds and
|
|
105
|
+
* throws away whatever was on screen — including a half-written issue. Almost every focus
|
|
106
|
+
* finds nothing new, so this one syncs, looks, and only then does any of that.
|
|
107
|
+
*/
|
|
108
|
+
export async function refreshQuietly() {
|
|
109
|
+
const bar = $("#refreshall");
|
|
110
|
+
bar?.classList.add("spin");
|
|
111
|
+
try {
|
|
112
|
+
S.sync = await getSync();
|
|
113
|
+
const data = await getOrg();
|
|
114
|
+
const changed = shape(data) !== shape(S.data);
|
|
115
|
+
S.data = data;
|
|
116
|
+
S.loadedAt = new Date();
|
|
117
|
+
stampLoaded();
|
|
118
|
+
// Something landed in a checkout: the inbox is stale too, and the screen should say so.
|
|
119
|
+
if (changed || (S.sync?.results ?? []).some((r) => r.pulled)) {
|
|
120
|
+
S.inbox = null;
|
|
121
|
+
inflight = null;
|
|
122
|
+
render();
|
|
123
|
+
countInBackground();
|
|
124
|
+
}
|
|
27
125
|
} finally {
|
|
28
126
|
bar?.classList.remove("spin");
|
|
29
127
|
}
|
|
30
128
|
}
|
|
31
129
|
|
|
130
|
+
/** The export minus its timestamp, which is stamped per request and always differs. */
|
|
131
|
+
function shape(data) {
|
|
132
|
+
if (!data) return "";
|
|
133
|
+
const { generatedAt, ...rest } = data;
|
|
134
|
+
return JSON.stringify(rest);
|
|
135
|
+
}
|
|
136
|
+
|
|
32
137
|
export function stampLoaded() {
|
|
33
138
|
const s = $("#loaded");
|
|
34
139
|
if (s && S.loadedAt) s.textContent = "data " + ago(S.loadedAt.toISOString());
|
|
@@ -40,20 +145,45 @@ export function syncNotice() {
|
|
|
40
145
|
const stuck = (S.sync?.results ?? []).filter((r) => r.skipped || r.error || r.behind > 0);
|
|
41
146
|
if (!stuck.length) return null;
|
|
42
147
|
const d = el("div", { className: "notice" });
|
|
43
|
-
|
|
148
|
+
const lines = el("div");
|
|
149
|
+
lines.innerHTML = stuck
|
|
44
150
|
.map(
|
|
45
151
|
(r) =>
|
|
46
152
|
"<b>" + esc(r.dir) + "</b> " +
|
|
47
153
|
(r.error
|
|
48
154
|
? "could not sync: " + esc(r.error)
|
|
49
155
|
: r.skipped === "dirty"
|
|
50
|
-
? "is " + r.behind + " behind and has uncommitted changes, so it was not pulled"
|
|
156
|
+
? "is " + r.behind + " behind and has uncommitted changes, so it was not pulled. " +
|
|
157
|
+
"Commit or stash them, then pull."
|
|
51
158
|
: r.skipped === "diverged"
|
|
52
|
-
? "has diverged (" + r.ahead + " ahead, " + r.behind + " behind)"
|
|
159
|
+
? "has diverged (" + r.ahead + " ahead, " + r.behind + " behind). " +
|
|
160
|
+
"Push or rebase it by hand: a merge is not this button's to make."
|
|
53
161
|
: r.skipped === "no-remote"
|
|
54
162
|
? "has no remote"
|
|
55
|
-
: "is
|
|
163
|
+
: "is " + r.behind + " behind"),
|
|
56
164
|
)
|
|
57
165
|
.join("<br>");
|
|
166
|
+
d.append(lines);
|
|
167
|
+
|
|
168
|
+
/* The same conservative pull `roster portal` runs on a refresh, on demand: fetch, and pull
|
|
169
|
+
only what is clean and fast-forward. It is here because the answer to "commit those and
|
|
170
|
+
pull" should not be to reload the page and hope. */
|
|
171
|
+
const status = el("span", { className: "meta" });
|
|
172
|
+
const pull = el("button", { className: "ghbtn", textContent: "Pull now" });
|
|
173
|
+
pull.onclick = async () => {
|
|
174
|
+
pull.disabled = true;
|
|
175
|
+
status.textContent = "pulling…";
|
|
176
|
+
status.className = "meta";
|
|
177
|
+
S.sync = await getSync();
|
|
178
|
+
const pulled = (S.sync?.results ?? []).filter((r) => r.pulled);
|
|
179
|
+
// Anything that landed is on disk now, so the export and the inbox are both behind it.
|
|
180
|
+
if (pulled.length) await refreshAll(false);
|
|
181
|
+
else {
|
|
182
|
+
pull.disabled = false;
|
|
183
|
+
status.textContent = "nothing could be pulled";
|
|
184
|
+
render();
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
d.append(el("div", { className: "row", style: "margin-top:10px" }, [pull, status]));
|
|
58
188
|
return d;
|
|
59
189
|
}
|
|
@@ -22,7 +22,10 @@ export const VIEWS = [
|
|
|
22
22
|
export const VIEW_ALIAS = { memory: "brain" };
|
|
23
23
|
|
|
24
24
|
/** Screens that belong to the org rather than to one staff member. */
|
|
25
|
-
export const ORG_WIDE = new Set(["inbox", "org", "staff", "docs"]);
|
|
25
|
+
export const ORG_WIDE = new Set(["inbox", "prs", "org", "staff", "docs"]);
|
|
26
|
+
|
|
27
|
+
/** The two screens that read the inbox, and so share its filters and its `?t=` thread. */
|
|
28
|
+
const INBOXY = new Set(["inbox", "prs"]);
|
|
26
29
|
|
|
27
30
|
export const S = {
|
|
28
31
|
/** The org export from /api/org. Everything the brain screens read comes off this. */
|
|
@@ -48,13 +51,16 @@ export const S = {
|
|
|
48
51
|
changedFilter: "",
|
|
49
52
|
|
|
50
53
|
openDoc: null,
|
|
54
|
+
/** The docs search box. Its own field: the Docs screen keeps it while you read a page. */
|
|
55
|
+
docQuery: "",
|
|
51
56
|
|
|
52
57
|
/** Which kind of run the Prompt screen is showing, and which layer of it is open. */
|
|
53
58
|
promptKind: "daily",
|
|
54
59
|
promptOpen: null,
|
|
55
60
|
|
|
56
|
-
/** Which org-layer file the Org screen is showing. */
|
|
61
|
+
/** Which org-layer file the Org screen is showing, and what it found on disk. */
|
|
57
62
|
orgOpen: null,
|
|
63
|
+
orgFiles: [],
|
|
58
64
|
|
|
59
65
|
inboxFilter: "",
|
|
60
66
|
inboxStaff: "",
|
|
@@ -71,8 +77,35 @@ export const S = {
|
|
|
71
77
|
export const openCount = () =>
|
|
72
78
|
(S.inbox?.items ?? []).filter((i) => i.state === "OPEN").length;
|
|
73
79
|
|
|
80
|
+
/** The same, for the screen that is only pull requests. */
|
|
81
|
+
export const openPrCount = () =>
|
|
82
|
+
(S.inbox?.items ?? []).filter((i) => i.state === "OPEN" && i.kind === "pr").length;
|
|
83
|
+
|
|
74
84
|
export const staff = () => S.data.staff.find((s) => s.handle === S.staffHandle) ?? S.data.staff[0];
|
|
75
85
|
|
|
86
|
+
/**
|
|
87
|
+
* Everyone the staff answer to.
|
|
88
|
+
*
|
|
89
|
+
* An org can have more than one. `humans` is the list the export sends now; `human` is what a
|
|
90
|
+
* portal whose server predates the list sends, and falling back to it is what keeps the page
|
|
91
|
+
* working across the upgrade rather than showing an org with nobody in it.
|
|
92
|
+
*/
|
|
93
|
+
export const humansOf = () =>
|
|
94
|
+
S.data?.humans?.length ? S.data.humans : S.data?.human?.github ? [S.data.human] : [];
|
|
95
|
+
|
|
96
|
+
/** Whether a login is one of them. Assignment to any of them is assignment to "us". */
|
|
97
|
+
export const isHuman = (login) =>
|
|
98
|
+
!!login &&
|
|
99
|
+
humansOf().some((h) => String(h.github ?? "").toLowerCase() === String(login).toLowerCase());
|
|
100
|
+
|
|
101
|
+
/** What to call them collectively, for a filter label or a count. */
|
|
102
|
+
export const humanLabel = () => {
|
|
103
|
+
const humans = humansOf();
|
|
104
|
+
if (!humans.length) return "you";
|
|
105
|
+
if (humans.length === 1) return humans[0].name ?? humans[0].github ?? "you";
|
|
106
|
+
return humans.map((h) => h.name ?? h.github).join(" or ");
|
|
107
|
+
};
|
|
108
|
+
|
|
76
109
|
export function readHash() {
|
|
77
110
|
const raw = (location.hash || "").replace(/^#\/?/, "");
|
|
78
111
|
if (!raw) return null;
|
|
@@ -97,7 +130,14 @@ export function writeHash(push) {
|
|
|
97
130
|
if (S.applyingHash) return;
|
|
98
131
|
if (!S.staffHandle && !ORG_WIDE.has(S.view)) return;
|
|
99
132
|
const params = new URLSearchParams();
|
|
100
|
-
const q =
|
|
133
|
+
const q =
|
|
134
|
+
S.view === "brain"
|
|
135
|
+
? S.fileQuery
|
|
136
|
+
: S.view === "changed"
|
|
137
|
+
? S.changedQuery
|
|
138
|
+
: S.view === "docs"
|
|
139
|
+
? S.docQuery
|
|
140
|
+
: S.query;
|
|
101
141
|
if (q) params.set("q", q);
|
|
102
142
|
if (S.view === "brain" && S.openFile) params.set("f", S.openFile);
|
|
103
143
|
if (S.view === "docs" && S.openDoc) params.set("p", S.openDoc);
|
|
@@ -106,7 +146,7 @@ export function writeHash(push) {
|
|
|
106
146
|
if (S.promptKind !== "daily") params.set("k", S.promptKind);
|
|
107
147
|
if (S.promptOpen) params.set("f", S.promptOpen);
|
|
108
148
|
}
|
|
109
|
-
if (S.view
|
|
149
|
+
if (INBOXY.has(S.view)) {
|
|
110
150
|
if (S.inboxFilter) params.set("s", S.inboxFilter);
|
|
111
151
|
if (S.inboxStaff) params.set("w", S.inboxStaff);
|
|
112
152
|
if (S.inboxState) params.set("x", S.inboxState);
|
|
@@ -134,6 +174,8 @@ export function applyHash() {
|
|
|
134
174
|
S.openFile = h.f;
|
|
135
175
|
} else if (S.view === "changed") {
|
|
136
176
|
S.changedQuery = h.q;
|
|
177
|
+
} else if (S.view === "docs") {
|
|
178
|
+
S.docQuery = h.q;
|
|
137
179
|
} else {
|
|
138
180
|
S.query = h.q;
|
|
139
181
|
}
|
|
@@ -146,7 +188,7 @@ export function applyHash() {
|
|
|
146
188
|
S.promptOpen = h.f;
|
|
147
189
|
}
|
|
148
190
|
|
|
149
|
-
if (S.view
|
|
191
|
+
if (INBOXY.has(S.view)) {
|
|
150
192
|
S.inboxFilter = h.s;
|
|
151
193
|
S.inboxStaff = h.w;
|
|
152
194
|
S.inboxState = h.x;
|
|
@@ -9,8 +9,17 @@ import { el, toClipboard } from "../dom.js";
|
|
|
9
9
|
|
|
10
10
|
const LEVEL_ORDER = { fail: 0, warn: 1, ok: 2 };
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* @param opts.shell when Health draws this, the section it lives in: `{actions, say}`. The
|
|
14
|
+
* setup screen passes nothing and gets the layout it has always had. Its own screen is a
|
|
15
|
+
* wizard, where a chunky button and a paragraph under it are right; on Health it is one
|
|
16
|
+
* section of four, and has to look like the other three.
|
|
17
|
+
*/
|
|
12
18
|
export async function checklist(host, opts = {}) {
|
|
13
|
-
|
|
19
|
+
const inSection = Boolean(opts.shell);
|
|
20
|
+
const btnClass = inSection ? "ghbtn" : "btn";
|
|
21
|
+
const noteClass = inSection ? "hnote" : "sub";
|
|
22
|
+
host.replaceChildren(el("p", { className: noteClass, textContent: "Checking…" }));
|
|
14
23
|
|
|
15
24
|
let report;
|
|
16
25
|
try {
|
|
@@ -26,9 +35,10 @@ export async function checklist(host, opts = {}) {
|
|
|
26
35
|
const bad = findings.filter((f) => f.level !== "ok");
|
|
27
36
|
|
|
28
37
|
host.replaceChildren();
|
|
38
|
+
opts.shell?.say(bad.length ? String(bad.length) : "clean");
|
|
29
39
|
host.append(
|
|
30
40
|
el("p", {
|
|
31
|
-
className:
|
|
41
|
+
className: noteClass,
|
|
32
42
|
textContent: bad.length
|
|
33
43
|
? `${bad.filter((f) => f.level === "fail").length} failing, ${bad.filter((f) => f.level === "warn").length} to look at.` +
|
|
34
44
|
(report.online ? "" : " Local checks only.")
|
|
@@ -51,7 +61,7 @@ export async function checklist(host, opts = {}) {
|
|
|
51
61
|
|
|
52
62
|
/* The isitagentready move: the findings as instructions, for the agent you already have
|
|
53
63
|
pointed at this workspace. What only a person can do is deliberately not in that text. */
|
|
54
|
-
const copy = el("button", { className: "
|
|
64
|
+
const copy = el("button", { className: btnClass + " primary", textContent: "Copy all instructions" });
|
|
55
65
|
const note = el("span", { className: "meta" });
|
|
56
66
|
copy.onclick = async () => {
|
|
57
67
|
copy.disabled = true;
|
|
@@ -70,14 +80,17 @@ export async function checklist(host, opts = {}) {
|
|
|
70
80
|
}
|
|
71
81
|
};
|
|
72
82
|
|
|
73
|
-
const again = el("button", { className:
|
|
83
|
+
const again = el("button", { className: btnClass, textContent: "Check again" });
|
|
74
84
|
again.onclick = () => checklist(host, opts);
|
|
75
85
|
|
|
76
|
-
|
|
86
|
+
const feet = opts.shell?.actions ?? host;
|
|
87
|
+
if (opts.shell) feet.replaceChildren();
|
|
88
|
+
feet.append(copy, again, note);
|
|
77
89
|
if (bad.length) {
|
|
78
|
-
|
|
90
|
+
feet.append(
|
|
79
91
|
el("p", {
|
|
80
|
-
className:
|
|
92
|
+
className: noteClass,
|
|
93
|
+
style: "flex-basis:100%",
|
|
81
94
|
textContent:
|
|
82
95
|
"Paste it into Cursor, Claude Code or anything else that can edit files here, then " +
|
|
83
96
|
"check again. The ids above should be gone.",
|