@nanocollective/roster 0.1.0-alpha.6 → 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.
Files changed (63) hide show
  1. package/README.md +65 -84
  2. package/dist/cli.js +4187 -2709
  3. package/docs/README.md +9 -6
  4. package/docs/agents.md +24 -20
  5. package/docs/architecture.md +13 -5
  6. package/docs/charters/cmo.md +69 -0
  7. package/docs/charters/cto.md +71 -0
  8. package/docs/charters/support.md +60 -0
  9. package/docs/commands.md +81 -5
  10. package/docs/concepts.md +48 -14
  11. package/docs/cost.md +36 -1
  12. package/docs/developing.md +16 -21
  13. package/docs/doctor-codes.md +8 -2
  14. package/docs/extending.md +2 -2
  15. package/docs/getting-started.md +110 -77
  16. package/docs/manual-steps.md +93 -123
  17. package/docs/memory.md +21 -3
  18. package/docs/org-yaml.md +37 -2
  19. package/docs/portal.md +83 -35
  20. package/docs/prompts.md +25 -4
  21. package/docs/security.md +29 -5
  22. package/docs/session-workflow.md +49 -17
  23. package/docs/staff-yaml.md +13 -2
  24. package/docs/troubleshooting.md +8 -8
  25. package/docs/upgrading.md +6 -0
  26. package/docs/writing-a-charter.md +15 -0
  27. package/package.json +1 -1
  28. package/templates/brain/.github/workflows/%%STAFF%%-daily.yaml +6 -0
  29. package/templates/brain/.github/workflows/%%STAFF%%-mention.yaml +6 -0
  30. package/templates/brain/CHARTER.md +3 -3
  31. package/templates/brain/README.md +1 -0
  32. package/templates/brain/log/decisions.md +3 -0
  33. package/templates/brain/strategy/ideas.md +7 -0
  34. package/templates/ops/.github/workflows/session.yaml +108 -14
  35. package/templates/ops/agents.mjs +7 -3
  36. package/templates/ops/compose.mjs +16 -3
  37. package/templates/ops/inflight.mjs +157 -0
  38. package/templates/ops/org/operating.md +21 -1
  39. package/templates/ops/org/voice.md +9 -0
  40. package/templates/ops/prompts/_inflight.md +14 -0
  41. package/templates/ops/prompts/_paths.md +2 -1
  42. package/templates/ops/prompts/daily.md +16 -7
  43. package/templates/ops/prompts/mention.md +2 -0
  44. package/templates/ops/run-record.mjs +144 -0
  45. package/templates/portal/css/runs.css +13 -0
  46. package/templates/portal/css/setup.css +2 -0
  47. package/templates/portal/index.html +12 -1
  48. package/templates/portal/js/api.js +32 -4
  49. package/templates/portal/js/app.js +42 -8
  50. package/templates/portal/js/dialog.js +47 -0
  51. package/templates/portal/js/state.js +3 -1
  52. package/templates/portal/js/views/app.js +23 -5
  53. package/templates/portal/js/views/credential.js +93 -0
  54. package/templates/portal/js/views/health.js +17 -4
  55. package/templates/portal/js/views/inbox.js +6 -2
  56. package/templates/portal/js/views/org.js +23 -3
  57. package/templates/portal/js/views/paste.js +29 -0
  58. package/templates/portal/js/views/prompt.js +7 -3
  59. package/templates/portal/js/views/repos.js +12 -7
  60. package/templates/portal/js/views/runonce.js +94 -0
  61. package/templates/portal/js/views/runs.js +165 -0
  62. package/templates/portal/js/views/setup.js +267 -45
  63. package/templates/portal/js/views/staff.js +81 -20
@@ -0,0 +1,94 @@
1
+ /* One daily run, now, followed to the end.
2
+ *
3
+ * The last step of setting anybody up. Doctor calls a workflow that has never run unproven,
4
+ * because nothing short of a finished run shows the App, its grant, the secrets and the
5
+ * callers all work together. This starts one, and says how it ended. */
6
+
7
+ import { runStatus, startRun } from "../api.js";
8
+ import { askYes } from "../dialog.js";
9
+ import { el } from "../dom.js";
10
+
11
+ /**
12
+ * @param {{staff: string, name: string, onDone?: (ok: boolean) => void}} opts
13
+ */
14
+ export function runOnce(opts) {
15
+ const box = el("div", { className: "manual" });
16
+ box.append(
17
+ el("b", { textContent: "Run " + opts.name + " once, now" }),
18
+ el("p", {
19
+ className: "sub",
20
+ textContent:
21
+ "A real daily run: it does a day's work and spends what a scheduled one would. " +
22
+ "When it finishes you get the outcome and the log.",
23
+ }),
24
+ );
25
+ const go = el("button", { className: "btn primary", textContent: "Run once now" });
26
+ const out = el("div", { style: "margin-top:9px" });
27
+ box.append(el("div", { className: "row" }, [go]), out);
28
+
29
+ go.onclick = async () => {
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;
36
+ go.disabled = true;
37
+ const say = (text, cls) => out.replaceChildren(el("p", { className: cls ?? "sub", textContent: text }));
38
+ say("Starting…");
39
+ let run;
40
+ try {
41
+ run = await startRun(opts.staff);
42
+ } catch (err) {
43
+ say(String(err.message || err), "err");
44
+ go.disabled = false;
45
+ return;
46
+ }
47
+ const link = el("a", { href: run.url, target: "_blank", rel: "noopener", textContent: "the log" });
48
+ if (!run.id) {
49
+ out.replaceChildren(el("p", { className: "sub" }, ["Started. It has not shown up yet; follow it at ", link, "."]));
50
+ go.disabled = false;
51
+ return;
52
+ }
53
+
54
+ /* Polled rather than streamed: a run takes minutes, the status changes a handful of times,
55
+ and a clock beside it is what shows the page has not hung. */
56
+ const began = Date.now();
57
+ const tick = async () => {
58
+ let now;
59
+ try {
60
+ now = await runStatus(opts.staff, run.id);
61
+ if (now.error) throw new Error(now.error);
62
+ } catch (err) {
63
+ say(String(err.message || err), "err");
64
+ go.disabled = false;
65
+ return;
66
+ }
67
+ const mins = Math.floor((Date.now() - began) / 60000);
68
+ if (now.status !== "completed") {
69
+ out.replaceChildren(
70
+ el("p", { className: "sub" }, [now.status.replace("_", " ") + ", " + mins + " min so far. ", link]),
71
+ );
72
+ setTimeout(tick, 15000);
73
+ return;
74
+ }
75
+ go.disabled = false;
76
+ const ok = now.conclusion === "success";
77
+ const done = el("div");
78
+ done.append(
79
+ el("p", { className: ok ? "" : "err" }, [
80
+ ok
81
+ ? "It worked. The App, its grant, the secrets and the callers are proven, and Health now says so. "
82
+ : "It ended " + (now.conclusion || "without a conclusion") + ". ",
83
+ el("a", { href: now.url, target: "_blank", rel: "noopener", textContent: "The log" }),
84
+ ]),
85
+ );
86
+ for (const f of now.failedAt ?? []) done.append(el("p", { className: "sub", textContent: "Failed at " + f }));
87
+ if (!ok) done.append(el("p", { className: "sub", textContent: "Troubleshooting, in the docs, has what each failure usually means." }));
88
+ out.replaceChildren(done);
89
+ opts.onDone?.(ok);
90
+ };
91
+ tick();
92
+ };
93
+ return box;
94
+ }
@@ -0,0 +1,165 @@
1
+ /* Every staff member's recent runs, and what the last 30 days cost.
2
+ *
3
+ * The one screen that is only ever on GitHub: runs are not in any repo, so offline it says so
4
+ * rather than drawing an empty table that reads as "nothing ran". */
5
+
6
+ import { getRuns } from "../api.js";
7
+ import { ago, el, esc, skeleton } from "../dom.js";
8
+ import { render } from "../router.js";
9
+ import { S } from "../state.js";
10
+
11
+ /** How many runs a staff member's table shows. The spend covers all of the last 30 days. */
12
+ const SHOWN = 15;
13
+
14
+ export function viewRuns(m) {
15
+ m.append(el("h1", { textContent: "Runs" }));
16
+ m.append(
17
+ el("p", {
18
+ className: "sub",
19
+ textContent:
20
+ "What each staff member ran in the last 30 days, how it ended, and what it cost where " +
21
+ "the agent said. Read from GitHub through your own gh.",
22
+ }),
23
+ );
24
+
25
+ if (!S.runs) {
26
+ const wait = el("div", { className: "runs" }, skeleton("row", 4));
27
+ m.append(wait);
28
+ load(false);
29
+ return;
30
+ }
31
+ paint(m, S.runs);
32
+ }
33
+
34
+ let asking = false;
35
+
36
+ async function load(force) {
37
+ if (asking) return;
38
+ asking = true;
39
+ try {
40
+ S.runs = await getRuns(force);
41
+ } catch (e) {
42
+ S.runs = { online: false, error: e.message, staff: [] };
43
+ } finally {
44
+ asking = false;
45
+ }
46
+ if (S.view === "runs") render();
47
+ }
48
+
49
+ function paint(m, data) {
50
+ const again = el("button", { className: "ghbtn", textContent: "Ask GitHub again" });
51
+ again.onclick = () => {
52
+ S.runs = null;
53
+ load(true);
54
+ render();
55
+ };
56
+
57
+ if (!data.online) {
58
+ const box = el("div", { className: "notice" });
59
+ box.innerHTML =
60
+ "<b>Runs live on GitHub, and it could not be reached.</b><br>" +
61
+ esc(data.error ?? "gh is unavailable") +
62
+ "<br>Everything else in the portal reads from disk and still works.";
63
+ m.append(box, el("div", { className: "row hacts" }, [again]));
64
+ return;
65
+ }
66
+
67
+ const g = el("div", { className: "grid runsum" });
68
+ g.append(tile("Last 30 days", money(data.spend), priced(data.spend), over(data.spend, data.budget)));
69
+ if (data.budget) {
70
+ g.append(tile("Budget", "$" + data.budget, "org.yaml · 30 days, all staff", over(data.spend, data.budget)));
71
+ }
72
+ g.append(tile("Runs", String(data.spend?.runs ?? 0), "skipped mentions are not counted"));
73
+ m.append(g);
74
+
75
+ for (const s of data.staff ?? []) m.append(...staffSection(s));
76
+
77
+ m.append(
78
+ el("div", { className: "row hacts" }, [
79
+ again,
80
+ el("span", { className: "meta", textContent: data.fetchedAt ? "read " + ago(data.fetchedAt) : "" }),
81
+ ]),
82
+ );
83
+ }
84
+
85
+ function staffSection(s) {
86
+ const head = el("div", { className: "hsect" });
87
+ head.append(el("span", { textContent: s.name }));
88
+ const total = el("i", {
89
+ textContent: money(s.spend) + (s.budget ? " of $" + s.budget : "") + " · 30 days",
90
+ });
91
+ if (over(s.spend, s.budget)) total.className = "warn";
92
+ head.append(total);
93
+
94
+ const body = el("div", { className: "hbody runs" });
95
+ for (const e of s.errors ?? []) body.append(el("p", { className: "hnote err", textContent: e }));
96
+ if (!(s.runs ?? []).length) {
97
+ body.append(el("p", { className: "hnote", textContent: "No runs in the last 30 days." }));
98
+ return [head, body];
99
+ }
100
+
101
+ const table = el("table", { className: "runtable" });
102
+ table.innerHTML =
103
+ "<thead><tr><th>When</th><th>Kind</th><th>Outcome</th><th>Took</th><th>Turns</th>" +
104
+ "<th>Cost</th><th></th></tr></thead>";
105
+ const rows = el("tbody");
106
+ for (const r of s.runs.slice(0, SHOWN)) rows.append(row(r));
107
+ table.append(rows);
108
+ body.append(table);
109
+ if (s.runs.length > SHOWN) {
110
+ body.append(
111
+ el("p", {
112
+ className: "hnote",
113
+ textContent: s.runs.length - SHOWN + " older runs count towards the total and are not listed.",
114
+ }),
115
+ );
116
+ }
117
+ return [head, body];
118
+ }
119
+
120
+ function row(r) {
121
+ const tr = el("tr");
122
+ const out = outcome(r);
123
+ const rec = r.record ?? {};
124
+ const mins = rec.duration_s != null ? rec.duration_s / 60 : r.minutes;
125
+ tr.innerHTML =
126
+ '<td title="' + esc(r.createdAt) + '">' + esc(ago(r.createdAt)) + "</td>" +
127
+ "<td>" + esc(r.kind) + "</td>" +
128
+ '<td><span class="chip ' + out.tone + '">' + esc(out.word) + "</span></td>" +
129
+ "<td>" + (mins != null ? Math.round(mins) + "m" : "–") + "</td>" +
130
+ "<td>" + (rec.turns ?? "–") + "</td>" +
131
+ "<td>" + (typeof rec.cost_usd === "number" ? "$" + rec.cost_usd.toFixed(2) : "–") + "</td>" +
132
+ '<td><a class="ref" href="' + esc(r.url) + '" target="_blank" rel="noopener">log</a></td>';
133
+ return tr;
134
+ }
135
+
136
+ /** The record's word when there is one, which knows a setup failure from an agent failure. */
137
+ function outcome(r) {
138
+ if (r.status !== "completed") return { word: r.status.replace("_", " "), tone: "" };
139
+ const word = r.record?.outcome ?? r.conclusion ?? "unknown";
140
+ const tone = word === "success" ? "ok" : word === "cancelled" ? "warm" : "hot";
141
+ return { word, tone };
142
+ }
143
+
144
+ function money(spend) {
145
+ return "$" + (spend?.usd ?? 0).toFixed(2);
146
+ }
147
+
148
+ /** A total is only as good as the runs it could price, so it says how many that was. */
149
+ function priced(spend) {
150
+ if (!spend?.runs) return "no runs";
151
+ if (spend.known === spend.runs) return "every run priced";
152
+ return "cost known for " + spend.known + " of " + spend.runs + " runs";
153
+ }
154
+
155
+ function over(spend, budget) {
156
+ return !!budget && (spend?.usd ?? 0) > budget;
157
+ }
158
+
159
+ function tile(k, v, sub, bad) {
160
+ const d = el("div", { className: "kv" + (bad ? " bad" : "") });
161
+ d.innerHTML =
162
+ '<div class="k">' + esc(k) + '</div><div class="v">' + esc(v) +
163
+ (sub ? "<small>" + esc(sub) + "</small>" : "") + "</div>";
164
+ return d;
165
+ }
@@ -5,19 +5,109 @@
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, getSetup, joinOrg, planTenant } 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";
24
+ import { credentialPanel } from "./credential.js";
11
25
  import { repoPicker } from "./repos.js";
12
26
  import { paste } from "./paste.js";
13
27
 
14
28
  let S = null; // /api/setup/status, refreshed after anything that changes the world
15
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
+
16
38
  export async function viewSetup(main) {
17
39
  S = await getSetup();
18
40
  render(main);
19
41
  }
20
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
+
21
111
  function render(main) {
22
112
  /* The sidebar is written by boot() before any of this is known. Once a tenant has been
23
113
  adopted, leaving it on "no org yet" contradicts the card directly below it. */
@@ -30,8 +120,9 @@ function render(main) {
30
120
  el("p", {
31
121
  className: "sub",
32
122
  textContent:
33
- "Four things here, then two that GitHub insists a human does. Nothing is created until " +
34
- "you have 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.",
35
126
  }),
36
127
  );
37
128
 
@@ -41,7 +132,7 @@ function render(main) {
41
132
 
42
133
  /* Drawn whenever a tenant exists, not only in the seconds after creating one. Setup takes
43
134
  days: the repo picker and the two manual steps have to still be here tomorrow. */
44
- if (S.tenant.found) main.append(afterCreate());
135
+ if (S.tenant.found) main.append(afterCreate(3));
45
136
  }
46
137
 
47
138
  /* ---------------------------------- 1 · gh ---------------------------------- */
@@ -78,12 +169,14 @@ function stepOrg(main) {
78
169
  }),
79
170
  el("p", {
80
171
  className: "sub",
81
- 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.",
82
175
  }),
83
176
  );
84
- const rows = el("div", { style: "margin-top:12px" });
85
- card.append(rows);
86
- checklist(rows, {});
177
+ healthHost = el("div", { style: "margin-top:12px" });
178
+ card.append(healthHost);
179
+ checklist(healthHost, {});
87
180
  return card;
88
181
  }
89
182
 
@@ -259,7 +352,10 @@ function stepOrg(main) {
259
352
  out.replaceChildren();
260
353
  out.append(
261
354
  el("p", {
262
- textContent: `${result.files.length} files in ${result.dir}, and one private repo, ${p.org}/roster-ops.`,
355
+ textContent:
356
+ `${result.files.length} files in ${result.dir}, and one private repo, ${p.org}/roster-ops, ` +
357
+ "with its Actions access set so every repo in the org can call its workflow. If GitHub " +
358
+ "refuses that, you get the reason and the page to click.",
263
359
  }),
264
360
  );
265
361
  const list = el("div", { className: "filelist" });
@@ -302,56 +398,182 @@ function stepOrg(main) {
302
398
 
303
399
  /* ------------------------- 5 · the two GitHub insists on ------------------------- */
304
400
 
305
- function afterCreate() {
306
- const card = step(3, "What is left", false);
401
+ function afterCreate(n) {
402
+ fileSteps.clear();
403
+ const card = step(n, "What is left", false);
307
404
  card.append(
308
405
  el("p", {
309
406
  textContent:
310
- "None of these can be done for you, and the first fails in a way that wastes an " +
311
- "afternoon if you skip it.",
312
- }),
313
- );
314
-
315
- const one = el("div", { className: "manual" });
316
- one.append(
317
- el("b", { textContent: "Let the ops repo's workflow be called" }),
318
- el("p", {
319
- textContent:
320
- "Settings → Actions → General → Access → “Accessible from repositories in the " +
321
- "organisation”, on roster-ops.",
322
- }),
323
- el("small", {
324
- textContent:
325
- "Skip it and every workflow fails with “workflow not found”, which reads like a typo in " +
326
- "a path and is not one. It is an org permission on a repo, so it is yours to click.",
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.",
327
409
  }),
328
410
  );
329
- if (S.tenant.org) {
330
- const url = `https://github.com/${S.tenant.org}/roster-ops/settings/actions`;
331
- one.append(el("a", { className: "btn", href: url, target: "_blank", textContent: "Open that page" }));
332
- }
333
- card.append(one);
411
+ card.append(accessStep());
412
+ card.append(credentialPanel());
334
413
 
335
414
  if (S.tenant.org) {
336
415
  card.append(repoPicker({ org: S.tenant.org, known: S.tenant.repos ?? [] }));
337
416
  }
338
417
 
339
- const two = el("div", { className: "manual" });
340
- two.append(
341
- el("b", { textContent: "Answer org/business.md" }),
342
- el("p", {
343
- textContent:
344
- "It ships as questions. It is composed into the top of every prompt, every run, and an " +
345
- "agent that cannot answer them writes work that is plausible and generic.",
346
- }),
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.",
347
423
  );
348
- card.append(two);
349
-
350
424
  // The copy-a-prompt loop, which is the whole answer to "how do I write this file".
351
- 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);
352
436
  return card;
353
437
  }
354
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
+
523
+ /**
524
+ * The ops repo's Actions access. Read on arrival, so a page reloaded after it was set says it
525
+ * is done without anybody pressing anything; the button asks the server to set it, and a
526
+ * refusal comes back with GitHub's reason and the page to click instead.
527
+ *
528
+ * Skipped, every workflow fails with "workflow not found", which reads like a typo in a path.
529
+ */
530
+ function accessStep() {
531
+ const box = el("div", { className: "manual" });
532
+ box.append(el("b", { textContent: "Let every repo in the org call the ops repo's workflow" }));
533
+ const out = el("div");
534
+ box.append(out);
535
+ const say = (text, cls) => el("p", { className: cls ?? "sub", textContent: text });
536
+
537
+ const offer = (why) => {
538
+ const go = el("button", { className: "btn primary", textContent: "Set it for me" });
539
+ out.replaceChildren(
540
+ say(
541
+ (why ? why + " " : "") +
542
+ "Settings → Actions → General → Access on roster-ops. Skip it and every workflow fails " +
543
+ "with “workflow not found”.",
544
+ ),
545
+ el("div", { className: "row" }, [go]),
546
+ );
547
+ go.onclick = async () => {
548
+ go.disabled = true;
549
+ try {
550
+ const r = await setAccess();
551
+ if (r.ok) {
552
+ out.replaceChildren(say("Set. Every repo in the org can call roster-ops."));
553
+ return;
554
+ }
555
+ out.replaceChildren(
556
+ say("GitHub refused: " + r.error + ".", "err"),
557
+ el("a", { className: "btn", href: r.link, target: "_blank", textContent: "Set it by hand" }),
558
+ say("Access → “Accessible from repositories in the organisation”."),
559
+ );
560
+ } catch (err) {
561
+ out.replaceChildren(say(String(err.message || err), "err"));
562
+ go.disabled = false;
563
+ }
564
+ };
565
+ };
566
+
567
+ out.replaceChildren(say("Checking…"));
568
+ getAccess()
569
+ .then((r) => {
570
+ if (r.ok) out.replaceChildren(say("Done. Every repo in the org can call roster-ops."));
571
+ else offer(r.level ? "It is set to “" + r.level + "”." : "");
572
+ })
573
+ .catch(() => offer(""));
574
+ return box;
575
+ }
576
+
355
577
  /* ---------------------------------- furniture ---------------------------------- */
356
578
 
357
579
  function step(n, title, done) {