@nanocollective/roster 0.1.0-alpha.2 → 0.1.0-alpha.4

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 (68) hide show
  1. package/dist/cli.js +848 -164
  2. package/docs/README.md +10 -5
  3. package/docs/agents.md +320 -9
  4. package/docs/commands.md +5 -6
  5. package/docs/concepts.md +27 -9
  6. package/docs/cost.md +3 -2
  7. package/docs/doctor-codes.md +13 -4
  8. package/docs/export.md +2 -1
  9. package/docs/extending.md +11 -2
  10. package/docs/getting-started.md +89 -84
  11. package/docs/images/brain.jpg +0 -0
  12. package/docs/images/org.jpg +0 -0
  13. package/docs/images/prompt.jpg +0 -0
  14. package/docs/images/setup-org.jpg +0 -0
  15. package/docs/images/setup-plan.jpg +0 -0
  16. package/docs/images/staff.jpg +0 -0
  17. package/docs/manual-steps.md +36 -13
  18. package/docs/memory.md +9 -6
  19. package/docs/org-yaml.md +37 -9
  20. package/docs/portal.md +197 -31
  21. package/docs/prompts.md +50 -11
  22. package/docs/security.md +19 -7
  23. package/docs/session-workflow.md +8 -10
  24. package/docs/staff-yaml.md +3 -5
  25. package/docs/troubleshooting.md +17 -14
  26. package/docs/writing-a-charter.md +18 -17
  27. package/package.json +1 -1
  28. package/templates/brain/.github/workflows/%%STAFF%%-daily.yaml +1 -0
  29. package/templates/brain/.github/workflows/%%STAFF%%-mention.yaml +10 -4
  30. package/templates/brain/staff.yaml +0 -1
  31. package/templates/ops/.github/workflows/session.yaml +9 -26
  32. package/templates/ops/agents.mjs +121 -6
  33. package/templates/ops/compose.mjs +61 -4
  34. package/templates/ops/org/operating.md +0 -6
  35. package/templates/ops/prompts/_identity.md +8 -1
  36. package/templates/ops/prompts/mention.md +16 -2
  37. package/templates/portal/css/base.css +122 -8
  38. package/templates/portal/css/brain.css +8 -1
  39. package/templates/portal/css/diff.css +6 -2
  40. package/templates/portal/css/health.css +21 -2
  41. package/templates/portal/css/inbox.css +93 -5
  42. package/templates/portal/css/layout.css +26 -4
  43. package/templates/portal/css/markdown.css +23 -3
  44. package/templates/portal/css/setup.css +11 -6
  45. package/templates/portal/index.html +7 -1
  46. package/templates/portal/js/api.js +33 -0
  47. package/templates/portal/js/app.js +33 -7
  48. package/templates/portal/js/dialog.js +47 -4
  49. package/templates/portal/js/dom.js +25 -0
  50. package/templates/portal/js/icons.js +8 -1
  51. package/templates/portal/js/lightbox.js +273 -0
  52. package/templates/portal/js/md.js +23 -6
  53. package/templates/portal/js/mention.js +264 -0
  54. package/templates/portal/js/refresh.js +136 -6
  55. package/templates/portal/js/state.js +47 -5
  56. package/templates/portal/js/views/checklist.js +20 -7
  57. package/templates/portal/js/views/docs.js +94 -4
  58. package/templates/portal/js/views/files.js +58 -14
  59. package/templates/portal/js/views/health.js +163 -35
  60. package/templates/portal/js/views/inbox.js +882 -96
  61. package/templates/portal/js/views/memory.js +16 -1
  62. package/templates/portal/js/views/org.js +142 -62
  63. package/templates/portal/js/views/prompt.js +50 -63
  64. package/templates/portal/js/views/setup.js +37 -13
  65. package/templates/portal/js/views/staff.js +62 -2
  66. package/templates/portal/js/yaml.js +134 -0
  67. package/templates/brain/.github/workflows/%%STAFF%%-pr-mention.yaml +0 -50
  68. package/templates/ops/prompts/pr-mention.md +0 -57
@@ -13,6 +13,19 @@ import { inline } from "../md.js";
13
13
  import { S } from "../state.js";
14
14
  import { matchesFact } from "./brain.js";
15
15
 
16
+ /**
17
+ * Which of the three kinds of provenance a tag is, as a class the stylesheet knows.
18
+ *
19
+ * A fact is measured, derived, or ruled on by a human — and the third one is written with that
20
+ * human's own marker, `[will]` or `[sam]` or whatever the org chose. The class used to be the
21
+ * marker itself against a stylesheet that knew one word, so every org but the one this was
22
+ * written in lost the "a person decided this" colour, which is the one that matters most.
23
+ */
24
+ export function provenanceKind(provenance) {
25
+ const word = String(provenance).toLowerCase();
26
+ return word === "measured" || word === "derived" ? word : "ruled";
27
+ }
28
+
16
29
  export function showMemory(viewer, s, key, pick) {
17
30
  const section = key.startsWith("mem:") ? key.slice(4) : null;
18
31
  const slug = key.startsWith("fact:") ? key.slice(5) : null;
@@ -61,7 +74,9 @@ export function showMemory(viewer, s, key, pick) {
61
74
  d.innerHTML =
62
75
  '<span class="slug" data-goto="' + esc(f.slug) + '" title="Show just this fact">' +
63
76
  esc(f.slug) + "</span>" +
64
- (f.provenance ? '<span class="tag ' + esc(f.provenance) + '">' + esc(f.provenance) + "</span>" : "") +
77
+ (f.provenance
78
+ ? '<span class="tag ' + provenanceKind(f.provenance) + '">' + esc(f.provenance) + "</span>"
79
+ : "") +
65
80
  (f.note ? '<span class="notelink" data-note="' + esc(f.note) + '">' + esc(f.note) + "</span>" : "") +
66
81
  '<p class="stmt">' + inline(f.statement) + "</p>" +
67
82
  (f.consequence
@@ -1,27 +1,47 @@
1
1
  /* The org layer, in one place.
2
2
  *
3
- * These five files are what every staff member inherits. Editing `org/voice.md` once is the
4
- * whole extensibility story — the thing this arrangement exists to make possible — and until
5
- * now you reached them through a staff member's Prompt screen, which is a strange route to a
6
- * file that belongs to nobody in particular.
3
+ * These files are what every staff member inherits. Editing `org/voice.md` once is the whole
4
+ * extensibility story — the thing this arrangement exists to make possible — and until recently
5
+ * you reached them through a staff member's Prompt screen, which is a strange route to a file
6
+ * that belongs to nobody in particular.
7
+ *
8
+ * The list comes off disk rather than out of this file. It used to be five paths written here,
9
+ * which meant a tenant that added `org/pricing.md` could not open it at all, and one that had
10
+ * not written `org/business.md` yet got "not found" with nothing to do about it. Both are the
11
+ * same mistake: a list of what a tenant *should* have, standing in for what it has.
7
12
  *
8
13
  * `org.yaml` is here too, and is the one file that stops every prompt composing when it is
9
14
  * wrong. The server validates it before writing.
10
15
  */
11
16
 
12
- import { getFile, post } from "../api.js";
13
- import { el, esc, grow, kb } from "../dom.js";
17
+ import { getFile, getOrgLayer, post } from "../api.js";
18
+ import { el, esc, grow, kb, skeleton } from "../dom.js";
19
+ import { icon } from "../icons.js";
14
20
  import { mdlite } from "../md.js";
15
- import { S, writeHash } from "../state.js";
16
-
17
- /* What each file is for, in one line, because a list of five filenames tells you nothing
18
- about which one to open. */
19
- const DOCS = [
20
- ["org.yaml", "Who and what: the humans, the repos, the defaults, which agent runs them.", "yaml"],
21
- ["org/business.md", "What the business actually is. Every prompt is composed on top of it, and it is the file that stops the agents writing generic slop.", "md"],
22
- ["org/operating.md", "The autonomy contract: what they do without asking, what they escalate, how a run starts and ends.", "md"],
23
- ["org/guardrails.md", "The non-negotiables. Every staff member is bound by these and cannot argue with them.", "md"],
24
- ["org/voice.md", "How everything they write reads. Change it once, and every surface changes on the next run.", "md"],
21
+ import { humansOf, S, writeHash } from "../state.js";
22
+ import { yamlPre } from "../yaml.js";
23
+
24
+ /* What the files roster itself ships are for, in one line each. A filename says nothing about
25
+ which one to open; anything a tenant has added of its own falls back to its first heading. */
26
+ const KNOWN = {
27
+ "org.yaml": "Who and what: the humans, the repos, the defaults, which agent runs them.",
28
+ "org/business.md":
29
+ "What the business actually is. Every prompt is composed on top of it, and it is the file that stops the agents writing generic slop.",
30
+ "org/operating.md":
31
+ "The autonomy contract: what they do without asking, what they escalate, how a run starts and ends.",
32
+ "org/guardrails.md":
33
+ "The non-negotiables. Every staff member is bound by these and cannot argue with them.",
34
+ "org/voice.md":
35
+ "How everything they write reads. Change it once, and every surface changes on the next run.",
36
+ "prompts/daily.md": "The whole of a scheduled run, before the fragments are pulled into it.",
37
+ "prompts/mention.md": "What they are sent when you @-mention them.",
38
+ "prompts/_identity.md": "Who they post as, and what wakes you.",
39
+ "prompts/_paths.md": "Where everything is in the runner's checkout.",
40
+ };
41
+
42
+ const GROUPS = [
43
+ ["org", "The org layer", "Inherited by everybody, on their next run."],
44
+ ["prompts", "The prompts", "The runs themselves. Edit these last: they are the framework's."],
25
45
  ];
26
46
 
27
47
  export function viewOrg(m) {
@@ -37,10 +57,20 @@ export function viewOrg(m) {
37
57
  );
38
58
 
39
59
  const facts = el("div", { className: "grid", style: "margin-bottom:18px" });
40
- const human = org.human ?? {};
60
+ const humans = humansOf();
41
61
  facts.append(
42
62
  kv("Organisation", esc(org.org), org.name),
43
- kv("They answer to", esc(human.name ?? human.github ?? "nobody"), human.github ? "@" + human.github : "no github handle in org.yaml"),
63
+ /* Plural on purpose. An org can answer to more than one person, and a card that names one
64
+ of two is worse than one that names neither: it reads as the only one who counts. */
65
+ kv(
66
+ "They answer to",
67
+ humans.length
68
+ ? humans.map((h) => esc(h.name ?? h.github)).join(", ")
69
+ : "nobody",
70
+ humans.length
71
+ ? humans.map((h) => (h.github ? "@" + h.github : "no github handle")).join(" · ")
72
+ : "org.yaml names no human, so nothing can wake an agent",
73
+ ),
44
74
  kv("Staff", String(org.staff.length), org.staff.map((s) => s.handle).join(", ")),
45
75
  );
46
76
  m.append(facts);
@@ -51,30 +81,53 @@ export function viewOrg(m) {
51
81
  split.append(tree, viewer);
52
82
  m.append(split);
53
83
 
54
- const group = el("div", { className: "navgroup" });
55
- group.append(el("div", { className: "ghead", textContent: "The org layer" }));
56
- for (const [path, why] of DOCS) {
57
- const b = el("button", { className: "tfile tlayer", title: why });
58
- b.dataset.key = path;
59
- const t = el("span", { className: "t" });
60
- t.append(
61
- el("span", { className: "lname", textContent: path.replace(/^org\//, "") }),
62
- el("span", { className: "lrepo", textContent: why }),
63
- );
64
- b.append(t);
65
- b.onclick = () => open(path);
66
- group.append(b);
67
- }
68
- tree.append(group);
69
-
70
- /* Declared before the first `open()`, not after: `const` is not hoisted, and putting it
71
- below the call made the first paint fail with "cannot access 'full' before
72
- initialization" rather than rendering anything. */
73
84
  const full = (path) => S.data.opsName + "/" + path;
74
85
 
75
- open(S.orgOpen ?? DOCS[0][0]);
86
+ tree.replaceChildren(...skeleton("row", 6));
87
+ getOrgLayer()
88
+ .then(({ files }) => {
89
+ S.orgFiles = files ?? [];
90
+ paintTree();
91
+ const first = S.orgFiles[0]?.path;
92
+ open(S.orgFiles.some((f) => f.path === S.orgOpen) ? S.orgOpen : first);
93
+ })
94
+ .catch((e) => {
95
+ tree.replaceChildren(el("p", { className: "empty err", textContent: e.message }));
96
+ });
97
+
98
+ function why(file) {
99
+ return KNOWN[file.path] ?? file.title ?? "";
100
+ }
101
+
102
+ function paintTree() {
103
+ tree.replaceChildren();
104
+ if (!S.orgFiles.length) {
105
+ tree.append(el("p", { className: "empty", textContent: "No org files here." }));
106
+ return;
107
+ }
108
+ for (const [id, heading, note] of GROUPS) {
109
+ const mine = S.orgFiles.filter((f) => (f.group ?? "org") === id);
110
+ if (!mine.length) continue;
111
+ const group = el("div", { className: "navgroup" });
112
+ group.append(el("div", { className: "ghead", textContent: heading, title: note }));
113
+ for (const f of mine) {
114
+ const b = el("button", { className: "tfile tlayer", title: why(f) });
115
+ b.dataset.key = f.path;
116
+ const t = el("span", { className: "t" });
117
+ t.append(
118
+ el("span", { className: "lname", textContent: f.path.replace(/^(org|prompts)\//, "") }),
119
+ el("span", { className: "lrepo", textContent: why(f) }),
120
+ );
121
+ b.append(t);
122
+ b.onclick = () => open(f.path);
123
+ group.append(b);
124
+ }
125
+ tree.append(group);
126
+ }
127
+ }
76
128
 
77
129
  function open(path) {
130
+ if (!path) return;
78
131
  S.orgOpen = path;
79
132
  writeHash(false);
80
133
  for (const o of tree.querySelectorAll(".tfile")) {
@@ -84,7 +137,7 @@ export function viewOrg(m) {
84
137
  }
85
138
 
86
139
  async function show(path) {
87
- viewer.replaceChildren(el("p", { className: "empty", textContent: "Loading…" }));
140
+ viewer.replaceChildren(...skeleton("head", 1), ...skeleton("line", 7));
88
141
  let text;
89
142
  try {
90
143
  text = await getFile(full(path));
@@ -97,55 +150,82 @@ export function viewOrg(m) {
97
150
 
98
151
  function render(path, text) {
99
152
  viewer.replaceChildren();
100
- const why = DOCS.find(([p]) => p === path)?.[1] ?? "";
101
- const head = el("div", { className: "row", style: "margin-bottom:4px" });
153
+ const head = el("div", { className: "row filehead" });
102
154
  head.append(el("span", { className: "meta", textContent: full(path) + " · " + kb(text.length) }));
103
- head.append(el("button", { className: "ghbtn", textContent: "Edit", onclick: () => edit(path, text) }));
104
- viewer.append(head, el("p", { className: "factsub", textContent: why }));
105
-
106
- if (path.endsWith(".yaml")) {
107
- viewer.append(el("pre", { className: "code", textContent: text }));
155
+ /* The edit control used to be a word in a row of grey text, which is why "there is no easy
156
+ way to edit the org docs" was a fair thing to say about a screen that could edit them. */
157
+ const edit = el("button", { className: "ghbtn primary editbtn", onclick: () => editor(path, text) });
158
+ edit.append(icon("edit", "ic"), el("span", { textContent: "Edit" }));
159
+ head.append(edit);
160
+ const file = S.orgFiles.find((f) => f.path === path) ?? { path };
161
+ viewer.append(head, el("p", { className: "factsub", textContent: why(file) }));
162
+
163
+ if (path.endsWith(".yaml") || path.endsWith(".yml")) {
164
+ viewer.append(yamlPre(text));
108
165
  return;
109
166
  }
110
167
  viewer.append(el("div", { className: "md doc", innerHTML: mdlite(text) }));
111
168
  }
112
169
 
113
- function edit(path, text) {
170
+ function editor(path, text) {
114
171
  viewer.replaceChildren();
115
- viewer.append(
116
- el("div", { className: "row", style: "margin-bottom:10px" }, [
117
- el("span", { className: "meta", textContent: full(path) }),
118
- ]),
119
- );
172
+ const isYaml = /\.ya?ml$/.test(path);
173
+
174
+ const status = el("span", { className: "meta" });
175
+ const save = el("button", { className: "ghbtn primary", textContent: "Save and commit" });
176
+ const cancel = el("button", { className: "ghbtn", textContent: "Cancel" });
177
+
178
+ const bar = el("div", { className: "row filehead" });
179
+ bar.append(el("span", { className: "meta", textContent: full(path) }), save, cancel, status);
180
+ viewer.append(bar);
120
181
 
121
182
  const ta = el("textarea", { value: text, className: "editor" });
122
183
  viewer.append(ta);
123
184
  grow(ta);
124
185
  ta.addEventListener("input", () => grow(ta));
186
+ // ⌘S is what a person's hands do in a text box. Without it, saving means finding a button
187
+ // above a screenful of textarea you have just scrolled past.
188
+ ta.addEventListener("keydown", (e) => {
189
+ if ((e.metaKey || e.ctrlKey) && e.key === "s") {
190
+ e.preventDefault();
191
+ commit();
192
+ }
193
+ });
194
+ ta.focus?.();
125
195
 
126
- const status = el("span", { className: "meta" });
127
- const save = el("button", { className: "ghbtn primary", textContent: "Save and commit" });
128
- const cancel = el("button", { className: "ghbtn", textContent: "Cancel" });
129
196
  cancel.onclick = () => render(path, text);
130
- viewer.append(el("div", { className: "row", style: "margin-top:10px" }, [save, cancel, status]));
197
+ save.onclick = commit;
198
+
131
199
  viewer.append(
132
200
  el("p", {
133
201
  className: "editnote",
134
202
  textContent:
135
- "Commits to " + S.data.opsName + " and pushes. Picked up by every staff member on their " +
136
- "next run." +
137
- (path.endsWith(".yaml")
203
+ "⌘S saves. Commits to " + S.data.opsName + " and pushes, and every staff member picks " +
204
+ "it up on their next run." +
205
+ (isYaml
138
206
  ? " org.yaml is checked before it is written: bad YAML, or a missing org or name, is refused rather than committed."
139
207
  : ""),
140
208
  }),
141
209
  );
142
210
 
143
- save.onclick = async () => {
211
+ async function commit() {
144
212
  if (ta.value === text) {
145
213
  status.textContent = "nothing changed";
214
+ status.className = "meta";
215
+ return;
216
+ }
217
+ /* Only the manifest asks. A prose edit is one commit to revert and the agents pick it up
218
+ on their next run; org.yaml is the file that can stop every prompt composing, and the
219
+ portal has just told you it validates it. Asking every time taught people to click
220
+ through the question, which is worse than not asking. */
221
+ if (
222
+ isYaml &&
223
+ !confirm(
224
+ "Commit " + full(path) + " and push?\n\nEvery staff member picks this up on their next run.",
225
+ )
226
+ ) {
146
227
  return;
147
228
  }
148
- if (!confirm("Commit " + full(path) + " and push?\n\nEvery staff member picks this up on their next run.")) return;
149
229
  save.disabled = cancel.disabled = true;
150
230
  status.textContent = "committing…";
151
231
  status.className = "meta";
@@ -162,7 +242,7 @@ export function viewOrg(m) {
162
242
  status.className = "meta err";
163
243
  save.disabled = cancel.disabled = false;
164
244
  }
165
- };
245
+ }
166
246
  }
167
247
  }
168
248
 
@@ -14,6 +14,7 @@ import { askText } from "../dialog.js";
14
14
  import { el, esc, grow, kb, toClipboard } from "../dom.js";
15
15
  import { icon } from "../icons.js";
16
16
  import { mdlite } from "../md.js";
17
+ import { render } from "../router.js";
17
18
  import { S, staff, writeHash } from "../state.js";
18
19
  import { diffStat, unifiedDiff } from "../textdiff.js";
19
20
  import { renderDiff } from "./changed.js";
@@ -21,9 +22,53 @@ import { renderDiff } from "./changed.js";
21
22
  const KINDS = [
22
23
  ["daily", "Daily", "the scheduled run"],
23
24
  ["mention", "Mention", "someone typed @them in an issue"],
24
- ["pr-mention", "PR mention", "someone typed @them on a pull request"],
25
25
  ];
26
26
 
27
+ /**
28
+ * Build the paste-ready brief on the server, where compose.mjs lives, and copy it.
29
+ *
30
+ * A finding pre-fills the box rather than skipping it: what it wrote is a starting point, and
31
+ * "and keep it in operating.md" is exactly the sort of thing you want to add.
32
+ *
33
+ * Exported because the findings themselves live on Health now, and the fix for one of them is
34
+ * the same brief whichever screen you asked from.
35
+ */
36
+ export async function copyAmendBrief(handle, kind, want, btn, note) {
37
+ const label = btn.textContent;
38
+ const asked = await askText({
39
+ title: "What do you want changed?",
40
+ hint:
41
+ "This goes at the top of a brief carrying the whole prompt and every file it is " +
42
+ "made of. Say it the way you would say it to a person.",
43
+ value: want,
44
+ placeholder: "stop opening decision issues for anything reversible",
45
+ confirm: "Copy the brief",
46
+ });
47
+ if (!asked) return;
48
+ btn.disabled = true;
49
+ btn.textContent = "building…";
50
+ try {
51
+ const url =
52
+ "/api/amend?staff=" + encodeURIComponent(handle) +
53
+ "&kind=" + encodeURIComponent(kind) +
54
+ "&want=" + encodeURIComponent(asked);
55
+ const text = await (await fetch(url, { cache: "no-store" })).text();
56
+ btn.disabled = false;
57
+ toClipboard(text, btn, label);
58
+ if (note) {
59
+ note.textContent = "paste it into whatever agent you use";
60
+ note.className = "meta";
61
+ }
62
+ } catch (e) {
63
+ btn.disabled = false;
64
+ btn.textContent = label;
65
+ if (note) {
66
+ note.textContent = e.message;
67
+ note.className = "meta err";
68
+ }
69
+ }
70
+ }
71
+
27
72
  export function viewPrompt(m) {
28
73
  const s = staff();
29
74
  if (!S.promptKind) S.promptKind = "daily";
@@ -54,40 +99,7 @@ export function viewPrompt(m) {
54
99
  const note = el("span", { className: "meta" });
55
100
  m.append(el("div", { className: "row", style: "margin-bottom:16px" }, [kind, help, copy, note]));
56
101
 
57
- /* Build the paste-ready brief on the server, where compose.mjs lives, and copy it.
58
- A finding pre-fills the box rather than skipping it: what it wrote is a starting point,
59
- and "and keep it in operating.md" is exactly the sort of thing you want to add. */
60
- async function copyAmend(want, btn) {
61
- const target = btn ?? help;
62
- const label = target.textContent;
63
- const asked = await askText({
64
- title: "What do you want changed?",
65
- hint:
66
- "This goes at the top of a brief carrying the whole prompt and every file it is " +
67
- "made of. Say it the way you would say it to a person.",
68
- value: want,
69
- placeholder: "stop opening decision issues for anything reversible",
70
- confirm: "Copy the brief",
71
- });
72
- if (!asked) return;
73
- target.disabled = true;
74
- target.textContent = "building…";
75
- try {
76
- const url =
77
- "/api/amend?staff=" + encodeURIComponent(s.handle) +
78
- "&kind=" + encodeURIComponent(S.promptKind) +
79
- "&want=" + encodeURIComponent(asked);
80
- const text = await (await fetch(url, { cache: "no-store" })).text();
81
- target.disabled = false;
82
- toClipboard(text, target, label);
83
- note.textContent = "paste it into whatever agent you use";
84
- } catch (e) {
85
- target.disabled = false;
86
- target.textContent = label;
87
- note.textContent = e.message;
88
- note.className = "meta err";
89
- }
90
- }
102
+ const copyAmend = (want, btn) => copyAmendBrief(s.handle, S.promptKind, want, btn ?? help, note);
91
103
 
92
104
  const split = el("div", { className: "split" });
93
105
  const tree = el("div", { className: "tree" });
@@ -140,12 +152,9 @@ export function viewPrompt(m) {
140
152
  for (const l of view.layers) inlined.append(layerRow(l));
141
153
  tree.append(inlined);
142
154
 
143
- if (view.problems?.length) {
144
- const bad = group("Problems", String(view.problems.length));
145
- for (const p of view.problems) bad.append(problemRow(p));
146
- tree.append(bad);
147
- }
148
-
155
+ /* No Problems section. This screen answers "what is sent"; whether what is sent is any
156
+ good is a health question, and it is asked and answered on Health. Having both here
157
+ made the tree beside the prompt half prompt and half complaints. */
149
158
  const named = group("Named, not inlined");
150
159
  named.append(
151
160
  el("p", {
@@ -160,28 +169,6 @@ export function viewPrompt(m) {
160
169
  open(S.promptOpen ?? "composed");
161
170
  }
162
171
 
163
- /* A finding is only half useful. The other half is the sentence that goes into the brief,
164
- which is why every one of them carries a `want`. */
165
- function problemRow(p) {
166
- const b = el("div", { className: "prob " + p.level });
167
- b.append(el("div", { className: "ptitle", textContent: p.title }));
168
- b.append(el("div", { className: "pdetail", textContent: p.detail }));
169
- const fix = el("button", { className: "ghbtn", textContent: "Copy a prompt to fix this" });
170
- fix.onclick = () => copyAmend(p.want, fix);
171
- const row = el("div", { className: "row", style: "margin-top:8px" }, [fix]);
172
- if (p.path) {
173
- row.append(
174
- el("button", {
175
- className: "ghbtn",
176
- textContent: "Open the file",
177
- onclick: () => open(p.path),
178
- }),
179
- );
180
- }
181
- b.append(row);
182
- return b;
183
- }
184
-
185
172
  function group(title, right) {
186
173
  const g = el("div", { className: "navgroup" });
187
174
  const h = el("div", { className: "ghead", textContent: title });
@@ -19,6 +19,11 @@ export async function viewSetup(main) {
19
19
  }
20
20
 
21
21
  function render(main) {
22
+ /* The sidebar is written by boot() before any of this is known. Once a tenant has been
23
+ adopted, leaving it on "no org yet" contradicts the card directly below it. */
24
+ const brand = $("#orgname");
25
+ if (brand) brand.textContent = S.tenant.found ? (S.tenant.org ?? "set up") : "no org yet";
26
+
22
27
  main.replaceChildren();
23
28
  main.append(el("h1", { textContent: "Set up your org" }));
24
29
  main.append(
@@ -156,10 +161,11 @@ function stepOrg(main) {
156
161
  const human = field("human", "Your GitHub login", S.gh.login ?? "", "The person the agents answer to.");
157
162
 
158
163
  card.append(form);
164
+ const agentHead = el("h3", { textContent: "Which coding agent runs a session" });
159
165
 
160
166
  /* The agent. Its id decides the secret name later, so setup can name it exactly rather than
161
167
  saying "it depends on the runner". */
162
- card.append(el("h3", { textContent: "Which coding agent runs a session" }));
168
+ card.append(agentHead);
163
169
  const agents = el("div", { className: "choices" });
164
170
  S.agents.forEach((a, i) => {
165
171
  const opt = el("label", { className: "choice" });
@@ -170,14 +176,13 @@ function stepOrg(main) {
170
176
  agents.append(opt);
171
177
  });
172
178
  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
- );
179
+ const agentNote = el("p", {
180
+ className: "sub",
181
+ textContent:
182
+ "Anything else works too: a runner is an install command, a run command and the name of " +
183
+ "the secret holding its credential. Write those three into org.yaml afterwards.",
184
+ });
185
+ card.append(agentNote);
181
186
 
182
187
  const params = () => ({
183
188
  org: org.value === "__other" ? other.input.value.trim() : org.value,
@@ -200,11 +205,29 @@ function stepOrg(main) {
200
205
  join.hidden = !joining;
201
206
  plan.hidden = joining;
202
207
  if (joining) go.hidden = true;
208
+
209
+ /* An org that already runs roster has answered all of these: its own org.yaml names the
210
+ business, the human and the agent. Leaving them on screen asks for values that will be
211
+ thrown away, and implies the checkout is going to use them. */
212
+ for (const bit of [name.wrap, human.wrap, agentHead, agents, agentNote]) bit.hidden = joining;
203
213
  }
204
214
 
205
215
  join.onclick = async () => {
206
216
  join.disabled = true;
207
- out.replaceChildren(el("p", { className: "sub", textContent: "Cloning…" }));
217
+ /* Cloning an ops repo and every brain took 38 seconds against a real org, behind a word
218
+ that never changed. A static "Cloning…" for that long reads as hung, so this says what
219
+ it is doing and keeps a clock running to show it is alive. */
220
+ const clock = el("p", { className: "sub" });
221
+ out.replaceChildren(clock);
222
+ const started = Date.now();
223
+ const tick = () => {
224
+ const s = Math.round((Date.now() - started) / 1000);
225
+ clock.textContent =
226
+ `Cloning ${existing}: the ops repo and one for each staff member. ${s}s. ` +
227
+ "A first checkout usually takes under a minute.";
228
+ };
229
+ tick();
230
+ const ticking = setInterval(tick, 1000);
208
231
  try {
209
232
  const result = await joinOrg(existing);
210
233
  if (!result.ok) throw new Error(result.error);
@@ -219,6 +242,7 @@ function stepOrg(main) {
219
242
  } catch (err) {
220
243
  out.replaceChildren(el("p", { className: "err", textContent: String(err.message || err) }));
221
244
  } finally {
245
+ clearInterval(ticking);
222
246
  join.disabled = false;
223
247
  }
224
248
  };
@@ -279,12 +303,12 @@ function stepOrg(main) {
279
303
  /* ------------------------- 5 · the two GitHub insists on ------------------------- */
280
304
 
281
305
  function afterCreate() {
282
- const card = step(3, "Two things only you can do", false);
306
+ const card = step(3, "What is left", false);
283
307
  card.append(
284
308
  el("p", {
285
309
  textContent:
286
- "Neither can be automated, and the first one fails in a way that wastes an afternoon if " +
287
- "you skip it.",
310
+ "None of these can be done for you, and the first fails in a way that wastes an " +
311
+ "afternoon if you skip it.",
288
312
  }),
289
313
  );
290
314
 
@@ -15,6 +15,7 @@ import { icon } from "../icons.js";
15
15
  import { refreshAll } from "../refresh.js";
16
16
  import { S } from "../state.js";
17
17
  import { appPanel } from "./app.js";
18
+ import { cronText } from "./health.js";
18
19
  import { paste } from "./paste.js";
19
20
 
20
21
  export function viewStaff(m) {
@@ -110,7 +111,7 @@ export function viewStaff(m) {
110
111
  const handle = field("handle", "cfo", "lowercase, digits and dashes");
111
112
  const name = field("name", "Chief Financial Officer", "defaults to the handle, uppercased");
112
113
  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
+ const schedule = scheduleField();
114
115
  for (const f of [handle, name, dir, schedule]) box.append(f.row);
115
116
 
116
117
  const status = el("span", { className: "meta" });
@@ -134,7 +135,8 @@ export function viewStaff(m) {
134
135
  out.replaceChildren();
135
136
  const params = new URLSearchParams({ handle: handle.input.value.trim() });
136
137
  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
+ const value = (f.value ? f.value() : f.input.value).trim();
139
+ if (value) params.set(key, value);
138
140
  }
139
141
  try {
140
142
  const data = await (await fetch("/api/staff/plan?" + params, { cache: "no-store" })).json();
@@ -281,6 +283,64 @@ function field(label, placeholder, hint) {
281
283
  return { row, input };
282
284
  }
283
285
 
286
+ /**
287
+ * When the daily run happens, as a time and a set of days rather than as cron.
288
+ *
289
+ * The value on the wire is still a cron expression, because that is what goes in the workflow
290
+ * and what `roster hire` takes. But nobody hiring their first staff member knows that
291
+ * `0 9 * * 1-5` is nine in the morning on weekdays, and a field that demands it is a field
292
+ * that gets a wrong answer or an empty one.
293
+ *
294
+ * UTC, said out loud, with the local equivalent beside it: GitHub schedules in UTC, and an
295
+ * agent that starts an hour off twice a year is worse than one you had to think about once.
296
+ */
297
+ function scheduleField() {
298
+ const time = el("input", { type: "time", value: "", step: "60", style: "width:130px" });
299
+ const days = el("select");
300
+ days.append(
301
+ el("option", { value: "1-5", textContent: "Weekdays" }),
302
+ el("option", { value: "*", textContent: "Every day" }),
303
+ el("option", { value: "1", textContent: "Mondays" }),
304
+ el("option", { value: "1,3,5", textContent: "Mon, Wed, Fri" }),
305
+ );
306
+ // Said rather than relying on "the first option is selected", which is true of a rendered
307
+ // <select> and not of one that has only been built.
308
+ days.value = "1-5";
309
+
310
+ const said = el("small");
311
+ const value = () => {
312
+ if (!time.value) return "";
313
+ const [h, m] = time.value.split(":");
314
+ return `${Number(m)} ${Number(h)} * * ${days.value}`;
315
+ };
316
+ const say = () => {
317
+ const cron = value();
318
+ said.textContent = cron
319
+ ? cronText(cron) + local(time.value)
320
+ : "Leave it empty for a slot clear of everyone else.";
321
+ };
322
+ time.oninput = say;
323
+ days.onchange = say;
324
+ say();
325
+
326
+ const row = el("div", { className: "ffield" });
327
+ row.append(
328
+ el("label", { textContent: "schedule" }),
329
+ el("div", { className: "row" }, [time, days, el("span", { className: "meta", textContent: "UTC" })]),
330
+ said,
331
+ );
332
+ return { row, input: time, value };
333
+ }
334
+
335
+ /** What that UTC time is where the person reading it is, when the two differ. */
336
+ function local(hhmm) {
337
+ if (!hhmm) return "";
338
+ const [h, m] = hhmm.split(":").map(Number);
339
+ const at = new Date(Date.UTC(2026, 0, 5, h, m));
340
+ const here = at.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
341
+ return here === hhmm ? "" : " · " + here + " where you are";
342
+ }
343
+
284
344
  const MARK = { ok: "check", stop: "closed", keep: "issue-open", todo: "dash", warn: "label" };
285
345
 
286
346
  function line(kind, text) {