diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 0015f78a..126eccdd 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -228,6 +228,12 @@ jobs:
- name: Build the wasm bundle
run: ./web/build.sh
+ # assemble.sh generates the prose site with node. The runner image ships
+ # one, but pinning it here is what makes a local run evidence about this.
+ - uses: actions/setup-node@v4
+ with:
+ node-version: "24"
+
# `web/assemble.sh`, not an inline copy of it: the browser harness job
# below assembles the same tree, and a harness that runs against a
# different arrangement of files is testing something nobody deploys.
diff --git a/context/code/doc-site.md b/context/code/doc-site.md
new file mode 100644
index 00000000..4e900781
--- /dev/null
+++ b/context/code/doc-site.md
@@ -0,0 +1,154 @@
+# The documentation site — the arguments
+
+`scripts/build-doc-site.mjs` turns the prose in `context/` into the static site
+served at `/docs/` on GitHub Pages. This is the reasoning behind what it
+publishes, what it deliberately does not, and why it is a hundred-odd lines of
+node rather than a static site generator (improvise-djoo).
+
+---
+
+## 1. What is published, and what is not
+
+The manifest is the `SECTIONS` array at the top of the script, and **a document
+is published only by being named there**. That is the point: a glob over
+`context/**` would publish the next working file somebody drops in, and the
+decision about what a stranger should read is not one to make by accident.
+
+Published, in four sections:
+
+| Section | Contents | Why |
+|---|---|---|
+| Start here | `docs/design-notes.md`, `context/design-principles.md`, `context/repo-map.md` | What the project is for, the doctrine, and the single best explanation of how it is built. |
+| Decision records | `context/adr/*.md` | An ADR is written for an outside reader by construction — it states the options that were rejected, which is the part that is worth reading and the part that is nowhere else. |
+| The arguments, by area | `context/code/*.md` | This is the reasoning that used to live in code comments and was moved out under the two-line cap (design-principles §6). It is the most valuable prose in the repository and it is addressed to exactly the reader a documentation site has. |
+| Test evidence | `context/test-evidence/*.md` | 501 entries, one per test whose narrative is longer than its code. |
+
+Not published:
+
+- **`context/plan.md`** is a *prompt*: it opens "You are working in the
+ `improvise` repository… Your job is to prepare this repository for a public
+ Show HN launch", and it instructs a reader to audit a `context/SPEC.md` that
+ no longer exists. It is an instruction to an agent about a launch, not a
+ description of the program, and publishing a launch plan on the site the
+ launch points at inverts the two.
+- **`context/cloud-agents.md`** is operational notes for agents running in a
+ cloud session — tracker hydration, `bd` pitfalls, what the sandbox blocks.
+ It is true and it is useful and it is addressed to nobody who visits a
+ website.
+
+Neither is referenced by any published document except as a row in
+`repo-map.md`'s file inventory, which is inside a code fence, so leaving them
+out creates no dangling link. That was checked rather than assumed.
+
+**Test evidence is the one judgement call worth stating.** The argument for
+leaving it out is that 501 entries about individual tests are noise to a reader
+who wanted to know what a pivot table is. The argument for keeping it is
+stronger: every one of the seven `context/code/*.md` documents points at its
+evidence file by name, so omitting it would break the published prose in
+exactly the way this site is otherwise careful not to; and the evidence file is
+the most distinctive artefact the repository has — a written record of the
+injected fault behind each load-bearing test is not a thing most projects can
+show. It is therefore published as its **own section**, last, described as a
+contributor reference rather than a tour, so nobody wanders into it expecting
+a narrative.
+
+## 2. Ticket ids resolve to a page, never to a URL that does not exist
+
+The prose cites `improvise-xxxx` roughly 190 times. On a public site those are
+meaningless strings, and the obvious repair — link them to an issue tracker —
+is unavailable: beads keeps its issues in a Dolt database on a git ref, and
+there is no public issue URL to point at. Inventing one would produce 190
+links that 404, which is worse than plain text because a link is a promise.
+
+So the ids resolve **inward**. `tickets.html` is generated from
+`.beads/issues.jsonl` — already committed, already public — and carries id,
+status and title, nothing else; descriptions are internal working notes and are
+not the site's business. Each citation in the prose becomes a link to that
+page's anchor, with the title in a `title=` attribute so a hover answers the
+question without a navigation.
+
+**`issues.jsonl` is a passive export and lags the database**, which is not a
+defect to work around but a fact to render honestly: an id the export does not
+carry is emitted as plain text with a dotted underline and no link, and the
+ticket page lists it as "not exported". The site therefore never links to an
+anchor it did not generate, whatever state the export is in. At the time of
+writing 8 of 178 linkified ids are in that state. A generator that instead
+linked every id it saw would produce a page that is right today and broken the
+first time somebody cites a fresh ticket.
+
+## 3. Source paths link only where the file exists
+
+A code span whose entire content is a repo path becomes a link — to the
+generated page when the path is one of the published documents, to GitHub
+otherwise. Two constraints make that safe. The whole span must be the path, so
+a sentence that merely mentions a directory is untouched; and **the path must
+exist on disk at generation time**, so a citation that has gone stale degrades
+to plain text rather than to a 404. The GitHub ref is the cache-bust token
+`web/assemble.sh` already receives, used only when it is a 40-hex sha and
+falling back to `main` otherwise — a local `dev` build must not emit
+`blob/dev/…`.
+
+## 4. The link check is the test, and it is part of the build
+
+The generator collects every anchor it emits and every internal href it writes,
+resolves them against each other, and **exits non-zero** if any href names a
+page it did not generate or a fragment that page does not carry.
+`web/assemble.sh` runs under `set -eu`, so a broken cross-reference fails the
+assembly rather than shipping.
+
+This is not ceremony. It caught two real defects on its first two runs: a
+fence-close regex that compared a three-character marker against a
+one-character string, which swallowed every document from its first code block
+onward; and a list parser that ended the gotcha list at the first blank line,
+so 47 of the 48 gotcha anchors were never emitted while the "jump to gotcha N"
+strip linked to all of them. Both were invisible in a rendered page that
+otherwise looked fine.
+
+It is proved by injection rather than asserted: renaming the emitted anchor ids
+makes it report 48 missing anchors, and pointing the ticket links at a page
+name that is never generated makes it report 763 missing pages — two different
+diagnoses, because "the page is absent" and "the page is there but the anchor
+is not" call for different repairs.
+
+What it does **not** cover is links that leave the `docs/` subtree — the ones
+to `../index.html` and `../app/`. Those are checked against the assembled
+`site/` tree by hand at the moment, and the honest statement is that a rename
+of the app directory would not be caught here.
+
+## 5. Why a node script and not a generator
+
+The landing page is hand-written HTML with no build step and one pinned CDN
+script; the same posture applies here. A static site generator would add a
+dependency tree, a lockfile and a toolchain to a repository whose site is
+currently four files, in exchange for features this corpus does not use — the
+docs contain no front matter, no shortcodes and, as it happens, **not a single
+markdown link**. Every cross-reference in them is prose naming a path or an
+id, which is precisely why §2 and §3 are the interesting parts and Markdown
+rendering is the boring part.
+
+So the renderer is a deliberately small subset covering exactly what the corpus
+contains: ATX headings, fenced code, pipe tables, blockquotes, ordered and
+unordered lists with indented continuations, thematic breaks, and inline code,
+strong, emphasis and links. Anything it does not recognise becomes paragraph
+text, so **no input line is ever dropped** — the failure mode is an ugly
+paragraph, never a missing one.
+
+Two details in it are load-bearing and easy to undo. **Code spans are replaced
+by placeholders before emphasis is matched**, because this corpus routinely
+opens `**` on one side of a code span and closes it on the other; matching
+emphasis per segment silently drops that bold, which is a fidelity loss with
+no visible symptom. And **strong is matched before emphasis, with a body that
+may contain a single `*` pair**, because `[^*]+` cannot match
+`**a *b* c**` and the leftover `*` then pairs across the bold — which is how a
+single sentence in gotcha 33 came out as three italic paragraphs.
+
+## 6. Navigation
+
+`repo-map.md` is one long document whose gotcha list is numbered 1 to 48, and
+splitting it per gotcha was rejected: the gotchas cross-reference each other
+constantly ("see gotcha 35", "for the reason gotcha 39 watches…"), and a reader
+following those wants a scroll, not 48 page loads. It stays one page, with an
+id on every list item and a compact strip of 48 numbered links under the
+heading. The ids come from the list item's **own number**, not from a counter,
+so an inserted gotcha renumbers the anchors exactly as it renumbers the prose
+that cites them.
diff --git a/docs/index.html b/docs/index.html
index 090bea45..ebcfc879 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -134,6 +134,7 @@
▶ Run improvise in your browser
+ Documentation
Source on GitHub
@@ -191,7 +192,8 @@ nix profile install github:fiddlerwoaroof/improvise
diff --git a/scripts/build-doc-site.mjs b/scripts/build-doc-site.mjs
new file mode 100644
index 00000000..6cde83c1
--- /dev/null
+++ b/scripts/build-doc-site.mjs
@@ -0,0 +1,585 @@
+#!/usr/bin/env node
+// Generate the documentation site from context/ into OUT_DIR (default site/docs).
+// The argument and the rationale live in context/code/doc-site.md.
+import { readFileSync, writeFileSync, mkdirSync, rmSync, existsSync } from "node:fs";
+import { dirname, join, relative } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const root = join(dirname(fileURLToPath(import.meta.url)), "..");
+const out = process.argv[2] || join(root, "site", "docs");
+const token = process.argv[3] || "";
+const ref = /^[0-9a-f]{40}$/.test(token) ? token : "main";
+const REPO = "https://github.com/fiddlerwoaroof/improvise";
+
+// ---------------------------------------------------------------------------
+// The manifest. A doc is published only by being named here — see
+// context/code/doc-site.md for what was left out and why.
+const SECTIONS = [
+ {
+ id: "start",
+ title: "Start here",
+ blurb:
+ "What the project is for, and the principles the code is held to.",
+ docs: [
+ ["docs/design-notes.md", "design-notes", "Design notes"],
+ ["context/design-principles.md", "design-principles", "Design principles"],
+ ["context/repo-map.md", "repo-map", "Repository map"],
+ ],
+ },
+ {
+ id: "adr",
+ title: "Decision records",
+ blurb: "Decisions with consequences, including the options that were rejected.",
+ docs: [
+ ["context/adr/001-concurrent-edits.md", "adr/001-concurrent-edits", "ADR 001 — Concurrent edits"],
+ ["context/adr/002-naming-a-cell.md", "adr/002-naming-a-cell", "ADR 002 — Naming a cell"],
+ ["context/adr/003-live-backed-models.md", "adr/003-live-backed-models", "ADR 003 — Live-backed models"],
+ ],
+ },
+ {
+ id: "code",
+ title: "The arguments, by area",
+ blurb:
+ "The reasoning that used to live in code comments. Written for a reader with the source open.",
+ docs: [
+ ["context/code/core.md", "code/core", "improvise-core"],
+ ["context/code/formula-and-io.md", "code/formula-and-io", "improvise-formula and improvise-io"],
+ ["context/code/protocol.md", "code/protocol", "improvise-protocol"],
+ ["context/code/command.md", "code/command", "improvise-command"],
+ ["context/code/clients.md", "code/clients", "The clients"],
+ ["context/code/tui.md", "code/tui", "The TUI"],
+ ["context/code/gp.md", "code/gp", "gp-tree and improvise-gp"],
+ ],
+ },
+ {
+ id: "evidence",
+ title: "Test evidence",
+ blurb:
+ "For each test whose narrative is longer than its code: what broke, what fault was injected, and why the test can fail. Contributor reference, not a tour.",
+ docs: [
+ ["context/test-evidence/core.md", "test-evidence/core", "improvise-core"],
+ ["context/test-evidence/formula-and-io.md", "test-evidence/formula-and-io", "improvise-formula and improvise-io"],
+ ["context/test-evidence/protocol.md", "test-evidence/protocol", "improvise-protocol"],
+ ["context/test-evidence/command.md", "test-evidence/command", "improvise-command"],
+ ["context/test-evidence/clients.md", "test-evidence/clients", "The clients"],
+ ["context/test-evidence/tui.md", "test-evidence/tui", "The TUI"],
+ ["context/test-evidence/gp.md", "test-evidence/gp", "gp-tree and improvise-gp"],
+ ],
+ },
+ {
+ id: "meta",
+ title: "About this site",
+ blurb: "What is published here, what is not, and why.",
+ docs: [["context/code/doc-site.md", "doc-site", "About this site"]],
+ },
+];
+
+const PAGE_BY_SOURCE = new Map();
+for (const s of SECTIONS) for (const [src, slug] of s.docs) PAGE_BY_SOURCE.set(src, `${slug}.html`);
+
+// ---------------------------------------------------------------------------
+// The ticket index. `.beads/issues.jsonl` is a passive export and lags the Dolt
+// database, so an id it does not carry is rendered as plain text rather than a
+// link that 404s.
+function readTickets() {
+ const path = join(root, ".beads", "issues.jsonl");
+ const map = new Map();
+ if (!existsSync(path)) return map;
+ for (const line of readFileSync(path, "utf8").split("\n")) {
+ if (!line.trim()) continue;
+ let o;
+ try {
+ o = JSON.parse(line);
+ } catch {
+ continue;
+ }
+ if (o && o._type === "issue" && typeof o.id === "string" && typeof o.title === "string") {
+ map.set(o.id, { title: o.title, status: o.status || "unknown", kind: o.issue_type || o.type || "" });
+ }
+ }
+ return map;
+}
+const TICKETS = readTickets();
+
+// Tokens that look like an id but name a crate or a package. Anchored, because
+// an unanchored alternative is a prefix match that silently excludes a real id.
+const NON_TICKET = new Set(
+ "core formula io protocol command gp ws worker wasm harness registry side".split(" "),
+);
+
+// ---------------------------------------------------------------------------
+// Markdown. A deliberately small subset, covering exactly what the corpus uses;
+// anything unrecognised becomes paragraph text, so no input line is ever lost.
+const esc = (s) => s.replace(/&/g, "&").replace(//g, ">");
+
+function slugify(s) {
+ return (
+ s
+ .toLowerCase()
+ .replace(/<[^>]+>/g, "")
+ .replace(/&[a-z]+;/g, "")
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/^-+|-+$/g, "") || "section"
+ );
+}
+
+// A code span whose whole content is a repo path becomes a link: to the
+// generated page when the path is a published doc, to GitHub otherwise. The
+// path must exist on disk, so a stale citation stays plain text.
+function pathHref(p, depth) {
+ const clean = p.replace(/^\.\//, "");
+ if (!/^[A-Za-z0-9_.\/-]+$/.test(clean) || !/[\/.]/.test(clean)) return null;
+ if (clean.includes("..") || clean.startsWith("/")) return null;
+ if (PAGE_BY_SOURCE.has(clean)) return "../".repeat(depth) + PAGE_BY_SOURCE.get(clean);
+ if (!existsSync(join(root, clean))) return null;
+ return `${REPO}/blob/${ref}/${clean}`;
+}
+
+function inline(text, ctx) {
+ // Code spans first: a backtick run opens a span only if a run of the same
+ // length closes it, so an unmatched ``` in prose stays literal.
+ const parts = [];
+ let i = 0;
+ while (i < text.length) {
+ if (text[i] === "`") {
+ let n = 0;
+ while (text[i + n] === "`") n++;
+ const fence = "`".repeat(n);
+ const end = text.indexOf(fence, i + n);
+ const after = end >= 0 ? end + n : -1;
+ if (end >= 0 && (after >= text.length || text[after] !== "`")) {
+ parts.push({ code: text.slice(i + n, end) });
+ i = after;
+ continue;
+ }
+ }
+ const next = text.indexOf("`", i + 1);
+ parts.push({ text: text.slice(i, next < 0 ? text.length : next) });
+ i = next < 0 ? text.length : next;
+ }
+
+ // Code spans stand aside as placeholders while emphasis is matched, because
+ // the corpus routinely opens `**` on one side of a code span and closes it on
+ // the other; matching per segment silently drops that bold.
+ const held = [];
+ let s = parts
+ .map((p) => {
+ if (p.code === undefined) return esc(p.text);
+ const body = `${esc(p.code)}`;
+ const href = pathHref(p.code.trim(), ctx.depth);
+ held.push(href ? `${body}` : body);
+ return `\u0000${held.length - 1}\u0000`;
+ })
+ .join("");
+
+ s = s.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_m, label, href) => `${label}`);
+ // Strong first, and its body may contain a single `*` pair: `[^*]+` cannot
+ // match `**a *b* c**`, and the leftover `*` then pairs across the bold.
+ s = s.replace(/\*\*((?:[^*]|\*(?!\*))+)\*\*/g, "$1");
+ s = s.replace(/\*([^*]+)\*/g, "$1");
+ s = s.replace(/\bimprovise-([a-z0-9]{3,8})\b/g, (m, tail) => {
+ if (NON_TICKET.has(tail)) return m;
+ ctx.cited.add(m);
+ if (!TICKETS.has(m)) {
+ ctx.unresolved.add(m);
+ return `${m}`;
+ }
+ const t = TICKETS.get(m);
+ const href = `${"../".repeat(ctx.depth)}tickets.html#${m}`;
+ return `${m}`;
+ });
+ return s.replace(/\u0000(\d+)\u0000/g, (_m, n) => held[Number(n)]);
+}
+
+// Block parse. Returns { html, toc }.
+function renderMarkdown(src, ctx) {
+ const lines = src.split("\n");
+ const toc = [];
+ const seen = new Map();
+ const html = blocks(lines);
+ return { html: html.join("\n"), toc };
+
+ function heading(level, text) {
+ const rendered = inline(text, ctx);
+ let id = slugify(text);
+ const n = (seen.get(id) || 0) + 1;
+ seen.set(id, n);
+ if (n > 1) id = `${id}-${n}`;
+ ctx.anchors.add(id);
+ if (level <= 3) toc.push({ level, id, text: rendered });
+ const mark = level > 1 ? `#` : "";
+ return `
${inline(para.join(" "), ctx)}
`); + para = []; + }; + while (i < ls.length) { + const line = ls[i]; + const fence = line.match(/^(\s*)(`{3,}|~{3,})\s*([A-Za-z0-9_-]*)\s*$/); + if (fence) { + flush(); + const close = new RegExp(`^\\s*${fence[2][0] === "~" ? "~" : "\`"}{3,}\\s*$`); + const body = []; + i++; + while (i < ls.length && !close.test(ls[i])) { + body.push(ls[i]); + i++; + } + i++; + outHtml.push(`${esc(body.join("\n"))}`);
+ continue;
+ }
+ const h = line.match(/^(#{1,6})\s+(.*)$/);
+ if (h) {
+ flush();
+ outHtml.push(heading(h[1].length, h[2].trim()));
+ i++;
+ continue;
+ }
+ if (/^\s*(-{3,}|\*{3,}|_{3,})\s*$/.test(line) && !para.length) {
+ flush();
+ outHtml.push("${blocks(body).join("\n")}`); + continue; + } + const li = line.match(/^(\s*)([-*+]|\d{1,3}\.)\s+(.*)$/); + if (li) { + flush(); + const [listHtml, next] = list(ls, i, li[1].length); + outHtml.push(listHtml); + i = next; + continue; + } + if (!line.trim()) { + flush(); + i++; + continue; + } + para.push(line.trim()); + i++; + } + flush(); + return outHtml; + } + + // An item's continuation lines are dedented by that item's own marker width, + // so a nested list keeps its relative indentation and reparses as a list. + function list(ls, start, indent) { + const ordered = /^\s*\d{1,3}\./.test(ls[start]); + const items = []; + let i = start; + let first = null; + while (i < ls.length) { + const m = ls[i].match(/^(\s*)([-*+]|(\d{1,3})\.)\s+(.*)$/); + if (m && m[1].length === indent) { + if (ordered && first === null) first = Number(m[3]); + items.push({ num: m[3] ? Number(m[3]) : null, cw: m[0].length - m[4].length, body: [m[4]] }); + i++; + continue; + } + if (!items.length) break; + const cur = items[items.length - 1]; + if (!ls[i].trim()) { + let j = i; + while (j < ls.length && !ls[j].trim()) j++; + if (j >= ls.length) break; + // A blank line ends the list only if what follows is neither the next + // item nor a continuation of the open one. + const isSibling = /^(\s*)([-*+]|\d{1,3}\.)\s+/.test(ls[j]) && ls[j].search(/\S/) === indent; + if (isSibling) { + i = j; + continue; + } + if (ls[j].search(/\S/) > indent) { + cur.body.push(""); + i = j; + continue; + } + break; + } + const col = ls[i].search(/\S/); + if (col <= indent) break; + cur.body.push(ls[i].slice(Math.min(col, cur.cw))); + i++; + } + const tag = ordered ? "ol" : "ul"; + const attr = ordered && first !== null && first !== 1 ? ` start="${first}"` : ""; + const rendered = items + .map((it) => { + const inner = blocks(it.body); + const body = inner.length === 1 && inner[0].startsWith("
") + ? inner[0].slice(3, -4) + : inner.join("\n"); + const id = ctx.listItemId && it.num !== null ? ctx.listItemId(it.num) : null; + if (id) ctx.anchors.add(id); + return `
| ${c} | `).join("")}
|---|
| ${c} | `).join("")}
Gotchas: ${Array.from({ length: 48 }, (_, k) => `${k + 1}`).join("")}
` + : ""; + + const srcLine = `Source: ${src}
`; + written.push({ + rel: `${slug}.html`, + html: page({ + slug, + title, + depth, + body: `${srcLine}${tocHtml}${jump}${html}`, + }), + anchors: ctx.anchors, + }); + } +} + +// Ticket index. +{ + const ctx = { depth: 0, cited, unresolved, anchors: new Set() }; + const ids = [...cited].sort(); + const rows = ids + .map((id) => { + const t = TICKETS.get(id); + ctx.anchors.add(id); + if (!t) + return `The prose on this site cites issue ids like improvise-9c8c. They are this project's own tracker ids, kept in-repo with beads; there is no public issue URL to link to, so this page is what they resolve to. Titles come from .beads/issues.jsonl, a passive export that lags the database — an id it does not carry is listed here without a title and is not linked from the prose.
${ids.length} ids are cited, of which ${ids.length - unresolved.size} resolve.
+| Id | Status | Title |
|---|
${s.blurb}
These are the working documents of the improvise repository, published as written. They are addressed to someone reading or changing the code: the arguments behind the architecture, the decisions that were made and the ones that were rejected, and the record of what each load-bearing test is guarding against.
+Nothing here is a tutorial. For what the program does, see the project home or run the browser demo.
+${cards} +Ids like improvise-9c8c are tracker issues and link to the ticket index. Paths like crates/improvise-core/src/model/types.rs link to the source on GitHub at ${ref}.