Target: macOS only, v1. This is the durable, trackable gap list between "what's built" and "shippable to a non-technical user."
Scope reminder: Compose is a local-first Markdown writing app with a built-in
AI agent. You open a folder of .md files; you write in a WYSIWYG/source
editor; an agent (bob / Claude Code / Codex) reads and edits your files, every
edit gated through a review-or-snapshot safety layer with undo history. App
metadata lives in app-data SQLite, never inside the vault.
Doc hygiene note: the design docs now in
docs/archive/are stale — they describe a removed Canvas/WASM editor and a pre-streaming agent. The code is the source of truth; this file reconciles to the code as of the audit. The current docs aredocs/spec.md+ thedocs/*-guide.mdfiles.
[x]done ·[ ]open ·[~]partial / needs verification- P0 = blocks shipping to real users · P1 = strongly wanted for a credible v1 · P2 = track, can fast-follow
- CodeMirror 6 rich editor: WYSIWYG + source toggle, headings, bold, italic, strike, inline/block code, lists, tables, task lists, links, images, math, footnotes, YAML frontmatter preserved, autosave + conflict detection. Per-tab state caching (cursor, scroll, undo history).
- AI agent runner: real streaming (
run_harness_stream), multi-harness (bob/claude/codex), cancel mid-run, tool/file-op cards, usage stats, full conversation CRUD. - Edit-review safety gate: clone-and-diff (default) or snapshot mode, per-file accept/reject, stale-edit detection, atomic apply. The differentiator — well built and tested.
- Version history: per-file snapshots, undoable restore, compression + count-based retention (50) landed.
- Recoverable trash: soft-delete only, 30-day startup sweep, DB-tracked.
- Files/workspace: scan/read/write/create/rename/delete, atomic writes, file watcher, path-traversal guard, multi-workspace + tab persistence, binary/image writes.
- Search / index / backlinks: one Rust core, native + WASM.
- Comments anchored to byte-ranges, survive edits, can seed chat.
- Setup / onboarding / settings: key save (keychain), CLI install + login flows, harness picker (model / effort / max-turns).
Without these the app cannot reach a non-technical macOS user cleanly. None of these are product work; they're the ship vehicle.
Apple Developer Program: enrolled (individual account; Team ID
94SW7AUBMX, Developer ID Application certificate). Signing + notarization are wired — see the first item below. Still gated on that: the auto-updater (now unblocked, just not built) and the clean-machine verification of a downloaded.dmg.
- [~] [Apple] Code signing + notarization — DONE + Apple-verified; one clean-machine check left.
A full
scripts/build-release.shrun produced a.appand.dmgboth Accepted by Apple's notary service, stapled, and Gatekeeper-assessed asNotarized Developer ID. Driven entirely by env vars, so no personal identity lives in committed config and dev builds stay unsigned: a release setsAPPLE_SIGNING_IDENTITY(Tauri signs the app, frameworks and main binary with hardened runtime — its default) plusAPPLE_ID/APPLE_PASSWORD/APPLE_TEAM_ID. The script loadssrc-tauri/.env.release(gitignored), runs the build, notarizes the DMG, and verifies signature + Gatekeeper + staple.- Enrolled; Developer ID Application certificate installed (Team
94SW7AUBMX). - Hardened runtime (Tauri default) + bundled-runtime entitlements
(
entitlements/runtime.plist: JIT + unsigned-memory + library-validation exceptions). The bundlednode/uv/uvxare Resources Tauri doesn't sign, sofetch-runtime.shsigns them with the same Developer ID. - Notarization + stapling of both the
.app(Tauri, automatic) and the.dmg(abuild-release.shpost-step — Tauri only signs the DMG wrapper). - Verify the downloaded
.dmgopens clean on a machine that never built it.
- Enrolled; Developer ID Application certificate installed (Team
- [~] Auto-updater — wired; arming left. Plugin, config, capabilities, and
the in-app surface are in place: a silent launch check + a manual "Check for
updates" in About, a download-progress banner, and relaunch. It reads a
static
latest.jsonon GitHub Releases (no backend), verifies the signed artifact, swaps the bundle, and relaunches. Notarization (above) is done, so it's unblocked. Files:tauri-plugin-updater/-process,plugins.updaterin tauri.conf.json (endpoint set; pubkey blank until armed),capabilities/default.json,src/lib/ipc/updater.ts,app/store/updaterStore.ts,features/updater/UpdateBanner.tsx.build-release.shemits the signed updater artifacts whenTAURI_SIGNING_PRIVATE_KEYis set;make-update-manifest.shwriteslatest.json.- Arm once:
pnpm tauri signer generate -w ~/.tauri/compose.key→ paste the PUBLIC key into tauri.conf.jsonplugins.updater.pubkey; put the private key + password insrc-tauri/.env.release(see the example). - Each release: run
build-release.sh(key set) →make-update-manifest.sh→ create GitHub Releasev<version>, uploadCompose.app.tar.gz+latest.json.
- Arm once:
- Real README — replaced the Tauri boilerplate with a proper Compose README (what it is, features, dev setup, layout, docs, license).
- LICENSE file — MIT (
LICENSE),license: "MIT"added topackage.json. - Local crash / error capture.
compose::loggingappends uncaught front-end errors, unhandled rejections, failed agent runs, and back-end panics (a panic hook) to<app-data>/logs/errors.log— bounded (trimmed to its tail past 512 KB). Local-only, never sent anywhere, so no consent needed. Settings → Report a problem → "Open error log" reveals it in Finder to attach. Installed before render inmain.tsx. Unit-tested (append + trim + newline-safety).
- Deleted the Terminal stub. Removed
src/features/terminal/(a static placeholder, never mounted) and the deadsrc-tauri/src/pty/module (an unusedPtySessionDescriptorstruct —BobRunMode::InteractivePty, bob's-icommand builder, is unrelated and stays). A real terminal later = a Rust PTY (portable-pty) → Tauri channel → xterm.js; a separate project. - Generic pane fallback — audited, no change. Only
{ kind: "settings" }panes are ever constructed (workspaceStore), so the "isn't available yet" branch is unreachable; it stays as correct defensive code for a future pane kind. - [~] Prove the agent auth + edit round-trip in the packaged
.app. Streaming path is built but never verified end-to-end with a live key. Drivepnpm tauri buildonce: save a real key → send a prompt → confirm a real edit lands and review/apply works. This is a verification gap, not code. - Open tabs can vanish, replaced by Welcome.md (#14).
applyScanResultprunesopenFilePathsto whatever the latest scan lists, so a partial or racing scan drops open tabs and the empty-tabs fallback opensWelcome.md— reads as lost work. Fix: reconcile tab removal only on a confirmedremovedfs-event; don't let the Welcome fallback overwrite restored tabs. - Chat survives quit / sleep / app-switch. A reply was only persisted on
completion, so a logout/crash mid-stream left a one-sided conversation with
no explanation, and the agent child orphaned (could keep editing files).
Now: a
run_statuscolumn + throttled incremental saves keep the partial reply and mark it interrupted on next load (a Retry, not a dead "thinking…"); and agent children are cancelled on app exit (RunnerState::cancel_allonExitRequested) plus reaped on the next launch via a PID file (harness::orphan_runs). App-switch was already safe (backgroundThrottling: disabled). (A silence-based stall banner was considered and dropped — it can't tell a long tool call / thinking from a hang, and Stop is always available.) NeedsRunControl::pid()in agent-harness (rides the0.4.0-alpha.2bump). Unit-tested; in-app verify pending.
Onboarding was connect-centric (it forced saving a bob key before you could open a folder). Now it detects which agent CLIs are already on the system and offers them.
- Discovery is client-side over the existing
harness_list+harness_readiness(theHarnessPickerprobes the whole catalog in parallel). No new backend command needed. - New onboarding step "Choose your AI" (
SetupScreen.tsx) reuses the detection-drivenHarnessPicker: each harness shows Ready ✓ / Needs sign-in / Not installed / Add a key, with inline install + OAuth login. Capability-driven (harnessCapabilitiesOf), noharnessId === "bob"checks; an inline bob-key field appears only when a credential-required harness is selected. - Bob-auth gating removed from the folder step — a user with Claude/Codex ready is no longer forced to configure bob; the step is non-blocking (finish any AI sign-in later in Settings).
- Settings keeps re-probing on auth/install change (existing
HarnessPickerbehavior); browser preview keeps the bob-only fallback. - Verified in the browser preview (flow renders + navigates, no console errors) + typecheck + 220 vitest tests.
- Desktop check: confirm the discovery list renders in onboarding on the
packaged app (the picker is
isTauriRuntime-gated, so browser shows the fallback; the picker itself is already shipped/used in Settings).
Decision: v1 ships PDF only, generated by macOS WebKit — no Pandoc,
no LaTeX, no bundled engine. The document's current markdown → self-contained
HTML (comrak + print CSS, local images inlined as data URIs) → an offscreen
WKWebView produces the PDF via createPDFWithConfiguration:. HTML/DOCX and an
agent-skill export route are deferred (re-open below if wanted).
- Backend —
workspace_export_pdfcommand:src-tauri/src/export/html.rs(md→HTML, GFM, frontmatter dropped, raw HTML escaped, images inlined; unit-tested) +src-tauri/src/export/pdf.rs(offscreen WKWebView → PDF, main-thread-safe). Path-safety viaresolve_workspace_path. - Frontend — header "Export PDF" action → save dialog → open the result;
pdfExport.ts+exportClient.ts. Capabilities updated (dialog:allow-save,opener:allow-open-path). Passes the live buffer so unsaved edits are included. - Verified:
cargo check(native objc2/WKWebView compiles),cargo test(html renderer),pnpm typecheck,pnpm test. - Drive it in the packaged
.app— the WKWebView PDF path only runs with a live AppKit main thread; confirm a real document exports a correct, multi-page PDF with images/tables. (This is the one unverified step.) - Tune if needed after first run: page size/margins (
@pageinhtml.rsPRINT_CSS), readiness timing (pdf.rspoll/timeout). - Math/LaTeX renders as raw text (#12) —
$…$/$$…$$aren't typeset; the export HTML has no KaTeX. Render math into the export so PDF/HTML match the editor.
- HTML export — header "Export HTML" action →
workspace_export_htmlwrites the same self-contained HTML the PDF path renders (GFM, print CSS, inlined images) directly — no WebKit, works on any platform. The dialog→save→open flow is now shared (documentExport.ts) between PDF and HTML. Unit-tested (render + path-safety); typecheck + 246 vitest. - DOCX — would need Pandoc (sidecar ~150 MB) or a pure-Rust docx writer.
- Agent-skill export — "Export with AI" via a harness's docx/pdf skill; a power path and a fallback if native export ever can't cover a format.
- Markdown links navigate in both the editor (⌘/Ctrl-click) and chat
replies (click). A pure resolver
(
workspaceLink.ts, unit-tested) classifies an href as internal (resolves to a workspace file → opens in a tab viaselectFile) or external (→ browser); unresolved relative paths are inert. Editor wired via anonClickhandler on the editor surface; chat via acomponents.aoverride +MarkdownLinkContext. Sidebar backlinks already navigated — that's unchanged. - Verified: 11 resolver unit tests, typecheck, 231 vitest, browser render (no console errors).
- Verify click-navigation in the packaged app against a real vault with linked notes (the browser preview's virtual workspace has no files to link between).
- Wikilinks
[[Note]]are now clickable in both surfaces. Editor — a ProseMirror decoration (wikilinkExtension.ts) marks[[…]]spans clickable while leaving the text literal, so markdown round-trips byte-for-byte (no custom node/serializer — verified@tiptap/markdowndoesn't escape[). Chat — a remark plugin (remarkWikilink.ts) turns[[…]]into a#wikilink:link (a fragment href, so it survivesrehype-sanitize) thatMarkdownLinkresolves. Resolution (wikilink.ts) mirrors the crate's rule (splitn('|'),#-strip, path-like vs stem/slug match) so the editor/chat agree with the sidebar backlinks. - Verified: 14 new unit tests (resolver + remark transform incl. sanitize
survival + inline-code exclusion), typecheck, 246 vitest, and runtime in
the browser (modules load;
[[Daily Note|today]]→#wikilink:Daily%20Notewith alias text;resolveWikilinkTarget('Plan')→notes/Plan.md). - Desktop check: confirm the editor decoration renders + ⌘-click navigates, and chat wikilinks navigate, against a real vault (the browser preview has no files/editor to exercise interactively).
- Known gap: a broken
[[Note]]in the editor still looks like a link (the decoration doesn't resolve at paint time, only on click) — it's inert on click. Resolving at paint would need the file list in the plugin (reconfigured on change). Minor; the chat side already dims broken ones. - Optional polish: a discoverable affordance for editor links (⌘-click is non-obvious for non-technical users — e.g. a hover hint).
- File → Print + ⌘P open the macOS print panel (#13). A native menu item
(set at construction, so no menu-bar flash) emits
menu://print; the editor renders the active document to the same self-contained HTML as the PDF export and runsNSPrintOperationon an offscreenWKWebView(src-tauri/src/export/print.rs+workspace_print). The panel offers a real printer and "Save as PDF" — not a silent PDF-to-disk.cargo check+tsc+ 547 vitest green. - Drive it in the packaged
.app— confirm ⌘P opens the panel and a page prints / saves as PDF correctly (theNSPrintOperationpath needs a live AppKit main thread, like the PDF export).
- Accessibility + IME pass (spec R10). No evidence of screen-reader / keyboard-only / non-Latin IME testing. Real for "AI for everyone" — at least a smoke pass on VoiceOver + an IME (e.g. Japanese/Pinyin).
- [~] Perf baseline. Correction: the benchmark +
baseline.jsonare already post-Canvas (ops arepositionMapper*/comment*; no Canvas ops), so the gate is meaningful as-is. Re-ranpnpm bench:baselineto refresh the numbers on the current machine. (The benchmark covers the comment + coordinate hot paths, DOM-less.) - Metadata export / import / purge UI (spec R12). Backend pieces exist; no user surface to back up or delete all app data. P1 for a trust story ("your data, your control"), but can be a thin first cut.
- Review session is in-memory only — quit mid-review loses the sandbox (real files stay safe). Persist if it bites.
- Sandbox temp-dir leak on hard crash (COW, ~free) — add a startup sweep of stale review sandboxes.
- Trash: window hardcoded (30d), no "empty trash now" UI, sweep only at
launch — wire to
app_settingswhen a settings surface lands. - Binary files aren't in text history.
- Table-insert toolbar dropdown (keyboard insert works today).
- Privacy/telemetry stance: crash capture (§1) is local-only (a file on disk, never transmitted), so it honors the local-first promise with no opt-in. If a future "send report" ever transmits the log, make that the opt-in step.
- Fixed stale doc:
src-tauri/src/files/trash_sweep.rs's header no longer claims snapshot retention is unimplemented — it now describes the actual count-based retention (SNAPSHOT_RETENTION_LIMIT = 50) and why a trashed file's recovery snapshot (the protected latest revision) outlives the sweep. - [~] Frontend test gaps. Added store-level tests for the file-management
safety paths —
selectFile(the cross-file-link landing: opens, loads, activates; caches; surfaces read errors) andsaveActiveFile(sends the pre-edit mtime as the conflict guard; on aFileConflictErrorkeeps local edits + flags the conflict instead of clobbering). The pure model safety logic (applyFsEventdirty→conflict / clean→reload, etc.) was already well-covered (~40 model tests). Still thin: theVersionHistoryandSetupScreencomponents have no tests — but in this node-env / static-render setup, effect-driven interactive components are awkward to test meaningfully; the load-bearing logic lives in the (tested) store + model. (Rust side well covered — 136 tests.) - Strip dev-only CSP entries (
ws://localhost:142x, dev URLs intauri.conf.json) from the production build. - Dev sample-path constant —
browserPreviewWorkspacePathnow readsimport.meta.env.VITE_SAMPLE_WORKSPACE ?? "/sample-vault"in bothSetupScreen.tsxandDashboardScreen.tsx; no machine-specific path in source. (Earlier: archived the legacyvellum-*/bob4everyonebranding, removed deadvellum-*CSS, deleted the stalesrc-tauri/Cargo.lock.) - [~] Launch latency diagnosed; loading screen reworked. Instrumented the
boot end-to-end (native
run()/setup()marks + JSmarkBoot, both COMPOSE_PERF-gated). The slow first launch (~3s) is native cold-start — loading the release binary off cold disk (dyld) — not our code: the React shell paints in ~250ms,setup()is ~14ms, and a warm relaunch is ~770ms. Two levers landed: (1) a size-optimized release profile (lto = "thin",strip = truein the rootCargo.toml; was a 25 MB unstripped binary) so the cold dyld load + signature validation are faster —workspace-index-wasmpinswasm-opt = false(its cached wasm-opt predates default bulk-memory ops); (2) the brand splash — which only blinked ~130ms before the app and read as a "flash" — is replaced by an empty three-pane skeleton (sidebar | editor | chat with dividers, matching the real.workspacegrid) inindex.html+SplashScreen, so content fills the same structure with no swap. Remaining cold-launch lever: staple the notarization ticket (offline Gatekeeper) inbuild-release.sh. Optional follow-up: split the shell bundle (defer non-critical Carbon) to trim the ~250ms first paint.
pnpm typecheck # tsc clean, both tsconfigs
pnpm test # vitest, all suites green
pnpm test:rust # cargo tests green
pnpm bench:baseline # perf baseline, no regression
bash src-tauri/scripts/build-release.sh # signed + notarized + stapled .app and .dmgThen drive the packaged .app (a real .app proves what tests can't):
real key → agent edit → review/apply lands on disk → version restore →
soft-delete + recover → export HTML/DOCX/PDF → open a cross-file link. Confirm
the real file is untouched mid-run while the clone holds the edit.