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

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 (68) hide show
  1. package/dist/cli.js +848 -164
  2. package/docs/README.md +10 -5
  3. package/docs/agents.md +320 -9
  4. package/docs/commands.md +5 -6
  5. package/docs/concepts.md +27 -9
  6. package/docs/cost.md +3 -2
  7. package/docs/doctor-codes.md +13 -4
  8. package/docs/export.md +2 -1
  9. package/docs/extending.md +11 -2
  10. package/docs/getting-started.md +89 -84
  11. package/docs/images/brain.jpg +0 -0
  12. package/docs/images/org.jpg +0 -0
  13. package/docs/images/prompt.jpg +0 -0
  14. package/docs/images/setup-org.jpg +0 -0
  15. package/docs/images/setup-plan.jpg +0 -0
  16. package/docs/images/staff.jpg +0 -0
  17. package/docs/manual-steps.md +36 -13
  18. package/docs/memory.md +9 -6
  19. package/docs/org-yaml.md +37 -9
  20. package/docs/portal.md +197 -31
  21. package/docs/prompts.md +50 -11
  22. package/docs/security.md +19 -7
  23. package/docs/session-workflow.md +8 -10
  24. package/docs/staff-yaml.md +3 -5
  25. package/docs/troubleshooting.md +17 -14
  26. package/docs/writing-a-charter.md +18 -17
  27. package/package.json +1 -1
  28. package/templates/brain/.github/workflows/%%STAFF%%-daily.yaml +1 -0
  29. package/templates/brain/.github/workflows/%%STAFF%%-mention.yaml +10 -4
  30. package/templates/brain/staff.yaml +0 -1
  31. package/templates/ops/.github/workflows/session.yaml +9 -26
  32. package/templates/ops/agents.mjs +121 -6
  33. package/templates/ops/compose.mjs +61 -4
  34. package/templates/ops/org/operating.md +0 -6
  35. package/templates/ops/prompts/_identity.md +8 -1
  36. package/templates/ops/prompts/mention.md +16 -2
  37. package/templates/portal/css/base.css +122 -8
  38. package/templates/portal/css/brain.css +8 -1
  39. package/templates/portal/css/diff.css +6 -2
  40. package/templates/portal/css/health.css +21 -2
  41. package/templates/portal/css/inbox.css +93 -5
  42. package/templates/portal/css/layout.css +26 -4
  43. package/templates/portal/css/markdown.css +23 -3
  44. package/templates/portal/css/setup.css +11 -6
  45. package/templates/portal/index.html +7 -1
  46. package/templates/portal/js/api.js +33 -0
  47. package/templates/portal/js/app.js +33 -7
  48. package/templates/portal/js/dialog.js +47 -4
  49. package/templates/portal/js/dom.js +25 -0
  50. package/templates/portal/js/icons.js +8 -1
  51. package/templates/portal/js/lightbox.js +273 -0
  52. package/templates/portal/js/md.js +23 -6
  53. package/templates/portal/js/mention.js +264 -0
  54. package/templates/portal/js/refresh.js +136 -6
  55. package/templates/portal/js/state.js +47 -5
  56. package/templates/portal/js/views/checklist.js +20 -7
  57. package/templates/portal/js/views/docs.js +94 -4
  58. package/templates/portal/js/views/files.js +58 -14
  59. package/templates/portal/js/views/health.js +163 -35
  60. package/templates/portal/js/views/inbox.js +882 -96
  61. package/templates/portal/js/views/memory.js +16 -1
  62. package/templates/portal/js/views/org.js +142 -62
  63. package/templates/portal/js/views/prompt.js +50 -63
  64. package/templates/portal/js/views/setup.js +37 -13
  65. package/templates/portal/js/views/staff.js +62 -2
  66. package/templates/portal/js/yaml.js +134 -0
  67. package/templates/brain/.github/workflows/%%STAFF%%-pr-mention.yaml +0 -50
  68. package/templates/ops/prompts/pr-mention.md +0 -57
@@ -1,8 +1,8 @@
1
1
  /* The framework's own documentation, read where you already are. Same renderer as a brain
2
2
  file, so a link between pages behaves like a link between files rather than a dead end. */
3
3
 
4
- import { getDoc, getDocs } from "../api.js";
5
- import { el, slug } from "../dom.js";
4
+ import { getDoc, getDocs, searchDocs } from "../api.js";
5
+ import { el, esc, skeleton, slug } from "../dom.js";
6
6
  import { mdlite } from "../md.js";
7
7
  import { S, writeHash } from "../state.js";
8
8
 
@@ -16,6 +16,18 @@ export function viewDocs(m) {
16
16
  }),
17
17
  );
18
18
 
19
+ /* Titles are not enough. Twenty-odd pages is too many to scan and few enough for the server
20
+ to read in full on every keystroke, and what you are looking for — "which page explains the
21
+ mention gate" — is a sentence in a paragraph rather than a word in a heading. So the box
22
+ searches the text, and the list becomes the results while there is a query in it. */
23
+ const find = el("input", {
24
+ type: "search",
25
+ placeholder: "Search the docs…",
26
+ value: S.docQuery ?? "",
27
+ style: "width:100%;margin-bottom:12px",
28
+ });
29
+ m.append(find);
30
+
19
31
  const split = el("div", { className: "split" });
20
32
  const list = el("div", { className: "tree" });
21
33
  const viewer = el("div", { className: "viewer" });
@@ -24,14 +36,44 @@ export function viewDocs(m) {
24
36
 
25
37
  if (S.docs) paint();
26
38
  else {
27
- list.append(el("p", { className: "empty", textContent: "Loading…" }));
39
+ list.replaceChildren(...skeleton("row", 8));
28
40
  getDocs()
29
41
  .then((d) => { S.docs = d; paint(); })
30
42
  .catch((e) => list.replaceChildren(el("p", { className: "empty err", textContent: e.message })));
31
43
  }
32
44
 
45
+ /* Debounced, because every keystroke is a read of every page. 140ms is below the point
46
+ where the list feels like it is lagging behind the typing. */
47
+ let timer = null;
48
+ find.oninput = () => {
49
+ S.docQuery = find.value;
50
+ writeHash(false);
51
+ clearTimeout(timer);
52
+ timer = setTimeout(run, 140);
53
+ };
54
+ if (S.docQuery) run();
55
+
56
+ async function run() {
57
+ const q = (S.docQuery ?? "").trim();
58
+ if (!q) { paint(); return; }
59
+ let hits;
60
+ try {
61
+ hits = (await searchDocs(q)).hits ?? [];
62
+ } catch (e) {
63
+ list.replaceChildren(el("p", { className: "empty err", textContent: e.message }));
64
+ return;
65
+ }
66
+ // A slow answer to a query you have already moved on from must not land.
67
+ if ((S.docQuery ?? "").trim() !== q) return;
68
+ paintHits(hits, q);
69
+ }
70
+
33
71
  function paint() {
34
72
  list.replaceChildren();
73
+ if (!S.docs?.length) {
74
+ list.append(el("p", { className: "empty", textContent: "No docs here." }));
75
+ return;
76
+ }
35
77
  for (const d of S.docs) {
36
78
  const b = el("button", { className: "tfile tmem", title: d.file });
37
79
  b.dataset.key = d.file;
@@ -43,6 +85,54 @@ export function viewDocs(m) {
43
85
  show(S.openDoc ?? S.docs[0].file);
44
86
  }
45
87
 
88
+ /** The same list, narrowed, with the lines that matched under each row. */
89
+ function paintHits(hits, q) {
90
+ list.replaceChildren();
91
+ list.append(
92
+ el("div", {
93
+ className: "tdir first",
94
+ textContent: hits.length
95
+ ? hits.length + (hits.length === 1 ? " page" : " pages") + " mention that"
96
+ : "nothing mentions that",
97
+ }),
98
+ );
99
+ if (!hits.length) {
100
+ list.append(
101
+ el("p", {
102
+ className: "empty",
103
+ textContent: "Every word has to appear somewhere on the page. Try fewer.",
104
+ }),
105
+ );
106
+ return;
107
+ }
108
+ for (const h of hits) {
109
+ const b = el("button", { className: "tfile thit", title: h.file });
110
+ b.dataset.key = h.file;
111
+ const t = el("span", { className: "t" });
112
+ t.append(el("span", { className: "hitname", textContent: h.title }));
113
+ for (const line of h.matches ?? []) {
114
+ t.append(el("span", { className: "hitline", innerHTML: mark(line.text, q) }));
115
+ }
116
+ b.append(t);
117
+ b.setAttribute("aria-current", String(S.openDoc === h.file));
118
+ b.onclick = () => open(h.file);
119
+ list.append(b);
120
+ }
121
+ // Opening the first result is what makes typing feel like searching rather than filtering.
122
+ if (!hits.some((h) => h.file === S.openDoc)) open(hits[0].file);
123
+ }
124
+
125
+ /** The words that were asked for, shown in the line they were found in. */
126
+ function mark(text, q) {
127
+ const terms = q.toLowerCase().split(/\s+/).filter(Boolean)
128
+ .map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
129
+ let html = esc(text);
130
+ if (terms.length) {
131
+ html = html.replace(new RegExp("(" + terms.join("|") + ")", "gi"), "<em>$1</em>");
132
+ }
133
+ return html;
134
+ }
135
+
46
136
  function open(file) {
47
137
  S.openDoc = file;
48
138
  writeHash(false);
@@ -53,7 +143,7 @@ export function viewDocs(m) {
53
143
  }
54
144
 
55
145
  async function show(file) {
56
- viewer.replaceChildren(el("p", { className: "empty", textContent: "Loading…" }));
146
+ viewer.replaceChildren(...skeleton("head", 1), ...skeleton("line", 8));
57
147
  let text;
58
148
  try {
59
149
  text = await getDoc(file);
@@ -1,11 +1,18 @@
1
- /* Rendering one file out of a brain: markdown, image, CSV, JSON, or source. */
1
+ /* Rendering one file out of a brain: markdown, image, video, CSV, JSON, or source. */
2
2
 
3
3
  import { el, kb } from "../dom.js";
4
4
  import { fileUrl, getFile } from "../api.js";
5
+ import { icon } from "../icons.js";
5
6
  import { mdlite } from "../md.js";
7
+ import { yamlPre } from "../yaml.js";
6
8
 
7
9
  const IMG = ["png", "jpg", "jpeg", "gif", "svg", "webp", "ico"];
8
10
 
11
+ /* A brain records as often as it screenshots, and a recording read as source is a screenful
12
+ of binary. It plays here for the same reason an image displays here: it is the file. */
13
+ const VIDEO = ["mp4", "m4v", "webm", "mov", "ogv"];
14
+ const AUDIO = ["mp3", "m4a", "wav", "oga"];
15
+
9
16
  export async function showFile(viewer, s, f, pick) {
10
17
  viewer.replaceChildren(el("p", { className: "empty", textContent: "Loading…" }));
11
18
  const path = s.dir + "/" + f.path;
@@ -18,7 +25,33 @@ export async function showFile(viewer, s, f, pick) {
18
25
  head.textContent = stamp(f.bytes);
19
26
 
20
27
  if (IMG.includes(f.ext)) {
21
- viewer.replaceChildren(head, el("img", { src: fileUrl(path), alt: f.path }));
28
+ /* `zoomable` is the opt-in the lightbox looks for. The gallery tiles above are not
29
+ marked, because their click already means "open this file", and this is where it lands. */
30
+ viewer.replaceChildren(
31
+ head,
32
+ el("img", { className: "zoomable", src: fileUrl(path), alt: f.path }),
33
+ );
34
+ return;
35
+ }
36
+
37
+ if (VIDEO.includes(f.ext) || AUDIO.includes(f.ext)) {
38
+ const player = el(VIDEO.includes(f.ext) ? "video" : "audio", {
39
+ src: fileUrl(path),
40
+ controls: true,
41
+ preload: "metadata",
42
+ className: "player",
43
+ });
44
+ // Nothing else here streams, so say what to do when a codec the browser cannot decode
45
+ // turns up — a .mov is often h.265, and a silent black rectangle explains nothing.
46
+ const fallback = el("p", { className: "meta", hidden: true });
47
+ player.onerror = () => {
48
+ fallback.hidden = false;
49
+ fallback.textContent = "This browser cannot play " + f.ext + ". The file itself is fine — ";
50
+ fallback.append(
51
+ el("a", { href: fileUrl(path), download: f.path.split("/").pop(), textContent: "download it" }),
52
+ );
53
+ };
54
+ viewer.replaceChildren(head, player, fallback);
22
55
  return;
23
56
  }
24
57
 
@@ -56,6 +89,12 @@ export async function showFile(viewer, s, f, pick) {
56
89
  return;
57
90
  }
58
91
 
92
+ // A manifest is the one kind of source in a brain that is read closely rather than skimmed.
93
+ if (f.ext === "yaml" || f.ext === "yml") {
94
+ viewer.replaceChildren(head, yamlPre(text));
95
+ return;
96
+ }
97
+
59
98
  viewer.replaceChildren(head, el("pre", { className: "code", textContent: text }));
60
99
  }
61
100
 
@@ -69,26 +108,31 @@ function csvTable(text) {
69
108
  }
70
109
 
71
110
  export function showGallery(viewer, s, surface) {
72
- const imgs = surface.files.filter((f) => IMG.includes(f.ext));
73
- if (!imgs.length) {
74
- viewer.replaceChildren(el("p", { className: "empty", textContent: "No images here." }));
111
+ const shots = surface.files.filter((f) => IMG.includes(f.ext) || VIDEO.includes(f.ext));
112
+ if (!shots.length) {
113
+ viewer.replaceChildren(el("p", { className: "empty", textContent: "Nothing to show here." }));
75
114
  return;
76
115
  }
77
116
  const g = el("div", { className: "gallery" });
78
- for (const f of imgs.slice(0, 200)) {
117
+ for (const f of shots.slice(0, 200)) {
79
118
  const fig = el("figure");
80
- const img = el("img", {
81
- src: fileUrl(s.dir + "/" + f.path),
82
- loading: "lazy",
83
- alt: f.path,
84
- });
85
- img.onclick = () => showFile(viewer, s, f);
86
- fig.append(img, el("figcaption", { textContent: f.path.split("/").pop() }));
119
+ const src = fileUrl(s.dir + "/" + f.path);
120
+ /* A video tile is its own first frame rather than a placeholder, which is what makes a
121
+ wall of recordings tell you anything. `preload="metadata"` is enough for that and does
122
+ not pull the whole file for every tile on the page. */
123
+ const tile = IMG.includes(f.ext)
124
+ ? el("img", { src, loading: "lazy", alt: f.path })
125
+ : el("video", { src, preload: "metadata", muted: true, playsInline: true });
126
+ tile.onclick = () => showFile(viewer, s, f);
127
+ fig.append(tile);
128
+ if (!IMG.includes(f.ext)) fig.append(el("span", { className: "playmark" }, [icon("play", "ic")]));
129
+ fig.append(el("figcaption", { textContent: f.path.split("/").pop() }));
87
130
  g.append(fig);
88
131
  }
132
+ const vids = shots.filter((f) => VIDEO.includes(f.ext)).length;
89
133
  viewer.replaceChildren(
90
134
  el("div", { className: "meta", style: "margin-bottom:12px" }, [
91
- imgs.length + " images in " + surface.path,
135
+ shots.length - vids + " images" + (vids ? " and " + vids + " videos" : "") + " in " + surface.path,
92
136
  ]),
93
137
  g,
94
138
  );
@@ -6,6 +6,7 @@ import { inline } from "../md.js";
6
6
  import { render } from "../router.js";
7
7
  import { S, staff } from "../state.js";
8
8
  import { checklist } from "./checklist.js";
9
+ import { copyAmendBrief } from "./prompt.js";
9
10
 
10
11
  const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
11
12
 
@@ -27,20 +28,14 @@ export function viewHealth(m) {
27
28
  const rig = s.rig ?? {};
28
29
  m.append(el("h1", { textContent: s.name + " · health" }));
29
30
 
30
- /* `roster doctor` runs over the whole org, so it is drawn once at the top rather than
31
- pretended to be per-staff. The comment this replaced said doctor "does not exist yet". */
32
- const org = el("div", { className: "card", style: "margin-bottom:16px" });
33
- org.append(el("h3", { textContent: "The org, from roster doctor" }));
34
- const rows = el("div");
35
- org.append(rows);
36
- m.append(org);
37
- checklist(rows, {});
38
- const errs = s.problems.filter((p) => p.level === "error");
39
- const warns = s.problems.filter((p) => p.level === "warning");
40
- m.append(el("p", { className: "sub", innerHTML:
41
- "<span class='" + (errs.length ? "err" : "ok") + "'>" + errs.length + " errors</span> · " +
42
- "<span class='warn'>" + warns.length + " warnings</span> · memory checks are the same ones " +
43
- "<code>roster lint</code> runs; the rest is read off the checkout." }));
31
+ m.append(
32
+ el("p", {
33
+ className: "sub",
34
+ textContent:
35
+ "The rig, then three sets of checks: the org, the prompts they are sent, and what " +
36
+ "they have written down.",
37
+ }),
38
+ );
44
39
 
45
40
  /* The rig: is the scaffolding still there, and has the agent actually been running. This
46
41
  is everything answerable without the GitHub API — installation grants, secrets and
@@ -82,11 +77,75 @@ export function viewHealth(m) {
82
77
  }
83
78
  m.append(g);
84
79
 
85
- m.append(el("div", { className: "hsect", textContent: "Memory problems" }));
80
+ /* Three sets of checks, and they were three different-looking things: hairline rows for
81
+ doctor, bordered boxes for the prompt audit, cards with two buttons each for lint. Same
82
+ question in all three, so one shape: a heading that carries its own count, a line saying
83
+ what the section is, the findings as rows, and the section's actions at the end. */
84
+ const doctor = section(m, "The org, from roster doctor", "doctor");
85
+ checklist(doctor.body, { shell: doctor });
86
86
 
87
- if (!s.problems.length) {
88
- m.append(el("p", { className: "empty", textContent: "Nothing to fix." }));
89
- return;
87
+ /* What is wrong with what this agent is sent. It used to sit in the tree on the Prompt
88
+ screen, beside the prompt itself — which answers "what is sent", not "is it any good".
89
+ Both questions are health questions, so both are here. */
90
+ const prompt = section(m, "Prompt problems", "prompts");
91
+ promptProblems(prompt, s);
92
+
93
+ memoryProblems(section(m, "Memory problems", "memory"), s);
94
+ }
95
+
96
+ /**
97
+ * One section: a heading with a count, a body, and a row for whatever acts on all of it.
98
+ *
99
+ * Returned rather than built in one go because two of the three fill themselves in later, and
100
+ * a heading that appears with its count already right is worth the small indirection.
101
+ */
102
+ function section(m, title, key) {
103
+ const head = el("div", { className: "hsect" });
104
+ head.append(el("span", { textContent: title }));
105
+ const count = el("i");
106
+ head.append(count);
107
+ const body = el("div", { className: "hbody " + key });
108
+ const actions = el("div", { className: "row hacts" });
109
+ m.append(head, body, actions);
110
+ return {
111
+ body,
112
+ actions,
113
+ /** `n` findings, or a word when a count is the wrong shape of answer. */
114
+ say: (text) => { count.textContent = text; },
115
+ };
116
+ }
117
+
118
+ /** The same checks `roster lint` runs, over what this staff member has written down. */
119
+ function memoryProblems({ body, actions, say }, s) {
120
+ say(s.problems.length ? String(s.problems.length) : "clean");
121
+ body.append(
122
+ el("p", {
123
+ className: "hnote",
124
+ textContent: s.problems.length
125
+ ? "The same checks roster lint runs, over memory/INDEX.md."
126
+ : "Nothing roster lint can fault in memory/INDEX.md.",
127
+ }),
128
+ );
129
+ if (!s.problems.length) return;
130
+
131
+ for (const p of s.problems) {
132
+ const { row, body: text } = finding(
133
+ p.level === "error" ? "error" : "warning",
134
+ { html: inline(p.message) },
135
+ [p.rule, p.line ? "INDEX.md:" + p.line : ""],
136
+ );
137
+ const status = el("span", { className: "meta" });
138
+ const btn = el("button", { className: "ghbtn", textContent: "Ask " + s.handle.toUpperCase() + " to fix" });
139
+ btn.onclick = () => askToFix(s, [p], btn, status);
140
+ const acts = el("div", { className: "row facts" }, [btn]);
141
+ if (p.slug) {
142
+ const go = el("button", { className: "ghbtn", textContent: "Show the fact" });
143
+ go.onclick = () => { S.openFile = "fact:" + p.slug; S.fileQuery = ""; S.view = "brain"; render(); };
144
+ acts.append(go);
145
+ }
146
+ acts.append(status);
147
+ text.append(acts);
148
+ body.append(row);
90
149
  }
91
150
 
92
151
  /* The portal's one power over an agent is that it acts as the human, so "fix this" is an
@@ -95,27 +154,96 @@ export function viewHealth(m) {
95
154
  const all = el("button", { className: "ghbtn primary",
96
155
  textContent: "Ask " + s.handle.toUpperCase() + " to fix all " + s.problems.length });
97
156
  all.onclick = () => askToFix(s, s.problems, all, allStatus);
98
- m.append(el("div", { className: "row", style: "margin-bottom:12px" }, [all, allStatus]));
157
+ actions.append(all, allStatus);
158
+ }
99
159
 
100
- for (const p of s.problems) {
101
- const d = el("div", { className: "card" });
102
- d.innerHTML = '<div class="row"><span class="' + (p.level === "error" ? "err" : "warn") +
103
- '" style="font:11px var(--mono)">' + p.level + "</span>" +
104
- '<span class="pill">' + esc(p.rule) + "</span>" +
105
- (p.line ? '<span class="meta">INDEX.md:' + p.line + "</span>" : "") + "</div>" +
106
- '<p style="margin:8px 0 0">' + inline(p.message) + "</p>";
160
+ /**
161
+ * One finding, in the shape every section uses: a level marker, what is wrong, and where.
162
+ *
163
+ * The marker carries the severity so the words do not have to. A row that opens with the word
164
+ * "warning" spends its first line saying something a glyph already said.
165
+ */
166
+ function finding(level, title, tags) {
167
+ const row = el("div", { className: "check " + (level === "error" ? "fail" : "warn") });
168
+ row.append(el("span", { className: "checkmark", textContent: level === "error" ? "✗" : "!" }));
169
+ const body = el("div");
170
+ // Plain text where there is any, markup only where a message carries its own.
171
+ const head = el("b");
172
+ if (title.html) head.innerHTML = title.html;
173
+ else head.textContent = title.text;
174
+ body.append(head);
175
+ for (const t of tags.filter(Boolean)) {
176
+ body.append(el("span", { className: "meta", textContent: t }));
177
+ }
178
+ row.append(body);
179
+ // Everything else goes in the second column, under the text rather than under the marker.
180
+ return { row, body };
181
+ }
107
182
 
108
- const status = el("span", { className: "meta" });
109
- const btn = el("button", { className: "ghbtn", textContent: "Ask " + s.handle.toUpperCase() + " to fix" });
110
- btn.onclick = () => askToFix(s, [p], btn, status);
111
- const actions = el("div", { className: "row fix" }, [btn, status]);
112
- if (p.slug) {
113
- const go = el("button", { className: "ghbtn", textContent: "Show the fact" });
114
- go.onclick = () => { S.openFile = "fact:" + p.slug; S.fileQuery = ""; S.view = "brain"; render(); };
183
+ /**
184
+ * Everything the audit says about this staff member's prompts, over every kind of run.
185
+ *
186
+ * Fetched rather than read off the export: composing three prompts and diffing their layers is
187
+ * not something to do on every page paint of every screen, and this is the only one that asks.
188
+ */
189
+ async function promptProblems({ body, say }, s) {
190
+ say("…");
191
+ body.append(el("p", { className: "hnote", textContent: "Composing the prompts…" }));
192
+ let data;
193
+ try {
194
+ data = await (
195
+ await fetch("/api/promptaudit?staff=" + encodeURIComponent(s.handle), { cache: "no-store" })
196
+ ).json();
197
+ } catch (e) {
198
+ say("?");
199
+ body.replaceChildren(el("p", { className: "hnote err", textContent: e.message }));
200
+ return;
201
+ }
202
+
203
+ const problems = data.problems ?? [];
204
+ const errors = data.errors ?? [];
205
+ say(problems.length ? String(problems.length) : errors.length ? "?" : "clean");
206
+ body.replaceChildren(
207
+ el("p", {
208
+ className: "hnote",
209
+ textContent: problems.length
210
+ ? "What the audit can be sure about, over every kind of run this staff member has."
211
+ : "Nothing the audit can fault in what this staff member is sent.",
212
+ }),
213
+ );
214
+ for (const bad of errors) {
215
+ body.append(
216
+ el("div", { className: "notice", textContent:
217
+ "The " + bad.kind + " prompt does not compose: " + bad.error }),
218
+ );
219
+ }
220
+ if (!problems.length) return;
221
+
222
+ /* Worst first, and each one carrying the sentence that fixes it. Knowing there is a problem
223
+ is the hard part; writing the paragraph that asks for the change is not. */
224
+ const rank = { error: 0, warning: 1, note: 2 };
225
+ for (const p of [...problems].sort((a, b) => (rank[a.level] ?? 3) - (rank[b.level] ?? 3))) {
226
+ const { row, body: text } = finding(p.level, { text: p.title }, [(p.kinds ?? []).join(", ")]);
227
+ text.append(el("p", { textContent: p.detail }));
228
+
229
+ const note = el("span", { className: "meta" });
230
+ const fix = el("button", { className: "ghbtn", textContent: "Copy a prompt to fix this" });
231
+ fix.onclick = () => copyAmendBrief(s.handle, p.kind, p.want, fix, note);
232
+ const actions = el("div", { className: "row facts" }, [fix]);
233
+ if (p.path) {
234
+ const go = el("button", { className: "ghbtn", textContent: "Open the file" });
235
+ // The file is a layer of a prompt, so it opens where prompts are read.
236
+ go.onclick = () => {
237
+ S.promptKind = p.kind;
238
+ S.promptOpen = p.path;
239
+ S.view = "prompt";
240
+ render();
241
+ };
115
242
  actions.append(go);
116
243
  }
117
- d.append(actions);
118
- m.append(d);
244
+ actions.append(note);
245
+ text.append(actions);
246
+ body.append(row);
119
247
  }
120
248
  }
121
249