From 3f77f3faad8df576f5aef109553ad23c1975e308 Mon Sep 17 00:00:00 2001 From: root Date: Fri, 5 Jun 2026 22:33:10 +1000 Subject: [PATCH 1/4] feat(pages): explicit position ordering + sectioned space view Add position column to pages (migration 020), update listBySpace to ORDER BY position, title, expose position in update(), add to patchSchema, and replace the space view flat table with a tree renderer grouping pages by parent_id under h4 section headers. Co-Authored-By: Claude Opus 4.8 --- lib/api/routes/pages.js | 3 +- lib/db/migrations/020_page_position.sql | 3 ++ lib/db/repos/pages.js | 6 +-- public/views/space.js | 50 +++++++++++++++++-------- tests/repos/pages_position.test.js | 22 +++++++++++ 5 files changed, 65 insertions(+), 19 deletions(-) create mode 100644 lib/db/migrations/020_page_position.sql create mode 100644 tests/repos/pages_position.test.js diff --git a/lib/api/routes/pages.js b/lib/api/routes/pages.js index ecf6826..87743a2 100644 --- a/lib/api/routes/pages.js +++ b/lib/api/routes/pages.js @@ -19,7 +19,8 @@ const patchSchema = z.object({ title: z.string().min(1).max(500).optional(), body_md: z.string().optional(), body_html: z.string().nullable().optional(), - parent_id: z.string().uuid().nullable().optional() + parent_id: z.string().uuid().nullable().optional(), + position: z.number().int().optional() }); const idParams = z.object({ id: z.string().uuid() }); diff --git a/lib/db/migrations/020_page_position.sql b/lib/db/migrations/020_page_position.sql new file mode 100644 index 0000000..1fc8297 --- /dev/null +++ b/lib/db/migrations/020_page_position.sql @@ -0,0 +1,3 @@ +-- 020: explicit page ordering within a space (and within a parent). +ALTER TABLE pages ADD COLUMN IF NOT EXISTS position integer NOT NULL DEFAULT 0; +CREATE INDEX IF NOT EXISTS idx_pages_space_position ON pages (space_id, position, title); diff --git a/lib/db/repos/pages.js b/lib/db/repos/pages.js index f25006b..007f6a8 100644 --- a/lib/db/repos/pages.js +++ b/lib/db/repos/pages.js @@ -45,8 +45,8 @@ export async function getBySlug(space_id, slug) { export async function listBySpace(space_id) { const { rows } = await pool.query( - `SELECT id, space_id, slug, title, parent_id, updated_at - FROM pages WHERE space_id=$1 ORDER BY title`, [space_id] + `SELECT id, space_id, slug, title, parent_id, position, updated_at + FROM pages WHERE space_id=$1 ORDER BY position, title`, [space_id] ); return rows; } @@ -64,7 +64,7 @@ export async function update(id, patch, actor) { const client = await pool.connect(); try { await client.query('BEGIN'); - const fields = ['slug','title','body_md','body_html','parent_id','embedding']; + const fields = ['slug','title','body_md','body_html','parent_id','position','embedding']; const sets = [], vals = []; let i = 1; for (const f of fields) { diff --git a/public/views/space.js b/public/views/space.js index 155155e..5abd7c3 100644 --- a/public/views/space.js +++ b/public/views/space.js @@ -11,10 +11,38 @@ function taskItem(t) { el('span', { class: 'status' + (t.status === 'blocked' ? ' bad' : '') }, t.status), ' ', t.title); } -function tableRow(href, title, type) { - return el('tr', {}, - el('td', { style: { padding: '5px 8px', borderTop: '1px solid var(--border)' } }, el('a', { href }, title || '(untitled)')), - el('td', { class: 'muted', style: { padding: '5px 8px', borderTop: '1px solid var(--border)', width: '90px' } }, type)); +function pageLink(p) { + return el('a', { href: '#/page/' + p.id }, p.title || '(untitled)'); +} + +function renderPageTree(pages, refs) { + const byParent = new Map(); + for (const p of pages) { + const k = p.parent_id || '__root__'; + if (!byParent.has(k)) byParent.set(k, []); + byParent.get(k).push(p); + } + const roots = byParent.get('__root__') || []; + const blocks = []; + for (const r of roots) { + const kids = byParent.get(r.id) || []; + blocks.push(el('div', { class: 'doc-section' }, + el('h4', { style: { margin: '12px 0 4px' } }, pageLink(r)), + kids.length + ? el('ul', { class: 'plain', style: { margin: '0 0 0 14px' } }, + kids.map(k => { + const gk = byParent.get(k.id) || []; + return el('li', {}, pageLink(k), + gk.length ? el('ul', { class: 'plain', style: { margin: '0 0 0 14px' } }, + gk.map(g => el('li', {}, pageLink(g)))) : null); + })) + : null)); + } + if (refs.length) blocks.push(el('div', { class: 'doc-section' }, + el('h4', { style: { margin: '12px 0 4px' } }, 'References'), + el('ul', { class: 'plain', style: { margin: '0 0 0 14px' } }, + refs.map(rf => el('li', {}, el('a', { href: '#/ref/' + rf.id }, rf.title || rf.source_url)))))); + return blocks; } export async function render(main, ctx) { @@ -46,10 +74,6 @@ export async function render(main, ctx) { const projHead = el('h3', {}, 'Projects'); renderProjects(); - const rows = [ - ...pages.map(p => tableRow('#/page/' + p.id, p.title, 'page')), - ...refs.map(r => tableRow('#/ref/' + r.id, r.title || r.source_url, r.kind)) - ]; mount(main, el('div', { class: 'doc-head' }, @@ -75,13 +99,9 @@ export async function render(main, ctx) { tasks.length ? el('ul', { class: 'plain' }, tasks.map(taskItem)) : el('p', { class: 'muted' }, 'Clear board.')), el('div', { class: 'card' }, - el('h3', {}, `Pages & references${rows.length ? ` (${pages.length + refs.length})` : ''}`), - rows.length - ? el('table', { style: { width: '100%', borderCollapse: 'collapse', fontSize: '13px' } }, - el('thead', {}, el('tr', {}, - el('th', { class: 'muted', style: { textAlign: 'left', padding: '5px 8px', fontWeight: '500' } }, 'Title'), - el('th', { class: 'muted', style: { textAlign: 'left', padding: '5px 8px', width: '90px', fontWeight: '500' } }, 'Type'))), - el('tbody', {}, rows)) + el('h3', {}, `Pages & references${(pages.length + refs.length) ? ` (${pages.length + refs.length})` : ''}`), + (pages.length + refs.length) > 0 + ? el('div', {}, renderPageTree(pages, refs)) : el('p', { class: 'muted' }, 'Nothing here yet.')) ); } diff --git a/tests/repos/pages_position.test.js b/tests/repos/pages_position.test.js new file mode 100644 index 0000000..779df24 --- /dev/null +++ b/tests/repos/pages_position.test.js @@ -0,0 +1,22 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { resetDb } from '../helpers/db.js'; +import { migrateUp } from '../../lib/db/migrate.js'; +import { create, listBySpace, update } from '../../lib/db/repos/pages.js'; +import { create as createSpace } from '../../lib/db/repos/spaces.js'; + +const actor = { kind: 'user', id: null }; + +beforeEach(async () => { await resetDb(); await migrateUp(); }); + +describe('page ordering', () => { + it('orders by position then title', async () => { + const space = await createSpace({ slug: 'ord-test', name: 'Ord' }, actor); + const sid = space.id; + const a = await create({ space_id: sid, slug: 'a', title: 'Zzz', body_md: '' }, actor); + const b = await create({ space_id: sid, slug: 'b', title: 'Aaa', body_md: '' }, actor); + await update(a.id, { position: 1 }, actor); + await update(b.id, { position: 9 }, actor); + const list = await listBySpace(sid); + expect(list.map(p => p.title)).toEqual(['Zzz', 'Aaa']); + }); +}); From c9bf79575d2965ea96ce3b9a9c0f2dfdd822045d Mon Sep 17 00:00:00 2001 From: root Date: Fri, 5 Jun 2026 22:36:33 +1000 Subject: [PATCH 2/4] chore(space-view): clarify tree depth limit + drop stray blank line --- public/views/space.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/public/views/space.js b/public/views/space.js index 5abd7c3..6820467 100644 --- a/public/views/space.js +++ b/public/views/space.js @@ -15,6 +15,8 @@ function pageLink(p) { return el('a', { href: '#/page/' + p.id }, p.title || '(untitled)'); } +// Static 3-level descent (root → child → grandchild); pages nested deeper than +// 3 levels are intentionally not shown. Our spaces never nest beyond that. function renderPageTree(pages, refs) { const byParent = new Map(); for (const p of pages) { @@ -74,7 +76,6 @@ export async function render(main, ctx) { const projHead = el('h3', {}, 'Projects'); renderProjects(); - mount(main, el('div', { class: 'doc-head' }, el('h1', { class: 'view-h1', style: { margin: '0' } }, space.name), From 494793874de21ccf19d88038bbc3ef01f2f4917f Mon Sep 17 00:00:00 2001 From: root Date: Fri, 5 Jun 2026 22:37:28 +1000 Subject: [PATCH 3/4] chore: release 2.0.0-alpha.20 (page ordering + sectioned space view) --- CHANGELOG.md | 4 ++++ package.json | 2 +- server.js | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f2b54a3..585b3c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,10 @@ All notable changes to Void 2.0 are documented here. Format: [Keep a Changelog](https://keepachangelog.com). +## 2.0.0-alpha.20 — Page ordering + sectioned space view +- **Explicit page ordering** (`migration 020`, `lib/db/repos/pages.js`): pages gain a `position integer` column; `listBySpace` now orders `position, title` instead of alphabetical-only, with a covering index `(space_id, position, title)`. `position` is patchable via `PUT /api/pages/:id`. Backfills all rows to `0` (preserves prior title order until positions are set). +- **Sectioned page tree** (`public/views/space.js`): the flat pages table is replaced by a `parent_id`-grouped tree — top-level pages render as section headers with their children/grandchildren nested. Backward-compatible with flat (un-nested) spaces. Enables the Wiki to read as ordered, sectioned documentation rather than an alphabetical dump. + ## 2.0.0-alpha.19 — Whisper GPU sharing + mobile chat Send button + registry - **Whisper on GPU with graceful CPU fallback** (`workers/void_workers/model.py`): the STT worker uses the in-container NVIDIA driver on the GPU node, and **falls back to CPU on any load failure** (e.g. shared-card VRAM exhaustion) so a transcription never hard-fails. (Passthrough alone gave device nodes but no `libcuda` — the matching userspace driver was installed inside CT 311; see [[gpu-cpu-fallback-for-ha]].) - **Cooperative GPU sharing with Ollama** (`workers/void_workers/gpu.py`): before loading Whisper on CUDA, the worker asks the co-resident Ollama (CT 102, same A2000) to unload its models (`GET /api/ps` + `POST /api/generate keep_alive:0`) and waits for the card to clear; Ollama reloads on its next request. Best-effort, stdlib-only; toggle `OLLAMA_FREE_BEFORE_STT`, endpoint `OLLAMA_URL`. diff --git a/package.json b/package.json index 06a2acc..72270ff 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "void-server", - "version": "2.0.0-alpha.19", + "version": "2.0.0-alpha.20", "type": "module", "private": true, "scripts": { diff --git a/server.js b/server.js index f8044db..fcc1b66 100644 --- a/server.js +++ b/server.js @@ -13,7 +13,7 @@ import { mcpAuth } from './lib/api/middleware/mcp_auth.js'; import { handleMcp } from './lib/mcp/http.js'; import httpProxy from 'http-proxy'; -const VERSION = '2.0.0-alpha.19'; +const VERSION = '2.0.0-alpha.20'; // Proxy /terminal (+ its WebSocket) to ttyd on CT 300, so the embedded terminal // works whether the Void is reached via Traefik (void2-app.hynesy.com) OR the From c54bc76e2438dfda7d3775d708adb237d4dca7b5 Mon Sep 17 00:00:00 2001 From: root Date: Fri, 5 Jun 2026 22:59:58 +1000 Subject: [PATCH 4/4] docs: void docs consolidation + wiki restructure plan (executed) --- .../2026-06-05-void-docs-consolidation.md | 785 ++++++++++++++++++ 1 file changed, 785 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-05-void-docs-consolidation.md diff --git a/docs/superpowers/plans/2026-06-05-void-docs-consolidation.md b/docs/superpowers/plans/2026-06-05-void-docs-consolidation.md new file mode 100644 index 0000000..f21fac2 --- /dev/null +++ b/docs/superpowers/plans/2026-06-05-void-docs-consolidation.md @@ -0,0 +1,785 @@ +# Void Docs Consolidation & Wiki Restructure — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Collapse the Void's 7 overlapping spaces into 3 meaningful ones (Wiki / The Void / Bookmarks), turn the Wiki into an ordered, sectioned "traditional lab documentation" area that is the single source of truth, preserve the Void 1.x record + doc lineage, and retire BookStack to a passive secondary mirror. + +**Architecture:** Most work is data manipulation on the **prod** Postgres DB (`void` @ `192.168.1.215`). One small code change adds page ordering + sectioned rendering to the `void-v2` app. Pure structural moves (space/parent/position changes, deletes) are done via SQL transactions; content **merges** (where two copies of a doc diverge) are done through the Void web page-editor so `body_html` + embeddings regenerate. Lineage between projects (The Void) and service docs (Wiki) is expressed with `entity_links`. + +**Tech Stack:** PostgreSQL 16 + pgvector (prod DB on CT 310 `.215`), Node 22 / Express app (`void-v2`, CT 311 `.216`), `node lib/db/migrate.js up`, deploy via `deploy/push.sh`. PVE snapshots for safety (CT 310 + 311 on host Z). + +**Authoritative reference data** (gathered 2026-06-05; re-verify counts before destructive steps): +- Spaces today: `void` (1 pg, 3 proj), `void1` (25 pg, 17 proj, 30 convo), `wiki` (24 pg), `plans` (6 pg), `void3` (8 pg), `bookmarks` (2 refs), `external-research` (empty). +- Wiki root page: slug `hynesy-homelab`, title "Hynesy Homelab" (all 23 other wiki pages are its direct children). +- DB creds for this work: `DATABASE_URL` in `/project/src/void-v2/.env` (`postgres://void:…@192.168.1.215:5432/void`). Prod app OWNER_TOKEN lives in `/opt/void-server/.env` on CT 311 (`.216`) — fetch at execution for API edits (the `.env` token in the repo is dev-only and is rejected by prod). +- Latest applied migration: `019_project_research.sql`. Next: `020`. +- `projects.status` CHECK allows: `idea | active | paused | done | abandoned`. + +--- + +## Pre-flight verdict table (void1 ↔ wiki duplicates) + +Classification computed by line-diff on 2026-06-05. Drives Task 5. "Drop v1" = delete the void1 copy, wiki copy is canonical. "Merge" = fold void1-only facts into the wiki copy first. + +| void1 page | wiki counterpart | verdict | action | +|---|---|---|---| +| Next steps & follow-ups | (same) | IDENTICAL | drop v1 | +| Open WebUI LXC (103) | (same) | IDENTICAL | drop v1 | +| OpenClaw VM (200) | (same) | IDENTICAL | drop v1 | +| BookStack LXC (104) | (same) | ±5 lines | drop v1 (cosmetic) | +| Claude Code LXC deployer (agentic.sh) | (same) | ±2 lines | drop v1 (cosmetic) | +| Iventoy Pxe Lxc | iVentoy PXE LXC (107) | ±2 lines | drop v1 (cosmetic) | +| Mediastack Lxc | Mediastack LXC (100) | ±2 lines | drop v1 (cosmetic) | +| Usb Autosync | USB auto-sync drive | ±3 lines | drop v1 (cosmetic) | +| Operations notes | (same) | wiki ⊇ v1 (+26/-4) | merge 4 v1-only lines → wiki, drop v1 | +| Overview | (same) | wiki fuller (+10/-11) | merge 11 v1-only lines → wiki, drop v1 | +| Network map | (same) | wiki fuller (+34/-19) | merge 19 v1-only lines → wiki, drop v1 | +| Master Index | (same) | wiki fuller (+48/-28) | rewrite (Task 9), drop both old | +| Gramps Web — CT 109 | Gramps LXC (109) | wiki 14.5KB ⊇ v1 1.8KB | salvage ≤29 v1 lines → wiki, drop v1 | +| **Ollama LXC (102)** | (same) | **DIVERGE (conflicting facts)** | **real merge → wiki, drop v1** | +| Claude LXC deployer | (intra-v1 dup of agentic.sh, same md5) | EXACT DUP | drop v1 (pure cruft) | + +void1 pages with **no** wiki counterpart (these are Void-app dev docs, salvaged in Task 6, not deleted): Active Projects Status, Agent Roster & Personas, Cron Tasks & Schedules, Deployment Guide, Path B — Build Log, Security Posture & Known Issues, The Void — Architecture & Agent System, The Void — Dashboard Progress (2026-05-14), Homelab Topology, Dashboard rebuild & Orthos overhaul (2026-05-16, also in wiki — moves to The Void, removed from wiki). + +--- + +## Helper: SQL shell used throughout + +All SQL steps assume this is exported in the working shell: + +```bash +export PGPASSWORD=eIvGMUmYxlVdWnkSZmRQ0EJ8v81 +Q() { psql -h 192.168.1.215 -U void -d void -X "$@"; } # interactive +QT(){ psql -h 192.168.1.215 -U void -d void -X -A -t "$@"; } # scalar +``` + +Resolve space UUIDs once and reuse (slugs are stable): + +```sql +-- run: Q -c "SELECT slug,id FROM spaces ORDER BY slug;" +``` + +--- + +## Phase 0 — Safety net + +### Task 0: Snapshot + logical backup + +**Files:** none (infra) + +- [ ] **Step 1: Snapshot both Void CTs on host Z** (standing rule: backup before major changes) + +Run (on Z, `.124`): +```bash +ts=$(date +%Y%m%d_%H%M) +pct snapshot 310 predocs_${ts} +pct snapshot 311 predocs_${ts} +``` +Expected: two snapshots created, no error. + +- [ ] **Step 2: Logical dump of the prod DB to this box** + +Run: +```bash +export PGPASSWORD=eIvGMUmYxlVdWnkSZmRQ0EJ8v81 +pg_dump -h 192.168.1.215 -U void -d void -Fc -f /project/backups/void-predocs-$(date +%Y%m%d_%H%M).dump +ls -lh /project/backups/void-predocs-*.dump +``` +Expected: a >0-byte custom-format dump. (`mkdir -p /project/backups` first if missing.) + +- [ ] **Step 3: Record current counts as the rollback baseline** + +Run: +```bash +Q -c "SELECT s.slug, (SELECT count(*) FROM pages p WHERE p.space_id=s.id) pages, + (SELECT count(*) FROM projects pr WHERE pr.space_id=s.id) projects + FROM spaces s ORDER BY s.slug;" +``` +Expected: matches the inventory in this plan's header (void 1/3, void1 25/17, wiki 24/0, plans 6/0, void3 8/0, bookmarks 0/0, external-research 0/0). If it does **not** match, STOP — the data drifted; re-audit before proceeding. + +--- + +## Phase 1 — Page ordering foundation (code) + +This is the only code change. It gives every space a stable, intentional order instead of alphabetical-by-title, which is the root cause of "Overview is buried." + +### Task 1: Add `position` column + order by it + +**Files:** +- Create: `lib/db/migrations/020_page_position.sql` +- Modify: `lib/db/repos/pages.js:46-52` (`listBySpace`) +- Modify: `lib/api/routes/pages.js` (allow `position` in the update schema) +- Test: `tests/repos/pages_position.test.js` + +- [ ] **Step 1: Write the migration** + +`lib/db/migrations/020_page_position.sql`: +```sql +-- 020: explicit page ordering within a space (and within a parent). +ALTER TABLE pages ADD COLUMN IF NOT EXISTS position integer NOT NULL DEFAULT 0; +CREATE INDEX IF NOT EXISTS idx_pages_space_position ON pages (space_id, position, title); +``` + +- [ ] **Step 2: Write the failing test** + +`tests/repos/pages_position.test.js`: +```js +import { describe, it, expect, beforeAll } from 'vitest'; +import { create, listBySpace, update } from '../../lib/db/repos/pages.js'; +import { create as createSpace } from '../../lib/db/repos/spaces.js'; + +describe('page ordering', () => { + let sid; + beforeAll(async () => { sid = (await createSpace({ slug: 'ord-'+Date.now(), name: 'Ord' }, 'test')).id; }); + it('orders by position then title', async () => { + const a = await create({ space_id: sid, slug: 'a', title: 'Zzz', body_md: '' }, 'test'); + const b = await create({ space_id: sid, slug: 'b', title: 'Aaa', body_md: '' }, 'test'); + await update(a.id, { position: 1 }, 'test'); + await update(b.id, { position: 9 }, 'test'); + const list = await listBySpace(sid); + expect(list.map(p => p.title)).toEqual(['Zzz', 'Aaa']); // position 1 before 9 despite title + }); +}); +``` + +- [ ] **Step 3: Run it, verify it fails** + +Run: `cd /project/src/void-v2 && npx vitest run tests/repos/pages_position.test.js` +Expected: FAIL (`update` rejects `position`, or order is alphabetical). + +- [ ] **Step 4: Update `listBySpace` ordering** + +In `lib/db/repos/pages.js`, change the `listBySpace` query (currently `ORDER BY title`) to: +```js +export async function listBySpace(space_id) { + const { rows } = await pool.query( + `SELECT id, space_id, slug, title, parent_id, position, updated_at + FROM pages WHERE space_id=$1 ORDER BY position, title`, [space_id] + ); + return rows; +} +``` + +- [ ] **Step 5: Allow `position` through update** + +In `lib/db/repos/pages.js` `update()`, add `'position'` to the updatable `fields` array (currently `['slug','title','body_md','body_html','parent_id','embedding']`): +```js +const fields = ['slug','title','body_md','body_html','parent_id','embedding','position']; +``` +In `lib/api/routes/pages.js`, add to the page update zod schema (next to `body_html`): +```js +position: z.number().int().optional(), +``` + +- [ ] **Step 6: Run tests, verify pass** + +Run: `npx vitest run tests/repos/pages_position.test.js` +Expected: PASS. + +- [ ] **Step 7: Full suite green** + +Run: `npx vitest run` +Expected: all pass (no regressions from the ordering change). + +- [ ] **Step 8: Commit** + +```bash +git add lib/db/migrations/020_page_position.sql lib/db/repos/pages.js lib/api/routes/pages.js tests/repos/pages_position.test.js +git commit -m "feat(pages): explicit position ordering within a space" +``` + +### Task 2: Sectioned page rendering in the space view + +**Files:** +- Modify: `public/views/space.js:14-18,49-52,77-85` + +Currently `space.js` renders pages as a single flat table. Change it to render a **page tree**: top-level pages (no parent, or the space's root page) as section headers, their children indented beneath. Backward-compatible — flat spaces (all pages parent-null) render as before, just ordered. + +- [ ] **Step 1: Add a tree renderer** (replace `tableRow` helper, lines 14-18) + +```js +function pageLink(p) { + return el('a', { href: '#/page/' + p.id }, p.title || '(untitled)'); +} + +// Build parent→children map and render top-level pages as section blocks. +function renderPageTree(pages, refs) { + const byParent = new Map(); + for (const p of pages) { + const k = p.parent_id || '__root__'; + if (!byParent.has(k)) byParent.set(k, []); + byParent.get(k).push(p); + } + const roots = byParent.get('__root__') || []; + const blocks = []; + for (const r of roots) { + const kids = byParent.get(r.id) || []; + blocks.push(el('div', { class: 'doc-section' }, + el('h4', { style: { margin: '12px 0 4px' } }, pageLink(r)), + kids.length + ? el('ul', { class: 'plain', style: { margin: '0 0 0 14px' } }, + kids.map(k => { + const gk = byParent.get(k.id) || []; + return el('li', {}, pageLink(k), + gk.length ? el('ul', { class: 'plain', style: { margin: '0 0 0 14px' } }, + gk.map(g => el('li', {}, pageLink(g)))) : null); + })) + : null)); + } + if (refs.length) blocks.push(el('div', { class: 'doc-section' }, + el('h4', { style: { margin: '12px 0 4px' } }, 'References'), + el('ul', { class: 'plain', style: { margin: '0 0 0 14px' } }, + refs.map(rf => el('li', {}, el('a', { href: '#/ref/' + rf.id }, rf.title || rf.source_url)))))); + return blocks; +} +``` + +- [ ] **Step 2: Use it in the Pages card** (replace the `rows` block, lines 49-52 + 77-85) + +Remove the `const rows = [...]` block. Replace the final "Pages & references" card with: +```js + el('div', { class: 'card' }, + el('h3', {}, `Pages & references${(pages.length + refs.length) ? ` (${pages.length + refs.length})` : ''}`), + (pages.length + refs.length) + ? el('div', {}, renderPageTree(pages, refs)) + : el('p', { class: 'muted' }, 'Nothing here yet.')) +``` + +- [ ] **Step 3: Smoke locally against prod data (read-only)** + +Run the app pointed at prod read-only, or eyeball after deploy. Minimum: `node -e "require('./public/views/space.js')"` won't run (browser ESM) — instead verify via deploy in Task 3. + +- [ ] **Step 4: Commit** + +```bash +git add public/views/space.js +git commit -m "feat(space-view): render pages as ordered sectioned tree" +``` + +### Task 3: Deploy the code change + +**Files:** none (deploy) + +- [ ] **Step 1: Bump version** in `package.json` + `server.js` VERSION const + add a CHANGELOG entry (alpha-20). + +- [ ] **Step 2: Deploy + migrate** + +Run: +```bash +cd /project/src/void-v2 +./deploy/push.sh # rsync + npm i --omit=dev + restart (TARGET defaults root@192.168.1.216) +ssh root@192.168.1.216 'cd /opt/void-server && npm run migrate' # applies 020 +ssh root@192.168.1.216 'systemctl restart void-server' +``` + +- [ ] **Step 3: Verify** + +Run: +```bash +curl -s http://192.168.1.216:3000/health +Q -c "\d pages" | grep position +Q -c "SELECT name FROM schema_migrations WHERE name='020_page_position.sql';" +``` +Expected: health = `2.0.0-alpha.20`; `position` column present; migration row present. Open `void.hynesy.com` → a space → confirm pages render as a tree (still alphabetical until positions are set in Phase 2). + +--- + +## Phase 2 — Wiki → traditional documentation area + +Make `wiki` the ordered, sectioned source of truth, and remove Void-app dev docs that don't belong in a homelab wiki (they move to The Void in Phase 4/6). + +### Task 4: Section the Wiki + set order + +**Files:** none (SQL on prod) + +- [ ] **Step 1: Move Void-app dev docs OUT of `wiki` into `void` (The Void)** + +These 6 "Void 2.0 — *" pages + "Dashboard rebuild & Orthos overhaul" are project history, not homelab infra: +```sql +WITH w AS (SELECT id FROM spaces WHERE slug='wiki'), + v AS (SELECT id FROM spaces WHERE slug='void') +UPDATE pages SET space_id=(SELECT id FROM v), parent_id=NULL +WHERE space_id=(SELECT id FROM w) + AND title IN ( + 'Void 2.0 — Deployment (CT 310 + 311)', + 'Void 2.0 — Plan 1 Foundation (complete)', + 'Void 2.0 — Plan 2 API + UI (complete)', + 'Void 2.0 — Plan 3 Capture (complete)', + 'Void 2.0 — Plan 4 Workers (complete)', + 'Dashboard rebuild & Orthos overhaul (2026-05-16)' + ); +``` +(These are re-parented under The Void's "Void 2.0 — Build Log" in Task 7.) + +- [ ] **Step 2: Create three section parent-pages under the wiki root** + +```sql +WITH w AS (SELECT id FROM spaces WHERE slug='wiki'), + root AS (SELECT id FROM pages WHERE space_id=(SELECT id FROM w) AND slug='hynesy-homelab') +INSERT INTO pages (space_id, slug, title, body_md, parent_id, position) +SELECT (SELECT id FROM w), x.slug, x.title, + '_Section index. Pages in this section are listed below._', (SELECT id FROM root), x.pos +FROM (VALUES + ('sec-start-here','Start Here',0), + ('sec-hosts-services','Hosts & Services',1), + ('sec-operations','Operations & Infrastructure',2) +) AS x(slug,title,pos) +ON CONFLICT (space_id, slug) DO NOTHING; +``` + +- [ ] **Step 3: Re-parent each content page under the right section + set positions** + +```sql +-- helper CTE pattern: set parent + position by title +WITH w AS (SELECT id sid FROM spaces WHERE slug='wiki'), + sec AS (SELECT slug, id FROM pages WHERE space_id=(SELECT sid FROM w) AND slug LIKE 'sec-%') +UPDATE pages p SET + parent_id = (SELECT id FROM sec WHERE sec.slug = m.section), + position = m.pos +FROM (VALUES + -- Start Here + ('Overview', 'sec-start-here', 0), + ('Network map', 'sec-start-here', 1), + ('Master Index', 'sec-start-here', 2), + -- Hosts & Services + ('Mediastack LXC (100)', 'sec-hosts-services', 0), + ('Ollama LXC (102)', 'sec-hosts-services', 1), + ('Open WebUI LXC (103)', 'sec-hosts-services', 2), + ('BookStack LXC (104)', 'sec-hosts-services', 3), + ('iVentoy PXE LXC (107)', 'sec-hosts-services', 4), + ('Gramps LXC (109)', 'sec-hosts-services', 5), + ('OpenClaw VM (200)', 'sec-hosts-services', 6), + ('Jellyfin plugins', 'sec-hosts-services', 7), + ('USB auto-sync drive', 'sec-hosts-services', 8), + ('Claude Code LXC deployer (agentic.sh)', 'sec-hosts-services', 9), + -- Operations & Infrastructure + ('Cluster and HA', 'sec-operations', 0), + ('Operations notes', 'sec-operations', 1), + ('AI Usage', 'sec-operations', 2), + ('Next steps & follow-ups', 'sec-operations', 3) +) AS m(title, section, pos) +WHERE p.space_id=(SELECT sid FROM w) AND p.title = m.title; +``` + +- [ ] **Step 4: Set section order on the root's children** (sections already have positions 0/1/2 from Step 2; verify root page sorts first) + +```sql +WITH w AS (SELECT id FROM spaces WHERE slug='wiki') +UPDATE pages SET position=-1 WHERE space_id=(SELECT id FROM w) AND slug='hynesy-homelab'; +``` + +- [ ] **Step 5: Verify the tree** + +```sql +WITH w AS (SELECT id FROM spaces WHERE slug='wiki') +SELECT COALESCE(par.title,'(root)') AS parent, p.position, p.title +FROM pages p LEFT JOIN pages par ON par.id=p.parent_id +WHERE p.space_id=(SELECT id FROM w) +ORDER BY COALESCE(par.position,-2), par.title NULLS FIRST, p.position, p.title; +``` +Expected: root → Start Here(Overview, Network map, Master Index) → Hosts & Services(...) → Operations(...). Open `void.hynesy.com` → Wiki space → confirm **Overview is at the top**, grouped under sections. This resolves the user's "out of order" complaint. + +--- + +## Phase 3 — Dedup void1 ↔ wiki + +Per the verdict table. Merges (content edits) go through the **web page editor** (regenerates html/embedding); structural drops via SQL. + +### Task 5: Salvage divergent facts, then drop void1 duplicates + +**Files:** none (web editor + SQL on prod) + +- [ ] **Step 1: Merge the DIVERGE page — Ollama LXC (102)** + +Open the **wiki** "Ollama LXC (102)" page in the Void editor. Reconcile against the void1 copy using these authoritative facts (void1's are newer; cross-checked vs memory `reference-ollama-ct102`): + - Binary path: **`/usr/share/ollama`** for models; service user **`ollama`** (not root); installed via the community-script then upgraded. (Resolve the `/usr/bin` vs `/usr/local/bin` conflict by verifying live: `pct exec 102 -- which ollama` — use the real path; do not leave both.) + - Keep wiki-only content: the **PATH gotcha**, the **keep-alive / multi-model tuning after the 62 GiB RAM upgrade**, and the **driver 595.58.03** detail. + - Keep void1-only content: the **"CRITICAL: preserving config across upgrades"** section (systemd drop-in for `0.0.0.0:11434` binding) — this is the most important salvage; verify it's present in the merged page. + - Add a one-line lineage note at top: `> Consolidated from the Void 1 and BookStack copies, 2026-06-05.` +Save. Confirm the page renders and a search for "preserving config" returns it. + +- [ ] **Step 2: Merge the wiki-superset pages** (Operations notes, Overview, Network map, Gramps LXC (109)) + +For each, diff the void1 copy and append any **void1-only** lines that carry real facts into the wiki copy via the editor. Use this to see exactly what's void1-only: +```bash +diff <(QT -c "SELECT body_md FROM pages p JOIN spaces s ON s.id=p.space_id WHERE s.slug='void1' AND p.title=''") \ + <(QT -c "SELECT body_md FROM pages p JOIN spaces s ON s.id=p.space_id WHERE s.slug='wiki' AND p.title=''") +``` +Title pairs: Operations notes↔Operations notes; Overview↔Overview; Network map↔Network map; "Gramps Web — CT 109 (2026-05-25)"↔"Gramps LXC (109)". Lines prefixed `<` are void1-only — judge each; most will be stale and can be skipped. Save each edited wiki page. + +- [ ] **Step 3: Fold Homelab Topology into wiki Network map** + +void1 "Homelab Topology" has no wiki twin but overlaps "Network map". Diff it against wiki Network map; salvage any unique device/IP rows into Network map, then it's covered by the void1 drop in Step 5. + +- [ ] **Step 4: Verify nothing unique is about to be lost** + +```sql +-- void1 pages that will be dropped, with their wiki survivor lengths for a final sanity glance +WITH v AS (SELECT id FROM spaces WHERE slug='void1'), + w AS (SELECT id FROM spaces WHERE slug='wiki') +SELECT p.title, length(p.body_md) v1len, + (SELECT length(body_md) FROM pages wp WHERE wp.space_id=(SELECT id FROM w) + AND wp.title = CASE p.title + WHEN 'Mediastack Lxc' THEN 'Mediastack LXC (100)' + WHEN 'Iventoy Pxe Lxc' THEN 'iVentoy PXE LXC (107)' + WHEN 'Usb Autosync' THEN 'USB auto-sync drive' + WHEN 'Gramps Web — CT 109 (2026-05-25)' THEN 'Gramps LXC (109)' + ELSE p.title END) wikilen +FROM pages p WHERE p.space_id=(SELECT id FROM v) ORDER BY p.title; +``` +Expected: every row that is being dropped has a non-null `wikilen` (a survivor exists). Investigate any NULL before deleting. + +- [ ] **Step 5: Delete the void1 duplicate + intra-dup pages** + +```sql +WITH v AS (SELECT id FROM spaces WHERE slug='void1') +DELETE FROM pages WHERE space_id=(SELECT id FROM v) AND title IN ( + 'Next steps & follow-ups','Open WebUI LXC (103)','OpenClaw VM (200)', + 'BookStack LXC (104)','Claude Code LXC deployer (agentic.sh)','Claude LXC deployer', + 'Iventoy Pxe Lxc','Mediastack Lxc','Usb Autosync', + 'Operations notes','Overview','Network map','Master Index', + 'Gramps Web — CT 109 (2026-05-25)','Ollama LXC (102)','Homelab Topology' +); +``` +Expected: `DELETE 16`. (Leaves only the 9 Void-app-specific pages in void1, salvaged next.) + +--- + +## Phase 4 — Collapse void1 → The Void + +### Task 6: Salvage void1's unique dev docs into The Void + +**Files:** none (SQL on prod) + +- [ ] **Step 1: Create a "Void 1.x — Final State / Retrospective" page** (the record of where Void 1 ended) + +```sql +WITH v AS (SELECT id sid FROM spaces WHERE slug='void') +INSERT INTO pages (space_id, slug, title, body_md, position) +VALUES ((SELECT sid FROM v), 'void1-retrospective', 'Void 1.x — Final State / Retrospective', +$md$# Void 1.x — Final State / Retrospective + +**Status at retirement (2026-06-05):** Void 1 ran on **CT 301**, `192.168.1.11:2424`, `void.hynesy.com`. +At the 8b cutover (2026-06-05, alpha-18) `void.hynesy.com` was repointed to **Void 2** (CT 311, `.216`). +CT 301 is **legacy-but-running**, kept as instant rollback; scheduled to be vzdump'd then retired after the grace period. + +## What Void 1 was +Cradle-themed homelab dashboard (Node + Express + `node:sqlite`, gridstack "Sacred Valley", 8 character agents). +Architecture, agent system, dashboard progress and the Path B rebuild log are preserved as child pages of this page. + +## Projects completed / migrated +- Sacred Valley dashboard, agent roster (Dross/Yerin/Little Blue/Orthos/Eithan/Lindon/Mercy), cron briefings. +- All knowledge migrated into Void 2 spaces (Wiki + The Void) on 2026-06-04–05. + +## Lineage +Services first documented/managed under Void 1 now live in the **Wiki** (source of truth) and, where they evolved, +the improved state is noted there — e.g. **Mediastack (CT 100)** began under Void-1 docs and is now Z→Z3 replicated and +documented under Wiki › Hosts & Services with a Void-2 lineage note. See linked references. +$md$, 1) +ON CONFLICT (space_id, slug) DO NOTHING; +``` + +- [ ] **Step 2: Move the remaining void1 pages into The Void, nested under the retrospective** + +```sql +WITH v1 AS (SELECT id FROM spaces WHERE slug='void1'), + vd AS (SELECT id FROM spaces WHERE slug='void'), + retro AS (SELECT id FROM pages WHERE space_id=(SELECT id FROM vd) AND slug='void1-retrospective') +UPDATE pages SET space_id=(SELECT id FROM vd), parent_id=(SELECT id FROM retro) +WHERE space_id=(SELECT id FROM v1) AND title IN ( + 'The Void — Architecture & Agent System', + 'The Void — Dashboard Progress (2026-05-14)', + 'Path B — Build Log', + 'Deployment Guide', + 'Cron Tasks & Schedules', + 'Security Posture & Known Issues', + 'Active Projects Status', + 'Agent Roster & Personas' +); +``` +Expected: `UPDATE 8`. (Agent Roster stays a live reference under The Void; the rest are historical children of the retrospective.) + +### Task 7: Migrate void1 projects + reparent the moved build-log pages + +**Files:** none (SQL on prod) + +- [ ] **Step 1: Move real homelab projects into The Void with corrected status** + +```sql +WITH v1 AS (SELECT id FROM spaces WHERE slug='void1'), + vd AS (SELECT id FROM spaces WHERE slug='void') +UPDATE projects p SET space_id=(SELECT id FROM vd), status=m.st +FROM (VALUES + ('Homelab','active'), + ('Homelab Atlas','active'), + ('Knowledge Pipeline','active'), + ('Network Rebuild','paused'), + ('Sacred Valley Widgets — Phase 4 Follow-up','done'), + ('USB Auto-Sync Drive','idea'), + ('VM 203 Docker Stack Rebuild','active'), + ('Win10 → Mediastack Migration','idea'), + ('Z2 → Cluster Migration','active'), + ('iVentoy PXE Server','paused') +) AS m(name, st) +WHERE p.space_id=(SELECT id FROM v1) AND p.name=m.name; +``` +Expected: `UPDATE 10`. + +- [ ] **Step 2: Salvage agent persona one-liners into the Agent Roster page, then delete the 7 agent "projects"** + +The 7 agent-named projects (Dross, Eithan, Lindon, Little Blue, Mercy, Orthos, Yerin) are personas, not projects. Their descriptions are 1-liners. Append any not already on "Agent Roster & Personas" (now in The Void), then: +```sql +WITH v1 AS (SELECT id FROM spaces WHERE slug='void1') +DELETE FROM projects WHERE space_id=(SELECT id FROM v1) + AND name IN ('Dross','Eithan','Lindon','Little Blue','Mercy','Orthos','Yerin'); +``` +Expected: `DELETE 7`. (Live agents already exist in the `agents` table; these project stubs are redundant.) + +- [ ] **Step 3: Reparent the 6 Void-2 dev docs moved in Task 4/Step 1 under the Build Log** + +```sql +WITH vd AS (SELECT id FROM spaces WHERE slug='void'), + bl AS (SELECT id FROM pages WHERE space_id=(SELECT id FROM vd) AND title='Void 2.0 — Build Log') +UPDATE pages SET parent_id=(SELECT id FROM bl) +WHERE space_id=(SELECT id FROM vd) AND title IN ( + 'Void 2.0 — Deployment (CT 310 + 311)', + 'Void 2.0 — Plan 1 Foundation (complete)', + 'Void 2.0 — Plan 2 API + UI (complete)', + 'Void 2.0 — Plan 3 Capture (complete)', + 'Void 2.0 — Plan 4 Workers (complete)', + 'Dashboard rebuild & Orthos overhaul (2026-05-16)' +); +``` +Expected: `UPDATE 6`. + +### Task 8: Handle void1 conversations, then delete the void1 space + +**Files:** none (SQL on prod) + +- [ ] **Step 1: Drop the 30 conversations** (historical Dross/companion chat logs — user confirmed 2026-06-05: drop) + +```sql +WITH v1 AS (SELECT id FROM spaces WHERE slug='void1') +DELETE FROM conversations WHERE space_id=(SELECT id FROM v1); +-- (messages cascade via FK) +``` + +- [ ] **Step 2: Confirm void1 is empty of survivors** + +```sql +WITH v1 AS (SELECT id FROM spaces WHERE slug='void1') +SELECT + (SELECT count(*) FROM pages WHERE space_id=(SELECT id FROM v1)) pages, + (SELECT count(*) FROM projects WHERE space_id=(SELECT id FROM v1)) projects, + (SELECT count(*) FROM conversations WHERE space_id=(SELECT id FROM v1)) convos, + (SELECT count(*) FROM refs WHERE space_id=(SELECT id FROM v1)) refs; +``` +Expected: all `0`. If not, STOP and reconcile. + +- [ ] **Step 3: Delete the void1 space** + +```sql +DELETE FROM spaces WHERE slug='void1'; +``` +Expected: `DELETE 1` (cascades any stragglers). + +### Task 9: Rebuild a single Master Index + +**Files:** none (web editor on prod) + +- [ ] **Step 1:** The wiki "Master Index" survived (wiki copy was fuller). Open it in the editor and update links so they point at the new sectioned Wiki structure + The Void hub; remove dead links to deleted void1 pages. Save. + +--- + +## Phase 5 — Fold void3 into the Void 3.0 project + +### Task 10: Move void3 pages into The Void under a Void 3.0 hub page, link to the project + +**Files:** none (SQL on prod) + +- [ ] **Step 1: Create a Void 3.0 hub page in The Void** + +```sql +WITH vd AS (SELECT id sid FROM spaces WHERE slug='void') +INSERT INTO pages (space_id, slug, title, body_md, position) +VALUES ((SELECT sid FROM vd),'void3-hub','Void 3.0 — Roadmap & Audit (2026-06-02)', +'_Point-in-time homelab evolution audit + roadmap, migrated from the former void3 space. Child pages are the original audit notes._', 2) +ON CONFLICT (space_id, slug) DO NOTHING; +``` + +- [ ] **Step 2: Move all 8 void3 pages under the hub** + +```sql +WITH v3 AS (SELECT id FROM spaces WHERE slug='void3'), + vd AS (SELECT id FROM spaces WHERE slug='void'), + hub AS (SELECT id FROM pages WHERE space_id=(SELECT id FROM vd) AND slug='void3-hub') +UPDATE pages SET space_id=(SELECT id FROM vd), parent_id=(SELECT id FROM hub) +WHERE space_id=(SELECT id FROM v3); +``` +Expected: `UPDATE 8`. + +- [ ] **Step 3: Link the hub page to the "Void 3.0" project** + +```sql +WITH vd AS (SELECT id FROM spaces WHERE slug='void'), + pr AS (SELECT id FROM projects WHERE space_id=(SELECT id FROM vd) AND name='Void 3.0'), + hub AS (SELECT id FROM pages WHERE space_id=(SELECT id FROM vd) AND slug='void3-hub') +INSERT INTO entity_links (from_type, from_id, to_type, to_id, relation) +VALUES ('project',(SELECT id FROM pr),'page',(SELECT id FROM hub),'reference'); +``` + +- [ ] **Step 4: Move the void3 conversation (if any) then delete the space** + +```sql +WITH v3 AS (SELECT id FROM spaces WHERE slug='void3') +DELETE FROM conversations WHERE space_id=(SELECT id FROM v3); -- user confirmed: drop +DELETE FROM spaces WHERE slug='void3'; +``` +Expected: `DELETE 1`. + +--- + +## Phase 6 — Relocate `plans`, decide `external-research` + +**User decision (2026-06-05): KEEP the plan pages** — give them a home in The Void (not the Wiki, since they are project artifacts not infra reference). Move them under an "Implementation Plans" hub page in `void` and link each to its related project. + +### Task 11: Move plan pages into The Void under an "Implementation Plans" hub, then delete the empty plans space + +**Files:** none (SQL on prod) + +- [ ] **Step 1: Create the hub page in The Void** + +```sql +WITH vd AS (SELECT id sid FROM spaces WHERE slug='void') +INSERT INTO pages (space_id, slug, title, body_md, position) +VALUES ((SELECT sid FROM vd),'implementation-plans','Implementation Plans', +'_Implementation plans for homelab + Void projects, migrated from the former plans space. Canonical copies also live in the repo `docs/superpowers/plans/` and `~/.claude/plans/`. Child pages below; each is linked to its related project._', 3) +ON CONFLICT (space_id, slug) DO NOTHING; +``` + +- [ ] **Step 2: Move all 6 plan pages under the hub** + +```sql +WITH p AS (SELECT id FROM spaces WHERE slug='plans'), + vd AS (SELECT id FROM spaces WHERE slug='void'), + hub AS (SELECT id FROM pages WHERE space_id=(SELECT id FROM vd) AND slug='implementation-plans') +UPDATE pages SET space_id=(SELECT id FROM vd), parent_id=(SELECT id FROM hub) +WHERE space_id=(SELECT id FROM p); +``` +Expected: `UPDATE 6`. + +- [ ] **Step 3: Link each plan page to its related project** (where one exists in The Void) + +```sql +WITH vd AS (SELECT id sid FROM spaces WHERE slug='void') +INSERT INTO entity_links (from_type, from_id, to_type, to_id, relation) +SELECT 'project', pr.id, 'page', pg.id, 'reference' +FROM (VALUES + ('Plan: Homelab USB Auto-Sync Drive', 'USB Auto-Sync Drive'), + ('Plan: The Void — Path B Rebuild (Mastra Agent Framework)', 'Void 1.x'), + ('Plan: Homepage-style Service Cards in Sacred Valley','Void 2.0'), + ('Plan: Replace BookStack with Mercy''s Records (Option B)', 'Void 3.0') +) AS m(page_title, project_name) +JOIN pages pg ON pg.space_id=(SELECT sid FROM vd) AND pg.title=m.page_title +JOIN projects pr ON pr.space_id=(SELECT sid FROM vd) AND pr.name=m.project_name; +``` +(The two Farm plans — "Farm (won) Failure-Recovery Plan", "Farm Timelapse System — Implementation Plan" — have no matching project in The Void; they remain under the hub, unlinked. Optionally create Farm projects later.) + +- [ ] **Step 4: Confirm plans space is empty, then delete it** + +```sql +SELECT count(*) FROM pages WHERE space_id=(SELECT id FROM spaces WHERE slug='plans'); -- expect 0 +WITH p AS (SELECT id FROM spaces WHERE slug='plans') +DELETE FROM conversations WHERE space_id=(SELECT id FROM p); -- drop the placeholder convo +DELETE FROM spaces WHERE slug='plans'; +``` +Expected: count `0`, then `DELETE 1`. + +### Task 12: Decide `external-research` + +**Files:** none + +- [ ] **Step 1:** `external-research` is empty but is the bound Space for the MCP external-research agent (`reference-void-mcp-endpoint`). Check it's still in use: +```sql +SELECT a.slug, a.scopes FROM agents a WHERE a.scopes->>'space_id' = (SELECT id::text FROM spaces WHERE slug='external-research'); +``` +- [ ] **Step 2:** If an agent is bound → **keep** the space (add a one-line description page explaining its purpose). If no agent → `DELETE FROM spaces WHERE slug='external-research';`. + +--- + +## Phase 7 — Lineage cross-links + +### Task 13: Express doc lineage between The Void projects and Wiki service docs + +**Files:** none (SQL + web editor on prod) + +- [ ] **Step 1: Mediastack lineage (the user's example)** — add an evolution note + link + +In the editor, append to Wiki "Mediastack LXC (100)": +``` +> **Lineage:** First documented under Void 1. Now CT 100 on Z, Donatello/Leonardo pools, syncoid daily replica Z→Z3, and the Void-2 ingress host. See The Void › Void 2.0. +``` +Then link the Void 2.0 project → the Mediastack page: +```sql +WITH vd AS (SELECT id FROM spaces WHERE slug='void'), + pr AS (SELECT id FROM projects WHERE space_id=(SELECT id FROM vd) AND name='Void 2.0'), + w AS (SELECT id FROM spaces WHERE slug='wiki'), + pg AS (SELECT id FROM pages WHERE space_id=(SELECT id FROM w) AND title='Mediastack LXC (100)') +INSERT INTO entity_links (from_type, from_id, to_type, to_id, relation) +VALUES ('project',(SELECT id FROM pr),'page',(SELECT id FROM pg),'reference'); +``` + +- [ ] **Step 2: Repeat for the other materially-evolved services** — Ollama (GPU userspace driver + sharing), Cluster and HA (qdevice, replication). Same pattern: add a one-line lineage note in the Wiki page + an `entity_links` row from the relevant Void project. + +--- + +## Phase 8 — Verify, re-embed, finalize + +### Task 14: Final verification + +**Files:** none + +- [ ] **Step 1: Space inventory is the target shape** + +```sql +SELECT s.slug, (SELECT count(*) FROM pages p WHERE p.space_id=s.id) pages, + (SELECT count(*) FROM projects pr WHERE pr.space_id=s.id) projects +FROM spaces s ORDER BY s.slug; +``` +Expected: `bookmarks`, `void` (≈12 pages incl. retrospective+build-log children+void3 hub; ~13 projects), `wiki` (≈21 pages: root + 3 sections + 17 content), and `external-research` only if kept. No `void1`/`void3`/`plans`. + +- [ ] **Step 2: No orphaned page references** (links pointing at deleted pages) + +```sql +SELECT count(*) FROM entity_links el +WHERE el.to_type='page' AND NOT EXISTS (SELECT 1 FROM pages p WHERE p.id=el.to_id); +``` +Expected: `0`. Delete any orphans found. + +- [ ] **Step 3: Re-embed edited/moved pages so hybrid search stays accurate** + +Content edits made via the web editor already re-embed. For pages changed only by SQL (parent/position/space moves) embeddings are still valid (body unchanged). If any body was edited via SQL, run the app's embed backfill: +```bash +ssh root@192.168.1.216 'cd /opt/void-server && node lib/db/backfill_embeddings.js 2>/dev/null || echo "no backfill script — re-save the page in the editor"' +``` + +- [ ] **Step 4: UI walkthrough** — open `void.hynesy.com`: + - Wiki space: Overview at top, three sections in order, no Void-2 plan docs. + - The Void space: projects Void 1.x/2.0/3.0 + the 10 homelab projects with corrected statuses; Void 1.x retrospective with its historical children; Void 3.0 hub with the 8 audit pages; Build Log with its dev-doc children. + - Search "preserving config across upgrades" → returns the merged Ollama page. + +- [ ] **Step 5: Update the project memory** — note the consolidation done (spaces 7→3, wiki sectioned, void1/void3/plans retired) in `void-v2-roadmap` / `void-v2-execution-in-progress`, since the roadmap's "clean up old void1/void3/plans spaces" item is now complete. + +### Task 15: BookStack — minimal (per user: secondary mirror only) + +**Files:** none + +- [ ] **Step 1:** Leave BookStack content as-is (it's already Overview-first). Do **not** invest in re-chaptering. Optionally add one note to BookStack "Overview": `> Primary docs now live in The Void Wiki (void.hynesy.com). This BookStack is a secondary reference mirror.` Skip if not wanted. + +--- + +## Self-review notes +- **Spec coverage:** ordering fix (Tasks 1-3, Phase 2), dedup with lineage-aware merges (Phase 3), collapse void1 + preserve Void 1.x record (Phase 4), void3 fold (Phase 5), plans/external-research (Phase 6), lineage cross-links incl. mediastack example (Phase 7), Wiki-as-source-of-truth + BookStack demoted (Phase 2 + Task 15). All requested scope covered. +- **Destructive steps** (Tasks 5,7,8,10,11) are each gated by a verification query proving a survivor exists before delete, and the whole run is recoverable from Task 0's snapshot + pg_dump. +- **Confirm-with-user gates:** conversation deletion (Task 8/10/11), external-research deletion (Task 12), keeping plan pages browsable (Task 11). +- **Reversibility:** all Phase 1 code is additive (nullable/defaulted column); content moves are reversible from the dump.