All Void 2.0 superpowers specs and implementation plans now live at
docs/superpowers/{specs,plans}/ inside the repo. Previously they were
at /project/docs/superpowers/ which was not under git.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
27 KiB
Void 2.0 — Plan 2: Core REST API + Void UI Shell
Goal: Expose all Plan 1 repos as a REST API and ship the Cradle-themed Void UI shell on top.
Architecture: Thin Express routes in lib/api/routes/ call the existing lib/db/repos/ (no raw SQL in routes). Shared zod-validate + error middleware. Static SPA in public/ consumed by the bearer-protected /api/*. Agent bearer auth composes with owner. FTS-only search; vector search is Plan 3. Capture endpoints + jobs surface are Plan 3+.
Tech stack: Express 5, zod 4 (already in deps), supertest 7, vanilla ES modules in browser, marked.js (CDN) for markdown render. No new server deps beyond marked if we choose to vendor it.
Out of scope (deferred):
- Vector/RRF search (needs embeddings — Plan 3)
- Capture endpoints (
/api/capture/*) and/api/jobs(needs pg-boss — Plan 3) - MCP server (Plan 5)
- Sacred Valley gridstack widgets (Plan 6) — ship a placeholder card
- E2E Playwright tests (Plan 8 sweep)
File structure
lib/api/
index.js # registers routers onto app
errors.js # NotFoundError, ValidationError, asyncWrap, errorMiddleware
validate.js # validate({ body?, params?, query? }) using zod
pagination.js # parsePagination(req) → { limit, offset }
middleware/
agent_auth.js # bearer → agent actor; composes with ownerOnly
routes/
spaces.js
projects.js
tasks.js
pages.js
refs.js
resources.js
source_docs.js
conversations.js
messages.js
agents.js
tags.js
links.js
pending_changes.js
audit.js
search.js
public/
index.html # SPA shell
style.css # blackflame palette + three-column layout
app.js # bootstrap, router, fetch wrapper (auth header)
router.js # hash router
api.js # typed-ish wrappers over fetch
components/
sidebar.js
topbar.js
rightrail.js
markdown_editor.js
views/
space.js
project.js
page.js
reference.js
resource.js
search.js
inbox.js
sacred_valley.js # placeholder
home.js # landing fallback
vendor/
marked.min.js # vendored, no CDN at runtime
tests/api/
helpers.js # createApp + auth headers + reset/migrate
spaces.test.js
projects.test.js
tasks.test.js
pages.test.js
refs.test.js
resources.test.js
source_docs.test.js
conversations.test.js
messages.test.js
agents.test.js
tags.test.js
links.test.js
pending_changes.test.js
audit.test.js
search.test.js
agent_auth.test.js
validate.test.js
errors.test.js
server.js shrinks: build app, mount static, mount /api router from lib/api/index.js. Drop the inline /api/spaces smoke route.
Conventions (apply to every route task)
- TDD always. Write the failing supertest test first. Run it red. Then write the route. Run it green. Commit.
- Route file shape:
import { Router } from 'express'; import * as repo from '../../db/repos/<name>.js'; import { validate } from '../validate.js'; import { asyncWrap } from '../errors.js'; import { z } from 'zod'; export const router = Router(); - No raw SQL in routes — every data access is
repo.fn(...). - Mutations pass
req.actorto the repo. - Errors: throw
new NotFoundError(...)/new ValidationError(...). The shared error middleware shapes them as{error:{code,message,details?}}. UseasyncWrapor rely on Express 5's native promise handling (already default in 5.2). - Pagination: all
GETlist endpoints accept?limit=&offset=viaparsePagination. Defaultlimit=50, max200. - Status codes:
201for create,200for read/update,204for delete,400for validation errors,401for unauthenticated,403for capability deny,404not found,409for conflicts. - Test file shape: import
tests/api/helpers.js'ssetup()which callsresetDb+migrateUp+ returns{ app, ownerHeaders }. Each test seeds the minimum it needs (e.g. one space) via repos, then hits the route. - Commit per task with message
feat(api): <entity> routesorfeat(ui): <view>etc.
Task list
Phase A — Plumbing
Task 1: Error + validation + pagination plumbing
Files: create lib/api/errors.js, lib/api/validate.js, lib/api/pagination.js, lib/api/index.js; create tests/api/helpers.js, tests/api/errors.test.js, tests/api/validate.test.js.
errors.js: export classesNotFoundError,ValidationError,ConflictError,ForbiddenErroreach with.codeand.status; exporterrorMiddleware(err, req, res, next)that maps known errors to{error:{code,message,details?}}with correct status, logs unknowns at 500.validate.js:validate({ body, params, query })returns middleware that runs the relevant zod schemas, on parse failure throwsValidationErrorwith zod'serror.issuesasdetails.pagination.js:parsePagination(req, { defaultLimit=50, max=200 })→{ limit, offset }, throwsValidationErrorif out of range.lib/api/index.js: exportsmountApi(app)that mounts an/apirouter (initially empty) underownerOnly. We'll register each route module here in later tasks.- Update
server.jsto callmountApi(app)and remove the inline/api/spacesroute. The existing server smoke test must keep passing — it should now route through the newspacesrouter (added in Task 2). Until then, expect that test to break — fix it in Task 2.
Tests: validate.test exercises happy + zod failure; errors.test exercises the middleware status mapping and JSON shape.
Commit: feat(api): error + validate + pagination plumbing.
Task 2: Spaces routes
Files: create lib/api/routes/spaces.js, tests/api/spaces.test.js. Register router in lib/api/index.js.
Endpoints:
GET /api/spaces→repo.list()POST /api/spacesbody{slug,name,description?,theme?}→repo.create(body, req.actor);201GET /api/spaces/:id→repo.getById; 404 if missingGET /api/spaces/by-slug/:slug→repo.getBySlug; 404 if missingPATCH /api/spaces/:idpartial body →repo.updateDELETE /api/spaces/:id→repo.del;204
Tests: list empty → []; create → 201 + record exists in DB; create with bad slug → 400 + zod details; get unknown id → 404; patch updates; delete then get → 404. Re-enable existing tests/server.test.js expectations (the [] smoke should now serve from this router).
Commit: feat(api): spaces routes.
Task 3: Projects routes
Files: lib/api/routes/projects.js, tests/api/projects.test.js.
Endpoints:
GET /api/spaces/:space_id/projects?status=→repo.listBySpacePOST /api/spaces/:space_id/projectsbody{slug,name,description?,status?,started_at?}GET /api/projects/:idPATCH /api/projects/:idDELETE /api/projects/:id
Tests: list filter by status; create rejects unknown space (FK error → map to 400 with code invalid_space); patch flips status to done (does not auto-set completed_at — that's a client/UI concern, mirroring repo behavior). Verify completed_at only changes when caller passes it.
Commit: feat(api): projects routes.
Task 4: Tasks routes
Files: lib/api/routes/tasks.js, tests/api/tasks.test.js.
Endpoints:
GET /api/spaces/:space_id/tasks?status=→repo.listBySpaceGET /api/projects/:project_id/tasks→repo.listByProjectPOST /api/spaces/:space_id/tasksbody{project_id?,title,body?,priority?,due_at?,position?}GET /api/tasks/:idPATCH /api/tasks/:idDELETE /api/tasks/:id
Tests: create sibling task (no project_id); create child task (project_id present); listByProject ordered by position NULLS LAST, created_at; patch with status:'done' sets completed_at.
Commit: feat(api): tasks routes.
Task 5: Pages routes (+ revisions, backlinks)
Files: lib/api/routes/pages.js, tests/api/pages.test.js.
Endpoints:
GET /api/spaces/:space_id/pages→repo.listBySpacePOST /api/spaces/:space_id/pagesbody{slug,title,body_md?,parent_id?}GET /api/pages/:idGET /api/spaces/:space_id/pages/by-slug/:slugPATCH /api/pages/:idDELETE /api/pages/:idGET /api/pages/:id/revisions→repo.listRevisionsGET /api/pages/:id/backlinks→links.listTo('page', id)joined with the source entity's title for display (route does the join via repos: read each from_type/from_id and resolve title)
Tests: create with body_md writes a revision; update body_md adds a revision; revisions ordered desc; backlinks returns rows when a entity_links row points at the page.
Commit: feat(api): pages routes.
Task 6: Refs routes
Files: lib/api/routes/refs.js, tests/api/refs.test.js.
Endpoints:
GET /api/refs?space_id=&kind=&limit=&offset=→repo.listPOST /api/refsbody matchesrepo.create(allFIELDSwhitelist)GET /api/refs/:idPATCH /api/refs/:idDELETE /api/refs/:idPOST /api/refs/upsertbody must includespace_id+source_kind+external_id→repo.upsertByExternal
Tests: list with kind=url filter; upsert twice with same external_id returns the same row id with updated fields; pagination caps at 200.
Commit: feat(api): refs routes.
Task 7: Resources routes (+ deps)
Files: lib/api/routes/resources.js, tests/api/resources.test.js.
Endpoints:
GET /api/spaces/:space_id/resources→repo.listBySpacePOST /api/spaces/:space_id/resourcesbody matches resource FIELDSGET /api/resources/:idPATCH /api/resources/:idDELETE /api/resources/:idPOST /api/resources/:id/dependenciesbody{depends_on, kind?}→repo.addDependency; 400 on self-depGET /api/resources/:id/dependencies→repo.listDependenciesDELETE /api/resources/:id/dependencies/:dep_id→repo.removeDependencyGET /api/resources/:id/source-docs→source_docs.listByResourceGET /api/resources/:id/changes→audit.listForEntity('resource', id)— the resource change history is the audit log filtered to that resource
Tests: dependency create rejects self; cross-space dependency rejected by composite FK → mapped to 400 with cross_space code; listing dependencies returns the right rows; changes endpoint returns audit entries (create + each patch).
Commit: feat(api): resources routes.
Task 8: Source docs routes
Files: lib/api/routes/source_docs.js, tests/api/source_docs.test.js.
Endpoints:
POST /api/resources/:resource_id/source-docsbody matches source_docs FIELDS (minus resource_id, taken from URL)GET /api/source-docs/:idPATCH /api/source-docs/:idDELETE /api/source-docs/:idPOST /api/source-docs/:id/resync— stub for now: returns202 {queued:true, note:"workers land in Plan 3"}. Behind a feature flag checkif (process.env.ENABLE_RESYNC === 'true')→ 503 otherwise. Document in the route comment that this hooks intopg-bossin Plan 3.
Tests: create requires resource_id from URL; resync returns 202/503 based on env.
Commit: feat(api): source-docs routes.
Phase B — Agents + auth
Task 9: Agent bearer auth middleware
Files: create lib/api/middleware/agent_auth.js, tests/api/agent_auth.test.js. Modify lib/api/index.js.
agent_auth.js exports agentOrOwner(req, res, next):
- Read
Authorization: Bearer <token>(401 if absent). - If token equals
OWNER_TOKEN→req.actor = { kind:'user', id:null }; next(). - Else
agents.verifyToken(token):- null → 401.
- row →
req.actor = { kind:'agent', id:row.id, capabilities:row.capabilities, scopes:row.scopes }; next().
lib/api/index.js: swap ownerOnly for agentOrOwner on the /api router. Owner tests continue to pass (same token path). New agent token tests pass.
Tests: missing header → 401; wrong token → 401; owner token → 200 + actor.kind='user'; valid agent token → 200 + actor.kind='agent'; revoked agent token → 401.
Commit: feat(api): agent bearer auth.
Task 10: Capability enforcement on mutating routes
Files: modify lib/auth/capability.js if needed; add helper lib/api/cap.js; add tests tests/api/capability_routes.test.js.
Add requireWrite(entity_type) middleware that calls canAct(req.actor, 'write', entity_type):
allow→ next().suggest→ divert: write the operation intopending_changesinstead of running it, return202 {pending:true, change_id}. The handler still needs to know what payload to record. Strategy: middleware attaches areq.divertToPending(payloadFactory)helper the route calls just before invoking the repo. Ifreq.actor.kind === 'agent'and tier issuggest, route doesawait pending_changes.create({agent_id, entity_type, entity_id, action, payload, reason})and returns 202.deny→ 403.
For Plan 2, apply to: POST/PATCH/DELETE on pages, refs, resources, source_docs, projects, tasks, tags, links. (Read endpoints stay open to any authed agent.) agents writes are owner-only regardless (a hard req.actor.kind === 'user' check, 403 otherwise).
Tests: agent at suggest tier POSTing a page → 202 + pending row exists; agent at allow tier POSTing a page → 201 + page row exists; agent at deny tier → 403; agent attempting POST /api/agents → 403.
Commit: feat(api): capability enforcement on writes.
Task 11: Agents routes
Files: lib/api/routes/agents.js, tests/api/agents.test.js.
All endpoints owner-only (see Task 10 rule). Endpoints:
GET /api/agents→repo.listPOST /api/agentsbody matches FIELDS →repo.createGET /api/agents/:idPATCH /api/agents/:id/capabilitiesbody{capabilities, scopes}→repo.setCapabilitiesPOST /api/agents/:id/tokensbody{label?}→repo.createToken; response{id, token}(plaintext shown once — document in comment)DELETE /api/agent-tokens/:token_id→repo.revokeToken
Tests: token mint returns plaintext; mint then auth-as-agent works; revoke then auth fails.
Commit: feat(api): agents routes + token mgmt.
Phase C — Cross-cutting
Task 12: Conversations + messages routes
Files: lib/api/routes/conversations.js, lib/api/routes/messages.js, tests/api/conversations.test.js, tests/api/messages.test.js.
Conversations:
GET /api/conversations?limit=&offset=→repo.listPOST /api/conversations→repo.createGET /api/conversations/:idPATCH /api/conversations/:id/statusbody{status}→repo.setStatusPATCH /api/conversations/:id/summarybody{summary}→repo.setSummary
Messages:
GET /api/conversations/:conversation_id/messages?limit=→messages.listByConversationPOST /api/conversations/:conversation_id/messagesbody{role,body,agent_id?,metadata?}→messages.append
Tests: append message, list returns it ordered; setSummary flips status to summarized.
Commit: feat(api): conversations + messages routes.
Task 13: Tags routes
Files: lib/api/routes/tags.js, tests/api/tags.test.js.
Endpoints:
GET /api/tags→tags.listPOST /api/tagsbody{name, description?, color?}→tags.upsertPOST /api/<entity_type>/:entity_id/tagsbody{tag_id}→tags.attach; 204DELETE /api/<entity_type>/:entity_id/tags/:tag_id→tags.detach; 204GET /api/<entity_type>/:entity_id/tags→tags.listForEntity
Allow entity_type values: space|project|task|page|ref|resource|source_doc|conversation. Validate via zod enum.
Tests: upsert idempotent; attach idempotent on conflict; listForEntity returns tags sorted by name.
Commit: feat(api): tags routes.
Task 14: Entity links routes
Files: lib/api/routes/links.js, tests/api/links.test.js.
Endpoints:
POST /api/linksbody{from_type,from_id,to_type,to_id,relation?}→links.createGET /api/links/from/:type/:id→links.listFromGET /api/links/to/:type/:id→links.listToDELETE /api/links/:id→links.remove
Tests: create twice with same tuple returns same row (ON CONFLICT path); list from/to.
Commit: feat(api): links routes.
Task 15: Pending-changes + audit routes
Files: lib/api/routes/pending_changes.js, lib/api/routes/audit.js, tests/api/pending_changes.test.js, tests/api/audit.test.js.
Pending changes (owner-only):
GET /api/pending-changes?limit=→pending_changes.listPendingPOST /api/pending-changes/:id/approve→ load row; dispatch byentity_type+actionthrough the same repo (pages.create,refs.update, etc.) usingreq.actor(the approving user); mark rowapproved. Single dispatch helperapplyPendingChange(row, actor)lives inlib/api/routes/pending_changes.jsand uses a small switch table mappingentity_type→ repo module.POST /api/pending-changes/:id/reject→ markrejected.
Audit (owner-only):
GET /api/audit/entity/:type/:id?limit=→audit.listForEntityGET /api/audit/actor?actor_kind=&actor_id=&limit=→audit.listByActor
Tests: agent at suggest POSTs a page (from Task 10) → owner approves → page now exists, pending row is approved, audit log shows the create with actor_kind='user' (the approver). Reject test marks row rejected, no entity created.
Commit: feat(api): pending-changes + audit routes.
Phase D — Search
Task 16: FTS search endpoint
Files: create lib/db/repos/search.js, lib/api/routes/search.js, tests/repos/search.test.js, tests/api/search.test.js.
search.fts({q, space_id?, kinds?, limit, offset}) runs tsvector @@ plainto_tsquery against four sources unioned with a kind discriminator:
pages— fts column already exists in migration 002 (fts_tsv); fall back toto_tsvector('english', title || ' ' || coalesce(body_md,''))if not.refs— usesrefs.fts_tsvfrom migration 002.source_docs—to_tsvector('english', name || ' ' || coalesce(body_text,'')).messages— usesmessages.fts_tsvfrom migration 004.
Each branch returns {kind, id, space_id, title_or_snippet, rank}. Final SELECT orders by ts_rank desc and applies limit/offset. kinds filter restricts which branches run.
Endpoint:
GET /api/search?q=&space_id=&kinds=page,ref&limit=&offset=→ results grouped client-side; server returns flat array withkinddiscriminator.
Tests (repo): seed 1 page + 1 ref + 1 source_doc + 1 message containing the word "blackflame", search for "blackflame" returns 4 hits, kinds filter narrows correctly. Tests (api): 401 without auth, 200 with results.
Vector search and RRF are explicitly deferred to Plan 3 — add a TODO comment in search.js linking to the spec section.
Commit: feat(api): unified FTS search.
Phase E — Void UI shell
Task 17: Static serving + shell HTML/CSS + SPA bootstrap
Files: create public/index.html, public/style.css, public/app.js, public/router.js, public/api.js. Modify server.js to app.use(express.static('public')) BEFORE the /api mount and ABOVE the 404 catch-all.
index.html: three-column flex layout: <aside id="sidebar">, <main id="main">, <aside id="rightrail">. Header bar <header id="topbar">. Loads app.js as <script type="module">.
style.css: blackflame palette — copy variables from Void 1.x (/project/src/void/public/css/ if accessible) or define minimum:
:root {
--bg: #0a0a0e;
--panel: #14141c;
--border: #2a2a36;
--text: #e8e6ed;
--muted: #888094;
--accent: #ff4f2e; /* blackflame */
--accent-dim: #7a2716;
}
Three-column grid: grid-template-columns: 280px 1fr 360px. Right rail collapsible to 40px. Top bar 48px height. Use system font stack + Cinzel or Cormorant via Google Fonts link for headings (Cradle aesthetic). Document the choice with a comment.
router.js: hash-based router (#/space/:id, #/project/:id, #/page/:id, #/ref/:id, #/resource/:id, #/search?q=, #/inbox, #/sacred-valley, #/). Exports route(handler), navigate(hash), current().
api.js: thin fetch wrapper that reads owner token from localStorage.void_token. On 401, prompts for token via a modal. Methods: api.get, api.post, api.patch, api.del.
app.js: bootstrap — mount sidebar + topbar + rightrail, register all views via router (most as views/home.js stub initially), render current route.
Tests: smoke tests/server.test.js — GET / returns 200 + content-type text/html. (Don't deep-test the SPA here.)
Commit: feat(ui): static shell + router + api wrapper.
Task 18: Sidebar + topbar components
Files: public/components/sidebar.js, public/components/topbar.js, public/components/rightrail.js.
sidebar.js:
- Top section: Spaces tree —
api.get('/api/spaces'), render as collapsible list. Each space header expands to show projects (api.get('/api/spaces/:id/projects')lazy on click). Drag-reorder deferred — render in static order for now. - Bottom section: global links — Sacred Valley, Agents, Inbox (badge with pending count from
api.get('/api/pending-changes')length), Resources (placeholder route), Search.
topbar.js:
- Universal capture button (placeholder: opens a modal that says "Capture lands in Plan 3" — proves the surface).
- Global search input (Enter →
router.navigate('#/search?q=' + encoded)). - Pending bell with count from same poll the sidebar uses (share state via a tiny
public/state.jsevent bus). - User/agent toggle (placeholder; owner-only for now).
rightrail.js: collapsible panel with a "Chat lands in Plan 5" placeholder + collapse toggle. Persist collapsed state in localStorage.void_rail_collapsed.
Tests: none — visual review only. Note this in the task block.
Commit: feat(ui): sidebar + topbar + rightrail components.
Task 19: Space view + Project view + Home
Files: public/views/space.js, public/views/project.js, public/views/home.js.
home.js: "Recent activity" — calls api.get('/api/audit/actor?limit=20') and renders entity-typed rows linking to their detail view.
space.js: header (name, description), three columns: Projects list, Recent tasks (api.get('/api/spaces/:id/tasks?status=todo')), Recent refs/pages. Each item is a hash-link.
project.js: header (name, status, started/completed), Tasks list with inline status toggle (PATCH on click), Pages list, Refs list, "Add task" inline form.
Tests: none (visual). After this task, manually verify: create a Space via curl, navigate UI, click through.
Commit: feat(ui): space + project + home views.
Task 20: Page editor + Reference detail
Files: public/views/page.js, public/views/reference.js, public/components/markdown_editor.js, public/vendor/marked.min.js (vendor from npm marked package).
markdown_editor.js: split pane — textarea on left, rendered preview on right via marked.parse(value). Save button calls api.patch('/api/pages/:id', {body_md}). Show last revision timestamp.
page.js: header (title, slug), markdown editor, attachments list (read-only for now), backlinks panel calling /api/pages/:id/backlinks.
reference.js: media block (image preview if kind=image, embed if kind=video, link if kind=url), AI summary block (ref.summary), metadata table, tag list with attach/detach controls, linked-from list (/api/links/to/ref/:id).
Tests: none (visual). Manually: create a page via API, edit + save in UI, confirm revision count increments via API.
Commit: feat(ui): page editor + reference detail.
Task 21: Resource detail + Inbox
Files: public/views/resource.js, public/views/inbox.js.
resource.js: status header with runtime_type/host/url/status badge, dependencies list (with add via a small inline form), Source Docs list, runbook pages list (entity_links of kind runbook), change history (/api/resources/:id/changes).
inbox.js: list of pending changes grouped by agent, each item shows entity type icon, action, reason, JSON diff (preformatted), Approve and Reject buttons that call the respective endpoints and re-fetch. On approve, navigate to the resulting entity if the response carries entity_id.
Tests: none (visual). Manually create a fake pending change via API and approve it through the UI.
Commit: feat(ui): resource + inbox views.
Task 22: Search + Sacred Valley placeholder + version bump + CHANGELOG
Files: public/views/search.js, public/views/sacred_valley.js, CHANGELOG.md, package.json, lib/db/migrate.js (if version constant lives elsewhere — check first).
search.js: reads ?q from hash, calls /api/search?q=..., renders results grouped by kind, sidebar filters for kinds and space_id. Empty state suggests typing.
sacred_valley.js: one placeholder card "Sacred Valley — widgets ported in Plan 6" with a screenshot of the Void 1.x dashboard linked. Keep the route registered so the sidebar link works.
Bump package.json version to 2.0.0-alpha.2; same in server.js VERSION constant.
Add CHANGELOG entry under ## [2.0.0-alpha.2] — 2026-MM-DD listing all 14 route groups, FTS search, UI shell, sidebar/topbar/rightrail, six views, agent bearer auth + capability dispatch on writes.
Tests: update tests/server.test.js version assertion to match. Re-run full suite — should still be green.
Commit: chore: version 2.0.0-alpha.2 + changelog.
Verification at end of plan
cd /project/src/void-v2
npm test # all plan 1 tests + new api tests green
npm run migrate # no-op
OWNER_TOKEN=test npm start &
sleep 1
curl -s localhost:3000/health
curl -s -H "Authorization: Bearer test" localhost:3000/api/spaces
curl -s -H "Authorization: Bearer test" \
-H "Content-Type: application/json" \
-d '{"slug":"home","name":"Home"}' \
localhost:3000/api/spaces # 201
# browser: http://localhost:3000/ → SPA loads, set token, see Home space
kill %1
Manual UI smoke (record outcome in docs/plan-2-complete.md):
- Set token in modal → token persists in localStorage.
- Create space "Home" via UI → space appears in sidebar.
- Create project → appears in space view.
- Create page → editor opens, edit + save → revision count increments via API check.
- Navigate
#/search?q=home→ space title shows up. - Create pending-change via API → bell shows badge → approve via Inbox → entity created.
What's left after Plan 2
- Plan 3: capture pipeline (pg-boss, Karakeep webhook, URL/YouTube/PDF/image workers, embeddings)
- Plan 4: heavy ingest (Whisper, Tesseract, OCR) and
void-workersPython service - Plan 5: MCP server (stdio + HTTP/SSE) + Cradle agent runtime + right-rail chat
- Plan 6: Sacred Valley widgets ported into UI
- Plan 7: Void 1.x / BookStack / Karakeep / auto-memory migrations
- Plan 8: E2E Playwright + CI + tighten security follow-ups (drop SUPERUSER, fileParallelism revisit, polymorphic space_id question)