@nanocollective/roster 0.1.0-alpha.6 → 0.1.0-alpha.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/README.md +65 -84
  2. package/dist/cli.js +4187 -2709
  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/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 +81 -5
  10. package/docs/concepts.md +48 -14
  11. package/docs/cost.md +36 -1
  12. package/docs/developing.md +16 -21
  13. package/docs/doctor-codes.md +8 -2
  14. package/docs/extending.md +2 -2
  15. package/docs/getting-started.md +110 -77
  16. package/docs/manual-steps.md +93 -123
  17. package/docs/memory.md +21 -3
  18. package/docs/org-yaml.md +37 -2
  19. package/docs/portal.md +83 -35
  20. package/docs/prompts.md +25 -4
  21. package/docs/security.md +29 -5
  22. package/docs/session-workflow.md +49 -17
  23. package/docs/staff-yaml.md +13 -2
  24. package/docs/troubleshooting.md +8 -8
  25. package/docs/upgrading.md +6 -0
  26. package/docs/writing-a-charter.md +15 -0
  27. package/package.json +1 -1
  28. package/templates/brain/.github/workflows/%%STAFF%%-daily.yaml +6 -0
  29. package/templates/brain/.github/workflows/%%STAFF%%-mention.yaml +6 -0
  30. package/templates/brain/CHARTER.md +3 -3
  31. package/templates/brain/README.md +1 -0
  32. package/templates/brain/log/decisions.md +3 -0
  33. package/templates/brain/strategy/ideas.md +7 -0
  34. package/templates/ops/.github/workflows/session.yaml +108 -14
  35. package/templates/ops/agents.mjs +7 -3
  36. package/templates/ops/compose.mjs +16 -3
  37. package/templates/ops/inflight.mjs +157 -0
  38. package/templates/ops/org/operating.md +21 -1
  39. package/templates/ops/org/voice.md +9 -0
  40. package/templates/ops/prompts/_inflight.md +14 -0
  41. package/templates/ops/prompts/_paths.md +2 -1
  42. package/templates/ops/prompts/daily.md +16 -7
  43. package/templates/ops/prompts/mention.md +2 -0
  44. package/templates/ops/run-record.mjs +144 -0
  45. package/templates/portal/css/runs.css +13 -0
  46. package/templates/portal/css/setup.css +2 -0
  47. package/templates/portal/index.html +12 -1
  48. package/templates/portal/js/api.js +32 -4
  49. package/templates/portal/js/app.js +42 -8
  50. package/templates/portal/js/dialog.js +47 -0
  51. package/templates/portal/js/state.js +3 -1
  52. package/templates/portal/js/views/app.js +23 -5
  53. package/templates/portal/js/views/credential.js +93 -0
  54. package/templates/portal/js/views/health.js +17 -4
  55. package/templates/portal/js/views/inbox.js +6 -2
  56. package/templates/portal/js/views/org.js +23 -3
  57. package/templates/portal/js/views/paste.js +29 -0
  58. package/templates/portal/js/views/prompt.js +7 -3
  59. package/templates/portal/js/views/repos.js +12 -7
  60. package/templates/portal/js/views/runonce.js +94 -0
  61. package/templates/portal/js/views/runs.js +165 -0
  62. package/templates/portal/js/views/setup.js +267 -45
  63. package/templates/portal/js/views/staff.js +81 -20
@@ -14,7 +14,7 @@ import {
14
14
  syncNotice,
15
15
  } from "./refresh.js";
16
16
  import { onRender } from "./router.js";
17
- import { ORG_WIDE, S, VIEWS, applyHash, writeHash } from "./state.js";
17
+ import { ORG_WIDE, S, VIEWS, applyHash, readHash, writeHash } from "./state.js";
18
18
  import { viewBrain } from "./views/brain.js";
19
19
  import { viewChanged } from "./views/changed.js";
20
20
  import { viewDocs } from "./views/docs.js";
@@ -23,12 +23,15 @@ import { viewHealth } from "./views/health.js";
23
23
  import { viewInbox, viewPrs } from "./views/inbox.js";
24
24
  import { viewOrg } from "./views/org.js";
25
25
  import { viewPrompt } from "./views/prompt.js";
26
- import { viewSetup } from "./views/setup.js";
26
+ import { viewRuns } from "./views/runs.js";
27
+ import { paintSetupNav, viewGettingStarted, viewSetup } from "./views/setup.js";
27
28
  import { viewStaff } from "./views/staff.js";
28
29
 
29
30
  const SCREEN = {
31
+ setup: viewGettingStarted,
30
32
  inbox: viewInbox,
31
33
  prs: viewPrs,
34
+ runs: viewRuns,
32
35
  org: viewOrg,
33
36
  staff: viewStaff,
34
37
  docs: viewDocs,
@@ -50,8 +53,38 @@ const SCREEN = {
50
53
  */
51
54
  const NEEDS = ["org", "name", "opsName", "staff"];
52
55
 
56
+ /** The org-wide screens in the sidebar, by button. */
57
+ const NAV = [
58
+ ["#setupnav", "setup"],
59
+ ["#inboxnav", "inbox"],
60
+ ["#prsnav", "prs"],
61
+ ["#runsnav", "runs"],
62
+ ["#staffnav", "staff"],
63
+ ["#orgnav", "org"],
64
+ ["#docsnav", "docs"],
65
+ ];
66
+
53
67
  export async function boot() {
54
68
  initTheme();
69
+
70
+ /* Bound before the first request rather than after it. The sidebar is markup, so it is on
71
+ screen and clickable while /api/org is still building the export, and a click in that
72
+ window used to do nothing at all: the first click on Staff after a load was simply lost.
73
+ Now it is remembered and honoured once there is something to render. */
74
+ let early = null;
75
+ for (const [id, view] of NAV) {
76
+ const b = $(id);
77
+ if (!b) continue;
78
+ b.onclick = () => {
79
+ if (!S.data) {
80
+ early = view;
81
+ return;
82
+ }
83
+ S.view = view;
84
+ render();
85
+ };
86
+ }
87
+
55
88
  /* Before the first render, and once: it is delegated, so it covers every image any screen
56
89
  draws from here on, including the setup screen. */
57
90
  installLightbox();
@@ -66,7 +99,7 @@ export async function boot() {
66
99
  /* Every one of these reads a tenant, so during setup they answer 409 and the sidebar is a
67
100
  row of four dead ends beside an empty Staff heading. There is one thing to do on this
68
101
  screen; the navigation comes back with the org. */
69
- for (const el of document.querySelectorAll("#inboxnav, #prsnav, #orgnav, #staffnav, #docsnav, #refreshall, .sect, #stafflist")) {
102
+ for (const el of document.querySelectorAll("#inboxnav, #prsnav, #runsnav, #orgnav, #staffnav, #docsnav, #refreshall, .sect, #stafflist")) {
70
103
  el.hidden = true;
71
104
  }
72
105
  for (const slot of document.querySelectorAll("[data-icon]")) {
@@ -88,15 +121,15 @@ export async function boot() {
88
121
  slot.innerHTML = iconHTML(slot.dataset.icon);
89
122
  }
90
123
  onRender(render);
124
+ const linked = readHash() !== null;
91
125
  applyHash();
126
+ /* An org nobody has been hired into has an empty Inbox, which says nothing about what to do
127
+ next. It opens on what is left instead, unless a link or a click asked for somewhere else. */
128
+ if (early) S.view = early;
129
+ else if (!linked && (S.data.unfinished ?? []).includes("hire")) S.view = "setup";
92
130
  initStaffFold();
93
131
  paintSidebar();
94
132
 
95
- $("#inboxnav").onclick = () => { S.view = "inbox"; render(); };
96
- $("#prsnav").onclick = () => { S.view = "prs"; render(); };
97
- $("#staffnav").onclick = () => { S.view = "staff"; render(); };
98
- $("#orgnav").onclick = () => { S.view = "org"; render(); };
99
- $("#docsnav").onclick = () => { S.view = "docs"; render(); };
100
133
  $("#refreshall").onclick = () => refreshAll(false);
101
134
 
102
135
  /* Coming back to the tab after a run should show the run — and coming back to it thirty
@@ -296,6 +329,7 @@ function render() {
296
329
  // A refresh can add or remove a staff member, and the sidebar is built once.
297
330
  const roster = S.data.staff.map((s) => s.handle).join(" ");
298
331
  if (roster !== paintedRoster) paintSidebar();
332
+ paintSetupNav();
299
333
  markSidebar();
300
334
  stampCounts();
301
335
  writeHash(true);
@@ -111,3 +111,50 @@ export function askText({
111
111
  ta.setSelectionRange?.(ta.value.length, ta.value.length);
112
112
  });
113
113
  }
114
+
115
+ /**
116
+ * Asking before something that cannot be taken back: a merge, a push, a new repo, a paid run.
117
+ *
118
+ * Native `confirm()` looked like the browser rather than this page, and it blocks the whole
119
+ * tab, which froze browser automation mid-run. This is the same `<dialog>` as askText, so the
120
+ * backdrop, Escape and the focus trap come free. The wording is the caller's: `title` is the
121
+ * question, `hint` says what will happen.
122
+ *
123
+ * @returns true only for the confirm button. Escape, Cancel and the backdrop are all no.
124
+ */
125
+ export function askYes({ title, hint, confirm = "Continue" }) {
126
+ const box = document.createElement("dialog");
127
+ // The test shim has no dialog element; see askText.
128
+ if (typeof box.showModal !== "function") {
129
+ return Promise.resolve(Boolean(globalThis.confirm?.(hint ? title + "\n\n" + hint : title)));
130
+ }
131
+
132
+ box.className = "ask";
133
+ box.append(el("h3", { textContent: title }));
134
+ if (hint) box.append(el("p", { className: "askhint", textContent: hint }));
135
+
136
+ const cancel = el("button", { className: "ghbtn", textContent: "Cancel" });
137
+ const go = el("button", { className: "ghbtn primary", textContent: confirm });
138
+ box.append(el("div", { className: "row askrow" }, [cancel, go]));
139
+
140
+ return new Promise((resolve) => {
141
+ let answer = false;
142
+ const done = (yes) => {
143
+ answer = yes;
144
+ box.close();
145
+ };
146
+ cancel.onclick = () => done(false);
147
+ go.onclick = () => done(true);
148
+ box.addEventListener("close", () => {
149
+ box.remove();
150
+ resolve(answer);
151
+ });
152
+ box.addEventListener("click", (e) => {
153
+ if (onBackdrop(e, box.getBoundingClientRect?.())) done(false);
154
+ });
155
+ document.body.append(box);
156
+ box.showModal();
157
+ // Cancel has the focus, so an Enter pressed out of habit does not merge anything.
158
+ cancel.focus();
159
+ });
160
+ }
@@ -22,7 +22,7 @@ export const VIEWS = [
22
22
  export const VIEW_ALIAS = { memory: "brain" };
23
23
 
24
24
  /** Screens that belong to the org rather than to one staff member. */
25
- export const ORG_WIDE = new Set(["inbox", "prs", "org", "staff", "docs"]);
25
+ export const ORG_WIDE = new Set(["setup", "inbox", "prs", "runs", "org", "staff", "docs"]);
26
26
 
27
27
  /** The two screens that read the inbox, and so share its filters and its `?t=` thread. */
28
28
  const INBOXY = new Set(["inbox", "prs"]);
@@ -32,6 +32,8 @@ export const S = {
32
32
  data: null,
33
33
  docs: null,
34
34
  inbox: null,
35
+ /** /api/runs. Kept once read: it is minutes of downloads on a cold cache. */
36
+ runs: null,
35
37
  sync: null,
36
38
  loadedAt: null,
37
39
 
@@ -6,8 +6,8 @@
6
6
  * it is one browser, one origin, and nothing extra to explain.
7
7
  *
8
8
  * What it still cannot do is install the App. Installing is a grant of access to specific
9
- * repositories and GitHub asks a human to choose them, which is correct behaviour and should not
10
- * be worked around. */
9
+ * repositories and GitHub asks a human to confirm them, which is correct behaviour and should not
10
+ * be worked around. The link it hands back has the right repos already selected. */
11
11
 
12
12
  import { appResult, startApp } from "../api.js";
13
13
  import { el } from "../dom.js";
@@ -48,6 +48,11 @@ export function appPanel(opts) {
48
48
  it: an App that exists is not a problem, it is a step you have already done. */
49
49
  const note = el("div");
50
50
  note.append(el("p", { className: "err", textContent: String(err.message || err) }));
51
+ if (err.data?.install) {
52
+ note.append(
53
+ el("a", { className: "btn", href: err.data.install, target: "_blank", textContent: "Install it" }),
54
+ );
55
+ }
51
56
  out.replaceChildren(note);
52
57
  return;
53
58
  }
@@ -102,9 +107,21 @@ export function appPanel(opts) {
102
107
  el("p", {
103
108
  className: "sub",
104
109
  textContent:
105
- "Grant it every tracker this staff member writes to, not just their own: the token " +
106
- "is minted organisation-wide and a peer's board is where a brief lands.",
110
+ "The link opens GitHub's install page with the org and the repos already selected: " +
111
+ "this staff member's brain, every peer tracker it writes to, and the product repos. " +
112
+ "Check the list and confirm.",
107
113
  }),
114
+ ...((result.preselected ?? []).length
115
+ ? [el("p", { className: "sub", textContent: "Selected: " + result.preselected.join(", ") })]
116
+ : []),
117
+ ...((result.missing ?? []).length
118
+ ? [
119
+ el("p", {
120
+ className: "err",
121
+ textContent: "Tick these yourself, their ids could not be read: " + result.missing.join(", "),
122
+ }),
123
+ ]
124
+ : []),
108
125
  el("a", {
109
126
  className: "btn primary",
110
127
  href: result.install,
@@ -115,7 +132,8 @@ export function appPanel(opts) {
115
132
  className: "sub",
116
133
  textContent:
117
134
  "Do not verify this by reading the API. It reports what an App declares separately " +
118
- "from what an installation was granted. Only a run that finished proves the chain.",
135
+ "from what an installation was granted. Only a run that finished proves the chain, " +
136
+ "which is what Run once now on the staff card is for.",
119
137
  }),
120
138
  );
121
139
  out.replaceChildren(done);
@@ -0,0 +1,93 @@
1
+ /* The agent's credential, stored once for the org.
2
+ *
3
+ * The value goes from this box to the local server in a POST, and from there to gh on its
4
+ * standard input. It is not kept in the page after it is sent, and nothing echoes it back. */
5
+
6
+ import { getCredential, storeCredential } from "../api.js";
7
+ import { el } from "../dom.js";
8
+
9
+ export function credentialPanel() {
10
+ const box = el("div", { className: "manual" });
11
+ box.append(el("b", { textContent: "Your agent's credential, once" }));
12
+ const body = el("div");
13
+ box.append(body);
14
+ body.append(el("p", { className: "sub", textContent: "Looking at where it would go…" }));
15
+
16
+ getCredential()
17
+ .then((c) => draw(body, c))
18
+ .catch((err) => body.replaceChildren(el("p", { className: "err", textContent: String(err.message || err) })));
19
+ return box;
20
+ }
21
+
22
+ function draw(body, c) {
23
+ body.replaceChildren();
24
+ if (c.howTo) body.append(el("p", { textContent: "Where to get one: " + c.howTo }));
25
+
26
+ if (!c.brains.length) {
27
+ body.append(
28
+ el("p", {
29
+ className: "sub",
30
+ textContent:
31
+ "Nothing reads it until somebody is hired, so this box opens once there is a staff " +
32
+ "member. It is asked for once, not once per hire.",
33
+ }),
34
+ );
35
+ return;
36
+ }
37
+
38
+ if (c.orgSecret) {
39
+ body.append(
40
+ el("p", {
41
+ className: "sub",
42
+ textContent:
43
+ c.name + " is already an org secret, shared with " + c.orgSecret.visibility + " repos. " +
44
+ "Each hire adds its brain to it. Paste a new one only to replace it.",
45
+ }),
46
+ );
47
+ }
48
+
49
+ body.append(
50
+ el("p", {
51
+ className: "sub",
52
+ textContent:
53
+ (c.plan.mode === "org"
54
+ ? "Stored as one org secret, " + c.name + ", shared with " + c.brains.join(", ") + ". "
55
+ : "Stored as " + c.name + " on each of " + c.brains.join(", ") + ", because " + c.plan.reason + ". ") +
56
+ "It goes to gh on standard input and is never written to disk.",
57
+ }),
58
+ );
59
+
60
+ const input = el("input", { type: "password", placeholder: c.name, autocomplete: "off" });
61
+ input.style.width = "100%";
62
+ const go = el("button", { className: "btn primary", textContent: "Store it" });
63
+ const out = el("div", { style: "margin-top:9px" });
64
+ body.append(input, el("div", { className: "row", style: "margin-top:9px" }, [go]), out);
65
+
66
+ go.onclick = async () => {
67
+ const value = input.value.trim();
68
+ if (!value) {
69
+ input.focus();
70
+ return;
71
+ }
72
+ go.disabled = true;
73
+ out.replaceChildren(el("p", { className: "sub", textContent: "Storing…" }));
74
+ try {
75
+ const r = await storeCredential(value, false);
76
+ input.value = "";
77
+ if (!r.ok) throw new Error(r.error);
78
+ out.replaceChildren(
79
+ ...(r.fellBack ? [el("p", { className: "err", textContent: "The org secret was refused: " + r.fellBack })] : []),
80
+ el("p", {
81
+ textContent:
82
+ r.mode === "org"
83
+ ? "Stored once for the org, shared with " + r.repos.length + " brains."
84
+ : "Stored on " + r.repos.join(", ") + ".",
85
+ }),
86
+ );
87
+ } catch (err) {
88
+ out.replaceChildren(el("p", { className: "err", textContent: String(err.message || err) }));
89
+ } finally {
90
+ go.disabled = false;
91
+ }
92
+ };
93
+ }
@@ -1,12 +1,14 @@
1
1
  /* Is the scaffolding still there, has the agent been running, and what does lint say. */
2
2
 
3
3
  import { post } from "../api.js";
4
+ import { askYes } from "../dialog.js";
4
5
  import { ago, el, esc, kb } from "../dom.js";
5
6
  import { inline } from "../md.js";
6
7
  import { render } from "../router.js";
7
8
  import { S, staff } from "../state.js";
8
9
  import { checklist } from "./checklist.js";
9
10
  import { copyAmendBrief } from "./prompt.js";
11
+ import { runOnce } from "./runonce.js";
10
12
 
11
13
  const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
12
14
 
@@ -81,7 +83,18 @@ export function viewHealth(m) {
81
83
  doctor, bordered boxes for the prompt audit, cards with two buttons each for lint. Same
82
84
  question in all three, so one shape: a heading that carries its own count, a line saying
83
85
  what the section is, the findings as rows, and the section's actions at the end. */
84
- const doctor = section(m, "The org, from roster doctor", "doctor");
86
+ /* A workflow that has never run is unproven, and the way to prove it is to run it. Here
87
+ because Health is where "unproven" is read; a success re-runs the checks below. */
88
+ let doctor = null;
89
+ m.append(
90
+ runOnce({
91
+ staff: s.handle,
92
+ name: s.name,
93
+ onDone: (ok) => ok && doctor && checklist(doctor.body, { shell: doctor }),
94
+ }),
95
+ );
96
+
97
+ doctor = section(m, "The org, from roster doctor", "doctor");
85
98
  checklist(doctor.body, { shell: doctor });
86
99
 
87
100
  /* What is wrong with what this agent is sent. It used to sit in the tree on the Prompt
@@ -132,7 +145,7 @@ function memoryProblems({ body, actions, say }, s) {
132
145
  const { row, body: text } = finding(
133
146
  p.level === "error" ? "error" : "warning",
134
147
  { html: inline(p.message) },
135
- [p.rule, p.line ? "INDEX.md:" + p.line : ""],
148
+ [p.rule, p.line ? "INDEX.md:" + p.line : (p.file ?? "")],
136
149
  );
137
150
  const status = el("span", { className: "meta" });
138
151
  const btn = el("button", { className: "ghbtn", textContent: "Ask " + s.handle.toUpperCase() + " to fix" });
@@ -263,13 +276,13 @@ export async function askToFix(s, problems, btn, status) {
263
276
  "",
264
277
  "| rule | where | what |",
265
278
  "|---|---|---|",
266
- ...problems.map((p) => "| `" + p.rule + "` | " + (p.line ? "INDEX.md:" + p.line : "—") + " | " +
279
+ ...problems.map((p) => "| `" + p.rule + "` | " + (p.line ? "INDEX.md:" + p.line : (p.file ?? "—")) + " | " +
267
280
  String(p.message).replace(/\|/g, "\\|") + " |"),
268
281
  "",
269
282
  "Fix in your next session and close this.",
270
283
  ].join("\n");
271
284
 
272
- if (!confirm("Open an issue in " + s.brain + "?")) return;
285
+ if (!(await askYes({ title: "Open an issue in " + s.brain + "?", confirm: "Open it" }))) return;
273
286
  btn.disabled = true;
274
287
  status.textContent = "opening…";
275
288
  status.className = "meta";
@@ -1,7 +1,7 @@
1
1
  /* The inbox: everything open across the org, and the thread beside it. */
2
2
 
3
3
  import { getLabels, getPr, getThread, post, upload } from "../api.js";
4
- import { askText } from "../dialog.js";
4
+ import { askText, askYes } from "../dialog.js";
5
5
  import { ago, el, esc, markCurrent, skeleton } from "../dom.js";
6
6
  import { icon, iconHTML } from "../icons.js";
7
7
  import { mdlite } from "../md.js";
@@ -693,7 +693,11 @@ function inboxScreen(m, opts) {
693
693
  merge.title = "This branch conflicts with its base";
694
694
  }
695
695
  merge.onclick = async () => {
696
- if (!confirm("Merge " + item.repo + " #" + item.number + " into its base branch?")) return;
696
+ const yes = await askYes({
697
+ title: "Merge " + item.repo + " #" + item.number + " into its base branch?",
698
+ confirm: "Merge",
699
+ });
700
+ if (!yes) return;
697
701
  busy(true, "merging…");
698
702
  try {
699
703
  await post({ action: "merge", repo: item.repo, number: item.number });
@@ -15,11 +15,13 @@
15
15
  */
16
16
 
17
17
  import { getFile, getOrgLayer, post } from "../api.js";
18
+ import { askYes } from "../dialog.js";
18
19
  import { el, esc, grow, kb, skeleton } from "../dom.js";
19
20
  import { icon } from "../icons.js";
20
21
  import { mdlite } from "../md.js";
21
22
  import { humansOf, S, writeHash } from "../state.js";
22
23
  import { yamlPre } from "../yaml.js";
24
+ import { repoPicker } from "./repos.js";
23
25
 
24
26
  /* What the files roster itself ships are for, in one line each. A filename says nothing about
25
27
  which one to open; anything a tenant has added of its own falls back to its first heading. */
@@ -27,6 +29,8 @@ const KNOWN = {
27
29
  "org.yaml": "Who and what: the humans, the repos, the defaults, which agent runs them.",
28
30
  "org/business.md":
29
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.",
30
34
  "org/operating.md":
31
35
  "The autonomy contract: what they do without asking, what they escalate, how a run starts and ends.",
32
36
  "org/guardrails.md":
@@ -75,6 +79,20 @@ export function viewOrg(m) {
75
79
  );
76
80
  m.append(facts);
77
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
+
78
96
  const split = el("div", { className: "split" });
79
97
  const tree = el("div", { className: "tree" });
80
98
  const viewer = el("div", { className: "viewer" });
@@ -220,9 +238,11 @@ export function viewOrg(m) {
220
238
  through the question, which is worse than not asking. */
221
239
  if (
222
240
  isYaml &&
223
- !confirm(
224
- "Commit " + full(path) + " and push?\n\nEvery staff member picks this up on their next run.",
225
- )
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
+ }))
226
246
  ) {
227
247
  return;
228
248
  }
@@ -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,7 +10,7 @@
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";
@@ -375,8 +375,12 @@ export function viewPrompt(m) {
375
375
  status.textContent = "nothing changed";
376
376
  return;
377
377
  }
378
- if (!confirm("Commit " + layer.path + " to " + layer.repo + " and push?\n\n" +
379
- "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;
380
384
  save.disabled = cancel.disabled = true;
381
385
  status.textContent = "committing…";
382
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?.();