@nanocollective/roster 0.1.0-alpha.7 → 0.1.0-alpha.9

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
@@ -1245,6 +1245,25 @@ function repoName(full) {
1245
1245
  import { execFile as execFile3 } from "child_process";
1246
1246
  import { existsSync as existsSync11, mkdirSync, readFileSync as readFileSync10 } from "fs";
1247
1247
  import { join as join11 } from "path";
1248
+
1249
+ // src/lib/stub.ts
1250
+ var MARKERS = [
1251
+ "**This file is a stub",
1252
+ "This file is a stub, and it is the most important file in this repo",
1253
+ "This file is a stub, and everything the staff say is downstream of it",
1254
+ "The headings below are the shape that has worked; the words are yours",
1255
+ "Write it with your own AI",
1256
+ "One paragraph. What this role is for, in this business specifically.",
1257
+ "Not a segment. The person, what they were doing ten minutes before they arrived",
1258
+ "The single thing this staff member is optimising",
1259
+ "Or answer these by hand. Short is better than complete."
1260
+ ];
1261
+ function looksUnwritten(body) {
1262
+ if (!body.trim()) return true;
1263
+ return MARKERS.some((marker) => body.includes(marker));
1264
+ }
1265
+
1266
+ // src/lib/setup.ts
1248
1267
  var AGENTS = [
1249
1268
  {
1250
1269
  id: "claude-code-action",
@@ -1303,7 +1322,8 @@ async function setupStatus(startedIn) {
1303
1322
  root: found.root,
1304
1323
  opsDir: found.opsDir,
1305
1324
  org: readOrgName(found.opsDir),
1306
- repos: readRepoNames(found.opsDir)
1325
+ repos: readBlockNames(found.opsDir, "repos", "name"),
1326
+ left: unfinished(found.opsDir, readBlockNames(found.opsDir, "staff", "handle").length)
1307
1327
  } : { found: false },
1308
1328
  gh: ready.ok ? { ok: true, login: ready.data?.login } : { ok: false, error: ready.error ?? "gh is not ready" },
1309
1329
  orgs,
@@ -1317,23 +1337,36 @@ function readOrgName(opsDir) {
1317
1337
  const m = /^org:\s*(\S+)\s*$/m.exec(readFileSync10(path, "utf8"));
1318
1338
  return m?.[1];
1319
1339
  }
1320
- function readRepoNames(opsDir) {
1340
+ function readBlockNames(opsDir, block, key) {
1321
1341
  const path = join11(opsDir, "org.yaml");
1322
1342
  if (!existsSync11(path)) return [];
1323
1343
  const names = [];
1344
+ const pattern = new RegExp(`\\b${key}:\\s*([\\w.-]+)`);
1324
1345
  let inside = false;
1325
1346
  for (const line of readFileSync10(path, "utf8").split("\n")) {
1326
- if (/^repos:\s*$/.test(line)) {
1347
+ if (new RegExp(`^${block}:\\s*$`).test(line)) {
1327
1348
  inside = true;
1328
1349
  continue;
1329
1350
  }
1330
1351
  if (inside && /^\S/.test(line)) break;
1331
1352
  if (!inside) continue;
1332
- const m = /name:\s*([\w.-]+)/.exec(line);
1353
+ const m = pattern.exec(line);
1333
1354
  if (m) names.push(m[1]);
1334
1355
  }
1335
1356
  return names;
1336
1357
  }
1358
+ function unfinished(opsDir, staffCount) {
1359
+ const out = [];
1360
+ if (!staffCount) out.push("hire");
1361
+ for (const [id, rel] of [
1362
+ ["business", "org/business.md"],
1363
+ ["priorities", "org/priorities.md"]
1364
+ ]) {
1365
+ const path = join11(opsDir, rel);
1366
+ if (!existsSync11(path) || looksUnwritten(readFileSync10(path, "utf8"))) out.push(id);
1367
+ }
1368
+ return out;
1369
+ }
1337
1370
  async function loadFrameworkComposer() {
1338
1371
  const path = join11(templatesRoot(), "ops", "compose.mjs");
1339
1372
  return await import(`file://${path}`);
@@ -1615,9 +1648,12 @@ function buildPlan(ws, org, handle, opts, parseYaml) {
1615
1648
  }
1616
1649
  if (!app) warnings.push("no app slug could be inferred; pass --app");
1617
1650
  const publicApp = opts.publicApp ?? publicIdentity?.app ?? "";
1618
- if (!publicApp) {
1651
+ const publicProducts = (org.repos ?? []).filter(
1652
+ (r) => r.role === "product" && String(r.visibility ?? "").toLowerCase() !== "private"
1653
+ );
1654
+ if (!publicApp && publicProducts.length) {
1619
1655
  warnings.push(
1620
- "no shared public identity yet \u2014 pass --public-app, or the product-repo lane will not work"
1656
+ `no shared public identity yet \u2014 pass --public-app, or the product-repo lane will not work on ${publicProducts.map((r) => r.name).join(", ")}, which is public`
1621
1657
  );
1622
1658
  }
1623
1659
  const staff = {
@@ -2630,23 +2666,6 @@ function budgetOf(value) {
2630
2666
  return typeof n === "number" && Number.isFinite(n) && n > 0 ? n : null;
2631
2667
  }
2632
2668
 
2633
- // src/lib/stub.ts
2634
- var MARKERS = [
2635
- "**This file is a stub",
2636
- "This file is a stub, and it is the most important file in this repo",
2637
- "This file is a stub, and everything the staff say is downstream of it",
2638
- "The headings below are the shape that has worked; the words are yours",
2639
- "Write it with your own AI",
2640
- "One paragraph. What this role is for, in this business specifically.",
2641
- "Not a segment. The person, what they were doing ten minutes before they arrived",
2642
- "The single thing this staff member is optimising",
2643
- "Or answer these by hand. Short is better than complete."
2644
- ];
2645
- function looksUnwritten(body) {
2646
- if (!body.trim()) return true;
2647
- return MARKERS.some((marker) => body.includes(marker));
2648
- }
2649
-
2650
2669
  // src/commands/upgrade.ts
2651
2670
  import { execFileSync as execFileSync5 } from "child_process";
2652
2671
  import {
@@ -5335,6 +5354,29 @@ function short3(e) {
5335
5354
  return line.length > 200 ? `${line.slice(0, 199)}\u2026` : line;
5336
5355
  }
5337
5356
 
5357
+ // src/lib/browser.ts
5358
+ import { spawn } from "child_process";
5359
+ function shouldOpen(env, isTTY, noOpen) {
5360
+ if (noOpen || !isTTY) return false;
5361
+ if (env.CI || env.BROWSER === "none" || env.SSH_CONNECTION) return false;
5362
+ return true;
5363
+ }
5364
+ function openerFor(platform, url) {
5365
+ if (platform === "darwin") return ["open", [url]];
5366
+ if (platform === "win32") return ["cmd", ["/c", "start", "", url]];
5367
+ return ["xdg-open", [url]];
5368
+ }
5369
+ function openBrowser(url) {
5370
+ const [cmd, args] = openerFor(process.platform, url);
5371
+ try {
5372
+ const child = spawn(cmd, args, { stdio: "ignore", detached: true });
5373
+ child.on("error", () => {
5374
+ });
5375
+ child.unref();
5376
+ } catch {
5377
+ }
5378
+ }
5379
+
5338
5380
  // src/lib/inbox.ts
5339
5381
  import { execFile as execFile5 } from "child_process";
5340
5382
  import { promisify as promisify4 } from "util";
@@ -5664,20 +5706,20 @@ function parsePaste(raw, targets) {
5664
5706
  const known = new Map(targets.map((t) => [t.path, t]));
5665
5707
  BLOCK.lastIndex = 0;
5666
5708
  for (const m of text.matchAll(BLOCK)) {
5667
- const path = m[1].trim();
5709
+ const written = m[1].trim();
5668
5710
  const body = strip(m[2] ?? "");
5669
- const target = known.get(path);
5711
+ const target = resolveTarget(written, known);
5670
5712
  if (!target) {
5671
5713
  problems.push({
5672
5714
  id: "unknown-path",
5673
- path,
5674
- message: `The answer writes ${path}, which this brief did not ask for.`,
5715
+ path: written,
5716
+ message: `The answer writes ${written}, which this brief did not ask for.`,
5675
5717
  retry: `Write only ${[...known.keys()].join(", ")}, using the sentinels exactly as given.`
5676
5718
  });
5677
5719
  continue;
5678
5720
  }
5679
5721
  files.push({
5680
- path,
5722
+ path: target.path,
5681
5723
  text: ensureNewline(body),
5682
5724
  unchanged: body.trim() === target.before.trim()
5683
5725
  });
@@ -5724,6 +5766,14 @@ ${END}`
5724
5766
  }
5725
5767
  return { files, problems };
5726
5768
  }
5769
+ function resolveTarget(written, known) {
5770
+ const exact = known.get(written);
5771
+ if (exact) return exact;
5772
+ const tail2 = written.replace(/^(\.\/)+/, "").replace(/^\/+/, "");
5773
+ if (!tail2 || tail2.split("/").includes("..")) return void 0;
5774
+ const matches = [...known.values()].filter((t) => t.path === tail2 || t.path.endsWith(`/${tail2}`));
5775
+ return matches.length === 1 ? matches[0] : void 0;
5776
+ }
5727
5777
  function strip(body) {
5728
5778
  const trimmed = body.replace(/^\n+/, "").replace(/\s+$/, "");
5729
5779
  const fenced = /^(`{3,}|~{3,})[^\n]*\n([\s\S]*)\n\1\s*$/.exec(trimmed);
@@ -6241,6 +6291,7 @@ roster portal
6241
6291
  --host <addr> default 127.0.0.1. Anything else exposes write actions to the network.
6242
6292
  --ops <dir> ops repo directory (default: found by walking up)
6243
6293
  --dir <path> where a tenant would be created or checked out (default: here)
6294
+ --no-open don't open a browser (also off in CI, over SSH, or with BROWSER=none)
6244
6295
  `;
6245
6296
  var MIME = {
6246
6297
  ".md": "text/plain; charset=utf-8",
@@ -6501,7 +6552,8 @@ async function portalCommand(argv) {
6501
6552
  const payload = JSON.parse(await body(req) || "{}");
6502
6553
  const name = String(payload.name ?? "");
6503
6554
  const role = String(payload.role ?? "product");
6504
- if (!/^[\w.-]+$/.test(name) || !/^(product|brain|ops|repo)$/.test(role)) {
6555
+ const visibility = String(payload.visibility ?? "private").toLowerCase();
6556
+ if (!/^[\w.-]+$/.test(name) || !/^(product|brain|ops|repo)$/.test(role) || !/^(private|public|internal)$/.test(visibility)) {
6505
6557
  res.writeHead(400, { "content-type": "application/json" });
6506
6558
  res.end(JSON.stringify({ error: "a repo name and a known role are needed" }));
6507
6559
  return;
@@ -6515,7 +6567,11 @@ async function portalCommand(argv) {
6515
6567
  const result = saveFile(
6516
6568
  ws,
6517
6569
  `${ws.opsName}/org.yaml`,
6518
- insertUnder(text, "repos", ` - { name: ${name}, visibility: private, role: ${role} }`),
6570
+ insertUnder(
6571
+ text,
6572
+ "repos",
6573
+ ` - { name: ${name}, visibility: ${visibility}, role: ${role} }`
6574
+ ),
6519
6575
  `portal: org.yaml lists ${name}`
6520
6576
  );
6521
6577
  json(res, { ok: true, ...result });
@@ -6715,11 +6771,12 @@ async function portalCommand(argv) {
6715
6771
  if (url.pathname === "/api/org") {
6716
6772
  const org = readOrg(w.opsDir, parseYaml);
6717
6773
  const data = buildExport(w, org, parseYaml);
6774
+ const left = unfinished(w.opsDir, (org.staff ?? []).length);
6718
6775
  res.writeHead(200, {
6719
6776
  "content-type": "application/json; charset=utf-8",
6720
6777
  "cache-control": "no-store"
6721
6778
  });
6722
- res.end(JSON.stringify(data));
6779
+ res.end(JSON.stringify({ ...data, unfinished: left }));
6723
6780
  return;
6724
6781
  }
6725
6782
  if (url.pathname === "/api/orglayer") {
@@ -7347,6 +7404,9 @@ ${warn}
7347
7404
 
7348
7405
  `
7349
7406
  );
7407
+ if (shouldOpen(process.env, Boolean(process.stdout.isTTY), opts.noOpen === true)) {
7408
+ openBrowser(`http://localhost:${port}`);
7409
+ }
7350
7410
  });
7351
7411
  });
7352
7412
  }
@@ -7447,6 +7507,10 @@ function parseFlags11(argv) {
7447
7507
  const out = {};
7448
7508
  for (let i = 0; i < argv.length; i++) {
7449
7509
  const flag = argv[i];
7510
+ if (flag === "--no-open") {
7511
+ out.noOpen = true;
7512
+ continue;
7513
+ }
7450
7514
  const value = argv[++i];
7451
7515
  if (value === void 0) throw new Error(`${flag} needs a value`);
7452
7516
  if (flag === "--port") out.port = Number(value);
package/docs/commands.md CHANGED
@@ -271,8 +271,12 @@ which is the shortest way in.
271
271
  --port <n> default 4300
272
272
  --host <a> default 127.0.0.1. Anything else exposes write actions to the network.
273
273
  --dir <path> where a tenant would be created or checked out. Default: here.
274
+ --no-open don't open a browser
274
275
  ```
275
276
 
277
+ It opens the page in your browser when it starts. It stays closed in CI, over SSH, when output
278
+ is not a terminal, or with `BROWSER=none`.
279
+
276
280
  **With no tenant where you started it, this is the setup screen**: it stands up a new org, or
277
281
  checks out one that already runs roster. Local only. See [the portal](portal.md).
278
282
 
@@ -50,6 +50,9 @@ Creating it makes `<org>/roster-ops` and **sets its Actions access** so every re
50
50
  can call its workflow. Without that every run fails with "workflow not found". If GitHub refuses
51
51
  (it needs admin on the repo), the page says why and links to the setting to click instead.
52
52
 
53
+ The rest of the steps stay on the same page. Reload it and the portal opens on **Getting
54
+ started**, which keeps them in the sidebar until they are done, with hiring first.
55
+
53
56
  ## 2. Say what the business is
54
57
 
55
58
  `org/business.md` ships as questions, and it is composed into the top of every prompt. An agent
@@ -61,7 +64,8 @@ reply into the box and you get a diff and a save button. See
61
64
  [the portal](portal.md#copy-a-prompt-paste-the-answer-back).
62
65
 
63
66
  Then `org/priorities.md`: what matters this month, ranked, and what is out of scope. A few
64
- lines, only you can write it. See [concepts](concepts.md#priorities).
67
+ lines, only you can write it, so it opens in place on the setup screen and saves the same way.
68
+ See [concepts](concepts.md#priorities).
65
69
 
66
70
  ## 3. Hire someone
67
71
 
@@ -71,7 +75,13 @@ lines, only you can write it. See [concepts](concepts.md#priorities).
71
75
  schedule it chose, and the commits it will make **as you** in repos that already exist: each
72
76
  peer's `staff.yaml`, and `org.yaml`. Nothing is left uncommitted on disk.
73
77
 
74
- For the first hire there is nobody to copy an App name from, so it asks.
78
+ For the first hire there is nobody to copy an App name from, so the form asks for two: this
79
+ staff member's App, and the shared public App. Names are unique across GitHub, so prefix them
80
+ with the org. The public one only matters if a product repo is public; leave it empty when they
81
+ are all private.
82
+
83
+ Product repos come from `org.yaml`. Mark one on the setup screen, or later with **Org → Add a
84
+ product repo**.
75
85
 
76
86
  ## 4. Create the App, and confirm the install
77
87
 
package/docs/portal.md CHANGED
@@ -18,6 +18,9 @@ Keep the repos checked out beside each other, in the same shape the runner uses.
18
18
 
19
19
  ## The sidebar
20
20
 
21
+ Anything that asks before it acts (a merge, a push, a hire, a retire, a paid run) asks in the
22
+ page's own dialog, never the browser's `confirm()`, which blocks the whole tab.
23
+
21
24
  **The counts are right on load.** The badges beside Inbox and Pending work are fetched once at
22
25
  boot, and the Inbox screen shares that request rather than making a second one. A sidebar that
23
26
  says nothing until you look at it is not a sidebar.
@@ -56,12 +59,24 @@ and where it will be stored (one org secret shared with the brains, or each brai
56
59
  secret would not arrive, with the reason). The value goes to the local server in a POST and from
57
60
  there to `gh` on standard input; it is never written to disk or echoed back. Until somebody is
58
61
  hired the box says so, because nothing would read it. Then which repos the staff work in, as a
59
- picker over what your `gh` can see minus what `org.yaml` already has, and a prompt for writing
60
- `org/business.md`.
62
+ picker over what your `gh` can see minus what `org.yaml` already has, recorded with the
63
+ visibility GitHub reports. Then the two files only you can write: a prompt for writing
64
+ `org/business.md`, and `org/priorities.md` opened in place, stub and all, to replace with what
65
+ matters this month. Each says whether it is written yet.
61
66
 
62
67
  Nothing here stores which step you are on. Setup takes days rather than minutes: an App has to be
63
68
  installed, a credential set, a first run finished. So the page derives its state from `roster
64
- doctor` every time it is drawn. A stored step counter would disagree with the world within an hour.
69
+ doctor` every time it is drawn, and runs the check again after anything on it is saved. A stored
70
+ step counter would disagree with the world within an hour.
71
+
72
+ ### Getting started
73
+
74
+ **Once the org exists, the same steps stay in the sidebar as *Getting started*** for as long as
75
+ anything is left: nobody hired, or `org/business.md` or `org/priorities.md` still the stub. An
76
+ org nobody has been hired into opens on it rather than on an empty Inbox, with *Hire your first
77
+ staff member* first, then the Actions setting, the credential, the repo picker, both files, and
78
+ what doctor still says. A link to another screen still wins. When the list is empty the entry
79
+ goes away; the repo picker lives on under [Org](#org).
65
80
 
66
81
  ## Inbox
67
82
 
@@ -217,6 +232,10 @@ prompt files in `prompts/`, each with a line saying what it is for, because a fi
217
232
  you nothing about which to open. Anything roster does not ship falls back to its own first
218
233
  heading.
219
234
 
235
+ **Add a product repo** opens the same picker setup uses, so a repo created later does not mean
236
+ typing `role: product` into `org.yaml` by hand. Adding one commits `org.yaml` and pushes. A new
237
+ hire picks it up; somebody already hired keeps the `works_in` in their own `staff.yaml`.
238
+
220
239
  **The list comes off disk**, not out of the page, so a tenant that adds `org/pricing.md` can
221
240
  open it like any other. Only files the
222
241
  portal may actually write are listed: an editor that offers a file it cannot save is a trap.
@@ -244,7 +263,10 @@ have more than one human, and a card that names one of two reads as the only one
244
263
  Everyone on the roster, and the things you would otherwise do from a terminal.
245
264
 
246
265
  **Hiring** runs the same `buildPlan` and `applyPlan` that `roster hire` does, on the server.
247
- Only the handle is required; everything else is copied from whoever is already here. You see
266
+ Only the handle is required; everything else is copied from whoever is already here. The first
267
+ hire has nobody to copy App names from, so its form also asks for the App's name and the shared
268
+ public App's, which are `--app` and `--public-app`. The public one only matters when a product
269
+ repo is public; on private ones the session uses the staff member's own App. You see
248
270
  the plan first, listing every file, every label, the schedule it chose and why, the commits it
249
271
  will make as you in repos that already exist (each peer's `staff.yaml`, `org.yaml`), whether the
250
272
  new brain joins the credential's org secret, and what is left for you. Nothing happens until you
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanocollective/roster",
3
- "version": "0.1.0-alpha.7",
3
+ "version": "0.1.0-alpha.9",
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": {
@@ -56,6 +56,8 @@
56
56
 
57
57
  .manual{background:var(--bg-elev);border:1px solid var(--line);border-radius:var(--r);
58
58
  padding:13px 15px;margin:11px 0}
59
+ /* A file step that is written says so in the border too, the way a finished step does. */
60
+ .manual.done{border-color:color-mix(in srgb,var(--accent) 30%,var(--line))}
59
61
  .manual b{display:block;font:600 13px var(--sans);margin-bottom:4px}
60
62
  .manual p{margin:0 0 6px;font-size:13px}
61
63
  .manual small{display:block;color:var(--ink-faint);font-size:11.5px;line-height:1.5;margin-bottom:9px}
@@ -27,6 +27,10 @@
27
27
  <span class="sync" data-icon="refresh"></span><span id="loaded">data now</span>
28
28
  </button>
29
29
 
30
+ <!-- Only while something is left to set up; app.js decides. -->
31
+ <button class="nav top" id="setupnav" data-view="setup" hidden>
32
+ <span class="ic" data-icon="task-done"></span> Getting started
33
+ </button>
30
34
  <button class="nav top" id="inboxnav" data-view="inbox">
31
35
  <span class="ic" data-icon="inbox"></span> Inbox
32
36
  <span class="n" id="inboxcount"></span>
@@ -52,7 +56,10 @@
52
56
 
53
57
  <!-- The heading is the fold. Collapsed, it carries the count, because a hidden list still
54
58
  has to say how much it is hiding. -->
55
- <button class="sect sectfold" id="stafffold" aria-expanded="false" aria-controls="stafflist">
59
+ <!-- Named apart from the Staff screen above it: two controls both called "Staff" is how a
60
+ click meant for the screen landed on the fold, for people and for automation alike. -->
61
+ <button class="sect sectfold" id="stafffold" aria-expanded="false" aria-controls="stafflist"
62
+ aria-label="Staff list" title="Show or hide each staff member's own screens">
56
63
  <span class="caret" data-icon="chevron"></span>Staff<span class="n" id="staffcount" hidden></span>
57
64
  </button>
58
65
  <div id="stafflist"></div>
@@ -110,7 +110,9 @@ export const getDoctor = (offline) => json("/api/doctor" + (offline ? "?offline=
110
110
  export const getFix = (offline) => json("/api/fix" + (offline ? "?offline=1" : ""));
111
111
 
112
112
  export const listRepos = (org) => json("/api/setup/repos?org=" + encodeURIComponent(org));
113
- export const addRepo = (name, role) => post({ name, role }, "/api/setup/add-repo");
113
+ /** Commits org.yaml and pushes, as every other write here does. */
114
+ export const addRepo = (name, role, visibility) =>
115
+ post({ name, role, visibility }, "/api/setup/add-repo");
114
116
  export const startApp = (staff, scope) => post({ staff, scope }, "/api/setup/app");
115
117
  export const appResult = (state) => json("/api/setup/app-result?state=" + encodeURIComponent(state));
116
118
 
@@ -14,7 +14,7 @@ import {
14
14
  syncNotice,
15
15
  } from "./refresh.js";
16
16
  import { onRender } from "./router.js";
17
- import { ORG_WIDE, S, VIEWS, applyHash, writeHash } from "./state.js";
17
+ import { ORG_WIDE, S, VIEWS, applyHash, readHash, writeHash } from "./state.js";
18
18
  import { viewBrain } from "./views/brain.js";
19
19
  import { viewChanged } from "./views/changed.js";
20
20
  import { viewDocs } from "./views/docs.js";
@@ -24,10 +24,11 @@ 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 { viewSetup } from "./views/setup.js";
27
+ import { paintSetupNav, viewGettingStarted, viewSetup } from "./views/setup.js";
28
28
  import { viewStaff } from "./views/staff.js";
29
29
 
30
30
  const SCREEN = {
31
+ setup: viewGettingStarted,
31
32
  inbox: viewInbox,
32
33
  prs: viewPrs,
33
34
  runs: viewRuns,
@@ -52,8 +53,38 @@ const SCREEN = {
52
53
  */
53
54
  const NEEDS = ["org", "name", "opsName", "staff"];
54
55
 
56
+ /** The org-wide screens in the sidebar, by button. */
57
+ const NAV = [
58
+ ["#setupnav", "setup"],
59
+ ["#inboxnav", "inbox"],
60
+ ["#prsnav", "prs"],
61
+ ["#runsnav", "runs"],
62
+ ["#staffnav", "staff"],
63
+ ["#orgnav", "org"],
64
+ ["#docsnav", "docs"],
65
+ ];
66
+
55
67
  export async function boot() {
56
68
  initTheme();
69
+
70
+ /* Bound before the first request rather than after it. The sidebar is markup, so it is on
71
+ screen and clickable while /api/org is still building the export, and a click in that
72
+ window used to do nothing at all: the first click on Staff after a load was simply lost.
73
+ Now it is remembered and honoured once there is something to render. */
74
+ let early = null;
75
+ for (const [id, view] of NAV) {
76
+ const b = $(id);
77
+ if (!b) continue;
78
+ b.onclick = () => {
79
+ if (!S.data) {
80
+ early = view;
81
+ return;
82
+ }
83
+ S.view = view;
84
+ render();
85
+ };
86
+ }
87
+
57
88
  /* Before the first render, and once: it is delegated, so it covers every image any screen
58
89
  draws from here on, including the setup screen. */
59
90
  installLightbox();
@@ -90,16 +121,15 @@ export async function boot() {
90
121
  slot.innerHTML = iconHTML(slot.dataset.icon);
91
122
  }
92
123
  onRender(render);
124
+ const linked = readHash() !== null;
93
125
  applyHash();
126
+ /* An org nobody has been hired into has an empty Inbox, which says nothing about what to do
127
+ next. It opens on what is left instead, unless a link or a click asked for somewhere else. */
128
+ if (early) S.view = early;
129
+ else if (!linked && (S.data.unfinished ?? []).includes("hire")) S.view = "setup";
94
130
  initStaffFold();
95
131
  paintSidebar();
96
132
 
97
- $("#inboxnav").onclick = () => { S.view = "inbox"; render(); };
98
- $("#prsnav").onclick = () => { S.view = "prs"; render(); };
99
- $("#runsnav").onclick = () => { S.view = "runs"; render(); };
100
- $("#staffnav").onclick = () => { S.view = "staff"; render(); };
101
- $("#orgnav").onclick = () => { S.view = "org"; render(); };
102
- $("#docsnav").onclick = () => { S.view = "docs"; render(); };
103
133
  $("#refreshall").onclick = () => refreshAll(false);
104
134
 
105
135
  /* Coming back to the tab after a run should show the run — and coming back to it thirty
@@ -299,6 +329,7 @@ function render() {
299
329
  // A refresh can add or remove a staff member, and the sidebar is built once.
300
330
  const roster = S.data.staff.map((s) => s.handle).join(" ");
301
331
  if (roster !== paintedRoster) paintSidebar();
332
+ paintSetupNav();
302
333
  markSidebar();
303
334
  stampCounts();
304
335
  writeHash(true);
@@ -111,3 +111,50 @@ 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
+ }
@@ -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", "runs", "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"]);
@@ -1,6 +1,7 @@
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";
@@ -281,7 +282,7 @@ export async function askToFix(s, problems, btn, status) {
281
282
  "Fix in your next session and close this.",
282
283
  ].join("\n");
283
284
 
284
- if (!confirm("Open an issue in " + s.brain + "?")) return;
285
+ if (!(await askYes({ title: "Open an issue in " + s.brain + "?", confirm: "Open it" }))) return;
285
286
  btn.disabled = true;
286
287
  status.textContent = "opening…";
287
288
  status.className = "meta";
@@ -1,7 +1,7 @@
1
1
  /* The inbox: everything open across the org, and the thread beside it. */
2
2
 
3
3
  import { getLabels, getPr, getThread, post, upload } from "../api.js";
4
- import { askText } from "../dialog.js";
4
+ import { askText, askYes } from "../dialog.js";
5
5
  import { ago, el, esc, markCurrent, skeleton } from "../dom.js";
6
6
  import { icon, iconHTML } from "../icons.js";
7
7
  import { mdlite } from "../md.js";
@@ -693,7 +693,11 @@ function inboxScreen(m, opts) {
693
693
  merge.title = "This branch conflicts with its base";
694
694
  }
695
695
  merge.onclick = async () => {
696
- if (!confirm("Merge " + item.repo + " #" + item.number + " into its base branch?")) return;
696
+ const yes = await askYes({
697
+ title: "Merge " + item.repo + " #" + item.number + " into its base branch?",
698
+ confirm: "Merge",
699
+ });
700
+ if (!yes) return;
697
701
  busy(true, "merging…");
698
702
  try {
699
703
  await post({ action: "merge", repo: item.repo, number: item.number });
@@ -15,11 +15,13 @@
15
15
  */
16
16
 
17
17
  import { getFile, getOrgLayer, post } from "../api.js";
18
+ import { askYes } from "../dialog.js";
18
19
  import { el, esc, grow, kb, skeleton } from "../dom.js";
19
20
  import { icon } from "../icons.js";
20
21
  import { mdlite } from "../md.js";
21
22
  import { humansOf, S, writeHash } from "../state.js";
22
23
  import { yamlPre } from "../yaml.js";
24
+ import { repoPicker } from "./repos.js";
23
25
 
24
26
  /* What the files roster itself ships are for, in one line each. A filename says nothing about
25
27
  which one to open; anything a tenant has added of its own falls back to its first heading. */
@@ -77,6 +79,20 @@ export function viewOrg(m) {
77
79
  );
78
80
  m.append(facts);
79
81
 
82
+ /* Marking a product repo after setup. The picker only lived on the setup screen, so a repo
83
+ created later meant typing `role: product` into org.yaml by hand. Folded, because it asks
84
+ GitHub for every repo in the org and most visits here are to read a file. */
85
+ const repoPane = el("div");
86
+ const repos = el("button", { className: "ghbtn", textContent: "Add a product repo" });
87
+ repos.onclick = () => {
88
+ if (repoPane.children.length) {
89
+ repoPane.replaceChildren();
90
+ return;
91
+ }
92
+ repoPane.replaceChildren(repoPicker({ org: org.org, onAdded: () => open("org.yaml") }));
93
+ };
94
+ m.append(el("div", { className: "row", style: "margin-bottom:14px" }, [repos]), repoPane);
95
+
80
96
  const split = el("div", { className: "split" });
81
97
  const tree = el("div", { className: "tree" });
82
98
  const viewer = el("div", { className: "viewer" });
@@ -222,9 +238,11 @@ export function viewOrg(m) {
222
238
  through the question, which is worse than not asking. */
223
239
  if (
224
240
  isYaml &&
225
- !confirm(
226
- "Commit " + full(path) + " and push?\n\nEvery staff member picks this up on their next run.",
227
- )
241
+ !(await askYes({
242
+ title: "Commit " + full(path) + " and push?",
243
+ hint: "Every staff member picks this up on their next run.",
244
+ confirm: "Commit and push",
245
+ }))
228
246
  ) {
229
247
  return;
230
248
  }
@@ -10,7 +10,7 @@
10
10
  */
11
11
 
12
12
  import { getFile, post } from "../api.js";
13
- import { askText } from "../dialog.js";
13
+ import { askText, askYes } from "../dialog.js";
14
14
  import { el, esc, grow, kb, toClipboard } from "../dom.js";
15
15
  import { icon } from "../icons.js";
16
16
  import { mdlite } from "../md.js";
@@ -375,8 +375,12 @@ export function viewPrompt(m) {
375
375
  status.textContent = "nothing changed";
376
376
  return;
377
377
  }
378
- if (!confirm("Commit " + layer.path + " to " + layer.repo + " and push?\n\n" +
379
- "Picked up by " + reach + ".")) return;
378
+ const yes = await askYes({
379
+ title: "Commit " + layer.path + " to " + layer.repo + " and push?",
380
+ hint: "Picked up by " + reach + ".",
381
+ confirm: "Commit and push",
382
+ });
383
+ if (!yes) return;
380
384
  save.disabled = cancel.disabled = true;
381
385
  status.textContent = "committing…";
382
386
  status.className = "meta";
@@ -5,11 +5,12 @@
5
5
  * spelling a repo name exactly right in a YAML file, which is the kind of thing that fails
6
6
  * quietly and reads as the tool being broken. */
7
7
 
8
- import { addRepo, listRepos } from "../api.js";
8
+ import { addRepo, getRepos, listRepos } from "../api.js";
9
9
  import { el } from "../dom.js";
10
10
 
11
11
  /**
12
- * @param {{org: string, known: string[], onAdded?: () => void}} opts
12
+ * @param {{org: string, known?: string[], onAdded?: () => void}} opts without `known`, what
13
+ * org.yaml already lists is read off disk, which is how the Org screen uses it long after setup.
13
14
  */
14
15
  export function repoPicker(opts) {
15
16
  const box = el("div", { className: "manual" });
@@ -18,7 +19,8 @@ export function repoPicker(opts) {
18
19
  el("p", {
19
20
  textContent:
20
21
  "Repos marked as products are the ones staff contribute to without owning. A new hire " +
21
- "picks them up automatically.",
22
+ "picks them up automatically; somebody already hired keeps the works_in list in their " +
23
+ "own staff.yaml.",
22
24
  }),
23
25
  );
24
26
 
@@ -26,10 +28,13 @@ export function repoPicker(opts) {
26
28
  box.append(out);
27
29
  out.replaceChildren(el("p", { className: "sub", textContent: "Reading your repos…" }));
28
30
 
29
- listRepos(opts.org)
30
- .then((data) => {
31
+ const known = opts.known
32
+ ? Promise.resolve(opts.known)
33
+ : getRepos().then((d) => (d.repos ?? []).map((r) => r.name));
34
+ Promise.all([listRepos(opts.org), known])
35
+ .then(([data, listed]) => {
31
36
  if (data.error) throw new Error(data.error);
32
- const already = new Set(opts.known ?? []);
37
+ const already = new Set(listed);
33
38
  const candidates = (data.repos ?? []).filter((r) => !already.has(r.name));
34
39
 
35
40
  if (!candidates.length) {
@@ -52,7 +57,7 @@ export function repoPicker(opts) {
52
57
  add.disabled = true;
53
58
  add.textContent = "Adding…";
54
59
  try {
55
- await addRepo(repo.name, "product");
60
+ await addRepo(repo.name, "product", repo.visibility);
56
61
  add.textContent = "Added";
57
62
  row.classList.add("added");
58
63
  opts.onAdded?.();
@@ -5,6 +5,7 @@
5
5
  * callers all work together. This starts one, and says how it ended. */
6
6
 
7
7
  import { runStatus, startRun } from "../api.js";
8
+ import { askYes } from "../dialog.js";
8
9
  import { el } from "../dom.js";
9
10
 
10
11
  /**
@@ -26,7 +27,12 @@ export function runOnce(opts) {
26
27
  box.append(el("div", { className: "row" }, [go]), out);
27
28
 
28
29
  go.onclick = async () => {
29
- if (!confirm("Start a daily run for " + opts.name + " now?\n\nIt is a real run, and it costs what one does.")) return;
30
+ const yes = await askYes({
31
+ title: "Start a daily run for " + opts.name + " now?",
32
+ hint: "It is a real run, and it costs what one does.",
33
+ confirm: "Start the run",
34
+ });
35
+ if (!yes) return;
30
36
  go.disabled = true;
31
37
  const say = (text, cls) => out.replaceChildren(el("p", { className: cls ?? "sub", textContent: text }));
32
38
  say("Starting…");
@@ -5,8 +5,21 @@
5
5
  * is rather than as it was left. Setup cannot be finished in one sitting, so it must survive
6
6
  * being abandoned halfway. */
7
7
 
8
- import { checkOrg, createTenant, getAccess, getSetup, joinOrg, planTenant, setAccess } from "../api.js";
9
- import { $, el, esc, toClipboard } from "../dom.js";
8
+ import {
9
+ checkOrg,
10
+ createTenant,
11
+ fileUrl,
12
+ getAccess,
13
+ getOrg,
14
+ getSetup,
15
+ joinOrg,
16
+ planTenant,
17
+ saveFile,
18
+ setAccess,
19
+ } from "../api.js";
20
+ import { $, el, esc, grow, toClipboard } from "../dom.js";
21
+ import { go } from "../router.js";
22
+ import { S as App } from "../state.js";
10
23
  import { checklist } from "./checklist.js";
11
24
  import { credentialPanel } from "./credential.js";
12
25
  import { repoPicker } from "./repos.js";
@@ -14,11 +27,87 @@ import { paste } from "./paste.js";
14
27
 
15
28
  let S = null; // /api/setup/status, refreshed after anything that changes the world
16
29
 
30
+ /* Where the doctor checklist is drawn on this screen, so a save can re-run it. Left stale, it
31
+ went on saying business.stub after business.md had been saved, until "Check again". */
32
+ let healthHost = null;
33
+
34
+ /* Each "what is left" step that is a file, by id, so a save can mark it done in place rather
35
+ than repainting the page and throwing away the diff somebody is still reading. */
36
+ const fileSteps = new Map();
37
+
17
38
  export async function viewSetup(main) {
18
39
  S = await getSetup();
19
40
  render(main);
20
41
  }
21
42
 
43
+ /**
44
+ * The same steps once a tenant exists, as their own screen in the portal.
45
+ *
46
+ * Setup used to be reachable only before there was a tenant, so the reload that followed
47
+ * creating one landed on an empty Inbox with the repo picker, the credential and both files
48
+ * gone. The sidebar offers this while anything is unfinished, and an org nobody has been hired
49
+ * into opens on it.
50
+ */
51
+ export async function viewGettingStarted(main) {
52
+ main.append(el("h1", { textContent: "Getting started" }));
53
+ const sub = el("p", { className: "sub", textContent: "Reading what is left…" });
54
+ main.append(sub);
55
+ S = await getSetup();
56
+ // Somebody may have clicked elsewhere while GitHub was answering.
57
+ 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 somebody is hired. Hiring creates their repo, their workflows and " +
70
+ "their pinned issue; the GitHub App and the charter follow on their card.",
71
+ }),
72
+ el("div", { className: "row" }, [hire]),
73
+ );
74
+ main.append(next);
75
+ }
76
+
77
+ main.append(afterCreate(App.data?.staff?.length ? 1 : 2));
78
+
79
+ const health = step(App.data?.staff?.length ? 2 : 3, "What doctor still says", false);
80
+ healthHost = el("div", { style: "margin-top:12px" });
81
+ health.append(healthHost);
82
+ main.append(health);
83
+ checklist(healthHost, {});
84
+ }
85
+
86
+ /** Whether the sidebar offers Getting started: while anything is left, or while it is open. */
87
+ export function paintSetupNav() {
88
+ const nav = $("#setupnav");
89
+ if (!nav) return;
90
+ nav.hidden = !App.data?.unfinished?.length && App.view !== "setup";
91
+ }
92
+
93
+ /* After a save: the checklist and the step list are both derived from disk, so both are asked
94
+ again rather than updated by hand. The sidebar's list comes from /api/org, which is the one
95
+ the portal already reads. */
96
+ async function recheck() {
97
+ if (healthHost) checklist(healthHost, {});
98
+ try {
99
+ S = await getSetup();
100
+ for (const [id, mark] of fileSteps) mark(!(S.tenant.left ?? []).includes(id));
101
+ } catch {
102
+ // The saved file is saved either way; a stale tick is not worth an error on top of it.
103
+ }
104
+ if (App.data) {
105
+ const fresh = await getOrg().catch(() => null);
106
+ if (fresh?.unfinished) App.data.unfinished = fresh.unfinished;
107
+ paintSetupNav();
108
+ }
109
+ }
110
+
22
111
  function render(main) {
23
112
  /* The sidebar is written by boot() before any of this is known. Once a tenant has been
24
113
  adopted, leaving it on "no org yet" contradicts the card directly below it. */
@@ -31,9 +120,9 @@ function render(main) {
31
120
  el("p", {
32
121
  className: "sub",
33
122
  textContent:
34
- "Three things here, then what is left: a setting roster tries for you, the repos your " +
35
- "staff work in, and the one file only you can write. Nothing is created until you have " +
36
- "read the plan.",
123
+ "Sign in with gh, choose the organisation and the agent, then what is left: a setting " +
124
+ "roster tries for you, the agent's credential, the repos your staff work in, and the two " +
125
+ "files only you can write. Nothing is created until you have read the plan.",
37
126
  }),
38
127
  );
39
128
 
@@ -43,7 +132,7 @@ function render(main) {
43
132
 
44
133
  /* Drawn whenever a tenant exists, not only in the seconds after creating one. Setup takes
45
134
  days: the repo picker and the two manual steps have to still be here tomorrow. */
46
- if (S.tenant.found) main.append(afterCreate());
135
+ if (S.tenant.found) main.append(afterCreate(3));
47
136
  }
48
137
 
49
138
  /* ---------------------------------- 1 · gh ---------------------------------- */
@@ -80,12 +169,14 @@ function stepOrg(main) {
80
169
  }),
81
170
  el("p", {
82
171
  className: "sub",
83
- textContent: "Reload the page to leave setup and use the portal.",
172
+ textContent:
173
+ "Reload the page to open the portal. Anything below that is still undone stays under " +
174
+ "Getting started in the sidebar until it is.",
84
175
  }),
85
176
  );
86
- const rows = el("div", { style: "margin-top:12px" });
87
- card.append(rows);
88
- checklist(rows, {});
177
+ healthHost = el("div", { style: "margin-top:12px" });
178
+ card.append(healthHost);
179
+ checklist(healthHost, {});
89
180
  return card;
90
181
  }
91
182
 
@@ -307,13 +398,14 @@ function stepOrg(main) {
307
398
 
308
399
  /* ------------------------- 5 · the two GitHub insists on ------------------------- */
309
400
 
310
- function afterCreate() {
311
- const card = step(3, "What is left", false);
401
+ function afterCreate(n) {
402
+ fileSteps.clear();
403
+ const card = step(n, "What is left", false);
312
404
  card.append(
313
405
  el("p", {
314
406
  textContent:
315
- "The first is a setting roster tries for you. The last is the file that decides whether " +
316
- "any of this is worth running, and only you can write it.",
407
+ "The first is a setting roster tries for you. The last two are the files that decide " +
408
+ "whether any of this is worth running, and only you can write them.",
317
409
  }),
318
410
  );
319
411
  card.append(accessStep());
@@ -323,22 +415,111 @@ function afterCreate() {
323
415
  card.append(repoPicker({ org: S.tenant.org, known: S.tenant.repos ?? [] }));
324
416
  }
325
417
 
326
- const two = el("div", { className: "manual" });
327
- two.append(
328
- el("b", { textContent: "Answer org/business.md" }),
329
- el("p", {
330
- textContent:
331
- "It ships as questions. It is composed into the top of every prompt, every run, and an " +
332
- "agent that cannot answer them writes work that is plausible and generic.",
333
- }),
418
+ const business = fileStep(
419
+ "business",
420
+ "Answer org/business.md",
421
+ "It ships as questions. It is composed into the top of every prompt, every run, and an " +
422
+ "agent that cannot answer them writes work that is plausible and generic.",
334
423
  );
335
- card.append(two);
336
-
337
424
  // The copy-a-prompt loop, which is the whole answer to "how do I write this file".
338
- card.append(paste({ kind: "discover", title: "Write it with your own AI" }));
425
+ business.append(paste({ kind: "discover", title: "Write it with your own AI", onSaved: recheck }));
426
+ card.append(business);
427
+
428
+ const priorities = fileStep(
429
+ "priorities",
430
+ "Rank org/priorities.md",
431
+ "What matters this month, at most three things in order, and what is out of scope. Every " +
432
+ "daily run reads it; without it each staff member picks its own direction from its charter.",
433
+ );
434
+ priorities.append(prioritiesEditor());
435
+ card.append(priorities);
339
436
  return card;
340
437
  }
341
438
 
439
+ /**
440
+ * One of the files only a person can write, with whether it is written yet.
441
+ *
442
+ * Read from the status rather than from the click that saved it, so a file written in an
443
+ * editor, or by a coding agent, shows as done here too.
444
+ */
445
+ function fileStep(id, title, why) {
446
+ const box = el("div", { className: "manual" });
447
+ const tag = el("span", { className: "meta" });
448
+ const mark = (done) => {
449
+ tag.textContent = done ? "Written." : "Still the stub it shipped as.";
450
+ box.classList[done ? "add" : "remove"]("done");
451
+ };
452
+ mark(!(S.tenant.left ?? []).includes(id));
453
+ fileSteps.set(id, mark);
454
+ box.append(el("div", {}, [el("b", { textContent: title }), tag]), el("p", { textContent: why }));
455
+ return box;
456
+ }
457
+
458
+ /**
459
+ * org/priorities.md, edited in place.
460
+ *
461
+ * There is no brief for it, and it does not want one: business.md needs an interview because
462
+ * nobody can write it off the top of their head, while a ranked list of three things is
463
+ * quicker to type than to explain to a model. So the file opens as it is on disk, stub and
464
+ * all, and saving it is the same commit-and-push as every other editor here.
465
+ */
466
+ function prioritiesEditor() {
467
+ const box = el("div", { style: "margin-top:9px" });
468
+ const path = opsName() + "/org/priorities.md";
469
+ const ta = el("textarea", { className: "pastebox" });
470
+ ta.rows = 12;
471
+ const save = el("button", { className: "btn primary", textContent: "Save and commit" });
472
+ const out = el("div");
473
+ box.append(ta, el("div", { className: "row", style: "margin-top:9px" }, [save]), out);
474
+
475
+ let before = "";
476
+ save.disabled = true;
477
+ // Not getFile: an absent file answers 404 with a message, and that is not what to edit.
478
+ fetch(fileUrl(path), { cache: "no-store" })
479
+ .then((r) => (r.ok ? r.text() : ""))
480
+ .then((text) => {
481
+ before = text;
482
+ ta.value = text;
483
+ grow(ta);
484
+ save.disabled = false;
485
+ })
486
+ .catch((err) => out.replaceChildren(el("p", { className: "err", textContent: String(err.message || err) })));
487
+ ta.addEventListener("input", () => grow(ta));
488
+
489
+ save.onclick = async () => {
490
+ if (ta.value.trim() === before.trim()) {
491
+ out.replaceChildren(el("p", { className: "sub", textContent: "Nothing changed yet." }));
492
+ return;
493
+ }
494
+ save.disabled = true;
495
+ save.textContent = "Saving…";
496
+ try {
497
+ const r = await saveFile(path, ta.value, "portal: write org/priorities.md");
498
+ before = ta.value;
499
+ out.replaceChildren(
500
+ el("p", {
501
+ className: r.pushed ? "sub" : "err",
502
+ textContent: r.pushed
503
+ ? "Committed and pushed. Every daily run reads it from the next one."
504
+ : "Committed, but the push failed: " + (r.note ?? ""),
505
+ }),
506
+ );
507
+ recheck();
508
+ } catch (err) {
509
+ out.replaceChildren(el("p", { className: "err", textContent: String(err.message || err) }));
510
+ } finally {
511
+ save.disabled = false;
512
+ save.textContent = "Save and commit";
513
+ }
514
+ };
515
+ return box;
516
+ }
517
+
518
+ /** The ops repo's directory name, which is what every workspace-relative path starts with. */
519
+ function opsName() {
520
+ return App.data?.opsName ?? String(S.tenant.opsDir ?? "roster-ops").split("/").pop();
521
+ }
522
+
342
523
  /**
343
524
  * The ops repo's Actions access. Read on arrival, so a page reloaded after it was set says it
344
525
  * is done without anybody pressing anything; the button asks the server to set it, and a
@@ -10,6 +10,7 @@
10
10
  */
11
11
 
12
12
  import { post } from "../api.js";
13
+ import { askYes } from "../dialog.js";
13
14
  import { ago, el, esc } from "../dom.js";
14
15
  import { icon } from "../icons.js";
15
16
  import { refreshAll } from "../refresh.js";
@@ -117,16 +118,21 @@ export function viewStaff(m) {
117
118
  /* ------------------------------- hiring -------------------------------- */
118
119
 
119
120
  function hireForm(host) {
121
+ /* App names are copied from whoever is already here. The first hire has nobody to copy
122
+ from, and the CLI asks for --app and --public-app at that point; these are those two. */
123
+ const first = !S.data.staff.length;
120
124
  const box = el("div", { className: "card" });
121
125
  box.append(
122
126
  el("h3", { textContent: "Hire someone" }),
123
127
  el("p", {
124
128
  className: "sub",
125
129
  style: "margin-bottom:14px",
126
- textContent:
127
- "Only the handle is required. Everything else is copied from whoever is already " +
128
- "here, because app slugs carry a house naming scheme and the public identity is " +
129
- "genuinely shared.",
130
+ textContent: first
131
+ ? "Only the handle is required. This is the first hire, so there is nobody to copy " +
132
+ "App names from: name them here, and later hires follow the pattern."
133
+ : "Only the handle is required. Everything else is copied from whoever is already " +
134
+ "here, because app slugs carry a house naming scheme and the public identity is " +
135
+ "genuinely shared.",
130
136
  }),
131
137
  );
132
138
 
@@ -134,7 +140,17 @@ export function viewStaff(m) {
134
140
  const name = field("name", "Chief Financial Officer", "defaults to the handle, uppercased");
135
141
  const dir = field("dir", "finance", "directory and repo name; defaults to the handle");
136
142
  const schedule = scheduleField();
137
- for (const f of [handle, name, dir, schedule]) box.append(f.row);
143
+ const fields = [handle, name, dir, schedule];
144
+ const apps = [];
145
+ if (first) {
146
+ const org = S.data.org;
147
+ apps.push(
148
+ ["app", field("app", org + "-cfo", "this staff member's GitHub App. Names are unique across GitHub, so prefix it")],
149
+ ["publicApp", field("public app", org + "-robot", "the shared identity for public product repos. Optional when they are all private")],
150
+ );
151
+ fields.push(...apps.map(([, f]) => f));
152
+ }
153
+ for (const f of fields) box.append(f.row);
138
154
 
139
155
  const status = el("span", { className: "meta" });
140
156
  const plan = el("button", { className: "ghbtn primary", textContent: "Show the plan" });
@@ -156,7 +172,7 @@ export function viewStaff(m) {
156
172
  status.className = "meta";
157
173
  out.replaceChildren();
158
174
  const params = new URLSearchParams({ handle: handle.input.value.trim() });
159
- for (const [key, f] of [["name", name], ["dir", dir], ["schedule", schedule]]) {
175
+ for (const [key, f] of [["name", name], ["dir", dir], ["schedule", schedule], ...apps]) {
160
176
  const value = (f.value ? f.value() : f.input.value).trim();
161
177
  if (value) params.set(key, value);
162
178
  }
@@ -211,7 +227,7 @@ export function viewStaff(m) {
211
227
  line("todo", "Write the charter"),
212
228
  line("todo", "Run once now, or roster run " + s.handle + " --apply: the run that proves the wiring"),
213
229
  );
214
- for (const w of plan.warnings ?? []) box.append(line("warn", w));
230
+ for (const w of plan.warnings ?? []) box.append(line("warn", S.data.staff.length ? w : portalWords(w)));
215
231
 
216
232
  const status = el("span", { className: "meta" });
217
233
  const go = el("button", { className: "ghbtn primary", textContent: "Hire " + s.handle });
@@ -222,7 +238,7 @@ export function viewStaff(m) {
222
238
  handle: s.handle,
223
239
  flags: Object.fromEntries(params),
224
240
  },
225
- "Create " + s.brain + " and wire it up?\n\nThis creates a repository on GitHub.",
241
+ { title: "Create " + s.brain + " and wire it up?", hint: "This creates a repository on GitHub.", confirm: "Hire " + s.handle },
226
242
  go,
227
243
  status,
228
244
  box,
@@ -277,7 +293,7 @@ export function viewStaff(m) {
277
293
  go.onclick = () =>
278
294
  apply(
279
295
  { action: "retire", handle: plan.handle },
280
- "Retire " + plan.name + "?\n\n" + (plan.brain ?? plan.dir) + " is not touched.",
296
+ { title: "Retire " + plan.name + "?", hint: (plan.brain ?? plan.dir) + " is not touched.", confirm: "Retire " + plan.handle },
281
297
  go,
282
298
  status,
283
299
  box,
@@ -289,7 +305,7 @@ export function viewStaff(m) {
289
305
  /* -------------------------------- apply -------------------------------- */
290
306
 
291
307
  async function apply(payload, ask, btn, status, box) {
292
- if (!confirm(ask)) return;
308
+ if (!(await askYes(ask))) return;
293
309
  btn.disabled = true;
294
310
  status.textContent = "working…";
295
311
  status.className = "meta";
@@ -308,6 +324,14 @@ export function viewStaff(m) {
308
324
  }
309
325
  }
310
326
 
327
+ /* The plan's warnings are the CLI's, and name its flags. Here the same two options are fields
328
+ on the form above, and a flag the page has no box for reads as a dead end. */
329
+ function portalWords(warning) {
330
+ return warning
331
+ .replace(/pass --public-app\b/, "fill in public app above")
332
+ .replace(/pass --app\b/, "fill in app above");
333
+ }
334
+
311
335
  function field(label, placeholder, hint) {
312
336
  const input = el("input", { type: "search", placeholder, value: "", style: "width:100%" });
313
337
  input.dataset.field = label;