@nanocollective/roster 0.1.0-alpha.1

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 (89) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +129 -0
  3. package/dist/cli.js +5679 -0
  4. package/docs/README.md +99 -0
  5. package/docs/agents.md +163 -0
  6. package/docs/architecture.md +121 -0
  7. package/docs/commands.md +223 -0
  8. package/docs/concepts.md +112 -0
  9. package/docs/cost.md +61 -0
  10. package/docs/developing.md +147 -0
  11. package/docs/doctor-codes.md +74 -0
  12. package/docs/export.md +113 -0
  13. package/docs/extending.md +97 -0
  14. package/docs/getting-started.md +134 -0
  15. package/docs/hosting.md +72 -0
  16. package/docs/manual-steps.md +163 -0
  17. package/docs/memory.md +71 -0
  18. package/docs/org-yaml.md +143 -0
  19. package/docs/portal.md +342 -0
  20. package/docs/prompts.md +133 -0
  21. package/docs/security.md +122 -0
  22. package/docs/session-workflow.md +112 -0
  23. package/docs/staff-yaml.md +163 -0
  24. package/docs/troubleshooting.md +189 -0
  25. package/docs/upgrading.md +83 -0
  26. package/docs/writing-a-charter.md +83 -0
  27. package/package.json +60 -0
  28. package/templates/brain/.github/workflows/%%STAFF%%-daily.yaml +33 -0
  29. package/templates/brain/.github/workflows/%%STAFF%%-mention.yaml +65 -0
  30. package/templates/brain/.github/workflows/%%STAFF%%-pr-mention.yaml +50 -0
  31. package/templates/brain/CHARTER.md +49 -0
  32. package/templates/brain/README.md +18 -0
  33. package/templates/brain/drafts/README.md +7 -0
  34. package/templates/brain/log/decisions.md +6 -0
  35. package/templates/brain/memory/INDEX.md +28 -0
  36. package/templates/brain/staff.yaml +44 -0
  37. package/templates/brain/strategy/README.md +7 -0
  38. package/templates/briefs/amend.md +60 -0
  39. package/templates/briefs/charter.md +47 -0
  40. package/templates/briefs/discover.md +61 -0
  41. package/templates/briefs/voice.md +53 -0
  42. package/templates/ops/.github/workflows/session.yaml +333 -0
  43. package/templates/ops/agents.mjs +143 -0
  44. package/templates/ops/compose.mjs +333 -0
  45. package/templates/ops/org/guardrails.md +14 -0
  46. package/templates/ops/org/operating.md +82 -0
  47. package/templates/ops/org/voice.md +40 -0
  48. package/templates/ops/prompts/_identity.md +14 -0
  49. package/templates/ops/prompts/_paths.md +15 -0
  50. package/templates/ops/prompts/daily.md +82 -0
  51. package/templates/ops/prompts/mention.md +53 -0
  52. package/templates/ops/prompts/pr-mention.md +57 -0
  53. package/templates/ops/runner-plan.mjs +65 -0
  54. package/templates/portal/css/base.css +104 -0
  55. package/templates/portal/css/brain.css +106 -0
  56. package/templates/portal/css/diff.css +28 -0
  57. package/templates/portal/css/graph.css +34 -0
  58. package/templates/portal/css/health.css +41 -0
  59. package/templates/portal/css/inbox.css +79 -0
  60. package/templates/portal/css/layout.css +98 -0
  61. package/templates/portal/css/markdown.css +54 -0
  62. package/templates/portal/css/setup.css +106 -0
  63. package/templates/portal/index.html +55 -0
  64. package/templates/portal/js/api.js +74 -0
  65. package/templates/portal/js/app.js +282 -0
  66. package/templates/portal/js/dialog.js +70 -0
  67. package/templates/portal/js/dom.js +106 -0
  68. package/templates/portal/js/icons.js +94 -0
  69. package/templates/portal/js/md.js +386 -0
  70. package/templates/portal/js/refresh.js +59 -0
  71. package/templates/portal/js/router.js +20 -0
  72. package/templates/portal/js/state.js +160 -0
  73. package/templates/portal/js/textdiff.js +96 -0
  74. package/templates/portal/js/views/app.js +128 -0
  75. package/templates/portal/js/views/brain.js +260 -0
  76. package/templates/portal/js/views/changed.js +157 -0
  77. package/templates/portal/js/views/checklist.js +87 -0
  78. package/templates/portal/js/views/docs.js +84 -0
  79. package/templates/portal/js/views/files.js +95 -0
  80. package/templates/portal/js/views/graph.js +436 -0
  81. package/templates/portal/js/views/health.js +158 -0
  82. package/templates/portal/js/views/inbox.js +549 -0
  83. package/templates/portal/js/views/memory.js +135 -0
  84. package/templates/portal/js/views/org.js +175 -0
  85. package/templates/portal/js/views/paste.js +142 -0
  86. package/templates/portal/js/views/prompt.js +412 -0
  87. package/templates/portal/js/views/repos.js +92 -0
  88. package/templates/portal/js/views/setup.js +344 -0
  89. package/templates/portal/js/views/staff.js +290 -0
@@ -0,0 +1,106 @@
1
+ /* The handful of DOM helpers every view uses. No framework: the portal is a few thousand
2
+ lines of rendering over a JSON export, and a build step would cost more than it saves. */
3
+
4
+ export const $ = (s, r = document) => r.querySelector(s);
5
+
6
+ export const el = (t, props = {}, kids = []) => {
7
+ const n = Object.assign(document.createElement(t), props);
8
+ for (const k of [].concat(kids)) n.append(k);
9
+ return n;
10
+ };
11
+
12
+ export const esc = (s) =>
13
+ String(s ?? "").replace(
14
+ /[&<>"]/g,
15
+ (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c],
16
+ );
17
+
18
+ export function ago(iso) {
19
+ const s = (Date.now() - new Date(iso).getTime()) / 1000;
20
+ if (s < 90) return "just now";
21
+ if (s < 5400) return Math.round(s / 60) + "m ago";
22
+ if (s < 172800) return Math.round(s / 3600) + "h ago";
23
+ return Math.round(s / 86400) + "d ago";
24
+ }
25
+
26
+ export const kb = (n) =>
27
+ n < 1024 ? n + "b" : n < 1048576 ? Math.round(n / 1024) + "k" : (n / 1048576).toFixed(1) + "M";
28
+
29
+ /** Rows are `.tfile` in a file tree and `.irow` in the inbox. Matching only one of them is
30
+ how every clicked row stayed selected. */
31
+ export function markCurrent(list, btn) {
32
+ for (const o of list.querySelectorAll('[aria-current="true"]')) {
33
+ o.setAttribute("aria-current", "false");
34
+ }
35
+ btn.setAttribute("aria-current", "true");
36
+ }
37
+
38
+ export function store(k, v) {
39
+ try {
40
+ return v === undefined ? localStorage.getItem(k) : localStorage.setItem(k, v);
41
+ } catch {
42
+ return null;
43
+ }
44
+ }
45
+
46
+ /** GitHub's heading slug, for in-page anchors. */
47
+ export function slug(text) {
48
+ return String(text)
49
+ .toLowerCase()
50
+ .replace(/[^\w\s-]/g, "")
51
+ .trim()
52
+ .replace(/\s+/g, "-");
53
+ }
54
+
55
+ /**
56
+ * Size a textarea to its content, so the card it sits in is the only thing that scrolls.
57
+ *
58
+ * The border has to be added back: everything here is `border-box`, so `height` sets the
59
+ * outer box while `scrollHeight` counts the padding and not the border. Two pixels short is
60
+ * enough to clip the last line.
61
+ */
62
+ export function grow(ta) {
63
+ ta.style.height = "auto";
64
+ const border = (ta.offsetHeight || 0) - (ta.clientHeight || 0);
65
+ ta.style.height = (ta.scrollHeight || 0) + border + "px";
66
+ }
67
+
68
+ /**
69
+ * Put text on the clipboard and say so on the button that asked.
70
+ *
71
+ * `navigator.clipboard` needs a secure context, and http://localhost counts, but a portal
72
+ * bound to a LAN address does not. The textarea fallback is what makes the button work there
73
+ * rather than failing silently, which for a copy button is the worst outcome: you paste the
74
+ * last thing you copied and never notice.
75
+ */
76
+ export async function toClipboard(text, btn, label) {
77
+ const said = (msg) => {
78
+ if (!btn) return;
79
+ btn.textContent = msg;
80
+ setTimeout(() => {
81
+ btn.textContent = label;
82
+ }, 2200);
83
+ };
84
+ try {
85
+ await navigator.clipboard.writeText(text);
86
+ said("copied · " + Math.round(text.length / 1000) + "k");
87
+ return true;
88
+ } catch {
89
+ /* fall through */
90
+ }
91
+ try {
92
+ const ta = document.createElement("textarea");
93
+ ta.value = text;
94
+ ta.style.position = "fixed";
95
+ ta.style.opacity = "0";
96
+ document.body.append(ta);
97
+ ta.select();
98
+ const ok = document.execCommand("copy");
99
+ ta.remove();
100
+ said(ok ? "copied" : "could not copy");
101
+ return ok;
102
+ } catch {
103
+ said("could not copy");
104
+ return false;
105
+ }
106
+ }
@@ -0,0 +1,94 @@
1
+ /* Icons.
2
+ *
3
+ * Twenty-four Lucide glyphs (lucide.dev, ISC), vendored as path data rather than pulled from
4
+ * a CDN or a font. The portal is local-first and offline, so an icon that needs the network
5
+ * is an icon that is sometimes a blank square.
6
+ *
7
+ * This replaces the arrow-and-lozenge characters the UI used to draw with. Those were never
8
+ * icons: they are text, they render differently on every platform, half of them fall back to
9
+ * a box in the wrong font, and none of them line up with a baseline.
10
+ *
11
+ * They inherit `currentColor` and size from the `--ic` custom property, so a caller sets
12
+ * colour and size in CSS and never touches the SVG.
13
+ */
14
+
15
+ const PATHS = {
16
+ "check":
17
+ "<path d=\"M20 6 9 17l-5-5\" />",
18
+ "chevron":
19
+ "<path d=\"m6 9 6 6 6-6\" />",
20
+ "close":
21
+ "<path d=\"M18 6 6 18\" /> <path d=\"m6 6 12 12\" />",
22
+ "closed":
23
+ "<circle cx=\"12\" cy=\"12\" r=\"10\" /> <path d=\"m15 9-6 6\" /> <path d=\"m9 9 6 6\" />",
24
+ "commit":
25
+ "<circle cx=\"12\" cy=\"12\" r=\"3\" /> <line x1=\"3\" x2=\"9\" y1=\"12\" y2=\"12\" /> <line x1=\"15\" x2=\"21\" y1=\"12\" y2=\"12\" />",
26
+ "crossref":
27
+ "<path d=\"M7 7h10v10\" /> <path d=\"M7 17 17 7\" />",
28
+ "dash":
29
+ "<path d=\"M5 12h14\" />",
30
+ "docs":
31
+ "<path d=\"M12 5v16\" /> <path d=\"M20.001 19A2 2 0 0022 17V5a2 2 0 00-1.999-2L16 3.002A5 5 0 0012 5a5 5 0 00-4-2H4a2 2 0 00-2 2v12a2 2 0 001.999 2H8a5 5 0 014 2 5 5 0 014-2z\" />",
32
+ "dot":
33
+ "<circle cx=\"12\" cy=\"12\" r=\"1\" />",
34
+ "draft":
35
+ "<circle cx=\"18\" cy=\"18\" r=\"3\" /> <circle cx=\"6\" cy=\"6\" r=\"3\" /> <path d=\"M18 6V5\" /> <path d=\"M18 11v-1\" /> <line x1=\"6\" x2=\"6\" y1=\"9\" y2=\"21\" />",
36
+ "edit":
37
+ "<path d=\"M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z\" /> <path d=\"m15 5 4 4\" />",
38
+ "file":
39
+ "<path d=\"M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z\" /> <path d=\"M14 2v5a1 1 0 0 0 1 1h5\" /> <path d=\"M10 9H8\" /> <path d=\"M16 13H8\" /> <path d=\"M16 17H8\" />",
40
+ "gallery":
41
+ "<path d=\"m22 11-1.296-1.296a2.4 2.4 0 0 0-3.408 0L11 16\" /> <path d=\"M4 8a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2\" /> <circle cx=\"13\" cy=\"7\" r=\"1\" fill=\"currentColor\" /> <rect x=\"8\" y=\"2\" width=\"14\" height=\"14\" rx=\"2\" />",
42
+ "inbox":
43
+ "<polyline points=\"22 12 16 12 14 15 10 15 8 12 2 12\" /> <path d=\"M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z\" />",
44
+ "issue-closed":
45
+ "<circle cx=\"12\" cy=\"12\" r=\"10\" /> <path d=\"m16 9-5.5 5.5L8 12\" />",
46
+ "issue-open":
47
+ "<circle cx=\"12\" cy=\"12\" r=\"1\" /> <circle cx=\"12\" cy=\"12\" r=\"10\" />",
48
+ "label":
49
+ "<path d=\"M12.586 2.586A2 2 0 0 0 11.172 2H4a2 2 0 0 0-2 2v7.172a2 2 0 0 0 .586 1.414l8.704 8.704a2.426 2.426 0 0 0 3.42 0l6.58-6.58a2.426 2.426 0 0 0 0-3.42z\" /> <circle cx=\"7.5\" cy=\"7.5\" r=\".5\" fill=\"currentColor\" />",
50
+ "merged":
51
+ "<circle cx=\"18\" cy=\"18\" r=\"3\" /> <circle cx=\"6\" cy=\"6\" r=\"3\" /> <path d=\"M6 21V9a9 9 0 0 0 9 9\" />",
52
+ "monitor":
53
+ "<rect width=\"20\" height=\"14\" x=\"2\" y=\"3\" rx=\"2\" /> <line x1=\"8\" x2=\"16\" y1=\"21\" y2=\"21\" /> <line x1=\"12\" x2=\"12\" y1=\"17\" y2=\"21\" />",
54
+ "moon":
55
+ "<path d=\"M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401\" />",
56
+ "person":
57
+ "<circle cx=\"12\" cy=\"8\" r=\"5\" /> <path d=\"M20 21a8 8 0 0 0-16 0\" />",
58
+ "refresh":
59
+ "<path d=\"M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8\" /> <path d=\"M21 3v5h-5\" /> <path d=\"M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16\" /> <path d=\"M8 16H3v5\" />",
60
+ "rename":
61
+ "<path d=\"M13 21h8\" /> <path d=\"m15 5 4 4\" /> <path d=\"M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z\" />",
62
+ "reopened":
63
+ "<path d=\"M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8\" /> <path d=\"M3 3v5h5\" />",
64
+ "review":
65
+ "<path d=\"M22 17a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 21.286V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2z\" /> <path d=\"M7 11h10\" /> <path d=\"M7 15h6\" /> <path d=\"M7 7h8\" />",
66
+ "square":
67
+ "<rect width=\"18\" height=\"18\" x=\"3\" y=\"3\" rx=\"2\" />",
68
+ "sun":
69
+ "<circle cx=\"12\" cy=\"12\" r=\"4\" /> <path d=\"M12 2v2\" /> <path d=\"M12 20v2\" /> <path d=\"m4.93 4.93 1.41 1.41\" /> <path d=\"m17.66 17.66 1.41 1.41\" /> <path d=\"M2 12h2\" /> <path d=\"M20 12h2\" /> <path d=\"m6.34 17.66-1.41 1.41\" /> <path d=\"m19.07 4.93-1.41 1.41\" />",
70
+ "task-done":
71
+ "<path d=\"M21 10.656V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h12.344\" /> <path d=\"m9 11 3 3L22 4\" />",
72
+ };
73
+
74
+ const OPEN =
75
+ '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' +
76
+ 'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"';
77
+
78
+ /** An icon as an HTML string, for the places that build markup rather than nodes. */
79
+ export function iconHTML(name, cls = "") {
80
+ const body = PATHS[name];
81
+ if (!body) return "";
82
+ return OPEN + ' class="ic' + (cls ? " " + cls : "") + '">' + body + "</svg>";
83
+ }
84
+
85
+ /** An icon as an element. */
86
+ export function icon(name, cls = "") {
87
+ const span = document.createElement("span");
88
+ span.innerHTML = iconHTML(name, cls);
89
+ return span.firstChild ?? span;
90
+ }
91
+
92
+ export function hasIcon(name) {
93
+ return Object.hasOwn(PATHS, name);
94
+ }
@@ -0,0 +1,386 @@
1
+ /* Enough markdown for an issue thread and for a file in a brain, escaped first. Not a
2
+ * general parser: tables, headings, nested lists, code, quotes, rules, images and links,
3
+ * which is what these actually contain. Tables matter most — the status issues are mostly
4
+ * tables, and without them the body is a wall of pipes.
5
+ */
6
+
7
+ import { esc } from "./dom.js";
8
+ import { iconHTML } from "./icons.js";
9
+
10
+ /* Who this org is, so `@cto` can be told from `@some-stranger`. Registered once at boot
11
+ rather than threaded through every call site, because every call site would pass the
12
+ same value. */
13
+ let PEOPLE = new Map();
14
+
15
+ /** @param people Map of lowercased handle → { href, known } */
16
+ export function setPeople(people) {
17
+ PEOPLE = people;
18
+ }
19
+
20
+ /* Renders the small subset of markdown that appears in a fact line. Deliberately not a
21
+ markdown parser: escaping first, then a handful of inline forms. */
22
+ export function inline(s) {
23
+ return esc(s)
24
+ .replace(/`([^`]+)`/g, '<code style="font:12px var(--mono);color:var(--accent)">$1</code>')
25
+ .replace(/\[\[([a-z0-9-]+)\]\]/g, '<span class="slug" data-goto="$1">[[$1]]</span>')
26
+ .replace(/\*\*([^*]+)\*\*/g, "<b>$1</b>")
27
+ .replace(
28
+ /\[([^\]]+)\]\((https?:\/\/[^)]+)\)/g,
29
+ '<a href="$2" target="_blank" rel="noopener">$1</a>',
30
+ );
31
+ }
32
+
33
+ const LIST_RE = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/;
34
+
35
+ /* ---------------------------- HTML tables ----------------------------------
36
+ * Some comments are raw HTML rather than markdown. The Cloudflare Pages bot posts its deploy
37
+ * status as a `<table>`, and GitHub renders it; this escaped it and showed you the tags.
38
+ *
39
+ * Nothing here relaxes the escaping, which is the one thing standing between a comment on a
40
+ * public repo and this page. The table is taken apart, each cell is reduced to the handful of
41
+ * markdown forms a cell actually uses, and the result goes back through the same escape-first
42
+ * pipeline as everything else. No attacker-controlled markup reaches innerHTML.
43
+ */
44
+ const TABLE_RE = /<table[^>]*>[\s\S]*?<\/table>/gi;
45
+ const ROW_RE = /<tr[^>]*>([\s\S]*?)<\/tr>/gi;
46
+ const CELL_RE = /<(t[hd])[^>]*>([\s\S]*?)<\/\1>/gi;
47
+
48
+ const ENTITIES = {
49
+ "&nbsp;": " ", "&amp;": "&", "&lt;": "<", "&gt;": ">", "&quot;": '"',
50
+ "&#39;": "'", "&apos;": "'", "&mdash;": "—", "&ndash;": "–",
51
+ };
52
+
53
+ /**
54
+ * The handful of inline HTML forms a comment actually uses, as the markdown they stand in for.
55
+ * Everything else is dropped to its text, which is what GitHub does with it too.
56
+ *
57
+ * `<img>` is resolved before `<a>` on purpose: a linked icon is `<a><img alt="x"></a>`, and
58
+ * taking the anchor first leaves an empty label and a bare URL on the page.
59
+ */
60
+ function htmlToMarkdown(html, breakAs, stripUnknown) {
61
+ return html
62
+ .replace(/<br\s*\/?>/gi, breakAs)
63
+ .replace(/<(strong|b)[^>]*>([\s\S]*?)<\/\1>/gi, (_m, _t, x) => "**" + x.trim() + "**")
64
+ .replace(/<(em|i)[^>]*>([\s\S]*?)<\/\1>/gi, (_m, _t, x) => "*" + x.trim() + "*")
65
+ .replace(/<code[^>]*>([\s\S]*?)<\/code>/gi, (_m, x) => "`" + x.trim() + "`")
66
+ .replace(/<img[^>]*alt=["']([^"']*)["'][^>]*>/gi, "$1")
67
+ .replace(/<img[^>]*>/gi, "")
68
+ // Only http(s). A `javascript:` href would be escaped downstream anyway, but a link that
69
+ // cannot go anywhere useful is better dropped than rendered.
70
+ .replace(
71
+ /<a[^>]*href=["'](https?:\/\/[^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi,
72
+ (_m, href, text) => {
73
+ const label = text.replace(/<[^>]+>/g, "").trim();
74
+ return label ? "[" + label + "](" + href + ")" : href;
75
+ },
76
+ )
77
+ /* Only inside a cell. Out in the prose, a tag nobody asked about stays escaped and
78
+ visible: a brain document that mentions `<script>` or <div> without backticks should
79
+ still say so, and silently deleting a word is worse than printing an angle bracket. */
80
+ .replace(stripUnknown ? /<[^>]+>/g : /(?!)/g, "")
81
+ .replace(/&[a-z#0-9]+;/gi, (e) => ENTITIES[e.toLowerCase()] ?? e);
82
+ }
83
+
84
+ /** One `<td>`, on one line. */
85
+ function cellToMarkdown(html) {
86
+ return htmlToMarkdown(html, " ", true).replace(/\s+/g, " ").trim();
87
+ }
88
+
89
+ /**
90
+ * Pull every `<table>` out of the source, leaving a marker line where each one was.
91
+ *
92
+ * The markers are substituted back inside the main loop, so the table renders in place and
93
+ * everything around it is still ordinary markdown. A literal `%%HTMLTABLE0%%` typed by a
94
+ * person only collides if the same comment also contains a real HTML table.
95
+ */
96
+ /* A fence or a code span is content, not markup. Splitting on them first is what keeps a
97
+ comment discussing `<meta name="robots">` from having its example quietly deleted. */
98
+ const CODE_RE = /(```[\s\S]*?```|`[^`\n]*`)/;
99
+
100
+ function liftTables(src) {
101
+ const tables = [];
102
+ const text = String(src ?? "")
103
+ .split(CODE_RE)
104
+ .map((part, i) => (i % 2 ? part : htmlToMarkdown(liftTablesIn(part, tables), "\n", false)))
105
+ .join("");
106
+ return { text, tables };
107
+ }
108
+
109
+ function liftTablesIn(src, tables) {
110
+ return src.replace(TABLE_RE, (block) => {
111
+ const rows = [];
112
+ let head = null;
113
+ for (const [, inner] of block.matchAll(ROW_RE)) {
114
+ const cells = [];
115
+ let isHead = true;
116
+ for (const [, tag, body] of inner.matchAll(CELL_RE)) {
117
+ if (tag.toLowerCase() !== "th") isHead = false;
118
+ cells.push(cellToMarkdown(body));
119
+ }
120
+ if (!cells.length) continue;
121
+ if (isHead && !rows.length && !head) head = cells;
122
+ else rows.push(cells);
123
+ }
124
+ if (!head && !rows.length) return block;
125
+ tables.push({ head, rows });
126
+ return "\n\n%%HTMLTABLE" + (tables.length - 1) + "%%\n\n";
127
+ });
128
+ }
129
+
130
+ const MARKER_RE = /^%%HTMLTABLE(\d+)%%$/;
131
+
132
+ /**
133
+ * opts.repo resolves a bare #123 to that repository
134
+ * opts.file { dir, staffDir } resolves relative images and links inside a brain
135
+ * opts.docs links between doc pages stay inside the portal
136
+ */
137
+ export function mdlite(src, opts = {}) {
138
+ const lifted = liftTables(String(src ?? ""));
139
+ const lines = esc(lifted.text).replace(/\r/g, "").split("\n");
140
+ const out = [];
141
+ let i = 0;
142
+
143
+ const inlineFmt = (s) =>
144
+ chips(
145
+ s
146
+ .replace(/`([^`]+)`/g, "<code>$1</code>")
147
+ .replace(
148
+ /!\[([^\]]*)\]\(([^)\s]+)\)/g,
149
+ (_m, alt, src2) =>
150
+ '<img src="' + mdAsset(src2, opts) + '" alt="' + alt + '" loading="lazy">',
151
+ )
152
+ // Non-greedy and allowing an inner asterisk, because "**bold with *this* inside**" is
153
+ // ordinary in these files and the old pattern silently left the stars on the page.
154
+ // Requiring a non-space first character keeps a literal "/** wildcard" from opening one.
155
+ .replace(/\*\*(\S[\s\S]*?)\*\*/g, "<b>$1</b>")
156
+ .replace(/(^|[\s(])\*([^*\s][^*]*)\*/g, "$1<i>$2</i>")
157
+ .replace(/~~([^~]+)~~/g, "<s>$1</s>")
158
+ .replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_m, text, href) => mdLink(href, text, opts))
159
+ .replace(/(^|[\s(])(https?:\/\/[^\s<)]+)/g, (_m, pre, url) => pre + mdLink(url, url, opts)),
160
+ opts,
161
+ );
162
+
163
+ const cells = (row) => {
164
+ let s = row.trim();
165
+ if (s.startsWith("|")) s = s.slice(1);
166
+ if (s.endsWith("|")) s = s.slice(0, -1);
167
+ return s.split("|").map((c) => inlineFmt(c.trim()));
168
+ };
169
+
170
+ while (i < lines.length) {
171
+ const line = lines[i];
172
+
173
+ const lifted_at = MARKER_RE.exec(line.trim());
174
+ if (lifted_at && lifted.tables[Number(lifted_at[1])]) {
175
+ const t = lifted.tables[Number(lifted_at[1])];
176
+ const th = (t.head ?? []).map((h) => "<th>" + inlineFmt(esc(h)) + "</th>").join("");
177
+ const tr = t.rows
178
+ .map((r) => "<tr>" + r.map((c) => "<td>" + inlineFmt(esc(c)) + "</td>").join("") + "</tr>")
179
+ .join("");
180
+ out.push(
181
+ '<div class="ctable"><table>' +
182
+ (t.head ? "<thead><tr>" + th + "</tr></thead>" : "") +
183
+ "<tbody>" + tr + "</tbody></table></div>",
184
+ );
185
+ i++;
186
+ continue;
187
+ }
188
+
189
+ if (line.trim().startsWith("```")) {
190
+ const buf = [];
191
+ i++;
192
+ while (i < lines.length && !lines[i].trim().startsWith("```")) buf.push(lines[i++]);
193
+ i++;
194
+ out.push("<pre><code>" + buf.join("\n") + "</code></pre>");
195
+ continue;
196
+ }
197
+
198
+ // A table is a pipe row followed by a --- separator row.
199
+ if (
200
+ line.trim().startsWith("|") &&
201
+ (lines[i + 1] ?? "").replace(/[^|:\-\s]/g, "") === (lines[i + 1] ?? "").trim() &&
202
+ /^\|?[\s:|-]+\|[\s:|-]*$/.test((lines[i + 1] ?? "").trim())
203
+ ) {
204
+ const head = cells(line);
205
+ i += 2;
206
+ const body = [];
207
+ while (i < lines.length && lines[i].trim().startsWith("|")) body.push(cells(lines[i++]));
208
+ const th = head.map((h) => "<th>" + h + "</th>").join("");
209
+ const tr = body.map((r) => "<tr>" + r.map((c) => "<td>" + c + "</td>").join("") + "</tr>").join("");
210
+ // A leading empty header cell is how these issues start a two-column "thing | ask"
211
+ // table; keeping the row would just print a blank strip.
212
+ const showHead = head.some((h) => h.trim());
213
+ out.push(
214
+ '<div class="ctable"><table>' +
215
+ (showHead ? "<thead><tr>" + th + "</tr></thead>" : "") +
216
+ "<tbody>" + tr + "</tbody></table></div>",
217
+ );
218
+ continue;
219
+ }
220
+
221
+ if (!line.trim()) { i++; continue; }
222
+
223
+ const h = line.match(/^(#{1,6})\s+(.*)$/);
224
+ if (h) { out.push("<h" + h[1].length + ">" + inlineFmt(h[2]) + "</h" + h[1].length + ">"); i++; continue; }
225
+
226
+ if (/^\s*([-*_])\s*\1\s*\1[\s\-*_]*$/.test(line)) { out.push("<hr>"); i++; continue; }
227
+
228
+ if (line.trim().startsWith("&gt;")) {
229
+ const buf = [];
230
+ while (i < lines.length && lines[i].trim().startsWith("&gt;")) {
231
+ buf.push(lines[i++].trim().replace(/^&gt;\s?/, ""));
232
+ }
233
+ out.push("<blockquote>" + inlineFmt(buf.join(" ")) + "</blockquote>");
234
+ continue;
235
+ }
236
+
237
+ // Lists are gathered whole and nested by indent. Flat divs with a left margin looked
238
+ // right until a sub-point wrapped, and then the wrap did not line up with the bullet.
239
+ if (LIST_RE.test(line)) {
240
+ const items = [];
241
+ while (i < lines.length && LIST_RE.test(lines[i])) {
242
+ const m = LIST_RE.exec(lines[i++]);
243
+ items.push({
244
+ depth: Math.floor(m[1].replace(/\t/g, " ").length / 2),
245
+ ordered: /\d/.test(m[2]),
246
+ text: m[3],
247
+ });
248
+ // A continuation line indented under the bullet is part of that bullet.
249
+ while (i < lines.length && lines[i].trim() && /^\s{2,}\S/.test(lines[i]) && !LIST_RE.test(lines[i])) {
250
+ items[items.length - 1].text += " " + lines[i++].trim();
251
+ }
252
+ }
253
+ out.push(mdList(items, 0, items[0].depth, inlineFmt).html);
254
+ continue;
255
+ }
256
+
257
+ /* Consecutive plain lines are one paragraph; markdown hard-wraps and we should not.
258
+ The join happens before the formatting, not after: these files wrap at 95 columns, so
259
+ a **bold run** or a [link](…) routinely straddles two source lines, and formatting
260
+ line by line left the asterisks on the page. */
261
+ const para = [];
262
+ while (
263
+ i < lines.length && lines[i].trim() &&
264
+ !LIST_RE.test(lines[i]) && !/^\s*(#{1,6}\s|\||&gt;|```)/.test(lines[i])
265
+ ) {
266
+ para.push(lines[i++].trim());
267
+ }
268
+ out.push("<p>" + inlineFmt(para.length ? para.join(" ") : lines[i++].trim()) + "</p>");
269
+ }
270
+ return out.join("\n");
271
+ }
272
+
273
+ /** One level of a list, recursing wherever the indent goes deeper. */
274
+ function mdList(items, from, depth, fmt) {
275
+ let html = "";
276
+ let tasks = 0;
277
+ let i = from;
278
+ const ordered = items[from].ordered;
279
+ while (i < items.length && items[i].depth >= depth) {
280
+ if (items[i].depth > depth) {
281
+ const sub = mdList(items, i, items[i].depth, fmt);
282
+ // Nest inside the bullet we just closed, so the sub-list belongs to it.
283
+ html = html.replace(/<\/li>$/, sub.html + "</li>");
284
+ i = sub.next;
285
+ continue;
286
+ }
287
+ const task = /^\[( |x|X)\]\s+(.*)$/.exec(items[i].text);
288
+ if (task) {
289
+ tasks++;
290
+ html += '<li><span class="box">' + iconHTML(task[1] === " " ? "square" : "task-done") +
291
+ "</span>" + fmt(task[2]) + "</li>";
292
+ } else {
293
+ html += "<li>" + fmt(items[i].text) + "</li>";
294
+ }
295
+ i++;
296
+ }
297
+ const tag = ordered ? "ol" : "ul";
298
+ return { html: "<" + tag + (tasks ? ' class="tasks"' : "") + ">" + html + "</" + tag + ">", next: i };
299
+ }
300
+
301
+ const GH_REF = /^https?:\/\/github\.com\/([\w.-]+)\/([\w.-]+)\/(issues|pull)\/(\d+)/;
302
+
303
+ /** A link, or a chip if it points at an issue or a PR and the text adds nothing. */
304
+ function mdLink(href, text, opts) {
305
+ const gh = GH_REF.exec(href);
306
+ if (gh && (text === href || text === gh[1] + "/" + gh[2] + "#" + gh[4])) {
307
+ const repo = gh[1] + "/" + gh[2];
308
+ const label = (repo === opts.repo ? "" : repo) + "#" + gh[4];
309
+ return '<a class="ref' + (gh[3] === "pull" ? " pr" : "") + '" href="' + href +
310
+ '" target="_blank" rel="noopener" title="' + repo + " #" + gh[4] + '">' + label + "</a>";
311
+ }
312
+ if (/^https?:\/\//.test(href)) {
313
+ return '<a href="' + href + '" target="_blank" rel="noopener">' + text + "</a>";
314
+ }
315
+ if (opts.docs) {
316
+ // A link between doc pages stays inside the portal.
317
+ if (/^[\w.-]*\.md(#.*)?$/.test(href) || href.startsWith("#")) {
318
+ return '<a href="#" data-doc="' + href + '">' + text + "</a>";
319
+ }
320
+ return text;
321
+ }
322
+ if (href.startsWith("#")) return text;
323
+ // A relative link inside a brain opens the file here rather than going nowhere.
324
+ const path = mdPath(href, opts);
325
+ if (!path) return text;
326
+ return '<a href="#" data-file="' + path + '">' + text + "</a>";
327
+ }
328
+
329
+ /** Resolve a relative path against the file being read, so ../assets/x.png works. */
330
+ function mdPath(href, opts) {
331
+ if (!opts.file || /^(https?:|data:|mailto:)/.test(href)) return null;
332
+ const parts = (opts.file.dir + href.split("#")[0].split("?")[0]).split("/");
333
+ const stack = [];
334
+ for (const p of parts) {
335
+ if (!p || p === ".") continue;
336
+ if (p === "..") stack.pop();
337
+ else stack.push(p);
338
+ }
339
+ return stack.join("/");
340
+ }
341
+
342
+ function mdAsset(src, opts) {
343
+ const path = /^(https?:|data:)/.test(src) ? null : mdPath(src, opts);
344
+ return path ? "/api/file?path=" + encodeURIComponent(opts.file.staffDir + "/" + path) : src;
345
+ }
346
+
347
+ const REF_RE = /(^|[\s(>])([\w.-]+\/[\w.-]+)?#(\d+)\b/g;
348
+ // GitHub's own username shape, plus the short aliases the agents answer to.
349
+ const AT_RE = /(^|[\s(>])@([a-z\d](?:[a-z\d]|-(?=[a-z\d])){0,38})\b/gi;
350
+
351
+ /* #123, owner/repo#123 and @handle become chips.
352
+ Split on tags and skip anything already inside an anchor, because a markdown link whose
353
+ text is "#113" would otherwise end up as an anchor nested in an anchor. */
354
+ export function chips(html, opts = {}) {
355
+ const parts = html.split(/(<[^>]*>)/);
356
+ let inA = 0;
357
+ return parts
358
+ .map((p) => {
359
+ if (p.startsWith("<")) {
360
+ if (/^<a\b/i.test(p)) inA++;
361
+ else if (/^<\/a>/i.test(p)) inA = Math.max(0, inA - 1);
362
+ return p;
363
+ }
364
+ if (inA) return p;
365
+ return p
366
+ .replace(REF_RE, (m, pre, repo, n) => {
367
+ const target = repo || opts.repo;
368
+ if (!target) return m;
369
+ // /issues/N redirects to /pull/N when it is a PR, so one form is always right.
370
+ return pre + '<a class="ref" href="https://github.com/' + target + "/issues/" + n +
371
+ '" target="_blank" rel="noopener" title="' + target + " #" + n + '">' +
372
+ (repo ? repo : "") + "#" + n + "</a>";
373
+ })
374
+ .replace(AT_RE, (m, pre, name) => {
375
+ const who = PEOPLE.get(name.toLowerCase());
376
+ if (!who) {
377
+ return pre + '<a class="at" href="https://github.com/' + name +
378
+ '" target="_blank" rel="noopener">@' + name + "</a>";
379
+ }
380
+ // A mention of someone on this roster is marked, and goes to their repo.
381
+ return pre + '<a class="at you" href="' + who.href + '" target="_blank" rel="noopener"' +
382
+ ' title="' + esc(who.title) + '">@' + name + "</a>";
383
+ });
384
+ })
385
+ .join("");
386
+ }
@@ -0,0 +1,59 @@
1
+ /* Re-reading the world, and saying so.
2
+ *
3
+ * Everything is read from disk per request, so a refresh is just re-fetching. Without it a
4
+ * run that lands while the page is open stays invisible until a reload, which is what made
5
+ * the diffs look days old.
6
+ *
7
+ * Its own module because both the shell and the inbox need it, and importing the shell from
8
+ * a view would make the module graph a ring. */
9
+
10
+ import { getOrg, getSync } from "./api.js";
11
+ import { $, ago, el, esc } from "./dom.js";
12
+ import { render } from "./router.js";
13
+ import { S } from "./state.js";
14
+
15
+ export async function refreshAll(soft) {
16
+ const bar = $("#refreshall");
17
+ bar?.classList.add("spin");
18
+ try {
19
+ // Pull first. The portal reads the working tree, so a run that has landed on GitHub is
20
+ // invisible here until the checkout catches up.
21
+ S.sync = await getSync();
22
+ S.data = await getOrg();
23
+ S.inbox = null; // the inbox re-fetches itself on next paint
24
+ S.loadedAt = new Date();
25
+ if (!soft) render();
26
+ stampLoaded();
27
+ } finally {
28
+ bar?.classList.remove("spin");
29
+ }
30
+ }
31
+
32
+ export function stampLoaded() {
33
+ const s = $("#loaded");
34
+ if (s && S.loadedAt) s.textContent = "data " + ago(S.loadedAt.toISOString());
35
+ }
36
+
37
+ /* A repo that could not be fast-forwarded is the difference between "nothing happened
38
+ today" and "something happened and you cannot see it", so it is said out loud. */
39
+ export function syncNotice() {
40
+ const stuck = (S.sync?.results ?? []).filter((r) => r.skipped || r.error || r.behind > 0);
41
+ if (!stuck.length) return null;
42
+ const d = el("div", { className: "notice" });
43
+ d.innerHTML = stuck
44
+ .map(
45
+ (r) =>
46
+ "<b>" + esc(r.dir) + "</b> " +
47
+ (r.error
48
+ ? "could not sync: " + esc(r.error)
49
+ : r.skipped === "dirty"
50
+ ? "is " + r.behind + " behind and has uncommitted changes, so it was not pulled"
51
+ : r.skipped === "diverged"
52
+ ? "has diverged (" + r.ahead + " ahead, " + r.behind + " behind)"
53
+ : r.skipped === "no-remote"
54
+ ? "has no remote"
55
+ : "is still " + r.behind + " behind"),
56
+ )
57
+ .join("<br>");
58
+ return d;
59
+ }
@@ -0,0 +1,20 @@
1
+ /* One indirection, so a view can ask for a repaint without importing the shell that owns it.
2
+ app.js registers the real renderer at boot; views import `render` and `go`. */
3
+
4
+ import { S } from "./state.js";
5
+
6
+ let renderer = () => {};
7
+
8
+ export function onRender(fn) {
9
+ renderer = fn;
10
+ }
11
+
12
+ export function render() {
13
+ renderer();
14
+ }
15
+
16
+ /** Change some state and repaint. The one way a view navigates. */
17
+ export function go(patch) {
18
+ Object.assign(S, patch);
19
+ render();
20
+ }