@nanocollective/roster 0.1.0-alpha.4 → 0.1.0-alpha.40

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 (97) hide show
  1. package/README.md +70 -84
  2. package/dist/cli.js +4562 -2744
  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 +90 -5
  17. package/docs/concepts.md +48 -14
  18. package/docs/cost.md +36 -1
  19. package/docs/developing.md +16 -21
  20. package/docs/doctor-codes.md +8 -2
  21. package/docs/export.md +2 -0
  22. package/docs/extending.md +2 -2
  23. package/docs/getting-started.md +126 -77
  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 +39 -2
  33. package/docs/portal.md +119 -44
  34. package/docs/prompts.md +31 -4
  35. package/docs/security.md +37 -5
  36. package/docs/session-workflow.md +49 -17
  37. package/docs/staff-yaml.md +14 -2
  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 +1 -1
  42. package/templates/brain/.github/workflows/%%STAFF%%-daily.yaml +6 -0
  43. package/templates/brain/.github/workflows/%%STAFF%%-mention.yaml +6 -0
  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/strategy/ideas.md +7 -0
  48. package/templates/briefs/priorities.md +46 -0
  49. package/templates/ops/.github/workflows/session.yaml +108 -14
  50. package/templates/ops/agents.mjs +7 -3
  51. package/templates/ops/compose.mjs +16 -3
  52. package/templates/ops/inflight.mjs +157 -0
  53. package/templates/ops/org/operating.md +21 -1
  54. package/templates/ops/org/voice.md +9 -0
  55. package/templates/ops/prompts/_inflight.md +14 -0
  56. package/templates/ops/prompts/_paths.md +2 -1
  57. package/templates/ops/prompts/daily.md +16 -7
  58. package/templates/ops/prompts/mention.md +2 -0
  59. package/templates/ops/run-record.mjs +144 -0
  60. package/templates/portal/css/base.css +146 -73
  61. package/templates/portal/css/brain.css +23 -20
  62. package/templates/portal/css/diff.css +10 -9
  63. package/templates/portal/css/graph.css +12 -7
  64. package/templates/portal/css/health.css +13 -11
  65. package/templates/portal/css/inbox.css +57 -25
  66. package/templates/portal/css/layout.css +96 -41
  67. package/templates/portal/css/markdown.css +36 -14
  68. package/templates/portal/css/runs.css +13 -0
  69. package/templates/portal/css/setup.css +116 -34
  70. package/templates/portal/index.html +18 -2
  71. package/templates/portal/js/api.js +41 -4
  72. package/templates/portal/js/app.js +132 -9
  73. package/templates/portal/js/dialog.js +82 -0
  74. package/templates/portal/js/icons.js +37 -0
  75. package/templates/portal/js/inflight.js +18 -0
  76. package/templates/portal/js/md.js +5 -2
  77. package/templates/portal/js/mdedit.js +84 -0
  78. package/templates/portal/js/readiness.js +35 -0
  79. package/templates/portal/js/refresh.js +2 -1
  80. package/templates/portal/js/state.js +17 -4
  81. package/templates/portal/js/views/app.js +24 -7
  82. package/templates/portal/js/views/checklist.js +10 -4
  83. package/templates/portal/js/views/credential.js +84 -0
  84. package/templates/portal/js/views/graph.js +1 -1
  85. package/templates/portal/js/views/health.js +17 -4
  86. package/templates/portal/js/views/hire.js +583 -0
  87. package/templates/portal/js/views/inbox.js +248 -81
  88. package/templates/portal/js/views/org.js +46 -106
  89. package/templates/portal/js/views/orgedit.js +234 -0
  90. package/templates/portal/js/views/paste.js +87 -21
  91. package/templates/portal/js/views/prompt.js +11 -4
  92. package/templates/portal/js/views/repos.js +20 -15
  93. package/templates/portal/js/views/runonce.js +94 -0
  94. package/templates/portal/js/views/runs.js +170 -0
  95. package/templates/portal/js/views/setup.js +257 -75
  96. package/templates/portal/js/views/staff.js +158 -243
  97. 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
+ }
@@ -7,19 +7,27 @@
7
7
  * Nothing here writes. The panel parses, shows a diff, and waits for a second click. */
8
8
 
9
9
  import { getBrief, parsePaste, saveFile } from "../api.js";
10
+ import { mdlite } from "../md.js";
10
11
  import { el, toClipboard } from "../dom.js";
11
12
  import { unifiedDiff, diffStat } from "../textdiff.js";
12
13
 
13
14
  /**
14
- * @param {{kind: string, staff?: string, title?: string, onSaved?: () => void}} opts
15
+ * @param {{kind: string, staff?: string, title?: string, about?: string, onSaved?: () => void}} opts
16
+ * `about` is a sentence on what the role does, added to a charter brief.
15
17
  */
16
18
  export function paste(opts) {
17
19
  const box = el("div", { className: "paste" });
18
- box.append(el("h3", { textContent: opts.title ?? "Write it with your own AI" }));
20
+ if (opts.title !== "") box.append(el("h3", { textContent: opts.title ?? "Write it with your own AI" }));
19
21
 
20
22
  const status = el("p", { className: "sub", textContent: "Building the prompt…" });
21
23
  box.append(status);
22
24
 
25
+ /* A charter brief carries a worked example to model the shape on, matched to the role. The
26
+ choice is on the page because the match is a guess, and "none" is a fair answer. */
27
+ const model = el("select", { hidden: true });
28
+ const modelRow = el("label", { className: "sub", hidden: true }, ["Example to follow: ", model]);
29
+ box.append(modelRow);
30
+
23
31
  const actions = el("div", { className: "row" });
24
32
  const copy = el("button", { className: "btn primary", textContent: "Copy the prompt" });
25
33
  const show = el("button", { className: "btn", textContent: "Show it" });
@@ -32,8 +40,7 @@ export function paste(opts) {
32
40
  const answer = el("textarea", {
33
41
  className: "pastebox",
34
42
  placeholder:
35
- "Paste the whole reply here, including anything your AI said around the file. " +
36
- "Only the part between the sentinels is used.",
43
+ "Paste its whole reply here.",
37
44
  });
38
45
  answer.rows = 8;
39
46
 
@@ -42,16 +49,49 @@ export function paste(opts) {
42
49
 
43
50
  let brief = null;
44
51
 
45
- getBrief(opts.kind, opts.staff)
52
+ const NAMES = {
53
+ cto: "the CTO example",
54
+ cmo: "the CMO example",
55
+ support: "the support example",
56
+ pm: "the product manager example",
57
+ designer: "the designer example",
58
+ qa: "the QA example",
59
+ devops: "the DevOps example",
60
+ writer: "the writer example",
61
+ analyst: "the analyst example",
62
+ community: "the community example",
63
+ none: "no example",
64
+ };
65
+ model.onchange = () => {
66
+ copy.disabled = true;
67
+ getBrief(opts.kind, opts.staff, model.value, opts.about)
68
+ .then((data) => {
69
+ if (data.error) throw new Error(data.error);
70
+ brief = data;
71
+ if (!preview.hidden) preview.textContent = brief.text;
72
+ })
73
+ .catch((err) => {
74
+ status.className = "err";
75
+ status.textContent = String(err.message || err);
76
+ })
77
+ .finally(() => {
78
+ copy.disabled = false;
79
+ });
80
+ };
81
+
82
+ getBrief(opts.kind, opts.staff, undefined, opts.about)
46
83
  .then((data) => {
47
84
  if (data.error) throw new Error(data.error);
48
85
  brief = data;
86
+ if (data.examples) {
87
+ model.replaceChildren(...data.examples.map((e) => el("option", { value: e, textContent: NAMES[e] ?? e })));
88
+ model.value = data.example ?? "none";
89
+ model.hidden = modelRow.hidden = false;
90
+ }
49
91
  status.textContent =
50
- "Paste this into Claude, ChatGPT or anything else. It carries every file it refers to, " +
51
- "so there is nothing to attach and nothing for it to ask you for. It will interview you " +
52
- "first, then hand back the finished file.";
92
+ "Copy the prompt into Claude or ChatGPT and answer its questions.";
53
93
  box.append(
54
- el("p", { className: "sub", textContent: "Then paste its whole reply back here:" }),
94
+ el("p", { className: "sub", textContent: "Then paste its reply here:" }),
55
95
  answer,
56
96
  el("div", { className: "row" }, [check]),
57
97
  result,
@@ -97,23 +137,47 @@ export function paste(opts) {
97
137
  result.append(note);
98
138
  }
99
139
 
140
+ // What the AI said around the file: its caveats and "check these" are worth reading first.
141
+ const notes = String(answer.value ?? "")
142
+ .replace(/<<<ROSTER FILE [^>]+>>>[\s\S]*?<<<ROSTER END>>>/g, "")
143
+ .trim();
144
+
100
145
  for (const file of data.files ?? []) {
101
- if (file.unchanged) continue;
102
146
  const card = el("div", { className: "pastefile" });
103
- const stat = diffStat(file.before, file.text);
147
+ const name = file.path.split("/").slice(-2).join("/");
148
+ if (file.unchanged) {
149
+ card.append(el("p", { className: "verdict", textContent: "✓ Same as what's already in " + name + ". Nothing to save." }));
150
+ result.append(card);
151
+ continue;
152
+ }
104
153
  card.append(
105
- el("b", { textContent: file.path }),
106
- el("span", {
107
- className: "meta",
108
- textContent: `+${stat.added} −${stat.removed}` + (file.writable ? "" : " · not writable"),
154
+ el("p", {
155
+ className: "verdict" + (file.writable ? " ok" : " bad"),
156
+ textContent: file.writable
157
+ ? "✓ Looks good. Saving replaces " + name + " with this:"
158
+ : "✗ " + file.path + " can't be written from here.",
109
159
  }),
110
160
  );
111
- const diff = el("pre", { className: "diff" });
161
+ card.append(el("div", { className: "md doc pastepreview", innerHTML: mdlite(file.text) }));
162
+
163
+ if (notes) {
164
+ const said = el("details", { className: "pastenotes", open: true });
165
+ said.append(el("summary", { textContent: "Your AI also said" }), el("div", { className: "md doc", innerHTML: mdlite(notes) }));
166
+ card.append(said);
167
+ }
168
+
169
+ const diff = el("pre", { className: "diff", hidden: true });
112
170
  diff.textContent = unifiedDiff(file.before, file.text, file.path);
113
- card.append(diff);
171
+ const stat = diffStat(file.before, file.text);
172
+ const toggle = el("button", { className: "ghbtn", textContent: `Show changes (+${stat.added} −${stat.removed})` });
173
+ toggle.onclick = () => {
174
+ diff.hidden = !diff.hidden;
175
+ toggle.textContent = (diff.hidden ? "Show changes" : "Hide changes") + ` (+${stat.added} −${stat.removed})`;
176
+ };
114
177
 
178
+ const row = el("div", { className: "row" });
115
179
  if (file.writable) {
116
- const save = el("button", { className: "btn primary", textContent: "Save and commit" });
180
+ const save = el("button", { className: "btn primary", textContent: "Save" });
117
181
  save.onclick = async () => {
118
182
  save.disabled = true;
119
183
  save.textContent = "Saving…";
@@ -124,17 +188,19 @@ export function paste(opts) {
124
188
  opts.onSaved?.();
125
189
  } catch (err) {
126
190
  save.disabled = false;
127
- save.textContent = "Save and commit";
191
+ save.textContent = "Save";
128
192
  card.append(el("p", { className: "err", textContent: String(err.message || err) }));
129
193
  }
130
194
  };
131
- card.append(save);
195
+ row.append(save);
132
196
  }
197
+ row.append(toggle);
198
+ card.append(row, diff);
133
199
  result.append(card);
134
200
  }
135
201
 
136
202
  if (!(data.problems ?? []).length && !(data.files ?? []).length) {
137
- result.append(el("p", { className: "sub", textContent: "Nothing to do." }));
203
+ result.append(el("p", { className: "verdict bad", textContent: "✗ No file found in that reply." }));
138
204
  }
139
205
  };
140
206
 
@@ -9,8 +9,9 @@
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
- import { askText } from "../dialog.js";
14
+ import { askText, askYes } from "../dialog.js";
14
15
  import { el, esc, grow, kb, toClipboard } from "../dom.js";
15
16
  import { icon } from "../icons.js";
16
17
  import { mdlite } from "../md.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" });
@@ -375,8 +378,12 @@ export function viewPrompt(m) {
375
378
  status.textContent = "nothing changed";
376
379
  return;
377
380
  }
378
- if (!confirm("Commit " + layer.path + " to " + layer.repo + " and push?\n\n" +
379
- "Picked up by " + reach + ".")) return;
381
+ const yes = await askYes({
382
+ title: "Commit " + layer.path + " to " + layer.repo + " and push?",
383
+ hint: "Picked up by " + reach + ".",
384
+ confirm: "Commit and push",
385
+ });
386
+ if (!yes) return;
380
387
  save.disabled = cancel.disabled = true;
381
388
  status.textContent = "committing…";
382
389
  status.className = "meta";