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