@nanocollective/roster 0.1.0-alpha.22 → 0.1.0-alpha.24

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.
@@ -0,0 +1,526 @@
1
+ /* Hiring from the Staff screen: pick a role, then one numbered list per staff member.
2
+ *
3
+ * The plan and the hire are the CLI's own `buildPlan` and `applyPlan`, run on the server, so
4
+ * the browser never describes how to create a repo a second time. Every step's Done is read
5
+ * from something real where the portal has it: the export for the hire and the charter,
6
+ * GitHub for the App secrets and the first run, and the credential panel's own check. A step
7
+ * is never marked done because a button was clicked. */
8
+
9
+ import { getCharterExample, getCredential, getRepos, getStaffProgress, post } from "../api.js";
10
+ import { askYes } from "../dialog.js";
11
+ import { el } from "../dom.js";
12
+ import { icon } from "../icons.js";
13
+ import { refreshAll } from "../refresh.js";
14
+ import { S } from "../state.js";
15
+ import { appPanel } from "./app.js";
16
+ import { credentialPanel } from "./credential.js";
17
+ import { cronText } from "./health.js";
18
+ import { modes, rawMode } from "./orgedit.js";
19
+ import { paste } from "./paste.js";
20
+ import { runOnce } from "./runonce.js";
21
+ import { todo } from "./todo.js";
22
+
23
+ const ROLES = [
24
+ {
25
+ handle: "cto",
26
+ label: "CTO",
27
+ name: "Chief Technology Officer",
28
+ about: "Builds the product: fixes, features and tests, as pull requests for you to review.",
29
+ },
30
+ {
31
+ handle: "cmo",
32
+ label: "CMO",
33
+ name: "Chief Marketing Officer",
34
+ about: "Marketing: posts, SEO, copy and launch plans, as drafts for you to approve.",
35
+ },
36
+ {
37
+ handle: "support",
38
+ label: "Support",
39
+ name: "Head of Support",
40
+ about: "Answers issues, writes help docs, and turns user reports into bugs.",
41
+ },
42
+ ];
43
+
44
+ /**
45
+ * The roles as cards. The three with worked examples are offered until they are hired;
46
+ * Something else asks for a name and a sentence.
47
+ * @param {{onPick: (role: {handle: string, name: string, dir: string, about?: string}) => void}} o
48
+ */
49
+ export function rolePicker(o) {
50
+ const hired = new Set(S.data.staff.map((s) => s.handle));
51
+ const box = el("div");
52
+ const grid = el("div", { className: "roles" });
53
+ for (const r of ROLES.filter((r) => !hired.has(r.handle))) {
54
+ const b = roleCard(r.label, r.about);
55
+ b.dataset.role = r.handle;
56
+ b.onclick = () => o.onPick({ handle: r.handle, name: r.name, dir: r.handle });
57
+ grid.append(b);
58
+ }
59
+
60
+ const other = roleCard("Something else", "Name the role and say what they do.");
61
+ other.dataset.role = "other";
62
+ grid.append(other);
63
+
64
+ const name = el("input", { type: "text", placeholder: "e.g. Head of Finance" });
65
+ name.dataset.field = "role name";
66
+ const about = el("textarea", { className: "pastebox", rows: 2, placeholder: "e.g. Tracks spending and prepares the monthly accounts." });
67
+ about.dataset.field = "role about";
68
+ const go = el("button", { className: "btn primary", textContent: "Continue" });
69
+ const err = el("p", { className: "err", hidden: true });
70
+ const form = el("div", { className: "roleform", hidden: true }, [
71
+ el("label", { className: "qfield" }, [el("span", { textContent: "Role" }), name]),
72
+ el("label", { className: "qfield" }, [el("span", { textContent: "What do they do? One sentence." }), about]),
73
+ el("div", { className: "row" }, [go]),
74
+ err,
75
+ ]);
76
+ other.onclick = () => {
77
+ form.hidden = false;
78
+ name.focus?.();
79
+ };
80
+ go.onclick = () => {
81
+ const title = String(name.value ?? "").trim();
82
+ const handle = handleFor(title);
83
+ err.hidden = true;
84
+ if (!title || !handle) {
85
+ err.textContent = "Give the role a name.";
86
+ err.hidden = false;
87
+ name.focus?.();
88
+ return;
89
+ }
90
+ if (hired.has(handle)) {
91
+ err.textContent = "Someone called " + handle + " is already hired. Pick another name.";
92
+ err.hidden = false;
93
+ return;
94
+ }
95
+ o.onPick({ handle, name: title, dir: handle, about: String(about.value ?? "").trim() });
96
+ };
97
+
98
+ box.append(grid, form);
99
+ return box;
100
+ }
101
+
102
+ function roleCard(label, about) {
103
+ return el("button", { className: "role" }, [el("b", { textContent: label }), el("span", { textContent: about })]);
104
+ }
105
+
106
+ /** "Head of Finance" → "head-of-finance", inside what `hire` accepts as a handle. */
107
+ function handleFor(name) {
108
+ let h = String(name)
109
+ .toLowerCase()
110
+ .replace(/[^a-z0-9]+/g, "-")
111
+ .replace(/^-+|-+$/g, "")
112
+ .slice(0, 21)
113
+ .replace(/-+$/, "");
114
+ if (h && !/^[a-z]/.test(h)) h = ("staff-" + h).slice(0, 21).replace(/-+$/, "");
115
+ return /^[a-z][a-z0-9-]{1,20}$/.test(h) ? h : "";
116
+ }
117
+
118
+ /**
119
+ * The steps for one staff member, hired or about to be.
120
+ * @param {{
121
+ * role: {handle: string, name: string, dir: string, about?: string},
122
+ * onHired: (handle: string, result: object) => void,
123
+ * onClose: () => void,
124
+ * result?: {ok: boolean, output?: string},
125
+ * }} o `result` is what the hire said, shown once when it had problems.
126
+ */
127
+ export function hireFlow(o) {
128
+ const role = o.role;
129
+ const staff = () => S.data.staff.find((s) => s.handle === role.handle);
130
+ const s = staff();
131
+ const hired = Boolean(s);
132
+ const name = s?.name ?? role.name;
133
+
134
+ const box = el("div", { className: "hireflow" });
135
+ const close = el("button", { className: "ghbtn", textContent: "Close" });
136
+ close.onclick = o.onClose;
137
+ box.append(
138
+ el("div", { className: "row hireflowhead" }, [el("h2", { textContent: name }), close]),
139
+ );
140
+ const list = el("div", { className: "todos" });
141
+ box.append(list);
142
+
143
+ const hire = todo(1, "Hire", hired);
144
+ const app = todo(2, "Create their GitHub App", false);
145
+ const charter = todo(3, "Write their charter", hired && !s.rig?.charterStub);
146
+ const cred = todo(4, "Add your agent credential", false);
147
+ const run = todo(5, "Run once now", false);
148
+ list.append(hire.card, app.card, charter.card, cred.card, run.card);
149
+
150
+ /* The credential is once for the org. Stored already, it is not a step for this person. */
151
+ let credSeen = false;
152
+ const credStatus = (ok) => {
153
+ if (!credSeen && ok) {
154
+ cred.card.remove();
155
+ run.renumber(4);
156
+ } else cred.setDone(ok);
157
+ credSeen = true;
158
+ };
159
+
160
+ if (!hired) {
161
+ hireStep(hire.body, role, o.onHired);
162
+ for (const t of [app, charter, cred, run]) t.setLocked("Hire first");
163
+ getCredential()
164
+ .then((c) => credStatus(Boolean(c.orgSecret)))
165
+ .catch(() => {});
166
+ return box;
167
+ }
168
+
169
+ hire.body.append(el("p", { className: "sub", textContent: "Hired. Their repo is " + (s.brain ?? s.dir) + "." }));
170
+ if (o.result && !o.result.ok) {
171
+ hire.setDone(false);
172
+ hire.body.append(el("p", { className: "err", textContent: "The hire finished with problems. Read the output below." }));
173
+ }
174
+ if (o.result?.output) {
175
+ const out = el("details", { className: "planfiles" }, [
176
+ el("summary", { textContent: "Output" }),
177
+ el("pre", { className: "code planout", textContent: o.result.output }),
178
+ ]);
179
+ out.open = !o.result.ok;
180
+ hire.body.append(out);
181
+ }
182
+
183
+ /* 2 · the App. The public identity only matters when a product repo is public, and hire
184
+ treats one with no visibility written as public, so this does too. */
185
+ let needPublic = false;
186
+ const made = { private: false, public: false };
187
+ const appDone = () => app.setDone(made.private && (!needPublic || made.public));
188
+ 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
+ );
193
+ getRepos()
194
+ .then((r) => {
195
+ needPublic = (r.repos ?? []).some(
196
+ (x) => x.role === "product" && String(x.visibility ?? "").toLowerCase() !== "private",
197
+ );
198
+ if (needPublic) {
199
+ app.hint("One of your product repos is public. Public work uses a second App, shared by all staff.");
200
+ panels.append(
201
+ appPanel({ staff: s.handle, name: s.name, scope: "public", onDone: () => ((made.public = true), appDone()) }),
202
+ );
203
+ }
204
+ appDone();
205
+ })
206
+ .catch(() => {});
207
+
208
+ /* 3 · the charter. */
209
+ const charterSaved = () =>
210
+ refreshAll(true).then(() => charter.setDone(!staff()?.rig?.charterStub));
211
+ charter.body.append(charterModes(s, role.about, charterSaved));
212
+
213
+ /* 4 · the credential. */
214
+ cred.body.append(credentialPanel({ bare: true, onStatus: credStatus }));
215
+
216
+ /* 5 · the first run. */
217
+ run.body.append(
218
+ runOnce({
219
+ staff: s.handle,
220
+ name: s.name,
221
+ onDone: (ok) => {
222
+ run.setDone(ok);
223
+ if (ok) refreshAll(true);
224
+ },
225
+ }),
226
+ );
227
+
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(() => {});
237
+
238
+ return box;
239
+ }
240
+
241
+ /** Three ways to write CHARTER.md. The template is offered only when a worked example fits. */
242
+ function charterModes(s, about, onSaved) {
243
+ const box = el("div", {}, [el("p", { className: "sub", textContent: "Loading…" })]);
244
+ const path = s.dir + "/CHARTER.md";
245
+ getCharterExample(s.handle)
246
+ .catch(() => ({ kind: null }))
247
+ .then((ex) => {
248
+ const tabs = [
249
+ {
250
+ label: "Let an AI interview you",
251
+ node: paste({ kind: "charter", staff: s.handle, title: "", about, onSaved }),
252
+ },
253
+ ];
254
+ if (ex?.kind) {
255
+ tabs.push(
256
+ rawMode(path, onSaved, "md", {
257
+ label: "Start from the template",
258
+ note: "Edit this so it fits your business before saving.",
259
+ read: async () => ex.text,
260
+ }),
261
+ );
262
+ }
263
+ tabs.push(rawMode(path, onSaved));
264
+ box.replaceChildren(modes(tabs));
265
+ });
266
+ return box;
267
+ }
268
+
269
+ /* ---------------------------------- step 1 ---------------------------------- */
270
+
271
+ function hireStep(host, role, onHired) {
272
+ const first = !S.data.staff.length;
273
+ const org = S.data.org;
274
+
275
+ const summary = el("p", { className: "hiresummary", textContent: "Reading the plan…" });
276
+ const details = el("div", { className: "hiredetails", hidden: true });
277
+ const showDetails = el("button", { className: "ghbtn", textContent: "Show details" });
278
+ showDetails.onclick = () => {
279
+ details.hidden = !details.hidden;
280
+ showDetails.textContent = details.hidden ? "Show details" : "Hide details";
281
+ };
282
+
283
+ const handle = field("handle", role.handle, "Lowercase letters, digits and dashes.");
284
+ const name = field("name", role.name, "The role's full name.");
285
+ const dir = field("dir", role.dir, "The repo name.");
286
+ const schedule = scheduleField();
287
+ const fields = [
288
+ ["name", name],
289
+ ["dir", dir],
290
+ ["schedule", schedule],
291
+ ];
292
+ /* App names are copied from whoever is already here. The first hire has nobody to copy
293
+ from, so it names both, and later hires follow the pattern. */
294
+ let app = null;
295
+ if (first) {
296
+ app = field("app", org + "-" + role.handle, "This staff member's GitHub App. App names are unique across GitHub.");
297
+ const pub = field("public app", org + "-robot", "The App all staff share for public product repos.");
298
+ fields.push(["app", app], ["publicApp", pub]);
299
+ }
300
+
301
+ const advanced = el("div", { className: "hireadvanced", hidden: true }, [
302
+ handle.row,
303
+ ...fields.map(([, f]) => f.row),
304
+ ]);
305
+ const showAdvanced = el("button", { className: "ghbtn", textContent: "Advanced" });
306
+ showAdvanced.onclick = () => {
307
+ advanced.hidden = !advanced.hidden;
308
+ };
309
+
310
+ const status = el("span", { className: "meta" });
311
+ const go = el("button", { className: "btn primary", textContent: "Hire", disabled: true });
312
+
313
+ host.append(
314
+ summary,
315
+ el("div", { className: "row" }, [showDetails, showAdvanced]),
316
+ details,
317
+ advanced,
318
+ el("div", { className: "row" }, [go, status]),
319
+ );
320
+
321
+ const params = () => {
322
+ const p = new URLSearchParams({ handle: val(handle) });
323
+ for (const [key, f] of fields) {
324
+ const v = f.value ? f.value() : val(f);
325
+ if (v) p.set(key, v);
326
+ }
327
+ return p;
328
+ };
329
+
330
+ let plan = null;
331
+ const replan = async () => {
332
+ plan = null;
333
+ go.disabled = true;
334
+ summary.className = "hiresummary";
335
+ summary.textContent = "Reading the plan…";
336
+ try {
337
+ const data = await (await fetch("/api/staff/plan?" + params(), { cache: "no-store" })).json();
338
+ if (data.error) throw new Error(data.error);
339
+ plan = data.plan;
340
+ summary.textContent = summarise(plan);
341
+ details.replaceChildren(planDetails(plan));
342
+ go.disabled = false;
343
+ } catch (e) {
344
+ summary.className = "hiresummary err";
345
+ summary.textContent = e.message;
346
+ details.replaceChildren();
347
+ }
348
+ };
349
+
350
+ /* The App name follows the handle until somebody types their own. */
351
+ let lastHandle = val(handle);
352
+ handle.input.onchange = () => {
353
+ if (app && val(app) === org + "-" + lastHandle) app.input.value = org + "-" + val(handle);
354
+ lastHandle = val(handle);
355
+ replan();
356
+ };
357
+ for (const [, f] of fields) f.input.onchange = replan;
358
+ if (schedule.days) schedule.days.onchange = () => (schedule.say(), replan());
359
+
360
+ go.onclick = async () => {
361
+ if (!plan) return;
362
+ const p = plan.staff;
363
+ const yes = await askYes({
364
+ title: "Create " + p.brain + " and wire it up?",
365
+ hint: "This creates a repository on GitHub.",
366
+ confirm: "Hire " + p.handle,
367
+ });
368
+ if (!yes) return;
369
+ go.disabled = true;
370
+ status.className = "meta";
371
+ status.textContent = "Hiring…";
372
+ let r;
373
+ try {
374
+ r = await post({ action: "hire", handle: p.handle, flags: Object.fromEntries(params()) }, "/api/staff/apply");
375
+ await refreshAll(true);
376
+ } catch (e) {
377
+ status.className = "meta err";
378
+ status.textContent = e.message;
379
+ go.disabled = false;
380
+ return;
381
+ }
382
+ if (S.data.staff.some((x) => x.handle === p.handle)) {
383
+ onHired(p.handle, r);
384
+ return;
385
+ }
386
+ status.className = "meta err";
387
+ status.textContent = "The hire did not finish.";
388
+ if (r.output) host.append(el("pre", { className: "code planout", textContent: r.output }));
389
+ go.disabled = false;
390
+ };
391
+
392
+ replan();
393
+ }
394
+
395
+ /** One plain sentence from the plan. */
396
+ function summarise(plan) {
397
+ const s = plan.staff;
398
+ return "Creates a private repo " + s.brain + ", its workflows and a pinned status issue. " + runsAt(s.schedule);
399
+ }
400
+
401
+ const DAY_NAMES = ["Sundays", "Mondays", "Tuesdays", "Wednesdays", "Thursdays", "Fridays", "Saturdays"];
402
+
403
+ /** "0 8 * * 1-5" → "Runs weekdays at 08:00 UTC." */
404
+ function runsAt(cron) {
405
+ const p = String(cron ?? "").trim().split(/\s+/);
406
+ if (p.length !== 5 || !/^\d+$/.test(p[0]) || !/^\d+$/.test(p[1]) || p[2] !== "*" || p[3] !== "*") {
407
+ return "Schedule: " + cron + " (UTC).";
408
+ }
409
+ const at = p[1].padStart(2, "0") + ":" + p[0].padStart(2, "0") + " UTC";
410
+ if (p[4] === "1-5") return "Runs weekdays at " + at + ".";
411
+ if (p[4] === "*") return "Runs every day at " + at + ".";
412
+ const days = p[4].split(",").map((d) => DAY_NAMES[Number(d) % 7]);
413
+ if (days.some((d) => !d)) return "Schedule: " + cron + " (UTC).";
414
+ const said = days.length > 1 ? days.slice(0, -1).join(", ") + " and " + days.at(-1) : days[0];
415
+ return "Runs " + said + " at " + at + ".";
416
+ }
417
+
418
+ /** Everything the plan would do, for Show details. */
419
+ function planDetails(plan) {
420
+ const box = el("div", { className: "plan" });
421
+ const s = plan.staff;
422
+ box.append(el("div", { className: "planhead", textContent: "Creates" }));
423
+ box.append(
424
+ line("ok", s.brain + ", private"),
425
+ line("ok", plan.files.length + " files, including three caller workflows"),
426
+ line("ok", plan.labels.length + " labels: " + plan.labels.join(", ")),
427
+ line("ok", "a pinned status issue"),
428
+ line("ok", "runs at " + cronText(s.schedule) + ", on " + s.model),
429
+ );
430
+ for (const p of plan.peers) box.append(line("ok", p.brain + " gets a " + p.label + " label"));
431
+ /* Commits into repos that already exist, as you. Listed because they are writes to
432
+ somebody else's repo, and a plan that hid them would be the silent kind. */
433
+ if ((plan.commits ?? []).length) {
434
+ box.append(el("div", { className: "planhead", textContent: "Commits and pushes, as you" }));
435
+ for (const c of plan.commits) box.append(line("ok", c.repo + ": " + c.file + ", " + c.why));
436
+ }
437
+ const cred = s.agentSecret ?? "the agent credential";
438
+ box.append(
439
+ plan.orgSecret?.visibility === "selected"
440
+ ? line("ok", s.brain + " is added to the repos that can read the org secret " + cred)
441
+ : plan.orgSecret
442
+ ? line("ok", "the org secret " + cred + " already reaches " + plan.orgSecret.visibility + " repos")
443
+ : line("warn", "no org secret " + cred + " yet. Step 4 adds it."),
444
+ );
445
+ for (const w of plan.warnings ?? []) box.append(line("warn", portalWords(w)));
446
+ return box;
447
+ }
448
+
449
+ /* The plan's warnings are the CLI's, and name its flags. Here the same options are fields
450
+ under Advanced, and a flag the page has no box for reads as a dead end. */
451
+ function portalWords(warning) {
452
+ return warning
453
+ .replace(/pass --public-app\b/, "fill in public app under Advanced")
454
+ .replace(/pass --app\b/, "fill in app under Advanced");
455
+ }
456
+
457
+ const val = (f) => String(f.input.value ?? "").trim();
458
+
459
+ function field(label, value, hint) {
460
+ const input = el("input", { type: "text", value: value ?? "", style: "width:100%" });
461
+ input.dataset.field = label;
462
+ const row = el("div", { className: "ffield" });
463
+ row.append(el("label", { textContent: label }), input, el("small", { textContent: hint }));
464
+ return { row, input };
465
+ }
466
+
467
+ /**
468
+ * When the daily run happens, as a time and a set of days rather than as cron.
469
+ *
470
+ * The value on the wire is still a cron expression, because that is what goes in the workflow
471
+ * and what `roster hire` takes. UTC is said out loud, with the local time beside it: GitHub
472
+ * schedules in UTC.
473
+ */
474
+ function scheduleField() {
475
+ const time = el("input", { type: "time", value: "", step: "60", style: "width:130px" });
476
+ time.dataset.field = "schedule";
477
+ const days = el("select");
478
+ days.append(
479
+ el("option", { value: "1-5", textContent: "Weekdays" }),
480
+ el("option", { value: "*", textContent: "Every day" }),
481
+ el("option", { value: "1", textContent: "Mondays" }),
482
+ el("option", { value: "1,3,5", textContent: "Mon, Wed, Fri" }),
483
+ );
484
+ // Set rather than relying on "the first option is selected", which is true of a rendered
485
+ // <select> and not of one that has only been built.
486
+ days.value = "1-5";
487
+
488
+ const said = el("small");
489
+ const value = () => {
490
+ if (!time.value) return "";
491
+ const [h, m] = time.value.split(":");
492
+ return `${Number(m)} ${Number(h)} * * ${days.value}`;
493
+ };
494
+ const say = () => {
495
+ const cron = value();
496
+ said.textContent = cron ? cronText(cron) + local(time.value) : "Leave it empty to get a free slot.";
497
+ };
498
+ time.oninput = say;
499
+ say();
500
+
501
+ const row = el("div", { className: "ffield" });
502
+ row.append(
503
+ el("label", { textContent: "schedule" }),
504
+ el("div", { className: "row" }, [time, days, el("span", { className: "meta", textContent: "UTC" })]),
505
+ said,
506
+ );
507
+ return { row, input: time, days, value, say };
508
+ }
509
+
510
+ /** What that UTC time is where the person reading it is, when the two differ. */
511
+ function local(hhmm) {
512
+ if (!hhmm) return "";
513
+ const [h, m] = hhmm.split(":").map(Number);
514
+ const at = new Date(Date.UTC(2026, 0, 5, h, m));
515
+ const here = at.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
516
+ return here === hhmm ? "" : " · " + here + " where you are";
517
+ }
518
+
519
+ const MARK = { ok: "check", stop: "closed", keep: "issue-open", todo: "dash", warn: "label" };
520
+
521
+ /** One line of a plan, with its mark. */
522
+ export function line(kind, text) {
523
+ const d = el("div", { className: "planline " + kind });
524
+ d.append(icon(MARK[kind] ?? "dot", "ic"), el("span", { textContent: text }));
525
+ return d;
526
+ }
@@ -99,7 +99,12 @@ export function prioritiesForm(o) {
99
99
  (scoped.length ? "\n### Out of scope this month\n\n" + scoped.map((t) => `- ${t}`).join("\n") + "\n" : "");
100
100
  await saveAndSay(save, out, o.path, text, "portal: write org/priorities.md", o.onSaved);
101
101
  };
102
- return modes([...(o.before ?? []), { label: "Fill in", node: form }, rawMode(o.path, o.onSaved)]);
102
+ return modes([
103
+ ...(o.before ?? []),
104
+ { label: "Fill in", node: form },
105
+ { label: "Let an AI interview you", node: paste({ kind: "priorities", title: "", onSaved: o.onSaved }) },
106
+ rawMode(o.path, o.onSaved),
107
+ ]);
103
108
  }
104
109
 
105
110
  /** Any org file as highlighted text, with Read ahead of it on the Org screen. */
@@ -107,9 +112,15 @@ export function fileModes(o) {
107
112
  return modes([...(o.before ?? []), rawMode(o.path, o.onSaved, o.lang)]);
108
113
  }
109
114
 
110
- /** The file itself, for editing what is already there rather than starting again. */
111
- export function rawMode(path, onSaved, lang = "md") {
115
+ /**
116
+ * The file itself, for editing what is already there rather than starting again.
117
+ * @param {{label?: string, note?: string, read?: () => Promise<string>}} o
118
+ * `read` starts the editor from other text than the file, once, so switching tabs does not
119
+ * throw away what was typed. `note` is a line above the editor.
120
+ */
121
+ export function rawMode(path, onSaved, lang = "md", o = {}) {
112
122
  const node = el("div");
123
+ if (o.note) node.append(el("p", { className: "sub", textContent: o.note }));
113
124
  const ta = el("textarea", { className: "pastebox" });
114
125
  ta.rows = 12;
115
126
  const save = el("button", { className: "btn primary", textContent: "Save" });
@@ -128,16 +139,22 @@ export function rawMode(path, onSaved, lang = "md") {
128
139
  }
129
140
  });
130
141
  // Read when first shown, so it shows the file as it is then, not as it was at page load.
131
- const load = () =>
132
- // Not getFile: an absent file answers 404 with a message, and that is not what to edit.
133
- fetch(fileUrl(path), { cache: "no-store" })
134
- .then((r) => (r.ok ? r.text() : ""))
135
- .then((text) => {
136
- ta.value = text;
137
- grow(ta);
138
- hl.repaint();
139
- });
140
- return { label: "Edit the file", node, onShow: load };
142
+ let read = false;
143
+ const load = () => {
144
+ if (o.read && read) return;
145
+ read = true;
146
+ return (
147
+ o.read
148
+ ? o.read()
149
+ : // Not getFile: an absent file answers 404 with a message, and that is not what to edit.
150
+ fetch(fileUrl(path), { cache: "no-store" }).then((r) => (r.ok ? r.text() : ""))
151
+ ).then((text) => {
152
+ ta.value = text;
153
+ grow(ta);
154
+ hl.repaint();
155
+ });
156
+ };
157
+ return { label: o.label ?? "Edit the file", node, onShow: load };
141
158
  }
142
159
 
143
160
  /** Ways to do one thing, as a switcher across the top: one shown at a time. */
@@ -12,7 +12,8 @@ import { el, toClipboard } from "../dom.js";
12
12
  import { unifiedDiff, diffStat } from "../textdiff.js";
13
13
 
14
14
  /**
15
- * @param {{kind: string, staff?: string, title?: string, onSaved?: () => void}} opts
15
+ * @param {{kind: string, staff?: string, title?: string, about?: string, onSaved?: () => void}} opts
16
+ * `about` is a sentence on what the role does, added to a charter brief.
16
17
  */
17
18
  export function paste(opts) {
18
19
  const box = el("div", { className: "paste" });
@@ -51,7 +52,7 @@ export function paste(opts) {
51
52
  const NAMES = { cto: "the CTO example", cmo: "the CMO example", support: "the support example", none: "no example" };
52
53
  model.onchange = () => {
53
54
  copy.disabled = true;
54
- getBrief(opts.kind, opts.staff, model.value)
55
+ getBrief(opts.kind, opts.staff, model.value, opts.about)
55
56
  .then((data) => {
56
57
  if (data.error) throw new Error(data.error);
57
58
  brief = data;
@@ -66,7 +67,7 @@ export function paste(opts) {
66
67
  });
67
68
  };
68
69
 
69
- getBrief(opts.kind, opts.staff)
70
+ getBrief(opts.kind, opts.staff, undefined, opts.about)
70
71
  .then((data) => {
71
72
  if (data.error) throw new Error(data.error);
72
73
  brief = data;
@@ -22,6 +22,7 @@ import { checklist } from "./checklist.js";
22
22
  import { businessForm, prioritiesForm } from "./orgedit.js";
23
23
  import { credentialPanel } from "./credential.js";
24
24
  import { repoPicker } from "./repos.js";
25
+ import { todo } from "./todo.js";
25
26
 
26
27
  let S = null; // /api/setup/status, refreshed after anything that changes the world
27
28
 
@@ -414,7 +415,7 @@ function afterCreate() {
414
415
  list.append(priorities.card);
415
416
 
416
417
  const hire = todo(++n, "Hire your first staff member", staffCount > 0);
417
- hire.hint("Give them a role, like cto. You'll create their GitHub App and write their charter from their card.");
418
+ hire.hint("Pick a role. The Staff screen then lists each step to set them up.");
418
419
  const hireBtn = el("button", { className: "btn primary", textContent: "Hire someone" });
419
420
  hireBtn.onclick = () => {
420
421
  // Before the org has loaded, this screen is the whole app, so the Staff screen needs a reload.
@@ -436,46 +437,6 @@ function afterCreate() {
436
437
  return list;
437
438
  }
438
439
 
439
- /**
440
- * One numbered thing to do. Done ones fold to a single ticked line, and Change opens them again,
441
- * so the list shows what is left without hiding what was done.
442
- */
443
- function todo(n, title, done) {
444
- const card = el("section", { className: "todo" });
445
- const num = el("span", { className: "todon" });
446
- const pill = el("span", { className: "todopill" });
447
- const change = el("button", { className: "ghbtn todochange", textContent: "Change" });
448
- const head = el("div", { className: "todohead" }, [num, el("h3", { textContent: title }), pill, change]);
449
- const hintEl = el("p", { className: "sub todohint" });
450
- const body = el("div", { className: "todobody" });
451
- card.append(head, hintEl, body);
452
- let open = false;
453
- const setDone = (d) => {
454
- card.classList.toggle("done", d);
455
- num.textContent = d ? "✓" : String(n);
456
- pill.textContent = d ? "Done" : "To do";
457
- change.hidden = !d;
458
- const shown = !d || open;
459
- body.hidden = !shown;
460
- hintEl.hidden = !shown || !hintEl.textContent;
461
- };
462
- change.onclick = () => {
463
- open = !open;
464
- change.textContent = open ? "Close" : "Change";
465
- setDone(card.classList.contains("done"));
466
- };
467
- setDone(done);
468
- return {
469
- card,
470
- body,
471
- setDone,
472
- hint: (text) => {
473
- hintEl.textContent = text;
474
- setDone(card.classList.contains("done"));
475
- },
476
- };
477
- }
478
-
479
440
  /**
480
441
  * One of the files only a person can write, with whether it is written yet.
481
442
  *