@nanocollective/roster 0.1.0-alpha.4 → 0.1.0-alpha.41

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 (97) hide show
  1. package/README.md +70 -84
  2. package/dist/cli.js +4562 -2744
  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 +90 -5
  17. package/docs/concepts.md +48 -14
  18. package/docs/cost.md +36 -1
  19. package/docs/developing.md +16 -21
  20. package/docs/doctor-codes.md +8 -2
  21. package/docs/export.md +2 -0
  22. package/docs/extending.md +2 -2
  23. package/docs/getting-started.md +126 -77
  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 +39 -2
  33. package/docs/portal.md +119 -44
  34. package/docs/prompts.md +31 -4
  35. package/docs/security.md +37 -5
  36. package/docs/session-workflow.md +49 -17
  37. package/docs/staff-yaml.md +14 -2
  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 +6 -0
  43. package/templates/brain/.github/workflows/%%STAFF%%-mention.yaml +6 -0
  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/strategy/ideas.md +7 -0
  48. package/templates/briefs/priorities.md +46 -0
  49. package/templates/ops/.github/workflows/session.yaml +108 -14
  50. package/templates/ops/agents.mjs +7 -3
  51. package/templates/ops/compose.mjs +16 -3
  52. package/templates/ops/inflight.mjs +157 -0
  53. package/templates/ops/org/operating.md +21 -1
  54. package/templates/ops/org/voice.md +9 -0
  55. package/templates/ops/prompts/_inflight.md +14 -0
  56. package/templates/ops/prompts/_paths.md +2 -1
  57. package/templates/ops/prompts/daily.md +16 -7
  58. package/templates/ops/prompts/mention.md +2 -0
  59. package/templates/ops/run-record.mjs +144 -0
  60. package/templates/portal/css/base.css +146 -73
  61. package/templates/portal/css/brain.css +23 -20
  62. package/templates/portal/css/diff.css +10 -9
  63. package/templates/portal/css/graph.css +12 -7
  64. package/templates/portal/css/health.css +13 -11
  65. package/templates/portal/css/inbox.css +57 -25
  66. package/templates/portal/css/layout.css +96 -41
  67. package/templates/portal/css/markdown.css +36 -14
  68. package/templates/portal/css/runs.css +13 -0
  69. package/templates/portal/css/setup.css +116 -34
  70. package/templates/portal/index.html +18 -2
  71. package/templates/portal/js/api.js +41 -4
  72. package/templates/portal/js/app.js +132 -9
  73. package/templates/portal/js/dialog.js +82 -0
  74. package/templates/portal/js/icons.js +37 -0
  75. package/templates/portal/js/inflight.js +18 -0
  76. package/templates/portal/js/md.js +5 -2
  77. package/templates/portal/js/mdedit.js +84 -0
  78. package/templates/portal/js/readiness.js +35 -0
  79. package/templates/portal/js/refresh.js +2 -1
  80. package/templates/portal/js/state.js +17 -4
  81. package/templates/portal/js/views/app.js +24 -7
  82. package/templates/portal/js/views/checklist.js +10 -4
  83. package/templates/portal/js/views/credential.js +84 -0
  84. package/templates/portal/js/views/graph.js +1 -1
  85. package/templates/portal/js/views/health.js +17 -4
  86. package/templates/portal/js/views/hire.js +583 -0
  87. package/templates/portal/js/views/inbox.js +264 -84
  88. package/templates/portal/js/views/org.js +46 -106
  89. package/templates/portal/js/views/orgedit.js +234 -0
  90. package/templates/portal/js/views/paste.js +87 -21
  91. package/templates/portal/js/views/prompt.js +11 -4
  92. package/templates/portal/js/views/repos.js +20 -15
  93. package/templates/portal/js/views/runonce.js +94 -0
  94. package/templates/portal/js/views/runs.js +170 -0
  95. package/templates/portal/js/views/setup.js +257 -75
  96. package/templates/portal/js/views/staff.js +158 -243
  97. package/templates/portal/js/views/todo.js +62 -0
@@ -111,3 +111,85 @@ export function askText({
111
111
  ta.setSelectionRange?.(ta.value.length, ta.value.length);
112
112
  });
113
113
  }
114
+
115
+ /**
116
+ * Asking before something that cannot be taken back: a merge, a push, a new repo, a paid run.
117
+ *
118
+ * Native `confirm()` looked like the browser rather than this page, and it blocks the whole
119
+ * tab, which froze browser automation mid-run. This is the same `<dialog>` as askText, so the
120
+ * backdrop, Escape and the focus trap come free. The wording is the caller's: `title` is the
121
+ * question, `hint` says what will happen.
122
+ *
123
+ * @returns true only for the confirm button. Escape, Cancel and the backdrop are all no.
124
+ */
125
+ export function askYes({ title, hint, confirm = "Continue" }) {
126
+ const box = document.createElement("dialog");
127
+ // The test shim has no dialog element; see askText.
128
+ if (typeof box.showModal !== "function") {
129
+ return Promise.resolve(Boolean(globalThis.confirm?.(hint ? title + "\n\n" + hint : title)));
130
+ }
131
+
132
+ box.className = "ask";
133
+ box.append(el("h3", { textContent: title }));
134
+ if (hint) box.append(el("p", { className: "askhint", textContent: hint }));
135
+
136
+ const cancel = el("button", { className: "ghbtn", textContent: "Cancel" });
137
+ const go = el("button", { className: "ghbtn primary", textContent: confirm });
138
+ box.append(el("div", { className: "row askrow" }, [cancel, go]));
139
+
140
+ return new Promise((resolve) => {
141
+ let answer = false;
142
+ const done = (yes) => {
143
+ answer = yes;
144
+ box.close();
145
+ };
146
+ cancel.onclick = () => done(false);
147
+ go.onclick = () => done(true);
148
+ box.addEventListener("close", () => {
149
+ box.remove();
150
+ resolve(answer);
151
+ });
152
+ box.addEventListener("click", (e) => {
153
+ if (onBackdrop(e, box.getBoundingClientRect?.())) done(false);
154
+ });
155
+ document.body.append(box);
156
+ box.showModal();
157
+ // Cancel has the focus, so an Enter pressed out of habit does not merge anything.
158
+ cancel.focus();
159
+ });
160
+ }
161
+
162
+ /**
163
+ * A larger dialog for a whole task: hiring someone, or finishing their setup. Native <dialog>
164
+ * again, appended to <body> rather than to the screen, so a background refresh repainting the
165
+ * screen leaves it open with whatever was typed into it.
166
+ *
167
+ * @returns {{set: (node: Node) => void, close: () => void, isOpen: () => boolean} | null}
168
+ * null where there is no dialog element (the test shim), and the caller draws inline instead.
169
+ */
170
+ export function sheet({ node, onClose }) {
171
+ const box = document.createElement("dialog");
172
+ if (typeof box.showModal !== "function") return null;
173
+ box.className = "sheet";
174
+ const body = el("div", { className: "sheetbody" });
175
+ body.append(node);
176
+ box.append(body);
177
+ let open = true;
178
+ const close = () => {
179
+ if (!open) return;
180
+ open = false;
181
+ box.close();
182
+ box.remove();
183
+ onClose?.();
184
+ };
185
+ box.addEventListener("cancel", (e) => {
186
+ e.preventDefault();
187
+ close();
188
+ });
189
+ box.addEventListener("click", (e) => {
190
+ if (onBackdrop(e, box.getBoundingClientRect())) close();
191
+ });
192
+ document.body.append(box);
193
+ box.showModal();
194
+ return { set: (n) => body.replaceChildren(n), close, isOpen: () => open };
195
+ }
@@ -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,35 @@
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
+ }
@@ -10,7 +10,7 @@
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 { openCount, openPrCount, S, unreadCount } from "./state.js";
14
14
 
15
15
  /**
16
16
  * The inbox, fetched at most once at a time.
@@ -55,6 +55,7 @@ export function stampCounts() {
55
55
  for (const [sel, n] of [
56
56
  ["#inboxcount", openCount],
57
57
  ["#prcount", openPrCount],
58
+ ["#unreadcount", unreadCount],
58
59
  ]) {
59
60
  const slot = document.querySelector(sel);
60
61
  if (!slot) continue;
@@ -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", "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,11 +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
- view: "brain",
45
+ /* What is waiting on you, not whose brain you read last. The org-wide screens are where a
46
+ session starts, which is also why the Staff list below them opens folded. */
47
+ view: "inbox",
40
48
  /** The inbox filter box. Brain and "what changed" keep their own, below. */
41
49
  query: "",
42
50
 
@@ -73,9 +81,14 @@ export const S = {
73
81
 
74
82
  /* The sidebar badge counts open items, not everything loaded. Once the inbox could show
75
83
  closed work the two stopped being the same number, and a badge that says 135 when 34 things
76
- need you is worse than no badge. */
84
+ need you is worse than no badge. Pull requests are excluded for the same reason: they are
85
+ Pending work's, and counting them here would bill you twice for the same pile. */
77
86
  export const openCount = () =>
78
- (S.inbox?.items ?? []).filter((i) => i.state === "OPEN").length;
87
+ (S.inbox?.items ?? []).filter((i) => i.state === "OPEN" && i.kind !== "pr").length;
88
+
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;
79
92
 
80
93
  /** The same, for the screen that is only pull requests. */
81
94
  export const openPrCount = () =>
@@ -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);
@@ -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
  }
@@ -0,0 +1,84 @@
1
+ /* The agent's credential, stored once for the org.
2
+ *
3
+ * The value goes from this box to the local server in a POST, and from there to gh on its
4
+ * standard input. It is not kept in the page after it is sent, and nothing echoes it back. */
5
+
6
+ import { getCredential, storeCredential } from "../api.js";
7
+ import { el } from "../dom.js";
8
+
9
+ /** @param opts.bare drawn inside a step that already has a title. `onStatus(done)` hears whether one is stored. */
10
+ export function credentialPanel(opts = {}) {
11
+ const box = el("div", { className: opts.bare ? "" : "manual" });
12
+ if (!opts.bare) box.append(el("b", { textContent: "Agent credential" }));
13
+ const body = el("div");
14
+ box.append(body);
15
+ body.append(el("p", { className: "sub", textContent: "Checking…" }));
16
+
17
+ getCredential()
18
+ .then((c) => {
19
+ opts.onStatus?.(Boolean(c.stored ?? c.orgSecret));
20
+ draw(body, c, opts);
21
+ })
22
+ .catch((err) => body.replaceChildren(el("p", { className: "err", textContent: String(err.message || err) })));
23
+ return box;
24
+ }
25
+
26
+ function draw(body, c, opts = {}) {
27
+ body.replaceChildren();
28
+
29
+ if (!(c.brains ?? []).length) {
30
+ // Inside a step, the step's own hint already says when this opens.
31
+ if (!opts.bare) body.append(el("p", { className: "sub", textContent: "Available after your first hire. You only add it once." }));
32
+ return;
33
+ }
34
+
35
+ const form = el("div", { className: "credform" });
36
+ const input = el("input", { type: "password", placeholder: "Paste it here", autocomplete: "off", className: "textfield" });
37
+ const go = el("button", { className: "btn primary", textContent: "Save" });
38
+ const out = el("div");
39
+ form.append(
40
+ ...(c.howTo ? [el("p", { className: "sub", textContent: "To get one, " + c.howTo })] : []),
41
+ input,
42
+ el("div", { className: "row" }, [go]),
43
+ out,
44
+ );
45
+
46
+ // Stored already, on the org or on every brain repo: say so, and keep replacing it one click away.
47
+ if (c.stored ?? c.orgSecret) {
48
+ const replace = el("button", { className: "ghbtn", textContent: "Replace it" });
49
+ replace.onclick = () => {
50
+ replace.remove();
51
+ form.hidden = false;
52
+ input.focus?.();
53
+ };
54
+ form.hidden = true;
55
+ body.append(el("p", { className: "okline", textContent: "✓ Stored as " + c.name + "." }), replace, form);
56
+ } else {
57
+ body.append(form);
58
+ }
59
+
60
+ go.onclick = async () => {
61
+ const value = String(input.value ?? "").trim();
62
+ if (!value) {
63
+ input.focus?.();
64
+ return;
65
+ }
66
+ go.disabled = true;
67
+ go.textContent = "Saving…";
68
+ try {
69
+ const r = await storeCredential(value, false);
70
+ input.value = "";
71
+ if (!r.ok) throw new Error(r.error);
72
+ opts.onStatus?.(true);
73
+ out.replaceChildren(
74
+ ...(r.fellBack ? [el("p", { className: "sub", textContent: "Saved on each repo instead: " + r.fellBack })] : []),
75
+ el("p", { className: "okline", textContent: "✓ Saved." }),
76
+ );
77
+ } catch (err) {
78
+ out.replaceChildren(el("p", { className: "err", textContent: String(err.message || err) }));
79
+ } finally {
80
+ go.disabled = false;
81
+ go.textContent = "Save";
82
+ }
83
+ };
84
+ }
@@ -53,7 +53,7 @@ function drawGraph(canvas, wrap, s) {
53
53
  }
54
54
  const groups = [...members.keys()].sort((a, b) => members.get(b).length - members.get(a).length);
55
55
 
56
- const PALETTE = ["#7cc7a4","#7aa2f7","#c99bd6","#d8a657","#6fc3c9","#e07b7b","#b3c46b","#e0a1c0","#9d9ff5","#8fb0d9"];
56
+ const PALETTE = ["#30d158","#0a84ff","#bf5af2","#ff9f0a","#64d2ff","#ff453a","#ffcc00","#ff375f","#5e5ce6","#ac8e68"];
57
57
  const colour = new Map(groups.map((g, i) => [g, PALETTE[i % PALETTE.length]]));
58
58
 
59
59
  // The ring grows with the number of groups so they never crowd, and each group keeps its
@@ -1,12 +1,14 @@
1
1
  /* Is the scaffolding still there, has the agent been running, and what does lint say. */
2
2
 
3
3
  import { post } from "../api.js";
4
+ import { askYes } from "../dialog.js";
4
5
  import { ago, el, esc, kb } from "../dom.js";
5
6
  import { inline } from "../md.js";
6
7
  import { render } from "../router.js";
7
8
  import { S, staff } from "../state.js";
8
9
  import { checklist } from "./checklist.js";
9
10
  import { copyAmendBrief } from "./prompt.js";
11
+ import { runOnce } from "./runonce.js";
10
12
 
11
13
  const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
12
14
 
@@ -81,7 +83,18 @@ export function viewHealth(m) {
81
83
  doctor, bordered boxes for the prompt audit, cards with two buttons each for lint. Same
82
84
  question in all three, so one shape: a heading that carries its own count, a line saying
83
85
  what the section is, the findings as rows, and the section's actions at the end. */
84
- const doctor = section(m, "The org, from roster doctor", "doctor");
86
+ /* A workflow that has never run is unproven, and the way to prove it is to run it. Here
87
+ because Health is where "unproven" is read; a success re-runs the checks below. */
88
+ let doctor = null;
89
+ m.append(
90
+ runOnce({
91
+ staff: s.handle,
92
+ name: s.name,
93
+ onDone: (ok) => ok && doctor && checklist(doctor.body, { shell: doctor }),
94
+ }),
95
+ );
96
+
97
+ doctor = section(m, "The org, from roster doctor", "doctor");
85
98
  checklist(doctor.body, { shell: doctor });
86
99
 
87
100
  /* What is wrong with what this agent is sent. It used to sit in the tree on the Prompt
@@ -132,7 +145,7 @@ function memoryProblems({ body, actions, say }, s) {
132
145
  const { row, body: text } = finding(
133
146
  p.level === "error" ? "error" : "warning",
134
147
  { html: inline(p.message) },
135
- [p.rule, p.line ? "INDEX.md:" + p.line : ""],
148
+ [p.rule, p.line ? "INDEX.md:" + p.line : (p.file ?? "")],
136
149
  );
137
150
  const status = el("span", { className: "meta" });
138
151
  const btn = el("button", { className: "ghbtn", textContent: "Ask " + s.handle.toUpperCase() + " to fix" });
@@ -263,13 +276,13 @@ export async function askToFix(s, problems, btn, status) {
263
276
  "",
264
277
  "| rule | where | what |",
265
278
  "|---|---|---|",
266
- ...problems.map((p) => "| `" + p.rule + "` | " + (p.line ? "INDEX.md:" + p.line : "—") + " | " +
279
+ ...problems.map((p) => "| `" + p.rule + "` | " + (p.line ? "INDEX.md:" + p.line : (p.file ?? "—")) + " | " +
267
280
  String(p.message).replace(/\|/g, "\\|") + " |"),
268
281
  "",
269
282
  "Fix in your next session and close this.",
270
283
  ].join("\n");
271
284
 
272
- if (!confirm("Open an issue in " + s.brain + "?")) return;
285
+ if (!(await askYes({ title: "Open an issue in " + s.brain + "?", confirm: "Open it" }))) return;
273
286
  btn.disabled = true;
274
287
  status.textContent = "opening…";
275
288
  status.className = "meta";