Context. The vault is markdown for machines and for humans who read it in an editor, but it has no browsable, linkable, offline form. enchiridion export adds one: it renders wiki/ (and, opt-in, raw/) to a self-contained static HTML site under web/ — every page an .html file, frontmatter as a table, tags as their own pages, and a generated front page. This is a new capability with the same model-assignment question every capability faces (start at the deterministic floor, escalate only on a measured need — see the standing convention), plus one it does not: what a static export reads, given that everything else in the read path reads committed HEAD, not the working tree.
Decision 1 — export reads the working tree, but refuses to run when the exported subtree is dirty. The search index is a materialised view of HEAD and an uncommitted page is structurally invisible to it (ADR-0015). Export deliberately does not inherit that rule: it is a deterministic file transform whose least-surprising contract is “export what is on disk,” and coupling it to the index would make a just-written page silently absent from its own site. But “read the working tree” and “the vault is its committed history” are only reconcilable when the two agree, so export closes the gap the other way: it runs git status and refuses when the exported subtree carries staged/modified tracked files or untracked non-ignored files, naming the offending paths. A clean tree means working tree ≡ HEAD, so export reads disk yet never publishes bytes that were never committed to the vault. The check is scoped to what is being exported: wiki/ always, raw/ only under --raw — a clean wiki/ with an uncommitted file in raw/ exports fine when raw is not included. --allow-dirty is the explicit escape hatch for the rare intentional preview of uncommitted work. This is the inverse of ADR-0015’s stance (that one ignores the working tree by construction; this one demands the working tree equal HEAD), and the reconciliation is that both refuse to let an uncommitted byte be treated as vault content — one by not seeing it, the other by not running.
Decision 2 — the front page’s get-started set is LLM judgment in the skill, over a deterministic candidate ranking the script emits, with a script-only fallback. The front page links 10–12 pages “to get started with.” Choosing the best entry points into an arbitrary knowledge vault is a judgment call — the kind of comprehension the deterministic script cannot make well. So the split is: the script owns the mechanical half (enchiridion export --candidates emits a ranked JSON list — page ref, title, summary, inbound-link count, kind, tags — and writes nothing), and the /wiki-export skill’s LLM owns the judgment (read the candidate ranking plus the vault’s own README.md/CLAUDE.md/AGENTS.md when present, pick the set, invoke export --starters <refs…> with an optional per-ref annotation). Crucially, the script never depends on the skill: bare enchiridion export with no --starters falls back to a deterministic rule — inbound-link count, top 12, tie-broken by title — so the script alone always produces a complete, valid site (for tests, dogfooding, and non-skill callers). The LLM improves the front page; it is never load-bearing for producing one. This keeps the capability at the deterministic floor by default and layers judgment on top, rather than making a model a hard dependency of a file transform. When a start page is supplied the block is not rendered at all — the nominated page supplies the entrance itself — so the seam below is idle for that export (see Mechanism).
Decision 3 — --single-file writes the same parts as one document, and navigates by hash because file:// is the point. A directory tree is the right shape for browsing in place and the wrong one for sending: a recipient who wants to read it on a phone must unzip it first, which on a phone is a small ordeal. So the export gains a second shape, --single-file, in which every page and aggregate of the exported set becomes a <section> of one self-contained document, --out names that file instead of a directory (default wiki.html at the vault root, replaced outright on a re-run — the file is the export, so refreshing it is the normal case and needs no --force), the stylesheet is inlined because there is no second file to link, and every internal link is rewritten from a relative .html path to a #slug fragment. The clean-tree refusal, the title resolution, the get-started seam and the parts themselves are all shared with multi-page mode: this is a different assembly of one render, not a second renderer.
The navigation is deliberately the hash, not the History API. pushState is unreliable on file:// — some browsers throw SecurityError on an opaque origin, and a file:// document has exactly that — and being openable straight from an email attachment with no server is not an incidental property of this mode, it is what the mode is for. A hash change gives the same three things the History API would: tapping a link shows the target section without leaving the file, each navigation is its own history entry so the back button steps back through visited pages, and a #slug deep link lands on its section. The mechanism is one small inline script (ES5 in an IIFE, ~40 lines, no framework and nothing to fetch) that shows the section named by location.hash on load and on every hashchange, falling back to the reserved __front section when a cold open has no hash to honour. This retires the “zero JS” claim the multi-page mode below still keeps: the single-file document ships a script, by construction, and a recipient opening it with scripting off sees no page at all. Multi-page output remains scriptless.
Decision 4 — a section’s id is derived from the page’s output path by one function, used for both writing and rewriting. sectionIdFor drops the .html extension and collapses every run of remaining non-alphanumerics to one -, so wiki/concepts/alpha-concept.html becomes wiki-concepts-alpha-concept; the front page alone takes the reserved __front, which no derived id can collide with because the derivation never lets an underscore through — and since a nominated start page is written at index.html, it takes __front with no special case. Links are rewritten through the HrefFor strategy the render layer threads through its parts generators (relativeHref for multi-page, hashHref for single-file), so every link in the document — nav bar, body, frontmatter edges, tags, kind indexes, the front page’s own lists — goes through one seam rather than each of a dozen call sites deciding again. Writing a section and rewriting a link to it are then the same function applied to the same path, which is why a rewritten link cannot miss.
enchiridion export subcommand (enchiridion-ts/src/exportcommand.ts), no model, plus a thin /wiki-export skill that runs inline in the invoking session (no dedicated agent — the judgment is small and one-shot). The skill asks which format before it runs (multi-page, the default, or single-file) and passes --single-file through; the flag is also on the subcommand directly, so scripting a single-file export needs no skill.--out <dir>, default web/ at the vault root (the caller gitignores it). The target is clean-wiped each run behind a --force guard, so a deleted vault page leaves no orphan HTML. The site mirrors the vault tree (wiki/concepts/foo.md → web/wiki/concepts/foo.html) — except a nominated start page, which is written at the front page’s own path instead (see the start-page bullet below) — is fully offline, and is zero JS with all-relative links — a claim that holds for multi-page output and only for it (see Decision 3).<section id="<slug>" class="wiki-page">, hidden by an inlined display: none so a multi-megabyte document does not flash its whole contents before the script runs; the shared stylesheet inlined in the same <style> block; the navigation script inline at the end of the body; and nothing of the export’s own linked or fetched — no stylesheet, script, font or icon of ours costs the recipient a request. (Author-written content is the author’s business: a page that links an image by relative path gets an <img src> that cannot resolve on the recipient’s machine, exactly as it cannot in multi-page output, which copies no non-markdown asset either.) The page parts carry their own nav bar, so the sticky nav works per section exactly as it does per page.runExport prints a suggestion of multi-page mode to stderr. The threshold is a constant of the mode (SINGLE_FILE_WARN_BYTES), and the decision of what to say about a given size is a pure function (singleFileSizeWarning) so it can be tested without writing five megabytes to disk.web/assets/style.css and linked from every page, never inlined per page. It is Sakura v1.5.1 vendored verbatim — classless, ~4 KB, MIT, licence header intact — plus a small supplement for what a classless framework cannot know (the sticky nav bar, the page header’s title margin and subtitle treatment, and the frontmatter table’s label column, leading gap and literal/derived divider). Both live as string constants in enchiridion-ts/src/exportstyle.ts, not as a fetched or build-time-read file: the export must work with no network, and the script layer ships as one bundled .cjs on an already-installed interpreter (ADR-0017). Each page’s <link href> is computed from that page’s own depth (./assets/…, ../assets/…, ../../assets/…), so it is right by construction. Vendoring Sakura drops the previous hand-rolled prefers-color-scheme block; no automatic dark mode is the accepted trade-off.<meta name="viewport" content="width=device-width, initial-scale=1">, and Sakura gives a readable column with sane type scale at phone widths.nav.wiki-nav, position: sticky) at the top of every page — wiki pages, per-kind index pages, tag pages, the tag index, the front page, and raw/ pages under --raw: the wiki title on the left, Home · Tags on the right. The title defaults to the vault root directory name — the one thing a vault calls itself without being told. It is a render option, derived by the writer (the only layer that knows a vault), so the render layer never inspects a vault to name one. A render pass invoked with no vault at all (tests, a pure caller) gets a neutral label rather than an empty bar; that is a last-resort default, not a second resolution. The directory name is the fallback, not the decision: the title is settable per run and saved persistently in a vault config file, resolved flag → config → directory name in one function (ADR-0023).renderPageParts / renderAggregateParts yield a page’s parts (title, nav, main) with no document shell around them, and renderPages / renderAggregatePages wrap those parts in the shared shell (buildHtmlShell(parts, assetsRoot)). A mode that assembles its own document consumes the parts directly instead of unpicking whole pages, and that is what single-file mode does — the parts arrive unchanged and are nested in sections. Below both shapes sits one buildDocument(title, style, body): the skeleton they share, differing only in the style element (a linked file versus an inlined block) and the body (one page’s parts versus every page’s). The assetsRoot: null variant the multi-page shell carried in anticipation of this mode is gone: the single-file document is not one page’s parts with the stylesheet inlined, it is all of them, so the seam never fit and both shapes meet one level down instead.markdown-it dependency already present (today used only for code-fence detection): html: false, GFM tables, linkify, and slugified heading ids so intra- and cross-page #anchor links resolve. .md→.html link rewriting runs over body and frontmatter links through the existing iterLinks/offset-splice machinery (enchiridion-ts/src/wikipage.ts); a link whose target is not exported (a raw/ target when --raw is off) renders as dead plain text, not a broken href.title and summary above the article — the page’s identity and its one-line abstract, the two things a reader should meet first. The title is the page’s own <h1>; the summary is a subtitle paragraph, styled apart from the article’s first paragraph. A body H1 that merely repeats the title is dropped, and the header’s <h1> takes over its anchor, so a link to page.html#the-title still lands; suppression reads the rendered HTML, so any ATX or setext spelling of the heading is caught and the anchor reused is markdown-it’s own id rather than a second slug that could disagree with it. A leading H1 that says something else is the author’s and stays. A page whose frontmatter has neither key gets no header (and no empty one) — which is every page with no frontmatter. raw/ pages keep their whole frontmatter in the footer table and get no extracted header, since their frontmatter is arbitrary rather than the schema’s.kind (from the folder) and superseded_by (inverted from other pages’ supersedes). Link-valued keys (typed edges, supersedes, raw_source) become HTML links; tags become links to tag pages; lists become <ul>. It follows the page’s article, as a footer (#565): provenance trails the content it describes, and leading with a key/value table — a source list sometimes fifteen links long — made metadata the first thing a reader met in both output shapes. The divider stays where it was in the block, wherever the remaining rows leave it; title and summary are not repeated as rows; a page with no frontmatter carries no table, and the raw builder emits the same order with its rows intact.web/tags/<slug>.html per tag (slugify from place.ts, colliding slugs suffix-disambiguated, and the index’s own slug index reserved so a tag named index takes the next free suffix — index-2, or the next one after that) and a web/tags/index.html of all tags with counts. The reservation exists because tags and the index share one directory: without it the tag page and the index collide on output path, and in single-file mode on the tags-index section id, so the nav’s Tags link lands on the tag page (#566). Per-kind index pages (web/wiki/<folder>/index.html) list every page of a kind, so no page is orphaned; when no start page is supplied the front page’s per-kind counts link to them, and when one is, the Browse by Kind list at the start page’s foot is that route instead. A start page is a legitimate member of its tags (it is listed on its tag pages like any other page) but is absent from its own kind’s index — it is already the front page — and a kind left with no members writes no index page and appears in no list.--start-page <ref>, saved as startPage) promotes one vault page to the front page by output path, not by splicing finished HTML: ExportMeta.outputPathFor answers index.html for the nominated ref and the page’s own .html path for every other, and every link site — body links, frontmatter typed edges, supersedes/superseded_by, raw_source, tag pages, kind indexes, the nav bar — asks it for both ends of an href. The promoted page is therefore exported exactly once (nothing at its vacated path, no redirect stub) and every reference to it resolves to the landing page by construction, in both output shapes; single-file mode needs no special case, because sectionIdFor("index.html") is already the reserved __front. The generated front page’s <h1>{wiki title}</h1>, its page-count line and its get-started block are dropped — the title still appears in the sticky nav bar on every page — while the page’s own header, article and footer frontmatter table render through the ordinary page path, with the Browse by Kind list inserted between the article and the table so provenance stays last. The ref is normalised (a leading ./ stripped, .md appended when absent) and must be a member of the exported set; a ref the export does not carry — including a raw/ ref without --raw, which the error says explicitly — is a loud error, never a silent fallback to the generated front page, because that fallback is the behaviour the feature exists to replace. --save-start-page persists the choice (a blank value clears it) and validates only against the vault’s page enumeration, since whether the export carries a raw/ page depends on the run. --starters beside a start page warns on stderr and writes the export anyway, exit 0.raw/ (under --raw) is a separate flat section under web/raw/, never feeding tags, get-started, or the kind indexes: a frontmatter table only when a block exists, else body only; non-markdown files copied verbatim (linked, not converted)..html, frontmatter-table rendering, tag/kind index generation, the inbound-count fallback) are unit-testable without a model in the loop.--candidates / --starters seam is the whole contract between script and skill. Anything the skill wants to influence on the front page must flow through --starters (refs + optional annotation); the skill authors no HTML. This is unchanged in single-file mode: the front page is a section like any other, so its get-started block is still what the skill chooses. When a start page is supplied the seam is idle: the get-started block is not rendered, --starters is ignored with a warning on stderr, and the skill’s influence on the landing page moves to nominating it (--start-page / --save-start-page).web/ (and a single-file export’s wiki.html) is a generated artifact, reproducible from a clean tree — never committed, never a source of truth. Neither is gitignored by wiki-init, which writes nothing about export output: a vault that exports adds the path its own .gitignore. A single file at the vault root is at least outside the exported subtree, so it never trips the dirty-tree check.#anchor (foo.md#some-heading) lands at the top of the target section in single-file mode: one fragment can name either the section to show or a heading inside it, and showing the section is the half that must not fail. In-page anchors are untouched — they are not .md links, they stay #heading, and they keep resolving within the section already on screen. Second, because the slug flattens a path’s separators and its hyphens to the same -, two pages whose paths differ only in where a hyphen sits (wiki/a-b/c.md and wiki/a/b-c.md) derive the same section id, and the second becomes unreachable. This needs a vault with both a custom kind folder and a page named to collide with it, or a raw/ tree nested that way; multi-page mode has no such failure and remains the answer for a vault that hits it.A need to export continuously from a working session where pages sit uncommitted for a long time (the same pressure named in ADR-0015). The answer would be the same: shorten the write-then-commit gap (commit on write) rather than loosen the clean-tree refusal, because the refusal is what keeps “what the site publishes” and “what the vault contains” the same question with one answer.