@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.
- package/README.md +65 -84
- package/dist/cli.js +4187 -2709
- package/docs/README.md +9 -6
- package/docs/agents.md +24 -20
- package/docs/architecture.md +13 -5
- package/docs/charters/cmo.md +69 -0
- package/docs/charters/cto.md +71 -0
- package/docs/charters/support.md +60 -0
- package/docs/commands.md +81 -5
- package/docs/concepts.md +48 -14
- package/docs/cost.md +36 -1
- package/docs/developing.md +16 -21
- package/docs/doctor-codes.md +8 -2
- package/docs/extending.md +2 -2
- package/docs/getting-started.md +110 -77
- package/docs/manual-steps.md +93 -123
- package/docs/memory.md +21 -3
- package/docs/org-yaml.md +37 -2
- package/docs/portal.md +83 -35
- package/docs/prompts.md +25 -4
- package/docs/security.md +29 -5
- package/docs/session-workflow.md +49 -17
- package/docs/staff-yaml.md +13 -2
- package/docs/troubleshooting.md +8 -8
- package/docs/upgrading.md +6 -0
- package/docs/writing-a-charter.md +15 -0
- package/package.json +1 -1
- package/templates/brain/.github/workflows/%%STAFF%%-daily.yaml +6 -0
- package/templates/brain/.github/workflows/%%STAFF%%-mention.yaml +6 -0
- package/templates/brain/CHARTER.md +3 -3
- package/templates/brain/README.md +1 -0
- package/templates/brain/log/decisions.md +3 -0
- package/templates/brain/strategy/ideas.md +7 -0
- package/templates/ops/.github/workflows/session.yaml +108 -14
- package/templates/ops/agents.mjs +7 -3
- package/templates/ops/compose.mjs +16 -3
- package/templates/ops/inflight.mjs +157 -0
- package/templates/ops/org/operating.md +21 -1
- package/templates/ops/org/voice.md +9 -0
- package/templates/ops/prompts/_inflight.md +14 -0
- package/templates/ops/prompts/_paths.md +2 -1
- package/templates/ops/prompts/daily.md +16 -7
- package/templates/ops/prompts/mention.md +2 -0
- package/templates/ops/run-record.mjs +144 -0
- package/templates/portal/css/runs.css +13 -0
- package/templates/portal/css/setup.css +2 -0
- package/templates/portal/index.html +12 -1
- package/templates/portal/js/api.js +32 -4
- package/templates/portal/js/app.js +42 -8
- package/templates/portal/js/dialog.js +47 -0
- package/templates/portal/js/state.js +3 -1
- package/templates/portal/js/views/app.js +23 -5
- package/templates/portal/js/views/credential.js +93 -0
- package/templates/portal/js/views/health.js +17 -4
- package/templates/portal/js/views/inbox.js +6 -2
- package/templates/portal/js/views/org.js +23 -3
- package/templates/portal/js/views/paste.js +29 -0
- 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 +94 -0
- package/templates/portal/js/views/runs.js +165 -0
- package/templates/portal/js/views/setup.js +267 -45
- 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 {
|
|
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";
|
|
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
|
-
"
|
|
34
|
-
"you
|
|
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:
|
|
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
|
-
|
|
85
|
-
card.append(
|
|
86
|
-
checklist(
|
|
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:
|
|
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
|
-
|
|
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
|
-
"
|
|
311
|
-
"
|
|
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
|
-
|
|
330
|
-
|
|
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
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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
|
-
|
|
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) {
|