@nanocollective/roster 0.1.0-alpha.1
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/LICENSE +21 -0
- package/README.md +129 -0
- package/dist/cli.js +5679 -0
- package/docs/README.md +99 -0
- package/docs/agents.md +163 -0
- package/docs/architecture.md +121 -0
- package/docs/commands.md +223 -0
- package/docs/concepts.md +112 -0
- package/docs/cost.md +61 -0
- package/docs/developing.md +147 -0
- package/docs/doctor-codes.md +74 -0
- package/docs/export.md +113 -0
- package/docs/extending.md +97 -0
- package/docs/getting-started.md +134 -0
- package/docs/hosting.md +72 -0
- package/docs/manual-steps.md +163 -0
- package/docs/memory.md +71 -0
- package/docs/org-yaml.md +143 -0
- package/docs/portal.md +342 -0
- package/docs/prompts.md +133 -0
- package/docs/security.md +122 -0
- package/docs/session-workflow.md +112 -0
- package/docs/staff-yaml.md +163 -0
- package/docs/troubleshooting.md +189 -0
- package/docs/upgrading.md +83 -0
- package/docs/writing-a-charter.md +83 -0
- package/package.json +60 -0
- package/templates/brain/.github/workflows/%%STAFF%%-daily.yaml +33 -0
- package/templates/brain/.github/workflows/%%STAFF%%-mention.yaml +65 -0
- package/templates/brain/.github/workflows/%%STAFF%%-pr-mention.yaml +50 -0
- package/templates/brain/CHARTER.md +49 -0
- package/templates/brain/README.md +18 -0
- package/templates/brain/drafts/README.md +7 -0
- package/templates/brain/log/decisions.md +6 -0
- package/templates/brain/memory/INDEX.md +28 -0
- package/templates/brain/staff.yaml +44 -0
- package/templates/brain/strategy/README.md +7 -0
- package/templates/briefs/amend.md +60 -0
- package/templates/briefs/charter.md +47 -0
- package/templates/briefs/discover.md +61 -0
- package/templates/briefs/voice.md +53 -0
- package/templates/ops/.github/workflows/session.yaml +333 -0
- package/templates/ops/agents.mjs +143 -0
- package/templates/ops/compose.mjs +333 -0
- package/templates/ops/org/guardrails.md +14 -0
- package/templates/ops/org/operating.md +82 -0
- package/templates/ops/org/voice.md +40 -0
- package/templates/ops/prompts/_identity.md +14 -0
- package/templates/ops/prompts/_paths.md +15 -0
- package/templates/ops/prompts/daily.md +82 -0
- package/templates/ops/prompts/mention.md +53 -0
- package/templates/ops/prompts/pr-mention.md +57 -0
- package/templates/ops/runner-plan.mjs +65 -0
- package/templates/portal/css/base.css +104 -0
- package/templates/portal/css/brain.css +106 -0
- package/templates/portal/css/diff.css +28 -0
- package/templates/portal/css/graph.css +34 -0
- package/templates/portal/css/health.css +41 -0
- package/templates/portal/css/inbox.css +79 -0
- package/templates/portal/css/layout.css +98 -0
- package/templates/portal/css/markdown.css +54 -0
- package/templates/portal/css/setup.css +106 -0
- package/templates/portal/index.html +55 -0
- package/templates/portal/js/api.js +74 -0
- package/templates/portal/js/app.js +282 -0
- package/templates/portal/js/dialog.js +70 -0
- package/templates/portal/js/dom.js +106 -0
- package/templates/portal/js/icons.js +94 -0
- package/templates/portal/js/md.js +386 -0
- package/templates/portal/js/refresh.js +59 -0
- package/templates/portal/js/router.js +20 -0
- package/templates/portal/js/state.js +160 -0
- package/templates/portal/js/textdiff.js +96 -0
- package/templates/portal/js/views/app.js +128 -0
- package/templates/portal/js/views/brain.js +260 -0
- package/templates/portal/js/views/changed.js +157 -0
- package/templates/portal/js/views/checklist.js +87 -0
- package/templates/portal/js/views/docs.js +84 -0
- package/templates/portal/js/views/files.js +95 -0
- package/templates/portal/js/views/graph.js +436 -0
- package/templates/portal/js/views/health.js +158 -0
- package/templates/portal/js/views/inbox.js +549 -0
- package/templates/portal/js/views/memory.js +135 -0
- package/templates/portal/js/views/org.js +175 -0
- package/templates/portal/js/views/paste.js +142 -0
- package/templates/portal/js/views/prompt.js +412 -0
- package/templates/portal/js/views/repos.js +92 -0
- package/templates/portal/js/views/setup.js +344 -0
- package/templates/portal/js/views/staff.js +290 -0
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
/* Standing an org up, in the browser, before there is anything on disk to browse.
|
|
2
|
+
*
|
|
3
|
+
* The rule this screen follows: never claim a step is done because you clicked it. Everything
|
|
4
|
+
* here is re-derived from /api/setup/status, so a page reloaded tomorrow shows the world as it
|
|
5
|
+
* is rather than as it was left. Setup cannot be finished in one sitting, so it must survive
|
|
6
|
+
* being abandoned halfway. */
|
|
7
|
+
|
|
8
|
+
import { checkOrg, createTenant, getSetup, joinOrg, planTenant } from "../api.js";
|
|
9
|
+
import { $, el, esc, toClipboard } from "../dom.js";
|
|
10
|
+
import { checklist } from "./checklist.js";
|
|
11
|
+
import { repoPicker } from "./repos.js";
|
|
12
|
+
import { paste } from "./paste.js";
|
|
13
|
+
|
|
14
|
+
let S = null; // /api/setup/status, refreshed after anything that changes the world
|
|
15
|
+
|
|
16
|
+
export async function viewSetup(main) {
|
|
17
|
+
S = await getSetup();
|
|
18
|
+
render(main);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function render(main) {
|
|
22
|
+
main.replaceChildren();
|
|
23
|
+
main.append(el("h1", { textContent: "Set up your org" }));
|
|
24
|
+
main.append(
|
|
25
|
+
el("p", {
|
|
26
|
+
className: "sub",
|
|
27
|
+
textContent:
|
|
28
|
+
"Four things here, then two that GitHub insists a human does. Nothing is created until " +
|
|
29
|
+
"you have read the plan.",
|
|
30
|
+
}),
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
main.append(stepGh());
|
|
34
|
+
if (!S.gh.ok) return; // everything downstream needs gh, and saying so once is enough
|
|
35
|
+
main.append(stepOrg(main));
|
|
36
|
+
|
|
37
|
+
/* Drawn whenever a tenant exists, not only in the seconds after creating one. Setup takes
|
|
38
|
+
days: the repo picker and the two manual steps have to still be here tomorrow. */
|
|
39
|
+
if (S.tenant.found) main.append(afterCreate());
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/* ---------------------------------- 1 · gh ---------------------------------- */
|
|
43
|
+
|
|
44
|
+
function stepGh() {
|
|
45
|
+
const card = step(1, "GitHub CLI", S.gh.ok);
|
|
46
|
+
if (S.gh.ok) {
|
|
47
|
+
card.append(el("p", { className: "sub", textContent: "Signed in as " + S.gh.login + "." }));
|
|
48
|
+
return card;
|
|
49
|
+
}
|
|
50
|
+
card.append(
|
|
51
|
+
el("p", { textContent: S.gh.error }),
|
|
52
|
+
el("p", {
|
|
53
|
+
className: "sub",
|
|
54
|
+
textContent:
|
|
55
|
+
"roster does everything through your own gh, so it holds no token of its own. Run this " +
|
|
56
|
+
"in a terminal, then reload:",
|
|
57
|
+
}),
|
|
58
|
+
el("pre", { className: "cmd", textContent: "gh auth login" }),
|
|
59
|
+
);
|
|
60
|
+
return card;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/* ------------------------- 2, 3, 4 · org, agent, create ------------------------- */
|
|
64
|
+
|
|
65
|
+
function stepOrg(main) {
|
|
66
|
+
const done = S.tenant.found;
|
|
67
|
+
const card = step(2, "The organisation, and who runs it", done);
|
|
68
|
+
|
|
69
|
+
if (done) {
|
|
70
|
+
card.append(
|
|
71
|
+
el("p", {
|
|
72
|
+
textContent: S.tenant.org + " is set up. Its ops repo is at " + S.tenant.opsDir + ".",
|
|
73
|
+
}),
|
|
74
|
+
el("p", {
|
|
75
|
+
className: "sub",
|
|
76
|
+
textContent: "Reload the page to leave setup and use the portal.",
|
|
77
|
+
}),
|
|
78
|
+
);
|
|
79
|
+
const rows = el("div", { style: "margin-top:12px" });
|
|
80
|
+
card.append(rows);
|
|
81
|
+
checklist(rows, {});
|
|
82
|
+
return card;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const form = el("div", { className: "setupform" });
|
|
86
|
+
const field = (id, label, value, hint) => {
|
|
87
|
+
const wrap = el("label", { className: "field" });
|
|
88
|
+
wrap.append(el("span", { textContent: label }));
|
|
89
|
+
const input = el("input", { id: "f-" + id, value: value ?? "" });
|
|
90
|
+
input.setAttribute("type", "text");
|
|
91
|
+
wrap.append(input);
|
|
92
|
+
if (hint) wrap.append(el("small", { textContent: hint }));
|
|
93
|
+
form.append(wrap);
|
|
94
|
+
// The wrapper, not just the input: reaching back through parentElement to hide a row is
|
|
95
|
+
// the kind of indirection that works until the markup moves one level.
|
|
96
|
+
return { wrap, input };
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/* A picker over the orgs gh can already see, because typing a name you have to get exactly
|
|
100
|
+
right is the kind of thing that fails at step two and reads as the tool being broken. */
|
|
101
|
+
const orgWrap = el("label", { className: "field" });
|
|
102
|
+
orgWrap.append(el("span", { textContent: "GitHub organisation" }));
|
|
103
|
+
const org = el("select", { id: "f-org" });
|
|
104
|
+
org.append(el("option", { value: "", textContent: S.orgs.length ? "choose one…" : "type below" }));
|
|
105
|
+
for (const o of S.orgs) org.append(el("option", { value: o, textContent: o }));
|
|
106
|
+
org.append(el("option", { value: "__other", textContent: "another one…" }));
|
|
107
|
+
orgWrap.append(org);
|
|
108
|
+
orgWrap.append(
|
|
109
|
+
el("small", {
|
|
110
|
+
textContent:
|
|
111
|
+
"Repos are created here. GitHub has no API for creating an organisation, so make one on " +
|
|
112
|
+
"github.com first if you need to.",
|
|
113
|
+
}),
|
|
114
|
+
);
|
|
115
|
+
form.append(orgWrap);
|
|
116
|
+
|
|
117
|
+
const other = field("orgother", "Organisation name", "", "");
|
|
118
|
+
other.wrap.hidden = S.orgs.length > 0;
|
|
119
|
+
|
|
120
|
+
/* Which of the two things this is. An org that already has a roster-ops wants joining, not
|
|
121
|
+
a second one, and that is the question a second person on a team is really asking. */
|
|
122
|
+
const verdict = el("div", { className: "verdict", hidden: true });
|
|
123
|
+
form.append(verdict);
|
|
124
|
+
let existing = null;
|
|
125
|
+
|
|
126
|
+
const look = async () => {
|
|
127
|
+
const name = org.value === "__other" ? other.input.value.trim() : org.value;
|
|
128
|
+
existing = null;
|
|
129
|
+
verdict.hidden = true;
|
|
130
|
+
if (!name) return;
|
|
131
|
+
verdict.hidden = false;
|
|
132
|
+
verdict.replaceChildren(el("p", { className: "sub", textContent: "Looking at " + name + "…" }));
|
|
133
|
+
try {
|
|
134
|
+
const found = await checkOrg(name);
|
|
135
|
+
existing = found.exists ? name : null;
|
|
136
|
+
verdict.replaceChildren(
|
|
137
|
+
el("p", {
|
|
138
|
+
textContent: found.exists
|
|
139
|
+
? name + " already runs roster. Nothing here needs creating; it needs checking out."
|
|
140
|
+
: "Nothing in " + name + " yet. This will create " + name + "/roster-ops.",
|
|
141
|
+
}),
|
|
142
|
+
);
|
|
143
|
+
paint();
|
|
144
|
+
} catch {
|
|
145
|
+
verdict.hidden = true;
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
org.addEventListener("change", () => {
|
|
150
|
+
other.wrap.hidden = org.value !== "__other";
|
|
151
|
+
look();
|
|
152
|
+
});
|
|
153
|
+
other.input.addEventListener("change", look);
|
|
154
|
+
|
|
155
|
+
const name = field("name", "What the business is called", "", "Shown to the agents. Defaults to the org.");
|
|
156
|
+
const human = field("human", "Your GitHub login", S.gh.login ?? "", "The person the agents answer to.");
|
|
157
|
+
|
|
158
|
+
card.append(form);
|
|
159
|
+
|
|
160
|
+
/* The agent. Its id decides the secret name later, so setup can name it exactly rather than
|
|
161
|
+
saying "it depends on the runner". */
|
|
162
|
+
card.append(el("h3", { textContent: "Which coding agent runs a session" }));
|
|
163
|
+
const agents = el("div", { className: "choices" });
|
|
164
|
+
S.agents.forEach((a, i) => {
|
|
165
|
+
const opt = el("label", { className: "choice" });
|
|
166
|
+
const radio = el("input", { name: "agent", value: a.id, checked: i === 0 });
|
|
167
|
+
radio.setAttribute("type", "radio");
|
|
168
|
+
opt.append(radio, el("b", { textContent: a.label }), el("small", { textContent: a.note }));
|
|
169
|
+
opt.append(el("code", { textContent: a.tokenEnv }));
|
|
170
|
+
agents.append(opt);
|
|
171
|
+
});
|
|
172
|
+
card.append(agents);
|
|
173
|
+
card.append(
|
|
174
|
+
el("p", {
|
|
175
|
+
className: "sub",
|
|
176
|
+
textContent:
|
|
177
|
+
"Anything else works too: a runner is an install command, a run command and the name of " +
|
|
178
|
+
"the secret holding its credential. Write those three into org.yaml afterwards.",
|
|
179
|
+
}),
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
const params = () => ({
|
|
183
|
+
org: org.value === "__other" ? other.input.value.trim() : org.value,
|
|
184
|
+
name: name.input.value.trim(),
|
|
185
|
+
human: human.input.value.trim(),
|
|
186
|
+
marker: (human.input.value.trim().split("-")[0] || "human").toLowerCase(),
|
|
187
|
+
agent: agents.querySelector("input:checked")?.value ?? "claude-code-action",
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
const out = el("div", { className: "planout" });
|
|
191
|
+
const plan = el("button", { className: "btn", textContent: "Show me the plan" });
|
|
192
|
+
const go = el("button", { className: "btn primary", textContent: "Create it", hidden: true });
|
|
193
|
+
const join = el("button", { className: "btn primary", textContent: "Check it out here", hidden: true });
|
|
194
|
+
card.append(el("div", { className: "row" }, [plan, go, join]), out);
|
|
195
|
+
|
|
196
|
+
/* Creating and joining are mutually exclusive, and showing both is how somebody creates a
|
|
197
|
+
second ops repo in an org that already had one. */
|
|
198
|
+
function paint() {
|
|
199
|
+
const joining = existing !== null;
|
|
200
|
+
join.hidden = !joining;
|
|
201
|
+
plan.hidden = joining;
|
|
202
|
+
if (joining) go.hidden = true;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
join.onclick = async () => {
|
|
206
|
+
join.disabled = true;
|
|
207
|
+
out.replaceChildren(el("p", { className: "sub", textContent: "Cloning…" }));
|
|
208
|
+
try {
|
|
209
|
+
const result = await joinOrg(existing);
|
|
210
|
+
if (!result.ok) throw new Error(result.error);
|
|
211
|
+
out.replaceChildren(
|
|
212
|
+
el("p", {
|
|
213
|
+
textContent:
|
|
214
|
+
"Checked out " + (result.cloned ?? []).join(", ") + " into " + (S.startedIn ?? "here") + ".",
|
|
215
|
+
}),
|
|
216
|
+
);
|
|
217
|
+
S = await getSetup();
|
|
218
|
+
render(main);
|
|
219
|
+
} catch (err) {
|
|
220
|
+
out.replaceChildren(el("p", { className: "err", textContent: String(err.message || err) }));
|
|
221
|
+
} finally {
|
|
222
|
+
join.disabled = false;
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
plan.onclick = async () => {
|
|
227
|
+
const p = params();
|
|
228
|
+
if (!p.org) {
|
|
229
|
+
out.replaceChildren(el("p", { className: "err", textContent: "Pick an organisation first." }));
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
out.replaceChildren(el("p", { className: "sub", textContent: "Working…" }));
|
|
233
|
+
try {
|
|
234
|
+
const result = await planTenant(p);
|
|
235
|
+
out.replaceChildren();
|
|
236
|
+
out.append(
|
|
237
|
+
el("p", {
|
|
238
|
+
textContent: `${result.files.length} files in ${result.dir}, and one private repo, ${p.org}/roster-ops.`,
|
|
239
|
+
}),
|
|
240
|
+
);
|
|
241
|
+
const list = el("div", { className: "filelist" });
|
|
242
|
+
for (const f of result.files) list.append(el("code", { textContent: "+ " + f }));
|
|
243
|
+
out.append(list);
|
|
244
|
+
out.append(
|
|
245
|
+
el("p", {
|
|
246
|
+
className: "sub",
|
|
247
|
+
textContent:
|
|
248
|
+
"org/business.md arrives as questions, not prose. Answering it is the next step and " +
|
|
249
|
+
"it is the one that decides whether any of this is worth running.",
|
|
250
|
+
}),
|
|
251
|
+
);
|
|
252
|
+
go.hidden = false;
|
|
253
|
+
} catch (err) {
|
|
254
|
+
out.replaceChildren(el("p", { className: "err", textContent: String(err.message || err) }));
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
go.onclick = async () => {
|
|
259
|
+
go.disabled = plan.disabled = true;
|
|
260
|
+
out.replaceChildren(el("p", { className: "sub", textContent: "Creating…" }));
|
|
261
|
+
try {
|
|
262
|
+
const result = await createTenant(params());
|
|
263
|
+
out.replaceChildren(el("pre", { className: "cmd", textContent: result.output.trim() }));
|
|
264
|
+
if (result.ok) {
|
|
265
|
+
// render() draws the rest itself now that a tenant is on disk.
|
|
266
|
+
S = await getSetup();
|
|
267
|
+
render(main);
|
|
268
|
+
}
|
|
269
|
+
} catch (err) {
|
|
270
|
+
out.replaceChildren(el("p", { className: "err", textContent: String(err.message || err) }));
|
|
271
|
+
} finally {
|
|
272
|
+
go.disabled = plan.disabled = false;
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
return card;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/* ------------------------- 5 · the two GitHub insists on ------------------------- */
|
|
280
|
+
|
|
281
|
+
function afterCreate() {
|
|
282
|
+
const card = step(3, "Two things only you can do", false);
|
|
283
|
+
card.append(
|
|
284
|
+
el("p", {
|
|
285
|
+
textContent:
|
|
286
|
+
"Neither can be automated, and the first one fails in a way that wastes an afternoon if " +
|
|
287
|
+
"you skip it.",
|
|
288
|
+
}),
|
|
289
|
+
);
|
|
290
|
+
|
|
291
|
+
const one = el("div", { className: "manual" });
|
|
292
|
+
one.append(
|
|
293
|
+
el("b", { textContent: "Let the ops repo's workflow be called" }),
|
|
294
|
+
el("p", {
|
|
295
|
+
textContent:
|
|
296
|
+
"Settings → Actions → General → Access → “Accessible from repositories in the " +
|
|
297
|
+
"organisation”, on roster-ops.",
|
|
298
|
+
}),
|
|
299
|
+
el("small", {
|
|
300
|
+
textContent:
|
|
301
|
+
"Skip it and every workflow fails with “workflow not found”, which reads like a typo in " +
|
|
302
|
+
"a path and is not one. It is an org permission on a repo, so it is yours to click.",
|
|
303
|
+
}),
|
|
304
|
+
);
|
|
305
|
+
if (S.tenant.org) {
|
|
306
|
+
const url = `https://github.com/${S.tenant.org}/roster-ops/settings/actions`;
|
|
307
|
+
one.append(el("a", { className: "btn", href: url, target: "_blank", textContent: "Open that page" }));
|
|
308
|
+
}
|
|
309
|
+
card.append(one);
|
|
310
|
+
|
|
311
|
+
if (S.tenant.org) {
|
|
312
|
+
card.append(repoPicker({ org: S.tenant.org, known: S.tenant.repos ?? [] }));
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const two = el("div", { className: "manual" });
|
|
316
|
+
two.append(
|
|
317
|
+
el("b", { textContent: "Answer org/business.md" }),
|
|
318
|
+
el("p", {
|
|
319
|
+
textContent:
|
|
320
|
+
"It ships as questions. It is composed into the top of every prompt, every run, and an " +
|
|
321
|
+
"agent that cannot answer them writes work that is plausible and generic.",
|
|
322
|
+
}),
|
|
323
|
+
);
|
|
324
|
+
card.append(two);
|
|
325
|
+
|
|
326
|
+
// The copy-a-prompt loop, which is the whole answer to "how do I write this file".
|
|
327
|
+
card.append(paste({ kind: "discover", title: "Write it with your own AI" }));
|
|
328
|
+
return card;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/* ---------------------------------- furniture ---------------------------------- */
|
|
332
|
+
|
|
333
|
+
function step(n, title, done) {
|
|
334
|
+
const card = el("section", { className: "step" + (done ? " done" : "") });
|
|
335
|
+
const head = el("div", { className: "stephead" });
|
|
336
|
+
head.append(
|
|
337
|
+
el("span", { className: "stepn", textContent: done ? "✓" : String(n) }),
|
|
338
|
+
el("h2", { textContent: title }),
|
|
339
|
+
);
|
|
340
|
+
card.append(head);
|
|
341
|
+
return card;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
export { esc, toClipboard, $ };
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/* Hiring and retiring, from the portal.
|
|
2
|
+
*
|
|
3
|
+
* Both run the CLI's own `buildPlan` and `applyPlan` on the server rather than describing
|
|
4
|
+
* either again here. Two descriptions of how to create a repo is one too many, and the one in
|
|
5
|
+
* the browser would be the one nobody updated.
|
|
6
|
+
*
|
|
7
|
+
* Plan then apply, the same as every roster command that changes something: you see every
|
|
8
|
+
* file, every label and every existing staff member it would edit, and nothing happens until
|
|
9
|
+
* you say so.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { post } from "../api.js";
|
|
13
|
+
import { ago, el, esc } from "../dom.js";
|
|
14
|
+
import { icon } from "../icons.js";
|
|
15
|
+
import { refreshAll } from "../refresh.js";
|
|
16
|
+
import { S } from "../state.js";
|
|
17
|
+
import { appPanel } from "./app.js";
|
|
18
|
+
import { paste } from "./paste.js";
|
|
19
|
+
|
|
20
|
+
export function viewStaff(m) {
|
|
21
|
+
m.append(el("h1", { textContent: "Staff" }));
|
|
22
|
+
m.append(
|
|
23
|
+
el("p", {
|
|
24
|
+
className: "sub",
|
|
25
|
+
textContent:
|
|
26
|
+
S.data.staff.length +
|
|
27
|
+
" on the roster. Hiring creates a repo, its workflows, its labels and its pinned issue, " +
|
|
28
|
+
"and wires it to everyone already here.",
|
|
29
|
+
}),
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
const list = el("div", { className: "grid", style: "margin-bottom:18px" });
|
|
33
|
+
for (const s of S.data.staff) list.append(card(s));
|
|
34
|
+
m.append(list);
|
|
35
|
+
|
|
36
|
+
const hire = el("button", { className: "ghbtn primary", textContent: "Hire someone" });
|
|
37
|
+
const pane = el("div");
|
|
38
|
+
hire.onclick = () => hireForm(pane);
|
|
39
|
+
m.append(el("div", { className: "row", style: "margin-bottom:14px" }, [hire]));
|
|
40
|
+
m.append(pane);
|
|
41
|
+
|
|
42
|
+
/* --------------------------- one staff member --------------------------- */
|
|
43
|
+
|
|
44
|
+
function card(s) {
|
|
45
|
+
const d = el("div", { className: "kv staffcard" });
|
|
46
|
+
const rig = s.rig ?? {};
|
|
47
|
+
d.innerHTML =
|
|
48
|
+
'<div class="k">' + esc(s.handle) + "</div>" +
|
|
49
|
+
'<div class="v">' + esc(s.name) +
|
|
50
|
+
"<small>" + esc(s.brain ?? s.dir) + " · " + s.facts.length + " facts · " +
|
|
51
|
+
(rig.lastCommit ? "last commit " + esc(ago(rig.lastCommit.date)) : "no commits") +
|
|
52
|
+
"</small></div>";
|
|
53
|
+
|
|
54
|
+
const status = el("span", { className: "meta" });
|
|
55
|
+
const go = el("button", { className: "ghbtn", textContent: "Retire" });
|
|
56
|
+
go.onclick = () => retireForm(pane, s, status);
|
|
57
|
+
|
|
58
|
+
/* The charter is the one file `hire` deliberately does not write, and until now the only
|
|
59
|
+
route to it was a CLI command printing a brief. This is the same brief, with every file
|
|
60
|
+
it refers to already inside it and somewhere to put the answer. */
|
|
61
|
+
const write = el("button", { className: "ghbtn", textContent: "Write the charter" });
|
|
62
|
+
write.onclick = () => {
|
|
63
|
+
pane.replaceChildren(
|
|
64
|
+
el("div", { className: "card" }, [
|
|
65
|
+
paste({
|
|
66
|
+
kind: "charter",
|
|
67
|
+
staff: s.handle,
|
|
68
|
+
title: "Write " + s.name + "'s charter with your own AI",
|
|
69
|
+
onSaved: () => refreshAll(false),
|
|
70
|
+
}),
|
|
71
|
+
]),
|
|
72
|
+
);
|
|
73
|
+
pane.scrollIntoView?.({ behavior: "smooth", block: "start" });
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/* The step `hire` has always had to hand back: there is no API that creates a GitHub App,
|
|
77
|
+
so it is a browser hand-off either way. It may as well be this browser. */
|
|
78
|
+
const app = el("button", { className: "ghbtn", textContent: "GitHub App" });
|
|
79
|
+
app.onclick = () => {
|
|
80
|
+
pane.replaceChildren(
|
|
81
|
+
el("div", { className: "card" }, [
|
|
82
|
+
el("h3", { textContent: s.name + "'s identity" }),
|
|
83
|
+
appPanel({ staff: s.handle, name: s.name, scope: "private" }),
|
|
84
|
+
appPanel({ staff: s.handle, name: s.name, scope: "public" }),
|
|
85
|
+
]),
|
|
86
|
+
);
|
|
87
|
+
pane.scrollIntoView?.({ behavior: "smooth", block: "start" });
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
d.append(el("div", { className: "row", style: "margin-top:10px" }, [write, app, go, status]));
|
|
91
|
+
return d;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/* ------------------------------- hiring -------------------------------- */
|
|
95
|
+
|
|
96
|
+
function hireForm(host) {
|
|
97
|
+
const box = el("div", { className: "card" });
|
|
98
|
+
box.append(
|
|
99
|
+
el("h3", { textContent: "Hire someone" }),
|
|
100
|
+
el("p", {
|
|
101
|
+
className: "sub",
|
|
102
|
+
style: "margin-bottom:14px",
|
|
103
|
+
textContent:
|
|
104
|
+
"Only the handle is required. Everything else is copied from whoever is already " +
|
|
105
|
+
"here, because app slugs carry a house naming scheme and the public identity is " +
|
|
106
|
+
"genuinely shared.",
|
|
107
|
+
}),
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
const handle = field("handle", "cfo", "lowercase, digits and dashes");
|
|
111
|
+
const name = field("name", "Chief Financial Officer", "defaults to the handle, uppercased");
|
|
112
|
+
const dir = field("dir", "finance", "directory and repo name; defaults to the handle");
|
|
113
|
+
const schedule = field("schedule", "0 9 * * 1-5", "defaults to a slot clear of everyone else");
|
|
114
|
+
for (const f of [handle, name, dir, schedule]) box.append(f.row);
|
|
115
|
+
|
|
116
|
+
const status = el("span", { className: "meta" });
|
|
117
|
+
const plan = el("button", { className: "ghbtn primary", textContent: "Show the plan" });
|
|
118
|
+
const cancel = el("button", { className: "ghbtn", textContent: "Cancel" });
|
|
119
|
+
cancel.onclick = () => host.replaceChildren();
|
|
120
|
+
box.append(el("div", { className: "row", style: "margin-top:12px" }, [plan, cancel, status]));
|
|
121
|
+
|
|
122
|
+
const out = el("div");
|
|
123
|
+
box.append(out);
|
|
124
|
+
host.replaceChildren(box);
|
|
125
|
+
handle.input.focus();
|
|
126
|
+
|
|
127
|
+
plan.onclick = async () => {
|
|
128
|
+
if (!handle.input.value.trim()) {
|
|
129
|
+
handle.input.focus();
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
status.textContent = "planning…";
|
|
133
|
+
status.className = "meta";
|
|
134
|
+
out.replaceChildren();
|
|
135
|
+
const params = new URLSearchParams({ handle: handle.input.value.trim() });
|
|
136
|
+
for (const [key, f] of [["name", name], ["dir", dir], ["schedule", schedule]]) {
|
|
137
|
+
if (f.input.value.trim()) params.set(key, f.input.value.trim());
|
|
138
|
+
}
|
|
139
|
+
try {
|
|
140
|
+
const data = await (await fetch("/api/staff/plan?" + params, { cache: "no-store" })).json();
|
|
141
|
+
if (data.error) throw new Error(data.error);
|
|
142
|
+
status.textContent = "";
|
|
143
|
+
out.replaceChildren(hirePlan(data.plan, params));
|
|
144
|
+
} catch (e) {
|
|
145
|
+
status.textContent = e.message;
|
|
146
|
+
status.className = "meta err";
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function hirePlan(plan, params) {
|
|
152
|
+
const box = el("div", { className: "plan" });
|
|
153
|
+
box.append(el("div", { className: "planhead", textContent: "This would create" }));
|
|
154
|
+
|
|
155
|
+
const s = plan.staff;
|
|
156
|
+
box.append(
|
|
157
|
+
line("ok", s.brain + ", private"),
|
|
158
|
+
line("ok", plan.files.length + " files, including three caller workflows"),
|
|
159
|
+
line("ok", plan.labels.length + " labels: " + plan.labels.join(", ")),
|
|
160
|
+
line("ok", "a pinned status issue"),
|
|
161
|
+
line("ok", "runs at " + s.schedule + ", on " + s.model),
|
|
162
|
+
);
|
|
163
|
+
for (const p of plan.peers) {
|
|
164
|
+
box.append(line("ok", p.dir + "/staff.yaml gains a peer entry and a " + p.label + " label"));
|
|
165
|
+
}
|
|
166
|
+
box.append(line("ok", "org.yaml gains a staff entry and a repo entry"));
|
|
167
|
+
|
|
168
|
+
if (plan.secrets?.length) {
|
|
169
|
+
box.append(el("div", { className: "planhead", textContent: "You will still have to" }));
|
|
170
|
+
box.append(
|
|
171
|
+
line("todo", "create the GitHub App: roster app " + s.handle),
|
|
172
|
+
line("todo", "put " + plan.secrets.join(", ") + " on the new repo"),
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
for (const w of plan.warnings ?? []) box.append(line("warn", w));
|
|
176
|
+
|
|
177
|
+
const status = el("span", { className: "meta" });
|
|
178
|
+
const go = el("button", { className: "ghbtn primary", textContent: "Hire " + s.handle });
|
|
179
|
+
go.onclick = () =>
|
|
180
|
+
apply(
|
|
181
|
+
{
|
|
182
|
+
action: "hire",
|
|
183
|
+
handle: s.handle,
|
|
184
|
+
flags: Object.fromEntries(params),
|
|
185
|
+
},
|
|
186
|
+
"Create " + s.brain + " and wire it up?\n\nThis creates a repository on GitHub.",
|
|
187
|
+
go,
|
|
188
|
+
status,
|
|
189
|
+
box,
|
|
190
|
+
);
|
|
191
|
+
box.append(el("div", { className: "row", style: "margin-top:14px" }, [go, status]));
|
|
192
|
+
return box;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/* ------------------------------- retiring ------------------------------- */
|
|
196
|
+
|
|
197
|
+
async function retireForm(host, s, cardStatus) {
|
|
198
|
+
cardStatus.textContent = "planning…";
|
|
199
|
+
cardStatus.className = "meta";
|
|
200
|
+
let data;
|
|
201
|
+
try {
|
|
202
|
+
data = await (
|
|
203
|
+
await fetch("/api/staff/plan?action=retire&handle=" + encodeURIComponent(s.handle), {
|
|
204
|
+
cache: "no-store",
|
|
205
|
+
})
|
|
206
|
+
).json();
|
|
207
|
+
if (data.error) throw new Error(data.error);
|
|
208
|
+
} catch (e) {
|
|
209
|
+
cardStatus.textContent = e.message;
|
|
210
|
+
cardStatus.className = "meta err";
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
cardStatus.textContent = "";
|
|
214
|
+
|
|
215
|
+
const plan = data.plan;
|
|
216
|
+
const box = el("div", { className: "plan" });
|
|
217
|
+
box.append(el("h3", { textContent: "Retire " + plan.name + "?" }));
|
|
218
|
+
box.append(el("div", { className: "planhead", textContent: "This would stop" }));
|
|
219
|
+
for (const w of plan.workflows) box.append(line("stop", plan.dir + "/" + w + " disabled"));
|
|
220
|
+
box.append(line("stop", "removed from org.yaml"));
|
|
221
|
+
for (const p of plan.peers) {
|
|
222
|
+
box.append(
|
|
223
|
+
line("stop", "removed from " + p.dir + "/staff.yaml"),
|
|
224
|
+
line("stop", p.label + " deleted from " + (p.brain ?? p.dir)),
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/* The point of retiring rather than deleting. Said as loudly as what it stops, because
|
|
229
|
+
"this is not destructive" is the thing somebody needs to believe before clicking. */
|
|
230
|
+
box.append(el("div", { className: "planhead keep", textContent: "This would keep" }));
|
|
231
|
+
for (const k of plan.keeps) box.append(line("keep", k));
|
|
232
|
+
for (const w of plan.warnings ?? []) box.append(line("warn", w));
|
|
233
|
+
|
|
234
|
+
const status = el("span", { className: "meta" });
|
|
235
|
+
const go = el("button", { className: "ghbtn", textContent: "Retire " + plan.handle });
|
|
236
|
+
const cancel = el("button", { className: "ghbtn", textContent: "Cancel" });
|
|
237
|
+
cancel.onclick = () => host.replaceChildren();
|
|
238
|
+
go.onclick = () =>
|
|
239
|
+
apply(
|
|
240
|
+
{ action: "retire", handle: plan.handle },
|
|
241
|
+
"Retire " + plan.name + "?\n\n" + (plan.brain ?? plan.dir) + " is not touched.",
|
|
242
|
+
go,
|
|
243
|
+
status,
|
|
244
|
+
box,
|
|
245
|
+
);
|
|
246
|
+
box.append(el("div", { className: "row", style: "margin-top:14px" }, [go, cancel, status]));
|
|
247
|
+
host.replaceChildren(box);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/* -------------------------------- apply -------------------------------- */
|
|
251
|
+
|
|
252
|
+
async function apply(payload, ask, btn, status, box) {
|
|
253
|
+
if (!confirm(ask)) return;
|
|
254
|
+
btn.disabled = true;
|
|
255
|
+
status.textContent = "working…";
|
|
256
|
+
status.className = "meta";
|
|
257
|
+
try {
|
|
258
|
+
const r = await post(payload, "/api/staff/apply");
|
|
259
|
+
status.textContent = r.ok ? "done" : "finished with problems";
|
|
260
|
+
status.className = r.ok ? "meta ok" : "meta warn";
|
|
261
|
+
// What the terminal would have printed, because that is the useful account of it.
|
|
262
|
+
if (r.output) box.append(el("pre", { className: "code planout", textContent: r.output }));
|
|
263
|
+
await refreshAll(true);
|
|
264
|
+
} catch (e) {
|
|
265
|
+
status.textContent = e.message;
|
|
266
|
+
status.className = "meta err";
|
|
267
|
+
btn.disabled = false;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function field(label, placeholder, hint) {
|
|
273
|
+
const input = el("input", { type: "search", placeholder, value: "", style: "width:100%" });
|
|
274
|
+
input.dataset.field = label;
|
|
275
|
+
const row = el("div", { className: "ffield" });
|
|
276
|
+
row.append(
|
|
277
|
+
el("label", { textContent: label }),
|
|
278
|
+
input,
|
|
279
|
+
el("small", { textContent: hint }),
|
|
280
|
+
);
|
|
281
|
+
return { row, input };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const MARK = { ok: "check", stop: "closed", keep: "issue-open", todo: "dash", warn: "label" };
|
|
285
|
+
|
|
286
|
+
function line(kind, text) {
|
|
287
|
+
const d = el("div", { className: "planline " + kind });
|
|
288
|
+
d.append(icon(MARK[kind] ?? "dot", "ic"), el("span", { textContent: text }));
|
|
289
|
+
return d;
|
|
290
|
+
}
|