> **Note (2026-03-24):** `index.localhost` consolidated into `hub.localhost`. The index app now runs as part of the hub.

# Consolidated index.localhost Implementation Plan

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

**Goal:** Replace the Gradio index app + present/index.html with a single NiceGUI dashboard featuring service cards, report browsing, and full-text search across ~300+ catalog items.

**Architecture:** NiceGUI app at port 7800 with three layers: (1) `registry.py` for service discovery (reused as-is), (2) new `catalog.py` that scans services/reports/skills/CLI/docs into an in-memory catalog, (3) `app.py` with dashboard + search UI. Catalog rebuilds on SIGHUP.

**Tech Stack:** NiceGUI, existing `index/registry.py`, existing `static/share.py`

**Design doc:** `docs/plans/2026-03-06-consolidated-index-design.md`

---

### Task 1: Create catalog.py — data model and search

**Files:**
- Create: `index/catalog.py`
- Reference: `index/registry.py`, `static/share.py`, `present/build_index.py`

**Step 1: Create `index/catalog.py` with CatalogEntry and search**

```python
"""Content catalog — scans all discoverable items, provides search.

Scans at startup. Rebuild on SIGHUP or after 5 minutes.
Services get live status merged at query time.
"""

from __future__ import annotations

import re
import signal
import sys
from dataclasses import dataclass, field
from datetime import datetime
from html.parser import HTMLParser
from pathlib import Path

from loguru import logger

RIVUS_ROOT = Path(__file__).parent.parent
if str(RIVUS_ROOT) not in sys.path:
    sys.path.insert(0, str(RIVUS_ROOT))

SKILLS_DIR = Path.home() / ".claude" / "skills"


@dataclass
class CatalogEntry:
    id: str                  # "svc:vario", "report:present/learning/report.html"
    title: str               # display name
    type: str                # "service", "report", "skill", "cli", "doc", "static_dir"
    description: str         # short description
    url: str | None          # clickable URL
    icon: str                # emoji
    tags: list[str] = field(default_factory=list)
    group: str = ""          # functional group
    date: datetime | None = None  # last modified (for reports)
    running: bool | None = None   # only for services


# ---------------------------------------------------------------------------
# Title extraction (from present/build_index.py)
# ---------------------------------------------------------------------------

class _TitleParser(HTMLParser):
    def __init__(self):
        super().__init__()
        self._in_title = False
        self._parts: list[str] = []
        self.title: str | None = None

    def handle_starttag(self, tag, attrs):
        if tag == "title":
            self._in_title = True

    def handle_endtag(self, tag):
        if tag == "title" and self._in_title:
            self._in_title = False
            self.title = "".join(self._parts).strip()

    def handle_data(self, data):
        if self._in_title:
            self._parts.append(data)


def _extract_title(path: Path) -> str:
    try:
        text = path.read_text(encoding="utf-8", errors="ignore")
        head_end = text.lower().find("</head>")
        if head_end > 0:
            text = text[:head_end + 7]
        parser = _TitleParser()
        parser.feed(text)
        if parser.title:
            return parser.title
    except Exception:
        pass
    return path.stem.replace("_", " ").replace("-", " ").title()


# ---------------------------------------------------------------------------
# Scanners
# ---------------------------------------------------------------------------

def _scan_services() -> list[CatalogEntry]:
    from index.registry import discover_services, GROUPS

    entries = []
    services = discover_services()
    # Build group lookup: service_name -> group_id
    svc_to_group = {}
    for gid, ginfo in GROUPS.items():
        sub_groups = ginfo.get("sub_groups")
        if sub_groups:
            for sg in sub_groups.values():
                for name in sg["services"]:
                    svc_to_group[name] = gid
        else:
            for name in ginfo.get("services", []):
                svc_to_group[name] = gid

    for svc in services:
        entries.append(CatalogEntry(
            id=f"svc:{svc.name}",
            title=f"{svc.emoji} {svc.name}" if svc.emoji else svc.name,
            type="service",
            description=svc.description,
            url=svc.domain if svc.running else None,
            icon=svc.emoji or "🔧",
            tags=[svc.name, str(svc.port), svc.kind],
            group=svc_to_group.get(svc.name, "other"),
            running=svc.running,
        ))
    return entries


def _scan_reports() -> list[CatalogEntry]:
    from static.share import find_share_files, parse_share_config

    entries = []
    share_files = find_share_files()
    index_path = (RIVUS_ROOT / "present" / "index.html").resolve()
    seen: set[Path] = set()

    for share_file in share_files:
        share_dir = share_file.parent
        for html_file in share_dir.rglob("*.html"):
            resolved = html_file.resolve()
            if resolved == index_path or resolved in seen:
                continue
            try:
                stat = resolved.stat()
                if stat.st_size < 500:
                    continue
            except OSError:
                continue

            rel_path = str(resolved.relative_to(RIVUS_ROOT))
            if "/." in rel_path or rel_path.endswith("_raw.html") or "/raw/" in rel_path:
                continue

            seen.add(resolved)
            title = _extract_title(resolved)
            mtime = datetime.fromtimestamp(stat.st_mtime)

            entries.append(CatalogEntry(
                id=f"report:{rel_path}",
                title=title,
                type="report",
                description=rel_path,
                url=f"https://static.localhost/{rel_path}",
                icon="📄",
                tags=rel_path.split("/"),
                date=mtime,
            ))

    return entries


def _scan_static_dirs() -> list[CatalogEntry]:
    from static.share import find_share_files, parse_share_config

    entries = []
    for share_file in find_share_files():
        dir_path = share_file.parent
        desc, _, _ = parse_share_config(share_file)
        rel = str(dir_path.relative_to(RIVUS_ROOT))

        entries.append(CatalogEntry(
            id=f"dir:{rel}",
            title=rel,
            type="static_dir",
            description=desc,
            url=f"https://static.localhost/{rel}/",
            icon="📁",
            tags=rel.split("/"),
        ))

    return entries


def _scan_skills() -> list[CatalogEntry]:
    entries = []
    if not SKILLS_DIR.exists():
        return entries

    for skill_dir in sorted(SKILLS_DIR.iterdir()):
        if not skill_dir.is_dir():
            continue
        # Try SKILL.md then skill.md
        skill_file = skill_dir / "SKILL.md"
        if not skill_file.exists():
            skill_file = skill_dir / "skill.md"
        if not skill_file.exists():
            continue

        name = skill_dir.name
        desc = ""
        try:
            text = skill_file.read_text()
            # Parse YAML frontmatter
            if text.startswith("---"):
                end = text.find("---", 3)
                if end > 0:
                    import yaml
                    fm = yaml.safe_load(text[3:end])
                    if isinstance(fm, dict):
                        desc = fm.get("description", "")
        except Exception:
            pass

        entries.append(CatalogEntry(
            id=f"skill:{name}",
            title=f"/{name}",
            type="skill",
            description=desc[:120] if desc else name,
            url=None,
            icon="⚡",
            tags=[name],
        ))

    return entries


# CLI tools from CLAUDE.md — stable, hardcoded
_CLI_TOOLS = [
    ("inv", "Build/run from source: start servers, tests", "inv watch.server"),
    ("ops", "Server management, warm pool, resmon, backup", "ops servers"),
    ("helm", "Session engine: auto-respond, session intel", "helm sessions list"),
    ("it2", "iTerm2 control: panes, badges, fork, jump", "it2 sessions"),
    ("appctl", "macOS window control: list, focus, screenshot", "appctl windows"),
    ("ai", "LLM server CLI: embed, call, health", "ai call grok-fast '...'"),
    ("learn", "Add learnings to knowledge DB", "learn add 'observation'"),
    ("autodo", "Autonomous work queue", "autodo list"),
    ("jobctl", "Job control: list, status, run, pause", "jobctl list"),
    ("intel", "Entity intelligence: people + company dossiers", "intel people lookup"),
    ("draft", "Writing analysis: style eval, improve", "draft improve FILE"),
]


def _scan_cli_tools() -> list[CatalogEntry]:
    return [
        CatalogEntry(
            id=f"cli:{name}",
            title=name,
            type="cli",
            description=desc,
            url=None,
            icon="🔨",
            tags=[name, example],
        )
        for name, desc, example in _CLI_TOOLS
    ]


def _scan_docs() -> list[CatalogEntry]:
    entries = []
    for md in RIVUS_ROOT.glob("**/CLAUDE.md"):
        # Skip worktrees, node_modules, .git
        rel = str(md.relative_to(RIVUS_ROOT))
        if any(skip in rel for skip in [".claude/worktrees", "node_modules", ".git"]):
            continue

        project = md.parent.name if md.parent != RIVUS_ROOT else "rivus"
        entries.append(CatalogEntry(
            id=f"doc:{rel}",
            title=f"{project} docs",
            type="doc",
            description=f"Project instructions: {rel}",
            url=None,
            icon="📖",
            tags=[project, "docs", "claude"],
        ))

    return entries


# ---------------------------------------------------------------------------
# Catalog
# ---------------------------------------------------------------------------

_catalog: list[CatalogEntry] = []
_catalog_built_at: float = 0
_rebuild_requested = False


def _handle_hup(signum, frame):
    global _rebuild_requested
    _rebuild_requested = True
    logger.info("SIGHUP received — catalog rebuild requested")


signal.signal(signal.SIGHUP, _handle_hup)


def build_catalog() -> list[CatalogEntry]:
    """Scan all sources and build the catalog."""
    global _catalog, _catalog_built_at, _rebuild_requested
    _rebuild_requested = False

    entries = []
    for scanner_name, scanner in [
        ("services", _scan_services),
        ("reports", _scan_reports),
        ("static_dirs", _scan_static_dirs),
        ("skills", _scan_skills),
        ("cli_tools", _scan_cli_tools),
        ("docs", _scan_docs),
    ]:
        try:
            items = scanner()
            entries.extend(items)
            logger.info(f"Catalog: {scanner_name} -> {len(items)} entries")
        except Exception as e:
            logger.error(f"Catalog scan {scanner_name} failed: {e}")

    _catalog = entries
    _catalog_built_at = datetime.now().timestamp()
    logger.info(f"Catalog built: {len(entries)} total entries")
    return entries


def get_catalog() -> list[CatalogEntry]:
    """Get current catalog, rebuilding if stale or requested."""
    import time
    if _rebuild_requested or not _catalog or (time.time() - _catalog_built_at > 300):
        build_catalog()
    return _catalog


# ---------------------------------------------------------------------------
# Search
# ---------------------------------------------------------------------------

def search(query: str, limit: int = 30) -> list[CatalogEntry]:
    """Weighted substring search across catalog.

    Scoring: title-starts-with=100, title-contains=60, desc-contains=30, tag-match=20.
    Multi-word: AND logic, minimum score across terms.
    """
    catalog = get_catalog()
    if not query or not query.strip():
        return []

    terms = query.lower().split()
    scored: list[tuple[float, CatalogEntry]] = []

    for entry in catalog:
        title_lower = entry.title.lower()
        desc_lower = entry.description.lower()
        tags_lower = [t.lower() for t in entry.tags]

        min_score = float("inf")
        for term in terms:
            score = 0
            if title_lower.startswith(term):
                score = max(score, 100)
            elif term in title_lower:
                score = max(score, 60)
            if term in desc_lower:
                score = max(score, 30)
            if any(term in tag for tag in tags_lower):
                score = max(score, 20)
            min_score = min(min_score, score)

        if min_score > 0 and min_score != float("inf"):
            scored.append((min_score, entry))

    scored.sort(key=lambda x: (-x[0], x[1].title))
    return [entry for _, entry in scored[:limit]]
```

**Step 2: Verify catalog builds**

Run:
```bash
cd /Users/tchklovski/all-code/rivus
python -c "from index.catalog import build_catalog; entries = build_catalog(); print(f'{len(entries)} entries'); [print(f'  {e.type}: {e.title}') for e in entries[:10]]"
```

Expected: 200+ entries, mix of types.

**Step 3: Verify search works**

Run:
```bash
python -c "
from index.catalog import build_catalog, search
build_catalog()
for q in ['vario', 'learn', 'supply', 'ops']:
    results = search(q)
    print(f'{q}: {len(results)} results — {[r.title for r in results[:3]]}')"
```

Expected: Each query returns relevant results.

**Step 4: Commit**

```bash
git add index/catalog.py
git commit -m "feat(index): content catalog with search across services/reports/skills/CLI/docs"
```

---

### Task 2: Rewrite app.py — NiceGUI dashboard

**Files:**
- Rewrite: `index/app.py`
- Reference: `index/registry.py`, `index/catalog.py`

**Step 1: Rewrite `index/app.py` with NiceGUI**

```python
#!/usr/bin/env python
"""Consolidated service index — NiceGUI.

Start: python index/app.py
URL: https://index.localhost (port 7800)

Dashboard with service cards, recent reports, tools reference, and full-text search.
"""

from __future__ import annotations

import subprocess
import sys
import time
from pathlib import Path

from loguru import logger

RIVUS_ROOT = Path(__file__).parent.parent
sys.path.insert(0, str(RIVUS_ROOT))

from nicegui import app, ui

from index.catalog import CatalogEntry, build_catalog, get_catalog, search
from index.registry import discover_services, GROUPS, SERVICE_METADATA, TIER1_SERVICES

# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------

TMUX = "/opt/homebrew/bin/tmux"
OPS = "/Users/tchklovski/.local/bin/ops"

# Type badge colors
TYPE_COLORS = {
    "service": "#6366f1",
    "report": "#0ea5e9",
    "skill": "#f59e0b",
    "cli": "#10b981",
    "doc": "#8b5cf6",
    "static_dir": "#64748b",
}

TYPE_LABELS = {
    "service": "Service",
    "report": "Report",
    "skill": "Skill",
    "cli": "CLI",
    "doc": "Docs",
    "static_dir": "Directory",
}

# ---------------------------------------------------------------------------
# Service management
# ---------------------------------------------------------------------------


def _find_or_create_tmux_pane() -> tuple[str | None, str]:
    try:
        result = subprocess.run(
            ["tmux-idle-pane", "-q"],
            capture_output=True, text=True, timeout=10,
        )
        if result.returncode == 0 and result.stdout.strip():
            return result.stdout.strip(), "reused idle pane"
    except Exception as e:
        logger.warning(f"Failed to find idle tmux pane: {e}")

    try:
        subprocess.run(
            [TMUX, "split-window", "-t", "servers", "-h", "-c", str(RIVUS_ROOT)],
            capture_output=True, timeout=5,
        )
        subprocess.run(
            [TMUX, "select-layout", "-t", "servers", "tiled"],
            capture_output=True, timeout=5,
        )
        time.sleep(1.5)
        result = subprocess.run(
            ["tmux-idle-pane", "-q"],
            capture_output=True, text=True, timeout=10,
        )
        if result.returncode == 0 and result.stdout.strip():
            return result.stdout.strip(), "created new pane"
    except Exception as e:
        logger.warning(f"Failed to create tmux pane: {e}")

    return None, "failed to find or create tmux pane"


async def start_service(name: str, start_cmd: str):
    pane_id, how = _find_or_create_tmux_pane()
    if not pane_id:
        ui.notify(f"Could not find tmux pane: {how}", type="negative")
        return
    subprocess.run(
        ["it2", "send-text", pane_id, f"{start_cmd}\n"],
        capture_output=True, timeout=5,
    )
    ui.notify(f"Started {name} ({how})", type="positive")


async def stop_service(name: str):
    try:
        result = subprocess.run(
            [OPS, "stop", name],
            capture_output=True, text=True, timeout=15,
        )
        if result.returncode == 0:
            ui.notify(f"Stopped {name}", type="positive")
        else:
            ui.notify(f"Failed to stop {name}: {result.stderr.strip()}", type="negative")
    except Exception as e:
        ui.notify(f"Error stopping {name}: {e}", type="negative")


# ---------------------------------------------------------------------------
# UI components
# ---------------------------------------------------------------------------


def _service_card(svc) -> None:
    """Render a single service card."""
    meta = SERVICE_METADATA.get(svc.name, {})
    is_tier1 = svc.name in TIER1_SERVICES
    subs = meta.get("subs", [])

    with ui.card().classes("w-full p-3" + (" border-2 border-indigo-400" if is_tier1 else "")):
        with ui.row().classes("items-center gap-2 w-full"):
            # Status dot
            color = "green" if svc.running else "gray"
            ui.icon("circle").classes(f"text-xs text-{color}-500")

            # Name (clickable if running)
            label = f"{svc.emoji} {svc.name}" if svc.emoji else svc.name
            if svc.running:
                ui.link(label, svc.domain, new_tab=True).classes(
                    "font-semibold no-underline hover:underline"
                )
            else:
                ui.label(label).classes("font-semibold opacity-50")

            ui.label(f":{svc.port}").classes("text-xs opacity-40")
            ui.space()

            # Start/stop buttons
            if svc.running:
                ui.button("stop", on_click=lambda n=svc.name: stop_service(n)).props(
                    "flat dense size=xs color=red"
                )
            elif svc.start_cmd:
                ui.button("start", on_click=lambda n=svc.name, c=svc.start_cmd: start_service(n, c)).props(
                    "flat dense size=xs color=primary"
                )

        ui.label(svc.description).classes("text-sm opacity-60")

        if subs:
            ui.label(" · ".join(subs)).classes("text-xs opacity-40")


def _render_services(container) -> None:
    """Render the services section into the given container."""
    container.clear()
    services = discover_services()
    svc_by_name = {s.name: s for s in services}

    with container:
        for gid, ginfo in GROUPS.items():
            sub_groups = ginfo.get("sub_groups")
            if sub_groups:
                all_names = []
                for sg in sub_groups.values():
                    all_names.extend(sg["services"])
            else:
                all_names = ginfo.get("services", [])

            group_svcs = [svc_by_name[n] for n in all_names if n in svc_by_name]
            if not group_svcs:
                continue

            with ui.expansion(
                f"{ginfo['emoji']} {ginfo['label']} — {ginfo['desc']}",
                value=True,
            ).classes("w-full"):
                with ui.grid(columns=2).classes("w-full gap-2"):
                    for svc in group_svcs:
                        _service_card(svc)


def _render_recent_reports(container, limit: int = 12) -> None:
    """Render recent reports section."""
    container.clear()
    catalog = get_catalog()
    reports = [e for e in catalog if e.type == "report" and e.date]
    reports.sort(key=lambda e: e.date, reverse=True)
    reports = reports[:limit]

    with container:
        if not reports:
            ui.label("No reports found").classes("opacity-50")
            return

        for r in reports:
            date_str = r.date.strftime("%Y-%m-%d") if r.date else ""
            with ui.row().classes("items-center gap-2 w-full py-1"):
                ui.link(r.title, r.url, new_tab=True).classes(
                    "no-underline hover:underline text-sm"
                )
                ui.space()
                ui.label(date_str).classes("text-xs opacity-40 shrink-0")
            ui.label(r.description).classes("text-xs opacity-30 -mt-1 ml-0")

        total = len([e for e in catalog if e.type == "report"])
        if total > limit:
            ui.label(f"+ {total - limit} more reports").classes(
                "text-xs opacity-40 mt-2"
            )


def _render_tools(container) -> None:
    """Render tools & reference section."""
    container.clear()
    catalog = get_catalog()

    with container:
        for type_key, type_label in [("skill", "Skills"), ("cli", "CLI Tools"), ("doc", "Docs")]:
            items = [e for e in catalog if e.type == type_key]
            if not items:
                continue

            ui.label(type_label).classes("font-semibold text-sm opacity-60 mt-2")
            with ui.grid(columns=3).classes("w-full gap-1"):
                for item in items:
                    with ui.row().classes("items-center gap-1"):
                        ui.label(item.icon).classes("text-xs")
                        name = ui.label(item.title).classes("text-sm")
                        name.tooltip(item.description)


def _render_search_results(container, query: str) -> None:
    """Render search results grouped by type."""
    container.clear()
    results = search(query)

    with container:
        if not results:
            ui.label(f'No results for "{query}"').classes("opacity-50 py-4")
            return

        # Group by type
        grouped: dict[str, list[CatalogEntry]] = {}
        for entry in results:
            grouped.setdefault(entry.type, []).append(entry)

        for type_key in ["service", "report", "static_dir", "skill", "cli", "doc"]:
            items = grouped.get(type_key)
            if not items:
                continue

            color = TYPE_COLORS.get(type_key, "#666")
            label = TYPE_LABELS.get(type_key, type_key)
            ui.label(f"{label} ({len(items)})").classes("font-semibold text-sm mt-3").style(
                f"color: {color}"
            )

            for item in items:
                with ui.row().classes("items-center gap-2 py-1"):
                    ui.label(item.icon).classes("text-sm")
                    if item.url:
                        ui.link(item.title, item.url, new_tab=True).classes(
                            "no-underline hover:underline text-sm"
                        )
                    else:
                        ui.label(item.title).classes("text-sm")
                    ui.label(item.description[:80]).classes("text-xs opacity-40")


# ---------------------------------------------------------------------------
# Page
# ---------------------------------------------------------------------------

@ui.page("/")
def index_page():
    ui.dark_mode(True)

    # Header
    with ui.row().classes("w-full items-center py-4 px-2"):
        ui.label("Rivus").classes("text-2xl font-bold")
        ui.space()
        search_input = ui.input(placeholder="Search services, reports, skills...").props(
            "dense outlined clearable"
        ).classes("w-80")

    # Dashboard container (hidden during search)
    dashboard = ui.column().classes("w-full gap-4")

    # Search results container (hidden when not searching)
    search_results = ui.column().classes("w-full")
    search_results.set_visibility(False)

    # Build dashboard sections
    with dashboard:
        # Services
        services_container = ui.column().classes("w-full")

        # Recent Reports
        with ui.expansion("📄 Recent Reports", value=True).classes("w-full"):
            reports_container = ui.column().classes("w-full")

        # Tools & Reference
        with ui.expansion("🔧 Tools & Reference", value=False).classes("w-full"):
            tools_container = ui.column().classes("w-full")

    # Initial render
    _render_services(services_container)
    _render_recent_reports(reports_container)
    _render_tools(tools_container)

    # Search handler
    def on_search(e):
        query = e.value.strip() if e.value else ""
        if query:
            dashboard.set_visibility(False)
            search_results.set_visibility(True)
            _render_search_results(search_results, query)
        else:
            search_results.set_visibility(False)
            dashboard.set_visibility(True)

    search_input.on("update:model-value", on_search)

    # Auto-refresh services every 5s
    ui.timer(5.0, lambda: _render_services(services_container))


# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------

def main():
    # Build catalog at startup
    build_catalog()
    logger.info("Index server starting on port 7800")

    ui.run(
        port=7800,
        title="Rivus",
        favicon="🌊",
        dark=True,
        reload=False,
        show=False,
        message_history_length=0,
    )


if __name__ == "__main__":
    main()
```

**Step 2: Test the app starts**

Run:
```bash
cd /Users/tchklovski/all-code/rivus
python index/app.py &
sleep 3
curl -s http://localhost:7800 | head -20
kill %1
```

Expected: HTML response from NiceGUI.

**Step 3: Commit**

```bash
git add index/app.py
git commit -m "feat(index): rewrite to NiceGUI — dashboard + search"
```

---

### Task 3: Update Caddy config

**Files:**
- Modify: `infra/Caddyfile` (the `index.localhost` block)

**Step 1: Update Caddyfile**

Change the `index.localhost` block from:
```
# 🌊 index - content index (present/index.html via static server)
index.localhost {
	@root path /
	rewrite @root /present/index.html
	reverse_proxy localhost:7940
	import tls90d
}
```

To:
```
# 🌊 index - consolidated dashboard (NiceGUI)
index.localhost {
	reverse_proxy localhost:7800
	import tls90d
}
```

Also update the `:7800` block similarly:
```
# 🌊 index - HTTP listener for cloudflared tunnel (index.jott.ninja -> :7800)
:7800 {
	reverse_proxy localhost:7800
}
```

Wait — `:7800` can't proxy to itself. Check what port the NiceGUI app actually runs on. If Caddy is already listening on 7800, the NiceGUI app needs a different port (e.g., 7801 internal, Caddy 7800 external). OR remove the `:7800` Caddy block and let NiceGUI serve directly.

**Decision:** NiceGUI listens on 7800. Caddy `index.localhost` proxies to 7800. Remove the old `:7800` Caddy block (it was for the static rewrite; cloudflared tunnel for `index.jott.ninja` can point to 7800 directly).

Updated Caddyfile changes:
- Replace `index.localhost` block with `reverse_proxy localhost:7800`
- Remove the `:7800 { ... }` block entirely

**Step 2: Reload Caddy**

```bash
caddy reload --config /opt/homebrew/etc/Caddyfile
```

**Step 3: Verify**

```bash
curl -sI https://index.localhost | head -5
```

Expected: 200 OK from NiceGUI (not a redirect to present/index.html).

**Step 4: Commit**

```bash
git add infra/Caddyfile
git commit -m "chore(caddy): point index.localhost to NiceGUI app on :7800"
```

---

### Task 4: Clean up dead code

**Files:**
- Delete: `present/build_index.py`
- Delete: `present/index.html`

**Step 1: Delete dead files**

```bash
trash present/build_index.py
trash present/index.html
```

**Step 2: Check for references**

Search for `build_index` and `present/index.html` in the codebase to update any remaining references (Makefile, inv tasks, docs).

```bash
rg -i "build_index|present/index" --type py --type md --type yaml
```

Update any found references.

**Step 3: Commit**

```bash
git add -A
git commit -m "chore: remove present/build_index.py and present/index.html (folded into index catalog)"
```

---

### Task 5: Manual smoke test

**Step 1: Start the app**

```bash
python index/app.py
```

**Step 2: Verify in browser**

Open `https://index.localhost` and check:

1. Service cards show with correct running/stopped status
2. Click a running service -> opens in new tab
3. Start/stop buttons work
4. Recent Reports section shows ~10 reports with dates
5. Tools & Reference section expands to show skills/CLI/docs
6. Type "vario" in search -> shows vario service + related reports
7. Type "learn" in search -> shows learning service + /learn skill + learning reports
8. Clear search -> dashboard returns
9. Wait 5s -> service status refreshes

**Step 3: Final commit with any fixes**

```bash
git add -A
git commit -m "fix(index): smoke test fixes"
```
