@nanocollective/roster 0.1.0-alpha.3 → 0.1.0-alpha.31

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 (108) hide show
  1. package/README.md +70 -84
  2. package/dist/cli.js +4817 -2374
  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/analyst.md +65 -0
  7. package/docs/charters/cmo.md +69 -0
  8. package/docs/charters/community.md +63 -0
  9. package/docs/charters/cto.md +71 -0
  10. package/docs/charters/designer.md +65 -0
  11. package/docs/charters/devops.md +65 -0
  12. package/docs/charters/pm.md +70 -0
  13. package/docs/charters/qa.md +65 -0
  14. package/docs/charters/support.md +60 -0
  15. package/docs/charters/writer.md +63 -0
  16. package/docs/commands.md +95 -11
  17. package/docs/concepts.md +64 -12
  18. package/docs/cost.md +39 -3
  19. package/docs/developing.md +16 -21
  20. package/docs/doctor-codes.md +21 -6
  21. package/docs/export.md +4 -1
  22. package/docs/extending.md +13 -4
  23. package/docs/getting-started.md +133 -79
  24. package/docs/images/brain.jpg +0 -0
  25. package/docs/images/org.jpg +0 -0
  26. package/docs/images/prompt.jpg +0 -0
  27. package/docs/images/setup-org.jpg +0 -0
  28. package/docs/images/setup-plan.jpg +0 -0
  29. package/docs/images/staff.jpg +0 -0
  30. package/docs/manual-steps.md +95 -101
  31. package/docs/memory.md +29 -8
  32. package/docs/org-yaml.md +76 -11
  33. package/docs/portal.md +290 -49
  34. package/docs/prompts.md +77 -11
  35. package/docs/security.md +51 -7
  36. package/docs/session-workflow.md +51 -21
  37. package/docs/staff-yaml.md +17 -7
  38. package/docs/troubleshooting.md +23 -20
  39. package/docs/upgrading.md +9 -3
  40. package/docs/writing-a-charter.md +46 -17
  41. package/package.json +1 -1
  42. package/templates/brain/.github/workflows/%%STAFF%%-daily.yaml +7 -0
  43. package/templates/brain/.github/workflows/%%STAFF%%-mention.yaml +16 -4
  44. package/templates/brain/CHARTER.md +3 -3
  45. package/templates/brain/README.md +1 -0
  46. package/templates/brain/log/decisions.md +3 -0
  47. package/templates/brain/staff.yaml +0 -1
  48. package/templates/brain/strategy/ideas.md +7 -0
  49. package/templates/briefs/priorities.md +46 -0
  50. package/templates/ops/.github/workflows/session.yaml +117 -40
  51. package/templates/ops/agents.mjs +127 -8
  52. package/templates/ops/compose.mjs +77 -7
  53. package/templates/ops/inflight.mjs +157 -0
  54. package/templates/ops/org/operating.md +21 -7
  55. package/templates/ops/org/voice.md +9 -0
  56. package/templates/ops/prompts/_identity.md +8 -1
  57. package/templates/ops/prompts/_inflight.md +14 -0
  58. package/templates/ops/prompts/_paths.md +2 -1
  59. package/templates/ops/prompts/daily.md +16 -7
  60. package/templates/ops/prompts/mention.md +18 -2
  61. package/templates/ops/run-record.mjs +144 -0
  62. package/templates/portal/css/base.css +245 -64
  63. package/templates/portal/css/brain.css +30 -20
  64. package/templates/portal/css/diff.css +15 -10
  65. package/templates/portal/css/graph.css +12 -7
  66. package/templates/portal/css/health.css +32 -11
  67. package/templates/portal/css/inbox.css +117 -14
  68. package/templates/portal/css/layout.css +114 -41
  69. package/templates/portal/css/markdown.css +57 -15
  70. package/templates/portal/css/runs.css +13 -0
  71. package/templates/portal/css/setup.css +126 -39
  72. package/templates/portal/index.html +25 -3
  73. package/templates/portal/js/api.js +74 -4
  74. package/templates/portal/js/app.js +156 -14
  75. package/templates/portal/js/dialog.js +129 -4
  76. package/templates/portal/js/dom.js +25 -0
  77. package/templates/portal/js/icons.js +45 -1
  78. package/templates/portal/js/inflight.js +18 -0
  79. package/templates/portal/js/lightbox.js +273 -0
  80. package/templates/portal/js/md.js +23 -6
  81. package/templates/portal/js/mdedit.js +84 -0
  82. package/templates/portal/js/mention.js +264 -0
  83. package/templates/portal/js/readiness.js +35 -0
  84. package/templates/portal/js/refresh.js +136 -6
  85. package/templates/portal/js/state.js +59 -8
  86. package/templates/portal/js/views/app.js +24 -7
  87. package/templates/portal/js/views/checklist.js +29 -10
  88. package/templates/portal/js/views/credential.js +84 -0
  89. package/templates/portal/js/views/docs.js +94 -4
  90. package/templates/portal/js/views/files.js +58 -14
  91. package/templates/portal/js/views/graph.js +1 -1
  92. package/templates/portal/js/views/health.js +178 -37
  93. package/templates/portal/js/views/hire.js +583 -0
  94. package/templates/portal/js/views/inbox.js +959 -126
  95. package/templates/portal/js/views/memory.js +16 -1
  96. package/templates/portal/js/views/org.js +124 -104
  97. package/templates/portal/js/views/orgedit.js +234 -0
  98. package/templates/portal/js/views/paste.js +87 -21
  99. package/templates/portal/js/views/prompt.js +61 -67
  100. package/templates/portal/js/views/repos.js +20 -15
  101. package/templates/portal/js/views/runonce.js +94 -0
  102. package/templates/portal/js/views/runs.js +165 -0
  103. package/templates/portal/js/views/setup.js +257 -75
  104. package/templates/portal/js/views/staff.js +157 -182
  105. package/templates/portal/js/views/todo.js +62 -0
  106. package/templates/portal/js/yaml.js +134 -0
  107. package/templates/brain/.github/workflows/%%STAFF%%-pr-mention.yaml +0 -50
  108. 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,46 @@
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 } from "../api.js";
18
+ import { el, esc, kb, skeleton } from "../dom.js";
14
19
  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"],
20
+ import { humansOf, S, writeHash } from "../state.js";
21
+ import { yamlPre } from "../yaml.js";
22
+ import { businessForm, fileModes, prioritiesForm } from "./orgedit.js";
23
+ import { repoPicker } from "./repos.js";
24
+
25
+ /* What the files roster itself ships are for, in one line each. A filename says nothing about
26
+ which one to open; anything a tenant has added of its own falls back to its first heading. */
27
+ const KNOWN = {
28
+ "org.yaml": "The people, repos, defaults and coding agent.",
29
+ "org/business.md": "What the business is. Every staff member reads it.",
30
+ "org/priorities.md": "This month's priorities, in order, and what's out of scope.",
31
+ "org/operating.md": "What staff do on their own, what they ask about, and how a run starts and ends.",
32
+ "org/guardrails.md": "Rules every staff member must follow.",
33
+ "org/voice.md": "How staff write.",
34
+ "prompts/daily.md": "The instructions for a scheduled run.",
35
+ "prompts/mention.md": "The instructions when you @-mention someone.",
36
+ "prompts/_identity.md": "Who staff post as.",
37
+ "prompts/_paths.md": "Where files are during a run.",
38
+ "prompts/_inflight.md": "Pull requests people have open, so staff don't work on the same files.",
39
+ };
40
+
41
+ const GROUPS = [
42
+ ["org", "Org files", "Every staff member reads these."],
43
+ ["prompts", "Prompts", "Roster's run instructions. Upgrades may change them."],
25
44
  ];
26
45
 
27
46
  export function viewOrg(m) {
@@ -31,50 +50,96 @@ export function viewOrg(m) {
31
50
  el("p", {
32
51
  className: "sub",
33
52
  textContent:
34
- "The layer every staff member inherits. Editing one of these reaches all " +
35
- org.staff.length + " of them on their next run.",
53
+ "Files every staff member reads. Changes apply from their next run.",
36
54
  }),
37
55
  );
38
56
 
39
57
  const facts = el("div", { className: "grid", style: "margin-bottom:18px" });
40
- const human = org.human ?? {};
58
+ const humans = humansOf();
41
59
  facts.append(
42
60
  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"),
61
+ /* Plural on purpose. An org can answer to more than one person, and a card that names one
62
+ of two is worse than one that names neither: it reads as the only one who counts. */
63
+ kv(
64
+ "They answer to",
65
+ humans.length
66
+ ? humans.map((h) => esc(h.name ?? h.github)).join(", ")
67
+ : "nobody",
68
+ humans.length
69
+ ? humans.map((h) => (h.github ? "@" + h.github : "no github handle")).join(" · ")
70
+ : "org.yaml names no human, so nothing can wake an agent",
71
+ ),
44
72
  kv("Staff", String(org.staff.length), org.staff.map((s) => s.handle).join(", ")),
45
73
  );
46
74
  m.append(facts);
47
75
 
76
+ /* Marking a product repo after setup. The picker only lived on the setup screen, so a repo
77
+ created later meant typing `role: product` into org.yaml by hand. Folded, because it asks
78
+ GitHub for every repo in the org and most visits here are to read a file. */
79
+ const repoPane = el("div");
80
+ const repos = el("button", { className: "ghbtn", textContent: "Add a product repo" });
81
+ repos.onclick = () => {
82
+ if (repoPane.children.length) {
83
+ repoPane.replaceChildren();
84
+ return;
85
+ }
86
+ repoPane.replaceChildren(repoPicker({ org: org.org, onAdded: () => open("org.yaml") }));
87
+ };
88
+ m.append(el("div", { className: "row", style: "margin-bottom:14px" }, [repos]), repoPane);
89
+
48
90
  const split = el("div", { className: "split" });
49
91
  const tree = el("div", { className: "tree" });
50
92
  const viewer = el("div", { className: "viewer" });
51
93
  split.append(tree, viewer);
52
94
  m.append(split);
53
95
 
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
96
  const full = (path) => S.data.opsName + "/" + path;
74
97
 
75
- open(S.orgOpen ?? DOCS[0][0]);
98
+ tree.replaceChildren(...skeleton("row", 6));
99
+ getOrgLayer()
100
+ .then(({ files }) => {
101
+ S.orgFiles = files ?? [];
102
+ paintTree();
103
+ const first = S.orgFiles[0]?.path;
104
+ open(S.orgFiles.some((f) => f.path === S.orgOpen) ? S.orgOpen : first);
105
+ })
106
+ .catch((e) => {
107
+ tree.replaceChildren(el("p", { className: "empty err", textContent: e.message }));
108
+ });
109
+
110
+ function why(file) {
111
+ return KNOWN[file.path] ?? file.title ?? "";
112
+ }
113
+
114
+ function paintTree() {
115
+ tree.replaceChildren();
116
+ if (!S.orgFiles.length) {
117
+ tree.append(el("p", { className: "empty", textContent: "No org files here." }));
118
+ return;
119
+ }
120
+ for (const [id, heading, note] of GROUPS) {
121
+ const mine = S.orgFiles.filter((f) => (f.group ?? "org") === id);
122
+ if (!mine.length) continue;
123
+ const group = el("div", { className: "navgroup" });
124
+ group.append(el("div", { className: "ghead", textContent: heading, title: note }));
125
+ for (const f of mine) {
126
+ const b = el("button", { className: "tfile tlayer", title: why(f) });
127
+ b.dataset.key = f.path;
128
+ const t = el("span", { className: "t" });
129
+ t.append(
130
+ el("span", { className: "lname", textContent: f.path.replace(/^(org|prompts)\//, "") }),
131
+ el("span", { className: "lrepo", textContent: why(f) }),
132
+ );
133
+ b.append(t);
134
+ b.onclick = () => open(f.path);
135
+ group.append(b);
136
+ }
137
+ tree.append(group);
138
+ }
139
+ }
76
140
 
77
141
  function open(path) {
142
+ if (!path) return;
78
143
  S.orgOpen = path;
79
144
  writeHash(false);
80
145
  for (const o of tree.querySelectorAll(".tfile")) {
@@ -84,7 +149,7 @@ export function viewOrg(m) {
84
149
  }
85
150
 
86
151
  async function show(path) {
87
- viewer.replaceChildren(el("p", { className: "empty", textContent: "Loading…" }));
152
+ viewer.replaceChildren(...skeleton("head", 1), ...skeleton("line", 7));
88
153
  let text;
89
154
  try {
90
155
  text = await getFile(full(path));
@@ -97,72 +162,27 @@ export function viewOrg(m) {
97
162
 
98
163
  function render(path, text) {
99
164
  viewer.replaceChildren();
100
- const why = DOCS.find(([p]) => p === path)?.[1] ?? "";
101
- const head = el("div", { className: "row", style: "margin-bottom:4px" });
165
+ const head = el("div", { className: "row filehead" });
102
166
  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 }));
108
- return;
109
- }
110
- viewer.append(el("div", { className: "md doc", innerHTML: mdlite(text) }));
111
- }
112
-
113
- function edit(path, text) {
114
- viewer.replaceChildren();
115
- viewer.append(
116
- el("div", { className: "row", style: "margin-bottom:10px" }, [
117
- el("span", { className: "meta", textContent: full(path) }),
118
- ]),
119
- );
120
-
121
- const ta = el("textarea", { value: text, className: "editor" });
122
- viewer.append(ta);
123
- grow(ta);
124
- ta.addEventListener("input", () => grow(ta));
125
-
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
- cancel.onclick = () => render(path, text);
130
- viewer.append(el("div", { className: "row", style: "margin-top:10px" }, [save, cancel, status]));
167
+ const file = S.orgFiles.find((f) => f.path === path) ?? { path };
168
+
169
+ const isYaml = /\.ya?ml$/.test(path);
170
+ /* Every file opens on Read, with the ways to change it as tabs beside it: the same ones
171
+ Getting started offers, because a file is no easier to write after setup than during it.
172
+ org.yaml is checked by the server before it is written, so a bad one is refused. */
173
+ viewer.append(head, el("p", { className: "factsub", textContent: why(file) }));
174
+ const read = {
175
+ label: "Read",
176
+ node: isYaml ? yamlPre(text) : el("div", { className: "md doc", innerHTML: mdlite(text) }),
177
+ };
178
+ const o = { path: full(path), name: S.data.name, text, before: [read], onSaved: () => show(path) };
131
179
  viewer.append(
132
- el("p", {
133
- className: "editnote",
134
- textContent:
135
- "Commits to " + S.data.opsName + " and pushes. Picked up by every staff member on their " +
136
- "next run." +
137
- (path.endsWith(".yaml")
138
- ? " org.yaml is checked before it is written: bad YAML, or a missing org or name, is refused rather than committed."
139
- : ""),
140
- }),
180
+ path === "org/business.md"
181
+ ? businessForm(o)
182
+ : path === "org/priorities.md"
183
+ ? prioritiesForm(o)
184
+ : fileModes({ ...o, lang: isYaml ? "yaml" : "md" }),
141
185
  );
142
-
143
- save.onclick = async () => {
144
- if (ta.value === text) {
145
- status.textContent = "nothing changed";
146
- return;
147
- }
148
- if (!confirm("Commit " + full(path) + " and push?\n\nEvery staff member picks this up on their next run.")) return;
149
- save.disabled = cancel.disabled = true;
150
- status.textContent = "committing…";
151
- status.className = "meta";
152
- try {
153
- const r = await post({ path: full(path), text: ta.value }, "/api/save");
154
- status.textContent = r.pushed
155
- ? "committed and pushed · " + r.sha
156
- : "committed " + r.sha + ", but the push failed: " + (r.note ?? "");
157
- status.className = r.pushed ? "meta ok" : "meta warn";
158
- render(path, ta.value);
159
- } catch (e) {
160
- // A refused org.yaml is the interesting case: the reason is the whole message.
161
- status.textContent = e.message;
162
- status.className = "meta err";
163
- save.disabled = cancel.disabled = false;
164
- }
165
- };
166
186
  }
167
187
  }
168
188
 
@@ -0,0 +1,234 @@
1
+ /* Ways to write the two org files a person has to write, shared by Getting started and the Org
2
+ * screen: plain questions, an AI interview, or the file itself. Each is one tab of a switcher,
3
+ * so the choice is visible rather than folded under a Save button. */
4
+
5
+ import { fileUrl, saveFile } from "../api.js";
6
+ import { el, grow } from "../dom.js";
7
+ import { highlighted } from "../mdedit.js";
8
+ import { paste } from "./paste.js";
9
+
10
+ const BUSINESS_QUESTIONS = [
11
+ ["line", "What does it do, in one line?", "What this business does, in one line", true],
12
+ ["who", "Who is it for?", "Who the customers are, specifically", true],
13
+ ["key", "What does everything depend on?", "The one fact everything else follows from", false],
14
+ ["true", "What's already true? What's built, measured or tried?", "What is already true", false],
15
+ ["not", "What should staff never decide on their own?", "What is not ours to decide", false],
16
+ ];
17
+
18
+ const val = (input) => String(input.value ?? "").trim();
19
+
20
+ /* A file still in the shape it shipped in holds the questions, not answers, and pre-filling the
21
+ fields with "Not a segment. The person…" reads as if somebody had already answered them. */
22
+ const isStub = (text) => /This file is a stub|Or answer these by hand/.test(text);
23
+
24
+ /**
25
+ * org/business.md as five questions. With `text`, the answers already in the file fill the
26
+ * fields, so changing one answer does not mean retyping the other four.
27
+ * @param {{path: string, name: string, text?: string, onSaved: () => void, before?: object[]}} o
28
+ * `before` puts extra tabs ahead of the three, which is how the Org screen adds Read.
29
+ */
30
+ export function businessForm(o) {
31
+ const form = el("div");
32
+ const known = isStub(o.text ?? "") ? new Map() : sections(o.text ?? "");
33
+ const fields = BUSINESS_QUESTIONS.map(([id, label, heading, required]) => {
34
+ const input = id === "line" ? el("input", { type: "text" }) : el("textarea", { className: "pastebox", rows: 3 });
35
+ const had = known.get(heading.toLowerCase());
36
+ if (had) input.value = had.replace(/^>\s?/gm, "");
37
+ return { id, input, wrap: el("label", { className: "qfield" }, [el("span", { textContent: label + (required ? "" : " (optional)") }), input]) };
38
+ });
39
+ const save = el("button", { className: "btn primary", textContent: "Save" });
40
+ const out = el("div");
41
+ form.append(...fields.map((f) => f.wrap), el("div", { className: "row" }, [save]), out);
42
+
43
+ save.onclick = async () => {
44
+ const missing = fields.find((f, i) => BUSINESS_QUESTIONS[i][3] && !val(f.input));
45
+ if (missing) {
46
+ missing.input.focus?.();
47
+ out.replaceChildren(el("p", { className: "err", textContent: "Answer the first two at least." }));
48
+ return;
49
+ }
50
+ const text =
51
+ `# What ${o.name} is\n\n` +
52
+ fields
53
+ .map((f, i) => [BUSINESS_QUESTIONS[i][2], val(f.input)])
54
+ .filter(([, v]) => v)
55
+ .map(([h, v]) => `## ${h}\n\n${v}\n`)
56
+ .join("\n");
57
+ await saveAndSay(save, out, o.path, text, "portal: write org/business.md", o.onSaved);
58
+ };
59
+
60
+ return modes([
61
+ ...(o.before ?? []),
62
+ { label: "Answer questions", node: form },
63
+ { label: "Let an AI interview you", node: paste({ kind: "discover", title: "", onSaved: o.onSaved }) },
64
+ rawMode(o.path, o.onSaved),
65
+ ]);
66
+ }
67
+
68
+ /** org/priorities.md as three lines and an out-of-scope list, filled from the file if it has them. */
69
+ export function prioritiesForm(o) {
70
+ const form = el("div");
71
+ const { items, outs } = isStub(o.text ?? "") ? { items: [], outs: [] } : parsePriorities(o.text ?? "");
72
+ const ranks = [1, 2, 3].map((i) => {
73
+ const input = el("input", { type: "text", placeholder: i === 1 ? "e.g. Tasks can be ticked off and removed" : "" });
74
+ if (items[i - 1]) input.value = items[i - 1];
75
+ return { input, wrap: el("label", { className: "qfield" }, [el("span", { textContent: "Priority " + i + (i > 1 ? " (optional)" : "") }), input]) };
76
+ });
77
+ const scope = el("textarea", { className: "pastebox", rows: 3, placeholder: "One per line" });
78
+ if (outs.length) scope.value = outs.join("\n");
79
+ const save = el("button", { className: "btn primary", textContent: "Save" });
80
+ const out = el("div");
81
+ form.append(
82
+ ...ranks.map((r) => r.wrap),
83
+ el("label", { className: "qfield" }, [el("span", { textContent: "Out of scope this month (optional)" }), scope]),
84
+ el("div", { className: "row" }, [save]),
85
+ out,
86
+ );
87
+ save.onclick = async () => {
88
+ const list = ranks.map((r) => val(r.input)).filter(Boolean);
89
+ if (!list.length) {
90
+ ranks[0].input.focus?.();
91
+ out.replaceChildren(el("p", { className: "err", textContent: "Add at least one priority." }));
92
+ return;
93
+ }
94
+ const scoped = val(scope).split("\n").map((l) => l.replace(/^[-*]\s*/, "").trim()).filter(Boolean);
95
+ const text =
96
+ "## What matters this month\n\n### Priorities, in order\n\n" +
97
+ list.map((t, i) => `${i + 1}. ${t}`).join("\n") +
98
+ "\n" +
99
+ (scoped.length ? "\n### Out of scope this month\n\n" + scoped.map((t) => `- ${t}`).join("\n") + "\n" : "");
100
+ await saveAndSay(save, out, o.path, text, "portal: write org/priorities.md", o.onSaved);
101
+ };
102
+ return modes([
103
+ ...(o.before ?? []),
104
+ { label: "Fill in", node: form },
105
+ { label: "Let an AI interview you", node: paste({ kind: "priorities", title: "", onSaved: o.onSaved }) },
106
+ rawMode(o.path, o.onSaved),
107
+ ]);
108
+ }
109
+
110
+ /** Any org file as highlighted text, with Read ahead of it on the Org screen. */
111
+ export function fileModes(o) {
112
+ return modes([...(o.before ?? []), rawMode(o.path, o.onSaved, o.lang)]);
113
+ }
114
+
115
+ /**
116
+ * The file itself, for editing what is already there rather than starting again.
117
+ * @param {{label?: string, note?: string, read?: () => Promise<string>}} o
118
+ * `read` starts the editor from other text than the file, once, so switching tabs does not
119
+ * throw away what was typed. `note` is a line above the editor.
120
+ */
121
+ export function rawMode(path, onSaved, lang = "md", o = {}) {
122
+ const node = el("div");
123
+ if (o.note) node.append(el("p", { className: "sub", textContent: o.note }));
124
+ const ta = el("textarea", { className: "pastebox" });
125
+ ta.rows = 12;
126
+ const save = el("button", { className: "btn primary", textContent: "Save" });
127
+ const out = el("div");
128
+ const hl = highlighted(ta, lang);
129
+ node.append(hl, el("div", { className: "row" }, [save]), out);
130
+ ta.addEventListener("input", () => grow(ta));
131
+ const commit = () =>
132
+ saveAndSay(save, out, path, ta.value, "portal: edit " + path.split("/").slice(1).join("/"), onSaved);
133
+ save.onclick = commit;
134
+ // ⌘S is what a person's hands do in a text box.
135
+ ta.addEventListener("keydown", (e) => {
136
+ if ((e.metaKey || e.ctrlKey) && e.key === "s") {
137
+ e.preventDefault();
138
+ commit();
139
+ }
140
+ });
141
+ // Read when first shown, so it shows the file as it is then, not as it was at page load.
142
+ let read = false;
143
+ const load = () => {
144
+ if (o.read && read) return;
145
+ read = true;
146
+ return (
147
+ o.read
148
+ ? o.read()
149
+ : // Not getFile: an absent file answers 404 with a message, and that is not what to edit.
150
+ fetch(fileUrl(path), { cache: "no-store" }).then((r) => (r.ok ? r.text() : ""))
151
+ ).then((text) => {
152
+ ta.value = text;
153
+ grow(ta);
154
+ hl.repaint();
155
+ });
156
+ };
157
+ return { label: o.label ?? "Edit the file", node, onShow: load };
158
+ }
159
+
160
+ /** Ways to do one thing, as a switcher across the top: one shown at a time. */
161
+ export function modes(list) {
162
+ const box = el("div");
163
+ const bar = el("div", { className: "modes" });
164
+ const panes = el("div");
165
+ const buttons = list.map((m, i) => {
166
+ const b = el("button", { className: "mode", textContent: m.label });
167
+ b.onclick = () => show(i);
168
+ return b;
169
+ });
170
+ bar.append(...buttons);
171
+ box.append(bar, panes);
172
+ function show(i) {
173
+ buttons.forEach((b, j) => b.classList.toggle("on", i === j));
174
+ panes.replaceChildren(list[i].node);
175
+ list[i].onShow?.();
176
+ }
177
+ show(0);
178
+ return box;
179
+ }
180
+
181
+ async function saveAndSay(btn, out, path, text, message, onSaved) {
182
+ btn.disabled = true;
183
+ const label = btn.textContent;
184
+ btn.textContent = "Saving…";
185
+ try {
186
+ const r = await saveFile(path, text, message);
187
+ out.replaceChildren(
188
+ el("p", {
189
+ className: r.pushed ? "sub" : "err",
190
+ textContent: r.pushed ? "Saved." : "Saved locally, but the push failed: " + (r.note ?? ""),
191
+ }),
192
+ );
193
+ onSaved?.();
194
+ } catch (err) {
195
+ out.replaceChildren(el("p", { className: "err", textContent: String(err.message || err) }));
196
+ } finally {
197
+ btn.disabled = false;
198
+ btn.textContent = label;
199
+ }
200
+ }
201
+
202
+ /** `## Heading` → the text under it, by lower-cased heading. */
203
+ function sections(text) {
204
+ const out = new Map();
205
+ let key = null;
206
+ let buf = [];
207
+ const flush = () => key && out.set(key, buf.join("\n").trim());
208
+ for (const line of text.split("\n")) {
209
+ const m = /^##\s+(.*)$/.exec(line);
210
+ if (m) {
211
+ flush();
212
+ key = m[1].trim().toLowerCase();
213
+ buf = [];
214
+ } else if (key) buf.push(line);
215
+ }
216
+ flush();
217
+ return out;
218
+ }
219
+
220
+ function parsePriorities(text) {
221
+ const items = [];
222
+ const outs = [];
223
+ let where = "";
224
+ for (const line of text.split("\n")) {
225
+ if (/^#+\s.*priorit/i.test(line)) where = "p";
226
+ else if (/^#+\s.*out of scope/i.test(line)) where = "o";
227
+ else if (/^#/.test(line)) where = "";
228
+ const n = /^\s*\d+[.)]\s+(.*)$/.exec(line);
229
+ const b = /^\s*[-*]\s+(.*)$/.exec(line);
230
+ if (where === "p" && n) items.push(n[1].trim());
231
+ if (where === "o" && b) outs.push(b[1].trim());
232
+ }
233
+ return { items, outs };
234
+ }