Lessons
How to build @amitkaps/prose correctly next time. spec.md is the design, plan.md the remaining work; this file is for gotchas and bug classes. Each entry: symptom, cause, fix, how to detect it.
The Devframe, Svelte client, annotator, safe-writes and check sections describe code that 0.2.0 removes (spec §6). They stay because the bug classes carry over to whatever comes next.
Field use: what got used (2026-09-30)
0.1.0 was used on two more projects, sitez (a static site generator, 49 commits) and markz (a Markdown parser, about 4,600 lines). The agents on each wrote a report, and the human’s own use matched them.
- Build the smallest thing that gets read, and watch whether it is. The
/__prose/route, its annotator and the planned *since* view were most of 0.1.0’s work. The human rarely opened the route: theprose/docs were read in the editor, and@proseblocks once in a while. When an agent changes code quickly, the reader wants Markdown where they already look. Detect it early by asking what the human actually opened last week, before building the next view. - Discussion happens in the chat, not in annotations. A decision took several turns of conversation, and the agent then rewrote
spec.mdorplan.md.@notenever became the channel: sitez has one note open for weeks (src/site.ts), markz has none, and markz’s cross-file questions went intoprose/docs instead. A channel the human has to go and look at loses to the one they’re already in. - The short promises doc paid off most. sitez’s
prose/idea.mdlet design questions be settled from what the project promises (no configuration, so a layer can’t be switched off, only overridden), and its “Not in v1” list pushed features to other projects. It works because it’s short enough to hold in mind. prose/became the published docs, and publishing belongs to sitez. markz’s site renders itsprose/docs in place as its pages, and sitez generalizes that: aprose/folder of Markdown is a site. It works because the docs link by repo path and were rewritten into neutral, current prose (markz #62) rather than left as a record of the conversation. So Prose doesn’t publish; it keeps the same files readable locally (spec §3.4).- The checks never ran. markz’s
AGENTS.mdsays “nothing checks prose against code on its own”, and the one real drift (a block naming origins thatAGENTS.mdplaces elsewhere) was about meaning, which no symbol or staleness check sees. Agents grep instead. What was kept is the reading: the convention and a renderer (spec §6). - First paragraphs are a map for finding, not a substitute for reading. Grepping 89
@prosesummaries finds the right file faster than reading code. Agents still read the whole file once there. Writing the prose also caught gaps: stating “plain-JS scripts are checked too” led to checking it, and then to a test. - The prose costs a second edit per change and drifts toward density. 36 of sitez’s 49 commits touch
prose/. Blocks grew into long sentences carrying three qualifications each (“rewrite, don’t append” is harder than it sounds). Hence the three-line limit on first paragraphs (spec §5). - A rule stated in three places drifts in the untested ones. markz explains its syntax in
syntax.md, states it in the testedgrammar.md, and restated it in@prose. One@proseblock already disagreed withAGENTS.mdabout where origins live. Link to the owner and keep only the how and why (spec §3.5). - Tuning changes rightly skip prose, and staleness can’t tell. markz’s speed commits changed code without prose, which was correct, but a speed change that alters a cost the prose states would slip through the same way.
Integrating Devframe
- Set the client build’s
baseabsolutely to the mount path, and passconnectDevframe()an explicitbaseURL. The same relative-path bug lives at two layers. (1)base: "./"in the client’s Vite config breaks when the route is hit without its trailing slash (/__prose):./assets/x.jsresolves against the parent path and lands on the host app. Hardcodebase: "/__<id>/", Devframe’s default mount for a hosted devframe. (2)connectDevframe()defaults tobaseURL: "./", so__connection.json404s the same way (Failed to get connection meta from ./). DerivebaseURLfromimport.meta.urlby string slicing (lastIndexOf("/"), twice), notnew URL("../", import.meta.url)(next entry). new URL(<string literal>, import.meta.url)is a build-time asset pattern in Vite, not a runtime join. With"../"it resolved to the repo’sdist/index.jsand inlined it as adata:text/javascript;base64,…URL, so the browser failed withFailed to get connection meta from data:text/javascript…. Node-based checks never run Vite’s asset analysis and passed. Detect withgrep -c "data:text/javascript" client/dist/assets/*.js;scripts/check-client-bundle.mjsnow does this as the lastpnpm buildstep.- Register RPC names fully qualified on the server.
client.scope("prose").rpc.call("tree")prefixes toprose:tree, butctx.rpc.register(defineRpcFunction({ name: "tree" }))registers the literal string. Register"prose:tree". The error[birpc] function "prose:tree" not foundpoints straight at it. - Verify asset serving by content, not status. A wrong base can return
200with the host app’sindex.html. Usecurl -sD - -o /dev/null <url>and comparecontent-typeandcontent-lengthwith the built file. SharedStatecan resolve before it is populated.sharedState("tree")with no clientinitialValuetakes an internal trusted branch that resolves at once;.value()isundefinedfor a tick, then the real data arrives as an ordinary"updated"event.client/main.ts’sfirstTreeValue()reads.value()and falls back to awaiting the first"updated". Found with a live headless script, not by reading source.- Devframe’s node context does not expose Vite’s watcher (its own doc comment says it is narrower than
ViteDevServer). Watch files in a plainconfigureServerplugin and share one closure-scoped object withregisterRpc’ssetup(ctx), not a module-level global, so multipleprose()instances can’t collide. - Headless clients:
devframe/clientreadslocationunconditionally, so a Node script needsglobalThis.location = new URL(...)first. Usetransport: "websocket"; forcing"sse"hung every RPC call in this environment (not root-caused). A real RPC error from a script beats guessing from.d.ts. @vitejs/devtoolswas the wrong host. ItsclientAuthdefaulted on, gated by a terminal-approved handshake and not a plain option onDevTools(), and it brings a dock/terminal/command surface a one-developer tool doesn’t need.@devframes/vite’sdevframeViteBridgetakesauth: falsedirectly. See spec §6.
The parser and scanner
Found by dogfooding on the plugin’s own source; examples/single and examples/base hadn’t hit them. Real code has more variety than two curated examples.
- A
@proseblock inside a callback or object literal is silently dropped.defineDevframe({ setup(ctx) { /** @prose */ } })is at depth 2, not 0. The depth-0 rule (spec §3.1) is intentional but gives no visual hint. Pull the callback out to a named top-level function (registerRpcinsrc/plugin.ts). - A tokenizer that handles strings and comments but not regex literals is one construct from silent corruption.
.replace(/\/g, ““)read the backtick as a template-literal opener, which "closed" at the next backtick in the file and broke{}depth for everything after. No error, comments just stopped being found.scanJsLikenow hasskipRegexLiteral(the last-significant-token heuristic);return /x/` is still misread as division, a documented limitation. - A scanner with no closing delimiter must let the marker define the boundary. YAML/TOML
#comments: collecting every contiguous#line and checking only the first for a marker merged a@proseand its back-to-back@note. A block now runs from a marker line to the next marker line or the first non-#line. Ask “what happens when two blocks touch,” not just “what happens at the end.” Caught by writing that test case directly. - Measure a block’s indentation from its opening line, not its closing one. A multi-line block’s closing line is
" */", with the gutter’s own leading space, so every inserted@notewas one space too deep.ProseChunk.startIndexfixes it. A single-line block can’t reproduce this; only a live round trip againstexamples/baseand a multi-line regression test show it.
The symbol check
- Don’t reimplement scope analysis with regexes.
declaredIdentifiersgrew one construct per bug report: imports (marked), then parameters (htmlinheadingId(html: string), whose nested parens and braces no flat regex can find), with destructuring and class members still pending. The fix was structural: parse withoxc-parser(the engine oxlint, oxfmt and tsdown already use) and walk the AST. Keep parameters (declaredParameters) local-only so they never enter the cross-file table. - Check that an API exists before choosing it.
typescriptis TS 7 here (the native port); its package entry resolves toversion.cjs, with nots.createSourceFile(Cannot read properties of undefined (reading 'Latest')). Revisit if 7.1’s WASM compiler API arrives, since pure JS has no native binary to ship. - A tolerant parser matters when one path serves several languages.
oxc-parseron CSS returns an emptyprogram.bodypluserrors, no throw (checked with a real snippet).codeLangper chunk gates the parse anyway, since a.sveltefile’s<script>and<style>share the comment-scanning path. - Real false positives come from real projects. Package names in prose (
`marked`), JS builtins (Set), file names (README.md), reserved words (return,import) andimport.meta.*were all flagged. Fixes:package.jsondependency names,BUILTIN_GLOBALS, filename-shaped spans and a longer keyword list skipped; the file’s preamble scope shared by all its chunks.
Build and tooling
- A
200or a clean startup is not “the feature works.” Every early check passed until a browser hit three unrelated bugs (asset base, RPC naming,location). Verify a client/server integration with a browser or a headless client that calls the RPC layer end to end. - No standalone tests meant one-off scripts, written and thrown away, which is how the
new URLbug shipped. Now:vp testfor parser, tree, checks, notes;vp check(oxfmt + type-aware oxlint) onsrc/,client/and root config;check-client-bundle.mjsfor the one bug class no Node test can see. oxlint’s type-aware mode (tsgolint) is the type check for.ts, not a separatetsc --noEmit; it caught a realno-floating-promisesat once. It does not read.svelte, sosvelte-checkcovers those (next section).- Import shiki through
shiki/core, not the main entry. The maincodeToHtmlresolveslangby name at runtime, so Rollup kept all ~200 grammars as lazy chunks (321 files inclient/dist). Explicit@shikijs/langs/*imports pluscreateHighlighterCoregave 14 assets, ~1.5 MB; keep the highlighter lazy and memoized. - SvelteKit’s
vite buildnever runstransformIndexHtml(it warns “not supported”). The HTML strip (dropped in 0.2.0, spec §3.1) was skipped there. Harmless today, but a@proseinapp.htmlon a SvelteKit host would ship to production with no error, only that easy-to-miss warning. Not something this plugin can detect. - A global
code { padding }rule for inline spans also hit shiki’s<pre><code>, adding a stray space before every block’s first token. Copying the text came out clean, since padding is box model, not content. Fix with apre codereset.
The Svelte client
examples/base (src/content/lessons.md) had already worked through most of this.
- Stay on TypeScript 6.x. TS 7 (the native port) breaks
svelte-check, which still expects the 6.x JS API. The repo was on 7.0.2 and is back on 6.0.3. Re-test when svelte-check supports the native port. svelte-checkfinds the Svelte config in thevite.configof the workspace it checks, andvp checkdoesn’t read.svelteat all. The rootvite.config.tsis thevite-plustooling config with no Svelte plugin, so the client build config lives atclient/vite.config.ts(withroot: import.meta.dirname) andcheckrunssvelte-check --workspace client. Symptom otherwise:No Svelte configuration found in vite config.client/tsconfig.jsonneedstypes: ["node", …]because the client imports theTreeNodetype fromsrc/tree.ts, which usesnode:fs. Type-only import: the server module must never be bundled into the browser.- Component
<style>blocks are unlayered and beat@layer, so styling stays in the globalstyle.css. - Force runes (
compilerOptions: { runes: true }) so a component can’t fall back to legacy reactivity. Reactive class fields ($state,$derived) require a.svelte.tsmodule; keep logic that Node tests must reach (nav.ts,stats.ts,fuzzy.ts) in plain.ts. - Derive the current node from the pushed tree instead of fetching it. The tree already carries every node’s prose, code and note, so a lookup by path is local, live updates re-render for free, and there’s no async flash. The old client fetched
nodeper navigation and rebuiltinnerHTMLwholesale, which also wiped a half-typed note whenever a file changed. - Found by dogfooding again: the symbol check flagged browser globals (
sessionStorage) named in the client’s prose.BUILTIN_GLOBALSnow lists the common ones.
The file as the leaf
- A chunk shown alone doesn’t make sense, so the tree stops at files and blocks hang off the file node (
blocks, each with its comment’s bytespan). The page lays the source out around those spans, dropping the comment text and rendering it as prose in its place, which shows the whole file with nothing repeated. Exact offsets beat line ranges here; the.sveltepart-clipping had already shown that per-chunk code slices lose the tags between parts. - Checking file prose adds noise, so scope it. Once the file prose became a checked block, about 20 new warnings appeared. Two were structural: its “code” is only the preamble, so a staleness comparison against imports means nothing (skipped), and its prose describes the whole file, so it resolves against everything the file declares, not just the imports. The rest are the usual symbol-check false positives on external names.
Design review (2026-09-25)
Found by reading the spec against the code rather than by running either; prose/review.md (removed once every item was settled; see git log) had the full list.
- A write path that builds comments from user text must escape the delimiter.
formatNoteput note text inside/** … */verbatim, so a note containing*/closed the comment and the rest became live code, run by HMR. Every round-trip test used friendly text. Detect with property tests over arbitrary note text, not examples. - An address built from position isn’t stable, whatever the spec calls it. The spec promised
#addTodo; the code producedtop-chunk-2, which moves when a block is inserted above. Re-parsing the file fresh on every write didn’t help, because the address itself had moved. Derive anchors from content and guard writes with a content hash (spec §3.2). git blamecan’t tell who wrote a line when the human makes every commit. Author-based ideas (agent notes vs human notes) fail in a one-person-plus-agents workflow. Put anything that needs an author in the text itself.- Don’t assume work gets committed. Sessions run long and uncommitted; blame-based checks see all of it as “newest”. Anything the view needs within a session has to read the working tree (spec §2).
- Section references drift like any other reference. Code prose cited §6.4 for the RPC handlers (§6.3) and §3.3 for rules in §3.1. Check them (plan step 5a) and don’t renumber sections casually; add new ones at the end of their chapter.
Safe writes
- Devframe’s origin check lets through clients that send no
Originheader. It refuses browser pages on non-loopback origins, but it treats a connection with noOriginas a native tool and allows it. So withvite --host, anything on the network could calladd-note. The loopback check on the HTTP server’s own bound address (isLoopbackAddressfromdevframe/utils/origin) is what closes that. Verified with a headless client against a--hostserver; reading the origin module alone suggested the boundary was already covered. - Property tests found a CRLF bug that no example had. The parser kept the
\rof a CRLF line inside comment text, and the#-style scanner’sendIndexsat between\rand\n, so a note inserted in a CRLF YAML/TOML file would have split a line ending. Fixture variants (LF, CRLF, no final newline) cost one line each in the generator. - Property-test the pure edit, not the file write. Going through
addNoteputgit ls-files, a tree build and file I/O in every run, and 100 runs timed out. Splitting outwithNote/withoutNote(source in, source out) gave 500 runs per comment style in about a second, leaving the path guard to ordinary example tests. - A mechanical rename across a test file also renamed the helper it introduced. Replacing
addNote(root,withadd(turnedadd’s own body intoadd(…), which recursed with a tree build at each level. It looked like slow property tests, not a bug. When a run hangs, time one test at a time (-t) before tuning the slow-looking one.
Working style
- Read the installed
.d.tsfiles, not a summarized doc fetch.WebFetchran pages through a smaller model that lost specifics (no concreteaction/event/state examples, and it omitted “mounts inside@vitejs/devtools”, which changed the cost of adopting it). Install into a scratch directory and grepdist/*.d.ts. - Stale dev servers make a correct fix look broken.
pkill -f "vp dev --port …"matched the wrapper, not the childvite-plus-coreholding the port; the oldest server kept answering while a new one started elsewhere. A fix verified by a directbuildTree()call still failed over RPC, which burned time in the parser.ps aux | grep -i vite, kill by PID, and confirm the list is empty before starting one.