@nanocollective/roster 0.1.0-alpha.5 → 0.1.0-alpha.50

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 (102) hide show
  1. package/README.md +70 -84
  2. package/dist/cli.js +4833 -2752
  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/analyst.md +65 -0
  7. package/docs/charters/cmo.md +69 -0
  8. package/docs/charters/community.md +63 -0
  9. package/docs/charters/cto.md +71 -0
  10. package/docs/charters/designer.md +65 -0
  11. package/docs/charters/devops.md +65 -0
  12. package/docs/charters/pm.md +70 -0
  13. package/docs/charters/qa.md +65 -0
  14. package/docs/charters/support.md +60 -0
  15. package/docs/charters/writer.md +63 -0
  16. package/docs/commands.md +93 -7
  17. package/docs/concepts.md +61 -14
  18. package/docs/cost.md +36 -1
  19. package/docs/developing.md +16 -21
  20. package/docs/doctor-codes.md +10 -2
  21. package/docs/export.md +2 -0
  22. package/docs/extending.md +2 -2
  23. package/docs/getting-started.md +128 -78
  24. package/docs/images/brain.jpg +0 -0
  25. package/docs/images/org.jpg +0 -0
  26. package/docs/images/prompt.jpg +0 -0
  27. package/docs/images/setup-org.jpg +0 -0
  28. package/docs/images/setup-plan.jpg +0 -0
  29. package/docs/images/staff.jpg +0 -0
  30. package/docs/manual-steps.md +94 -123
  31. package/docs/memory.md +21 -3
  32. package/docs/org-yaml.md +40 -2
  33. package/docs/portal.md +165 -48
  34. package/docs/prompts.md +31 -4
  35. package/docs/security.md +37 -5
  36. package/docs/session-workflow.md +63 -17
  37. package/docs/staff-yaml.md +30 -3
  38. package/docs/troubleshooting.md +8 -8
  39. package/docs/upgrading.md +9 -3
  40. package/docs/writing-a-charter.md +28 -0
  41. package/package.json +18 -20
  42. package/templates/brain/.github/workflows/%%STAFF%%-daily.yaml +26 -1
  43. package/templates/brain/.github/workflows/%%STAFF%%-mention.yaml +41 -7
  44. package/templates/brain/CHARTER.md +3 -3
  45. package/templates/brain/README.md +1 -0
  46. package/templates/brain/log/decisions.md +3 -0
  47. package/templates/brain/staff.yaml +4 -1
  48. package/templates/brain/strategy/ideas.md +7 -0
  49. package/templates/briefs/priorities.md +46 -0
  50. package/templates/ops/.github/workflows/session.yaml +236 -15
  51. package/templates/ops/agents.mjs +7 -3
  52. package/templates/ops/compose.mjs +31 -3
  53. package/templates/ops/inflight.mjs +157 -0
  54. package/templates/ops/org/operating.md +43 -4
  55. package/templates/ops/org/voice.md +9 -0
  56. package/templates/ops/prompts/_inflight.md +14 -0
  57. package/templates/ops/prompts/_paths.md +2 -1
  58. package/templates/ops/prompts/daily.md +37 -9
  59. package/templates/ops/prompts/mention.md +21 -0
  60. package/templates/ops/run-record.mjs +146 -0
  61. package/templates/portal/css/base.css +167 -73
  62. package/templates/portal/css/brain.css +23 -20
  63. package/templates/portal/css/diff.css +10 -9
  64. package/templates/portal/css/graph.css +12 -7
  65. package/templates/portal/css/health.css +13 -11
  66. package/templates/portal/css/home.css +93 -0
  67. package/templates/portal/css/inbox.css +45 -25
  68. package/templates/portal/css/layout.css +90 -46
  69. package/templates/portal/css/markdown.css +36 -14
  70. package/templates/portal/css/runs.css +13 -0
  71. package/templates/portal/css/setup.css +116 -34
  72. package/templates/portal/index.html +21 -9
  73. package/templates/portal/js/api.js +44 -4
  74. package/templates/portal/js/app.js +94 -9
  75. package/templates/portal/js/dialog.js +83 -0
  76. package/templates/portal/js/homesort.js +174 -0
  77. package/templates/portal/js/icons.js +37 -0
  78. package/templates/portal/js/inflight.js +18 -0
  79. package/templates/portal/js/md.js +5 -2
  80. package/templates/portal/js/mdedit.js +84 -0
  81. package/templates/portal/js/readiness.js +70 -0
  82. package/templates/portal/js/refresh.js +10 -2
  83. package/templates/portal/js/state.js +11 -5
  84. package/templates/portal/js/views/app.js +24 -7
  85. package/templates/portal/js/views/brain.js +1 -1
  86. package/templates/portal/js/views/checklist.js +10 -4
  87. package/templates/portal/js/views/credential.js +84 -0
  88. package/templates/portal/js/views/graph.js +1 -1
  89. package/templates/portal/js/views/health.js +17 -4
  90. package/templates/portal/js/views/hire.js +593 -0
  91. package/templates/portal/js/views/home.js +546 -0
  92. package/templates/portal/js/views/inbox.js +226 -70
  93. package/templates/portal/js/views/org.js +46 -106
  94. package/templates/portal/js/views/orgedit.js +234 -0
  95. package/templates/portal/js/views/paste.js +87 -21
  96. package/templates/portal/js/views/prompt.js +11 -4
  97. package/templates/portal/js/views/repos.js +20 -15
  98. package/templates/portal/js/views/runonce.js +94 -0
  99. package/templates/portal/js/views/runs.js +170 -0
  100. package/templates/portal/js/views/setup.js +261 -75
  101. package/templates/portal/js/views/staff.js +170 -243
  102. package/templates/portal/js/views/todo.js +62 -0
@@ -0,0 +1,593 @@
1
+ /* Hiring from the Staff screen: pick a role, then one numbered list per staff member.
2
+ *
3
+ * The plan and the hire are the CLI's own `buildPlan` and `applyPlan`, run on the server, so
4
+ * the browser never describes how to create a repo a second time. Every step's Done is read
5
+ * from something real where the portal has it: the export for the hire and the charter,
6
+ * GitHub for the App secrets and the first run, and the credential panel's own check. A step
7
+ * is never marked done because a button was clicked. */
8
+
9
+ import { getCharterExample, getCredential, getRepos, getStaffProgress, post } from "../api.js";
10
+ import { askYes } from "../dialog.js";
11
+ import { el } from "../dom.js";
12
+ import { icon } from "../icons.js";
13
+ import { refreshAll } from "../refresh.js";
14
+ import { S } from "../state.js";
15
+ import { appPanel } from "./app.js";
16
+ import { credentialPanel } from "./credential.js";
17
+ import { cronText } from "./health.js";
18
+ import { modes, rawMode } from "./orgedit.js";
19
+ import { paste } from "./paste.js";
20
+ import { runOnce } from "./runonce.js";
21
+ import { todo } from "./todo.js";
22
+
23
+ const ROLES = [
24
+ {
25
+ handle: "cto",
26
+ label: "CTO",
27
+ name: "Chief Technology Officer",
28
+ about: "Builds the product: fixes, features and tests, as pull requests for you to review.",
29
+ },
30
+ {
31
+ handle: "cmo",
32
+ label: "CMO",
33
+ name: "Chief Marketing Officer",
34
+ about: "Marketing: posts, SEO, copy and launch plans, as drafts for you to approve.",
35
+ },
36
+ {
37
+ handle: "support",
38
+ label: "Support",
39
+ name: "Head of Support",
40
+ about: "Answers issues, writes help docs, and turns user reports into bugs.",
41
+ },
42
+ {
43
+ handle: "pm",
44
+ label: "Product manager",
45
+ name: "Product Manager",
46
+ about: "Turns ideas and user feedback into clear specs and a ranked backlog.",
47
+ },
48
+ {
49
+ handle: "designer",
50
+ label: "Designer",
51
+ name: "Designer",
52
+ about: "Improves the product's look and usability, with accessibility fixes, as pull requests.",
53
+ },
54
+ {
55
+ handle: "qa",
56
+ label: "QA",
57
+ name: "QA Engineer",
58
+ about: "Tests the product, finds bugs, and writes clear reproductions and tests.",
59
+ },
60
+ {
61
+ handle: "devops",
62
+ label: "DevOps",
63
+ name: "DevOps Engineer",
64
+ about: "Keeps CI, deploys and dependencies healthy, and patches security updates.",
65
+ },
66
+ {
67
+ handle: "writer",
68
+ label: "Writer",
69
+ name: "Technical Writer",
70
+ about: "Writes and keeps up the docs, guides and changelog.",
71
+ },
72
+ {
73
+ handle: "analyst",
74
+ label: "Analyst",
75
+ name: "Data Analyst",
76
+ about: "Reads the numbers and writes a short weekly report on what changed.",
77
+ },
78
+ {
79
+ handle: "community",
80
+ label: "Community",
81
+ name: "Community Manager",
82
+ about: "Answers discussions, welcomes contributors, and drafts release announcements.",
83
+ },
84
+ ];
85
+
86
+ /**
87
+ * The roles as cards. Each has a worked example, and is offered until it is hired;
88
+ * Something else asks for a name and a sentence.
89
+ * @param {{onPick: (role: {handle: string, name: string, dir: string, about?: string}) => void}} o
90
+ */
91
+ export function rolePicker(o) {
92
+ const hired = new Set(S.data.staff.map((s) => s.handle));
93
+ const box = el("div");
94
+ const grid = el("div", { className: "roles" });
95
+ for (const r of ROLES.filter((r) => !hired.has(r.handle))) {
96
+ const b = roleCard(r.label, r.about);
97
+ b.dataset.role = r.handle;
98
+ b.onclick = () => o.onPick({ handle: r.handle, name: r.name, dir: r.handle });
99
+ grid.append(b);
100
+ }
101
+
102
+ const other = roleCard("Something else", "Name the role and say what they do.");
103
+ other.dataset.role = "other";
104
+ grid.append(other);
105
+
106
+ const name = el("input", { type: "text", placeholder: "e.g. Head of Finance" });
107
+ name.dataset.field = "role name";
108
+ const about = el("textarea", { className: "pastebox", rows: 2, placeholder: "e.g. Tracks spending and prepares the monthly accounts." });
109
+ about.dataset.field = "role about";
110
+ const go = el("button", { className: "btn primary", textContent: "Continue" });
111
+ const err = el("p", { className: "err", hidden: true });
112
+ const form = el("div", { className: "roleform", hidden: true }, [
113
+ el("label", { className: "qfield" }, [el("span", { textContent: "Role" }), name]),
114
+ el("label", { className: "qfield" }, [el("span", { textContent: "What do they do? One sentence." }), about]),
115
+ el("div", { className: "row" }, [go]),
116
+ err,
117
+ ]);
118
+ other.onclick = () => {
119
+ form.hidden = false;
120
+ name.focus?.();
121
+ };
122
+ go.onclick = () => {
123
+ const title = String(name.value ?? "").trim();
124
+ const handle = handleFor(title);
125
+ err.hidden = true;
126
+ if (!title || !handle) {
127
+ err.textContent = "Give the role a name.";
128
+ err.hidden = false;
129
+ name.focus?.();
130
+ return;
131
+ }
132
+ if (hired.has(handle)) {
133
+ err.textContent = "Someone called " + handle + " is already hired. Pick another name.";
134
+ err.hidden = false;
135
+ return;
136
+ }
137
+ o.onPick({ handle, name: title, dir: handle, about: String(about.value ?? "").trim() });
138
+ };
139
+
140
+ box.append(grid, form);
141
+ return box;
142
+ }
143
+
144
+ function roleCard(label, about) {
145
+ return el("button", { className: "role" }, [el("b", { textContent: label }), el("span", { textContent: about })]);
146
+ }
147
+
148
+ /** "Head of Finance" → "head-of-finance", inside what `hire` accepts as a handle. */
149
+ function handleFor(name) {
150
+ let h = String(name)
151
+ .toLowerCase()
152
+ .replace(/[^a-z0-9]+/g, "-")
153
+ .replace(/^-+|-+$/g, "")
154
+ .slice(0, 21)
155
+ .replace(/-+$/, "");
156
+ if (h && !/^[a-z]/.test(h)) h = ("staff-" + h).slice(0, 21).replace(/-+$/, "");
157
+ return /^[a-z][a-z0-9-]{1,20}$/.test(h) ? h : "";
158
+ }
159
+
160
+ /**
161
+ * The steps for one staff member, hired or about to be.
162
+ * @param {{
163
+ * role: {handle: string, name: string, dir: string, about?: string},
164
+ * onHired: (handle: string, result: object) => void,
165
+ * onClose: () => void,
166
+ * result?: {ok: boolean, output?: string},
167
+ * }} o `result` is what the hire said, shown once when it had problems.
168
+ */
169
+ export function hireFlow(o) {
170
+ const role = o.role;
171
+ const staff = () => S.data.staff.find((s) => s.handle === role.handle);
172
+ const s = staff();
173
+ const hired = Boolean(s);
174
+ const name = s?.name ?? role.name;
175
+
176
+ const box = el("div", { className: "hireflow" });
177
+ const close = el("button", { className: "ghbtn", textContent: "Close" });
178
+ close.onclick = o.onClose;
179
+ box.append(
180
+ el("div", { className: "row hireflowhead" }, [el("h2", { textContent: name }), close]),
181
+ );
182
+ const list = el("div", { className: "todos" });
183
+ box.append(list);
184
+
185
+ const hire = todo(1, "Hire", hired);
186
+ const app = todo(2, "Create and install their GitHub App", false);
187
+ const charter = todo(3, "Write their charter", hired && !s.rig?.charterStub);
188
+ const cred = todo(4, "Add your agent credential", false);
189
+ const run = todo(5, "Run once now", false);
190
+ list.append(hire.card, app.card, charter.card, cred.card, run.card);
191
+
192
+ /* The credential is once for the org. Stored already, it is not a step for this person. */
193
+ let credSeen = false;
194
+ const credStatus = (ok) => {
195
+ if (!credSeen && ok) {
196
+ cred.card.remove();
197
+ run.renumber(4);
198
+ } else cred.setDone(ok);
199
+ credSeen = true;
200
+ };
201
+
202
+ if (!hired) {
203
+ hireStep(hire.body, role, o.onHired);
204
+ for (const t of [app, charter, cred, run]) t.setLocked("Hire first");
205
+ getCredential()
206
+ .then((c) => credStatus(Boolean(c.stored ?? c.orgSecret)))
207
+ .catch(() => {});
208
+ return box;
209
+ }
210
+
211
+ hire.body.append(el("p", { className: "sub", textContent: "Hired. Their repo is " + (s.brain ?? s.dir) + "." }));
212
+ if (o.result && !o.result.ok) {
213
+ hire.setDone(false);
214
+ hire.body.append(el("p", { className: "err", textContent: "The hire finished with problems. Read the output below." }));
215
+ }
216
+ if (o.result?.output) {
217
+ const out = el("details", { className: "planfiles" }, [
218
+ el("summary", { textContent: "Output" }),
219
+ el("pre", { className: "code planout", textContent: o.result.output }),
220
+ ]);
221
+ out.open = !o.result.ok;
222
+ hire.body.append(out);
223
+ }
224
+
225
+ /* 2 · the App. The public identity only matters when a product repo is public, and hire
226
+ treats one with no visibility written as public, so this does too. */
227
+ /* Done means installed, not created: an App with its keys saved and no install looked
228
+ finished, and every run would have failed. */
229
+ let needPublic = false;
230
+ const made = { private: false, public: false };
231
+ const appDone = () => app.setDone(made.private && (!needPublic || made.public));
232
+ const panels = el("div");
233
+ const installNote = el("p", { className: "warnline", hidden: true });
234
+ const recheck = el("button", { className: "ghbtn", textContent: "Check again" });
235
+ recheck.onclick = () => checkProgress();
236
+ app.body.append(panels, installNote, el("div", { className: "row" }, [recheck]));
237
+ panels.append(appPanel({ staff: s.handle, name: s.name, scope: "private", onDone: () => checkProgress() }));
238
+ getRepos()
239
+ .then((r) => {
240
+ needPublic = (r.repos ?? []).some(
241
+ (x) => x.role === "product" && String(x.visibility ?? "").toLowerCase() !== "private",
242
+ );
243
+ if (needPublic) {
244
+ app.hint("One of your product repos is public. Public work uses a second App, shared by all staff.");
245
+ panels.append(
246
+ appPanel({ staff: s.handle, name: s.name, scope: "public", onDone: () => checkProgress() }),
247
+ );
248
+ }
249
+ appDone();
250
+ })
251
+ .catch(() => {});
252
+
253
+ /* 3 · the charter. */
254
+ const charterSaved = () =>
255
+ refreshAll(true).then(() => charter.setDone(!staff()?.rig?.charterStub));
256
+ charter.body.append(charterModes(s, role.about, charterSaved));
257
+
258
+ /* 4 · the credential. */
259
+ cred.body.append(credentialPanel({ bare: true, onStatus: credStatus }));
260
+
261
+ /* 5 · the first run. */
262
+ run.body.append(
263
+ runOnce({
264
+ staff: s.handle,
265
+ name: s.name,
266
+ onDone: (ok) => {
267
+ run.setDone(ok);
268
+ if (ok) refreshAll(true);
269
+ },
270
+ }),
271
+ );
272
+
273
+ /* What only GitHub knows: whether the App is installed, whether the credential is stored,
274
+ and whether a run has succeeded. Asked again by Check again, after a trip to GitHub. */
275
+ function checkProgress() {
276
+ return getStaffProgress(s.handle, true)
277
+ .then((p) => {
278
+ made.private = p.installed === true;
279
+ made.public = p.publicInstalled === true;
280
+ appDone();
281
+ installNote.hidden = !(p.app && p.installed === false);
282
+ installNote.textContent =
283
+ "Created, but not installed yet. Press Install, and choose All repositories or include roster-ops.";
284
+ if (p.installed && p.installSelection === "selected") {
285
+ installNote.hidden = false;
286
+ installNote.textContent =
287
+ "Installed on selected repos. Make sure roster-ops is one of them, or runs will fail.";
288
+ }
289
+ if (p.ran) run.setDone(true);
290
+ })
291
+ .catch(() => {});
292
+ }
293
+ checkProgress();
294
+
295
+ return box;
296
+ }
297
+
298
+ /** Three ways to write CHARTER.md. The template is offered only when a worked example fits. */
299
+ function charterModes(s, about, onSaved) {
300
+ const box = el("div", {}, [el("p", { className: "sub", textContent: "Loading…" })]);
301
+ const path = s.dir + "/CHARTER.md";
302
+ getCharterExample(s.handle)
303
+ .catch(() => ({ kind: null }))
304
+ .then((ex) => {
305
+ const tabs = [
306
+ {
307
+ label: "Let an AI interview you",
308
+ node: paste({ kind: "charter", staff: s.handle, title: "", about, onSaved }),
309
+ },
310
+ ];
311
+ if (ex?.kind) {
312
+ tabs.push(
313
+ rawMode(path, onSaved, "md", {
314
+ label: "Start from the template",
315
+ note: "Edit this so it fits your business before saving.",
316
+ read: async () => ex.text,
317
+ }),
318
+ );
319
+ }
320
+ tabs.push(rawMode(path, onSaved));
321
+ box.replaceChildren(modes(tabs));
322
+ });
323
+ return box;
324
+ }
325
+
326
+ /* ---------------------------------- step 1 ---------------------------------- */
327
+
328
+ function hireStep(host, role, onHired) {
329
+ const first = !S.data.staff.length;
330
+ const org = S.data.org;
331
+
332
+ const summary = el("p", { className: "hiresummary", textContent: "Reading the plan…" });
333
+ const details = el("div", { className: "hiredetails", hidden: true });
334
+ const showDetails = el("button", { className: "ghbtn", textContent: "Show details" });
335
+ showDetails.onclick = () => {
336
+ details.hidden = !details.hidden;
337
+ showDetails.textContent = details.hidden ? "Show details" : "Hide details";
338
+ };
339
+
340
+ const handle = field("handle", role.handle, "Lowercase letters, digits and dashes.");
341
+ const name = field("name", role.name, "The role's full name.");
342
+ const dir = field("dir", role.dir, "The repo name.");
343
+ const schedule = scheduleField();
344
+ const fields = [
345
+ ["name", name],
346
+ ["dir", dir],
347
+ ["schedule", schedule],
348
+ ];
349
+ /* App names are copied from whoever is already here. The first hire has nobody to copy
350
+ from, so it names both, and later hires follow the pattern. */
351
+ let app = null;
352
+ if (first) {
353
+ app = field("app", org + "-" + role.handle, "This staff member's GitHub App. App names are unique across GitHub.");
354
+ const pub = field("public app", org + "-robot", "The App all staff share for public product repos.");
355
+ fields.push(["app", app], ["publicApp", pub]);
356
+ /* Only shown when a product repo is public, which is the only time the shared App is
357
+ created. Hidden, it still sends the same default, so nothing changes underneath. */
358
+ pub.row.hidden = true;
359
+ getRepos()
360
+ .then((r) => {
361
+ pub.row.hidden = !(r.repos ?? []).some(
362
+ (x) => x.role === "product" && String(x.visibility ?? "").toLowerCase() !== "private",
363
+ );
364
+ })
365
+ .catch(() => {});
366
+ }
367
+
368
+ const advanced = el("div", { className: "hireadvanced", hidden: true }, [
369
+ handle.row,
370
+ ...fields.map(([, f]) => f.row),
371
+ ]);
372
+ const showAdvanced = el("button", { className: "ghbtn", textContent: "Advanced" });
373
+ showAdvanced.onclick = () => {
374
+ advanced.hidden = !advanced.hidden;
375
+ };
376
+
377
+ const status = el("span", { className: "meta" });
378
+ const go = el("button", { className: "btn primary", textContent: "Hire", disabled: true });
379
+
380
+ host.append(
381
+ summary,
382
+ el("div", { className: "row" }, [showDetails, showAdvanced]),
383
+ details,
384
+ advanced,
385
+ el("div", { className: "row" }, [go, status]),
386
+ );
387
+
388
+ const params = () => {
389
+ const p = new URLSearchParams({ handle: val(handle) });
390
+ for (const [key, f] of fields) {
391
+ const v = f.value ? f.value() : val(f);
392
+ if (v) p.set(key, v);
393
+ }
394
+ return p;
395
+ };
396
+
397
+ let plan = null;
398
+ const replan = async () => {
399
+ plan = null;
400
+ go.disabled = true;
401
+ summary.className = "hiresummary";
402
+ summary.textContent = "Reading the plan…";
403
+ try {
404
+ const data = await (await fetch("/api/staff/plan?" + params(), { cache: "no-store" })).json();
405
+ if (data.error) throw new Error(data.error);
406
+ plan = data.plan;
407
+ summary.textContent = summarise(plan);
408
+ details.replaceChildren(planDetails(plan));
409
+ go.disabled = false;
410
+ } catch (e) {
411
+ summary.className = "hiresummary err";
412
+ summary.textContent = e.message;
413
+ details.replaceChildren();
414
+ }
415
+ };
416
+
417
+ /* The App name follows the handle until somebody types their own. */
418
+ let lastHandle = val(handle);
419
+ handle.input.onchange = () => {
420
+ if (app && val(app) === org + "-" + lastHandle) app.input.value = org + "-" + val(handle);
421
+ lastHandle = val(handle);
422
+ replan();
423
+ };
424
+ for (const [, f] of fields) f.input.onchange = replan;
425
+ if (schedule.days) schedule.days.onchange = () => (schedule.say(), replan());
426
+
427
+ go.onclick = async () => {
428
+ if (!plan) return;
429
+ const p = plan.staff;
430
+ const yes = await askYes({
431
+ title: "Create " + p.brain + " and wire it up?",
432
+ hint: "This creates a repository on GitHub.",
433
+ confirm: "Hire " + p.handle,
434
+ });
435
+ if (!yes) return;
436
+ go.disabled = true;
437
+ status.className = "meta";
438
+ status.textContent = "Hiring…";
439
+ let r;
440
+ try {
441
+ r = await post({ action: "hire", handle: p.handle, flags: Object.fromEntries(params()) }, "/api/staff/apply");
442
+ await refreshAll(true);
443
+ } catch (e) {
444
+ status.className = "meta err";
445
+ status.textContent = e.message;
446
+ go.disabled = false;
447
+ return;
448
+ }
449
+ if (S.data.staff.some((x) => x.handle === p.handle)) {
450
+ onHired(p.handle, r);
451
+ return;
452
+ }
453
+ status.className = "meta err";
454
+ status.textContent = "The hire did not finish.";
455
+ if (r.output) host.append(el("pre", { className: "code planout", textContent: r.output }));
456
+ go.disabled = false;
457
+ };
458
+
459
+ replan();
460
+ }
461
+
462
+ /** One plain sentence from the plan. */
463
+ function summarise(plan) {
464
+ const s = plan.staff;
465
+ return "Creates a private repo " + s.brain + ", its workflows and a pinned status issue. " + runsAt(s.schedule);
466
+ }
467
+
468
+ const DAY_NAMES = ["Sundays", "Mondays", "Tuesdays", "Wednesdays", "Thursdays", "Fridays", "Saturdays"];
469
+
470
+ /** "0 8 * * 1-5" → "Runs weekdays at 08:00 UTC." */
471
+ function runsAt(cron) {
472
+ const p = String(cron ?? "").trim().split(/\s+/);
473
+ if (p.length !== 5 || !/^\d+$/.test(p[0]) || !/^\d+$/.test(p[1]) || p[2] !== "*" || p[3] !== "*") {
474
+ return "Schedule: " + cron + " (UTC).";
475
+ }
476
+ const at = p[1].padStart(2, "0") + ":" + p[0].padStart(2, "0") + " UTC";
477
+ if (p[4] === "1-5") return "Runs weekdays at " + at + ".";
478
+ if (p[4] === "*") return "Runs every day at " + at + ".";
479
+ const days = p[4].split(",").map((d) => DAY_NAMES[Number(d) % 7]);
480
+ if (days.some((d) => !d)) return "Schedule: " + cron + " (UTC).";
481
+ const said = days.length > 1 ? days.slice(0, -1).join(", ") + " and " + days.at(-1) : days[0];
482
+ return "Runs " + said + " at " + at + ".";
483
+ }
484
+
485
+ /** Everything the plan would do, for Show details. */
486
+ function planDetails(plan) {
487
+ const box = el("div", { className: "plan" });
488
+ const s = plan.staff;
489
+ box.append(el("div", { className: "planhead", textContent: "Creates" }));
490
+ box.append(
491
+ line("ok", s.brain + ", private"),
492
+ line("ok", plan.files.length + " files, including three caller workflows"),
493
+ line("ok", plan.labels.length + " labels: " + plan.labels.join(", ")),
494
+ line("ok", "a pinned status issue"),
495
+ line("ok", "runs at " + cronText(s.schedule) + ", on " + s.model),
496
+ );
497
+ for (const p of plan.peers) box.append(line("ok", p.brain + " gets a " + p.label + " label"));
498
+ /* Commits into repos that already exist, as you. Listed because they are writes to
499
+ somebody else's repo, and a plan that hid them would be the silent kind. */
500
+ if ((plan.commits ?? []).length) {
501
+ box.append(el("div", { className: "planhead", textContent: "Commits and pushes, as you" }));
502
+ for (const c of plan.commits) box.append(line("ok", c.repo + ": " + c.file + ", " + c.why));
503
+ }
504
+ const cred = s.agentSecret ?? "the agent credential";
505
+ box.append(
506
+ plan.orgSecret?.visibility === "selected"
507
+ ? line("ok", s.brain + " is added to the repos that can read the org secret " + cred)
508
+ : plan.orgSecret
509
+ ? line("ok", "the org secret " + cred + " already reaches " + plan.orgSecret.visibility + " repos")
510
+ : line("warn", "no org secret " + cred + " yet. Step 4 adds it."),
511
+ );
512
+ for (const w of plan.warnings ?? []) box.append(line("warn", portalWords(w)));
513
+ return box;
514
+ }
515
+
516
+ /* The plan's warnings are the CLI's, and name its flags. Here the same options are fields
517
+ under Advanced, and a flag the page has no box for reads as a dead end. */
518
+ function portalWords(warning) {
519
+ return warning
520
+ .replace(/pass --public-app\b/, "fill in public app under Advanced")
521
+ .replace(/pass --app\b/, "fill in app under Advanced");
522
+ }
523
+
524
+ const val = (f) => String(f.input.value ?? "").trim();
525
+
526
+ function field(label, value, hint) {
527
+ const input = el("input", { type: "text", value: value ?? "", style: "width:100%" });
528
+ input.dataset.field = label;
529
+ const row = el("div", { className: "ffield" });
530
+ row.append(el("label", { textContent: label }), input, el("small", { textContent: hint }));
531
+ return { row, input };
532
+ }
533
+
534
+ /**
535
+ * When the daily run happens, as a time and a set of days rather than as cron.
536
+ *
537
+ * The value on the wire is still a cron expression, because that is what goes in the workflow
538
+ * and what `roster hire` takes. UTC is said out loud, with the local time beside it: GitHub
539
+ * schedules in UTC.
540
+ */
541
+ function scheduleField() {
542
+ const time = el("input", { type: "time", value: "", step: "60", style: "width:130px" });
543
+ time.dataset.field = "schedule";
544
+ const days = el("select");
545
+ days.append(
546
+ el("option", { value: "1-5", textContent: "Weekdays" }),
547
+ el("option", { value: "*", textContent: "Every day" }),
548
+ el("option", { value: "1", textContent: "Mondays" }),
549
+ el("option", { value: "1,3,5", textContent: "Mon, Wed, Fri" }),
550
+ );
551
+ // Set rather than relying on "the first option is selected", which is true of a rendered
552
+ // <select> and not of one that has only been built.
553
+ days.value = "1-5";
554
+
555
+ const said = el("small");
556
+ const value = () => {
557
+ if (!time.value) return "";
558
+ const [h, m] = time.value.split(":");
559
+ return `${Number(m)} ${Number(h)} * * ${days.value}`;
560
+ };
561
+ const say = () => {
562
+ const cron = value();
563
+ said.textContent = cron ? cronText(cron) + local(time.value) : "Leave it empty to get a free slot.";
564
+ };
565
+ time.oninput = say;
566
+ say();
567
+
568
+ const row = el("div", { className: "ffield" });
569
+ row.append(
570
+ el("label", { textContent: "schedule" }),
571
+ el("div", { className: "row" }, [time, days, el("span", { className: "meta", textContent: "UTC" })]),
572
+ said,
573
+ );
574
+ return { row, input: time, days, value, say };
575
+ }
576
+
577
+ /** What that UTC time is where the person reading it is, when the two differ. */
578
+ function local(hhmm) {
579
+ if (!hhmm) return "";
580
+ const [h, m] = hhmm.split(":").map(Number);
581
+ const at = new Date(Date.UTC(2026, 0, 5, h, m));
582
+ const here = at.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
583
+ return here === hhmm ? "" : " · " + here + " where you are";
584
+ }
585
+
586
+ const MARK = { ok: "check", stop: "closed", keep: "issue-open", todo: "dash", warn: "label" };
587
+
588
+ /** One line of a plan, with its mark. */
589
+ export function line(kind, text) {
590
+ const d = el("div", { className: "planline " + kind });
591
+ d.append(icon(MARK[kind] ?? "dot", "ic"), el("span", { textContent: text }));
592
+ return d;
593
+ }