@nanocollective/roster 0.1.0-alpha.15 → 0.1.0-alpha.16

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanocollective/roster",
3
- "version": "0.1.0-alpha.15",
3
+ "version": "0.1.0-alpha.16",
4
4
  "description": "An agent-run org, powered by GitHub. Scaffold AI staff members whose brain is a repo.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -75,3 +75,22 @@ pre.yaml{color:var(--ink)}
75
75
  .md img{display:block;max-width:100%;height:auto;margin:14px 0;
76
76
  border:1px solid var(--line);border-radius:var(--r);box-shadow:var(--shadow)}
77
77
  .md img+em,.md p>img+em{display:block}
78
+
79
+ /* ---------- highlighted editors (mdedit.js) ---------- */
80
+
81
+ .hled{position:relative}
82
+ .hled .hlback{position:absolute;inset:0;margin:0;pointer-events:none;overflow:hidden;
83
+ white-space:pre-wrap;overflow-wrap:break-word;word-break:normal;color:var(--ink);
84
+ border-color:transparent !important;background:transparent !important}
85
+ .hled .hlfront{position:relative;color:transparent;background:transparent;caret-color:var(--ink);
86
+ -webkit-text-fill-color:transparent}
87
+ .hled .hlfront::selection{background:var(--accent-soft)}
88
+ .mdh{color:var(--ruled);font-weight:700}
89
+ .mdb{font-weight:700}
90
+ .mdi{font-style:italic}
91
+ .mdc,.mdcode,.mdfence{color:var(--derived)}
92
+ .mdq{color:var(--ink-dim);font-style:italic}
93
+ .mdl{color:var(--accent)}
94
+ .mdk{color:var(--accent)}
95
+ .mdu{color:var(--ink-faint)}
96
+ .mdt{color:var(--ruled)}
@@ -0,0 +1,84 @@
1
+ /* Colour behind a textarea, for the files people edit here.
2
+ *
3
+ * The textarea stays the thing you type into, so undo, selection, spellcheck and ⌘S all work
4
+ * as they always did. Its text is made transparent and a <pre> with the same box sits behind
5
+ * it, redrawn on every keystroke. The editors grow to fit rather than scroll, which is what
6
+ * keeps the two lined up without syncing a scroll position.
7
+ *
8
+ * Markdown here is a per-line tokeniser, like yaml.js: enough to read by, and anything it does
9
+ * not recognise is plain text rather than an error.
10
+ */
11
+
12
+ import { el, esc } from "./dom.js";
13
+ import { yamlHTML } from "./yaml.js";
14
+
15
+ /**
16
+ * Wrap a textarea so its contents are highlighted. Returns the wrapper, which is what goes on
17
+ * the page in place of the textarea.
18
+ * @param {"md"|"yaml"} lang
19
+ */
20
+ export function highlighted(ta, lang = "md") {
21
+ const wrap = el("div", { className: "hled" });
22
+ const back = el("pre", { className: "hlback" });
23
+ back.setAttribute?.("aria-hidden", "true");
24
+ ta.classList?.add("hlfront");
25
+ wrap.append(back, ta);
26
+ /* The colours only line up if both boxes set text identically, and the textarea's box comes
27
+ from whichever class it already had, so it is read rather than restated in CSS. */
28
+ const match = () => {
29
+ if (typeof getComputedStyle !== "function" || !ta.isConnected) return;
30
+ const cs = getComputedStyle(ta);
31
+ for (const k of ["font", "lineHeight", "letterSpacing", "tabSize", "padding", "borderWidth", "borderStyle", "borderRadius"]) {
32
+ back.style[k] = cs[k];
33
+ }
34
+ };
35
+ const paint = () => {
36
+ match();
37
+ // A trailing newline in a textarea is a line; in a <pre> it is not, without something after it.
38
+ back.innerHTML = (lang === "yaml" ? yamlHTML(ta.value) : markdownHTML(ta.value)) + "\n ";
39
+ };
40
+ ta.addEventListener("input", paint);
41
+ paint();
42
+ // Setting .value from code fires no input event, so the owner can ask for a repaint.
43
+ wrap.repaint = paint;
44
+ return wrap;
45
+ }
46
+
47
+ /** Escaped, highlighted HTML for markdown source. */
48
+ export function markdownHTML(text) {
49
+ let fenced = false;
50
+ return String(text ?? "")
51
+ .split("\n")
52
+ .map((line) => {
53
+ if (/^\s*(```|~~~)/.test(line)) {
54
+ fenced = !fenced;
55
+ return span("mdfence", line);
56
+ }
57
+ if (fenced) return span("mdcode", line);
58
+ if (/^#{1,6}\s/.test(line)) return span("mdh", line);
59
+ if (/^\s*>/.test(line)) return span("mdq", line);
60
+ if (/^\s*([-*_])(\s*\1){2,}\s*$/.test(line)) return span("mdfence", line);
61
+ const list = /^(\s*)([-*+]|\d+[.)])(\s+)(.*)$/.exec(line);
62
+ if (list) return esc(list[1]) + span("mdl", list[2]) + esc(list[3]) + inline(list[4]);
63
+ return inline(line);
64
+ })
65
+ .join("\n");
66
+ }
67
+
68
+ const span = (cls, text) => '<span class="' + cls + '">' + esc(text) + "</span>";
69
+
70
+ /** Inline marks, on one line. Code spans first, so nothing inside one is read as markup. */
71
+ function inline(line) {
72
+ return line
73
+ .split(/(`[^`]+`)/)
74
+ .map((part, i) => (i % 2 ? span("mdc", part) : marks(part)))
75
+ .join("");
76
+ }
77
+
78
+ function marks(text) {
79
+ return esc(text)
80
+ .replace(/\{\{[^}]*\}\}/g, (m) => '<span class="mdt">' + m + "</span>")
81
+ .replace(/(\*\*|__)(?=\S)(.+?)(?<=\S)\1/g, '<span class="mdb">$1$2$1</span>')
82
+ .replace(/(^|[^*\w])([*_])(?=\S)([^*_]+?)(?<=\S)\2(?![*\w])/g, '$1<span class="mdi">$2$3$2</span>')
83
+ .replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, '<span class="mdk">[$1]</span><span class="mdu">($2)</span>');
84
+ }
@@ -14,6 +14,7 @@
14
14
  * wrong. The server validates it before writing.
15
15
  */
16
16
 
17
+ import { highlighted } from "../mdedit.js";
17
18
  import { getFile, getOrgLayer, post } from "../api.js";
18
19
  import { askYes } from "../dialog.js";
19
20
  import { el, esc, grow, kb, skeleton } from "../dom.js";
@@ -21,31 +22,27 @@ import { icon } from "../icons.js";
21
22
  import { mdlite } from "../md.js";
22
23
  import { humansOf, S, writeHash } from "../state.js";
23
24
  import { yamlPre } from "../yaml.js";
25
+ import { businessForm, fileModes, prioritiesForm } from "./orgedit.js";
24
26
  import { repoPicker } from "./repos.js";
25
27
 
26
28
  /* What the files roster itself ships are for, in one line each. A filename says nothing about
27
29
  which one to open; anything a tenant has added of its own falls back to its first heading. */
28
30
  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.",
31
+ "org.yaml": "The people, repos, defaults and coding agent.",
32
+ "org/business.md": "What the business is. Every staff member reads it.",
33
+ "org/priorities.md": "This month's priorities, in order, and what's out of scope.",
34
+ "org/operating.md": "What staff do on their own, what they ask about, and how a run starts and ends.",
35
+ "org/guardrails.md": "Rules every staff member must follow.",
36
+ "org/voice.md": "How staff write.",
37
+ "prompts/daily.md": "The instructions for a scheduled run.",
38
+ "prompts/mention.md": "The instructions when you @-mention someone.",
39
+ "prompts/_identity.md": "Who staff post as.",
40
+ "prompts/_paths.md": "Where files are during a run.",
44
41
  };
45
42
 
46
43
  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."],
44
+ ["org", "Org files", "Every staff member reads these."],
45
+ ["prompts", "Prompts", "Roster's run instructions. Upgrades may change them."],
49
46
  ];
50
47
 
51
48
  export function viewOrg(m) {
@@ -55,8 +52,7 @@ export function viewOrg(m) {
55
52
  el("p", {
56
53
  className: "sub",
57
54
  textContent:
58
- "The layer every staff member inherits. Editing one of these reaches all " +
59
- org.staff.length + " of them on their next run.",
55
+ "Files every staff member reads. Changes apply from their next run.",
60
56
  }),
61
57
  );
62
58
 
@@ -172,17 +168,28 @@ export function viewOrg(m) {
172
168
  head.append(el("span", { className: "meta", textContent: full(path) + " · " + kb(text.length) }));
173
169
  /* The edit control used to be a word in a row of grey text, which is why "there is no easy
174
170
  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
171
  const file = S.orgFiles.find((f) => f.path === path) ?? { path };
179
- viewer.append(head, el("p", { className: "factsub", textContent: why(file) }));
180
172
 
181
173
  if (path.endsWith(".yaml") || path.endsWith(".yml")) {
182
- viewer.append(yamlPre(text));
174
+ const edit = el("button", { className: "ghbtn primary editbtn", onclick: () => editor(path, text) });
175
+ edit.append(icon("edit", "ic"), el("span", { textContent: "Edit" }));
176
+ head.append(edit);
177
+ viewer.append(head, el("p", { className: "factsub", textContent: why(file) }), yamlPre(text));
183
178
  return;
184
179
  }
185
- viewer.append(el("div", { className: "md doc", innerHTML: mdlite(text) }));
180
+
181
+ /* Prose files open on Read, with the ways to change them as tabs beside it: the same ones
182
+ Getting started offers, because a file is no easier to write after setup than during it. */
183
+ viewer.append(head, el("p", { className: "factsub", textContent: why(file) }));
184
+ const read = { label: "Read", node: el("div", { className: "md doc", innerHTML: mdlite(text) }) };
185
+ const o = { path: full(path), name: S.data.name, text, before: [read], onSaved: () => show(path) };
186
+ viewer.append(
187
+ path === "org/business.md"
188
+ ? businessForm(o)
189
+ : path === "org/priorities.md"
190
+ ? prioritiesForm(o)
191
+ : fileModes(o),
192
+ );
186
193
  }
187
194
 
188
195
  function editor(path, text) {
@@ -198,8 +205,10 @@ export function viewOrg(m) {
198
205
  viewer.append(bar);
199
206
 
200
207
  const ta = el("textarea", { value: text, className: "editor" });
201
- viewer.append(ta);
208
+ const hl = highlighted(ta, isYaml ? "yaml" : "md");
209
+ viewer.append(hl);
202
210
  grow(ta);
211
+ hl.repaint();
203
212
  ta.addEventListener("input", () => grow(ta));
204
213
  // ⌘S is what a person's hands do in a text box. Without it, saving means finding a button
205
214
  // above a screenful of textarea you have just scrolled past.
@@ -0,0 +1,213 @@
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
+ /**
21
+ * org/business.md as five questions. With `text`, the answers already in the file fill the
22
+ * fields, so changing one answer does not mean retyping the other four.
23
+ * @param {{path: string, name: string, text?: string, onSaved: () => void, before?: object[]}} o
24
+ * `before` puts extra tabs ahead of the three, which is how the Org screen adds Read.
25
+ */
26
+ export function businessForm(o) {
27
+ const form = el("div");
28
+ const known = sections(o.text ?? "");
29
+ const fields = BUSINESS_QUESTIONS.map(([id, label, heading, required]) => {
30
+ const input = id === "line" ? el("input", { type: "text" }) : el("textarea", { className: "pastebox", rows: 3 });
31
+ const had = known.get(heading.toLowerCase());
32
+ if (had) input.value = had.replace(/^>\s?/gm, "");
33
+ return { id, input, wrap: el("label", { className: "qfield" }, [el("span", { textContent: label + (required ? "" : " (optional)") }), input]) };
34
+ });
35
+ const save = el("button", { className: "btn primary", textContent: "Save" });
36
+ const out = el("div");
37
+ form.append(...fields.map((f) => f.wrap), el("div", { className: "row" }, [save]), out);
38
+
39
+ save.onclick = async () => {
40
+ const missing = fields.find((f, i) => BUSINESS_QUESTIONS[i][3] && !val(f.input));
41
+ if (missing) {
42
+ missing.input.focus?.();
43
+ out.replaceChildren(el("p", { className: "err", textContent: "Answer the first two at least." }));
44
+ return;
45
+ }
46
+ const text =
47
+ `# What ${o.name} is\n\n` +
48
+ fields
49
+ .map((f, i) => [BUSINESS_QUESTIONS[i][2], val(f.input)])
50
+ .filter(([, v]) => v)
51
+ .map(([h, v]) => `## ${h}\n\n${v}\n`)
52
+ .join("\n");
53
+ await saveAndSay(save, out, o.path, text, "portal: write org/business.md", o.onSaved);
54
+ };
55
+
56
+ return modes([
57
+ ...(o.before ?? []),
58
+ { label: "Answer questions", node: form },
59
+ { label: "Let an AI interview you", node: paste({ kind: "discover", title: "", onSaved: o.onSaved }) },
60
+ rawMode(o.path, o.onSaved),
61
+ ]);
62
+ }
63
+
64
+ /** org/priorities.md as three lines and an out-of-scope list, filled from the file if it has them. */
65
+ export function prioritiesForm(o) {
66
+ const form = el("div");
67
+ const { items, outs } = parsePriorities(o.text ?? "");
68
+ const ranks = [1, 2, 3].map((i) => {
69
+ const input = el("input", { type: "text", placeholder: i === 1 ? "e.g. Tasks can be ticked off and removed" : "" });
70
+ if (items[i - 1]) input.value = items[i - 1];
71
+ return { input, wrap: el("label", { className: "qfield" }, [el("span", { textContent: "Priority " + i + (i > 1 ? " (optional)" : "") }), input]) };
72
+ });
73
+ const scope = el("textarea", { className: "pastebox", rows: 3, placeholder: "One per line" });
74
+ if (outs.length) scope.value = outs.join("\n");
75
+ const save = el("button", { className: "btn primary", textContent: "Save" });
76
+ const out = el("div");
77
+ form.append(
78
+ ...ranks.map((r) => r.wrap),
79
+ el("label", { className: "qfield" }, [el("span", { textContent: "Out of scope this month (optional)" }), scope]),
80
+ el("div", { className: "row" }, [save]),
81
+ out,
82
+ );
83
+ save.onclick = async () => {
84
+ const list = ranks.map((r) => val(r.input)).filter(Boolean);
85
+ if (!list.length) {
86
+ ranks[0].input.focus?.();
87
+ out.replaceChildren(el("p", { className: "err", textContent: "Add at least one priority." }));
88
+ return;
89
+ }
90
+ const scoped = val(scope).split("\n").map((l) => l.replace(/^[-*]\s*/, "").trim()).filter(Boolean);
91
+ const text =
92
+ "## What matters this month\n\n### Priorities, in order\n\n" +
93
+ list.map((t, i) => `${i + 1}. ${t}`).join("\n") +
94
+ "\n" +
95
+ (scoped.length ? "\n### Out of scope this month\n\n" + scoped.map((t) => `- ${t}`).join("\n") + "\n" : "");
96
+ await saveAndSay(save, out, o.path, text, "portal: write org/priorities.md", o.onSaved);
97
+ };
98
+ return modes([...(o.before ?? []), { label: "Fill in", node: form }, rawMode(o.path, o.onSaved)]);
99
+ }
100
+
101
+ /** Any org file as highlighted text, with Read ahead of it on the Org screen. */
102
+ export function fileModes(o) {
103
+ return modes([...(o.before ?? []), rawMode(o.path, o.onSaved, o.lang)]);
104
+ }
105
+
106
+ /** The file itself, for editing what is already there rather than starting again. */
107
+ export function rawMode(path, onSaved, lang = "md") {
108
+ const node = el("div");
109
+ const ta = el("textarea", { className: "pastebox" });
110
+ ta.rows = 12;
111
+ const save = el("button", { className: "btn primary", textContent: "Save" });
112
+ const out = el("div");
113
+ const hl = highlighted(ta, lang);
114
+ node.append(hl, el("div", { className: "row" }, [save]), out);
115
+ ta.addEventListener("input", () => grow(ta));
116
+ const commit = () =>
117
+ saveAndSay(save, out, path, ta.value, "portal: edit " + path.split("/").slice(1).join("/"), onSaved);
118
+ save.onclick = commit;
119
+ // ⌘S is what a person's hands do in a text box.
120
+ ta.addEventListener("keydown", (e) => {
121
+ if ((e.metaKey || e.ctrlKey) && e.key === "s") {
122
+ e.preventDefault();
123
+ commit();
124
+ }
125
+ });
126
+ // Read when first shown, so it shows the file as it is then, not as it was at page load.
127
+ const load = () =>
128
+ // Not getFile: an absent file answers 404 with a message, and that is not what to edit.
129
+ fetch(fileUrl(path), { cache: "no-store" })
130
+ .then((r) => (r.ok ? r.text() : ""))
131
+ .then((text) => {
132
+ ta.value = text;
133
+ grow(ta);
134
+ hl.repaint();
135
+ });
136
+ return { label: "Edit the file", node, onShow: load };
137
+ }
138
+
139
+ /** Ways to do one thing, as a switcher across the top: one shown at a time. */
140
+ export function modes(list) {
141
+ const box = el("div");
142
+ const bar = el("div", { className: "modes" });
143
+ const panes = el("div");
144
+ const buttons = list.map((m, i) => {
145
+ const b = el("button", { className: "mode", textContent: m.label });
146
+ b.onclick = () => show(i);
147
+ return b;
148
+ });
149
+ bar.append(...buttons);
150
+ box.append(bar, panes);
151
+ function show(i) {
152
+ buttons.forEach((b, j) => b.classList.toggle("on", i === j));
153
+ panes.replaceChildren(list[i].node);
154
+ list[i].onShow?.();
155
+ }
156
+ show(0);
157
+ return box;
158
+ }
159
+
160
+ async function saveAndSay(btn, out, path, text, message, onSaved) {
161
+ btn.disabled = true;
162
+ const label = btn.textContent;
163
+ btn.textContent = "Saving…";
164
+ try {
165
+ const r = await saveFile(path, text, message);
166
+ out.replaceChildren(
167
+ el("p", {
168
+ className: r.pushed ? "sub" : "err",
169
+ textContent: r.pushed ? "Saved." : "Saved locally, but the push failed: " + (r.note ?? ""),
170
+ }),
171
+ );
172
+ onSaved?.();
173
+ } catch (err) {
174
+ out.replaceChildren(el("p", { className: "err", textContent: String(err.message || err) }));
175
+ } finally {
176
+ btn.disabled = false;
177
+ btn.textContent = label;
178
+ }
179
+ }
180
+
181
+ /** `## Heading` → the text under it, by lower-cased heading. */
182
+ function sections(text) {
183
+ const out = new Map();
184
+ let key = null;
185
+ let buf = [];
186
+ const flush = () => key && out.set(key, buf.join("\n").trim());
187
+ for (const line of text.split("\n")) {
188
+ const m = /^##\s+(.*)$/.exec(line);
189
+ if (m) {
190
+ flush();
191
+ key = m[1].trim().toLowerCase();
192
+ buf = [];
193
+ } else if (key) buf.push(line);
194
+ }
195
+ flush();
196
+ return out;
197
+ }
198
+
199
+ function parsePriorities(text) {
200
+ const items = [];
201
+ const outs = [];
202
+ let where = "";
203
+ for (const line of text.split("\n")) {
204
+ if (/^#+\s.*priorit/i.test(line)) where = "p";
205
+ else if (/^#+\s.*out of scope/i.test(line)) where = "o";
206
+ else if (/^#/.test(line)) where = "";
207
+ const n = /^\s*\d+[.)]\s+(.*)$/.exec(line);
208
+ const b = /^\s*[-*]\s+(.*)$/.exec(line);
209
+ if (where === "p" && n) items.push(n[1].trim());
210
+ if (where === "o" && b) outs.push(b[1].trim());
211
+ }
212
+ return { items, outs };
213
+ }
@@ -9,6 +9,7 @@
9
9
  * changing a byte of the composed text, and nothing said so before.
10
10
  */
11
11
 
12
+ import { highlighted } from "../mdedit.js";
12
13
  import { getFile, post } from "../api.js";
13
14
  import { askText, askYes } from "../dialog.js";
14
15
  import { el, esc, grow, kb, toClipboard } from "../dom.js";
@@ -351,8 +352,10 @@ export function viewPrompt(m) {
351
352
  );
352
353
 
353
354
  const ta = el("textarea", { value: text, className: "editor" });
354
- viewer.append(ta);
355
+ const hl = highlighted(ta, "md");
356
+ viewer.append(hl);
355
357
  grow(ta);
358
+ hl.repaint();
356
359
  ta.addEventListener("input", () => grow(ta));
357
360
 
358
361
  const status = el("span", { className: "meta" });
@@ -8,22 +8,20 @@
8
8
  import {
9
9
  checkOrg,
10
10
  createTenant,
11
- fileUrl,
12
11
  getAccess,
13
12
  getOrg,
14
13
  getSetup,
15
14
  joinOrg,
16
15
  planTenant,
17
- saveFile,
18
16
  setAccess,
19
17
  } from "../api.js";
20
- import { $, el, esc, grow, toClipboard } from "../dom.js";
18
+ import { $, el, esc, toClipboard } from "../dom.js";
21
19
  import { go } from "../router.js";
22
20
  import { S as App } from "../state.js";
23
21
  import { checklist } from "./checklist.js";
22
+ import { businessForm, prioritiesForm } from "./orgedit.js";
24
23
  import { credentialPanel } from "./credential.js";
25
24
  import { repoPicker } from "./repos.js";
26
- import { paste } from "./paste.js";
27
25
 
28
26
  let S = null; // /api/setup/status, refreshed after anything that changes the world
29
27
 
@@ -394,13 +392,24 @@ function afterCreate() {
394
392
 
395
393
  const business = todo(++n, "Describe the business", !left.includes("business"));
396
394
  business.hint("A few short answers. Every staff member reads them before they start work.");
397
- business.body.append(businessForm(() => business.setDone(true)));
395
+ business.body.append(
396
+ businessForm({
397
+ path: opsName() + "/org/business.md",
398
+ name: App.data?.name ?? S.tenant.name ?? S.tenant.org,
399
+ onSaved: () => (business.setDone(true), recheck()),
400
+ }),
401
+ );
398
402
  fileSteps.set("business", business.setDone);
399
403
  list.append(business.card);
400
404
 
401
405
  const priorities = todo(++n, "Set this month's priorities", !left.includes("priorities"));
402
406
  priorities.hint("Up to three, in order. Staff pick work that serves them.");
403
- priorities.body.append(prioritiesForm(() => priorities.setDone(true)));
407
+ priorities.body.append(
408
+ prioritiesForm({
409
+ path: opsName() + "/org/priorities.md",
410
+ onSaved: () => (priorities.setDone(true), recheck()),
411
+ }),
412
+ );
404
413
  fileSteps.set("priorities", priorities.setDone);
405
414
  list.append(priorities.card);
406
415
 
@@ -482,158 +491,11 @@ function todo(n, title, done) {
482
491
  * quicker to type than to explain to a model. So the file opens as it is on disk, stub and
483
492
  * all, and saving it is the same commit-and-push as every other editor here.
484
493
  */
485
- const BUSINESS_QUESTIONS = [
486
- ["line", "What does it do, in one line?", "What this business does, in one line", true],
487
- ["who", "Who is it for?", "Who the customers are, specifically", true],
488
- ["key", "What does everything depend on?", "The one fact everything else follows from", false],
489
- ["true", "What's already true? What's built, measured or tried?", "What is already true", false],
490
- ["not", "What should staff never decide on their own?", "What is not ours to decide", false],
491
- ];
492
-
493
- /** The business, as five questions in plain fields, written out as org/business.md. */
494
- function businessForm(onSaved) {
495
- const form = el("div");
496
- const path = opsName() + "/org/business.md";
497
- const fields = BUSINESS_QUESTIONS.map(([id, label, , required]) => {
498
- const input = id === "line" ? el("input", { type: "text" }) : el("textarea", { className: "pastebox", rows: 3 });
499
- const wrap = el("label", { className: "qfield" }, [
500
- el("span", { textContent: label + (required ? "" : " (optional)") }),
501
- input,
502
- ]);
503
- return { id, input, wrap };
504
- });
505
- const save = el("button", { className: "btn primary", textContent: "Save" });
506
- const out = el("div");
507
- form.append(...fields.map((f) => f.wrap), el("div", { className: "row" }, [save]), out);
508
494
 
509
- save.onclick = async () => {
510
- const missing = fields.find((f, i) => BUSINESS_QUESTIONS[i][3] && !String(f.input.value ?? "").trim());
511
- if (missing) {
512
- missing.input.focus();
513
- out.replaceChildren(el("p", { className: "err", textContent: "Answer the first two at least." }));
514
- return;
515
- }
516
- const name = App.data?.name ?? S.tenant.name ?? S.tenant.org;
517
- const text =
518
- `# What ${name} is\n\n` +
519
- fields
520
- .map((f, i) => [BUSINESS_QUESTIONS[i][2], String(f.input.value ?? "").trim()])
521
- .filter(([, v]) => v)
522
- .map(([h, v]) => `## ${h}\n\n${v}\n`)
523
- .join("\n");
524
- await saveAndSay(save, out, path, text, "portal: write org/business.md", onSaved);
525
- };
526
495
 
527
- return modes([
528
- { label: "Answer questions", node: form },
529
- {
530
- label: "Let an AI interview you",
531
- node: paste({ kind: "discover", title: "", onSaved: () => (onSaved(), recheck()) }),
532
- },
533
- rawMode(path, onSaved),
534
- ]);
535
- }
536
-
537
- /** This month's priorities, as three lines and an out-of-scope list. */
538
- function prioritiesForm(onSaved) {
539
- const form = el("div");
540
- const path = opsName() + "/org/priorities.md";
541
- const ranks = [1, 2, 3].map((i) => {
542
- const input = el("input", { type: "text", placeholder: i === 1 ? "e.g. Tasks can be ticked off and removed" : "" });
543
- return { input, wrap: el("label", { className: "qfield" }, [el("span", { textContent: "Priority " + i + (i > 1 ? " (optional)" : "") }), input]) };
544
- });
545
- const scope = el("textarea", { className: "pastebox", rows: 3, placeholder: "One per line" });
546
- const save = el("button", { className: "btn primary", textContent: "Save" });
547
- const out = el("div");
548
- form.append(
549
- ...ranks.map((r) => r.wrap),
550
- el("label", { className: "qfield" }, [el("span", { textContent: "Out of scope this month (optional)" }), scope]),
551
- el("div", { className: "row" }, [save]),
552
- out,
553
- );
554
- save.onclick = async () => {
555
- const items = ranks.map((r) => String(r.input.value ?? "").trim()).filter(Boolean);
556
- if (!items.length) {
557
- ranks[0].input.focus();
558
- out.replaceChildren(el("p", { className: "err", textContent: "Add at least one priority." }));
559
- return;
560
- }
561
- const outs = String(scope.value ?? "").split("\n").map((l) => l.replace(/^[-*]\s*/, "").trim()).filter(Boolean);
562
- const text =
563
- "## What matters this month\n\n### Priorities, in order\n\n" +
564
- items.map((t, i) => `${i + 1}. ${t}`).join("\n") +
565
- "\n" +
566
- (outs.length ? "\n### Out of scope this month\n\n" + outs.map((t) => `- ${t}`).join("\n") + "\n" : "");
567
- await saveAndSay(save, out, path, text, "portal: write org/priorities.md", onSaved);
568
- };
569
- return modes([{ label: "Fill in", node: form }, rawMode(path, onSaved)]);
570
- }
571
496
 
572
- async function saveAndSay(btn, out, path, text, message, onSaved) {
573
- btn.disabled = true;
574
- const label = btn.textContent;
575
- btn.textContent = "Saving…";
576
- try {
577
- const r = await saveFile(path, text, message);
578
- out.replaceChildren(
579
- el("p", {
580
- className: r.pushed ? "sub" : "err",
581
- textContent: r.pushed ? "Saved." : "Saved locally, but the push failed: " + (r.note ?? ""),
582
- }),
583
- );
584
- onSaved();
585
- recheck();
586
- } catch (err) {
587
- out.replaceChildren(el("p", { className: "err", textContent: String(err.message || err) }));
588
- } finally {
589
- btn.disabled = false;
590
- btn.textContent = label;
591
- }
592
- }
593
497
 
594
- /** The file itself, for editing what is already there rather than starting again. */
595
- function rawMode(path, onSaved) {
596
- const node = el("div");
597
- const ta = el("textarea", { className: "pastebox" });
598
- ta.rows = 12;
599
- const save = el("button", { className: "btn primary", textContent: "Save" });
600
- const out = el("div");
601
- node.append(ta, el("div", { className: "row" }, [save]), out);
602
- ta.addEventListener("input", () => grow(ta));
603
- save.onclick = () =>
604
- saveAndSay(save, out, path, ta.value, "portal: edit " + path.split("/").slice(1).join("/"), onSaved);
605
- // Read when first shown, so it shows the file as it is then, not as it was at page load.
606
- const load = () =>
607
- // Not getFile: an absent file answers 404 with a message, and that is not what to edit.
608
- fetch(fileUrl(path), { cache: "no-store" })
609
- .then((r) => (r.ok ? r.text() : ""))
610
- .then((text) => {
611
- ta.value = text;
612
- grow(ta);
613
- });
614
- return { label: "Edit the file", node, onShow: load };
615
- }
616
498
 
617
- /** Ways to do one step, as a switcher across the top: one shown at a time. */
618
- function modes(list) {
619
- const box = el("div");
620
- const bar = el("div", { className: "modes" });
621
- const panes = el("div");
622
- const buttons = list.map((m, i) => {
623
- const b = el("button", { className: "mode", textContent: m.label });
624
- b.onclick = () => show(i);
625
- return b;
626
- });
627
- bar.append(...buttons);
628
- box.append(bar, panes);
629
- function show(i) {
630
- buttons.forEach((b, j) => b.classList.toggle("on", i === j));
631
- panes.replaceChildren(list[i].node);
632
- list[i].onShow?.();
633
- }
634
- show(0);
635
- return box;
636
- }
637
499
 
638
500
  /** The ops repo's directory name, which is what every workspace-relative path starts with. */
639
501
  function opsName() {