@nanocollective/roster 0.1.0-alpha.25 → 0.1.0-alpha.27

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.
@@ -28,10 +28,10 @@ layer. That is the part that matters most, because without them the model writes
28
28
  of whoever it was shown. The brief then interviews you, drafts from your answers, and tells you
29
29
  what it cut and why.
30
30
 
31
- **So is a worked example, when one fits.** A staff member whose handle or role reads as a CTO, a
32
- CMO or support gets the matching [example](#worked-examples) inside the brief, labelled as a
31
+ **So is a worked example, when one fits.** A staff member whose handle or role reads as one of
32
+ the ten roles below gets the matching [example](#worked-examples) inside the brief, labelled as a
33
33
  model for the shape and not content to copy. The copy-a-prompt panel has a picker to choose
34
- another or none; in a terminal it is `--example cto|cmo|support|none`. It is still a brief you
34
+ another or none; in a terminal it is `--example <handle>` or `--example none`. It is still a brief you
35
35
  answer: the interview comes first, and nothing in the charter should come from the example
36
36
  rather than from you.
37
37
 
@@ -72,9 +72,22 @@ status issue, and the surfaces the manifest declares.
72
72
 
73
73
  ## Worked examples
74
74
 
75
- Three, for an invented company called Acme: a [CTO](charters/cto.md), a [CMO](charters/cmo.md)
76
- and a [Head of Support](charters/support.md). They are examples to adapt, not templates to fill
77
- in. Read them for what a finished charter covers and how specific it gets, then write your own
75
+ Ten, for an invented company called Acme. The handle in brackets is the one `--example` takes.
76
+
77
+ | Example | What they do |
78
+ |---|---|
79
+ | [CTO](charters/cto.md) (`cto`) | Builds the product: fixes, features and tests, as pull requests. |
80
+ | [CMO](charters/cmo.md) (`cmo`) | Posts, SEO, copy and launch plans, as drafts to approve. |
81
+ | [Head of Support](charters/support.md) (`support`) | Answers issues, writes help docs, and turns user reports into bugs. |
82
+ | [Product Manager](charters/pm.md) (`pm`) | Turns ideas and user feedback into clear specs and a ranked backlog. |
83
+ | [Designer](charters/designer.md) (`designer`) | Improves the product's look and usability, with accessibility fixes, as pull requests. |
84
+ | [QA Engineer](charters/qa.md) (`qa`) | Tests the product, finds bugs, and writes clear reproductions and tests. |
85
+ | [DevOps Engineer](charters/devops.md) (`devops`) | Keeps CI, deploys and dependencies healthy, and patches security updates. |
86
+ | [Technical Writer](charters/writer.md) (`writer`) | Writes and keeps up the docs, guides and changelog. |
87
+ | [Data Analyst](charters/analyst.md) (`analyst`) | Reads the numbers and writes a short weekly report on what changed. |
88
+ | [Community Manager](charters/community.md) (`community`) | Answers discussions, welcomes contributors, and drafts release announcements. |
89
+
90
+ They are examples to adapt, not templates to fill in. Read them for what a finished charter covers and how specific it gets, then write your own
78
91
  about your business. A charter copied from one of these describes Acme. The brief carries the matching one for
79
92
  you; these links are for reading them first.
80
93
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanocollective/roster",
3
- "version": "0.1.0-alpha.25",
3
+ "version": "0.1.0-alpha.27",
4
4
  "description": "An agent-run org, powered by GitHub. Scaffold AI staff members whose brain is a repo.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -276,3 +276,12 @@ body[data-lightbox] {overflow:hidden}
276
276
 
277
277
  /* And the affordance on the document side: an image you can open should say so. */
278
278
  .md img,img.zoomable{cursor:zoom-in}
279
+
280
+ /* ---------- a dialog for a whole task (sheet() in dialog.js) ---------- */
281
+ dialog.sheet{border:1px solid var(--border-strong);border-radius:var(--r-lg);background:var(--bg);
282
+ color:var(--ink);padding:0;width:min(860px,calc(100vw - 32px));max-height:calc(100vh - 48px);
283
+ box-shadow:var(--shadow-lg)}
284
+ dialog.sheet[open]{animation:dlgin .18s var(--ease)}
285
+ dialog.sheet::backdrop{background:rgba(0,0,0,.35);backdrop-filter:blur(8px)}
286
+ .sheetbody{padding:22px 24px 26px;overflow:auto;max-height:calc(100vh - 50px)}
287
+ .sheettitle{margin:0 0 14px;font:600 18px var(--sans);letter-spacing:-.02em}
@@ -148,3 +148,12 @@ main{padding:36px 44px 96px;max-width:1220px;overflow-x:hidden}
148
148
  .side{position:static;height:auto;border-right:none;border-bottom:1px solid var(--line)}
149
149
  .sidefoot{margin-top:0}
150
150
  }
151
+
152
+ /* A staff member who cannot run yet (readiness.js). */
153
+ .readydot{width:7px;height:7px;border-radius:50%;background:var(--warn);flex:none;margin-left:4px}
154
+ .staffcard.notready{border-color:color-mix(in srgb,var(--warn) 45%,var(--line))}
155
+ .hirecard{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:6px;min-height:120px;
156
+ border:1.5px dashed var(--border-strong);border-radius:var(--r-lg);background:none;color:var(--ink-dim);
157
+ font:600 14px var(--sans);cursor:pointer}
158
+ .hirecard:hover{color:var(--ink);border-color:var(--accent)}
159
+ .hirecard::before{content:"+";font-size:22px;line-height:1}
@@ -182,3 +182,4 @@
182
182
  .pastenotes summary{cursor:pointer;font:600 12.5px var(--sans);color:var(--ink-dim);margin-bottom:6px}
183
183
  .pastenotes .md{border-left:3px solid var(--line);padding-left:12px}
184
184
  .pastefile .diff{white-space:pre-wrap;overflow-wrap:anywhere}
185
+ .warnline{margin:10px 0 0;padding:8px 11px;border-radius:var(--r);background:color-mix(in srgb,var(--warn) 12%,transparent);color:var(--ink);font-size:13px}
@@ -24,6 +24,7 @@ import { viewInbox, viewPrs } from "./views/inbox.js";
24
24
  import { viewOrg } from "./views/org.js";
25
25
  import { viewPrompt } from "./views/prompt.js";
26
26
  import { viewRuns } from "./views/runs.js";
27
+ import { sentence, whatsLeft } from "./readiness.js";
27
28
  import { paintSetupNav, viewGettingStarted, viewSetup } from "./views/setup.js";
28
29
  import { viewStaff } from "./views/staff.js";
29
30
 
@@ -112,6 +113,7 @@ export async function boot() {
112
113
  S.data = first;
113
114
  S.loadedAt = new Date();
114
115
  S.staffOpen = null; // an open hire list survives a refresh, not a page load
116
+ S.readiness = null;
115
117
  S.staffHandle = S.data.staff[0]?.handle ?? null;
116
118
  $("#orgname").textContent = S.data.name + " · " + S.data.staff.length + " staff";
117
119
 
@@ -262,11 +264,19 @@ function paintSidebar() {
262
264
  const head = el("button", { className: "nav staffrow" });
263
265
  head.dataset.staff = s.handle;
264
266
  head.setAttribute("aria-expanded", String(open));
267
+ const dot = el("span", { className: "readydot", hidden: true });
265
268
  head.append(
266
269
  icon("chevron", "caret"),
267
270
  el("span", { className: "t", textContent: s.name }),
271
+ dot,
268
272
  el("span", { className: "hh", textContent: s.handle }),
269
273
  );
274
+ whatsLeft(s).then((left) => {
275
+ if (!left.length) return;
276
+ dot.hidden = false;
277
+ head.title = "Not ready to run: " + sentence(left);
278
+ paintSetupNav();
279
+ });
270
280
 
271
281
  const views = el("div", { className: "staffviews" });
272
282
  views.hidden = !open;
@@ -158,3 +158,38 @@ export function askYes({ title, hint, confirm = "Continue" }) {
158
158
  cancel.focus();
159
159
  });
160
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
+ }
@@ -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
+ }
@@ -38,6 +38,8 @@ export const S = {
38
38
  loadedAt: null,
39
39
  /** The role whose hire list is open on the Staff screen, kept across a refresh. */
40
40
  staffOpen: null,
41
+ /** Per staff member, what is left before they can run. See readiness.js. */
42
+ readiness: null,
41
43
 
42
44
  staffHandle: null,
43
45
  /* What is waiting on you, not whose brain you read last. The org-wide screens are where a
@@ -39,10 +39,52 @@ const ROLES = [
39
39
  name: "Head of Support",
40
40
  about: "Answers issues, writes help docs, and turns user reports into bugs.",
41
41
  },
42
+ {
43
+ handle: "pm",
44
+ label: "Product manager",
45
+ name: "Product Manager",
46
+ about: "Turns ideas and user feedback into clear specs and a ranked backlog.",
47
+ },
48
+ {
49
+ handle: "designer",
50
+ label: "Designer",
51
+ name: "Designer",
52
+ about: "Improves the product's look and usability, with accessibility fixes, as pull requests.",
53
+ },
54
+ {
55
+ handle: "qa",
56
+ label: "QA",
57
+ name: "QA Engineer",
58
+ about: "Tests the product, finds bugs, and writes clear reproductions and tests.",
59
+ },
60
+ {
61
+ handle: "devops",
62
+ label: "DevOps",
63
+ name: "DevOps Engineer",
64
+ about: "Keeps CI, deploys and dependencies healthy, and patches security updates.",
65
+ },
66
+ {
67
+ handle: "writer",
68
+ label: "Writer",
69
+ name: "Technical Writer",
70
+ about: "Writes and keeps up the docs, guides and changelog.",
71
+ },
72
+ {
73
+ handle: "analyst",
74
+ label: "Analyst",
75
+ name: "Data Analyst",
76
+ about: "Reads the numbers and writes a short weekly report on what changed.",
77
+ },
78
+ {
79
+ handle: "community",
80
+ label: "Community",
81
+ name: "Community Manager",
82
+ about: "Answers discussions, welcomes contributors, and drafts release announcements.",
83
+ },
42
84
  ];
43
85
 
44
86
  /**
45
- * The roles as cards. The three with worked examples are offered until they are hired;
87
+ * The roles as cards. Each has a worked example, and is offered until it is hired;
46
88
  * Something else asks for a name and a sentence.
47
89
  * @param {{onPick: (role: {handle: string, name: string, dir: string, about?: string}) => void}} o
48
90
  */
@@ -141,7 +183,7 @@ export function hireFlow(o) {
141
183
  box.append(list);
142
184
 
143
185
  const hire = todo(1, "Hire", hired);
144
- const app = todo(2, "Create their GitHub App", false);
186
+ const app = todo(2, "Create and install their GitHub App", false);
145
187
  const charter = todo(3, "Write their charter", hired && !s.rig?.charterStub);
146
188
  const cred = todo(4, "Add your agent credential", false);
147
189
  const run = todo(5, "Run once now", false);
@@ -182,14 +224,17 @@ export function hireFlow(o) {
182
224
 
183
225
  /* 2 · the App. The public identity only matters when a product repo is public, and hire
184
226
  treats one with no visibility written as public, so this does too. */
227
+ /* Done means installed, not created: an App with its keys saved and no install looked
228
+ finished, and every run would have failed. */
185
229
  let needPublic = false;
186
230
  const made = { private: false, public: false };
187
231
  const appDone = () => app.setDone(made.private && (!needPublic || made.public));
188
232
  const panels = el("div");
189
- app.body.append(panels);
190
- panels.append(
191
- appPanel({ staff: s.handle, name: s.name, scope: "private", onDone: () => ((made.private = true), appDone()) }),
192
- );
233
+ const installNote = el("p", { className: "warnline", hidden: true });
234
+ const recheck = el("button", { className: "ghbtn", textContent: "Check again" });
235
+ recheck.onclick = () => checkProgress();
236
+ app.body.append(panels, installNote, el("div", { className: "row" }, [recheck]));
237
+ panels.append(appPanel({ staff: s.handle, name: s.name, scope: "private", onDone: () => checkProgress() }));
193
238
  getRepos()
194
239
  .then((r) => {
195
240
  needPublic = (r.repos ?? []).some(
@@ -198,7 +243,7 @@ export function hireFlow(o) {
198
243
  if (needPublic) {
199
244
  app.hint("One of your product repos is public. Public work uses a second App, shared by all staff.");
200
245
  panels.append(
201
- appPanel({ staff: s.handle, name: s.name, scope: "public", onDone: () => ((made.public = true), appDone()) }),
246
+ appPanel({ staff: s.handle, name: s.name, scope: "public", onDone: () => checkProgress() }),
202
247
  );
203
248
  }
204
249
  appDone();
@@ -225,15 +270,27 @@ export function hireFlow(o) {
225
270
  }),
226
271
  );
227
272
 
228
- /* What only GitHub knows: the App's secrets on the repo, and whether a run has succeeded. */
229
- getStaffProgress(s.handle, true)
230
- .then((p) => {
231
- if (p.app) made.private = true;
232
- if (p.publicApp) made.public = true;
233
- appDone();
234
- if (p.ran) run.setDone(true);
235
- })
236
- .catch(() => {});
273
+ /* What only GitHub knows: whether the App is installed, whether the credential is stored,
274
+ and whether a run has succeeded. Asked again by Check again, after a trip to GitHub. */
275
+ function checkProgress() {
276
+ return getStaffProgress(s.handle, true)
277
+ .then((p) => {
278
+ made.private = p.installed === true;
279
+ made.public = p.publicInstalled === true;
280
+ appDone();
281
+ installNote.hidden = !(p.app && p.installed === false);
282
+ installNote.textContent =
283
+ "Created, but not installed yet. Press Install, and choose All repositories or include roster-ops.";
284
+ if (p.installed && p.installSelection === "selected") {
285
+ installNote.hidden = false;
286
+ installNote.textContent =
287
+ "Installed on selected repos. Make sure roster-ops is one of them, or runs will fail.";
288
+ }
289
+ if (p.ran) run.setDone(true);
290
+ })
291
+ .catch(() => {});
292
+ }
293
+ checkProgress();
237
294
 
238
295
  return box;
239
296
  }
@@ -49,7 +49,19 @@ export function paste(opts) {
49
49
 
50
50
  let brief = null;
51
51
 
52
- const NAMES = { cto: "the CTO example", cmo: "the CMO example", support: "the support example", none: "no example" };
52
+ const NAMES = {
53
+ cto: "the CTO example",
54
+ cmo: "the CMO example",
55
+ support: "the support example",
56
+ pm: "the product manager example",
57
+ designer: "the designer example",
58
+ qa: "the QA example",
59
+ devops: "the DevOps example",
60
+ writer: "the writer example",
61
+ analyst: "the analyst example",
62
+ community: "the community example",
63
+ none: "no example",
64
+ };
53
65
  model.onchange = () => {
54
66
  copy.disabled = true;
55
67
  getBrief(opts.kind, opts.staff, model.value, opts.about)
@@ -18,6 +18,7 @@ import {
18
18
  import { $, el, esc, toClipboard } from "../dom.js";
19
19
  import { go } from "../router.js";
20
20
  import { S as App } from "../state.js";
21
+ import { checkAll, notReady, sentence } from "../readiness.js";
21
22
  import { checklist } from "./checklist.js";
22
23
  import { businessForm, prioritiesForm } from "./orgedit.js";
23
24
  import { credentialPanel } from "./credential.js";
@@ -60,8 +61,24 @@ export async function viewGettingStarted(main) {
60
61
  S = await getSetup();
61
62
  // Somebody may have clicked elsewhere while GitHub was answering.
62
63
  if (App.view !== "setup") return;
64
+ await checkAll();
65
+ if (App.view !== "setup") return;
63
66
  sub.textContent = "Work through these in order.";
64
- main.append(afterCreate());
67
+ const list = afterCreate();
68
+
69
+ // Somebody hired but not ready to run is the most important thing left, so it comes first.
70
+ for (const s of notReady().reverse()) {
71
+ const t = todo("!", "Finish setting up " + s.name, false);
72
+ t.hint("Still to do: " + sentence(App.readiness?.[s.handle] ?? []) + ".");
73
+ const cont = el("button", { className: "btn primary", textContent: "Continue" });
74
+ cont.onclick = () => {
75
+ App.staffOpen = { handle: s.handle, name: s.name, dir: s.dir };
76
+ go({ view: "staff" });
77
+ };
78
+ t.body.append(el("div", { className: "row" }, [cont]));
79
+ list.prepend(t.card);
80
+ }
81
+ main.append(list);
65
82
 
66
83
  const health = step("!", "Other problems", false);
67
84
  healthHost = el("div", { style: "margin-top:12px" });
@@ -75,7 +92,7 @@ export async function viewGettingStarted(main) {
75
92
  export function paintSetupNav() {
76
93
  const nav = $("#setupnav");
77
94
  if (!nav) return;
78
- nav.hidden = !App.data?.unfinished?.length && App.view !== "setup";
95
+ nav.hidden = !App.data?.unfinished?.length && !notReady().length && App.view !== "setup";
79
96
  }
80
97
 
81
98
  /* After a save: the checklist and the step list are both derived from disk, so both are asked
@@ -5,16 +5,17 @@
5
5
  * and nothing happens until you say so.
6
6
  */
7
7
 
8
- import { getStaffProgress, post } from "../api.js";
9
- import { askYes } from "../dialog.js";
8
+ import { post } from "../api.js";
9
+ import { askYes, sheet } from "../dialog.js";
10
10
  import { ago, el, esc } from "../dom.js";
11
+ import { whatsLeft, sentence } from "../readiness.js";
11
12
  import { refreshAll } from "../refresh.js";
12
13
  import { S } from "../state.js";
13
- import { appPanel } from "./app.js";
14
- import { credentialPanel } from "./credential.js";
15
14
  import { hireFlow, line, rolePicker } from "./hire.js";
16
- import { paste } from "./paste.js";
17
- import { runOnce } from "./runonce.js";
15
+
16
+ /* The open dialog, if any. It lives on <body>, so it outlives a repaint of this screen, and a
17
+ background refresh leaves whatever was typed into it alone. */
18
+ let current = null;
18
19
 
19
20
  /**
20
21
  * @param {{open?: string, result?: object}} [o] `open` keeps one staff member's steps on
@@ -27,24 +28,52 @@ export function viewStaff(m, o = {}) {
27
28
 
28
29
  const pane = el("div", { className: "hirepane" });
29
30
 
30
- const pick = () => {
31
- pane.replaceChildren(
32
- ...(empty ? [] : [el("h3", { textContent: "Pick a role" })]),
33
- rolePicker({ onPick: (role) => open(role) }),
34
- );
35
- if (!empty) {
36
- const cancel = el("button", { className: "ghbtn", textContent: "Cancel" });
37
- cancel.onclick = () => pane.replaceChildren();
38
- pane.append(el("div", { className: "row" }, [cancel]));
31
+ /* Tasks open in a dialog. Where there is no dialog element (the test shim) they draw into
32
+ the pane instead, which is how this screen always drew them. */
33
+ function present(node) {
34
+ if (current?.isOpen()) {
35
+ current.set(node);
36
+ return;
39
37
  }
40
- };
38
+ current = sheet({
39
+ node,
40
+ onClose: () => {
41
+ current = null;
42
+ S.staffOpen = null;
43
+ },
44
+ });
45
+ if (!current) pane.replaceChildren(node);
46
+ }
47
+ function dismiss() {
48
+ if (current) current.close();
49
+ else pane.replaceChildren();
50
+ S.staffOpen = null;
51
+ if (empty) pick();
52
+ }
53
+
54
+ // With nobody hired the roles are the page. After that they are a dialog behind Hire someone.
55
+ function pick() {
56
+ if (empty) {
57
+ pane.replaceChildren(rolePicker({ onPick: (role) => open(role) }));
58
+ return;
59
+ }
60
+ const cancel = el("button", { className: "ghbtn", textContent: "Cancel" });
61
+ cancel.onclick = dismiss;
62
+ present(
63
+ el("div", {}, [
64
+ el("h2", { className: "sheettitle", textContent: "Pick a role" }),
65
+ rolePicker({ onPick: (role) => open(role) }),
66
+ el("div", { className: "row", style: "margin-top:14px" }, [cancel]),
67
+ ]),
68
+ );
69
+ }
41
70
 
42
71
  /* One staff member's steps, hired or not yet. A hire repaints the whole screen, so the new
43
72
  card is in the list and the steps reopen on the same person. */
44
73
  function open(role, result) {
45
74
  // Remembered, so a refresh after a trip to GitHub (creating the App) reopens it.
46
75
  S.staffOpen = role;
47
- pane.replaceChildren(
76
+ present(
48
77
  hireFlow({
49
78
  role,
50
79
  result,
@@ -52,32 +81,29 @@ export function viewStaff(m, o = {}) {
52
81
  m.replaceChildren();
53
82
  viewStaff(m, { open: handle, result: r });
54
83
  },
55
- onClose: () => {
56
- S.staffOpen = null;
57
- if (empty) pick();
58
- else pane.replaceChildren();
59
- },
84
+ onClose: dismiss,
60
85
  }),
61
86
  );
62
- pane.scrollIntoView?.({ behavior: "smooth", block: "start" });
63
87
  }
64
88
 
65
89
  if (!empty) {
66
90
  const list = el("div", { className: "grid", style: "margin-bottom:18px" });
67
91
  for (const s of S.data.staff) list.append(card(s));
68
- m.append(list);
69
- const hire = el("button", { className: "ghbtn primary", textContent: "Hire someone" });
92
+ // In the grid, where the next person would go, rather than a button under it.
93
+ const hire = el("button", { className: "hirecard", textContent: "Hire someone" });
70
94
  hire.onclick = pick;
71
- m.append(el("div", { className: "row", style: "margin-bottom:14px" }, [hire]));
95
+ list.append(hire);
96
+ m.append(list);
72
97
  }
73
98
  m.append(pane);
74
99
 
75
100
  const opened = o.open && S.data.staff.find((s) => s.handle === o.open);
76
101
  const kept = !opened && S.staffOpen;
77
102
  const keptStaff = kept && S.data.staff.find((s) => s.handle === kept.handle);
103
+ if (empty) pick();
78
104
  if (opened) open(roleOf(opened), o.result);
79
- else if (kept) open(keptStaff ? roleOf(keptStaff) : kept);
80
- else if (empty) pick();
105
+ // An open dialog is still open; only the inline fallback has to be drawn again.
106
+ else if (kept && !current?.isOpen()) open(keptStaff ? roleOf(keptStaff) : kept);
81
107
 
82
108
  /* --------------------------- one staff member --------------------------- */
83
109
 
@@ -93,73 +119,30 @@ export function viewStaff(m, o = {}) {
93
119
 
94
120
  const status = el("span", { className: "meta" });
95
121
  const go = el("button", { className: "ghbtn", textContent: "Retire" });
96
- go.onclick = () => retireForm(pane, s, status);
97
-
98
- /* The charter is the one file `hire` deliberately does not write, and until now the only
99
- route to it was a CLI command printing a brief. This is the same brief, with every file
100
- it refers to already inside it and somewhere to put the answer. */
101
- const write = el("button", { className: "ghbtn", textContent: "Write the charter" });
102
- write.onclick = () => {
103
- pane.replaceChildren(
104
- el("div", { className: "card" }, [
105
- paste({
106
- kind: "charter",
107
- staff: s.handle,
108
- title: "Write " + s.name + "'s charter with your own AI",
109
- onSaved: () => refreshAll(false),
110
- }),
111
- ]),
112
- );
113
- pane.scrollIntoView?.({ behavior: "smooth", block: "start" });
114
- };
115
-
116
- /* The step `hire` has always had to hand back: there is no API that creates a GitHub App,
117
- so it is a browser hand-off either way. It may as well be this browser. */
118
- const app = el("button", { className: "ghbtn", textContent: "GitHub App" });
119
- app.onclick = () => {
120
- pane.replaceChildren(
121
- el("div", { className: "card" }, [
122
- el("h3", { textContent: s.name + "'s identity" }),
123
- appPanel({ staff: s.handle, name: s.name, scope: "private" }),
124
- appPanel({ staff: s.handle, name: s.name, scope: "public" }),
125
- ]),
126
- );
127
- pane.scrollIntoView?.({ behavior: "smooth", block: "start" });
128
- };
129
-
130
- /* The credential is once for the org, but the moment somebody looks for it is while setting
131
- up one staff member, so it is reachable from each card. */
132
- const cred = el("button", { className: "ghbtn", textContent: "Agent credential" });
133
- cred.onclick = () => {
134
- pane.replaceChildren(el("div", { className: "card" }, [credentialPanel()]));
135
- pane.scrollIntoView?.({ behavior: "smooth", block: "start" });
136
- };
137
-
138
- const run = el("button", { className: "ghbtn", textContent: "Run once now" });
139
- run.onclick = () => {
140
- pane.replaceChildren(
141
- el("div", { className: "card" }, [
142
- runOnce({ staff: s.handle, name: s.name, onDone: (ok) => ok && refreshAll(true) }),
143
- ]),
144
- );
145
- pane.scrollIntoView?.({ behavior: "smooth", block: "start" });
122
+ go.onclick = () => {
123
+ const host = el("div");
124
+ present(host);
125
+ retireForm(host, s, status);
146
126
  };
147
127
 
148
- /* Whether setup is finished, as far as can be told cheaply: the charter from disk at once,
149
- and the App's secrets and a first run from GitHub when it answers. */
150
- const finish = el("button", { className: "ghbtn primary", textContent: "Finish setting up", hidden: true });
128
+ /* Every setup step (the App, the charter, the credential, a run) is in the one list, so the
129
+ card has one way in. It says Finish setting up until setup looks finished: the charter
130
+ from disk at once, and the App's secrets and a first run from GitHub when it answers. */
131
+ const finish = el("button", { className: "ghbtn", textContent: "Set up" });
151
132
  finish.onclick = () => open(roleOf(s));
152
- if (s.rig?.charterStub) finish.hidden = false;
153
- else {
154
- getStaffProgress(s.handle)
155
- .then((p) => {
156
- if (p.app === false || p.ran === false) finish.hidden = false;
157
- })
158
- .catch(() => {});
159
- }
133
+ const warn = el("p", { className: "warnline", hidden: true });
134
+ whatsLeft(s).then((left) => {
135
+ if (!left.length) return;
136
+ finish.textContent = "Finish setting up";
137
+ finish.className = "ghbtn primary";
138
+ d.classList.add("notready");
139
+ warn.hidden = false;
140
+ warn.textContent = "Not ready to run. Still to do: " + sentence(left) + ".";
141
+ });
142
+ d.append(warn);
160
143
 
161
144
  d.append(
162
- el("div", { className: "row", style: "margin-top:10px" }, [finish, write, app, cred, run, go, status]),
145
+ el("div", { className: "row", style: "margin-top:10px" }, [finish, go, status]),
163
146
  );
164
147
  return d;
165
148
  }
@@ -206,7 +189,7 @@ export function viewStaff(m, o = {}) {
206
189
  const status = el("span", { className: "meta" });
207
190
  const go = el("button", { className: "ghbtn", textContent: "Retire " + plan.handle });
208
191
  const cancel = el("button", { className: "ghbtn", textContent: "Cancel" });
209
- cancel.onclick = () => host.replaceChildren();
192
+ cancel.onclick = dismiss;
210
193
  go.onclick = () =>
211
194
  apply(
212
195
  { action: "retire", handle: plan.handle },