@nanocollective/roster 0.1.0-alpha.12 → 0.1.0-alpha.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -1319,6 +1319,7 @@ async function setupStatus(startedIn) {
1319
1319
  opsDir: found.opsDir,
1320
1320
  org: readOrgName(found.opsDir),
1321
1321
  repos: readBlockNames(found.opsDir, "repos", "name"),
1322
+ products: readProducts(found.opsDir),
1322
1323
  left: unfinished(found.opsDir, readBlockNames(found.opsDir, "staff", "handle").length)
1323
1324
  } : { found: false },
1324
1325
  gh: ready.ok ? { ok: true, login: ready.data?.login } : { ok: false, error: ready.error ?? "gh is not ready" },
@@ -1333,6 +1334,23 @@ function readOrgName(opsDir) {
1333
1334
  const m = /^org:\s*(\S+)\s*$/m.exec(readFileSync10(path, "utf8"));
1334
1335
  return m?.[1];
1335
1336
  }
1337
+ function readProducts(opsDir) {
1338
+ const path = join11(opsDir, "org.yaml");
1339
+ if (!existsSync11(path)) return [];
1340
+ const out = [];
1341
+ let inside = false;
1342
+ for (const line of readFileSync10(path, "utf8").split("\n")) {
1343
+ if (/^repos:\s*$/.test(line)) {
1344
+ inside = true;
1345
+ continue;
1346
+ }
1347
+ if (inside && /^\S/.test(line)) break;
1348
+ if (!inside || !/\brole:\s*product\b/.test(line)) continue;
1349
+ const m = /\bname:\s*([\w.-]+)/.exec(line);
1350
+ if (m) out.push(m[1]);
1351
+ }
1352
+ return out;
1353
+ }
1336
1354
  function readBlockNames(opsDir, block, key) {
1337
1355
  const path = join11(opsDir, "org.yaml");
1338
1356
  if (!existsSync11(path)) return [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanocollective/roster",
3
- "version": "0.1.0-alpha.12",
3
+ "version": "0.1.0-alpha.14",
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": {
@@ -121,3 +121,25 @@
121
121
  .plansteps{margin:6px 0 12px;padding-left:20px;font-size:13.5px;line-height:1.7}
122
122
  .planfiles{margin:0 0 12px}
123
123
  .planfiles summary{cursor:pointer;color:var(--ink-dim);font-size:12.5px}
124
+
125
+ /* ---------- the numbered to-do list ---------- */
126
+
127
+ .todos{display:grid;gap:10px}
128
+ .todo{background:var(--panel);border:1px solid var(--line);border-radius:var(--r-lg);padding:16px 18px}
129
+ .todo.done{padding:11px 18px;border-color:color-mix(in srgb,var(--accent) 30%,var(--line))}
130
+ .todohead{display:flex;align-items:center;gap:11px}
131
+ .todohead h3{margin:0;font:600 14.5px var(--sans);flex:1}
132
+ .todon{flex:none;width:24px;height:24px;border-radius:50%;display:grid;place-items:center;
133
+ background:var(--surface-2);color:var(--ink-dim);font:600 11px var(--mono)}
134
+ .todo.done .todon{background:var(--accent-fill);color:var(--accent-ink)}
135
+ .todopill{font:600 11px var(--sans);padding:2px 8px;border-radius:99px;background:var(--surface-2);color:var(--ink-dim)}
136
+ .todo.done .todopill{background:var(--accent-soft);color:var(--accent)}
137
+ .todohint{margin:8px 0 0 35px}
138
+ .todobody{margin:12px 0 0 35px}
139
+ .todobody .row{margin-top:10px}
140
+ .qfield{display:grid;gap:5px;margin:0 0 12px}
141
+ .qfield span{font:600 12.5px var(--sans)}
142
+ .qfield input{background:var(--bg-elev);border:1px solid var(--border-strong);color:var(--ink);
143
+ border-radius:var(--r);padding:8px 10px;font:13px var(--sans)}
144
+ .altway{margin-top:14px}
145
+ .altway summary{cursor:pointer;color:var(--ink-dim);font-size:12.5px}
@@ -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
  }
@@ -6,24 +6,30 @@
6
6
  import { getCredential, storeCredential } from "../api.js";
7
7
  import { el } from "../dom.js";
8
8
 
9
- export function credentialPanel() {
10
- const box = el("div", { className: "manual" });
11
- box.append(el("b", { textContent: "Agent credential" }));
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" }));
12
13
  const body = el("div");
13
14
  box.append(body);
14
15
  body.append(el("p", { className: "sub", textContent: "Looking at where it would go…" }));
15
16
 
16
17
  getCredential()
17
- .then((c) => draw(body, c))
18
+ .then((c) => {
19
+ opts.onStatus?.(Boolean(c.orgSecret));
20
+ draw(body, c, opts);
21
+ })
18
22
  .catch((err) => body.replaceChildren(el("p", { className: "err", textContent: String(err.message || err) })));
19
23
  return box;
20
24
  }
21
25
 
22
- function draw(body, c) {
26
+ function draw(body, c, opts = {}) {
23
27
  body.replaceChildren();
24
28
  if (c.howTo) body.append(el("p", { textContent: "Where to get one: " + c.howTo }));
25
29
 
26
- if (!c.brains.length) {
30
+ if (!(c.brains ?? []).length) {
31
+ // Inside a step, the step's own hint already says when this opens.
32
+ if (opts.bare) return;
27
33
  body.append(
28
34
  el("p", {
29
35
  className: "sub",
@@ -73,6 +79,7 @@ function draw(body, c) {
73
79
  const r = await storeCredential(value, false);
74
80
  input.value = "";
75
81
  if (!r.ok) throw new Error(r.error);
82
+ opts.onStatus?.(true);
76
83
  out.replaceChildren(
77
84
  ...(r.fellBack ? [el("p", { className: "err", textContent: "The org secret was refused: " + r.fellBack })] : []),
78
85
  el("p", {
@@ -15,7 +15,7 @@ import { unifiedDiff, diffStat } from "../textdiff.js";
15
15
  */
16
16
  export function paste(opts) {
17
17
  const box = el("div", { className: "paste" });
18
- box.append(el("h3", { textContent: opts.title ?? "Write it with your own AI" }));
18
+ if (opts.title !== "") box.append(el("h3", { textContent: opts.title ?? "Write it with your own AI" }));
19
19
 
20
20
  const status = el("p", { className: "sub", textContent: "Building the prompt…" });
21
21
  box.append(status);
@@ -23,7 +23,7 @@ export function paste(opts) {
23
23
  /* A charter brief carries a worked example to model the shape on, matched to the role. The
24
24
  choice is on the page because the match is a guess, and "none" is a fair answer. */
25
25
  const model = el("select", { hidden: true });
26
- const modelRow = el("label", { className: "sub", hidden: true }, ["Model it on: ", model]);
26
+ const modelRow = el("label", { className: "sub", hidden: true }, ["Example to follow: ", model]);
27
27
  box.append(modelRow);
28
28
 
29
29
  const actions = el("div", { className: "row" });
@@ -38,8 +38,7 @@ export function paste(opts) {
38
38
  const answer = el("textarea", {
39
39
  className: "pastebox",
40
40
  placeholder:
41
- "Paste the whole reply here, including anything your AI said around the file. " +
42
- "Only the part between the sentinels is used.",
41
+ "Paste its whole reply here.",
43
42
  });
44
43
  answer.rows = 8;
45
44
 
@@ -76,11 +75,9 @@ export function paste(opts) {
76
75
  model.hidden = modelRow.hidden = false;
77
76
  }
78
77
  status.textContent =
79
- "Paste this into Claude, ChatGPT or anything else. It carries every file it refers to, " +
80
- "so there is nothing to attach and nothing for it to ask you for. It will interview you " +
81
- "first, then hand back the finished file.";
78
+ "Copy the prompt into Claude or ChatGPT and answer its questions.";
82
79
  box.append(
83
- el("p", { className: "sub", textContent: "Then paste its whole reply back here:" }),
80
+ el("p", { className: "sub", textContent: "Then paste its reply here:" }),
84
81
  answer,
85
82
  el("div", { className: "row" }, [check]),
86
83
  result,
@@ -13,14 +13,16 @@ import { el } from "../dom.js";
13
13
  * org.yaml already lists is read off disk, which is how the Org screen uses it long after setup.
14
14
  */
15
15
  export function repoPicker(opts) {
16
- const box = el("div", { className: "manual" });
17
- box.append(
18
- el("b", { textContent: "Which repos the staff work in" }),
19
- el("p", {
20
- textContent:
21
- "Add the repos your staff should work on. New hires get access to them automatically.",
22
- }),
23
- );
16
+ const box = el("div", { className: opts.bare ? "" : "manual" });
17
+ if (!opts.bare) {
18
+ box.append(
19
+ el("b", { textContent: "Which repos the staff work in" }),
20
+ el("p", {
21
+ textContent:
22
+ "Add the repos your staff should work on. New hires get access to them automatically.",
23
+ }),
24
+ );
25
+ }
24
26
 
25
27
  const out = el("div", { style: "margin-top:9px" });
26
28
  box.append(out);
@@ -30,6 +30,12 @@ let S = null; // /api/setup/status, refreshed after anything that changes the wo
30
30
  /* Where the doctor checklist is drawn on this screen, so a save can re-run it. Left stale, it
31
31
  went on saying business.stub after business.md had been saved, until "Check again". */
32
32
  let healthHost = null;
33
+ let healthStep = null;
34
+
35
+ /* What the "What is left" cards already ask for. Doctor reporting them as well, right after a
36
+ successful create, read as the create having failed. */
37
+ const COVERED = ["business", "business.stub", "priorities", "priorities.stub", "actions-access", "secrets", "agent"];
38
+ const HEALTH = { skip: COVERED, hideWhenClean: true };
33
39
 
34
40
  /* Each "what is left" step that is a file, by id, so a save can mark it done in place rather
35
41
  than repainting the page and throwing away the diff somebody is still reading. */
@@ -55,31 +61,15 @@ export async function viewGettingStarted(main) {
55
61
  S = await getSetup();
56
62
  // Somebody may have clicked elsewhere while GitHub was answering.
57
63
  if (App.view !== "setup") return;
58
- sub.textContent =
59
- "What is still to do before " + (App.data?.name ?? "this org") + " runs well. Each step " +
60
- "reads the world rather than remembering a click, so it is safe to leave and come back.";
61
-
62
- if (!App.data?.staff?.length) {
63
- const next = step(1, "Hire your first staff member", false);
64
- const hire = el("button", { className: "btn primary", textContent: "Hire someone" });
65
- hire.onclick = () => go({ view: "staff" });
66
- next.append(
67
- el("p", {
68
- textContent:
69
- "Nothing runs until you hire someone. You'll set up their GitHub App and charter from their card.",
70
- }),
71
- el("div", { className: "row" }, [hire]),
72
- );
73
- main.append(next);
74
- }
64
+ sub.textContent = "Work through these in order.";
65
+ main.append(afterCreate());
75
66
 
76
- main.append(afterCreate(App.data?.staff?.length ? 1 : 2));
77
-
78
- const health = step(App.data?.staff?.length ? 2 : 3, "What doctor still says", false);
67
+ const health = step("!", "Other problems", false);
79
68
  healthHost = el("div", { style: "margin-top:12px" });
80
69
  health.append(healthHost);
81
70
  main.append(health);
82
- checklist(healthHost, {});
71
+ healthStep = health;
72
+ checklist(healthHost, { ...HEALTH, onEmpty: () => health.remove() });
83
73
  }
84
74
 
85
75
  /** Whether the sidebar offers Getting started: while anything is left, or while it is open. */
@@ -93,7 +83,7 @@ export function paintSetupNav() {
93
83
  again rather than updated by hand. The sidebar's list comes from /api/org, which is the one
94
84
  the portal already reads. */
95
85
  async function recheck() {
96
- if (healthHost) checklist(healthHost, {});
86
+ if (healthHost) checklist(healthHost, { ...HEALTH, onEmpty: () => healthStep?.remove() });
97
87
  try {
98
88
  S = await getSetup();
99
89
  for (const [id, mark] of fileSteps) mark(!(S.tenant.left ?? []).includes(id));
@@ -129,7 +119,11 @@ function render(main) {
129
119
 
130
120
  /* Drawn whenever a tenant exists, not only in the seconds after creating one. Setup takes
131
121
  days: the repo picker and the two manual steps have to still be here tomorrow. */
132
- if (S.tenant.found) main.append(afterCreate(3));
122
+ if (S.tenant.found) {
123
+ const rest = step(3, "Finish setting up", false);
124
+ rest.append(afterCreate());
125
+ main.append(rest);
126
+ }
133
127
  }
134
128
 
135
129
  /* ---------------------------------- 1 · gh ---------------------------------- */
@@ -166,13 +160,9 @@ function stepOrg(main) {
166
160
  el("p", {
167
161
  className: "sub",
168
162
  textContent:
169
- "Reload the page to open the portal. Anything below that is still undone stays under " +
170
- "Getting started in the sidebar until it is.",
163
+ "Finish the steps below. They stay under Getting started in the sidebar until they're done.",
171
164
  }),
172
165
  );
173
- healthHost = el("div", { style: "margin-top:12px" });
174
- card.append(healthHost);
175
- checklist(healthHost, {});
176
166
  return card;
177
167
  }
178
168
 
@@ -386,33 +376,95 @@ function stepOrg(main) {
386
376
 
387
377
  /* ------------------------- 5 · the two GitHub insists on ------------------------- */
388
378
 
389
- function afterCreate(n) {
379
+ function afterCreate() {
390
380
  fileSteps.clear();
391
- const card = step(n, "What is left", false);
392
- card.append(accessStep());
393
- card.append(credentialPanel());
381
+ const list = el("div", { className: "todos" });
382
+ const left = S.tenant.left ?? [];
383
+ const staffCount = App.data?.staff?.length ?? 0;
384
+ let n = 0;
394
385
 
395
386
  if (S.tenant.org) {
396
- card.append(repoPicker({ org: S.tenant.org, known: S.tenant.repos ?? [] }));
387
+ const repos = todo(++n, "Choose the repos your staff work on", (S.tenant.products ?? []).length > 0);
388
+ repos.hint("Pick at least one. Staff open pull requests there, and you review them.");
389
+ repos.body.append(
390
+ repoPicker({ org: S.tenant.org, known: S.tenant.repos ?? [], bare: true, onAdded: () => repos.setDone(true) }),
391
+ );
392
+ list.append(repos.card);
397
393
  }
398
394
 
399
- const business = fileStep(
400
- "business",
401
- "Describe the business",
402
- "Answer the questions in org/business.md. Every staff member reads it before they start work.",
403
- );
404
- // The copy-a-prompt loop, which is the whole answer to "how do I write this file".
405
- business.append(paste({ kind: "discover", title: "Write it with your own AI", onSaved: recheck }));
406
- card.append(business);
407
-
408
- const priorities = fileStep(
409
- "priorities",
410
- "Set this month's priorities",
411
- "Up to three things, in order, plus what's out of scope. Saved as org/priorities.md.",
412
- );
413
- priorities.append(prioritiesEditor());
414
- card.append(priorities);
415
- return card;
395
+ const business = todo(++n, "Describe the business", !left.includes("business"));
396
+ business.hint("A few short answers. Every staff member reads them before they start work.");
397
+ business.body.append(businessForm(() => business.setDone(true)));
398
+ fileSteps.set("business", business.setDone);
399
+ list.append(business.card);
400
+
401
+ const priorities = todo(++n, "Set this month's priorities", !left.includes("priorities"));
402
+ priorities.hint("Up to three, in order. Staff pick work that serves them.");
403
+ priorities.body.append(prioritiesForm(() => priorities.setDone(true)));
404
+ fileSteps.set("priorities", priorities.setDone);
405
+ list.append(priorities.card);
406
+
407
+ const hire = todo(++n, "Hire your first staff member", staffCount > 0);
408
+ hire.hint("Give them a role, like cto. You'll create their GitHub App and write their charter from their card.");
409
+ const hireBtn = el("button", { className: "btn primary", textContent: "Hire someone" });
410
+ hireBtn.onclick = () => {
411
+ // Before the org has loaded, this screen is the whole app, so the Staff screen needs a reload.
412
+ if (App.data) go({ view: "staff" });
413
+ else location.assign("/#/-/staff") || location.reload();
414
+ };
415
+ hire.body.append(el("div", { className: "row" }, [hireBtn]));
416
+ list.append(hire.card);
417
+
418
+ const cred = todo(++n, "Add your agent credential", false);
419
+ cred.hint(staffCount ? "Staff use it to run. You only add it once." : "Do this after your first hire.");
420
+ cred.body.append(credentialPanel({ bare: true, onStatus: (ok) => cred.setDone(ok) }));
421
+ list.append(cred.card);
422
+
423
+ const access = todo(++n, "Let staff repos use the shared workflow", false);
424
+ access.body.append(accessStep({ onStatus: (ok) => access.setDone(ok) }));
425
+ list.append(access.card);
426
+
427
+ return list;
428
+ }
429
+
430
+ /**
431
+ * One numbered thing to do. Done ones fold to a single ticked line, and Change opens them again,
432
+ * so the list shows what is left without hiding what was done.
433
+ */
434
+ function todo(n, title, done) {
435
+ const card = el("section", { className: "todo" });
436
+ const num = el("span", { className: "todon" });
437
+ const pill = el("span", { className: "todopill" });
438
+ const change = el("button", { className: "ghbtn todochange", textContent: "Change" });
439
+ const head = el("div", { className: "todohead" }, [num, el("h3", { textContent: title }), pill, change]);
440
+ const hintEl = el("p", { className: "sub todohint" });
441
+ const body = el("div", { className: "todobody" });
442
+ card.append(head, hintEl, body);
443
+ let open = false;
444
+ const setDone = (d) => {
445
+ card.classList.toggle("done", d);
446
+ num.textContent = d ? "✓" : String(n);
447
+ pill.textContent = d ? "Done" : "To do";
448
+ change.hidden = !d;
449
+ const shown = !d || open;
450
+ body.hidden = !shown;
451
+ hintEl.hidden = !shown || !hintEl.textContent;
452
+ };
453
+ change.onclick = () => {
454
+ open = !open;
455
+ change.textContent = open ? "Close" : "Change";
456
+ setDone(card.classList.contains("done"));
457
+ };
458
+ setDone(done);
459
+ return {
460
+ card,
461
+ body,
462
+ setDone,
463
+ hint: (text) => {
464
+ hintEl.textContent = text;
465
+ setDone(card.classList.contains("done"));
466
+ },
467
+ };
416
468
  }
417
469
 
418
470
  /**
@@ -421,18 +473,6 @@ function afterCreate(n) {
421
473
  * Read from the status rather than from the click that saved it, so a file written in an
422
474
  * editor, or by a coding agent, shows as done here too.
423
475
  */
424
- function fileStep(id, title, why) {
425
- const box = el("div", { className: "manual" });
426
- const tag = el("span", { className: "meta" });
427
- const mark = (done) => {
428
- tag.textContent = done ? "Written." : "Still the stub it shipped as.";
429
- box.classList[done ? "add" : "remove"]("done");
430
- };
431
- mark(!(S.tenant.left ?? []).includes(id));
432
- fileSteps.set(id, mark);
433
- box.append(el("div", {}, [el("b", { textContent: title }), tag]), el("p", { textContent: why }));
434
- return box;
435
- }
436
476
 
437
477
  /**
438
478
  * org/priorities.md, edited in place.
@@ -442,58 +482,143 @@ function fileStep(id, title, why) {
442
482
  * quicker to type than to explain to a model. So the file opens as it is on disk, stub and
443
483
  * all, and saving it is the same commit-and-push as every other editor here.
444
484
  */
445
- function prioritiesEditor() {
446
- const box = el("div", { style: "margin-top:9px" });
447
- const path = opsName() + "/org/priorities.md";
448
- const ta = el("textarea", { className: "pastebox" });
449
- ta.rows = 12;
450
- const save = el("button", { className: "btn primary", textContent: "Save and commit" });
485
+ const BUSINESS_QUESTIONS = [
486
+ ["line", "What does it do, in one line?", "What this business does, in one line", true],
487
+ ["who", "Who is it for?", "Who the customers are, specifically", true],
488
+ ["key", "What does everything depend on?", "The one fact everything else follows from", false],
489
+ ["true", "What's already true? What's built, measured or tried?", "What is already true", false],
490
+ ["not", "What should staff never decide on their own?", "What is not ours to decide", false],
491
+ ];
492
+
493
+ /** The business, as five questions in plain fields, written out as org/business.md. */
494
+ function businessForm(onSaved) {
495
+ const box = el("div");
496
+ const path = opsName() + "/org/business.md";
497
+ const fields = BUSINESS_QUESTIONS.map(([id, label, , required]) => {
498
+ const input = id === "line" ? el("input", { type: "text" }) : el("textarea", { className: "pastebox", rows: 3 });
499
+ const wrap = el("label", { className: "qfield" }, [
500
+ el("span", { textContent: label + (required ? "" : " (optional)") }),
501
+ input,
502
+ ]);
503
+ return { id, input, wrap };
504
+ });
505
+ const save = el("button", { className: "btn primary", textContent: "Save" });
451
506
  const out = el("div");
452
- box.append(ta, el("div", { className: "row", style: "margin-top:9px" }, [save]), out);
453
-
454
- let before = "";
455
- save.disabled = true;
456
- // Not getFile: an absent file answers 404 with a message, and that is not what to edit.
457
- fetch(fileUrl(path), { cache: "no-store" })
458
- .then((r) => (r.ok ? r.text() : ""))
459
- .then((text) => {
460
- before = text;
461
- ta.value = text;
462
- grow(ta);
463
- save.disabled = false;
464
- })
465
- .catch((err) => out.replaceChildren(el("p", { className: "err", textContent: String(err.message || err) })));
466
- ta.addEventListener("input", () => grow(ta));
507
+ box.append(...fields.map((f) => f.wrap), el("div", { className: "row" }, [save]), out);
467
508
 
468
509
  save.onclick = async () => {
469
- if (ta.value.trim() === before.trim()) {
470
- out.replaceChildren(el("p", { className: "sub", textContent: "Nothing changed yet." }));
510
+ const missing = fields.find((f, i) => BUSINESS_QUESTIONS[i][3] && !String(f.input.value ?? "").trim());
511
+ if (missing) {
512
+ missing.input.focus();
513
+ out.replaceChildren(el("p", { className: "err", textContent: "Answer the first two at least." }));
471
514
  return;
472
515
  }
473
- save.disabled = true;
474
- save.textContent = "Saving…";
475
- try {
476
- const r = await saveFile(path, ta.value, "portal: write org/priorities.md");
477
- before = ta.value;
478
- out.replaceChildren(
479
- el("p", {
480
- className: r.pushed ? "sub" : "err",
481
- textContent: r.pushed
482
- ? "Saved and pushed."
483
- : "Committed, but the push failed: " + (r.note ?? ""),
484
- }),
485
- );
486
- recheck();
487
- } catch (err) {
488
- out.replaceChildren(el("p", { className: "err", textContent: String(err.message || err) }));
489
- } finally {
490
- save.disabled = false;
491
- save.textContent = "Save and commit";
516
+ const name = App.data?.name ?? S.tenant.name ?? S.tenant.org;
517
+ const text =
518
+ `# What ${name} is\n\n` +
519
+ fields
520
+ .map((f, i) => [BUSINESS_QUESTIONS[i][2], String(f.input.value ?? "").trim()])
521
+ .filter(([, v]) => v)
522
+ .map(([h, v]) => `## ${h}\n\n${v}\n`)
523
+ .join("\n");
524
+ await saveAndSay(save, out, path, text, "portal: write org/business.md", onSaved);
525
+ };
526
+
527
+ // For somebody who would rather be interviewed than fill in a form.
528
+ box.append(
529
+ el("details", { className: "altway" }, [
530
+ el("summary", { textContent: "Or let an AI interview you instead" }),
531
+ paste({ kind: "discover", title: "", onSaved: () => (onSaved(), recheck()) }),
532
+ ]),
533
+ );
534
+ box.append(changeRaw(path, onSaved));
535
+ return box;
536
+ }
537
+
538
+ /** This month's priorities, as three lines and an out-of-scope list. */
539
+ function prioritiesForm(onSaved) {
540
+ const box = el("div");
541
+ const path = opsName() + "/org/priorities.md";
542
+ const ranks = [1, 2, 3].map((i) => {
543
+ const input = el("input", { type: "text", placeholder: i === 1 ? "e.g. Tasks can be ticked off and removed" : "" });
544
+ return { input, wrap: el("label", { className: "qfield" }, [el("span", { textContent: "Priority " + i + (i > 1 ? " (optional)" : "") }), input]) };
545
+ });
546
+ const scope = el("textarea", { className: "pastebox", rows: 3, placeholder: "One per line" });
547
+ const save = el("button", { className: "btn primary", textContent: "Save" });
548
+ const out = el("div");
549
+ box.append(
550
+ ...ranks.map((r) => r.wrap),
551
+ el("label", { className: "qfield" }, [el("span", { textContent: "Out of scope this month (optional)" }), scope]),
552
+ el("div", { className: "row" }, [save]),
553
+ out,
554
+ );
555
+ save.onclick = async () => {
556
+ const items = ranks.map((r) => String(r.input.value ?? "").trim()).filter(Boolean);
557
+ if (!items.length) {
558
+ ranks[0].input.focus();
559
+ out.replaceChildren(el("p", { className: "err", textContent: "Add at least one priority." }));
560
+ return;
492
561
  }
562
+ const outs = String(scope.value ?? "").split("\n").map((l) => l.replace(/^[-*]\s*/, "").trim()).filter(Boolean);
563
+ const text =
564
+ "## What matters this month\n\n### Priorities, in order\n\n" +
565
+ items.map((t, i) => `${i + 1}. ${t}`).join("\n") +
566
+ "\n" +
567
+ (outs.length ? "\n### Out of scope this month\n\n" + outs.map((t) => `- ${t}`).join("\n") + "\n" : "");
568
+ await saveAndSay(save, out, path, text, "portal: write org/priorities.md", onSaved);
493
569
  };
570
+ box.append(changeRaw(path, onSaved));
494
571
  return box;
495
572
  }
496
573
 
574
+ async function saveAndSay(btn, out, path, text, message, onSaved) {
575
+ btn.disabled = true;
576
+ const label = btn.textContent;
577
+ btn.textContent = "Saving…";
578
+ try {
579
+ const r = await saveFile(path, text, message);
580
+ out.replaceChildren(
581
+ el("p", {
582
+ className: r.pushed ? "sub" : "err",
583
+ textContent: r.pushed ? "Saved." : "Saved locally, but the push failed: " + (r.note ?? ""),
584
+ }),
585
+ );
586
+ onSaved();
587
+ recheck();
588
+ } catch (err) {
589
+ out.replaceChildren(el("p", { className: "err", textContent: String(err.message || err) }));
590
+ } finally {
591
+ btn.disabled = false;
592
+ btn.textContent = label;
593
+ }
594
+ }
595
+
596
+ /** The file itself, for editing what is already there rather than starting again. */
597
+ function changeRaw(path, onSaved) {
598
+ const details = el("details", { className: "altway" });
599
+ details.append(el("summary", { textContent: "Or edit the file directly" }));
600
+ let loaded = false;
601
+ details.addEventListener("toggle", () => {
602
+ if (!details.open || loaded) return;
603
+ loaded = true;
604
+ const ta = el("textarea", { className: "pastebox" });
605
+ ta.rows = 12;
606
+ const save = el("button", { className: "btn", textContent: "Save" });
607
+ const out = el("div");
608
+ details.append(ta, el("div", { className: "row" }, [save]), out);
609
+ // Not getFile: an absent file answers 404 with a message, and that is not what to edit.
610
+ fetch(fileUrl(path), { cache: "no-store" })
611
+ .then((r) => (r.ok ? r.text() : ""))
612
+ .then((text) => {
613
+ ta.value = text;
614
+ grow(ta);
615
+ });
616
+ ta.addEventListener("input", () => grow(ta));
617
+ save.onclick = () => saveAndSay(save, out, path, ta.value, "portal: edit " + path.split("/").slice(1).join("/"), onSaved);
618
+ });
619
+ return details;
620
+ }
621
+
497
622
  /** The ops repo's directory name, which is what every workspace-relative path starts with. */
498
623
  function opsName() {
499
624
  return App.data?.opsName ?? String(S.tenant.opsDir ?? "roster-ops").split("/").pop();
@@ -506,9 +631,8 @@ function opsName() {
506
631
  *
507
632
  * Skipped, every workflow fails with "workflow not found", which reads like a typo in a path.
508
633
  */
509
- function accessStep() {
510
- const box = el("div", { className: "manual" });
511
- box.append(el("b", { textContent: "Let staff repos run the shared workflow" }));
634
+ function accessStep(opts = {}) {
635
+ const box = el("div");
512
636
  const out = el("div");
513
637
  box.append(out);
514
638
  const say = (text, cls) => el("p", { className: cls ?? "sub", textContent: text });
@@ -518,7 +642,7 @@ function accessStep() {
518
642
  out.replaceChildren(
519
643
  say(
520
644
  (why ? why + " " : "") +
521
- "Needed so staff repos can run the shared workflow.",
645
+ "Roster tried to set this when it created the org, and GitHub didn't allow it.",
522
646
  ),
523
647
  el("div", { className: "row" }, [go]),
524
648
  );
@@ -528,6 +652,7 @@ function accessStep() {
528
652
  const r = await setAccess();
529
653
  if (r.ok) {
530
654
  out.replaceChildren(say("Done."));
655
+ opts.onStatus?.(true);
531
656
  return;
532
657
  }
533
658
  out.replaceChildren(
@@ -545,6 +670,7 @@ function accessStep() {
545
670
  out.replaceChildren(say("Checking…"));
546
671
  getAccess()
547
672
  .then((r) => {
673
+ opts.onStatus?.(Boolean(r.ok));
548
674
  if (r.ok) out.replaceChildren(say("Done."));
549
675
  else offer(r.level ? "It is set to “" + r.level + "”." : "");
550
676
  })