@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.
Files changed (102) hide show
  1. package/README.md +70 -84
  2. package/dist/cli.js +4833 -2752
  3. package/docs/README.md +9 -6
  4. package/docs/agents.md +24 -20
  5. package/docs/architecture.md +13 -5
  6. package/docs/charters/analyst.md +65 -0
  7. package/docs/charters/cmo.md +69 -0
  8. package/docs/charters/community.md +63 -0
  9. package/docs/charters/cto.md +71 -0
  10. package/docs/charters/designer.md +65 -0
  11. package/docs/charters/devops.md +65 -0
  12. package/docs/charters/pm.md +70 -0
  13. package/docs/charters/qa.md +65 -0
  14. package/docs/charters/support.md +60 -0
  15. package/docs/charters/writer.md +63 -0
  16. package/docs/commands.md +93 -7
  17. package/docs/concepts.md +61 -14
  18. package/docs/cost.md +36 -1
  19. package/docs/developing.md +16 -21
  20. package/docs/doctor-codes.md +10 -2
  21. package/docs/export.md +2 -0
  22. package/docs/extending.md +2 -2
  23. package/docs/getting-started.md +128 -78
  24. package/docs/images/brain.jpg +0 -0
  25. package/docs/images/org.jpg +0 -0
  26. package/docs/images/prompt.jpg +0 -0
  27. package/docs/images/setup-org.jpg +0 -0
  28. package/docs/images/setup-plan.jpg +0 -0
  29. package/docs/images/staff.jpg +0 -0
  30. package/docs/manual-steps.md +94 -123
  31. package/docs/memory.md +21 -3
  32. package/docs/org-yaml.md +40 -2
  33. package/docs/portal.md +165 -48
  34. package/docs/prompts.md +31 -4
  35. package/docs/security.md +37 -5
  36. package/docs/session-workflow.md +63 -17
  37. package/docs/staff-yaml.md +30 -3
  38. package/docs/troubleshooting.md +8 -8
  39. package/docs/upgrading.md +9 -3
  40. package/docs/writing-a-charter.md +28 -0
  41. package/package.json +18 -20
  42. package/templates/brain/.github/workflows/%%STAFF%%-daily.yaml +26 -1
  43. package/templates/brain/.github/workflows/%%STAFF%%-mention.yaml +41 -7
  44. package/templates/brain/CHARTER.md +3 -3
  45. package/templates/brain/README.md +1 -0
  46. package/templates/brain/log/decisions.md +3 -0
  47. package/templates/brain/staff.yaml +4 -1
  48. package/templates/brain/strategy/ideas.md +7 -0
  49. package/templates/briefs/priorities.md +46 -0
  50. package/templates/ops/.github/workflows/session.yaml +236 -15
  51. package/templates/ops/agents.mjs +7 -3
  52. package/templates/ops/compose.mjs +31 -3
  53. package/templates/ops/inflight.mjs +157 -0
  54. package/templates/ops/org/operating.md +43 -4
  55. package/templates/ops/org/voice.md +9 -0
  56. package/templates/ops/prompts/_inflight.md +14 -0
  57. package/templates/ops/prompts/_paths.md +2 -1
  58. package/templates/ops/prompts/daily.md +37 -9
  59. package/templates/ops/prompts/mention.md +21 -0
  60. package/templates/ops/run-record.mjs +146 -0
  61. package/templates/portal/css/base.css +167 -73
  62. package/templates/portal/css/brain.css +23 -20
  63. package/templates/portal/css/diff.css +10 -9
  64. package/templates/portal/css/graph.css +12 -7
  65. package/templates/portal/css/health.css +13 -11
  66. package/templates/portal/css/home.css +93 -0
  67. package/templates/portal/css/inbox.css +45 -25
  68. package/templates/portal/css/layout.css +90 -46
  69. package/templates/portal/css/markdown.css +36 -14
  70. package/templates/portal/css/runs.css +13 -0
  71. package/templates/portal/css/setup.css +116 -34
  72. package/templates/portal/index.html +21 -9
  73. package/templates/portal/js/api.js +44 -4
  74. package/templates/portal/js/app.js +94 -9
  75. package/templates/portal/js/dialog.js +83 -0
  76. package/templates/portal/js/homesort.js +174 -0
  77. package/templates/portal/js/icons.js +37 -0
  78. package/templates/portal/js/inflight.js +18 -0
  79. package/templates/portal/js/md.js +5 -2
  80. package/templates/portal/js/mdedit.js +84 -0
  81. package/templates/portal/js/readiness.js +70 -0
  82. package/templates/portal/js/refresh.js +10 -2
  83. package/templates/portal/js/state.js +11 -5
  84. package/templates/portal/js/views/app.js +24 -7
  85. package/templates/portal/js/views/brain.js +1 -1
  86. package/templates/portal/js/views/checklist.js +10 -4
  87. package/templates/portal/js/views/credential.js +84 -0
  88. package/templates/portal/js/views/graph.js +1 -1
  89. package/templates/portal/js/views/health.js +17 -4
  90. package/templates/portal/js/views/hire.js +593 -0
  91. package/templates/portal/js/views/home.js +546 -0
  92. package/templates/portal/js/views/inbox.js +226 -70
  93. package/templates/portal/js/views/org.js +46 -106
  94. package/templates/portal/js/views/orgedit.js +234 -0
  95. package/templates/portal/js/views/paste.js +87 -21
  96. package/templates/portal/js/views/prompt.js +11 -4
  97. package/templates/portal/js/views/repos.js +20 -15
  98. package/templates/portal/js/views/runonce.js +94 -0
  99. package/templates/portal/js/views/runs.js +170 -0
  100. package/templates/portal/js/views/setup.js +261 -75
  101. package/templates/portal/js/views/staff.js +170 -243
  102. 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
- const REF_RE = /(^|[\s(>])([\w.-]+\/[\w.-]+)?#(\d+)\b/g;
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 target = repo || opts.repo;
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 { openCount, openPrCount, S } from "./state.js";
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
- ["#prcount", openPrCount],
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: "inbox",
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
- /** The same, for the screen that is only pull requests. */
84
- export const openPrCount = () =>
85
- (S.inbox?.items ?? []).filter((i) => i.state === "OPEN" && i.kind === "pr").length;
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 choose them, which is correct behaviour and should not
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, GitHub asks you to confirm, and the App's id and private key go straight " +
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
- "Grant it every tracker this staff member writes to, not just their own: the token " +
106
- "is minted organisation-wide and a peer's board is where a brief lands.",
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("Memory", s.facts.length + " facts");
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 bad = findings.filter((f) => f.level !== "ok");
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 it into Cursor, Claude Code or anything else that can edit files here, then " +
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
  }