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

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 (58) hide show
  1. package/README.md +65 -84
  2. package/dist/cli.js +4153 -2708
  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 +100 -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 +59 -33
  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/index.html +4 -0
  47. package/templates/portal/js/api.js +29 -3
  48. package/templates/portal/js/app.js +4 -1
  49. package/templates/portal/js/state.js +3 -1
  50. package/templates/portal/js/views/app.js +23 -5
  51. package/templates/portal/js/views/credential.js +93 -0
  52. package/templates/portal/js/views/health.js +15 -3
  53. package/templates/portal/js/views/org.js +2 -0
  54. package/templates/portal/js/views/paste.js +29 -0
  55. package/templates/portal/js/views/runonce.js +88 -0
  56. package/templates/portal/js/views/runs.js +165 -0
  57. package/templates/portal/js/views/setup.js +67 -26
  58. package/templates/portal/js/views/staff.js +47 -10
@@ -6,8 +6,8 @@
6
6
  * it is one browser, one origin, and nothing extra to explain.
7
7
  *
8
8
  * What it still cannot do is install the App. Installing is a grant of access to specific
9
- * repositories and GitHub asks a human to choose them, which is correct behaviour and should not
10
- * be worked around. */
9
+ * repositories and GitHub asks a human to confirm them, which is correct behaviour and should not
10
+ * be worked around. The link it hands back has the right repos already selected. */
11
11
 
12
12
  import { appResult, startApp } from "../api.js";
13
13
  import { el } from "../dom.js";
@@ -48,6 +48,11 @@ export function appPanel(opts) {
48
48
  it: an App that exists is not a problem, it is a step you have already done. */
49
49
  const note = el("div");
50
50
  note.append(el("p", { className: "err", textContent: String(err.message || err) }));
51
+ if (err.data?.install) {
52
+ note.append(
53
+ el("a", { className: "btn", href: err.data.install, target: "_blank", textContent: "Install it" }),
54
+ );
55
+ }
51
56
  out.replaceChildren(note);
52
57
  return;
53
58
  }
@@ -102,9 +107,21 @@ export function appPanel(opts) {
102
107
  el("p", {
103
108
  className: "sub",
104
109
  textContent:
105
- "Grant it every tracker this staff member writes to, not just their own: the token " +
106
- "is minted organisation-wide and a peer's board is where a brief lands.",
110
+ "The link opens GitHub's install page with the org and the repos already selected: " +
111
+ "this staff member's brain, every peer tracker it writes to, and the product repos. " +
112
+ "Check the list and confirm.",
107
113
  }),
114
+ ...((result.preselected ?? []).length
115
+ ? [el("p", { className: "sub", textContent: "Selected: " + result.preselected.join(", ") })]
116
+ : []),
117
+ ...((result.missing ?? []).length
118
+ ? [
119
+ el("p", {
120
+ className: "err",
121
+ textContent: "Tick these yourself, their ids could not be read: " + result.missing.join(", "),
122
+ }),
123
+ ]
124
+ : []),
108
125
  el("a", {
109
126
  className: "btn primary",
110
127
  href: result.install,
@@ -115,7 +132,8 @@ export function appPanel(opts) {
115
132
  className: "sub",
116
133
  textContent:
117
134
  "Do not verify this by reading the API. It reports what an App declares separately " +
118
- "from what an installation was granted. Only a run that finished proves the chain.",
135
+ "from what an installation was granted. Only a run that finished proves the chain, " +
136
+ "which is what Run once now on the staff card is for.",
119
137
  }),
120
138
  );
121
139
  out.replaceChildren(done);
@@ -0,0 +1,93 @@
1
+ /* The agent's credential, stored once for the org.
2
+ *
3
+ * The value goes from this box to the local server in a POST, and from there to gh on its
4
+ * standard input. It is not kept in the page after it is sent, and nothing echoes it back. */
5
+
6
+ import { getCredential, storeCredential } from "../api.js";
7
+ import { el } from "../dom.js";
8
+
9
+ export function credentialPanel() {
10
+ const box = el("div", { className: "manual" });
11
+ box.append(el("b", { textContent: "Your agent's credential, once" }));
12
+ const body = el("div");
13
+ box.append(body);
14
+ body.append(el("p", { className: "sub", textContent: "Looking at where it would go…" }));
15
+
16
+ getCredential()
17
+ .then((c) => draw(body, c))
18
+ .catch((err) => body.replaceChildren(el("p", { className: "err", textContent: String(err.message || err) })));
19
+ return box;
20
+ }
21
+
22
+ function draw(body, c) {
23
+ body.replaceChildren();
24
+ if (c.howTo) body.append(el("p", { textContent: "Where to get one: " + c.howTo }));
25
+
26
+ if (!c.brains.length) {
27
+ body.append(
28
+ el("p", {
29
+ className: "sub",
30
+ textContent:
31
+ "Nothing reads it until somebody is hired, so this box opens once there is a staff " +
32
+ "member. It is asked for once, not once per hire.",
33
+ }),
34
+ );
35
+ return;
36
+ }
37
+
38
+ if (c.orgSecret) {
39
+ body.append(
40
+ el("p", {
41
+ className: "sub",
42
+ textContent:
43
+ c.name + " is already an org secret, shared with " + c.orgSecret.visibility + " repos. " +
44
+ "Each hire adds its brain to it. Paste a new one only to replace it.",
45
+ }),
46
+ );
47
+ }
48
+
49
+ body.append(
50
+ el("p", {
51
+ className: "sub",
52
+ textContent:
53
+ (c.plan.mode === "org"
54
+ ? "Stored as one org secret, " + c.name + ", shared with " + c.brains.join(", ") + ". "
55
+ : "Stored as " + c.name + " on each of " + c.brains.join(", ") + ", because " + c.plan.reason + ". ") +
56
+ "It goes to gh on standard input and is never written to disk.",
57
+ }),
58
+ );
59
+
60
+ const input = el("input", { type: "password", placeholder: c.name, autocomplete: "off" });
61
+ input.style.width = "100%";
62
+ const go = el("button", { className: "btn primary", textContent: "Store it" });
63
+ const out = el("div", { style: "margin-top:9px" });
64
+ body.append(input, el("div", { className: "row", style: "margin-top:9px" }, [go]), out);
65
+
66
+ go.onclick = async () => {
67
+ const value = input.value.trim();
68
+ if (!value) {
69
+ input.focus();
70
+ return;
71
+ }
72
+ go.disabled = true;
73
+ out.replaceChildren(el("p", { className: "sub", textContent: "Storing…" }));
74
+ try {
75
+ const r = await storeCredential(value, false);
76
+ input.value = "";
77
+ if (!r.ok) throw new Error(r.error);
78
+ out.replaceChildren(
79
+ ...(r.fellBack ? [el("p", { className: "err", textContent: "The org secret was refused: " + r.fellBack })] : []),
80
+ el("p", {
81
+ textContent:
82
+ r.mode === "org"
83
+ ? "Stored once for the org, shared with " + r.repos.length + " brains."
84
+ : "Stored on " + r.repos.join(", ") + ".",
85
+ }),
86
+ );
87
+ } catch (err) {
88
+ out.replaceChildren(el("p", { className: "err", textContent: String(err.message || err) }));
89
+ } finally {
90
+ go.disabled = false;
91
+ }
92
+ };
93
+ }
@@ -7,6 +7,7 @@ import { render } from "../router.js";
7
7
  import { S, staff } from "../state.js";
8
8
  import { checklist } from "./checklist.js";
9
9
  import { copyAmendBrief } from "./prompt.js";
10
+ import { runOnce } from "./runonce.js";
10
11
 
11
12
  const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
12
13
 
@@ -81,7 +82,18 @@ export function viewHealth(m) {
81
82
  doctor, bordered boxes for the prompt audit, cards with two buttons each for lint. Same
82
83
  question in all three, so one shape: a heading that carries its own count, a line saying
83
84
  what the section is, the findings as rows, and the section's actions at the end. */
84
- const doctor = section(m, "The org, from roster doctor", "doctor");
85
+ /* A workflow that has never run is unproven, and the way to prove it is to run it. Here
86
+ because Health is where "unproven" is read; a success re-runs the checks below. */
87
+ let doctor = null;
88
+ m.append(
89
+ runOnce({
90
+ staff: s.handle,
91
+ name: s.name,
92
+ onDone: (ok) => ok && doctor && checklist(doctor.body, { shell: doctor }),
93
+ }),
94
+ );
95
+
96
+ doctor = section(m, "The org, from roster doctor", "doctor");
85
97
  checklist(doctor.body, { shell: doctor });
86
98
 
87
99
  /* What is wrong with what this agent is sent. It used to sit in the tree on the Prompt
@@ -132,7 +144,7 @@ function memoryProblems({ body, actions, say }, s) {
132
144
  const { row, body: text } = finding(
133
145
  p.level === "error" ? "error" : "warning",
134
146
  { html: inline(p.message) },
135
- [p.rule, p.line ? "INDEX.md:" + p.line : ""],
147
+ [p.rule, p.line ? "INDEX.md:" + p.line : (p.file ?? "")],
136
148
  );
137
149
  const status = el("span", { className: "meta" });
138
150
  const btn = el("button", { className: "ghbtn", textContent: "Ask " + s.handle.toUpperCase() + " to fix" });
@@ -263,7 +275,7 @@ export async function askToFix(s, problems, btn, status) {
263
275
  "",
264
276
  "| rule | where | what |",
265
277
  "|---|---|---|",
266
- ...problems.map((p) => "| `" + p.rule + "` | " + (p.line ? "INDEX.md:" + p.line : "—") + " | " +
278
+ ...problems.map((p) => "| `" + p.rule + "` | " + (p.line ? "INDEX.md:" + p.line : (p.file ?? "—")) + " | " +
267
279
  String(p.message).replace(/\|/g, "\\|") + " |"),
268
280
  "",
269
281
  "Fix in your next session and close this.",
@@ -27,6 +27,8 @@ const KNOWN = {
27
27
  "org.yaml": "Who and what: the humans, the repos, the defaults, which agent runs them.",
28
28
  "org/business.md":
29
29
  "What the business actually is. Every prompt is composed on top of it, and it is the file that stops the agents writing generic slop.",
30
+ "org/priorities.md":
31
+ "What matters this month, ranked, and what is out of scope. Every daily run reads it, and every PR says which priority it serves.",
30
32
  "org/operating.md":
31
33
  "The autonomy contract: what they do without asking, what they escalate, how a run starts and ends.",
32
34
  "org/guardrails.md":
@@ -20,6 +20,12 @@ export function paste(opts) {
20
20
  const status = el("p", { className: "sub", textContent: "Building the prompt…" });
21
21
  box.append(status);
22
22
 
23
+ /* A charter brief carries a worked example to model the shape on, matched to the role. The
24
+ choice is on the page because the match is a guess, and "none" is a fair answer. */
25
+ const model = el("select", { hidden: true });
26
+ const modelRow = el("label", { className: "sub", hidden: true }, ["Model it on: ", model]);
27
+ box.append(modelRow);
28
+
23
29
  const actions = el("div", { className: "row" });
24
30
  const copy = el("button", { className: "btn primary", textContent: "Copy the prompt" });
25
31
  const show = el("button", { className: "btn", textContent: "Show it" });
@@ -42,10 +48,33 @@ export function paste(opts) {
42
48
 
43
49
  let brief = null;
44
50
 
51
+ const NAMES = { cto: "the CTO example", cmo: "the CMO example", support: "the support example", none: "no example" };
52
+ model.onchange = () => {
53
+ copy.disabled = true;
54
+ getBrief(opts.kind, opts.staff, model.value)
55
+ .then((data) => {
56
+ if (data.error) throw new Error(data.error);
57
+ brief = data;
58
+ if (!preview.hidden) preview.textContent = brief.text;
59
+ })
60
+ .catch((err) => {
61
+ status.className = "err";
62
+ status.textContent = String(err.message || err);
63
+ })
64
+ .finally(() => {
65
+ copy.disabled = false;
66
+ });
67
+ };
68
+
45
69
  getBrief(opts.kind, opts.staff)
46
70
  .then((data) => {
47
71
  if (data.error) throw new Error(data.error);
48
72
  brief = data;
73
+ if (data.examples) {
74
+ model.replaceChildren(...data.examples.map((e) => el("option", { value: e, textContent: NAMES[e] ?? e })));
75
+ model.value = data.example ?? "none";
76
+ model.hidden = modelRow.hidden = false;
77
+ }
49
78
  status.textContent =
50
79
  "Paste this into Claude, ChatGPT or anything else. It carries every file it refers to, " +
51
80
  "so there is nothing to attach and nothing for it to ask you for. It will interview you " +
@@ -0,0 +1,88 @@
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 { el } from "../dom.js";
9
+
10
+ /**
11
+ * @param {{staff: string, name: string, onDone?: (ok: boolean) => void}} opts
12
+ */
13
+ export function runOnce(opts) {
14
+ const box = el("div", { className: "manual" });
15
+ box.append(
16
+ el("b", { textContent: "Run " + opts.name + " once, now" }),
17
+ el("p", {
18
+ className: "sub",
19
+ textContent:
20
+ "A real daily run: it does a day's work and spends what a scheduled one would. " +
21
+ "When it finishes you get the outcome and the log.",
22
+ }),
23
+ );
24
+ const go = el("button", { className: "btn primary", textContent: "Run once now" });
25
+ const out = el("div", { style: "margin-top:9px" });
26
+ box.append(el("div", { className: "row" }, [go]), out);
27
+
28
+ go.onclick = async () => {
29
+ if (!confirm("Start a daily run for " + opts.name + " now?\n\nIt is a real run, and it costs what one does.")) return;
30
+ go.disabled = true;
31
+ const say = (text, cls) => out.replaceChildren(el("p", { className: cls ?? "sub", textContent: text }));
32
+ say("Starting…");
33
+ let run;
34
+ try {
35
+ run = await startRun(opts.staff);
36
+ } catch (err) {
37
+ say(String(err.message || err), "err");
38
+ go.disabled = false;
39
+ return;
40
+ }
41
+ const link = el("a", { href: run.url, target: "_blank", rel: "noopener", textContent: "the log" });
42
+ if (!run.id) {
43
+ out.replaceChildren(el("p", { className: "sub" }, ["Started. It has not shown up yet; follow it at ", link, "."]));
44
+ go.disabled = false;
45
+ return;
46
+ }
47
+
48
+ /* Polled rather than streamed: a run takes minutes, the status changes a handful of times,
49
+ and a clock beside it is what shows the page has not hung. */
50
+ const began = Date.now();
51
+ const tick = async () => {
52
+ let now;
53
+ try {
54
+ now = await runStatus(opts.staff, run.id);
55
+ if (now.error) throw new Error(now.error);
56
+ } catch (err) {
57
+ say(String(err.message || err), "err");
58
+ go.disabled = false;
59
+ return;
60
+ }
61
+ const mins = Math.floor((Date.now() - began) / 60000);
62
+ if (now.status !== "completed") {
63
+ out.replaceChildren(
64
+ el("p", { className: "sub" }, [now.status.replace("_", " ") + ", " + mins + " min so far. ", link]),
65
+ );
66
+ setTimeout(tick, 15000);
67
+ return;
68
+ }
69
+ go.disabled = false;
70
+ const ok = now.conclusion === "success";
71
+ const done = el("div");
72
+ done.append(
73
+ el("p", { className: ok ? "" : "err" }, [
74
+ ok
75
+ ? "It worked. The App, its grant, the secrets and the callers are proven, and Health now says so. "
76
+ : "It ended " + (now.conclusion || "without a conclusion") + ". ",
77
+ el("a", { href: now.url, target: "_blank", rel: "noopener", textContent: "The log" }),
78
+ ]),
79
+ );
80
+ for (const f of now.failedAt ?? []) done.append(el("p", { className: "sub", textContent: "Failed at " + f }));
81
+ if (!ok) done.append(el("p", { className: "sub", textContent: "Troubleshooting, in the docs, has what each failure usually means." }));
82
+ out.replaceChildren(done);
83
+ opts.onDone?.(ok);
84
+ };
85
+ tick();
86
+ };
87
+ return box;
88
+ }
@@ -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,9 +5,10 @@
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";
8
+ import { checkOrg, createTenant, getAccess, getSetup, joinOrg, planTenant, setAccess } from "../api.js";
9
9
  import { $, el, esc, toClipboard } from "../dom.js";
10
10
  import { checklist } from "./checklist.js";
11
+ import { credentialPanel } from "./credential.js";
11
12
  import { repoPicker } from "./repos.js";
12
13
  import { paste } from "./paste.js";
13
14
 
@@ -30,8 +31,9 @@ function render(main) {
30
31
  el("p", {
31
32
  className: "sub",
32
33
  textContent:
33
- "Four things here, then two that GitHub insists a human does. Nothing is created until " +
34
- "you have read the plan.",
34
+ "Three things here, then what is left: a setting roster tries for you, the repos your " +
35
+ "staff work in, and the one file only you can write. Nothing is created until you have " +
36
+ "read the plan.",
35
37
  }),
36
38
  );
37
39
 
@@ -259,7 +261,10 @@ function stepOrg(main) {
259
261
  out.replaceChildren();
260
262
  out.append(
261
263
  el("p", {
262
- textContent: `${result.files.length} files in ${result.dir}, and one private repo, ${p.org}/roster-ops.`,
264
+ textContent:
265
+ `${result.files.length} files in ${result.dir}, and one private repo, ${p.org}/roster-ops, ` +
266
+ "with its Actions access set so every repo in the org can call its workflow. If GitHub " +
267
+ "refuses that, you get the reason and the page to click.",
263
268
  }),
264
269
  );
265
270
  const list = el("div", { className: "filelist" });
@@ -307,30 +312,12 @@ function afterCreate() {
307
312
  card.append(
308
313
  el("p", {
309
314
  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.",
315
+ "The first is a setting roster tries for you. The last is the file that decides whether " +
316
+ "any of this is worth running, and only you can write it.",
312
317
  }),
313
318
  );
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.",
327
- }),
328
- );
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);
319
+ card.append(accessStep());
320
+ card.append(credentialPanel());
334
321
 
335
322
  if (S.tenant.org) {
336
323
  card.append(repoPicker({ org: S.tenant.org, known: S.tenant.repos ?? [] }));
@@ -352,6 +339,60 @@ function afterCreate() {
352
339
  return card;
353
340
  }
354
341
 
342
+ /**
343
+ * The ops repo's Actions access. Read on arrival, so a page reloaded after it was set says it
344
+ * is done without anybody pressing anything; the button asks the server to set it, and a
345
+ * refusal comes back with GitHub's reason and the page to click instead.
346
+ *
347
+ * Skipped, every workflow fails with "workflow not found", which reads like a typo in a path.
348
+ */
349
+ function accessStep() {
350
+ const box = el("div", { className: "manual" });
351
+ box.append(el("b", { textContent: "Let every repo in the org call the ops repo's workflow" }));
352
+ const out = el("div");
353
+ box.append(out);
354
+ const say = (text, cls) => el("p", { className: cls ?? "sub", textContent: text });
355
+
356
+ const offer = (why) => {
357
+ const go = el("button", { className: "btn primary", textContent: "Set it for me" });
358
+ out.replaceChildren(
359
+ say(
360
+ (why ? why + " " : "") +
361
+ "Settings → Actions → General → Access on roster-ops. Skip it and every workflow fails " +
362
+ "with “workflow not found”.",
363
+ ),
364
+ el("div", { className: "row" }, [go]),
365
+ );
366
+ go.onclick = async () => {
367
+ go.disabled = true;
368
+ try {
369
+ const r = await setAccess();
370
+ if (r.ok) {
371
+ out.replaceChildren(say("Set. Every repo in the org can call roster-ops."));
372
+ return;
373
+ }
374
+ out.replaceChildren(
375
+ say("GitHub refused: " + r.error + ".", "err"),
376
+ el("a", { className: "btn", href: r.link, target: "_blank", textContent: "Set it by hand" }),
377
+ say("Access → “Accessible from repositories in the organisation”."),
378
+ );
379
+ } catch (err) {
380
+ out.replaceChildren(say(String(err.message || err), "err"));
381
+ go.disabled = false;
382
+ }
383
+ };
384
+ };
385
+
386
+ out.replaceChildren(say("Checking…"));
387
+ getAccess()
388
+ .then((r) => {
389
+ if (r.ok) out.replaceChildren(say("Done. Every repo in the org can call roster-ops."));
390
+ else offer(r.level ? "It is set to “" + r.level + "”." : "");
391
+ })
392
+ .catch(() => offer(""));
393
+ return box;
394
+ }
395
+
355
396
  /* ---------------------------------- furniture ---------------------------------- */
356
397
 
357
398
  function step(n, title, done) {