@nanocollective/roster 0.1.0-alpha.1 → 0.1.0-alpha.10

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 (94) hide show
  1. package/README.md +65 -84
  2. package/dist/cli.js +4687 -2495
  3. package/docs/README.md +19 -11
  4. package/docs/agents.md +328 -13
  5. package/docs/architecture.md +13 -5
  6. package/docs/charters/cmo.md +69 -0
  7. package/docs/charters/cto.md +71 -0
  8. package/docs/charters/support.md +60 -0
  9. package/docs/commands.md +90 -11
  10. package/docs/concepts.md +64 -12
  11. package/docs/cost.md +39 -3
  12. package/docs/developing.md +16 -21
  13. package/docs/doctor-codes.md +21 -6
  14. package/docs/export.md +2 -1
  15. package/docs/extending.md +13 -4
  16. package/docs/getting-started.md +118 -80
  17. package/docs/images/brain.jpg +0 -0
  18. package/docs/images/org.jpg +0 -0
  19. package/docs/images/prompt.jpg +0 -0
  20. package/docs/images/setup-org.jpg +0 -0
  21. package/docs/images/setup-plan.jpg +0 -0
  22. package/docs/images/staff.jpg +0 -0
  23. package/docs/manual-steps.md +94 -101
  24. package/docs/memory.md +29 -8
  25. package/docs/org-yaml.md +74 -11
  26. package/docs/portal.md +261 -47
  27. package/docs/prompts.md +71 -11
  28. package/docs/security.md +43 -7
  29. package/docs/session-workflow.md +51 -21
  30. package/docs/staff-yaml.md +16 -7
  31. package/docs/troubleshooting.md +23 -20
  32. package/docs/upgrading.md +6 -0
  33. package/docs/writing-a-charter.md +33 -17
  34. package/package.json +1 -1
  35. package/templates/brain/.github/workflows/%%STAFF%%-daily.yaml +7 -0
  36. package/templates/brain/.github/workflows/%%STAFF%%-mention.yaml +16 -4
  37. package/templates/brain/CHARTER.md +3 -3
  38. package/templates/brain/README.md +1 -0
  39. package/templates/brain/log/decisions.md +3 -0
  40. package/templates/brain/staff.yaml +0 -1
  41. package/templates/brain/strategy/ideas.md +7 -0
  42. package/templates/ops/.github/workflows/session.yaml +117 -40
  43. package/templates/ops/agents.mjs +127 -8
  44. package/templates/ops/compose.mjs +77 -7
  45. package/templates/ops/inflight.mjs +157 -0
  46. package/templates/ops/org/operating.md +21 -7
  47. package/templates/ops/org/voice.md +9 -0
  48. package/templates/ops/prompts/_identity.md +8 -1
  49. package/templates/ops/prompts/_inflight.md +14 -0
  50. package/templates/ops/prompts/_paths.md +2 -1
  51. package/templates/ops/prompts/daily.md +16 -7
  52. package/templates/ops/prompts/mention.md +18 -2
  53. package/templates/ops/run-record.mjs +144 -0
  54. package/templates/portal/css/base.css +238 -64
  55. package/templates/portal/css/brain.css +30 -20
  56. package/templates/portal/css/diff.css +15 -10
  57. package/templates/portal/css/graph.css +12 -7
  58. package/templates/portal/css/health.css +31 -10
  59. package/templates/portal/css/inbox.css +117 -14
  60. package/templates/portal/css/layout.css +92 -41
  61. package/templates/portal/css/markdown.css +38 -15
  62. package/templates/portal/css/runs.css +13 -0
  63. package/templates/portal/css/setup.css +50 -37
  64. package/templates/portal/index.html +24 -3
  65. package/templates/portal/js/api.js +65 -4
  66. package/templates/portal/js/app.js +112 -12
  67. package/templates/portal/js/dialog.js +94 -4
  68. package/templates/portal/js/dom.js +25 -0
  69. package/templates/portal/js/icons.js +8 -1
  70. package/templates/portal/js/lightbox.js +273 -0
  71. package/templates/portal/js/md.js +23 -6
  72. package/templates/portal/js/mention.js +264 -0
  73. package/templates/portal/js/refresh.js +136 -6
  74. package/templates/portal/js/state.js +55 -8
  75. package/templates/portal/js/views/app.js +23 -5
  76. package/templates/portal/js/views/checklist.js +20 -7
  77. package/templates/portal/js/views/credential.js +93 -0
  78. package/templates/portal/js/views/docs.js +94 -4
  79. package/templates/portal/js/views/files.js +58 -14
  80. package/templates/portal/js/views/graph.js +1 -1
  81. package/templates/portal/js/views/health.js +178 -37
  82. package/templates/portal/js/views/inbox.js +938 -98
  83. package/templates/portal/js/views/memory.js +16 -1
  84. package/templates/portal/js/views/org.js +161 -61
  85. package/templates/portal/js/views/paste.js +29 -0
  86. package/templates/portal/js/views/prompt.js +57 -66
  87. package/templates/portal/js/views/repos.js +12 -7
  88. package/templates/portal/js/views/runonce.js +94 -0
  89. package/templates/portal/js/views/runs.js +165 -0
  90. package/templates/portal/js/views/setup.js +302 -56
  91. package/templates/portal/js/views/staff.js +143 -22
  92. package/templates/portal/js/yaml.js +134 -0
  93. package/templates/brain/.github/workflows/%%STAFF%%-pr-mention.yaml +0 -50
  94. 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,51 @@
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 { askYes } from "../dialog.js";
19
+ import { el, esc, grow, kb, skeleton } from "../dom.js";
20
+ import { icon } from "../icons.js";
14
21
  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"],
22
+ import { humansOf, S, writeHash } from "../state.js";
23
+ import { yamlPre } from "../yaml.js";
24
+ import { repoPicker } from "./repos.js";
25
+
26
+ /* What the files roster itself ships are for, in one line each. A filename says nothing about
27
+ which one to open; anything a tenant has added of its own falls back to its first heading. */
28
+ const KNOWN = {
29
+ "org.yaml": "Who and what: the humans, the repos, the defaults, which agent runs them.",
30
+ "org/business.md":
31
+ "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.",
32
+ "org/priorities.md":
33
+ "What matters this month, ranked, and what is out of scope. Every daily run reads it, and every PR says which priority it serves.",
34
+ "org/operating.md":
35
+ "The autonomy contract: what they do without asking, what they escalate, how a run starts and ends.",
36
+ "org/guardrails.md":
37
+ "The non-negotiables. Every staff member is bound by these and cannot argue with them.",
38
+ "org/voice.md":
39
+ "How everything they write reads. Change it once, and every surface changes on the next run.",
40
+ "prompts/daily.md": "The whole of a scheduled run, before the fragments are pulled into it.",
41
+ "prompts/mention.md": "What they are sent when you @-mention them.",
42
+ "prompts/_identity.md": "Who they post as, and what wakes you.",
43
+ "prompts/_paths.md": "Where everything is in the runner's checkout.",
44
+ };
45
+
46
+ const GROUPS = [
47
+ ["org", "The org layer", "Inherited by everybody, on their next run."],
48
+ ["prompts", "The prompts", "The runs themselves. Edit these last: they are the framework's."],
25
49
  ];
26
50
 
27
51
  export function viewOrg(m) {
@@ -37,44 +61,91 @@ export function viewOrg(m) {
37
61
  );
38
62
 
39
63
  const facts = el("div", { className: "grid", style: "margin-bottom:18px" });
40
- const human = org.human ?? {};
64
+ const humans = humansOf();
41
65
  facts.append(
42
66
  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"),
67
+ /* Plural on purpose. An org can answer to more than one person, and a card that names one
68
+ of two is worse than one that names neither: it reads as the only one who counts. */
69
+ kv(
70
+ "They answer to",
71
+ humans.length
72
+ ? humans.map((h) => esc(h.name ?? h.github)).join(", ")
73
+ : "nobody",
74
+ humans.length
75
+ ? humans.map((h) => (h.github ? "@" + h.github : "no github handle")).join(" · ")
76
+ : "org.yaml names no human, so nothing can wake an agent",
77
+ ),
44
78
  kv("Staff", String(org.staff.length), org.staff.map((s) => s.handle).join(", ")),
45
79
  );
46
80
  m.append(facts);
47
81
 
82
+ /* Marking a product repo after setup. The picker only lived on the setup screen, so a repo
83
+ created later meant typing `role: product` into org.yaml by hand. Folded, because it asks
84
+ GitHub for every repo in the org and most visits here are to read a file. */
85
+ const repoPane = el("div");
86
+ const repos = el("button", { className: "ghbtn", textContent: "Add a product repo" });
87
+ repos.onclick = () => {
88
+ if (repoPane.children.length) {
89
+ repoPane.replaceChildren();
90
+ return;
91
+ }
92
+ repoPane.replaceChildren(repoPicker({ org: org.org, onAdded: () => open("org.yaml") }));
93
+ };
94
+ m.append(el("div", { className: "row", style: "margin-bottom:14px" }, [repos]), repoPane);
95
+
48
96
  const split = el("div", { className: "split" });
49
97
  const tree = el("div", { className: "tree" });
50
98
  const viewer = el("div", { className: "viewer" });
51
99
  split.append(tree, viewer);
52
100
  m.append(split);
53
101
 
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
102
  const full = (path) => S.data.opsName + "/" + path;
74
103
 
75
- open(S.orgOpen ?? DOCS[0][0]);
104
+ tree.replaceChildren(...skeleton("row", 6));
105
+ getOrgLayer()
106
+ .then(({ files }) => {
107
+ S.orgFiles = files ?? [];
108
+ paintTree();
109
+ const first = S.orgFiles[0]?.path;
110
+ open(S.orgFiles.some((f) => f.path === S.orgOpen) ? S.orgOpen : first);
111
+ })
112
+ .catch((e) => {
113
+ tree.replaceChildren(el("p", { className: "empty err", textContent: e.message }));
114
+ });
115
+
116
+ function why(file) {
117
+ return KNOWN[file.path] ?? file.title ?? "";
118
+ }
119
+
120
+ function paintTree() {
121
+ tree.replaceChildren();
122
+ if (!S.orgFiles.length) {
123
+ tree.append(el("p", { className: "empty", textContent: "No org files here." }));
124
+ return;
125
+ }
126
+ for (const [id, heading, note] of GROUPS) {
127
+ const mine = S.orgFiles.filter((f) => (f.group ?? "org") === id);
128
+ if (!mine.length) continue;
129
+ const group = el("div", { className: "navgroup" });
130
+ group.append(el("div", { className: "ghead", textContent: heading, title: note }));
131
+ for (const f of mine) {
132
+ const b = el("button", { className: "tfile tlayer", title: why(f) });
133
+ b.dataset.key = f.path;
134
+ const t = el("span", { className: "t" });
135
+ t.append(
136
+ el("span", { className: "lname", textContent: f.path.replace(/^(org|prompts)\//, "") }),
137
+ el("span", { className: "lrepo", textContent: why(f) }),
138
+ );
139
+ b.append(t);
140
+ b.onclick = () => open(f.path);
141
+ group.append(b);
142
+ }
143
+ tree.append(group);
144
+ }
145
+ }
76
146
 
77
147
  function open(path) {
148
+ if (!path) return;
78
149
  S.orgOpen = path;
79
150
  writeHash(false);
80
151
  for (const o of tree.querySelectorAll(".tfile")) {
@@ -84,7 +155,7 @@ export function viewOrg(m) {
84
155
  }
85
156
 
86
157
  async function show(path) {
87
- viewer.replaceChildren(el("p", { className: "empty", textContent: "Loading…" }));
158
+ viewer.replaceChildren(...skeleton("head", 1), ...skeleton("line", 7));
88
159
  let text;
89
160
  try {
90
161
  text = await getFile(full(path));
@@ -97,55 +168,84 @@ export function viewOrg(m) {
97
168
 
98
169
  function render(path, text) {
99
170
  viewer.replaceChildren();
100
- const why = DOCS.find(([p]) => p === path)?.[1] ?? "";
101
- const head = el("div", { className: "row", style: "margin-bottom:4px" });
171
+ const head = el("div", { className: "row filehead" });
102
172
  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 }));
173
+ /* The edit control used to be a word in a row of grey text, which is why "there is no easy
174
+ way to edit the org docs" was a fair thing to say about a screen that could edit them. */
175
+ const edit = el("button", { className: "ghbtn primary editbtn", onclick: () => editor(path, text) });
176
+ edit.append(icon("edit", "ic"), el("span", { textContent: "Edit" }));
177
+ head.append(edit);
178
+ const file = S.orgFiles.find((f) => f.path === path) ?? { path };
179
+ viewer.append(head, el("p", { className: "factsub", textContent: why(file) }));
105
180
 
106
- if (path.endsWith(".yaml")) {
107
- viewer.append(el("pre", { className: "code", textContent: text }));
181
+ if (path.endsWith(".yaml") || path.endsWith(".yml")) {
182
+ viewer.append(yamlPre(text));
108
183
  return;
109
184
  }
110
185
  viewer.append(el("div", { className: "md doc", innerHTML: mdlite(text) }));
111
186
  }
112
187
 
113
- function edit(path, text) {
188
+ function editor(path, text) {
114
189
  viewer.replaceChildren();
115
- viewer.append(
116
- el("div", { className: "row", style: "margin-bottom:10px" }, [
117
- el("span", { className: "meta", textContent: full(path) }),
118
- ]),
119
- );
190
+ const isYaml = /\.ya?ml$/.test(path);
191
+
192
+ const status = el("span", { className: "meta" });
193
+ const save = el("button", { className: "ghbtn primary", textContent: "Save and commit" });
194
+ const cancel = el("button", { className: "ghbtn", textContent: "Cancel" });
195
+
196
+ const bar = el("div", { className: "row filehead" });
197
+ bar.append(el("span", { className: "meta", textContent: full(path) }), save, cancel, status);
198
+ viewer.append(bar);
120
199
 
121
200
  const ta = el("textarea", { value: text, className: "editor" });
122
201
  viewer.append(ta);
123
202
  grow(ta);
124
203
  ta.addEventListener("input", () => grow(ta));
204
+ // ⌘S is what a person's hands do in a text box. Without it, saving means finding a button
205
+ // above a screenful of textarea you have just scrolled past.
206
+ ta.addEventListener("keydown", (e) => {
207
+ if ((e.metaKey || e.ctrlKey) && e.key === "s") {
208
+ e.preventDefault();
209
+ commit();
210
+ }
211
+ });
212
+ ta.focus?.();
125
213
 
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
214
  cancel.onclick = () => render(path, text);
130
- viewer.append(el("div", { className: "row", style: "margin-top:10px" }, [save, cancel, status]));
215
+ save.onclick = commit;
216
+
131
217
  viewer.append(
132
218
  el("p", {
133
219
  className: "editnote",
134
220
  textContent:
135
- "Commits to " + S.data.opsName + " and pushes. Picked up by every staff member on their " +
136
- "next run." +
137
- (path.endsWith(".yaml")
221
+ "⌘S saves. Commits to " + S.data.opsName + " and pushes, and every staff member picks " +
222
+ "it up on their next run." +
223
+ (isYaml
138
224
  ? " org.yaml is checked before it is written: bad YAML, or a missing org or name, is refused rather than committed."
139
225
  : ""),
140
226
  }),
141
227
  );
142
228
 
143
- save.onclick = async () => {
229
+ async function commit() {
144
230
  if (ta.value === text) {
145
231
  status.textContent = "nothing changed";
232
+ status.className = "meta";
233
+ return;
234
+ }
235
+ /* Only the manifest asks. A prose edit is one commit to revert and the agents pick it up
236
+ on their next run; org.yaml is the file that can stop every prompt composing, and the
237
+ portal has just told you it validates it. Asking every time taught people to click
238
+ through the question, which is worse than not asking. */
239
+ if (
240
+ isYaml &&
241
+ !(await askYes({
242
+ title: "Commit " + full(path) + " and push?",
243
+ hint: "Every staff member picks this up on their next run.",
244
+ confirm: "Commit and push",
245
+ }))
246
+ ) {
146
247
  return;
147
248
  }
148
- if (!confirm("Commit " + full(path) + " and push?\n\nEvery staff member picks this up on their next run.")) return;
149
249
  save.disabled = cancel.disabled = true;
150
250
  status.textContent = "committing…";
151
251
  status.className = "meta";
@@ -162,7 +262,7 @@ export function viewOrg(m) {
162
262
  status.className = "meta err";
163
263
  save.disabled = cancel.disabled = false;
164
264
  }
165
- };
265
+ }
166
266
  }
167
267
  }
168
268
 
@@ -20,6 +20,12 @@ export function paste(opts) {
20
20
  const status = el("p", { className: "sub", textContent: "Building the prompt…" });
21
21
  box.append(status);
22
22
 
23
+ /* A charter brief carries a worked example to model the shape on, matched to the role. The
24
+ choice is on the page because the match is a guess, and "none" is a fair answer. */
25
+ const model = el("select", { hidden: true });
26
+ const modelRow = el("label", { className: "sub", hidden: true }, ["Model it on: ", model]);
27
+ box.append(modelRow);
28
+
23
29
  const actions = el("div", { className: "row" });
24
30
  const copy = el("button", { className: "btn primary", textContent: "Copy the prompt" });
25
31
  const show = el("button", { className: "btn", textContent: "Show it" });
@@ -42,10 +48,33 @@ export function paste(opts) {
42
48
 
43
49
  let brief = null;
44
50
 
51
+ const NAMES = { cto: "the CTO example", cmo: "the CMO example", support: "the support example", none: "no example" };
52
+ model.onchange = () => {
53
+ copy.disabled = true;
54
+ getBrief(opts.kind, opts.staff, model.value)
55
+ .then((data) => {
56
+ if (data.error) throw new Error(data.error);
57
+ brief = data;
58
+ if (!preview.hidden) preview.textContent = brief.text;
59
+ })
60
+ .catch((err) => {
61
+ status.className = "err";
62
+ status.textContent = String(err.message || err);
63
+ })
64
+ .finally(() => {
65
+ copy.disabled = false;
66
+ });
67
+ };
68
+
45
69
  getBrief(opts.kind, opts.staff)
46
70
  .then((data) => {
47
71
  if (data.error) throw new Error(data.error);
48
72
  brief = data;
73
+ if (data.examples) {
74
+ model.replaceChildren(...data.examples.map((e) => el("option", { value: e, textContent: NAMES[e] ?? e })));
75
+ model.value = data.example ?? "none";
76
+ model.hidden = modelRow.hidden = false;
77
+ }
49
78
  status.textContent =
50
79
  "Paste this into Claude, ChatGPT or anything else. It carries every file it refers to, " +
51
80
  "so there is nothing to attach and nothing for it to ask you for. It will interview you " +
@@ -10,10 +10,11 @@
10
10
  */
11
11
 
12
12
  import { getFile, post } from "../api.js";
13
- import { askText } from "../dialog.js";
13
+ import { askText, askYes } 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 });
@@ -388,8 +375,12 @@ export function viewPrompt(m) {
388
375
  status.textContent = "nothing changed";
389
376
  return;
390
377
  }
391
- if (!confirm("Commit " + layer.path + " to " + layer.repo + " and push?\n\n" +
392
- "Picked up by " + reach + ".")) return;
378
+ const yes = await askYes({
379
+ title: "Commit " + layer.path + " to " + layer.repo + " and push?",
380
+ hint: "Picked up by " + reach + ".",
381
+ confirm: "Commit and push",
382
+ });
383
+ if (!yes) return;
393
384
  save.disabled = cancel.disabled = true;
394
385
  status.textContent = "committing…";
395
386
  status.className = "meta";
@@ -5,11 +5,12 @@
5
5
  * spelling a repo name exactly right in a YAML file, which is the kind of thing that fails
6
6
  * quietly and reads as the tool being broken. */
7
7
 
8
- import { addRepo, listRepos } from "../api.js";
8
+ import { addRepo, getRepos, listRepos } from "../api.js";
9
9
  import { el } from "../dom.js";
10
10
 
11
11
  /**
12
- * @param {{org: string, known: string[], onAdded?: () => void}} opts
12
+ * @param {{org: string, known?: string[], onAdded?: () => void}} opts without `known`, what
13
+ * org.yaml already lists is read off disk, which is how the Org screen uses it long after setup.
13
14
  */
14
15
  export function repoPicker(opts) {
15
16
  const box = el("div", { className: "manual" });
@@ -18,7 +19,8 @@ export function repoPicker(opts) {
18
19
  el("p", {
19
20
  textContent:
20
21
  "Repos marked as products are the ones staff contribute to without owning. A new hire " +
21
- "picks them up automatically.",
22
+ "picks them up automatically; somebody already hired keeps the works_in list in their " +
23
+ "own staff.yaml.",
22
24
  }),
23
25
  );
24
26
 
@@ -26,10 +28,13 @@ export function repoPicker(opts) {
26
28
  box.append(out);
27
29
  out.replaceChildren(el("p", { className: "sub", textContent: "Reading your repos…" }));
28
30
 
29
- listRepos(opts.org)
30
- .then((data) => {
31
+ const known = opts.known
32
+ ? Promise.resolve(opts.known)
33
+ : getRepos().then((d) => (d.repos ?? []).map((r) => r.name));
34
+ Promise.all([listRepos(opts.org), known])
35
+ .then(([data, listed]) => {
31
36
  if (data.error) throw new Error(data.error);
32
- const already = new Set(opts.known ?? []);
37
+ const already = new Set(listed);
33
38
  const candidates = (data.repos ?? []).filter((r) => !already.has(r.name));
34
39
 
35
40
  if (!candidates.length) {
@@ -52,7 +57,7 @@ export function repoPicker(opts) {
52
57
  add.disabled = true;
53
58
  add.textContent = "Adding…";
54
59
  try {
55
- await addRepo(repo.name, "product");
60
+ await addRepo(repo.name, "product", repo.visibility);
56
61
  add.textContent = "Added";
57
62
  row.classList.add("added");
58
63
  opts.onAdded?.();