@nanocollective/roster 0.1.0-alpha.5 → 0.1.0-alpha.51

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 (103) hide show
  1. package/README.md +70 -84
  2. package/dist/cli.js +5161 -3012
  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/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 +93 -7
  17. package/docs/concepts.md +61 -14
  18. package/docs/cost.md +36 -1
  19. package/docs/developing.md +16 -21
  20. package/docs/doctor-codes.md +10 -2
  21. package/docs/export.md +2 -0
  22. package/docs/extending.md +2 -2
  23. package/docs/getting-started.md +128 -78
  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 +94 -123
  31. package/docs/memory.md +21 -3
  32. package/docs/org-yaml.md +40 -2
  33. package/docs/portal.md +177 -58
  34. package/docs/prompts.md +31 -4
  35. package/docs/security.md +37 -5
  36. package/docs/session-workflow.md +63 -17
  37. package/docs/staff-yaml.md +30 -3
  38. package/docs/troubleshooting.md +8 -8
  39. package/docs/upgrading.md +9 -3
  40. package/docs/writing-a-charter.md +28 -0
  41. package/package.json +18 -20
  42. package/templates/brain/.github/workflows/%%STAFF%%-daily.yaml +26 -1
  43. package/templates/brain/.github/workflows/%%STAFF%%-mention.yaml +59 -13
  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 +4 -1
  48. package/templates/brain/strategy/ideas.md +7 -0
  49. package/templates/briefs/amend.md +4 -3
  50. package/templates/briefs/priorities.md +46 -0
  51. package/templates/ops/.github/workflows/session.yaml +236 -15
  52. package/templates/ops/agents.mjs +7 -3
  53. package/templates/ops/compose.mjs +31 -3
  54. package/templates/ops/inflight.mjs +157 -0
  55. package/templates/ops/org/operating.md +43 -4
  56. package/templates/ops/org/voice.md +9 -0
  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 +37 -9
  60. package/templates/ops/prompts/mention.md +21 -0
  61. package/templates/ops/run-record.mjs +146 -0
  62. package/templates/portal/css/base.css +167 -73
  63. package/templates/portal/css/brain.css +23 -20
  64. package/templates/portal/css/diff.css +10 -9
  65. package/templates/portal/css/graph.css +12 -7
  66. package/templates/portal/css/health.css +26 -11
  67. package/templates/portal/css/home.css +95 -0
  68. package/templates/portal/css/inbox.css +45 -25
  69. package/templates/portal/css/layout.css +90 -46
  70. package/templates/portal/css/markdown.css +36 -14
  71. package/templates/portal/css/runs.css +13 -0
  72. package/templates/portal/css/setup.css +117 -34
  73. package/templates/portal/index.html +21 -9
  74. package/templates/portal/js/api.js +44 -4
  75. package/templates/portal/js/app.js +94 -9
  76. package/templates/portal/js/dialog.js +83 -0
  77. package/templates/portal/js/homesort.js +174 -0
  78. package/templates/portal/js/icons.js +37 -0
  79. package/templates/portal/js/inflight.js +18 -0
  80. package/templates/portal/js/md.js +5 -2
  81. package/templates/portal/js/mdedit.js +84 -0
  82. package/templates/portal/js/readiness.js +70 -0
  83. package/templates/portal/js/refresh.js +10 -2
  84. package/templates/portal/js/state.js +11 -5
  85. package/templates/portal/js/views/app.js +24 -7
  86. package/templates/portal/js/views/brain.js +1 -1
  87. package/templates/portal/js/views/checklist.js +10 -4
  88. package/templates/portal/js/views/credential.js +84 -0
  89. package/templates/portal/js/views/graph.js +1 -1
  90. package/templates/portal/js/views/health.js +27 -9
  91. package/templates/portal/js/views/hire.js +593 -0
  92. package/templates/portal/js/views/home.js +546 -0
  93. package/templates/portal/js/views/inbox.js +226 -70
  94. package/templates/portal/js/views/org.js +46 -106
  95. package/templates/portal/js/views/orgedit.js +234 -0
  96. package/templates/portal/js/views/paste.js +137 -63
  97. package/templates/portal/js/views/prompt.js +100 -42
  98. package/templates/portal/js/views/repos.js +20 -15
  99. package/templates/portal/js/views/runonce.js +94 -0
  100. package/templates/portal/js/views/runs.js +170 -0
  101. package/templates/portal/js/views/setup.js +261 -75
  102. package/templates/portal/js/views/staff.js +170 -243
  103. package/templates/portal/js/views/todo.js +62 -0
@@ -14,34 +14,33 @@
14
14
  * wrong. The server validates it before writing.
15
15
  */
16
16
 
17
- import { getFile, getOrgLayer, post } from "../api.js";
18
- import { el, esc, grow, kb, skeleton } from "../dom.js";
19
- import { icon } from "../icons.js";
17
+ import { getFile, getOrgLayer } from "../api.js";
18
+ import { el, esc, kb, skeleton } from "../dom.js";
20
19
  import { mdlite } from "../md.js";
21
20
  import { humansOf, S, writeHash } from "../state.js";
22
21
  import { yamlPre } from "../yaml.js";
22
+ import { businessForm, fileModes, prioritiesForm } from "./orgedit.js";
23
+ import { repoPicker } from "./repos.js";
23
24
 
24
25
  /* What the files roster itself ships are for, in one line each. A filename says nothing about
25
26
  which one to open; anything a tenant has added of its own falls back to its first heading. */
26
27
  const KNOWN = {
27
- "org.yaml": "Who and what: the humans, the repos, the defaults, which agent runs them.",
28
- "org/business.md":
29
- "What the business actually is. Every prompt is composed on top of it, and it is the file that stops the agents writing generic slop.",
30
- "org/operating.md":
31
- "The autonomy contract: what they do without asking, what they escalate, how a run starts and ends.",
32
- "org/guardrails.md":
33
- "The non-negotiables. Every staff member is bound by these and cannot argue with them.",
34
- "org/voice.md":
35
- "How everything they write reads. Change it once, and every surface changes on the next run.",
36
- "prompts/daily.md": "The whole of a scheduled run, before the fragments are pulled into it.",
37
- "prompts/mention.md": "What they are sent when you @-mention them.",
38
- "prompts/_identity.md": "Who they post as, and what wakes you.",
39
- "prompts/_paths.md": "Where everything is in the runner's checkout.",
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.",
40
39
  };
41
40
 
42
41
  const GROUPS = [
43
- ["org", "The org layer", "Inherited by everybody, on their next run."],
44
- ["prompts", "The prompts", "The runs themselves. Edit these last: they are the framework's."],
42
+ ["org", "Org files", "Every staff member reads these."],
43
+ ["prompts", "Prompts", "Roster's run instructions. Upgrades may change them."],
45
44
  ];
46
45
 
47
46
  export function viewOrg(m) {
@@ -51,8 +50,7 @@ export function viewOrg(m) {
51
50
  el("p", {
52
51
  className: "sub",
53
52
  textContent:
54
- "The layer every staff member inherits. Editing one of these reaches all " +
55
- org.staff.length + " of them on their next run.",
53
+ "Files every staff member reads. Changes apply from their next run.",
56
54
  }),
57
55
  );
58
56
 
@@ -75,6 +73,20 @@ export function viewOrg(m) {
75
73
  );
76
74
  m.append(facts);
77
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
+
78
90
  const split = el("div", { className: "split" });
79
91
  const tree = el("div", { className: "tree" });
80
92
  const viewer = el("div", { className: "viewer" });
@@ -152,97 +164,25 @@ export function viewOrg(m) {
152
164
  viewer.replaceChildren();
153
165
  const head = el("div", { className: "row filehead" });
154
166
  head.append(el("span", { className: "meta", textContent: full(path) + " · " + kb(text.length) }));
155
- /* The edit control used to be a word in a row of grey text, which is why "there is no easy
156
- way to edit the org docs" was a fair thing to say about a screen that could edit them. */
157
- const edit = el("button", { className: "ghbtn primary editbtn", onclick: () => editor(path, text) });
158
- edit.append(icon("edit", "ic"), el("span", { textContent: "Edit" }));
159
- head.append(edit);
160
167
  const file = S.orgFiles.find((f) => f.path === path) ?? { path };
161
- viewer.append(head, el("p", { className: "factsub", textContent: why(file) }));
162
-
163
- if (path.endsWith(".yaml") || path.endsWith(".yml")) {
164
- viewer.append(yamlPre(text));
165
- return;
166
- }
167
- viewer.append(el("div", { className: "md doc", innerHTML: mdlite(text) }));
168
- }
169
168
 
170
- function editor(path, text) {
171
- viewer.replaceChildren();
172
169
  const isYaml = /\.ya?ml$/.test(path);
173
-
174
- const status = el("span", { className: "meta" });
175
- const save = el("button", { className: "ghbtn primary", textContent: "Save and commit" });
176
- const cancel = el("button", { className: "ghbtn", textContent: "Cancel" });
177
-
178
- const bar = el("div", { className: "row filehead" });
179
- bar.append(el("span", { className: "meta", textContent: full(path) }), save, cancel, status);
180
- viewer.append(bar);
181
-
182
- const ta = el("textarea", { value: text, className: "editor" });
183
- viewer.append(ta);
184
- grow(ta);
185
- ta.addEventListener("input", () => grow(ta));
186
- // ⌘S is what a person's hands do in a text box. Without it, saving means finding a button
187
- // above a screenful of textarea you have just scrolled past.
188
- ta.addEventListener("keydown", (e) => {
189
- if ((e.metaKey || e.ctrlKey) && e.key === "s") {
190
- e.preventDefault();
191
- commit();
192
- }
193
- });
194
- ta.focus?.();
195
-
196
- cancel.onclick = () => render(path, text);
197
- save.onclick = commit;
198
-
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) };
199
179
  viewer.append(
200
- el("p", {
201
- className: "editnote",
202
- textContent:
203
- "⌘S saves. Commits to " + S.data.opsName + " and pushes, and every staff member picks " +
204
- "it up on their next run." +
205
- (isYaml
206
- ? " org.yaml is checked before it is written: bad YAML, or a missing org or name, is refused rather than committed."
207
- : ""),
208
- }),
180
+ path === "org/business.md"
181
+ ? businessForm(o)
182
+ : path === "org/priorities.md"
183
+ ? prioritiesForm(o)
184
+ : fileModes({ ...o, lang: isYaml ? "yaml" : "md" }),
209
185
  );
210
-
211
- async function commit() {
212
- if (ta.value === text) {
213
- status.textContent = "nothing changed";
214
- status.className = "meta";
215
- return;
216
- }
217
- /* Only the manifest asks. A prose edit is one commit to revert and the agents pick it up
218
- on their next run; org.yaml is the file that can stop every prompt composing, and the
219
- portal has just told you it validates it. Asking every time taught people to click
220
- through the question, which is worse than not asking. */
221
- if (
222
- isYaml &&
223
- !confirm(
224
- "Commit " + full(path) + " and push?\n\nEvery staff member picks this up on their next run.",
225
- )
226
- ) {
227
- return;
228
- }
229
- save.disabled = cancel.disabled = true;
230
- status.textContent = "committing…";
231
- status.className = "meta";
232
- try {
233
- const r = await post({ path: full(path), text: ta.value }, "/api/save");
234
- status.textContent = r.pushed
235
- ? "committed and pushed · " + r.sha
236
- : "committed " + r.sha + ", but the push failed: " + (r.note ?? "");
237
- status.className = r.pushed ? "meta ok" : "meta warn";
238
- render(path, ta.value);
239
- } catch (e) {
240
- // A refused org.yaml is the interesting case: the reason is the whole message.
241
- status.textContent = e.message;
242
- status.className = "meta err";
243
- save.disabled = cancel.disabled = false;
244
- }
245
- }
246
186
  }
247
187
  }
248
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
+ }