@nanocollective/roster 0.1.0-alpha.7 → 0.1.0-alpha.8
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 +64 -31
- package/docs/getting-started.md +12 -2
- package/docs/portal.md +26 -4
- package/package.json +1 -1
- package/templates/portal/css/setup.css +2 -0
- package/templates/portal/index.html +8 -1
- package/templates/portal/js/api.js +3 -1
- package/templates/portal/js/app.js +39 -8
- package/templates/portal/js/dialog.js +47 -0
- package/templates/portal/js/state.js +1 -1
- package/templates/portal/js/views/health.js +2 -1
- package/templates/portal/js/views/inbox.js +6 -2
- package/templates/portal/js/views/org.js +21 -3
- package/templates/portal/js/views/prompt.js +7 -3
- package/templates/portal/js/views/repos.js +12 -7
- package/templates/portal/js/views/runonce.js +7 -1
- package/templates/portal/js/views/setup.js +206 -25
- package/templates/portal/js/views/staff.js +34 -10
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:
|
|
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
|
|
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 (
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
@@ -5664,20 +5683,20 @@ function parsePaste(raw, targets) {
|
|
|
5664
5683
|
const known = new Map(targets.map((t) => [t.path, t]));
|
|
5665
5684
|
BLOCK.lastIndex = 0;
|
|
5666
5685
|
for (const m of text.matchAll(BLOCK)) {
|
|
5667
|
-
const
|
|
5686
|
+
const written = m[1].trim();
|
|
5668
5687
|
const body = strip(m[2] ?? "");
|
|
5669
|
-
const target = known
|
|
5688
|
+
const target = resolveTarget(written, known);
|
|
5670
5689
|
if (!target) {
|
|
5671
5690
|
problems.push({
|
|
5672
5691
|
id: "unknown-path",
|
|
5673
|
-
path,
|
|
5674
|
-
message: `The answer writes ${
|
|
5692
|
+
path: written,
|
|
5693
|
+
message: `The answer writes ${written}, which this brief did not ask for.`,
|
|
5675
5694
|
retry: `Write only ${[...known.keys()].join(", ")}, using the sentinels exactly as given.`
|
|
5676
5695
|
});
|
|
5677
5696
|
continue;
|
|
5678
5697
|
}
|
|
5679
5698
|
files.push({
|
|
5680
|
-
path,
|
|
5699
|
+
path: target.path,
|
|
5681
5700
|
text: ensureNewline(body),
|
|
5682
5701
|
unchanged: body.trim() === target.before.trim()
|
|
5683
5702
|
});
|
|
@@ -5724,6 +5743,14 @@ ${END}`
|
|
|
5724
5743
|
}
|
|
5725
5744
|
return { files, problems };
|
|
5726
5745
|
}
|
|
5746
|
+
function resolveTarget(written, known) {
|
|
5747
|
+
const exact = known.get(written);
|
|
5748
|
+
if (exact) return exact;
|
|
5749
|
+
const tail2 = written.replace(/^(\.\/)+/, "").replace(/^\/+/, "");
|
|
5750
|
+
if (!tail2 || tail2.split("/").includes("..")) return void 0;
|
|
5751
|
+
const matches = [...known.values()].filter((t) => t.path === tail2 || t.path.endsWith(`/${tail2}`));
|
|
5752
|
+
return matches.length === 1 ? matches[0] : void 0;
|
|
5753
|
+
}
|
|
5727
5754
|
function strip(body) {
|
|
5728
5755
|
const trimmed = body.replace(/^\n+/, "").replace(/\s+$/, "");
|
|
5729
5756
|
const fenced = /^(`{3,}|~{3,})[^\n]*\n([\s\S]*)\n\1\s*$/.exec(trimmed);
|
|
@@ -6501,7 +6528,8 @@ async function portalCommand(argv) {
|
|
|
6501
6528
|
const payload = JSON.parse(await body(req) || "{}");
|
|
6502
6529
|
const name = String(payload.name ?? "");
|
|
6503
6530
|
const role = String(payload.role ?? "product");
|
|
6504
|
-
|
|
6531
|
+
const visibility = String(payload.visibility ?? "private").toLowerCase();
|
|
6532
|
+
if (!/^[\w.-]+$/.test(name) || !/^(product|brain|ops|repo)$/.test(role) || !/^(private|public|internal)$/.test(visibility)) {
|
|
6505
6533
|
res.writeHead(400, { "content-type": "application/json" });
|
|
6506
6534
|
res.end(JSON.stringify({ error: "a repo name and a known role are needed" }));
|
|
6507
6535
|
return;
|
|
@@ -6515,7 +6543,11 @@ async function portalCommand(argv) {
|
|
|
6515
6543
|
const result = saveFile(
|
|
6516
6544
|
ws,
|
|
6517
6545
|
`${ws.opsName}/org.yaml`,
|
|
6518
|
-
insertUnder(
|
|
6546
|
+
insertUnder(
|
|
6547
|
+
text,
|
|
6548
|
+
"repos",
|
|
6549
|
+
` - { name: ${name}, visibility: ${visibility}, role: ${role} }`
|
|
6550
|
+
),
|
|
6519
6551
|
`portal: org.yaml lists ${name}`
|
|
6520
6552
|
);
|
|
6521
6553
|
json(res, { ok: true, ...result });
|
|
@@ -6715,11 +6747,12 @@ async function portalCommand(argv) {
|
|
|
6715
6747
|
if (url.pathname === "/api/org") {
|
|
6716
6748
|
const org = readOrg(w.opsDir, parseYaml);
|
|
6717
6749
|
const data = buildExport(w, org, parseYaml);
|
|
6750
|
+
const left = unfinished(w.opsDir, (org.staff ?? []).length);
|
|
6718
6751
|
res.writeHead(200, {
|
|
6719
6752
|
"content-type": "application/json; charset=utf-8",
|
|
6720
6753
|
"cache-control": "no-store"
|
|
6721
6754
|
});
|
|
6722
|
-
res.end(JSON.stringify(data));
|
|
6755
|
+
res.end(JSON.stringify({ ...data, unfinished: left }));
|
|
6723
6756
|
return;
|
|
6724
6757
|
}
|
|
6725
6758
|
if (url.pathname === "/api/orglayer") {
|
package/docs/getting-started.md
CHANGED
|
@@ -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
|
|
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
|
|
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,
|
|
60
|
-
|
|
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
|
|
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.
|
|
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
|
@@ -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
|
-
|
|
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
|
-
|
|
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 (!
|
|
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
|
-
|
|
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
|
-
!
|
|
226
|
-
"Commit " + full(path) + " and push
|
|
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
|
-
|
|
379
|
-
|
|
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
|
|
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
|
-
|
|
30
|
-
.
|
|
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(
|
|
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
|
-
|
|
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 {
|
|
9
|
-
|
|
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
|
-
"
|
|
35
|
-
"
|
|
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:
|
|
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
|
-
|
|
87
|
-
card.append(
|
|
88
|
-
checklist(
|
|
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
|
-
|
|
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
|
|
316
|
-
"any of this is worth running, and only you can write
|
|
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
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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
|
-
|
|
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.
|
|
128
|
-
|
|
129
|
-
"
|
|
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
|
-
|
|
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
|
|
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 + "
|
|
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 (!
|
|
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;
|