diff --git a/.cursor/environment.json b/.cursor/environment.json
new file mode 100644
index 00000000..d68ad3fd
--- /dev/null
+++ b/.cursor/environment.json
@@ -0,0 +1,4 @@
+{
+ "name": "Dripnex",
+ "install": "bash .cursor/install.sh"
+}
diff --git a/.cursor/install.sh b/.cursor/install.sh
new file mode 100755
index 00000000..3352de5f
--- /dev/null
+++ b/.cursor/install.sh
@@ -0,0 +1,118 @@
+#!/usr/bin/env bash
+#
+# Cloud Agent install script for Dripnex.
+#
+# Non-obvious constraints this script exists to satisfy:
+#
+# 1. node:sqlite FTS5 — @dripnex/mcp-server uses Node's built-in `node:sqlite`
+# (DatabaseSync) and asserts FTS5 is compiled in. The node binary the
+# Cursor exec daemon ships (/exec-daemon/node) has FTS5 DISABLED, so a
+# plain `node`/`pnpm test` would fail. We select an nvm-managed Node whose
+# bundled SQLite has FTS5 enabled and make it the default `node` for every
+# shell by symlinking the toolchain into a PATH dir that precedes
+# /exec-daemon.
+#
+# 2. better-sqlite3 (desktop) — apps/desktop's postinstall runs
+# `electron-builder install-app-deps`, which rebuilds better-sqlite3
+# against Electron's ABI. That is required for `pnpm dev` and the
+# Playwright+Electron e2e suite to launch. (This is why `pnpm test`
+# excludes @dripnex/storage-sqlite — see CLAUDE.md.)
+#
+set -euo pipefail
+
+cd "$(dirname "$0")/.."
+REPO_ROOT="$(pwd)"
+echo "==> Dripnex install (repo: $REPO_ROOT)"
+
+# Dependabot-regenerated lockfiles can resolve GitHub git deps over SSH; CI
+# rewrites them to HTTPS so the tarball install works without a deploy key.
+git config --global 'url.https://github.com/.insteadOf' 'git@github.com:' || true
+
+# --- 1. Select an FTS5-capable Node and make it the default ----------------
+has_fts5() {
+ "$1" -e 'const{DatabaseSync}=require("node:sqlite");const d=new DatabaseSync(":memory:");process.exit(d.prepare("SELECT sqlite_compileoption_used(\x27ENABLE_FTS5\x27) v").get().v===1?0:1)' >/dev/null 2>&1
+}
+
+export NVM_DIR="${NVM_DIR:-$HOME/.nvm}"
+NODE_BIN=""
+if [ -s "$NVM_DIR/nvm.sh" ]; then
+ # shellcheck disable=SC1091
+ . "$NVM_DIR/nvm.sh"
+ # Prefer an already-installed Node >= 22 that has FTS5; otherwise install 22.
+ for ver in $(nvm ls --no-colors 2>/dev/null | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+' | sort -Vr | uniq); do
+ cand="$NVM_DIR/versions/node/$ver/bin/node"
+ if [ -x "$cand" ] && has_fts5 "$cand"; then NODE_BIN="$cand"; break; fi
+ done
+ if [ -z "$NODE_BIN" ]; then
+ echo "==> No FTS5-capable Node found; installing Node 22 via nvm"
+ nvm install 22 >/dev/null
+ cand="$(nvm which 22 2>/dev/null || true)"
+ if [ -n "$cand" ] && has_fts5 "$cand"; then NODE_BIN="$cand"; fi
+ fi
+fi
+
+if [ -z "$NODE_BIN" ]; then
+ echo "ERROR: could not locate a Node build with node:sqlite FTS5 enabled." >&2
+ exit 1
+fi
+NODE_BIN_DIR="$(dirname "$NODE_BIN")"
+echo "==> Using Node $("$NODE_BIN" -v) (FTS5 enabled) from $NODE_BIN_DIR"
+
+# Make the FTS5 node the default `node`/`npx` for every shell.
+#
+# The Cursor daemon's runtime PATH places /exec-daemon (which ships an
+# FTS5-less node) AHEAD of the nvm bin, so a plain `node` would be wrong at
+# agent runtime. Crucially, the install-time PATH can differ from the agent
+# runtime PATH (install may run with nvm already ahead), so we cannot skip
+# based on the current ordering — we ALWAYS place shims. We symlink the
+# toolchain into every writable PATH dir that precedes /exec-daemon, plus
+# /usr/local/cargo/bin (world-writable in the base image and consistently
+# ahead of /exec-daemon at runtime). Symlinks live on disk, so they survive
+# into environment builds/snapshots and win over /exec-daemon's node.
+declare -a SHIM_CANDIDATES=()
+IFS=':' read -r -a _path_entries <<< "$PATH"
+for d in "${_path_entries[@]}"; do
+ case "$d" in */exec-daemon*) break ;; esac
+ SHIM_CANDIDATES+=("$d")
+done
+SHIM_CANDIDATES+=("/usr/local/cargo/bin")
+
+_shimmed=""
+for d in "${SHIM_CANDIDATES[@]}"; do
+ [ "$d" = "$NODE_BIN_DIR" ] && continue # never clobber the node bin itself
+ case " $_shimmed " in *" $d "*) continue ;; esac # dedupe
+ mkdir -p "$d" 2>/dev/null || sudo mkdir -p "$d" 2>/dev/null || true
+ [ -d "$d" ] && [ -w "$d" ] || continue
+ for b in node npm npx corepack pnpm pnpx yarn yarnpkg; do
+ src="$NODE_BIN_DIR/$b"; dest="$d/$b"
+ [ -e "$src" ] && [ "$src" != "$dest" ] && ln -sfn "$src" "$dest" 2>/dev/null || true
+ done
+ echo "==> Linked node toolchain into $d"
+ _shimmed="$_shimmed $d"
+done
+
+# Ensure the rest of THIS script uses the FTS5 node too.
+export PATH="$NODE_BIN_DIR:$PATH"
+hash -r || true
+
+# --- 2. Install workspace dependencies (runs postinstall scripts) ----------
+# postinstall: lefthook install (git hooks) + electron-builder install-app-deps
+# (rebuilds better-sqlite3 for Electron) + downloads the Electron binary.
+echo "==> pnpm install --frozen-lockfile"
+pnpm install --frozen-lockfile
+
+# Belt-and-suspenders: ensure the Electron binary is materialized for e2e.
+if [ -f apps/desktop/node_modules/electron/install.js ]; then
+ echo "==> Materializing Electron binary"
+ ( cd apps/desktop && node node_modules/electron/install.js )
+fi
+
+# --- 3. Build workspace packages (source-derived; needed by typecheck/e2e) -
+echo "==> pnpm build"
+pnpm build
+
+# --- 4. Verify the critical invariant --------------------------------------
+echo "==> Verifying node:sqlite FTS5"
+node -e 'const{DatabaseSync}=require("node:sqlite");const v=new DatabaseSync(":memory:").prepare("SELECT sqlite_compileoption_used(\x27ENABLE_FTS5\x27) v").get().v;if(v!==1){console.error("FTS5 missing");process.exit(1)}console.log("node",process.version,"FTS5 OK")'
+
+echo "==> Install complete."
diff --git a/.github/workflows/automerge.yml b/.github/workflows/automerge.yml
index e5b20356..135d5a6a 100644
--- a/.github/workflows/automerge.yml
+++ b/.github/workflows/automerge.yml
@@ -59,11 +59,21 @@ jobs:
disable-automerge:
runs-on: ubuntu-latest
permissions:
- # Only the auto-merge state changes here; no contents write needed.
+ # `pull-requests: write` alone returned FORBIDDEN ("Resource not
+ # accessible by integration") on PR #571. The narrower scope came from
+ # a least-privilege review; the mutation appears to want contents too.
+ # If FORBIDDEN comes back with both scopes granted, the cause is a
+ # repository or org restriction on GITHUB_TOKEN, not this file.
+ contents: write
pull-requests: write
+ # Only where a queued merge could still land somewhere unprotected: a
+ # draft, or a base that is neither develop nor main. A PR retargeted onto
+ # main is a release promotion, and main is protected — there is nothing
+ # dangerous to undo, so firing there was pure noise.
if: >-
github.event.pull_request.draft == true ||
- github.event.pull_request.base.ref != 'develop'
+ (github.event.pull_request.base.ref != 'develop' &&
+ github.event.pull_request.base.ref != 'main')
steps:
- name: Disable auto-merge for drafts and non-develop bases
env:
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 36bf914b..20ee3bc6 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -218,23 +218,53 @@ jobs:
if: needs.publish.result == 'success'
runs-on: ubuntu-latest
steps:
- - name: Create sync PR
+ - uses: actions/checkout@v5
+ with:
+ fetch-depth: 0
+
+ # A `develop <- main` PR cannot merge: develop requires the head branch
+ # to be up to date, and main is behind develop the moment anything lands
+ # after the release. Push a branch descended from develop with main
+ # merged into it instead — that satisfies the rule and, unlike a squash,
+ # actually makes main an ancestor of develop so the next promotion is
+ # not stuck at BEHIND.
+ - name: Open the back-merge PR
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG_NAME: ${{ needs.meta.outputs.tag }}
run: |
set -euo pipefail
- if ! pr_count="$(gh pr list --base develop --head main --repo "$GITHUB_REPOSITORY" --json number --jq 'length')"; then
- echo "::error::Unable to check for an existing sync PR."
- exit 1
+ git fetch origin main develop
+
+ if git merge-base --is-ancestor origin/main origin/develop; then
+ echo "main is already an ancestor of develop; nothing to sync."
+ exit 0
fi
- if [ "$pr_count" -eq 0 ]; then
- gh pr create \
- --base develop \
- --head main \
- --title "chore: sync release $TAG_NAME back to develop" \
- --body "Auto sync of release commit and changelog from $TAG_NAME." \
- --repo "$GITHUB_REPOSITORY"
- else
- echo "Sync PR already open; nothing to create."
+
+ # The chore/backmerge- prefix keeps this out of the squash
+ # auto-merge in automerge.yml. Squashing it would defeat the point.
+ branch="chore/backmerge-main-${TAG_NAME}"
+
+ if git ls-remote --exit-code --heads origin "$branch" >/dev/null 2>&1; then
+ echo "$branch already exists; leaving the open PR alone."
+ exit 0
fi
+
+ git config user.name "github-actions[bot]"
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
+ git checkout -b "$branch" origin/develop
+ git merge origin/main -m "chore(release): merge main into develop"
+ git push origin "$branch"
+
+ gh pr create \
+ --base develop \
+ --head "$branch" \
+ --title "chore(release): merge main into develop" \
+ --body "Back-merge of $TAG_NAME so main stays an ancestor of develop and the next promotion PR is not stuck at BEHIND.
+
+ Merge this with a **merge commit**. A squash replays the content as a new commit and does not establish ancestry." \
+ --repo "$GITHUB_REPOSITORY"
+
+ # Explicitly a merge commit. automerge.yml skips chore/backmerge-*,
+ # so nothing else will arm this with --squash.
+ gh pr merge "$branch" --auto --merge --repo "$GITHUB_REPOSITORY"
diff --git a/apps/desktop/src/renderer/themes/__tests__/officialThemes.test.ts b/apps/desktop/src/renderer/themes/__tests__/officialThemes.test.ts
new file mode 100644
index 00000000..e4df44a1
--- /dev/null
+++ b/apps/desktop/src/renderer/themes/__tests__/officialThemes.test.ts
@@ -0,0 +1,32 @@
+import { describe, expect, it } from 'vitest';
+import { validateThemeTokens } from '@dripnex/plugin-api';
+import { OFFICIAL_THEMES } from '../officialThemes';
+
+describe('OFFICIAL_THEMES', () => {
+ it('registers unique ids with only valid tokens', () => {
+ const ids = OFFICIAL_THEMES.map(theme => theme.id);
+ expect(new Set(ids).size).toBe(ids.length);
+
+ for (const theme of OFFICIAL_THEMES) {
+ expect(theme.name.length).toBeGreaterThan(0);
+ expect(['dark', 'light']).toContain(theme.colorScheme);
+ const valid = validateThemeTokens(theme.tokens, theme.id);
+ for (const [token, value] of Object.entries(theme.tokens)) {
+ expect(valid[token]).toBe(value);
+ }
+ expect(valid['--accent-primary']).toBe(theme.tokens['--accent']);
+ }
+ });
+
+ it('includes Harbor Dusk as a dark official palette', () => {
+ const harbor = OFFICIAL_THEMES.find(theme => theme.id === 'dripnex-harbor-dusk');
+ expect(harbor).toMatchObject({
+ name: 'Harbor Dusk',
+ colorScheme: 'dark',
+ pluginId: 'dripnex',
+ });
+ expect(harbor?.tokens['--bg-base']).toBe('#141c26');
+ expect(harbor?.tokens['--accent']).toBe('#5e9a92');
+ expect(harbor?.tokens['--cm-link']).toBe('#d4a05a');
+ });
+});
diff --git a/apps/desktop/src/renderer/themes/officialThemes.ts b/apps/desktop/src/renderer/themes/officialThemes.ts
index eaed6475..5a36cbc7 100644
--- a/apps/desktop/src/renderer/themes/officialThemes.ts
+++ b/apps/desktop/src/renderer/themes/officialThemes.ts
@@ -464,6 +464,42 @@ export const OFFICIAL_THEMES: ThemeDefinition[] = [
'--status-dropped': '#c44b4b',
},
},
+ {
+ id: 'dripnex-harbor-dusk',
+ name: 'Harbor Dusk',
+ description: 'Coastal evening. Mist text, muted teal, amber lanterns.',
+ author: 'Dripnex',
+ colorScheme: 'dark',
+ pluginId: 'dripnex',
+ tokens: {
+ '--bg-base': '#141c26',
+ '--bg-surface': '#10161e',
+ '--bg-elevated': '#1c2633',
+ '--bg-inset': '#0c1218',
+ '--bg-hover': 'rgba(205, 214, 222, 0.06)',
+ '--bg-active': 'rgba(205, 214, 222, 0.1)',
+ '--text-primary': '#cdd6de',
+ '--text-secondary': 'rgba(205, 214, 222, 0.74)',
+ '--text-muted': 'rgba(205, 214, 222, 0.5)',
+ '--text-faint': 'rgba(205, 214, 222, 0.32)',
+ '--border': 'rgba(205, 214, 222, 0.1)',
+ '--border-subtle': 'rgba(205, 214, 222, 0.06)',
+ '--border-strong': 'rgba(205, 214, 222, 0.16)',
+ '--accent': '#5e9a92',
+ '--accent-hover': '#74aea6',
+ '--accent-muted': 'rgba(94, 154, 146, 0.2)',
+ '--accent-subtle': 'rgba(94, 154, 146, 0.1)',
+ '--glass-bg': 'rgba(20, 28, 38, 0.9)',
+ '--glass-border': 'rgba(205, 214, 222, 0.08)',
+ '--glass-bg-menu': 'rgba(28, 38, 51, 0.95)',
+ '--glass-border-menu': 'rgba(205, 214, 222, 0.08)',
+ '--status-active': '#5e9a92',
+ '--status-on-hold': '#d4a05a',
+ '--status-completed': '#7aad8a',
+ '--status-dropped': '#c46b6b',
+ '--cm-link': '#d4a05a',
+ },
+ },
];
export function registerOfficialThemes(): void {
diff --git a/docs/dripnex-dependency-map.md b/docs/dripnex-dependency-map.md
new file mode 100644
index 00000000..2d0fbf1d
--- /dev/null
+++ b/docs/dripnex-dependency-map.md
@@ -0,0 +1,153 @@
+# Dripnex — Dependency Map & Critical Path
+
+> **Status:** Living planning doc derived from the GitHub issue tree (E1–E18, 116 issues).
+> The issues remain the source of truth; this is the cross-cutting view they can't show:
+> the dependency DAG, the critical path, spike gates, and a 2-person parallelization plan.
+
+## TL;DR
+
+- **Three keystones:** **E1** (foundation — blocks everything), **E9** (knowledge substrate —
+ blocks all of Q3), **E10** (the MCP write-loop — _the_ value of the pivot).
+- **The differentiator ships in Q3.** Q1–Q2 are prelude (stabilize + platform + rename).
+ There is no demonstrable "dripnex moment" until E9→E10→E12 land mid-year.
+- **Longest dependency chain = the backend split** (E5→E8→E14→E16). It's also the highest
+ scope/capacity risk — see D3 in the reconciliation doc.
+- **Two spike gates** decide Q3 subsystems: graph WebGL (#318→E11) and sqlite-vec (#319→E13).
+
+---
+
+## 1. Epic dependency DAG
+
+```mermaid
+graph TD
+ subgraph Q1["Q1 — Stabilize + Platform foundations"]
+ E1["E1 v0.15.4 patch
(stabilize + e2e signal)"]
+ E2["E2 Spike week
graph · sqlite-vec · Tauri"]
+ E3["E3 Plugin API v1"]
+ E4["E4 First satellites"]
+ E5["E5 Backend contracts"]
+ end
+ subgraph Q2["Q2 — Extraction + Rename + Split 1"]
+ E6["E6 Built-in extraction"]
+ E7["E7 Rename train
(highest risk)"]
+ E8["E8 Service split 1
auth + sync"]
+ E9["E9 Knowledge layer
frontmatter + link index"]
+ end
+ subgraph Q3["Q3 — AI notetaker core"]
+ E10["E10 MCP write surface"]
+ E11["E11 Graph view"]
+ E12["E12 Palette + Connect w/ Claude"]
+ E13["E13 Semantic layer"]
+ E14["E14 Service split 2"]
+ end
+ subgraph Q4["Q4 — Ecosystem + polish"]
+ E15["E15 UX hygiene"]
+ E16["E16 Plugin ecosystem"]
+ E17["E17 QA program"]
+ E18["E18 Positioning & launch"]
+ end
+
+ E1 --> E3 & E5 & E9 & E7
+ E3 --> E4 --> E6
+ E3 --> E6
+ E5 --> E8 --> E14
+ E2 -. graph spike .-> E11
+ E2 -. sqlite-vec spike .-> E13
+ E9 --> E10 & E11 & E13
+ E10 --> E12
+ E7 -. deep-link .-> E12
+ E7 --> E18
+ E11 --> E18
+ E12 --> E18
+ E3 --> E16
+ E6 --> E16
+ E14 -- plugin-registry --> E16
+ E8 & E14 --> E17
+ E16 --> E18
+```
+
+> **Note:** E1 is drawn feeding the four chain-heads it actually unblocks; in practice its
+> e2e-signal task (#309) gates _everything_ — nothing should merge on a red e2e suite.
+
+---
+
+## 2. The dependency chains, ranked
+
+| Chain | Depth | What it delivers | Risk |
+| --------------------------------- | ----- | -------------------------------------------------- | -------------------------------------------------------- |
+| **Backend:** E1→E5→E8→E14→E16→E18 | 6 | Microservices + marketplace + launch | ⚠️ Longest; biggest capacity cost (D3) |
+| **Value loop:** E1→E9→E10→E12→E18 | 5 | The agent write-loop — the pivot's reason to exist | 🎯 Demo-critical |
+| **Platform:** E1→E3→E4→E6→E16→E18 | 6 | Plugin API → satellites → marketplace | Steady, well-sequenced |
+| **Graph:** E2⇢E11, E9→E11→E18 | — | The differentiator UI | Gated on spike (#318) |
+| **Semantic:** E2⇢E13, E9→E13 | — | Embeddings / related notes / ContextBuilder v2 | Gated on spike (#319); degrades to link-only if it slips |
+| **Rename:** E1→E7→E18 | — | Identity change | 🔴 Highest single-epic risk; ships alone |
+
+**Read this as:** the product's _value_ (value loop) is only 5 deep and mostly TS app work.
+The _operational cost_ (backend chain) is 6 deep and the part most worth trimming for a
+2-person team.
+
+---
+
+## 3. Spike gates (decide before building)
+
+| Spike | Gates | Pass bar | If it fails |
+| ---------------------------------- | ---------------- | ----------------------------------------------------------- | --------------------------------------------------------------- |
+| #318 graph WebGL | E11 | 5k nodes / 20k edges ≥ 30 fps in Electron | E11 ships with degradation/ego-graph only, or library swap |
+| #319 sqlite-vec | E13 | semantic beats FTS5 on a golden-query eval over the real DB | E13 ships link-only; ContextBuilder v2 works without embeddings |
+| #320 Tauri | (stack decision) | paper PoC only — door stays closed regardless | Record decision (#321), kill the option |
+| #380 Connect w/ Claude (Agent SDK) | E12 | official-path auth ergonomics acceptable | E12 falls back to demoted API-key path |
+
+**Run E2's spikes in Q1, in parallel with E1.** Their verdicts shape two whole Q3 epics, so
+late spikes = late re-planning.
+
+---
+
+## 4. Suggested re-sequencing
+
+1. **Pull frontmatter schema (#363) into late Q1.** E9 is the keystone for all of Q3 but
+ sits in Q2. The _schema_ (not the full link index) is small and pure-core; landing it
+ early de-risks E10/E11/E13 and lets the MCP tool contract stabilize sooner. Fold in D1
+ (the `type` enum) while doing it.
+2. **Gate E14 by trigger, not by calendar** (reconciliation D3). Keep `billing-webhooks`;
+ defer `ai-gateway` (desktop BYO doesn't use it) and `plugin-registry` (only when E16 is next).
+3. **Protect the rename week (E7).** Its body already says "nothing else ships in its week" —
+ hold the line; the upgrade-path e2e (#354) is the only gate.
+4. **Treat E17 (QA) as continuous, not Q4.** Its own body says charter items burn down
+ ~3/week across all quarters. Q4 is just where completion is _tracked_, not where QA starts.
+
+---
+
+## 5. Parallelization for 2 people
+
+The `wf:` labels are already a clean work-breakdown into 9 tracks. Mapped to two owners
+(adjust to reality — Alicio is referenced as the graph owner in E11):
+
+| Owner | Primary tracks | Rationale |
+| ------------------------- | --------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
+| **Founder** | `wf:stabilization`, `wf:knowledge-layer`, `wf:mcp-agents`, `wf:backend-services`, `wf:rename-brand` | The keystones + risky/identity work + backend; the spine of the value loop |
+| **Collaborator (Alicio)** | `wf:graph`, `wf:palette-ux`, plus `wf:plugin-platform` extractions | Graph is explicitly theirs; palette + satellite extractions are isolatable, plugin-API-mediated units |
+| **Shared / async** | `wf:qa-infra` | Continuous; both contribute, neither owns full-time |
+
+**Parallelism rules that fall out of the DAG:**
+
+- E2 spikes ∥ E1 (independent — start day 1).
+- Once E3's RFC (#322) lands, E4 satellites ∥ E5 backend contracts (different tracks, no shared files).
+- E9 (founder) ∥ E6 extraction (collaborator) in Q2 — but **E7 rename is a stop-the-world week** for both.
+- In Q3, the value loop (E9→E10→E12, founder) runs ∥ graph (E11, collaborator) ∥ semantic
+ (E13, gated). E14 backend is the deferrable one.
+
+**Capacity reality check:** ~30 / 28 / 24 / 16 child tasks per quarter ≈ 2–2.5 tasks/week
+sustained, with several large items (table WYSIWYG #342, WebGL graph #375, 5-Worker split).
+This is aggressive but the sequencing is sound. The deferrals in §4.2 are the main lever if
+velocity lags — cut backend surface before cutting the value loop.
+
+---
+
+## 6. What "done" looks like per quarter
+
+| Q | Demonstrable outcome |
+| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Q1 | Green e2e gate, signed v0.15.4 shipped, plugin-API RFC + npm publish, spike verdicts recorded |
+| Q2 | App is "dripnex" (rename done, zero data loss), built-ins are real satellites, frontmatter+backlinks live |
+| Q3 | **The pivot is real:** Claude Code/Desktop writes validated notes via MCP, graph renders, palette + Connect-with-Claude work, semantic search beats FTS |
+| Q4 | Marketplace browses real registry, QA is a credible gate, dripnex.app launched around the MCP write-loop demo |
diff --git a/docs/dripnex-scope-reconciliation.md b/docs/dripnex-scope-reconciliation.md
new file mode 100644
index 00000000..84332566
--- /dev/null
+++ b/docs/dripnex-scope-reconciliation.md
@@ -0,0 +1,137 @@
+# Dripnex — Scope Reconciliation (Vision ↔ Issues ↔ Code)
+
+> **Status:** Living decision doc. Last reconciled against the open GitHub issue tree
+> (E1–E18, 116 issues) and the working code at `v0.15.2`.
+>
+> **Source-of-truth hierarchy:** running code > GitHub issues (the real roadmap) >
+> this doc > the "Agentic Software Memory System" vision doc > `plan.md` / `docs/ROADMAP.md`
+> (both **stale, do not trust**).
+
+## Why this doc exists
+
+Four documents describe Dripnex and they disagree:
+
+| Doc | What it is | Trust |
+| ------------------------------------------------- | ----------------------------------------------- | --------------------------------- |
+| **GitHub issues E1–E18** | The real, sequenced, dependency-aware roadmap | ✅ Source of truth |
+| **Vision doc** ("Agentic Software Memory System") | North-star narrative / concept | ⚠️ Concept only, not a stack spec |
+| `plan.md` (root) | v1.0 "markdown-first, AI deferred" architecture | ❌ Stale — superseded by pivot |
+| `docs/ROADMAP.md` | v0.5.0 "close the Inkdrop gap" plan | ❌ Stale — pre-pivot |
+
+This doc resolves the divergences so nobody (human or agent) builds against the wrong map.
+
+---
+
+## 1. Divergence table
+
+Legend: **DECIDED** = issues already chose, build accordingly · **ALIGNED** = vision and
+issues agree · **OPEN** = needs founder ratification (see §2).
+
+| # | Topic | Vision doc says | Issues + code reality | Status |
+| --- | ----------------- | --------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | ---------------------------- |
+| 1 | Desktop runtime | **Tauri v2** | Electron, locked. E2 (#320) is a _half-day paper spike_ only — "Decision already locked: Electron base, no rewrite" | **DECIDED → Electron** |
+| 2 | Core engine | **Rust** "the heart of the product" | TypeScript: `packages/core` + `ai-core` `ContextBuilder` + `storage` link index. No Rust anywhere (no `Cargo.toml`) | **DECIDED → TS** |
+| 3 | MCP server | **Rust MCP server** | TypeScript `@dripnex/mcp-server`, extracted as public satellite (E4 #333) | **DECIDED → TS** |
+| 4 | Primary unit | **Artifact-first** (not notes) | Notes + **typed frontmatter** carry the artifact logic (E9 #363). "Artifact" = note with a `type` + structured frontmatter | **OPEN** (see D1) |
+| 5 | Artifact taxonomy | Plan / Decision / RFC / Investigation / Migration / Audit / Incident / Task | E9 frontmatter v1 ships generic `project / status / relations / dates / custom`; **no `type` enum yet** | **OPEN — gap** (see D1) |
+| 6 | Semantic storage | SQLite + FTS5 + sqlite-vec | Exactly that — E13 (#384) embeddings table, gated on E2 spike (#319) | **ALIGNED** |
+| 7 | Graph | Relations in SQLite, **no Neo4j** | E9 link index (#364) + E11 WebGL graph (#375). Same call | **ALIGNED** |
+| 8 | Sync | **CRDTs (Yjs / Automerge)** | Existing AES-256-GCM E2E sync; E8 `sync-service` is **push/pull/conflict**, not CRDT | **OPEN** (see D2) |
+| 9 | Model-agnostic | Claude / GPT / Gemini / Ollama / vLLM | `ai-core` has anthropic / openai / ollama via `fetch`; E14 `ai-gateway` adds quotas/keys. Gemini + vLLM not built | **ALIGNED** (subset shipped) |
+| 10 | Local-first | Default, offline | Same — offline-first is a non-negotiable | **ALIGNED** |
+| 11 | Backend | (silent) | E5/E8/E14: decompose Hono+Turso monolith into **5 Cloudflare Workers** | **OPEN — capacity** (see D3) |
+| 12 | "Context Engine" | Rust, builds optimal agent context | `ContextBuilder v2` (E13 #387) — TS, graph traversal + semantic fill under token budget | **ALIGNED** (TS, not Rust) |
+
+**Bottom line:** the vision doc's _stack_ (Rust + Tauri + Rust-MCP) was rejected. Its
+_ideas_ (artifact-centric memory, local-first, model-agnostic, context-before-generation,
+SQLite graph, semantic retrieval) are all alive and map cleanly onto the TS/Electron
+implementation. Treat the vision doc as **product narrative**, never as a build spec.
+
+---
+
+## 2. Open decisions (need a founder call)
+
+### D1 — Encode the artifact-type taxonomy in frontmatter v1? **(recommend: YES, lightweight)**
+
+The vision's whole thesis is "artifact-first." Today E9 (#363) ships a generic frontmatter
+schema with no `type`. If artifact-first is real, add a `type` enum now — it's cheap at
+schema-design time and expensive to retrofit once notes exist in the wild.
+
+Recommendation: add an **open** `type` field to frontmatter v1:
+
+```yaml
+---
+type: decision # plan | decision | rfc | investigation | migration | audit | incident | task | note
+status: accepted
+project: dripnex-core
+relations:
+ supersedes: [adr-001]
+---
+```
+
+Keep it open-vocabulary (string, with a known set) so the MCP tools (E10) and ContextBuilder
+(E13) can filter by artifact type without a migration later. This makes #363 the single
+schema doc that becomes the MCP tool contract in Q3 — exactly as E9's body promises.
+
+### D2 — CRDT sync (Yjs/Automerge) vs keep push/pull? **(recommend: DEFER CRDT)**
+
+CRDTs only pay off for real-time multi-writer collaboration. Dripnex is single-user
+local-first today, and `plan.md`'s own non-goals listed collaboration as "Never (v1)."
+The shipped AES E2E push/pull/conflict model (E8 #359) is sufficient and far simpler.
+
+Recommendation: **defer CRDT** to a future "teams" epic; do not let the vision doc pull
+Yjs/Automerge into the Q2 sync-service split. Revisit only if real-time collab becomes a
+funded goal.
+
+### D3 — Full 5-Worker split vs slim monolith? **(recommend: PHASE IT — auth+sync only until traction)**
+
+This is the single largest scope/capacity risk. E5+E8+E14 decompose the backend into
+auth / sync / ai-gateway / plugin-registry / billing-webhooks. The issues themselves admit
+"contract-versioning overhead on a 2-person team." Five independently-deployed Workers,
+five staging envs, five smoke suites, shared `@dripnex/contracts` versioning — that is a lot
+of operational surface for two people pre-revenue.
+
+Recommendation:
+
+- **Do** E8 (auth + sync extraction) — gives a collaborator one ownable unit and isolates the
+ two cleanest boundaries.
+- **Gate** E14 (ai-gateway / plugin-registry / billing split) on actual need:
+ - `plugin-registry` only when the marketplace (E16) is genuinely next.
+ - `billing-webhooks` isolation is cheap and worth it (smallest blast radius for Stripe).
+ - `ai-gateway` only matters for a **web/team** future — the desktop BYO-key path explicitly
+ does _not_ route through it (per E14's own body). So it can wait.
+- Keep a **slim monolith** for everything not yet extracted; let traffic evidence decide the
+ monolith-retirement audit (#390), not architectural purity.
+
+### D4 — Tauri: close the door? **(recommend: keep #320 as a paper spike, then formally kill it)**
+
+E2 already treats Tauri as a non-starter ("Electron base, no rewrite"). Run #320 as the
+half-day paper PoC, write the decision record (#321), and **close the option explicitly** so
+it stops resurfacing. The vision doc should be updated to say "Electron" or annotated as
+superseded.
+
+---
+
+## 3. Concept → implementation map (for the vision doc readers)
+
+| Vision concept | Where it actually lives |
+| --------------- | ---------------------------------------------------------------------------- |
+| Artifact | Note + typed frontmatter (`packages/core`, E9) |
+| Core Engine | `packages/core` + `ai-core/ContextBuilder` + `storage` link/embeddings index |
+| Context Engine | `ContextBuilder v2` (#387) — graph traversal + semantic fill, token-budgeted |
+| MCP Layer | `@dripnex/mcp-server` (TS), E10 write/query/graph tools |
+| Embedding Layer | sqlite-vec + incremental indexing (E13 #384), offline-first source per spike |
+| Graph Layer | `links` table in SQLite (E9 #364) + `@dripnex/plugin-graph` WebGL (E11) |
+| Sync Layer | `sync-service` Worker push/pull/conflict (E8) — **not** CRDT (see D2) |
+| Model-agnostic | `ai-core` providers + `ai-gateway` (E14) |
+
+---
+
+## 4. Action items out of this reconciliation
+
+- [ ] Ratify D1–D4 (founder).
+- [ ] If D1 = yes: amend #363 to include the `type` enum before building the schema.
+- [ ] Annotate the vision doc header: "north-star concept; stack is Electron/TS, see scope-reconciliation."
+- [ ] Mark `plan.md` and `docs/ROADMAP.md` as superseded (move to `docs/archived/`).
+- [ ] If D3 = phase: re-label E14 children — keep `billing-webhooks`, gate `ai-gateway` and
+ `plugin-registry` behind explicit triggers.
diff --git a/docs/releases/v0.18.0.md b/docs/releases/v0.18.0.md
new file mode 100644
index 00000000..3f0d0286
--- /dev/null
+++ b/docs/releases/v0.18.0.md
@@ -0,0 +1,17 @@
+---
+version: 0.18.0
+date: 2026-08-24
+title: Harbor Dusk
+status: draft
+---
+
+A coastal evening palette for long writing sessions. Harbor Dusk joins
+the official themes already in the desktop — same Settings → Themes
+list, same token contract as Parchment, Wave, Night, and Fog. No new
+fonts, no plugin pack.
+
+## Themes
+
+- **Harbor Dusk.** Official dark palette. Deep slate water, mist text,
+ muted teal on chrome, amber lanterns on editor links. Evening sibling
+ to Fog (coastal gray morning). Pick it on Settings → Themes.
diff --git a/docs/themes/LOG.md b/docs/themes/LOG.md
index fc9eb8ba..923def31 100644
--- a/docs/themes/LOG.md
+++ b/docs/themes/LOG.md
@@ -8,3 +8,4 @@ Format: `YYYY-MM-DD | id | name | topic | colorScheme`
2026-08-22 | dripnex-matcha | Matcha | green-tea paper, calm reading | light
2026-08-22 | dripnex-phosphor | Phosphor | amber CRT / terminal glow | dark
2026-08-22 | dripnex-fog | Fog | coastal gray morning, muted blue accent | light
+2026-08-24 | dripnex-harbor-dusk | Harbor Dusk | coastal evening, mist text, muted teal, amber lanterns | dark
diff --git a/packages/api/tests/runMigrations.test.ts b/packages/api/tests/runMigrations.test.ts
index 2fb7efa4..45c167a2 100644
--- a/packages/api/tests/runMigrations.test.ts
+++ b/packages/api/tests/runMigrations.test.ts
@@ -30,36 +30,50 @@ describe('applyMigrations', () => {
}
});
- it('applies the catalog to an empty database, then no-ops', async () => {
- const path = `/tmp/dripnex-migrate-${randomUUID()}.db`;
- paths.push(path);
- const client = createClient({ url: `file:${path}` });
+ // These two drive a real libsql file database through the entire migration
+ // catalog — the second one twice. That is legitimately slower than vitest's
+ // 5s default (3.0s and 7.4s on CI), so they carry their own budget rather
+ // than the suite carrying a global bump that would mask slow unit tests.
+ const MIGRATION_TIMEOUT_MS = 30_000;
- const first = await applyMigrations(client);
- expect(first.skipped).toEqual([]);
- expect(first.applied.length + first.recordedExisting.length).toBe(MIGRATIONS.length);
+ it(
+ 'applies the catalog to an empty database, then no-ops',
+ async () => {
+ const path = `/tmp/dripnex-migrate-${randomUUID()}.db`;
+ paths.push(path);
+ const client = createClient({ url: `file:${path}` });
- const tables = await client.execute(
- "SELECT name FROM sqlite_master WHERE type='table' AND name='plugin_versions'"
- );
- expect(tables.rows).toHaveLength(1);
+ const first = await applyMigrations(client);
+ expect(first.skipped).toEqual([]);
+ expect(first.applied.length + first.recordedExisting.length).toBe(MIGRATIONS.length);
- const second = await applyMigrations(client);
- expect(second.applied).toEqual([]);
- expect(second.skipped).toEqual(MIGRATIONS.map(m => m.id));
- });
+ const tables = await client.execute(
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='plugin_versions'"
+ );
+ expect(tables.rows).toHaveLength(1);
- it('records migrations that already exist in the schema', async () => {
- const path = `/tmp/dripnex-migrate-${randomUUID()}.db`;
- paths.push(path);
- const client = createClient({ url: `file:${path}` });
- await client.execute(
- 'CREATE TABLE users (id text PRIMARY KEY, email text, created_at text, updated_at text)'
- );
+ const second = await applyMigrations(client);
+ expect(second.applied).toEqual([]);
+ expect(second.skipped).toEqual(MIGRATIONS.map(m => m.id));
+ },
+ MIGRATION_TIMEOUT_MS
+ );
- const report = await applyMigrations(client);
- expect(report.recordedExisting).toContain('0000_chubby_zzzax');
- const second = await applyMigrations(client);
- expect(second.skipped).toContain('0000_chubby_zzzax');
- });
+ it(
+ 'records migrations that already exist in the schema',
+ async () => {
+ const path = `/tmp/dripnex-migrate-${randomUUID()}.db`;
+ paths.push(path);
+ const client = createClient({ url: `file:${path}` });
+ await client.execute(
+ 'CREATE TABLE users (id text PRIMARY KEY, email text, created_at text, updated_at text)'
+ );
+
+ const report = await applyMigrations(client);
+ expect(report.recordedExisting).toContain('0000_chubby_zzzax');
+ const second = await applyMigrations(client);
+ expect(second.skipped).toContain('0000_chubby_zzzax');
+ },
+ MIGRATION_TIMEOUT_MS
+ );
});