@nanocollective/roster 0.1.0-alpha.5 → 0.1.0-alpha.50
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/README.md +70 -84
- package/dist/cli.js +4833 -2752
- package/docs/README.md +9 -6
- package/docs/agents.md +24 -20
- package/docs/architecture.md +13 -5
- package/docs/charters/analyst.md +65 -0
- package/docs/charters/cmo.md +69 -0
- package/docs/charters/community.md +63 -0
- package/docs/charters/cto.md +71 -0
- package/docs/charters/designer.md +65 -0
- package/docs/charters/devops.md +65 -0
- package/docs/charters/pm.md +70 -0
- package/docs/charters/qa.md +65 -0
- package/docs/charters/support.md +60 -0
- package/docs/charters/writer.md +63 -0
- package/docs/commands.md +93 -7
- package/docs/concepts.md +61 -14
- package/docs/cost.md +36 -1
- package/docs/developing.md +16 -21
- package/docs/doctor-codes.md +10 -2
- package/docs/export.md +2 -0
- package/docs/extending.md +2 -2
- package/docs/getting-started.md +128 -78
- 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 +94 -123
- package/docs/memory.md +21 -3
- package/docs/org-yaml.md +40 -2
- package/docs/portal.md +165 -48
- package/docs/prompts.md +31 -4
- package/docs/security.md +37 -5
- package/docs/session-workflow.md +63 -17
- package/docs/staff-yaml.md +30 -3
- package/docs/troubleshooting.md +8 -8
- package/docs/upgrading.md +9 -3
- package/docs/writing-a-charter.md +28 -0
- package/package.json +18 -20
- package/templates/brain/.github/workflows/%%STAFF%%-daily.yaml +26 -1
- package/templates/brain/.github/workflows/%%STAFF%%-mention.yaml +41 -7
- package/templates/brain/CHARTER.md +3 -3
- package/templates/brain/README.md +1 -0
- package/templates/brain/log/decisions.md +3 -0
- package/templates/brain/staff.yaml +4 -1
- package/templates/brain/strategy/ideas.md +7 -0
- package/templates/briefs/priorities.md +46 -0
- package/templates/ops/.github/workflows/session.yaml +236 -15
- package/templates/ops/agents.mjs +7 -3
- package/templates/ops/compose.mjs +31 -3
- package/templates/ops/inflight.mjs +157 -0
- package/templates/ops/org/operating.md +43 -4
- package/templates/ops/org/voice.md +9 -0
- package/templates/ops/prompts/_inflight.md +14 -0
- package/templates/ops/prompts/_paths.md +2 -1
- package/templates/ops/prompts/daily.md +37 -9
- package/templates/ops/prompts/mention.md +21 -0
- package/templates/ops/run-record.mjs +146 -0
- package/templates/portal/css/base.css +167 -73
- package/templates/portal/css/brain.css +23 -20
- package/templates/portal/css/diff.css +10 -9
- package/templates/portal/css/graph.css +12 -7
- package/templates/portal/css/health.css +13 -11
- package/templates/portal/css/home.css +93 -0
- package/templates/portal/css/inbox.css +45 -25
- package/templates/portal/css/layout.css +90 -46
- package/templates/portal/css/markdown.css +36 -14
- package/templates/portal/css/runs.css +13 -0
- package/templates/portal/css/setup.css +116 -34
- package/templates/portal/index.html +21 -9
- package/templates/portal/js/api.js +44 -4
- package/templates/portal/js/app.js +94 -9
- package/templates/portal/js/dialog.js +83 -0
- package/templates/portal/js/homesort.js +174 -0
- package/templates/portal/js/icons.js +37 -0
- package/templates/portal/js/inflight.js +18 -0
- package/templates/portal/js/md.js +5 -2
- package/templates/portal/js/mdedit.js +84 -0
- package/templates/portal/js/readiness.js +70 -0
- package/templates/portal/js/refresh.js +10 -2
- package/templates/portal/js/state.js +11 -5
- package/templates/portal/js/views/app.js +24 -7
- package/templates/portal/js/views/brain.js +1 -1
- package/templates/portal/js/views/checklist.js +10 -4
- package/templates/portal/js/views/credential.js +84 -0
- package/templates/portal/js/views/graph.js +1 -1
- package/templates/portal/js/views/health.js +17 -4
- package/templates/portal/js/views/hire.js +593 -0
- package/templates/portal/js/views/home.js +546 -0
- package/templates/portal/js/views/inbox.js +226 -70
- package/templates/portal/js/views/org.js +46 -106
- package/templates/portal/js/views/orgedit.js +234 -0
- package/templates/portal/js/views/paste.js +87 -21
- package/templates/portal/js/views/prompt.js +11 -4
- package/templates/portal/js/views/repos.js +20 -15
- package/templates/portal/js/views/runonce.js +94 -0
- package/templates/portal/js/views/runs.js +170 -0
- package/templates/portal/js/views/setup.js +261 -75
- package/templates/portal/js/views/staff.js +170 -243
- package/templates/portal/js/views/todo.js +62 -0
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/* Where every issue and pull request goes on Home.
|
|
2
|
+
*
|
|
3
|
+
* Every open item lands in exactly one place, so nothing is missed and nothing shows twice:
|
|
4
|
+
*
|
|
5
|
+
* needs an ask on a person, or a pull request ready to merge
|
|
6
|
+
* requests something a person asked a staff member for
|
|
7
|
+
* staff a staff member's own work: their status issue, a peer's ask, their own issues
|
|
8
|
+
* elsewhere draft pull requests, and product or ops issues nobody has put on a person
|
|
9
|
+
*
|
|
10
|
+
* `unread` is not a place: it lists what in `staff` and `elsewhere` has activity you have not
|
|
11
|
+
* seen (from your GitHub notifications), since those two are otherwise off Home. Items in the
|
|
12
|
+
* other places carry their own unread mark.
|
|
13
|
+
*
|
|
14
|
+
* Closed items only matter when a staff member closed them today: that is "Closed today", with
|
|
15
|
+
* Reopen beside each, because staff now close things without asking. */
|
|
16
|
+
|
|
17
|
+
/** The kinds an ask on a person carries. Older trackers used other words for a chore. */
|
|
18
|
+
const KINDS = ["decision", "review", "chore"];
|
|
19
|
+
const CHORE_WORDS = ["setup", "submit", "data", "blocked"];
|
|
20
|
+
|
|
21
|
+
const lower = (s) => String(s ?? "").toLowerCase();
|
|
22
|
+
|
|
23
|
+
/** How old a run report can be and still be this morning's. */
|
|
24
|
+
const REPORT_HOURS = 20;
|
|
25
|
+
|
|
26
|
+
/** A run report as lines, without the @mention it opens with. */
|
|
27
|
+
function reportLines(body) {
|
|
28
|
+
return String(body ?? "")
|
|
29
|
+
.split("\n")
|
|
30
|
+
.map((l) => l.replace(/^\s*(@[\w-]+\s*)+/, "").replace(/^[-*]\s+/, "").trim())
|
|
31
|
+
.filter(Boolean)
|
|
32
|
+
.slice(0, 5);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @param items the inbox list: open and recently closed issues and pull requests
|
|
37
|
+
* @param staff S.data.staff: handle, name, brain, statusIssue, mention
|
|
38
|
+
* @param humans [{ github, marker }]
|
|
39
|
+
* @param live /api/live's staff list, or [] before it has answered
|
|
40
|
+
* @param now for "today", in the viewer's own day
|
|
41
|
+
*/
|
|
42
|
+
export function sortHome(items, staff, humans, live = [], now = new Date()) {
|
|
43
|
+
const people = new Set(humans.map((h) => lower(h.github)).filter(Boolean));
|
|
44
|
+
const markers = new Set(humans.map((h) => lower(h.marker)).filter(Boolean));
|
|
45
|
+
const byBrain = new Map(staff.filter((s) => s.brain).map((s) => [s.brain, s]));
|
|
46
|
+
const working = new Set();
|
|
47
|
+
for (const l of live) {
|
|
48
|
+
const s = staff.find((x) => x.handle === l.handle);
|
|
49
|
+
for (const r of l.running ?? []) if (s && r.issue) working.add(`${s.brain}#${r.issue}`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const out = {
|
|
53
|
+
reports: [],
|
|
54
|
+
needs: [],
|
|
55
|
+
requests: [],
|
|
56
|
+
staff: [],
|
|
57
|
+
elsewhere: [],
|
|
58
|
+
unread: [],
|
|
59
|
+
closedToday: [],
|
|
60
|
+
};
|
|
61
|
+
const today = dayOf(now);
|
|
62
|
+
|
|
63
|
+
for (const item of items) {
|
|
64
|
+
const owner = byBrain.get(item.repo) ?? null;
|
|
65
|
+
|
|
66
|
+
if (item.state !== "OPEN") {
|
|
67
|
+
if (
|
|
68
|
+
owner &&
|
|
69
|
+
item.kind === "issue" &&
|
|
70
|
+
item.closedAt &&
|
|
71
|
+
dayOf(new Date(item.closedAt)) === today &&
|
|
72
|
+
!people.has(lower(item.closedBy))
|
|
73
|
+
) {
|
|
74
|
+
out.closedToday.push({ item, staff: owner, reason: reasonOf(item, people) });
|
|
75
|
+
}
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// The run report: the last word on a status issue, from the staff member, in the last day.
|
|
80
|
+
const c = item.lastComment;
|
|
81
|
+
if (
|
|
82
|
+
owner &&
|
|
83
|
+
item.number === owner.statusIssue &&
|
|
84
|
+
c &&
|
|
85
|
+
!people.has(lower(c.author)) &&
|
|
86
|
+
now - new Date(c.createdAt) < REPORT_HOURS * 3_600_000
|
|
87
|
+
) {
|
|
88
|
+
out.reports.push({ staff: owner, item, at: c.createdAt, lines: reportLines(c.body) });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const place = placeOf(item, owner, people, markers);
|
|
92
|
+
const entry = { item, staff: owner, ...place };
|
|
93
|
+
if (place.place === "requests") {
|
|
94
|
+
entry.status = working.has(`${item.repo}#${item.number}`)
|
|
95
|
+
? "working"
|
|
96
|
+
: item.lastComment && !people.has(lower(item.lastComment.author))
|
|
97
|
+
? "answered"
|
|
98
|
+
: "waiting";
|
|
99
|
+
}
|
|
100
|
+
out[place.place].push(entry);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const oldest = (a, b) => a.item.createdAt.localeCompare(b.item.createdAt);
|
|
104
|
+
out.needs.sort(oldest);
|
|
105
|
+
out.requests.sort((a, b) => b.item.updatedAt.localeCompare(a.item.updatedAt));
|
|
106
|
+
out.closedToday.sort((a, b) => b.item.closedAt.localeCompare(a.item.closedAt));
|
|
107
|
+
out.unread = [...out.staff, ...out.elsewhere]
|
|
108
|
+
.filter((e) => e.item.unread)
|
|
109
|
+
.sort((a, b) => b.item.updatedAt.localeCompare(a.item.updatedAt));
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Which place an open item belongs in, and for an ask, which kind it is. */
|
|
114
|
+
export function placeOf(item, owner, people, markers) {
|
|
115
|
+
if (item.kind === "pr") {
|
|
116
|
+
return item.draft ? { place: "elsewhere", why: "draft" } : { place: "needs", kind: "merge" };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const labels = (item.labels ?? []).map(lower);
|
|
120
|
+
const onPerson =
|
|
121
|
+
(item.assignees ?? []).some((a) => people.has(lower(a))) ||
|
|
122
|
+
labels.some((l) => markers.has(l));
|
|
123
|
+
const byPerson = people.has(lower(item.author));
|
|
124
|
+
/* You had the last word on an ask: approved it, answered it, said go with the default. It is
|
|
125
|
+
waiting on them now, so it is one of your requests until they reply or close it. If they
|
|
126
|
+
reply, the last word is theirs and it is back on you. */
|
|
127
|
+
const youAnswered = Boolean(item.lastComment) && people.has(lower(item.lastComment.author));
|
|
128
|
+
|
|
129
|
+
if (!owner) {
|
|
130
|
+
if (!onPerson || byPerson) {
|
|
131
|
+
return { place: "elsewhere", why: item.role === "product" ? "contributor" : "other" };
|
|
132
|
+
}
|
|
133
|
+
return youAnswered ? { place: "requests" } : { place: "needs", kind: kindOf(labels) };
|
|
134
|
+
}
|
|
135
|
+
if (item.number === owner.statusIssue || labels.includes("keep-open")) {
|
|
136
|
+
return { place: "staff", why: "status" };
|
|
137
|
+
}
|
|
138
|
+
if (byPerson) return { place: "requests" };
|
|
139
|
+
if (onPerson) return youAnswered ? { place: "requests" } : { place: "needs", kind: kindOf(labels) };
|
|
140
|
+
return {
|
|
141
|
+
place: "staff",
|
|
142
|
+
why: labels.some((l) => l.startsWith("from-")) ? "peer" : "own",
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function kindOf(labels) {
|
|
147
|
+
const found = KINDS.find((k) => labels.includes(k));
|
|
148
|
+
if (found) return found;
|
|
149
|
+
return labels.some((l) => CHORE_WORDS.includes(l)) ? "chore" : "ask";
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** The one line a staff member closed it with: their last comment's first line. */
|
|
153
|
+
function reasonOf(item, people) {
|
|
154
|
+
const c = item.lastComment;
|
|
155
|
+
if (!c || people.has(lower(c.author))) return "";
|
|
156
|
+
const line = String(c.body ?? "")
|
|
157
|
+
.split("\n")
|
|
158
|
+
.map((l) => l.trim())
|
|
159
|
+
.find(Boolean);
|
|
160
|
+
return line ? line.replace(/^[#>*\s-]+/, "").slice(0, 160) : "";
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** A date as the viewer's own calendar day. */
|
|
164
|
+
export function dayOf(d) {
|
|
165
|
+
return `${d.getFullYear()}-${d.getMonth() + 1}-${d.getDate()}`;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Whole days from today to a YYYY-MM-DD date: 0 today, negative once it has passed. */
|
|
169
|
+
export function daysUntil(date, now = new Date()) {
|
|
170
|
+
const [y, m, d] = String(date).split("-").map(Number);
|
|
171
|
+
const due = new Date(y, m - 1, d);
|
|
172
|
+
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
|
173
|
+
return Math.round((due - start) / 86_400_000);
|
|
174
|
+
}
|
|
@@ -13,6 +13,26 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
const PATHS = {
|
|
16
|
+
"code":
|
|
17
|
+
"<polyline points=\"16 18 22 12 16 6\" /> <polyline points=\"8 6 2 12 8 18\" />",
|
|
18
|
+
"megaphone":
|
|
19
|
+
"<path d=\"m3 11 18-5v12L3 14v-3z\" /> <path d=\"M11.6 16.8a3 3 0 1 1-5.8-1.6\" />",
|
|
20
|
+
"life-buoy":
|
|
21
|
+
"<circle cx=\"12\" cy=\"12\" r=\"10\" /> <path d=\"m4.93 4.93 4.24 4.24\" /> <path d=\"m14.83 9.17 4.24-4.24\" /> <path d=\"m14.83 14.83 4.24 4.24\" /> <path d=\"m9.17 14.83-4.24 4.24\" /> <circle cx=\"12\" cy=\"12\" r=\"4\" />",
|
|
22
|
+
"compass":
|
|
23
|
+
"<circle cx=\"12\" cy=\"12\" r=\"10\" /> <polygon points=\"16.24 7.76 14.12 14.12 7.76 16.24 9.88 9.88 16.24 7.76\" />",
|
|
24
|
+
"brush":
|
|
25
|
+
"<path d=\"m9.06 11.9 8.07-8.06a2.85 2.85 0 1 1 4.03 4.03l-8.06 8.08\" /> <path d=\"M7.07 14.94c-1.66 0-3 1.35-3 3.02 0 1.33-2.5 1.52-2 2.02 1.08 1.1 2.49 2.02 4 2.02 2.2 0 4-1.8 4-4.04a3.01 3.01 0 0 0-3-3.02z\" />",
|
|
26
|
+
"bug":
|
|
27
|
+
"<path d=\"m8 2 1.88 1.88\" /> <path d=\"M14.12 3.88 16 2\" /> <path d=\"M9 7.13v-1a3.003 3.003 0 1 1 6 0v1\" /> <path d=\"M12 20c-3.3 0-6-2.7-6-6v-3a4 4 0 0 1 4-4h4a4 4 0 0 1 4 4v3c0 3.3-2.7 6-6 6\" /> <path d=\"M12 20v-9\" /> <path d=\"M6.53 9C4.6 8.8 3 7.1 3 5\" /> <path d=\"M6 13H2\" /> <path d=\"M3 21c0-2.1 1.7-3.9 3.8-4\" /> <path d=\"M20.97 5c0 2.1-1.6 3.8-3.5 4\" /> <path d=\"M22 13h-4\" /> <path d=\"M17.2 17c2.1.1 3.8 1.9 3.8 4\" />",
|
|
28
|
+
"server":
|
|
29
|
+
"<rect width=\"20\" height=\"8\" x=\"2\" y=\"2\" rx=\"2\" ry=\"2\" /> <rect width=\"20\" height=\"8\" x=\"2\" y=\"14\" rx=\"2\" ry=\"2\" /> <line x1=\"6\" x2=\"6.01\" y1=\"6\" y2=\"6\" /> <line x1=\"6\" x2=\"6.01\" y1=\"18\" y2=\"18\" />",
|
|
30
|
+
"book-open":
|
|
31
|
+
"<path d=\"M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z\" /> <path d=\"M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z\" />",
|
|
32
|
+
"bar-chart":
|
|
33
|
+
"<line x1=\"12\" x2=\"12\" y1=\"20\" y2=\"10\" /> <line x1=\"18\" x2=\"18\" y1=\"20\" y2=\"4\" /> <line x1=\"6\" x2=\"6\" y1=\"20\" y2=\"16\" />",
|
|
34
|
+
"users":
|
|
35
|
+
"<path d=\"M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2\" /> <circle cx=\"9\" cy=\"7\" r=\"4\" /> <path d=\"M22 21v-2a4 4 0 0 0-3-3.87\" /> <path d=\"M16 3.13a4 4 0 0 1 0 7.75\" />",
|
|
16
36
|
"check":
|
|
17
37
|
"<path d=\"M20 6 9 17l-5-5\" />",
|
|
18
38
|
"chevron":
|
|
@@ -96,6 +116,23 @@ export function icon(name, cls = "") {
|
|
|
96
116
|
return span.firstChild ?? span;
|
|
97
117
|
}
|
|
98
118
|
|
|
119
|
+
/* A staff member's icon: `icon:` in their staff.yaml, or one picked from their role. */
|
|
120
|
+
const ROLE_ICONS = {
|
|
121
|
+
cto: "code",
|
|
122
|
+
cmo: "megaphone",
|
|
123
|
+
support: "life-buoy",
|
|
124
|
+
pm: "compass",
|
|
125
|
+
designer: "brush",
|
|
126
|
+
qa: "bug",
|
|
127
|
+
devops: "server",
|
|
128
|
+
writer: "book-open",
|
|
129
|
+
analyst: "bar-chart",
|
|
130
|
+
community: "users",
|
|
131
|
+
};
|
|
132
|
+
export function staffIcon(s) {
|
|
133
|
+
return s?.icon && hasIcon(s.icon) ? s.icon : (ROLE_ICONS[s?.handle] ?? "person");
|
|
134
|
+
}
|
|
135
|
+
|
|
99
136
|
export function hasIcon(name) {
|
|
100
137
|
return Object.hasOwn(PATHS, name);
|
|
101
138
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/* How many requests the page has open. The setup dialog waits for this to reach zero before it
|
|
2
|
+
* shows itself, because the list it draws is only final once every answer is back. */
|
|
3
|
+
|
|
4
|
+
let open = 0;
|
|
5
|
+
|
|
6
|
+
if (typeof globalThis.fetch === "function" && !globalThis.fetch.counted) {
|
|
7
|
+
const real = globalThis.fetch.bind(globalThis);
|
|
8
|
+
const counted = (...args) => {
|
|
9
|
+
open++;
|
|
10
|
+
return real(...args).finally(() => {
|
|
11
|
+
open--;
|
|
12
|
+
});
|
|
13
|
+
};
|
|
14
|
+
counted.counted = true;
|
|
15
|
+
globalThis.fetch = counted;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const pending = () => open;
|
|
@@ -361,7 +361,9 @@ function mdAsset(src, opts) {
|
|
|
361
361
|
return path ? "/api/file?path=" + encodeURIComponent(opts.file.staffDir + "/" + path) : src;
|
|
362
362
|
}
|
|
363
363
|
|
|
364
|
-
|
|
364
|
+
/* #123, repo#123 and owner/repo#123. The short repo form is how the staff write about the other
|
|
365
|
+
repos in their own org (pip-web#172), and it was left as plain text. */
|
|
366
|
+
const REF_RE = /(^|[\s(>])((?:[\w.-]+\/)?[\w.-]+)?#(\d+)\b/g;
|
|
365
367
|
// GitHub's own username shape, plus the short aliases the agents answer to.
|
|
366
368
|
const AT_RE = /(^|[\s(>])@([a-z\d](?:[a-z\d]|-(?=[a-z\d])){0,38})\b/gi;
|
|
367
369
|
|
|
@@ -381,7 +383,8 @@ export function chips(html, opts = {}) {
|
|
|
381
383
|
if (inA) return p;
|
|
382
384
|
return p
|
|
383
385
|
.replace(REF_RE, (m, pre, repo, n) => {
|
|
384
|
-
const
|
|
386
|
+
const owner = String(opts.repo ?? "").split("/")[0];
|
|
387
|
+
const target = !repo ? opts.repo : repo.includes("/") ? repo : owner ? owner + "/" + repo : "";
|
|
385
388
|
if (!target) return m;
|
|
386
389
|
// /issues/N redirects to /pull/N when it is a PR, so one form is always right.
|
|
387
390
|
return pre + '<a class="ref" href="https://github.com/' + target + "/issues/" + n +
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/* Colour behind a textarea, for the files people edit here.
|
|
2
|
+
*
|
|
3
|
+
* The textarea stays the thing you type into, so undo, selection, spellcheck and ⌘S all work
|
|
4
|
+
* as they always did. Its text is made transparent and a <pre> with the same box sits behind
|
|
5
|
+
* it, redrawn on every keystroke. The editors grow to fit rather than scroll, which is what
|
|
6
|
+
* keeps the two lined up without syncing a scroll position.
|
|
7
|
+
*
|
|
8
|
+
* Markdown here is a per-line tokeniser, like yaml.js: enough to read by, and anything it does
|
|
9
|
+
* not recognise is plain text rather than an error.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { el, esc } from "./dom.js";
|
|
13
|
+
import { yamlHTML } from "./yaml.js";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Wrap a textarea so its contents are highlighted. Returns the wrapper, which is what goes on
|
|
17
|
+
* the page in place of the textarea.
|
|
18
|
+
* @param {"md"|"yaml"} lang
|
|
19
|
+
*/
|
|
20
|
+
export function highlighted(ta, lang = "md") {
|
|
21
|
+
const wrap = el("div", { className: "hled" });
|
|
22
|
+
const back = el("pre", { className: "hlback" });
|
|
23
|
+
back.setAttribute?.("aria-hidden", "true");
|
|
24
|
+
ta.classList?.add("hlfront");
|
|
25
|
+
wrap.append(back, ta);
|
|
26
|
+
/* The colours only line up if both boxes set text identically, and the textarea's box comes
|
|
27
|
+
from whichever class it already had, so it is read rather than restated in CSS. */
|
|
28
|
+
const match = () => {
|
|
29
|
+
if (typeof getComputedStyle !== "function" || !ta.isConnected) return;
|
|
30
|
+
const cs = getComputedStyle(ta);
|
|
31
|
+
for (const k of ["font", "lineHeight", "letterSpacing", "tabSize", "padding", "borderWidth", "borderStyle", "borderRadius"]) {
|
|
32
|
+
back.style[k] = cs[k];
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
const paint = () => {
|
|
36
|
+
match();
|
|
37
|
+
// A trailing newline in a textarea is a line; in a <pre> it is not, without something after it.
|
|
38
|
+
back.innerHTML = (lang === "yaml" ? yamlHTML(ta.value) : markdownHTML(ta.value)) + "\n ";
|
|
39
|
+
};
|
|
40
|
+
ta.addEventListener("input", paint);
|
|
41
|
+
paint();
|
|
42
|
+
// Setting .value from code fires no input event, so the owner can ask for a repaint.
|
|
43
|
+
wrap.repaint = paint;
|
|
44
|
+
return wrap;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Escaped, highlighted HTML for markdown source. */
|
|
48
|
+
export function markdownHTML(text) {
|
|
49
|
+
let fenced = false;
|
|
50
|
+
return String(text ?? "")
|
|
51
|
+
.split("\n")
|
|
52
|
+
.map((line) => {
|
|
53
|
+
if (/^\s*(```|~~~)/.test(line)) {
|
|
54
|
+
fenced = !fenced;
|
|
55
|
+
return span("mdfence", line);
|
|
56
|
+
}
|
|
57
|
+
if (fenced) return span("mdcode", line);
|
|
58
|
+
if (/^#{1,6}\s/.test(line)) return span("mdh", line);
|
|
59
|
+
if (/^\s*>/.test(line)) return span("mdq", line);
|
|
60
|
+
if (/^\s*([-*_])(\s*\1){2,}\s*$/.test(line)) return span("mdfence", line);
|
|
61
|
+
const list = /^(\s*)([-*+]|\d+[.)])(\s+)(.*)$/.exec(line);
|
|
62
|
+
if (list) return esc(list[1]) + span("mdl", list[2]) + esc(list[3]) + inline(list[4]);
|
|
63
|
+
return inline(line);
|
|
64
|
+
})
|
|
65
|
+
.join("\n");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const span = (cls, text) => '<span class="' + cls + '">' + esc(text) + "</span>";
|
|
69
|
+
|
|
70
|
+
/** Inline marks, on one line. Code spans first, so nothing inside one is read as markup. */
|
|
71
|
+
function inline(line) {
|
|
72
|
+
return line
|
|
73
|
+
.split(/(`[^`]+`)/)
|
|
74
|
+
.map((part, i) => (i % 2 ? span("mdc", part) : marks(part)))
|
|
75
|
+
.join("");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function marks(text) {
|
|
79
|
+
return esc(text)
|
|
80
|
+
.replace(/\{\{[^}]*\}\}/g, (m) => '<span class="mdt">' + m + "</span>")
|
|
81
|
+
.replace(/(\*\*|__)(?=\S)(.+?)(?<=\S)\1/g, '<span class="mdb">$1$2$1</span>')
|
|
82
|
+
.replace(/(^|[^*\w])([*_])(?=\S)([^*_]+?)(?<=\S)\2(?![*\w])/g, '$1<span class="mdi">$2$3$2</span>')
|
|
83
|
+
.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, '<span class="mdk">[$1]</span><span class="mdu">($2)</span>');
|
|
84
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/* Whether each staff member can actually run yet.
|
|
2
|
+
*
|
|
3
|
+
* A hire that had its App created but not installed, and no credential, looked finished: the
|
|
4
|
+
* card said so and Getting started went away. This is the one answer every screen asks. */
|
|
5
|
+
|
|
6
|
+
import { getStaffProgress } from "./api.js";
|
|
7
|
+
import { S } from "./state.js";
|
|
8
|
+
|
|
9
|
+
/** What is left for one staff member, as short instructions. Empty when they are ready. */
|
|
10
|
+
export async function whatsLeft(s, fresh = false) {
|
|
11
|
+
const left = [];
|
|
12
|
+
if (s.rig?.charterStub) left.push("write the charter");
|
|
13
|
+
const p = await getStaffProgress(s.handle, fresh).catch(() => ({}));
|
|
14
|
+
if (p.installed === false) left.push(p.app ? "install the GitHub App" : "create the GitHub App");
|
|
15
|
+
if (p.credential === false) left.push("add the agent credential");
|
|
16
|
+
if (p.ran === false) left.push("run once");
|
|
17
|
+
S.readiness = { ...(S.readiness ?? {}), [s.handle]: left };
|
|
18
|
+
return left;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Every staff member's, then `then` once all have answered. */
|
|
22
|
+
export function checkAll(then) {
|
|
23
|
+
return Promise.all((S.data?.staff ?? []).map((s) => whatsLeft(s))).then(() => then?.());
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The staff with anything left, from what has been checked so far. */
|
|
27
|
+
export function notReady() {
|
|
28
|
+
return (S.data?.staff ?? []).filter((s) => (S.readiness?.[s.handle] ?? []).length);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** "install the GitHub App, add the agent credential and run once" */
|
|
32
|
+
export function sentence(left) {
|
|
33
|
+
if (left.length < 2) return left.join("");
|
|
34
|
+
return left.slice(0, -1).join(", ") + " and " + left.at(-1);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Per staff member: hire, the GitHub App, the charter, the agent credential, a first run. */
|
|
38
|
+
const STAFF_STEPS = 5;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Setup as a count, from the same answers as everything above: the org's three steps (create
|
|
42
|
+
* it, business.md, priorities.md), then five per staff member. With nobody hired, the first
|
|
43
|
+
* hire's five are counted as still to do.
|
|
44
|
+
*
|
|
45
|
+
* Null until every staff member's readiness has answered. A count that starts high and drops
|
|
46
|
+
* when GitHub replies reads as steps coming undone.
|
|
47
|
+
*/
|
|
48
|
+
export function progress(
|
|
49
|
+
unfinished = S.data?.unfinished ?? [],
|
|
50
|
+
staff = S.data?.staff ?? [],
|
|
51
|
+
readiness = S.readiness ?? {},
|
|
52
|
+
) {
|
|
53
|
+
const orgSteps = 3;
|
|
54
|
+
let done = orgSteps - unfinished.filter((id) => id !== "hire").length;
|
|
55
|
+
let total = orgSteps;
|
|
56
|
+
if (!staff.length) return { done, total: total + STAFF_STEPS };
|
|
57
|
+
for (const s of staff) {
|
|
58
|
+
const left = readiness[s.handle];
|
|
59
|
+
if (!left) return null;
|
|
60
|
+
total += STAFF_STEPS;
|
|
61
|
+
done += STAFF_STEPS - left.length;
|
|
62
|
+
}
|
|
63
|
+
return { done, total };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** One staff member's own count, or null before their readiness is known. */
|
|
67
|
+
export function staffProgress(handle, readiness = S.readiness ?? {}) {
|
|
68
|
+
const left = readiness[handle];
|
|
69
|
+
return left ? { done: STAFF_STEPS - left.length, total: STAFF_STEPS } : null;
|
|
70
|
+
}
|
|
@@ -10,7 +10,8 @@
|
|
|
10
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 {
|
|
13
|
+
import { sortHome } from "./homesort.js";
|
|
14
|
+
import { humansOf, openCount, S, unreadCount } from "./state.js";
|
|
14
15
|
|
|
15
16
|
/**
|
|
16
17
|
* The inbox, fetched at most once at a time.
|
|
@@ -53,8 +54,9 @@ export function ensureInbox(force) {
|
|
|
53
54
|
export function stampCounts() {
|
|
54
55
|
const waiting = !S.inbox;
|
|
55
56
|
for (const [sel, n] of [
|
|
57
|
+
["#homecount", needsCount],
|
|
56
58
|
["#inboxcount", openCount],
|
|
57
|
-
["#
|
|
59
|
+
["#unreadcount", unreadCount],
|
|
58
60
|
]) {
|
|
59
61
|
const slot = document.querySelector(sel);
|
|
60
62
|
if (!slot) continue;
|
|
@@ -70,6 +72,12 @@ export function stampCounts() {
|
|
|
70
72
|
* which in practice meant "after you visit the Inbox". A sidebar that says nothing until you
|
|
71
73
|
* look at it is not a sidebar.
|
|
72
74
|
*/
|
|
75
|
+
/** What Home's "Needs you" holds, for the sidebar. */
|
|
76
|
+
function needsCount() {
|
|
77
|
+
if (!S.inbox || !S.data) return 0;
|
|
78
|
+
return sortHome(S.inbox.items ?? [], S.data.staff, humansOf(), S.live?.staff ?? []).needs.length;
|
|
79
|
+
}
|
|
80
|
+
|
|
73
81
|
export function countInBackground() {
|
|
74
82
|
stampCounts();
|
|
75
83
|
return ensureInbox(false)
|
|
@@ -22,7 +22,7 @@ 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", "prs", "org", "staff", "docs"]);
|
|
25
|
+
export const ORG_WIDE = new Set(["setup", "home", "inbox", "prs", "runs", "org", "staff", "docs"]);
|
|
26
26
|
|
|
27
27
|
/** The two screens that read the inbox, and so share its filters and its `?t=` thread. */
|
|
28
28
|
const INBOXY = new Set(["inbox", "prs"]);
|
|
@@ -32,13 +32,19 @@ export const S = {
|
|
|
32
32
|
data: null,
|
|
33
33
|
docs: null,
|
|
34
34
|
inbox: null,
|
|
35
|
+
/** /api/runs. Kept once read: it is minutes of downloads on a cold cache. */
|
|
36
|
+
runs: null,
|
|
35
37
|
sync: null,
|
|
36
38
|
loadedAt: null,
|
|
39
|
+
/** The role whose hire list is open on the Staff screen, kept across a refresh. */
|
|
40
|
+
staffOpen: null,
|
|
41
|
+
/** Per staff member, what is left before they can run. See readiness.js. */
|
|
42
|
+
readiness: null,
|
|
37
43
|
|
|
38
44
|
staffHandle: null,
|
|
39
45
|
/* What is waiting on you, not whose brain you read last. The org-wide screens are where a
|
|
40
46
|
session starts, which is also why the Staff list below them opens folded. */
|
|
41
|
-
view: "
|
|
47
|
+
view: "home",
|
|
42
48
|
/** The inbox filter box. Brain and "what changed" keep their own, below. */
|
|
43
49
|
query: "",
|
|
44
50
|
|
|
@@ -80,9 +86,9 @@ export const S = {
|
|
|
80
86
|
export const openCount = () =>
|
|
81
87
|
(S.inbox?.items ?? []).filter((i) => i.state === "OPEN" && i.kind !== "pr").length;
|
|
82
88
|
|
|
83
|
-
/**
|
|
84
|
-
export const
|
|
85
|
-
(S.inbox?.items ?? []).filter((i) => i.state === "OPEN" && i.kind
|
|
89
|
+
/** Issues with activity you have not read, from GitHub's notifications. */
|
|
90
|
+
export const unreadCount = () =>
|
|
91
|
+
(S.inbox?.items ?? []).filter((i) => i.unread && i.state === "OPEN" && i.kind !== "pr").length;
|
|
86
92
|
|
|
87
93
|
export const staff = () => S.data.staff.find((s) => s.handle === S.staffHandle) ?? S.data.staff[0];
|
|
88
94
|
|
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
* it is one browser, one origin, and nothing extra to explain.
|
|
7
7
|
*
|
|
8
8
|
* What it still cannot do is install the App. Installing is a grant of access to specific
|
|
9
|
-
* repositories and GitHub asks a human to
|
|
10
|
-
* be worked around. */
|
|
9
|
+
* repositories and GitHub asks a human to confirm them, which is correct behaviour and should not
|
|
10
|
+
* be worked around. The link it hands back has the right repos already selected. */
|
|
11
11
|
|
|
12
12
|
import { appResult, startApp } from "../api.js";
|
|
13
13
|
import { el } from "../dom.js";
|
|
@@ -27,8 +27,7 @@ export function appPanel(opts) {
|
|
|
27
27
|
}),
|
|
28
28
|
el("p", {
|
|
29
29
|
textContent:
|
|
30
|
-
"A tab opens
|
|
31
|
-
"into the repo's secrets. The key is held in memory and never written to disk.",
|
|
30
|
+
"A GitHub tab opens. Confirm there, then come back here to install it.",
|
|
32
31
|
}),
|
|
33
32
|
);
|
|
34
33
|
|
|
@@ -48,6 +47,11 @@ export function appPanel(opts) {
|
|
|
48
47
|
it: an App that exists is not a problem, it is a step you have already done. */
|
|
49
48
|
const note = el("div");
|
|
50
49
|
note.append(el("p", { className: "err", textContent: String(err.message || err) }));
|
|
50
|
+
if (err.data?.install) {
|
|
51
|
+
note.append(
|
|
52
|
+
el("a", { className: "btn", href: err.data.install, target: "_blank", textContent: "Install it" }),
|
|
53
|
+
);
|
|
54
|
+
}
|
|
51
55
|
out.replaceChildren(note);
|
|
52
56
|
return;
|
|
53
57
|
}
|
|
@@ -102,9 +106,21 @@ export function appPanel(opts) {
|
|
|
102
106
|
el("p", {
|
|
103
107
|
className: "sub",
|
|
104
108
|
textContent:
|
|
105
|
-
"
|
|
106
|
-
"
|
|
109
|
+
"The link opens GitHub's install page with the org and the repos already selected: " +
|
|
110
|
+
"this staff member's brain, every peer tracker it writes to, and the product repos. " +
|
|
111
|
+
"Check the list and confirm.",
|
|
107
112
|
}),
|
|
113
|
+
...((result.preselected ?? []).length
|
|
114
|
+
? [el("p", { className: "sub", textContent: "Selected: " + result.preselected.join(", ") })]
|
|
115
|
+
: []),
|
|
116
|
+
...((result.missing ?? []).length
|
|
117
|
+
? [
|
|
118
|
+
el("p", {
|
|
119
|
+
className: "err",
|
|
120
|
+
textContent: "Tick these yourself, their ids could not be read: " + result.missing.join(", "),
|
|
121
|
+
}),
|
|
122
|
+
]
|
|
123
|
+
: []),
|
|
108
124
|
el("a", {
|
|
109
125
|
className: "btn primary",
|
|
110
126
|
href: result.install,
|
|
@@ -115,7 +131,8 @@ export function appPanel(opts) {
|
|
|
115
131
|
className: "sub",
|
|
116
132
|
textContent:
|
|
117
133
|
"Do not verify this by reading the API. It reports what an App declares separately " +
|
|
118
|
-
"from what an installation was granted. Only a run that finished proves the chain
|
|
134
|
+
"from what an installation was granted. Only a run that finished proves the chain, " +
|
|
135
|
+
"which is what Run once now on the staff card is for.",
|
|
119
136
|
}),
|
|
120
137
|
);
|
|
121
138
|
out.replaceChildren(done);
|
|
@@ -150,7 +150,7 @@ export function viewBrain(m) {
|
|
|
150
150
|
const fileHits = plain.filter((f) => !q || f.path.toLowerCase().includes(q));
|
|
151
151
|
|
|
152
152
|
/* ---- memory ---- */
|
|
153
|
-
const mem = group("
|
|
153
|
+
const mem = group("What they know", s.facts.length + " facts");
|
|
154
154
|
if (q) {
|
|
155
155
|
for (const f of hits.slice(0, 200)) mem.append(row("fact:" + f.slug, f.slug, "", "tmem"));
|
|
156
156
|
if (!hits.length) {
|
|
@@ -10,6 +10,8 @@ import { el, toClipboard } from "../dom.js";
|
|
|
10
10
|
const LEVEL_ORDER = { fail: 0, warn: 1, ok: 2 };
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
|
+
* @param opts.skip finding ids the caller already shows as a step of its own, so they are not
|
|
14
|
+
* reported twice. With `opts.hideWhenClean`, a list left empty draws nothing at all.
|
|
13
15
|
* @param opts.shell when Health draws this, the section it lives in: `{actions, say}`. The
|
|
14
16
|
* setup screen passes nothing and gets the layout it has always had. Its own screen is a
|
|
15
17
|
* wizard, where a chunky button and a paragraph under it are right; on Health it is one
|
|
@@ -32,9 +34,14 @@ export async function checklist(host, opts = {}) {
|
|
|
32
34
|
const findings = (report.findings ?? []).slice().sort(
|
|
33
35
|
(a, b) => (LEVEL_ORDER[a.level] ?? 3) - (LEVEL_ORDER[b.level] ?? 3),
|
|
34
36
|
);
|
|
35
|
-
const
|
|
37
|
+
const skip = new Set(opts.skip ?? []);
|
|
38
|
+
const bad = findings.filter((f) => f.level !== "ok" && !skip.has(f.id));
|
|
36
39
|
|
|
37
40
|
host.replaceChildren();
|
|
41
|
+
if (!bad.length && opts.hideWhenClean) {
|
|
42
|
+
opts.onEmpty?.();
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
38
45
|
opts.shell?.say(bad.length ? String(bad.length) : "clean");
|
|
39
46
|
host.append(
|
|
40
47
|
el("p", {
|
|
@@ -83,7 +90,7 @@ export async function checklist(host, opts = {}) {
|
|
|
83
90
|
const again = el("button", { className: btnClass, textContent: "Check again" });
|
|
84
91
|
again.onclick = () => checklist(host, opts);
|
|
85
92
|
|
|
86
|
-
const feet = opts.shell?.actions ?? host;
|
|
93
|
+
const feet = opts.shell?.actions ?? host.appendChild(el("div", { className: "row" }));
|
|
87
94
|
if (opts.shell) feet.replaceChildren();
|
|
88
95
|
feet.append(copy, again, note);
|
|
89
96
|
if (bad.length) {
|
|
@@ -92,8 +99,7 @@ export async function checklist(host, opts = {}) {
|
|
|
92
99
|
className: noteClass,
|
|
93
100
|
style: "flex-basis:100%",
|
|
94
101
|
textContent:
|
|
95
|
-
"Paste
|
|
96
|
-
"check again. The ids above should be gone.",
|
|
102
|
+
"Paste them into your coding agent, then check again.",
|
|
97
103
|
}),
|
|
98
104
|
);
|
|
99
105
|
}
|