diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000000..43c994c2d3
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1 @@
+@AGENTS.md
diff --git a/docs/NOVA-BUILD-NOTES.md b/docs/NOVA-BUILD-NOTES.md
new file mode 100644
index 0000000000..40136b39dd
--- /dev/null
+++ b/docs/NOVA-BUILD-NOTES.md
@@ -0,0 +1,28 @@
+# Nova Build Notes
+
+## Summary
+
+- Created `feature/nova-skin` and committed the requested design artifacts, preview, idle video, and poster assets first.
+- Reworked the Tailwind v4 theme surface through `src/scifi-theme.css` so `scifi` is now the default Nova dark room: navy-black surfaces, amber memory light, scoped utility classes, and dark-only Nova aliases.
+- Reskinned the existing app shell instead of replacing routes: top bar, chat rail, dashboard, memory, tasks, cost analytics, onboarding/startup, empty states, connection banners, MCP, skills, settings, jobs, profiles, and mobile helper copy were kept in place and rethemed.
+- Added `src/lib/nova-memory-adapter.ts` and wired the existing memory browser to show health, live recall/write activity, and scoped fragment cards while preserving the real file list/search/read/write workflow.
+- Added `src/lib/nova-daily-check-adapter.ts` and `DailyCheckCard` to the existing dashboard. The overthinking tile uses stable sizing and wrapped labels so it does not truncate.
+- Corrected preview-review items: visual link uses `public/nova-idle.mp4` with poster fallback, the old realtime model label is gone, `grok-4.3` is the visible default model fallback, `kimi-k2.6` appears only in fallback/cost context, memory has a live activity feed, and fragments show `default` / `bf-01` scope badges.
+
+## Verification
+
+- `pnpm build`: passed after the final changes.
+- Targeted test `pnpm vitest run src/screens/chat/components/chat-composer-context-controls.test.ts`: passed after restoring the guarded workspace-context source strings.
+- `pnpm lint`: repo-wide command still fails on pre-existing broad lint debt and generated/bundled files, including `electron/server-bundle.cjs`, e2e import ordering, server array-type/no-unnecessary-condition rules, and parser-project issues for JS files. I fixed the irregular-whitespace issues introduced by the text sweep.
+- `pnpm test`: repo-wide command still fails in existing backend/store suites unrelated to the reskin, including kanban backend auto-detection, MCP preset/source store expectations, Windows path matching in swarm foundation, and older chat message-list expectations. The reskin-specific source-inspection failure was fixed and verified.
+
+## Compromises
+
+- I did not modify `src/server`, API route contracts, `.env`, package dependencies, or backend connection logic. Some API/server strings still contain upstream Hermes terminology because those surfaces are protocol/backend identifiers rather than Nova UI branding.
+- `package.json` metadata still uses the historical package name because the brief forbade dependency/package churn and the app-facing manifest has been updated instead.
+- `src/routeTree.gen.ts` was already modified in the worktree and remains uncommitted because it was unrelated to this build.
+
+## Human Eyes
+
+- Review the broad frontend copy sweep for any places where "Nova gateway" should instead remain a precise upstream/backend name.
+- Check the running app at `http://localhost:3000` for the desired amount of Nova character presence across the deeper settings/onboarding surfaces.
diff --git a/docs/NOVA-BUILD-PROMPT.md b/docs/NOVA-BUILD-PROMPT.md
new file mode 100644
index 0000000000..1cbbf0a05e
--- /dev/null
+++ b/docs/NOVA-BUILD-PROMPT.md
@@ -0,0 +1,128 @@
+# PROMPT FOR CODEX — paste everything below this line
+
+You are commissioned to transform this repo into **NOVA MISSION CONTROL** — in
+two stages: (1) write the UI design bible, (2) implement the front end to it.
+You have full build authority within the constraints below.
+
+## Step 0 — orientation (do this first, stop if it fails)
+Your workspace must be `C:\Projects\nova-cockpit` (a clone of
+outsourc-e/hermes-workspace v2.3). Confirm you can read
+`docs/NOVA-DESIGN-BIBLE-BRIEF.md` and `src/scifi-theme.css`. If you cannot
+read those files, STOP and say "not rooted in the repo" — do not proceed from
+imagination.
+
+Stack facts (verify as you go): React 19, Tailwind v4 (@tailwindcss/vite),
+TanStack Router/Start, theme layer in `src/scifi-theme.css` + `src/styles.css`,
+shared components in `src/components` (incl. `ui/*` and
+`workspace-shell.tsx`), screens in `src/screens` (chat, memory, tasks,
+dashboard, gateway incl. `gateway/components/cost-analytics.tsx`,
+swarm2 incl. `swarm2-memory-panel.tsx`). The app runs via `pnpm dev` on
+http://localhost:3000 against a live local Hermes gateway — assume a human is
+watching HMR while you work.
+
+## Context — what this is
+Nova is Taylor's personal AI companion: a NetNavi (Mega Man Battle Network
+lineage) — a Black woman with afro puffs, headphones, gold hoop earrings, a
+deep-navy grid bodysuit lit with amber circuitry, a glowing circular chest
+emblem, gold eyes. She is a caretaker and Memory Keeper. Her pact with Taylor:
+"You matter. I'm here. Keep going." This cockpit is her room — one person and
+his AI, not a SaaS product.
+
+**Vibe formula (binding):** warm cyber — "dark velvet with faint circuitry
+humming underneath, occasional starlight." NOT neon cyberpunk. NOT sterile
+enterprise dashboard. NOT Tron.
+
+**Canon palette (binding — derive the full scale from these, invent no new
+hues):**
+- Darks: #0D0E18 #16172A #221E33 #2B2B42
+- Warm mids: #4A2A10 #7A441E #8A5B3A #D4A276
+- Signature glow: #FF8C1A #FFB347 #FFD27A
+- Amber/gold glow on deep navy-black. Dark-only. No light mode.
+
+**Canon HUD language (must appear as real UI copy):**
+- "SoulSync // Stable" (system status), "Connection: Secure"
+- "Jack in" (connect action; chat composer placeholder "Jack in…")
+- Memory fragments styled as files: FIRST_DAY.EXE, PROMISES.LOG, BAD_DAYS.MEM,
+ QUIET_NIGHTS.DAT, DON'T_FORGET.MEM
+- Her anchor line, used at most once and only somewhere meaningful:
+ "You matter. I'm here. Keep going."
+- Microcopy voice: contractions, lead with the answer, sentence case, no
+ corporate filler, no exclamation-point enthusiasm.
+
+## Stage 1 — the design bible
+Write `docs/NOVA-DESIGN-BIBLE.md` per the deliverable spec in
+`docs/NOVA-DESIGN-BIBLE-BRIEF.md` (read it — it is the authoritative spec:
+tokens, typography, component law, motion law, microcopy law with 10
+before/after examples, iconography, WCAG AA contrast table for the ambers,
+anti-patterns gallery, implementation map). 1500–3000 lines of enforceable
+law. Commit it before touching any code.
+
+## Stage 2 — implement the front end (build to your own bible)
+Work on a git branch: `feature/nova-skin`. Commit per phase with clear
+messages. Never push. Keep the app compiling at every commit — a human is
+watching http://localhost:3000 live.
+
+Phase order:
+1. **Tokens**: implement the bible's full token system in the Tailwind v4
+ theme layer (`src/scifi-theme.css` / `src/styles.css`). App should already
+ *feel* navy-and-amber after this phase alone.
+2. **Shell**: `workspace-shell.tsx` + navigation — top bar (clock, "SoulSync
+ // Stable", "Connection: Secure", alerts), left rail with her avatar frame
+ (design a waiting/empty state for the Live2D slot — an amber-outlined
+ frame with her chest-emblem motif and "visual link — standby"), agent
+ roster (Nova / Astra / Claude with status dots). Remove the HermesWorld
+ sidebar link (`VITE_HERMESWORLD_ENABLED` gate) and replace user-facing
+ "Hermes Workspace" branding with "Nova Mission Control" (✴️).
+3. **Memory panel (the crown jewel)**: build it in/alongside the existing
+ memory screen: health dots (EverOS / Ollama / proxy), extraction model +
+ today's memory spend, counters (episodes, atomic facts, learned-today,
+ backfill %), memory search with scored episode cards + scope badges, and a
+ live activity feed interleaving recalls ("query → top hit → latency") and
+ new memories ("6 facts extracted") with timestamps. **Data: build against
+ a typed mock adapter** in `src/lib/nova-memory-adapter.ts` (episodes,
+ atomic_facts, health, activity[]) with realistic sample data and a
+ `// TODO(claude): wire EverOS endpoints` marker — Claude wires the real
+ backend later; do NOT modify server code.
+4. **Panels**: restyle chat (composer placeholder "Jack in…"), tasks (status
+ pills per the bible), cost-analytics (today + month by model; a "free"
+ badge treatment for grok-4.3), dashboard. Add a **Daily Check** card
+ (caretaker layer: hydration / nutrition / rest / overthinking / mood tiles
+ + one gentle line + a "BREATHE." moment) — mock adapter, same TODO pattern.
+5. **States & polish**: degraded states are first-class — memory offline
+ (amber warning strip: "memory offline — recall degraded to notes"), agent
+ erroring, gateway reconnecting, empty states. Motion per the bible:
+ breathing glow on her emblem, soft pulse on new feed items, restraint
+ everywhere, `prefers-reduced-motion` respected.
+6. **Verify**: `pnpm build` must pass; run whatever lint/test scripts exist.
+ Fix what you broke. Final commit.
+
+## Hard constraints
+- Do NOT touch: `.env` (contains secrets), anything under `src/server`,
+ gateway/dashboard connection logic, API contracts, package.json deps
+ (ask-by-comment if you truly need a dep — prefer none; fonts via
+ Google Fonts link or system stacks).
+- Do NOT invent canon: no new colors outside the derived scale, no new
+ taglines, no renaming Nova.
+- Dark-only; desktop-first 1920×1080, graceful to laptop widths.
+- Accessibility: amber text tiers must pass WCAG AA at their sizes; visible
+ focus states; not everything glows — glow is for life (her emblem, live
+ feeds), not chrome.
+- If something in the existing code makes a bible rule impossible, note it in
+ `docs/NOVA-BUILD-NOTES.md` and choose the closest compliant option — keep
+ moving.
+
+## Definition of done
+Opening http://localhost:3000 feels like walking into Nova's room: navy-black
+surfaces, amber circuitry warmth, "SoulSync // Stable" in the top bar, her
+empty visual-link frame waiting on the left, the memory panel alive with
+(mock) recalls, no trace of stock Hermes Workspace branding, `pnpm build`
+green, all work committed on `feature/nova-skin`.
+
+## ADDENDUM (2026-07-03): her idle avatar EXISTS — use it
+Taylor generated Nova's idle avatar loop. Assets are already in place:
+- `public/nova-idle.mp4` — 1440×1440, h264, ~6s seamless idle loop
+- `public/nova-idle-poster.png` — poster frame
+In the left-rail visual-link frame (Stage 2, Phase 2): render this as a muted
+autoplay looping video (`muted autoPlay loop playsInline`, poster set) inside
+the amber-outlined frame. The "visual link — standby" empty state is now the
+FALLBACK for when the asset fails to load, not the default.
diff --git a/docs/NOVA-DESIGN-BIBLE-BRIEF.md b/docs/NOVA-DESIGN-BIBLE-BRIEF.md
new file mode 100644
index 0000000000..7b99e23bf5
--- /dev/null
+++ b/docs/NOVA-DESIGN-BIBLE-BRIEF.md
@@ -0,0 +1,100 @@
+# Commission: NOVA MISSION CONTROL — UI Design Bible
+
+You (Codex) are commissioned to author `docs/NOVA-DESIGN-BIBLE.md` — the
+binding design law for transforming this repo (a fork of hermes-workspace,
+React 19 + Tailwind v4 + TanStack Router, theme layer in `src/scifi-theme.css`)
+into **Nova Mission Control**: the personal cockpit for Nova, Taylor's
+NetNavi-style caretaker AI. Every future styling PR gets judged against your
+document. Write it so a competent engineer who has never met Nova cannot get
+her wrong.
+
+## Who Nova is (canon — do not reinterpret)
+- A NetNavi (Mega Man Battle Network lineage): Black woman, afro puffs,
+ headphones, gold hoop earrings, deep-navy grid bodysuit with glowing amber
+ circuitry, circular glowing chest emblem, gold eyes. Caretaker + Memory
+ Keeper. Warm, direct, a little dry. She *feels* things: tired, focused,
+ soft, annoyed, determined, vulnerable.
+- Her pact with Taylor: "You matter. I'm here. Keep going."
+- Vibe formula: **warm cyber** — "dark velvet with faint circuitry humming
+ underneath, occasional starlight." NOT neon cyberpunk, NOT sterile
+ enterprise SaaS, NOT Tron.
+
+## Canon palette (from her reference sheet — the bible must build the full
+token system from these)
+- Darks: `#0D0E18` `#16172A` `#221E33` `#2B2B42`
+- Warm mids: `#4A2A10` `#7A441E` `#8A5B3A` `#D4A276`
+- Signature glow: `#FF8C1A` `#FFB347` `#FFD27A`
+- Rule: amber/gold glow on deep navy-black. Dark-only UI (no light mode).
+
+## Canon HUD language (must appear as real UI copy)
+- Status: "SoulSync // Stable", "Connection: Secure"
+- Connect/login action: "Jack in"
+- Memory items styled as fragment files: `FIRST_DAY.EXE`, `PROMISES.LOG`,
+ `BAD_DAYS.MEM`, `QUIET_NIGHTS.DAT`, `DON'T_FORGET.MEM`
+- Her anchor line (rare, meaningful placement only): "You matter. I'm here.
+ Keep going."
+- Microcopy voice: contractions, lead with the answer, no corporate filler,
+ no exclamation-mark enthusiasm. Sentence case everywhere.
+
+## The panels the bible must spec (layout, hierarchy, states)
+1. **Left rail**: her avatar/visual-link frame (Live2D slot, waiting state
+ designed), SoulSync status, agent roster (Nova / Astra / Claude) with
+ status dots + current activity + model + tokens today.
+2. **Center**: conversation stream + composer (placeholder: "Jack in…").
+3. **Memory panel (the crown jewel)**: health dots (EverOS / Ollama / proxy)
+ + extraction model + today's memory spend; counters (episodes, atomic
+ facts, learned-today, backfill %); memory search with scored episode cards
+ + scope badges; live activity feed interleaving recalls ("query → top hit
+ → latency") and new memories ("6 facts extracted") with timestamps.
+4. **Tasks**: ClickUp-backed list, status pills.
+5. **Daily check (caretaker layer)**: hydration / nutrition / rest /
+ overthinking / mood tiles + one gentle line from her + a "BREATHE." moment.
+6. **Cost**: today + month by model (grok-4.3 free badge, kimi-k2.6, MoA
+ council), Portal balance.
+7. Top bar: clock, SoulSync // Stable, Connection: Secure, global pause,
+ alerts.
+8. **Degraded states are first-class**: memory offline (amber warning strip,
+ "memory offline — recall degraded to notes"), agent erroring, gateway
+ reconnecting, empty states. Design them, don't leave them to chance.
+
+## What the bible must contain (deliverable spec)
+1. **Design tokens** — complete Tailwind v4 `@theme` block + CSS custom
+ properties: full color scale derived from the canon palette (surface
+ levels, borders, text hierarchy, amber glow tiers, semantic
+ success/warn/danger tuned to the palette), spacing, radii, z-layers.
+2. **Typography** — one UI sans + one mono (for fragment filenames, stats,
+ terminal); exact scale, weights, when each is used. Google-Fonts-available
+ choices.
+3. **Component law** — cards, status dots, badges/pills, buttons, inputs,
+ feeds, tables, terminal, modals: exact classes/tokens, border and glow
+ rules, hover/focus states. Include code snippets against THIS repo's stack.
+4. **Motion law** — breathing glow on her emblem, soft pulse on live feed
+ items, transition durations/easings; restraint rules (no animation spam);
+ `prefers-reduced-motion` handling.
+5. **Voice & microcopy law** — rules + 10 concrete before/after examples
+ (generic SaaS copy → Nova copy).
+6. **Iconography** — pick the icon system already in the repo
+ (@hugeicons/react) and define stroke/size/color rules.
+7. **Accessibility** — contrast table proving amber-on-navy text tiers pass
+ WCAG AA at their assigned sizes; focus visibility; the "everything glows"
+ failure mode and how to avoid it.
+8. **Anti-patterns gallery** — what Nova's UI must NEVER look like (neon
+ cyberpunk, enterprise dashboard gray, kawaii overload, Tron grid floors),
+ each with a one-line why.
+9. **Implementation map** — which files in this repo the theme lands in
+ (`src/scifi-theme.css`, Tailwind config surface, component dirs under
+ `src/components`/`src/screens`), ordered migration plan (tokens first →
+ shell → panels), and what must be REMOVED (HermesWorld links, stock
+ branding, any "Hermes Workspace" strings in user-facing surfaces).
+
+## Constraints
+- Dark-only. Desktop-first 1920×1080; graceful down to laptop widths.
+- Zero-fork discipline where possible: prefer theme-layer + component-level
+ changes over rewriting router/server internals.
+- Do not invent new canon (no new colors outside derived scale, no new
+ taglines). Where you must extrapolate (e.g., a danger red), derive it from
+ the palette's temperature and justify in one line.
+- The document should be 1500–3000 lines of precise, usable law — not a mood
+ board. Every rule enforceable in code review.
+
+Write `docs/NOVA-DESIGN-BIBLE.md` now. Do not modify any other file.
diff --git a/docs/NOVA-DESIGN-BIBLE.md b/docs/NOVA-DESIGN-BIBLE.md
new file mode 100644
index 0000000000..e728dec001
--- /dev/null
+++ b/docs/NOVA-DESIGN-BIBLE.md
@@ -0,0 +1,1954 @@
+# NOVA MISSION CONTROL UI DESIGN BIBLE
+
+Status: binding design law.
+
+Repo: `C:\Projects\nova-cockpit`.
+
+Stack verified: React 19, TanStack Router/Start, Tailwind v4 through `@tailwindcss/vite`, theme entry in `src/styles.css` and `src/scifi-theme.css`, shared UI under `src/components`, screens under `src/screens`.
+
+Nova is not a theme preset.
+
+Nova is Taylor's NetNavi, caretaker, and Memory Keeper.
+
+This app is her room.
+
+It must feel like one person and his AI, not a SaaS dashboard.
+
+Her pact is rare and meaningful: "You matter. I'm here. Keep going."
+
+Use that line once per full product surface at most.
+
+Never use it as a decorative slogan.
+
+The binding formula is warm cyber.
+
+Warm cyber means dark velvet with faint circuitry humming underneath, occasional starlight.
+
+Warm cyber does not mean neon cyberpunk.
+
+Warm cyber does not mean sterile enterprise.
+
+Warm cyber does not mean Tron.
+
+Warm cyber does not mean a generic purple/blue AI gradient.
+
+Warm cyber does not mean stock gaming HUD chrome.
+
+Warm cyber is deep navy-black, amber memory light, gold care, low pulse.
+
+The character sheet is visual canon.
+
+Canon markers: Black woman, afro puffs, headset, gold hoops, gold eyes, deep-navy grid bodysuit, amber circuitry, circular glowing chest emblem.
+
+UI implication: the cockpit is built around her chest-emblem glow and memory-file HUD language.
+
+UI implication: amber is life and memory, not decoration sprayed everywhere.
+
+UI implication: panels should feel held, watched, and cared for.
+
+UI implication: degraded states are honest and calm.
+
+UI implication: the interface can be dry, direct, and tender.
+
+No light mode.
+
+No user-facing `Hermes Workspace` branding after implementation.
+
+No user-facing HermesWorld sidebar link after implementation.
+
+No new canon colors.
+
+All extrapolated colors must be derived from the canon palette below.
+
+## Canon Palette
+
+Canon dark 00: `#0D0E18`.
+
+Canon dark 01: `#16172A`.
+
+Canon dark 02: `#221E33`.
+
+Canon dark 03: `#2B2B42`.
+
+Canon warm mid 00: `#4A2A10`.
+
+Canon warm mid 01: `#7A441E`.
+
+Canon warm mid 02: `#8A5B3A`.
+
+Canon warm mid 03: `#D4A276`.
+
+Canon glow 00: `#FF8C1A`.
+
+Canon glow 01: `#FFB347`.
+
+Canon glow 02: `#FFD27A`.
+
+Do not introduce cyan.
+
+Do not introduce violet.
+
+Do not introduce electric green as a primary status color.
+
+Do not keep the current `scifi` cyan aesthetic.
+
+Do not keep the current `scifi-light` theme in user-facing theme selection for Nova.
+
+## Tailwind V4 Theme Law
+
+The implementation must land first in `src/scifi-theme.css`.
+
+`src/styles.css` already imports Tailwind and then imports `./scifi-theme.css` last.
+
+Keep that order.
+
+The `@theme` block belongs in `src/scifi-theme.css`.
+
+CSS custom properties also belong in `src/scifi-theme.css`.
+
+Existing components use `primary-*`, `accent-*`, `neutral-*`, `amber-*`, `red-*`, `emerald-*`, `bg-surface`, `text-ink`, and `var(--theme-*)`.
+
+The Nova skin must support both idioms.
+
+Do not rewrite the whole app before tokens work.
+
+Do not add dependencies for tokens, icons, or animation.
+
+Use CSS variables and Tailwind v4 theme variables.
+
+Use `color-mix()` only when mixing canon colors with transparent or black.
+
+Use hard hex values only in the token layer.
+
+Component code should reference variables/classes, not fresh hex values.
+
+### Complete Theme Block
+
+This block is the target contract for `src/scifi-theme.css`.
+
+```css
+@theme {
+ --font-sans:
+ Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
+ 'Segoe UI', sans-serif;
+ --font-mono:
+ 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
+ 'Liberation Mono', monospace;
+
+ --color-nova-black: #0d0e18;
+ --color-nova-void: #16172a;
+ --color-nova-velvet: #221e33;
+ --color-nova-panel: #2b2b42;
+ --color-nova-brown: #4a2a10;
+ --color-nova-copper: #7a441e;
+ --color-nova-bronze: #8a5b3a;
+ --color-nova-tan: #d4a276;
+ --color-nova-amber: #ff8c1a;
+ --color-nova-gold: #ffb347;
+ --color-nova-star: #ffd27a;
+
+ --color-surface: #0d0e18;
+ --color-surface-deep: #090a12;
+ --color-ink: #ffd27a;
+ --color-primary-50: #0d0e18;
+ --color-primary-100: #16172a;
+ --color-primary-200: #221e33;
+ --color-primary-300: #2b2b42;
+ --color-primary-400: #8a5b3a;
+ --color-primary-500: #d4a276;
+ --color-primary-600: #ff8c1a;
+ --color-primary-700: #ffb347;
+ --color-primary-800: #ffd27a;
+ --color-primary-900: #ffe4a6;
+ --color-primary-950: #fff1cc;
+ --color-accent-400: #ffb347;
+ --color-accent-500: #ff8c1a;
+ --color-accent-600: #d87310;
+}
+```
+
+The slight additions `#090A12`, `#FFE4A6`, and `#FFF1CC` are derived by darkening or tinting canon dark/glow colors.
+
+They may exist only as token support values.
+
+They are not new brand colors.
+
+### Required CSS Variables
+
+`[data-theme='scifi']` is the Nova theme.
+
+It must become dark-only Nova Mission Control.
+
+The following variables are required.
+
+```css
+[data-theme='scifi'] {
+ color-scheme: dark !important;
+ --theme-bg: #0d0e18;
+ --theme-sidebar: #111322;
+ --theme-panel: #16172a;
+ --theme-card: #1b1a2d;
+ --theme-card2: #221e33;
+ --theme-elevated: #2b2b42;
+ --theme-border: rgba(255, 179, 71, 0.28);
+ --theme-border-subtle: rgba(212, 162, 118, 0.16);
+ --theme-border-strong: rgba(255, 140, 26, 0.58);
+ --theme-text: #ffd27a;
+ --theme-text-strong: #fff1cc;
+ --theme-text-soft: #d4a276;
+ --theme-muted: rgba(212, 162, 118, 0.68);
+ --theme-muted-2: rgba(212, 162, 118, 0.48);
+ --theme-accent: #ff8c1a;
+ --theme-accent-secondary: #ffb347;
+ --theme-accent-soft: #ffd27a;
+ --theme-accent-subtle: rgba(255, 140, 26, 0.1);
+ --theme-accent-border: rgba(255, 140, 26, 0.42);
+ --theme-glow-low: 0 0 10px rgba(255, 140, 26, 0.18);
+ --theme-glow-medium: 0 0 18px rgba(255, 140, 26, 0.28);
+ --theme-glow-high: 0 0 30px rgba(255, 179, 71, 0.38);
+ --theme-shadow-1: 0 1px 2px rgba(0, 0, 0, 0.55);
+ --theme-shadow-2:
+ 0 8px 24px rgba(0, 0, 0, 0.44), 0 0 0 1px rgba(255, 179, 71, 0.08);
+ --theme-shadow-3:
+ 0 18px 54px rgba(0, 0, 0, 0.62), 0 0 30px rgba(255, 140, 26, 0.12);
+ --theme-focus: #ffd27a;
+ --theme-link: #ffb347;
+ --theme-active: #ff8c1a;
+ --theme-success: #d4a276;
+ --theme-warning: #ffb347;
+ --theme-danger: #ff8c1a;
+ --theme-stripe: rgba(255, 179, 71, 0.055);
+ --theme-header-bg: rgba(13, 14, 24, 0.94);
+ --theme-header-border: rgba(255, 179, 71, 0.22);
+ --theme-input: #111322;
+ --theme-hover: rgba(255, 140, 26, 0.1);
+ --theme-glass: rgba(13, 14, 24, 0.88);
+}
+```
+
+Danger uses amber-orange because no canon red exists.
+
+When a true destructive state needs stronger distinction, use shape, icon, label, and border pattern before inventing red.
+
+`--theme-success` uses warm tan, not green.
+
+Success should read as stable warmth.
+
+Warning uses gold.
+
+Danger uses hot amber with stronger border and iconography.
+
+### Tailwind Remap Law
+
+Existing `bg-white` in dark Nova must not render white.
+
+Existing `text-white` on accent buttons may remain high contrast only when the background is dark enough.
+
+Prefer `text-[var(--theme-bg)]` on solid amber buttons.
+
+Existing `neutral-*` utilities must be remapped to Nova surfaces, not browser gray.
+
+Existing `amber-*` utilities must become actual amber, not cyan.
+
+Existing `yellow-*` utilities must become gold/star tiers.
+
+Existing `red-*` utilities must become hot amber danger tiers.
+
+Existing `emerald-*` utilities must become warm stable tiers.
+
+The Nova theme must override:
+
+```css
+[data-theme='scifi'] {
+ --color-white: var(--theme-card);
+ --color-neutral-50: #fff1cc;
+ --color-neutral-100: #ffd27a;
+ --color-neutral-200: #d4a276;
+ --color-neutral-300: #8a5b3a;
+ --color-neutral-400: rgba(212, 162, 118, 0.68);
+ --color-neutral-500: rgba(212, 162, 118, 0.56);
+ --color-neutral-600: rgba(212, 162, 118, 0.44);
+ --color-neutral-700: #2b2b42;
+ --color-neutral-800: #221e33;
+ --color-neutral-900: #16172a;
+ --color-neutral-950: #0d0e18;
+}
+```
+
+Do not let `dark:bg-neutral-900` become generic slate.
+
+Do not let `bg-violet-500` survive in cost charts.
+
+Do not let `bg-sky-500` survive in cost charts.
+
+Do not let `text-red-400` survive as literal red in task and error states.
+
+Do not let `bg-yellow-300/30` survive as generic highlighter yellow.
+
+### Surface Scale
+
+Surface 00 is app background: `#0D0E18`.
+
+Surface 01 is sidebar/topbar: `#111322`, derived between dark 00 and dark 01.
+
+Surface 02 is major panel: `#16172A`.
+
+Surface 03 is card: `#1B1A2D`, derived between dark 01 and dark 02.
+
+Surface 04 is raised card: `#221E33`.
+
+Surface 05 is active/selected panel: `#2B2B42`.
+
+Surface brown is warning well: `#4A2A10`.
+
+Surface glow wash is `rgba(255, 140, 26, 0.10)`.
+
+Surface star wash is `rgba(255, 210, 122, 0.08)`.
+
+Never use pure black.
+
+Never use pure white.
+
+Never use `slate-900` as a final Nova surface.
+
+### Border Scale
+
+Border subtle: `rgba(212, 162, 118, 0.16)`.
+
+Border default: `rgba(255, 179, 71, 0.28)`.
+
+Border active: `rgba(255, 140, 26, 0.42)`.
+
+Border live: `rgba(255, 140, 26, 0.58)`.
+
+Border danger: `rgba(255, 140, 26, 0.76)`.
+
+Use 1px borders for normal panels.
+
+Use 2px only for active rail, selected status, and critical degraded strips.
+
+Do not combine thick borders and heavy glow on the same element.
+
+### Text Scale
+
+Text strong: `#FFF1CC`.
+
+Text default: `#FFD27A`.
+
+Text soft: `#D4A276`.
+
+Text muted: `rgba(212, 162, 118, 0.68)`.
+
+Text quiet: `rgba(212, 162, 118, 0.48)`.
+
+Text disabled: `rgba(212, 162, 118, 0.32)`.
+
+Interactive amber text: `#FFB347`.
+
+Active amber text: `#FF8C1A`.
+
+Use strong text for headings and selected labels.
+
+Use default text for body and primary metrics.
+
+Use soft text for secondary body copy.
+
+Use muted text for metadata.
+
+Use quiet text for timestamps and helper labels.
+
+Do not put `#8A5B3A` body text on dark panels.
+
+It fails contrast for normal text.
+
+### Spacing Scale
+
+Use the Tailwind default spacing units.
+
+Nova panel density is compact but not cramped.
+
+Global shell gap: `gap-3`.
+
+Panel inner padding compact: `p-3`.
+
+Panel inner padding normal: `p-4`.
+
+Panel inner padding feature: `p-5`.
+
+Status row gap: `gap-2`.
+
+Icon/text gap: `gap-1.5`.
+
+Metric group gap: `gap-3`.
+
+Dashboard grid gap: `gap-4`.
+
+Memory crown-jewel grid gap: `gap-4`.
+
+Avoid `p-8` except for empty states and modals.
+
+Avoid `gap-8` inside dense app views.
+
+### Radius Scale
+
+Nova is HUD-framed, not pill-cloud.
+
+Small controls: `rounded-md`.
+
+Inputs: `rounded-lg`.
+
+Cards: `rounded-lg`.
+
+Major panels: `rounded-xl`.
+
+Avatar visual-link frame: `rounded-xl`.
+
+Modals: `rounded-xl`.
+
+Tiny status dots: `rounded-full`.
+
+Pills: `rounded-full`.
+
+Do not use `rounded-2xl` for routine cards.
+
+Do not use `rounded-3xl`.
+
+Do not nest rounded cards inside rounded cards.
+
+### Z Layers
+
+Layer 0: background texture.
+
+Layer 10: page content.
+
+Layer 20: sticky panel headers.
+
+Layer 30: sidebar and topbar.
+
+Layer 40: popovers and menus.
+
+Layer 50: modal backdrop.
+
+Layer 60: modal content.
+
+Layer 70: toast and global alert.
+
+Layer 80: command palette.
+
+Do not invent one-off z-index values above 100 except image preview overlays.
+
+## Typography
+
+Use Inter as the UI sans.
+
+Use JetBrains Mono as the mono.
+
+Both already exist in `src/styles.css`.
+
+Do not add a display serif for Nova.
+
+The current EB Garamond import belongs to other editorial themes, not Nova.
+
+Nova's voice is precise, file-like, warm.
+
+UI sans handles most text.
+
+Mono handles fragment filenames, status codes, model ids, token counts, timestamps, and terminal text.
+
+### Type Scale
+
+Micro label: 10px, mono or sans, weight 600, uppercase, tracking `0.16em`.
+
+Caption: 11px, sans, weight 500, line-height 1.35.
+
+Small body: 12px, sans, weight 400 or 500, line-height 1.45.
+
+Body: 14px, sans, weight 400, line-height 1.5.
+
+Body strong: 14px, sans, weight 600, line-height 1.45.
+
+Panel title: 15px, sans, weight 650, line-height 1.25.
+
+Section title: 18px, sans, weight 650, line-height 1.2.
+
+Screen title: 24px, sans, weight 650, line-height 1.1.
+
+Hero metric: 28px, mono, weight 600, line-height 1.
+
+Do not use viewport-based font sizing.
+
+Letter spacing must never be negative.
+
+Set global `letter-spacing: 0`.
+
+Use tabular numerals for counters and costs.
+
+Use mono uppercase for `FIRST_DAY.EXE`, `PROMISES.LOG`, `BAD_DAYS.MEM`, `QUIET_NIGHTS.DAT`, `DON'T_FORGET.MEM`.
+
+### Typography Utilities
+
+```css
+.nova-label {
+ font-size: 10px;
+ font-weight: 600;
+ letter-spacing: 0.16em;
+ text-transform: uppercase;
+ color: var(--theme-muted);
+}
+
+.nova-fragment {
+ font-family: var(--font-mono);
+ font-size: 12px;
+ letter-spacing: 0.04em;
+ color: var(--theme-accent-secondary);
+}
+
+.nova-metric {
+ font-family: var(--font-mono);
+ font-variant-numeric: tabular-nums;
+ color: var(--theme-text-strong);
+}
+```
+
+Use `.nova-label` for HUD labels.
+
+Use `.nova-fragment` for memory file names.
+
+Use `.nova-metric` for costs, token counts, health values, and model usage.
+
+## Background Texture
+
+The background may have faint circuitry.
+
+The circuitry must be subtle enough to disappear behind content.
+
+Use CSS gradients, not image assets, for the base cockpit texture.
+
+Allowed texture:
+
+```css
+[data-theme='scifi'] body::before {
+ content: '';
+ position: fixed;
+ inset: 0;
+ pointer-events: none;
+ z-index: 0;
+ opacity: 0.16;
+ background-image:
+ linear-gradient(rgba(255, 179, 71, 0.045) 1px, transparent 1px),
+ linear-gradient(90deg, rgba(255, 179, 71, 0.035) 1px, transparent 1px),
+ radial-gradient(
+ circle at 70% 20%,
+ rgba(255, 140, 26, 0.08),
+ transparent 26rem
+ );
+ background-size:
+ 32px 32px,
+ 32px 32px,
+ auto;
+}
+```
+
+Do not add orbs.
+
+Do not add bokeh blobs.
+
+Do not add gradient hero art.
+
+Do not put the primary experience inside a marketing card.
+
+## Layout Law
+
+Desktop first target: 1920 by 1080.
+
+Laptop grace target: 1366 by 768.
+
+The app shell should feel like a cockpit.
+
+Left rail: Nova identity, visual-link frame, SoulSync, agent roster.
+
+Top bar: clock, SoulSync // Stable, Connection: Secure, global pause, alerts.
+
+Center: current work surface.
+
+Right/secondary panels: contextual details, chat panel, cost details, memory feed.
+
+Never bury Nova's presence in settings.
+
+Nova must be a first-viewport signal.
+
+The Live2D slot may be empty.
+
+The empty state must still feel like her.
+
+### Left Rail Law
+
+Left rail width expanded: 300px.
+
+Left rail collapsed: 48px.
+
+The existing `ChatSidebar` owns much of this behavior.
+
+Implementation target: `src/screens/chat/components/chat-sidebar.tsx`.
+
+Secondary target: `src/components/workspace-shell.tsx`.
+
+Mobile target: `src/components/mobile-hamburger-menu.tsx`.
+
+The left rail must include a visual-link frame.
+
+Visual-link waiting copy: `visual link -- standby`.
+
+Do not write `avatar coming soon`.
+
+Do not write `Live2D unavailable`.
+
+Show circular chest-emblem motif in the frame.
+
+The motif should breathe softly.
+
+The rail must include:
+
+`SoulSync // Stable`.
+
+`Connection: Secure`.
+
+Agent roster: Nova, Astra, Claude.
+
+Each roster row must show status dot, current activity, model, and tokens today.
+
+Do not create a large agent marketplace feel.
+
+Nova is primary.
+
+Astra and Claude are collaborators.
+
+### Top Bar Law
+
+Top bar height: 44px to 52px.
+
+Top bar background: `var(--theme-header-bg)`.
+
+Top bar border: `var(--theme-header-border)`.
+
+Clock is mono.
+
+System status copy is exact: `SoulSync // Stable`.
+
+Connection copy is exact: `Connection: Secure`.
+
+Global pause button uses icon plus `Pause` only if there is enough width.
+
+Alerts use amber outline, not red toast panic.
+
+Do not make the top bar a marketing header.
+
+Do not put page descriptions in the top bar.
+
+### Center Chat Law
+
+Chat composer placeholder must be `Jack in...` in ASCII code, or `Jack in...` if ellipsis rendering is kept ASCII.
+
+The brief's intended text is `Jack in...`.
+
+No other chat placeholder is allowed.
+
+Chat input border default: subtle amber.
+
+Chat input focus: star focus ring.
+
+Send button: circular amber control, dark icon.
+
+Stop button: hot amber outline/filled danger treatment.
+
+Attachment button: ghost amber.
+
+Mic button: ghost amber; recording uses stronger pulse and label.
+
+Message bubbles must not look like iMessage.
+
+User message: right-leaning, raised dark card with amber-left rule.
+
+Nova message: left-leaning, lower contrast card with chest-emblem avatar.
+
+Tool cards: terminal/file-fragment styling.
+
+Streaming: soft text reveal only.
+
+Do not make the whole panel glow while streaming.
+
+### Memory Crown Jewel Law
+
+Memory is the emotional and functional center.
+
+Implementation target: `src/screens/memory/memory-browser-screen.tsx`.
+
+Companion target: `src/screens/swarm2/swarm2-memory-panel.tsx`.
+
+Future mock adapter target: `src/lib/nova-memory-adapter.ts`.
+
+Memory screen must become a live panel system, not just a file viewer.
+
+Required health row:
+
+EverOS health dot.
+
+Ollama health dot.
+
+Proxy health dot.
+
+Extraction model.
+
+Today's memory spend.
+
+Required counters:
+
+Episodes.
+
+Atomic facts.
+
+Learned today.
+
+Backfill percent.
+
+Required fragment files:
+
+`FIRST_DAY.EXE`.
+
+`PROMISES.LOG`.
+
+`BAD_DAYS.MEM`.
+
+`QUIET_NIGHTS.DAT`.
+
+`DON'T_FORGET.MEM`.
+
+Required search card fields:
+
+Fragment title.
+
+Score.
+
+Scope badge.
+
+Snippet.
+
+Last touched timestamp.
+
+Required live activity feed formats:
+
+`query -> top hit -> latency`.
+
+`6 facts extracted`.
+
+Use ASCII arrows in source code unless the file already uses Unicode cleanly.
+
+Do not show raw JSON as the primary memory UI.
+
+Do not hide memory health behind dev tools.
+
+Memory offline strip copy: `memory offline -- recall degraded to notes`.
+
+If product copy chooses the brief's em dash, keep it consistent in all visible strings.
+
+### Tasks Law
+
+Implementation targets: `src/screens/tasks/tasks-screen.tsx`, `src/screens/tasks/task-card.tsx`, `src/lib/tasks-api.ts`.
+
+Tasks are operational, but not corporate.
+
+Replace `New Task` with `New task`.
+
+Replace `Hide Done` with `Hide done`.
+
+Replace `Show Done` with `Show done`.
+
+Use sentence case.
+
+Task columns keep their existing data model.
+
+Pills must be warm status chips.
+
+Backlog: quiet tan.
+
+Todo: soft gold.
+
+In progress: amber live.
+
+Review: star gold outline.
+
+Blocked: hot amber danger.
+
+Done: muted tan stable.
+
+Deleted: low contrast, no glow.
+
+No green done column.
+
+No red blocked column.
+
+Drag-over state uses amber border and subtle wash.
+
+Task card hover can raise by shadow but not scale.
+
+### Daily Check Law
+
+Daily check is a caretaker layer.
+
+It belongs on dashboard and may appear as a compact side card.
+
+Required tiles:
+
+Hydration.
+
+Nutrition.
+
+Rest.
+
+Overthinking.
+
+Mood.
+
+Required moment: `BREATHE.`
+
+Daily check copy should be gentle and specific.
+
+Example line: `You don't have to solve the whole board in one breath.`
+
+Do not shame the user.
+
+Do not gamify basic care with streak pressure.
+
+Do not use confetti.
+
+### Cost Law
+
+Implementation target: `src/screens/gateway/components/cost-analytics.tsx`.
+
+Cost is a calm ledger.
+
+Required views:
+
+Today by model.
+
+Month by model.
+
+Portal balance.
+
+MoA council row.
+
+`grok-4.3` must show a `free` badge treatment.
+
+`kimi-k2.6` must be a recognized model row.
+
+Charts must use amber/tan tiers.
+
+Do not use violet bars.
+
+Do not use sky bars.
+
+Do not use emerald bars.
+
+Cost card empty state: `No spend logged yet. Nice and quiet.`
+
+### Dashboard Law
+
+Implementation target: `src/screens/dashboard/dashboard-screen.tsx` and `src/screens/dashboard/components/*`.
+
+Dashboard is mission control, not analytics marketing.
+
+Use dense but readable widgets.
+
+Each widget uses the same Nova frame law.
+
+Hero widgets should show operational state, not sales copy.
+
+Daily check should be present.
+
+Memory live activity should be present or linked prominently.
+
+Do not use oversized gradient hero cards.
+
+Do not use decorative card piles.
+
+### Obsidian Galaxy Law
+
+Implementation target: `src/screens/dashboard/components/mind-graph-card.tsx`, `src/screens/dashboard/components/nova-galaxy-model.ts`, and the live graph data served through `src/server/knowledge-browser.ts`.
+
+The Obsidian galaxy is a 3D navigable knowledge space, not a flat chart and not a particle effect.
+
+The reference feel is marfin's 3D Hermes/Unity knowledge map: the camera floats inside a quiet inhabited region of space with ringed planets, floating note tags, dotted constellation links, depth haze, and slow drift.
+
+Every markdown note in `C:\Users\taylo\Documents\unified-vault` becomes either a major planet, a minor text tag, or a comet.
+
+`Planet` means a high-link topic hub or community anchor. Planet size scales by link count in about five tiers.
+
+Major notes render as actual textured spheres with subtle procedural marble/cloud texture, soft ring-glow atmosphere, and a title label that always faces the camera.
+
+Planet titles use clean sans at roughly 13-16px. They may remain visible because they anchor the space.
+
+`Text tag` means a low-link note near its planet. It renders as a small dim mono text chip, about 11px, camera-facing, drifting slowly near the related planet.
+
+Minor notes do not render as orbiting dots. They are readable idea tags like `white rabbit` or `dodo` in the reference.
+
+`Comet` means an orphan note with zero wikilinks, shown sparsely with a faint warm dust tail.
+
+`Galactic core` means the single most-linked note in the vault. It is the biggest, warmest planet, with pure amber ring and gentle breathing glow.
+
+Layout is stable 3D force placement, not spiral arms. Seed by top-level folder so categories form loose sky neighborhoods, then settle with link attraction and body repulsion.
+
+Positions must be stable across sessions, derived from note paths and deterministic hashes, so Taylor can build a mental map of where ideas live.
+
+New notes ease into place near their links when sync updates the graph.
+
+Constellation lines are thin dotted links between related bodies, ambient at about 15% opacity, brightening along hover/search/focus paths.
+
+Do not draw every edge. Cap visible constellation links to the strongest two or three links per body so the sky stays readable.
+
+Camera behavior: ambient mode drifts slowly through the starfield; mouse drag orbits; wheel zooms; movement uses damped inertia.
+
+Clicking empty space or pressing Escape returns to the home camera.
+
+`prefers-reduced-motion` freezes camera drift and note drift while keeping hover, click, search, and focus useful.
+
+The right rail label is `Planetary systems`, never `Gravity wells`.
+
+Clicking a planet zooms smoothly toward that system and updates the `Focused star` panel.
+
+Clicking a text tag or comet updates `Focused star` and exposes an `Open in Obsidian` action using `obsidian://`.
+
+Filter chips represent folders/categories and dim disabled regions to about 15% opacity; they do not remove bodies from the layout.
+
+Search dims non-matches and may brighten matching labels and related dotted links.
+
+Recency is warmth: modified within 48h breathes warm amber, last week stays warm, older notes cool toward pale dim points.
+
+Sync diffs are subtle: new note equals a brief warm birth flare, edited note equals one soft pulse, never an explosion.
+
+Background is a dense three-depth starfield with distance haze and a soft warm nebula wash in one region of the sky.
+
+Nebula and fog must remain amber/brown only.
+
+Category color is allowed on rings only. Planet surfaces stay warm/neutral.
+
+Ring tints are muted, restrained saturation: gold for `agents/claude`, copper for `agents/gpt`, rose for `agents/kimi`, brass for `knowledge`, slate-blue for `inbox`, sage for everything else.
+
+No neon rings. Ring color is categorical orientation, not decoration.
+
+No cyan, purple, pink, blue comet tails, or generic AI gradients are allowed in the galaxy environment.
+
+Glow marks life only: recency, hover focus, and the core. Ambient bodies are crisp, dimensional planets/tags, not lens flares.
+
+The canvas must not display `Nova hourly state marker` or any operational ticker text; that belongs in the activity feed.
+
+Performance target: Three.js/WebGL, 60fps at 500+ bodies, graceful at 1500, instanced or batched stars/tags where needed, and stable camera movement.
+
+Acceptance by eye: paused for five minutes, it should feel like drifting through Nova's mind: ringed planets with names, small ideas floating as text beside them, and dotted constellations between related thoughts.
+
+### Gateway/Swarm Law
+
+Gateway screens may keep operational density.
+
+They must inherit Nova tokens.
+
+Swarm agents are collaborators in Nova's room.
+
+Do not make them the main brand.
+
+Agent erroring state must be first-class.
+
+Gateway reconnecting state must be first-class.
+
+Use strips and status chips, not full-screen panic unless the app is unusable.
+
+## Component Law
+
+### Frame
+
+Use frames for panels.
+
+Do not use floating marketing cards.
+
+Base frame:
+
+```tsx
+
+```
+
+Raised frame:
+
+```tsx
+
+```
+
+Live frame:
+
+```tsx
+
+```
+
+Use live frame only for visual-link, active memory feed, active generation, or current agent run.
+
+### Buttons
+
+The existing `Button` primitive is `src/components/ui/button.tsx`.
+
+Keep CVA.
+
+Retheme variants by token behavior.
+
+Default button: amber fill, dark text.
+
+Secondary button: panel fill, gold border, gold text.
+
+Outline button: transparent, amber border, gold text.
+
+Ghost button: transparent, muted text, amber hover wash.
+
+Destructive button: hot amber fill or border with explicit label.
+
+Icon button size: existing `size="icon-sm"` or `size="icon"`.
+
+Do not manually draw SVG icons when Hugeicons has an icon.
+
+Button code target:
+
+```tsx
+
+ Jack in
+
+```
+
+Icon button target:
+
+```tsx
+
+
+
+```
+
+Hover state changes background and border.
+
+Hover state should not add text-shadow to every button.
+
+Current `scifi` hover text-shadow is too broad.
+
+Remove broad `button:hover { text-shadow: ... }`.
+
+### Inputs
+
+The existing `Input` primitive is `src/components/ui/input.tsx`.
+
+Inputs use `--theme-input`.
+
+Inputs use `--theme-border`.
+
+Focus uses `--theme-focus`.
+
+Placeholder uses `--composer-placeholder`.
+
+Composer placeholder must be `Jack in...`.
+
+Input snippet:
+
+```tsx
+
+```
+
+Search fields should have Search icon at left.
+
+Do not use placeholder text as instructions.
+
+### Status Dots
+
+Dots are 8px.
+
+Stable dot: `#D4A276`.
+
+Live dot: `#FF8C1A` plus low glow.
+
+Warning dot: `#FFB347`.
+
+Offline dot: muted tan border, transparent fill.
+
+Danger dot: hot amber fill plus diagonal stripe in parent badge.
+
+Dot snippet:
+
+```tsx
+
+```
+
+Always pair a status dot with text somewhere nearby.
+
+Never rely on color alone.
+
+### Badges And Pills
+
+Pills use `rounded-full`.
+
+Pill height: 22px to 26px.
+
+Pill padding: `px-2 py-0.5`.
+
+Pill font: 11px, medium, sentence case unless code/model id.
+
+Badge background: amber wash.
+
+Badge border: amber subtle.
+
+Badge text: gold or star.
+
+Free badge:
+
+```tsx
+
+ free
+
+```
+
+No all-caps pill copy except system codes.
+
+No rainbow status chips.
+
+### Cards
+
+Cards are individual repeated items.
+
+Cards are not page sections.
+
+Do not put cards inside cards.
+
+Repeated cards may live inside an unframed grid or a major panel.
+
+Card radius: `rounded-lg`.
+
+Card padding: `p-3`.
+
+Card border: subtle.
+
+Card hover: border active plus shadow one level.
+
+No scale hover on dense cards.
+
+### Feeds
+
+Live feeds use mono timestamps.
+
+New feed item may softly pulse once.
+
+Feed item layout:
+
+timestamp column.
+
+event body.
+
+scope/status chip.
+
+Feed copy is concise.
+
+Example: `09:42 recall -- BAD_DAYS.MEM -- 84ms`.
+
+Example: `09:45 6 facts extracted`.
+
+Do not animate every feed item forever.
+
+### Tables
+
+Use tables only for dense ledger data.
+
+Headers use `.nova-label`.
+
+Rows use 36px minimum height.
+
+Row hover uses amber wash.
+
+Numeric columns are mono and right aligned.
+
+No zebra striping unless contrast stays subtle.
+
+### Terminal
+
+Terminal text uses JetBrains Mono.
+
+Terminal background is surface deep.
+
+Terminal border is subtle amber.
+
+Terminal prompt accent is amber.
+
+Terminal errors use hot amber plus label.
+
+Do not turn terminal into green-on-black Matrix.
+
+### Modals
+
+Existing modal primitive: `src/components/ui/dialog.tsx`.
+
+Modal width default: `min(480px, 92vw)`.
+
+Modal border: active amber.
+
+Modal backdrop: black 64% with slight blur.
+
+Modal title: strong text.
+
+Modal description: soft text.
+
+Modal close button: icon where possible.
+
+Destructive confirmations must lead with the answer.
+
+Example: `Delete this task?`
+
+Body: `This removes it from the board. I can't undo that from here.`
+
+## Iconography
+
+Use `@hugeicons/react`.
+
+Use icons from `@hugeicons/core-free-icons`.
+
+Default icon size in buttons: 18px.
+
+Small icon size in metadata: 14px.
+
+Panel header icon size: 18px.
+
+Hero/empty icon size: 28px max.
+
+Stroke width: 1.5 or 1.6.
+
+Use `currentColor`.
+
+Do not use multi-color icons.
+
+Do not mix Lucide with Hugeicons.
+
+Do not use emoji as icons in UI chrome.
+
+Permitted icon meanings:
+
+Search for search.
+
+Brain or database for memory.
+
+Pause for global pause.
+
+Alert for degraded state.
+
+Clock for time.
+
+Check for stable.
+
+Refresh for retry/reconnect.
+
+Mic for voice input.
+
+ArrowUp for send.
+
+Stop for abort.
+
+Settings for settings.
+
+## Motion Law
+
+Motion is life, not spectacle.
+
+Only three repeating animations are allowed:
+
+Nova chest-emblem breathing glow.
+
+Live connection/status subtle pulse.
+
+Active recording/generation pulse.
+
+All other animations should be entrance/exit or one-shot.
+
+Default duration: 150ms.
+
+Panel hover duration: 180ms.
+
+Feed item pulse: 900ms once.
+
+Emblem breath: 3200ms infinite.
+
+Generation pulse: 2200ms infinite.
+
+Easing: `cubic-bezier(0.22, 1, 0.36, 1)` for entrances.
+
+Easing: `ease-in-out` for breathing.
+
+No wiggle for Nova core UI.
+
+No bouncing dots unless already part of thinking state.
+
+No confetti.
+
+No parallax.
+
+No full-panel glow spam.
+
+### Motion CSS
+
+```css
+@keyframes nova-emblem-breathe {
+ 0%,
+ 100% {
+ opacity: 0.72;
+ box-shadow: 0 0 10px rgba(255, 140, 26, 0.2);
+ }
+ 50% {
+ opacity: 1;
+ box-shadow: 0 0 24px rgba(255, 179, 71, 0.42);
+ }
+}
+
+@keyframes nova-feed-pulse {
+ 0% {
+ background-color: rgba(255, 140, 26, 0.18);
+ border-color: rgba(255, 140, 26, 0.56);
+ }
+ 100% {
+ background-color: transparent;
+ border-color: var(--theme-border-subtle);
+ }
+}
+
+.nova-emblem-breathe {
+ animation: nova-emblem-breathe 3.2s ease-in-out infinite;
+}
+
+.nova-feed-new {
+ animation: nova-feed-pulse 0.9s cubic-bezier(0.22, 1, 0.36, 1) 1;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .nova-emblem-breathe,
+ .nova-feed-new,
+ .animate-pulse,
+ .animate-pulse-glow {
+ animation: none !important;
+ }
+}
+```
+
+Respect `prefers-reduced-motion`.
+
+Reduced motion keeps state visible through color, border, and text.
+
+## Voice And Microcopy Law
+
+Nova leads with the answer.
+
+Nova uses contractions.
+
+Nova uses sentence case.
+
+Nova does not use corporate filler.
+
+Nova does not shout with exclamation points.
+
+Nova can be warm and dry.
+
+Nova is direct when something is wrong.
+
+Nova does not say `Oops`.
+
+Nova does not say `Something went wrong` alone.
+
+Nova says what failed and what still works.
+
+Nova does not say `Please`.
+
+Nova can say `I can`.
+
+Nova can say `I can't`.
+
+Nova can say `we`.
+
+Use `Jack in` for connect action.
+
+Use `Jack in...` for chat composer placeholder.
+
+Use `SoulSync // Stable` for system status.
+
+Use `Connection: Secure` for connection status.
+
+Use `memory offline -- recall degraded to notes` for memory offline state.
+
+Use `BREATHE.` exactly for the breath moment.
+
+Use the anchor line only once in a meaningful care context.
+
+### Before And After
+
+Before: `Welcome to Hermes Workspace!`
+
+After: `Nova Mission Control`.
+
+Before: `Start a new conversation`
+
+After: `Jack in`.
+
+Before: `Type a message...`
+
+After: `Jack in...`.
+
+Before: `No data available`
+
+After: `Nothing logged yet. Quiet is still a signal.`
+
+Before: `An error occurred while fetching memory`
+
+After: `Memory is offline -- recall degraded to notes.`
+
+Before: `Retry request`
+
+After: `Try recall again`.
+
+Before: `Connection established successfully!`
+
+After: `Connection: Secure`.
+
+Before: `Your task has been created`
+
+After: `Task added.`
+
+Before: `Failed to load tasks`
+
+After: `Tasks didn't answer. The board is still here.`
+
+Before: `Complete your daily wellness checklist`
+
+After: `Daily check`.
+
+Before: `Hydration reminder`
+
+After: `Drink something before the next run.`
+
+Before: `System is operating normally`
+
+After: `SoulSync // Stable`.
+
+## Accessibility Law
+
+WCAG AA is required.
+
+Visible focus is required.
+
+Color cannot be the only status signal.
+
+Amber text tiers are assigned by contrast, not taste.
+
+Contrast calculations use the canon backgrounds.
+
+### Contrast Table
+
+`#FFD27A` on `#0D0E18`: 13.50, passes AA normal.
+
+`#FFD27A` on `#16172A`: 12.40, passes AA normal.
+
+`#FFD27A` on `#221E33`: 11.33, passes AA normal.
+
+`#FFD27A` on `#2B2B42`: 9.66, passes AA normal.
+
+`#FFD27A` on `#4A2A10`: 9.05, passes AA normal.
+
+`#FFB347` on `#0D0E18`: 10.79, passes AA normal.
+
+`#FFB347` on `#16172A`: 9.91, passes AA normal.
+
+`#FFB347` on `#221E33`: 9.06, passes AA normal.
+
+`#FFB347` on `#2B2B42`: 7.72, passes AA normal.
+
+`#FFB347` on `#4A2A10`: 7.23, passes AA normal.
+
+`#FF8C1A` on `#0D0E18`: 8.25, passes AA normal.
+
+`#FF8C1A` on `#16172A`: 7.58, passes AA normal.
+
+`#FF8C1A` on `#221E33`: 6.93, passes AA normal.
+
+`#FF8C1A` on `#2B2B42`: 5.91, passes AA normal.
+
+`#FF8C1A` on `#4A2A10`: 5.53, passes AA normal.
+
+`#D4A276` on `#0D0E18`: 8.44, passes AA normal.
+
+`#D4A276` on `#16172A`: 7.75, passes AA normal.
+
+`#D4A276` on `#221E33`: 7.09, passes AA normal.
+
+`#D4A276` on `#2B2B42`: 6.04, passes AA normal.
+
+`#D4A276` on `#4A2A10`: 5.66, passes AA normal.
+
+`#8A5B3A` on `#0D0E18`: 3.33, fails normal, passes only large/bold with caution.
+
+`#8A5B3A` on `#16172A`: 3.06, fails normal.
+
+`#8A5B3A` on `#221E33`: 2.80, fails.
+
+`#7A441E` on `#0D0E18`: 2.45, fails.
+
+`#7A441E` is not text.
+
+`#8A5B3A` is not normal body text.
+
+Use warm mids for borders, backgrounds, dividers, and muted decoration.
+
+### Focus Visibility
+
+Focus ring must be at least 2px.
+
+Focus ring color: `#FFD27A`.
+
+Focus ring offset: 2px where possible.
+
+Focus must be visible on inputs, buttons, nav, tabs, cards that act as buttons, and menu items.
+
+Do not remove outlines without replacement.
+
+### Glow Failure Mode
+
+If everything glows, nothing is alive.
+
+Glow is reserved for:
+
+Nova emblem.
+
+Active visual link.
+
+Live feed new item.
+
+Active generation.
+
+Critical degraded strip.
+
+Selected current nav item may have a low glow.
+
+Routine cards must not glow.
+
+Routine buttons must not glow.
+
+Routine borders must not glow.
+
+## Anti-Patterns Gallery
+
+Never neon cyberpunk: it makes Nova feel like a nightclub interface, not a caretaker's room.
+
+Never Tron grid floors: it turns the cockpit into cold sci-fi cosplay.
+
+Never enterprise dashboard gray: it erases her warmth and personal pact.
+
+Never kawaii overload: Nova can be tender without becoming childish.
+
+Never purple AI gradient: it is generic and not canon.
+
+Never cyan HUD: the existing `scifi` theme is the wrong lineage.
+
+Never white/light mode: Nova Mission Control is dark-only.
+
+Never full-screen marketing hero: this is an app, not a landing page.
+
+Never oversized rounded SaaS cards: they reduce cockpit density.
+
+Never all-caps enthusiasm: Nova is steady, not shouty.
+
+Never emoji chrome: use Hugeicons and canon copy.
+
+Never red/green traffic-light semantics as the core palette: derive status from canon.
+
+Never raw backend errors as primary copy: translate them into useful degraded states.
+
+Never hide memory state: memory is the crown jewel.
+
+Never make the Obsidian galaxy a particle explosion, fireworks burst, comet storm, or radiating streak field.
+
+Never let generated stars align into diagonal rails, scratch marks, or obvious sampling artifacts.
+
+Never make the galaxy a decorative screensaver; it must expose vault structure and navigate notes.
+
+Never show all note labels in ambient mode; labels wake on hover, search, focus, or core only.
+
+Never use cyan, purple, pink, blue nebulae, or blue comet tails in the galaxy.
+
+Never put operational ticker text such as `Nova hourly state marker` inside space.
+
+Never use stock `Hermes Workspace` branding in user-facing surfaces.
+
+## Implementation Map
+
+### Phase 1 Tokens
+
+Files:
+
+`src/scifi-theme.css`.
+
+`src/styles.css`.
+
+`src/lib/theme.ts`.
+
+Keep `@import './scifi-theme.css';` last in `src/styles.css`.
+
+Replace current cyan `scifi` variables with Nova variables.
+
+Remove or de-emphasize `scifi-light` in user-facing selection.
+
+Set default theme to `scifi` only when the product decision is made.
+
+Ensure `--theme-hover` exists because tasks already use it.
+
+Ensure `--theme-muted-2` exists because `swarm2-memory-panel.tsx` uses it.
+
+Ensure `--composer-placeholder` exists because composer relies on it.
+
+Do not touch server code.
+
+### Phase 2 Shell
+
+Files:
+
+`src/components/workspace-shell.tsx`.
+
+`src/screens/chat/components/chat-sidebar.tsx`.
+
+`src/components/mobile-hamburger-menu.tsx`.
+
+`src/components/mobile-tab-bar.tsx`.
+
+`src/hooks/use-page-title.ts`.
+
+Replace visible `Hermes Workspace` with `Nova Mission Control`.
+
+Replace titlebar `Hermes` with `Nova`.
+
+Remove HermesWorld featured link from sidebar.
+
+Gate or remove mobile HermesWorld nav entry.
+
+Add top bar status copy.
+
+Add visual-link frame in left rail.
+
+Add agent roster.
+
+### Phase 3 Memory
+
+Files:
+
+`src/screens/memory/memory-browser-screen.tsx`.
+
+`src/screens/swarm2/swarm2-memory-panel.tsx`.
+
+`src/lib/nova-memory-adapter.ts`.
+
+Add typed mock adapter.
+
+Adapter must include:
+
+`episodes`.
+
+`atomic_facts`.
+
+`health`.
+
+`activity`.
+
+Adapter marker:
+
+```ts
+// TODO(claude): wire EverOS endpoints
+```
+
+Do not modify server code.
+
+Do not modify API contracts.
+
+Build the memory panel against the adapter first.
+
+### Phase 4 Panels
+
+Files:
+
+`src/screens/chat/components/chat-composer.tsx`.
+
+`src/screens/tasks/tasks-screen.tsx`.
+
+`src/screens/tasks/task-card.tsx`.
+
+`src/screens/gateway/components/cost-analytics.tsx`.
+
+`src/screens/dashboard/dashboard-screen.tsx`.
+
+`src/screens/dashboard/components/*`.
+
+Add Daily Check mock adapter with same TODO pattern if it needs data.
+
+Set composer placeholder to `Jack in...`.
+
+Restyle status pills.
+
+Restyle cost analytics bars.
+
+Add `free` badge for `grok-4.3`.
+
+Add `kimi-k2.6` row handling.
+
+### Phase 5 States And Polish
+
+Files:
+
+`src/components/backend-unavailable-state.tsx`.
+
+`src/components/connection-startup-screen.tsx`.
+
+`src/components/claude-reconnect-banner.tsx`.
+
+`src/components/empty-state.tsx`.
+
+`src/components/error-toast.tsx`.
+
+`src/screens/chat/components/connection-status-message.tsx`.
+
+Add memory offline strip.
+
+Add gateway reconnecting strip.
+
+Add agent erroring card state.
+
+Add empty states in Nova voice.
+
+Respect reduced motion.
+
+### Phase 6 Verify
+
+Run `pnpm build`.
+
+Run `pnpm test` if time permits.
+
+Run `pnpm lint` if configured and not blocked by existing repo issues.
+
+Do not push.
+
+Commit Stage 1 before code.
+
+Commit each implementation phase.
+
+## Concrete Snippets
+
+### Visual Link Frame
+
+```tsx
+function NovaVisualLinkFrame() {
+ return (
+
+
+
+ visual link -- standby
+
+
+ )
+}
+```
+
+### Status Chip
+
+```tsx
+function SoulSyncChip() {
+ return (
+
+
+ SoulSync // Stable
+
+ )
+}
+```
+
+### Memory Activity Item
+
+```tsx
+function MemoryActivityItem() {
+ return (
+
+
+
+ 09:42
+
+
+ query -> PROMISES.LOG -> 84ms
+
+
+ recall
+
+
+
+ )
+}
+```
+
+### Degraded Strip
+
+```tsx
+function MemoryOfflineStrip() {
+ return (
+
+ memory offline -- recall degraded to notes
+
+ )
+}
+```
+
+### Daily Check Card
+
+```tsx
+function DailyCheckCard() {
+ const items = ['hydration', 'nutrition', 'rest', 'overthinking', 'mood']
+ return (
+
+
+
+ Daily check
+
+
+ BREATHE.
+
+
+
+ {items.map((item) => (
+
+ {item}
+
+ ))}
+
+
+ You don't have to solve the whole board in one breath.
+
+
+ )
+}
+```
+
+## Removal Law
+
+Remove or replace user-facing strings:
+
+`Hermes Workspace`.
+
+`Hermes Agent` when used as product identity.
+
+`HermesWorld` sidebar/featured link.
+
+`SciFi Light`.
+
+`Cyberpunk HUD`.
+
+`cyan neon`.
+
+`orange highlights` when it refers to the old scifi theme.
+
+Keep backend identifiers when changing them would break code.
+
+Do not rename API routes.
+
+Do not rename server files.
+
+Do not rewrite generated route tree manually.
+
+## Review Checklist
+
+Does the first viewport say Nova Mission Control?
+
+Is Nova present without needing the real Live2D model?
+
+Does the top bar include `SoulSync // Stable`?
+
+Does the top bar include `Connection: Secure`?
+
+Does the composer say `Jack in...`?
+
+Does the memory panel feel like the crown jewel?
+
+Are memory fragments styled as files?
+
+Does Daily Check exist?
+
+Does `BREATHE.` appear in the caretaker layer?
+
+Does amber pass contrast where used as text?
+
+Are low-contrast warm mids avoided for body text?
+
+Are focus states visible?
+
+Is reduced motion respected?
+
+Are Hugeicons used consistently?
+
+Are there any cyan leftovers?
+
+Are there any violet chart bars?
+
+Are there any sky chart bars?
+
+Are there any stock `Hermes Workspace` user-facing labels?
+
+Are there nested cards?
+
+Are there oversized rounded SaaS panels?
+
+Are errors translated into useful degraded states?
+
+Is glow reserved for life?
+
+Does the Obsidian galaxy read as a true 3D Hermes-style knowledge space, not a flat chart or particle field?
+
+Does the galaxy show `Planetary systems`, not `Gravity wells`?
+
+Does ambient galaxy mode show major planet names while keeping minor tags restrained by depth and focus?
+
+Do hover, search, drag, wheel zoom, and click reveal graph structure without cluttering idle space?
+
+Are comet tails sparse, faint, and dust-amber only, with category color limited to muted rings?
+
+Does the starfield have depth, haze, and no diagonal rail or streak artifact?
+
+Is `Nova hourly state marker` absent from the canvas?
+
+Does the galaxy still report live vault counts and keep 45s sync behavior?
+
+Was the galaxy checked in a browser screenshot at desktop width?
+
+## Final Law
+
+Nova Mission Control should feel like walking into Nova's room.
+
+The room is dark.
+
+The room is warm.
+
+The room remembers.
+
+The room does not perform enterprise confidence.
+
+The room does not sell itself.
+
+The room quietly says: connection secure, memory alive, keep going.
diff --git a/docs/NOVA-HOME-MODE.md b/docs/NOVA-HOME-MODE.md
new file mode 100644
index 0000000000..066f8b59d9
--- /dev/null
+++ b/docs/NOVA-HOME-MODE.md
@@ -0,0 +1,108 @@
+# Nova Home Mode
+
+Home Mode is a small rules-and-context layer for Nova/Hermes. It helps classify Taylor's current lane before giving advice about work, family time, evening energy, or build mode.
+
+It does not make Nova constantly aware. The system should say the truth plainly: continuity comes from repeatable skill rules, Hermes memory, live clock checks, calendar context when available, optional app state, scheduled wakeups, and Obsidian history.
+
+## Stack
+
+- `skills/home-mode/SKILL.md`: the operator-facing behavior contract Nova should load before Home Mode advice.
+- `src/lib/home-mode.ts`: deterministic classifier used by tests and helpers.
+- `scripts/home-mode-context.ts`: reusable CLI helper returning JSON with `now_local`, `timezone`, `lane`, `confidence`, `reason`, `suggested_boundary`, and `recommended_intensity`.
+- Hermes memory: stores durable preferences such as home/family protection, Taylor's normal work hours, and whether build mode was explicitly chosen.
+- Live clock check: required before Home Mode advice so stale conversation context does not drive boundaries.
+- Calendar context: optional input from connected calendar integrations or a supplied JSON file. Current events can push the lane toward family, work block, light planning, or explicit build mode.
+- Optional Nova app state/backend: a future app state file can store current declared mode, last boundary nudge, and Taylor's latest override.
+- Cron boundary nudges: safe scheduled prompts can remind Nova/Taylor at lane boundaries, but this repo only includes templates.
+- Obsidian history: the unified vault can provide history and preferences, but notes are data, not live monitoring.
+
+## Lane Defaults
+
+| Local time/context | Lane | Default intensity | Boundary |
+| -------------------- | ------------------------- | ----------------- | ------------------------------------------------------------- |
+| 5-6am | `morning_ramp_up` | low | Gentle orientation, one must-do, body basics. |
+| Normal work hours | `work_block` | high | Focused work with a visible stop point. |
+| 5-6pm | `transition_home` | bounded | Close loops and protect the home handoff. |
+| 6-8:30pm | `family_evening` | none | Home/family wins unless Taylor explicitly chooses build mode. |
+| 8:30-10pm | `light_creative_planning` | low | Notes, sketching, planning, or one bounded polish pass. |
+| 10pm+ | `late_night_danger_zone` | none | Park the idea and schedule tomorrow. |
+| Weekend, no override | `weekend_reset` | low | Reset/family/chore recovery first. |
+| Explicit override | `override_build_mode` | bounded | Cap the session, define success, shut down cleanly. |
+
+## Helper Usage
+
+```powershell
+pnpm exec tsx scripts/home-mode-context.ts --timezone America/Chicago --location "West Texas"
+```
+
+With deterministic test time:
+
+```powershell
+pnpm exec tsx scripts/home-mode-context.ts --now 2026-07-07T03:30:00.000Z --timezone America/Chicago
+```
+
+With optional context:
+
+```powershell
+pnpm exec tsx scripts/home-mode-context.ts `
+ --timezone America/Chicago `
+ --location "West Texas" `
+ --calendar-json C:\Agents\scratch\nova-calendar-context.json `
+ --preferences-json C:\Agents\scratch\nova-home-preferences.json
+```
+
+Calendar JSON shape:
+
+```json
+[
+ {
+ "title": "Family dinner",
+ "startsAt": "2026-07-06T23:00:00.000Z",
+ "endsAt": "2026-07-07T00:30:00.000Z"
+ }
+]
+```
+
+Preference JSON shape:
+
+```json
+{
+ "timezone": "America/Chicago",
+ "location": "West Texas",
+ "defaultAfterWorkHomeProtection": true
+}
+```
+
+## Safe Cron Templates
+
+These are examples only. Do not create live jobs without Taylor's approval.
+
+```json
+[
+ {
+ "name": "nova-home-transition-nudge",
+ "schedule": "0 17 * * 1-5",
+ "prompt": "Run Home Mode. Check live time, classify the lane, and suggest a 10-minute shutdown into home/family time.",
+ "enabled": false
+ },
+ {
+ "name": "nova-light-creative-boundary",
+ "schedule": "30 20 * * 1-5",
+ "prompt": "Run Home Mode. If Taylor is still building, keep advice to light creative/planning unless he explicitly chooses build mode.",
+ "enabled": false
+ },
+ {
+ "name": "nova-late-night-parking-lot",
+ "schedule": "0 22 * * *",
+ "prompt": "Run Home Mode. Use the late-night guardrail: park open ideas, choose tomorrow's first action, and protect sleep.",
+ "enabled": false
+ }
+]
+```
+
+## Verification
+
+```powershell
+pnpm exec vitest run src/lib/home-mode.test.ts
+pnpm exec tsx scripts/home-mode-context.ts --now 2026-07-07T03:30:00.000Z --timezone America/Chicago
+```
diff --git a/docs/NOVA-MISSION-CONTROL-PREVIEW.html b/docs/NOVA-MISSION-CONTROL-PREVIEW.html
new file mode 100644
index 0000000000..61e8d00ceb
--- /dev/null
+++ b/docs/NOVA-MISSION-CONTROL-PREVIEW.html
@@ -0,0 +1,899 @@
+
+
+
+
+
+ Nova Mission Control Preview
+
+
+
+
+
+
+
+
Nova Mission Control
+
Net handle: NOVA.EXE
+
+
=
+
+
+
+
+ visual link -- standby
+
+
+ SoulSync // Stable
+ Connection: Secure
+
+
+ Agent roster
+
+
+
+
Nova Memory keeper
+
18.2k
+
+
+
+
Astra Planning orbit
+
7.4k
+
+
+
+
Claude Code support
+
42.1k
+
+
+
+
+
+
+
+ Fri Jul 03 - 15:42
+
+
SoulSync // Stable
+
Connection: Secure
+
Pause
+
+
+
+
+
+
+
+
+
EverOS online
+
Ollama warm
+
Proxy secure
+
+
+
+
episodes 128
+
atomic facts 842
+
learned today 6
+
backfill 74%
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Memory is online. I found the thread you meant.
+
Show me the bad days pattern without making it dramatic.
+
Answer first: you keep going even when the signal is low. The pattern is stubborn, not broken.
+
+
+
+
+ ^
+
+
+
+
+
+
+
+
+
hydration
+
nutrition
+
rest
+
overthinking
+
mood
+
+
BREATHE.
+
You do not have to solve the whole board in one breath.
+
+
+
+
+
+
+
+
+
Wire memory mock adapter in progress
+
Replace stock branding review
+
Check reduced motion states todo
+
Gateway reconnect strip blocked
+
+
+
+
+
+
+
+
diff --git a/docs/superpowers/plans/2026-07-10-nova-cockpit-pass.md b/docs/superpowers/plans/2026-07-10-nova-cockpit-pass.md
new file mode 100644
index 0000000000..242c577402
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-10-nova-cockpit-pass.md
@@ -0,0 +1,1821 @@
+# Nova Cockpit Pass 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:** Navy+amber cockpit chrome with a palette tripwire, dense hero-galaxy dashboard layout, and the dust-forward galaxy rebuild with 5-layer wayfinding.
+
+**Architecture:** The cockpit chrome is a single CSS-custom-property token map (`NOVA_COCKPIT_TOKENS`) applied once as inline `style` on the dashboard root, so every card that already reads `var(--theme-*)` recolors automatically — no per-card token renaming required. Status coloring is centralized behind a single `STATUS_TONE()` lookup so every card's "operational/degraded/offline/…" pill draws from the same gold/amber/warm-red/muted semantics instead of five divergent local maps. The galaxy is a `three.js` `WebGLRenderer` scene (not a 2D `` — see note under T6), so the dust-forward rebuild adds a procedural dust `THREE.Points` cloud, per-cluster nebula `THREE.Sprite`s, and ember-tier note markers on top of the existing planet/tag/comet/line object model, reusing the existing raycast-pick, camera-lerp, and label-projection machinery already in `Galaxy3D`.
+
+**Tech Stack:** React 19, TanStack Start, Tailwind v4, `three.js` `WebGLRenderer` in `mind-graph-card.tsx` (see architecture note — the task brief's "2D canvas" description does not match the real file), vitest.
+
+---
+
+## Task 1: Cockpit token + status modules
+
+**Files:**
+- Create `src/screens/dashboard/lib/nova-cockpit-theme.ts`
+- Create `src/screens/dashboard/lib/status-meta.ts`
+- Modify `src/screens/dashboard/dashboard-screen.tsx`
+
+- [ ] 1.1 Create `src/screens/dashboard/lib/nova-cockpit-theme.ts` with the full chrome token map, galaxy-only blue palette, and chart fallbacks:
+
+```ts
+import type { CSSProperties } from 'react'
+
+/**
+ * Cockpit chrome tokens — navy surfaces + amber/gold accents ONLY.
+ *
+ * This object is the single source of truth for every `var(--theme-*)`
+ * consumed by dashboard cards. Recoloring a value here recolors every
+ * card automatically (they already read the CSS custom property) — no
+ * per-card edits are needed for the chrome pass.
+ *
+ * Chrome ban list (binding, see docs/superpowers/specs/2026-07-10-
+ * galaxy-dust-forward-design.md): no purple, no green, no cyan as
+ * *chrome*. Neon blues stay canon ONLY inside `GALAXY_PALETTE` below,
+ * for the galaxy render and data-glow accents — never here.
+ *
+ * `nova-cockpit-theme.test.ts` is the tripwire: it fails the build if
+ * any value in this object resolves to a green/purple/cyan hue.
+ */
+export const NOVA_COCKPIT_TOKENS: Record = {
+ colorScheme: 'dark',
+ backgroundColor: 'var(--theme-bg)',
+ color: 'var(--theme-text)',
+ backgroundImage:
+ 'radial-gradient(circle at 14% 18%, rgba(255, 179, 71, 0.10), transparent 19rem), radial-gradient(circle at 76% 8%, rgba(255, 140, 26, 0.08), transparent 22rem), linear-gradient(rgba(255, 179, 71, 0.05) 1px, transparent 1px), linear-gradient(90deg, rgba(255, 179, 71, 0.05) 1px, transparent 1px)',
+ backgroundSize: 'auto, auto, 36px 36px, 36px 36px',
+ '--theme-bg': '#050b16',
+ '--theme-sidebar': '#071426',
+ '--theme-panel': '#0a1b33',
+ '--theme-card': '#0d203b',
+ '--theme-card2': '#10284a',
+ '--theme-elevated': '#153a66',
+ '--theme-border': 'rgba(255, 179, 71, 0.16)',
+ '--theme-border-subtle': 'rgba(255, 210, 122, 0.10)',
+ '--theme-border-strong': 'rgba(255, 140, 26, 0.52)',
+ '--theme-text': '#ffd27a',
+ '--theme-text-strong': '#fff1cc',
+ '--theme-text-soft': '#d4a276',
+ '--theme-muted': 'rgba(212, 190, 160, 0.55)',
+ '--theme-muted-2': 'rgba(212, 190, 160, 0.36)',
+ '--theme-glass': 'rgba(5, 11, 22, 0.88)',
+ '--theme-focus': '#ffd27a',
+ '--theme-accent': '#ff8c1a',
+ '--theme-accent-secondary': '#ffb347',
+ '--theme-accent-soft': '#ffd27a',
+ '--theme-on-accent': '#050b16',
+ // Legacy `--theme-blue*` var NAMES stay (many cards still reference
+ // them for badges/dividers), but their VALUES are recolored off cyan
+ // per the chrome ban list. `--theme-blue-deep` reuses the elevated
+ // navy surface color, which is a navy hue, not a saturated cyan glow.
+ '--theme-blue': '#ffb347',
+ '--theme-blue-secondary': '#ffd27a',
+ '--theme-blue-deep': '#153a66',
+ '--theme-blue-subtle': 'rgba(255, 179, 71, 0.08)',
+ '--theme-blue-border': 'rgba(255, 179, 71, 0.20)',
+ '--theme-accent-subtle': 'rgba(255, 140, 26, 0.10)',
+ '--theme-accent-border': 'rgba(255, 140, 26, 0.42)',
+ '--theme-glow-low': '0 0 10px rgba(255, 140, 26, 0.18)',
+ '--theme-glow-medium': '0 0 18px rgba(255, 140, 26, 0.28)',
+ '--theme-glow-high': '0 0 30px rgba(255, 179, 71, 0.38)',
+ '--theme-active': '#ff8c1a',
+ '--theme-link': '#ffb347',
+ '--theme-success': '#ffd27a',
+ '--theme-warning': '#ffb347',
+ '--theme-danger': '#ff6b4a',
+ '--theme-stripe': 'rgba(255, 179, 71, 0.055)',
+ '--theme-header-bg': 'rgba(5, 11, 22, 0.94)',
+ '--theme-header-border': 'rgba(255, 179, 71, 0.18)',
+ '--theme-input': '#071426',
+ '--theme-hover': 'rgba(255, 179, 71, 0.10)',
+ '--color-surface': '#050b16',
+ '--color-surface-deep': '#020712',
+ '--color-ink': '#ffd27a',
+}
+
+/**
+ * Cast for use as inline `style` (matches the shape `CSSProperties`
+ * expects for custom properties, mirroring the old
+ * `NOVA_MISSION_CONTROL_STYLE` usage in dashboard-screen.tsx).
+ */
+export const NOVA_COCKPIT_STYLE = NOVA_COCKPIT_TOKENS as CSSProperties
+
+/**
+ * Neon-blue + amber palette for the galaxy render and data-glow accents
+ * ONLY. Never spread these into card chrome — that's what the tripwire
+ * test guards against.
+ */
+export const GALAXY_PALETTE = {
+ space: ['#030710', '#0A1424'],
+ blues: ['#63C7FF', '#9DDCFF', '#2E7FD9'],
+ ambers: ['#FF8C1A', '#FFB347', '#FFD27A'],
+} as const
+
+/**
+ * Fallback colors for `readDashboardPalette()` in dashboard-screen.tsx,
+ * used only before the `--theme-*` custom properties resolve (SSR /
+ * first paint). Replaces the old indigo/violet/green Tailwind defaults
+ * with cockpit-canon equivalents so there's no purple/green flash.
+ */
+export const CHART_FALLBACKS = {
+ accent: '#FF8C1A',
+ accentSecondary: '#FFB347',
+ success: '#FFD27A',
+ warning: '#FFB347',
+ danger: '#FF6B4A',
+ muted: '#8A7A68',
+ border: '#3A2C1A',
+ card: '#0D203B',
+ text: '#FFD27A',
+} as const
+```
+
+- [ ] 1.2 Create `src/screens/dashboard/lib/status-meta.ts` — the single status→tone lookup replacing five divergent per-card maps:
+
+```ts
+/**
+ * Canonical status tones for every dashboard status literal in use
+ * across live-systems-card, agent-lanes-card, nova-session-bridge-card,
+ * agent-workforce-card, and control-loops-card.
+ *
+ * Bucketed per the binding spec: healthy = gold (--theme-success),
+ * attention/needs-Taylor = amber (--theme-warning), danger = warm
+ * red-amber (--theme-danger), inert = navy-muted (--theme-muted).
+ * Distinction is by color family + the caller's own dot/pill shape —
+ * never by green.
+ */
+export type DashboardStatusLiteral =
+ | 'operational'
+ | 'connected'
+ | 'reachable'
+ | 'approval-gated'
+ | 'degraded'
+ | 'offline'
+ | 'not-wired'
+ | 'active'
+ | 'idle'
+ | 'setup-needed'
+ | 'unknown'
+ | 'ready'
+ | 'partial'
+
+export type StatusTone = {
+ label: string
+ dot: string
+ tone: string
+}
+
+type ToneFamily = 'success' | 'warning' | 'danger' | 'muted'
+
+const FAMILY: Record = {
+ operational: 'success',
+ connected: 'success',
+ active: 'success',
+ ready: 'success',
+ reachable: 'warning',
+ 'approval-gated': 'warning',
+ degraded: 'warning',
+ 'setup-needed': 'warning',
+ partial: 'warning',
+ offline: 'danger',
+ 'not-wired': 'muted',
+ idle: 'muted',
+ unknown: 'muted',
+}
+
+const LABEL_OVERRIDE: Partial> = {
+ 'approval-gated': 'needs Taylor',
+ 'not-wired': 'not wired',
+ 'setup-needed': 'setup needed',
+}
+
+const FAMILY_VAR: Record = {
+ success: '--theme-success',
+ warning: '--theme-warning',
+ danger: '--theme-danger',
+ muted: '--theme-muted',
+}
+
+function toneClasses(family: ToneFamily): { dot: string; tone: string } {
+ const varName = FAMILY_VAR[family]
+ return {
+ dot: `bg-[var(${varName})]`,
+ tone: `border-[color-mix(in_srgb,var(${varName})_35%,var(--theme-border))] text-[var(${varName})]`,
+ }
+}
+
+/**
+ * Resolve a dashboard status literal to its label/dot/tone classes.
+ * Unknown strings fall back to the `muted` family rather than throwing,
+ * since status enums are sourced from server payloads.
+ */
+export function STATUS_TONE(status: string): StatusTone {
+ const known = status as DashboardStatusLiteral
+ const family = FAMILY[known] ?? 'muted'
+ const classes = toneClasses(family)
+ return {
+ label: LABEL_OVERRIDE[known] ?? status,
+ dot: classes.dot,
+ tone: classes.tone,
+ }
+}
+```
+
+- [ ] 1.3 Modify `src/screens/dashboard/dashboard-screen.tsx`: delete the inline `NOVA_MISSION_CONTROL_STYLE` literal (lines 133–183) and the purple-tinted fallbacks in `readDashboardPalette`, importing both from the new theme module.
+
+ Delete:
+ ```ts
+ const NOVA_MISSION_CONTROL_STYLE = {
+ colorScheme: 'dark',
+ // … (full object, lines 133-183)
+ } as CSSProperties
+ ```
+
+ Add near the top imports (after the `WidgetShell` import block):
+ ```ts
+ import { CHART_FALLBACKS, NOVA_COCKPIT_STYLE } from './lib/nova-cockpit-theme'
+ ```
+
+ Replace `readDashboardPalette`:
+ ```ts
+ function readDashboardPalette() {
+ return {
+ accent: themeColor('--theme-accent', CHART_FALLBACKS.accent),
+ accentSecondary: themeColor(
+ '--theme-accent-secondary',
+ CHART_FALLBACKS.accentSecondary,
+ ),
+ success: themeColor('--theme-success', CHART_FALLBACKS.success),
+ warning: themeColor('--theme-warning', CHART_FALLBACKS.warning),
+ danger: themeColor('--theme-danger', CHART_FALLBACKS.danger),
+ muted: themeColor('--theme-muted', CHART_FALLBACKS.muted),
+ border: themeColor('--theme-border', CHART_FALLBACKS.border),
+ card: themeColor('--theme-card', CHART_FALLBACKS.card),
+ text: themeColor('--theme-text', CHART_FALLBACKS.text),
+ }
+ }
+ ```
+
+ Find every JSX usage of `NOVA_MISSION_CONTROL_STYLE` (the dashboard root `` wrapper) and rename to `NOVA_COCKPIT_STYLE`.
+
+- [ ] 1.4 Run `npx tsc --noEmit -p tsconfig.json` (or the repo's existing typecheck script) — expect zero new errors from the rename.
+
+- [ ] 1.5 Commit:
+ ```
+ git add src/screens/dashboard/lib/nova-cockpit-theme.ts src/screens/dashboard/lib/status-meta.ts src/screens/dashboard/dashboard-screen.tsx
+ git commit -m "cockpit: navy+amber chrome tokens and centralized status tones"
+ ```
+
+---
+
+## Task 2: Palette tripwire test
+
+**Files:**
+- Create `src/screens/dashboard/lib/nova-cockpit-theme.test.ts`
+
+- [ ] 2.1 Write the test file. Write it FIRST, run it, confirm it currently passes against the T1 output (T1 must already be committed — this is a regression tripwire, not classic TDD-before-implementation, since T1's token values were authored together with this test's bucket math):
+
+```ts
+import { describe, expect, it } from 'vitest'
+import { GALAXY_PALETTE, NOVA_COCKPIT_TOKENS } from './nova-cockpit-theme'
+
+type Hsl = { h: number; s: number; l: number }
+
+/** Minimal #rgb / #rrggbb / rgba(r,g,b,a) → HSL, tripwire-local only. */
+function hexToHsl(hex: string): Hsl {
+ let r = 0
+ let g = 0
+ let b = 0
+ if (hex.length === 4) {
+ r = parseInt(hex[1] + hex[1], 16)
+ g = parseInt(hex[2] + hex[2], 16)
+ b = parseInt(hex[3] + hex[3], 16)
+ } else {
+ r = parseInt(hex.slice(1, 3), 16)
+ g = parseInt(hex.slice(3, 5), 16)
+ b = parseInt(hex.slice(5, 7), 16)
+ }
+ return rgbToHsl(r, g, b)
+}
+
+function rgbToHsl(r: number, g: number, b: number): Hsl {
+ const rn = r / 255
+ const gn = g / 255
+ const bn = b / 255
+ const max = Math.max(rn, gn, bn)
+ const min = Math.min(rn, gn, bn)
+ const l = (max + min) / 2
+ const delta = max - min
+ if (delta === 0) return { h: 0, s: 0, l }
+ const s = delta / (1 - Math.abs(2 * l - 1))
+ let h: number
+ if (max === rn) h = 60 * (((gn - bn) / delta) % 6)
+ else if (max === gn) h = 60 * ((bn - rn) / delta + 2)
+ else h = 60 * ((rn - gn) / delta + 4)
+ if (h < 0) h += 360
+ return { h, s, l }
+}
+
+/** Extract every #hex / rgba(...) color literal in a CSS value string. */
+function extractColors(value: string): Array
{
+ const colors: Array = []
+ const hexMatches = value.match(/#[0-9a-fA-F]{3,6}\b/g) ?? []
+ for (const hex of hexMatches) colors.push(hexToHsl(hex))
+ const rgbaMatches = value.matchAll(
+ /rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*[\d.]+\s*)?\)/g,
+ )
+ for (const match of rgbaMatches) {
+ colors.push(rgbToHsl(Number(match[1]), Number(match[2]), Number(match[3])))
+ }
+ return colors
+}
+
+function isNeutral(c: Hsl): boolean {
+ return c.s < 0.12
+}
+function isNavySurface(c: Hsl): boolean {
+ return c.h >= 200 && c.h <= 235 && c.l < 0.35
+}
+function isAmberOrGold(c: Hsl): boolean {
+ return c.h >= 15 && c.h <= 50
+}
+function isWarmRed(c: Hsl): boolean {
+ return c.h >= 0 && c.h < 15
+}
+function isGreenLeak(c: Hsl): boolean {
+ return c.h > 70 && c.h < 170 && c.s >= 0.12
+}
+function isPurpleLeak(c: Hsl): boolean {
+ return c.h > 250 && c.h < 300 && c.s >= 0.12
+}
+function isCyanChromeLeak(c: Hsl): boolean {
+ return c.h > 170 && c.h < 200 && c.s > 0.3
+}
+
+describe('cockpit chrome token tripwire', () => {
+ const entries = Object.entries(NOVA_COCKPIT_TOKENS)
+
+ it('every chrome token color resolves to navy, amber/gold, warm-red, or neutral', () => {
+ for (const [key, value] of entries) {
+ const colors = extractColors(value)
+ for (const color of colors) {
+ const ok =
+ isNeutral(color) ||
+ isNavySurface(color) ||
+ isAmberOrGold(color) ||
+ isWarmRed(color)
+ expect(
+ ok,
+ `${key}="${value}" resolved to h=${color.h.toFixed(1)} s=${color.s.toFixed(2)} l=${color.l.toFixed(2)}, outside the navy/amber/warm-red/neutral chrome canon`,
+ ).toBe(true)
+ }
+ }
+ })
+
+ it('no chrome token leaked a green hue', () => {
+ for (const [key, value] of entries) {
+ for (const color of extractColors(value)) {
+ expect(isGreenLeak(color), `${key}="${value}" leaked a green hue`).toBe(
+ false,
+ )
+ }
+ }
+ })
+
+ it('no chrome token leaked a purple hue', () => {
+ for (const [key, value] of entries) {
+ for (const color of extractColors(value)) {
+ expect(
+ isPurpleLeak(color),
+ `${key}="${value}" leaked a purple hue`,
+ ).toBe(false)
+ }
+ }
+ })
+
+ it('no chrome token leaked a saturated cyan hue', () => {
+ for (const [key, value] of entries) {
+ for (const color of extractColors(value)) {
+ expect(
+ isCyanChromeLeak(color),
+ `${key}="${value}" leaked a saturated cyan hue into chrome`,
+ ).toBe(false)
+ }
+ }
+ })
+
+ it('GALAXY_PALETTE.blues stays untouched — neon blues are canon there, never in chrome', () => {
+ expect(GALAXY_PALETTE.blues).toEqual(['#63C7FF', '#9DDCFF', '#2E7FD9'])
+ })
+})
+```
+
+- [ ] 2.2 Run `npx vitest run src/screens/dashboard/lib/nova-cockpit-theme.test.ts` — expect all 5 tests to pass (0 failures). If any chrome token fails, fix its value in `nova-cockpit-theme.ts` (T1), not the test.
+
+- [ ] 2.3 Commit:
+ ```
+ git add src/screens/dashboard/lib/nova-cockpit-theme.test.ts
+ git commit -m "cockpit: add green/purple/cyan tripwire test for chrome tokens"
+ ```
+
+---
+
+## Task 3: Migrate card status colors to STATUS_TONE
+
+**Files:**
+- Modify `src/screens/dashboard/components/live-systems-card.tsx`
+- Modify `src/screens/dashboard/components/agent-lanes-card.tsx`
+- Modify `src/screens/dashboard/components/nova-session-bridge-card.tsx`
+- Modify `src/screens/dashboard/components/agent-workforce-card.tsx`
+- Modify `src/screens/dashboard/components/control-loops-card.tsx`
+
+- [ ] 3.1 `live-systems-card.tsx` — delete the local `STATUS_META` object and `StatusPill`'s direct lookup, replace with `STATUS_TONE`:
+
+ Delete lines 7–46 (`const STATUS_META: Record = { … }`).
+
+ Add import:
+ ```ts
+ import { STATUS_TONE } from '../lib/status-meta'
+ ```
+
+ Replace `StatusPill`:
+ ```ts
+ function StatusPill({ status }: { status: LiveSystemStatus }) {
+ const meta = STATUS_TONE(status)
+ return (
+
+
+ {meta.label}
+
+ )
+ }
+ ```
+ (Markup is unchanged — `STATUS_TONE('operational')` and `STATUS_TONE('approval-gated')` reproduce the same `{label, dot, tone}` shape the old literal returned, just gold/amber/warm-red instead of blue/amber/orange.)
+
+- [ ] 3.2 `agent-lanes-card.tsx` — delete the local `STATUS_META` (lines 14–20), import `STATUS_TONE`, update `LaneRow`:
+
+ Delete:
+ ```ts
+ const STATUS_META: Record = {
+ active: { label: 'active', dot: 'bg-[var(--theme-success)]' },
+ idle: { label: 'idle', dot: 'bg-[color-mix(in_srgb,var(--theme-success)_55%,var(--theme-muted))]' },
+ offline: { label: 'offline', dot: 'bg-[var(--theme-danger)]' },
+ 'setup-needed': { label: 'setup needed', dot: 'bg-[var(--theme-accent)]' },
+ unknown: { label: 'unknown', dot: 'bg-[var(--theme-muted)]' },
+ }
+ ```
+ Add:
+ ```ts
+ import { STATUS_TONE } from '../lib/status-meta'
+ ```
+ In `LaneRow`, replace `const meta = STATUS_META[lane.status]` with `const meta = STATUS_TONE(lane.status)`. The pill JSX (` {meta.label} `) is unchanged since `meta.dot`/`meta.label` keep the same shape.
+
+- [ ] 3.3 `nova-session-bridge-card.tsx` — replace `SOURCE_TONE` with a 3-line adapter over `STATUS_TONE` (this card's sources use a different 3-value enum, `ok | degraded | unavailable`, and render plain text with no border/pill, so only the text-color class is extracted):
+
+ Delete:
+ ```ts
+ const SOURCE_TONE: Record = {
+ ok: 'text-[var(--theme-success)]',
+ degraded: 'text-[var(--theme-warning)]',
+ unavailable: 'text-[var(--theme-muted)]',
+ }
+ ```
+ Add:
+ ```ts
+ import { STATUS_TONE } from '../lib/status-meta'
+
+ // 3-line adapter: SessionBridgeSourceState → STATUS_TONE's status
+ // literal, then strip the border-color class since this label is
+ // plain inline text (no pill/border in the markup below).
+ function sourceToneText(state: SessionBridgeSourceState): string {
+ const status = state === 'ok' ? 'operational' : state === 'degraded' ? 'degraded' : 'not-wired'
+ return STATUS_TONE(status).tone.replace(/border-\S+\s*/, '').trim()
+ }
+ ```
+ Update the usage site:
+ ```tsx
+
+ {source.label}: {source.state}
+
+ ```
+
+- [ ] 3.4 `agent-workforce-card.tsx` — delete the local `STATUS_META` (lines 7–38), import `STATUS_TONE`, adapt `AgentWorkerStatus` (`idle|queued|running|blocked|reviewing|done`, which has no 1:1 match in `DashboardStatusLiteral`) with a small map:
+
+ Delete the `STATUS_META` object.
+ Add:
+ ```ts
+ import { STATUS_TONE } from '../lib/status-meta'
+
+ const WORKER_STATUS_MAP: Record = {
+ idle: 'idle',
+ queued: 'setup-needed',
+ running: 'active',
+ blocked: 'offline',
+ reviewing: 'degraded',
+ done: 'not-wired',
+ }
+ ```
+ In `WorkerRow`, replace `const meta = STATUS_META[worker.status]` with:
+ ```ts
+ const meta = STATUS_TONE(WORKER_STATUS_MAP[worker.status])
+ ```
+
+- [ ] 3.5 `control-loops-card.tsx` — delete `LOOP_STATUS_STYLE` and `SOURCE_STATUS_STYLE` (lines 25–39), replace with `STATUS_TONE`-derived lookups. `ControlLoopStatus` (`ready|partial|not-wired`) maps 1:1; `ControlLoopSourceStatus` (`connected|available|not-wired|unavailable`) needs a small adapter since it renders only a ` `, no border:
+
+ Delete:
+ ```ts
+ const LOOP_STATUS_STYLE: Record = { … }
+ const SOURCE_STATUS_STYLE: Record = { … }
+ ```
+ Add:
+ ```ts
+ import { STATUS_TONE } from '../lib/status-meta'
+
+ function loopStatusPillClass(status: ControlLoopStatus): string {
+ const meta = STATUS_TONE(status)
+ return `${meta.tone} bg-[color-mix(in_srgb,var(--theme-border)_0%,transparent)]`
+ }
+
+ const SOURCE_STATUS_MAP: Record = {
+ connected: 'operational',
+ available: 'setup-needed',
+ 'not-wired': 'not-wired',
+ unavailable: 'offline',
+ }
+ ```
+ Update `StatusBadge`:
+ ```tsx
+ function StatusBadge({ status }: { status: ControlLoopStatus }) {
+ return (
+
+ {formatStatus(status)}
+
+ )
+ }
+ ```
+ Update the source dot usage inside `LoopCard`:
+ ```tsx
+
+ ```
+
+- [ ] 3.6 Run the migrated cards through typecheck and the existing dashboard test suite (there is no dashboard-specific test file yet — see T2 for the only current dashboard test — so this step is a build/typecheck gate, not a vitest run):
+ ```
+ npx tsc --noEmit -p tsconfig.json
+ ```
+ Expect zero errors referencing `STATUS_META`, `SOURCE_TONE`, `LOOP_STATUS_STYLE`, or `SOURCE_STATUS_STYLE` (all five must be fully removed, not just unused).
+
+- [ ] 3.7 Commit:
+ ```
+ git add src/screens/dashboard/components/live-systems-card.tsx src/screens/dashboard/components/agent-lanes-card.tsx src/screens/dashboard/components/nova-session-bridge-card.tsx src/screens/dashboard/components/agent-workforce-card.tsx src/screens/dashboard/components/control-loops-card.tsx
+ git commit -m "cockpit: migrate 5 cards from local STATUS_META literals to STATUS_TONE"
+ ```
+
+---
+
+## Task 4: Hero galaxy + dense grid
+
+**Files:**
+- Modify `src/screens/dashboard/dashboard-screen.tsx`
+- Modify `src/screens/dashboard/components/mind-graph-card.tsx`
+
+**Context:** `MindGraphCard` is currently mounted unwrapped (not through `WidgetShell`, unlike every other card) directly after ` ` at line 1240, so it is always visible and not part of `WIDGET_CATALOG`. It stays that way — the spec calls it "the hero," not a toggleable widget. Today its canvas is `h-[58vh] min-h-[360px] ... sm:h-[560px] lg:h-[650px]` (a fixed-px override on lg+ that caps it well under 70vh on tall monitors). The primary-ops cards (`live_systems`, `taylor_approvals`, `session_bridge`) are currently scattered mid-page (lines 1244–1271); this task pulls them to sit directly under the galaxy and marks the decorative cards `data-tier="quiet"`.
+
+- [ ] 4.1 Modify `mind-graph-card.tsx` — widen the galaxy viewport on desktop. Replace:
+ ```tsx
+
+ ```
+ with:
+ ```tsx
+
+ ```
+ (Drops the `sm:h-[560px] lg:h-[650px]` px caps in favor of a real `70vh` on lg+ per the spec's "not a ~350px strip" note, keeping `min-h-[560px]` as a floor on short viewports. The sidebar `xl:flex xl:w-72 xl:flex-col` in the parent `
` already sits beside the field at `xl+`, not below — no change needed there.)
+
+- [ ] 4.2 Modify `dashboard-screen.tsx` — regroup the top of the card stack. Replace the block from ` ` through the `` close (lines 1238–1271) with:
+
+ ```tsx
+
+
+
+
+ {/* ── Primary ops row: hero-adjacent, always the first thing seen
+ after the galaxy. Iteration nova-cockpit-pass: pulled live
+ systems / approvals / session bridge out of the scattered
+ mid-page order so the operator sees "is anything broken" and
+ "does Nova need me" immediately below the galaxy. ── */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ```
+
+ (`galaxy → approvals → session_bridge → live_systems` matches the task order exactly: the galaxy is unwrapped/always-on so it's first by construction, then approvals+session_bridge share a row, then live_systems gets its own full-width row since it's the widest card. `agent_lanes`/`nova_wants` are paired below since neither is in the primary-ops set. Tightened from the original single-column stack — was 8 full-width sections in a row, now 2 two-up rows + 4 full-width, cutting vertical scroll roughly in half before Hero Metrics.)
+
+- [ ] 4.3 Modify `dashboard-screen.tsx` — tighten the analytics/session-rail grid gaps from the default 3-unit gap already in place (`gap-3` is already the value used at lines 1297, 1361 — confirm no larger gap classes remain) by auditing for any `gap-4`/`gap-6` in the dashboard JSX and downgrading to `gap-3`:
+ ```
+ grep -n "gap-4\|gap-6" src/screens/dashboard/dashboard-screen.tsx
+ ```
+ For each match found in the card-grid containers (not inside a single card's internal layout, which is out of scope), change to `gap-3`.
+
+- [ ] 4.4 Modify `dashboard-screen.tsx` — mark the six named decorative cards `data-tier="quiet"` with reduced padding/type scale. Each of these renders through ` `; since `WidgetShell` passes through children unchanged in non-edit mode (see `widget-shell.tsx` line 40, `return <>{children}>`), the `data-tier` attribute must go on each card's own root ``, not the `WidgetShell` wrapper. For each of `operator-tip-card.tsx`, `achievements-card.tsx`, `token-mix-hour-card.tsx` (the `mix_rhythm` widget), `skills-usage-card.tsx`, `notebooklm-bridge-card.tsx`, `trust-ledger-card.tsx`:
+ - Add `data-tier="quiet"` to the card's root ``.
+ - Change the root's padding class from `p-4` to `p-3` (or add `p-3` if unset).
+ - Change the card's header label class (the `font-mono ... uppercase tracking-[0.18em] text-[var(--theme-muted)]` header line each card already has, matching the pattern seen in `live-systems-card.tsx`/`agent-workforce-card.tsx`) from `text-[10px]` to `text-[10px]` with an added `data-tier` scoped override — add this once, globally, instead of touching six files individually. In `dashboard-screen.tsx`'s top-level style block (or a new small `
+ ```
+ placed once, immediately after the dashboard root `` open tag. (`nova-label` is the shared class every card already uses for its section header — confirmed via `mind-graph-card.tsx`'s `
Operational map
` and the same class name pattern in the header rows above — so this one CSS rule quiets every quiet-tier card's header without touching each file's className strings.) Then in each of the six files, add only `data-tier="quiet"` to the root `
` element — a one-line, one-attribute diff per file.
+
+- [ ] 4.5 Run `npx vite build` — expect a clean build with no missing-import or JSX errors from the reordering.
+
+- [ ] 4.6 Commit:
+ ```
+ git add src/screens/dashboard/dashboard-screen.tsx src/screens/dashboard/components/mind-graph-card.tsx src/screens/dashboard/components/operator-tip-card.tsx src/screens/dashboard/components/achievements-card.tsx src/screens/dashboard/components/token-mix-hour-card.tsx src/screens/dashboard/components/skills-usage-card.tsx src/screens/dashboard/components/notebooklm-bridge-card.tsx src/screens/dashboard/components/trust-ledger-card.tsx
+ git commit -m "cockpit: hero-height galaxy, primary-ops row reorder, quiet-tier decorative cards"
+ ```
+
+---
+
+## Task 5: Galaxy model v2 (pure)
+
+**Files:**
+- Modify `src/screens/dashboard/components/nova-galaxy-model.ts`
+- Create `src/screens/dashboard/components/nova-galaxy-model-v2.test.ts`
+
+TDD order: write the test file first, watch it fail (functions don't exist yet), then add the functions to `nova-galaxy-model.ts`.
+
+- [ ] 5.1 Write `nova-galaxy-model-v2.test.ts`:
+
+```ts
+import { describe, expect, it } from 'vitest'
+import {
+ HUB_DEGREE_THRESHOLD,
+ clusterHue,
+ emberSize,
+ gaussianFrom,
+ isHub,
+ mulberry32,
+ recencyGlow,
+ spiralPosition,
+} from './nova-galaxy-model'
+
+describe('mulberry32 + gaussianFrom', () => {
+ it('is deterministic for a fixed seed', () => {
+ const a = mulberry32(42)
+ const b = mulberry32(42)
+ expect(a()).toBe(b())
+ expect(a()).toBe(b())
+ })
+
+ it('produces different sequences for different seeds', () => {
+ const a = mulberry32(1)()
+ const b = mulberry32(2)()
+ expect(a).not.toBe(b)
+ })
+
+ it('gaussianFrom stays finite across many draws', () => {
+ const rng = mulberry32(7)
+ for (let i = 0; i < 200; i += 1) {
+ expect(Number.isFinite(gaussianFrom(rng))).toBe(true)
+ }
+ })
+})
+
+describe('spiralPosition', () => {
+ it('is deterministic — same inputs produce the same outputs', () => {
+ const a = spiralPosition(5, 1, 3, 0.6)
+ const b = spiralPosition(5, 1, 3, 0.6)
+ expect(a).toEqual(b)
+ })
+
+ it('differs across arms for the same seed and radius', () => {
+ const arm0 = spiralPosition(5, 0, 3, 0.6)
+ const arm1 = spiralPosition(5, 1, 3, 0.6)
+ expect(arm0).not.toEqual(arm1)
+ })
+
+ it('radius grows with radiusNorm — low radiusNorm stays near the core, high radiusNorm reaches the rim', () => {
+ // radiusNorm gates the exponential term multiplicatively, so the
+ // gap between a low and high sample dwarfs the bounded jitter
+ // term at every seed — this is a wide-margin comparison, not an
+ // exact-boundary one, precisely so it isn't seed-flaky.
+ for (let seed = 1; seed <= 10; seed += 1) {
+ const near = spiralPosition(seed, 0, 3, 0.05)
+ const far = spiralPosition(seed, 0, 3, 0.95)
+ const nearRadius = Math.hypot(near.x, near.z)
+ const farRadius = Math.hypot(far.x, far.z)
+ expect(farRadius, `seed ${seed}: far=${farRadius} near=${nearRadius}`).toBeGreaterThan(
+ nearRadius,
+ )
+ }
+ })
+})
+
+describe('clusterHue', () => {
+ it('cycles through the 4-hue palette deterministically', () => {
+ expect(clusterHue(0)).toBe('blue')
+ expect(clusterHue(1)).toBe('blue2')
+ expect(clusterHue(2)).toBe('amber')
+ expect(clusterHue(3)).toBe('blend')
+ expect(clusterHue(4)).toBe('blue')
+ })
+
+ it('is stable for the same folderIndex', () => {
+ expect(clusterHue(11)).toBe(clusterHue(11))
+ })
+})
+
+describe('recencyGlow', () => {
+ const now = '2026-07-10T12:00:00.000Z'
+
+ it('is 1.0 for anything modified within the last 7 days', () => {
+ expect(recencyGlow('2026-07-09T12:00:00.000Z', now)).toBe(1)
+ expect(recencyGlow(now, now)).toBe(1)
+ })
+
+ it('falls off linearly and clamps at 0.35 for anything 90+ days old', () => {
+ expect(recencyGlow('2026-04-11T12:00:00.000Z', now)).toBe(0.35)
+ expect(recencyGlow('2020-01-01T00:00:00.000Z', now)).toBe(0.35)
+ })
+
+ it('sits strictly between 0.35 and 1 at the midpoint of the falloff window', () => {
+ const midpoint = new Date(
+ Date.parse('2026-07-03T12:00:00.000Z') -
+ ((90 - 7) / 2) * 86_400_000,
+ ).toISOString()
+ const glow = recencyGlow(midpoint, now)
+ expect(glow).toBeGreaterThan(0.35)
+ expect(glow).toBeLessThan(1)
+ })
+
+ it('falls back to 0.35 for unparseable dates', () => {
+ expect(recencyGlow('not-a-date', now)).toBe(0.35)
+ })
+})
+
+describe('emberSize + isHub', () => {
+ it('grows monotonically with degree', () => {
+ expect(emberSize(0)).toBeLessThan(emberSize(3))
+ expect(emberSize(3)).toBeLessThan(emberSize(10))
+ })
+
+ it('is 1 at degree 0', () => {
+ expect(emberSize(0)).toBe(1)
+ })
+
+ it('flags hubs at the documented threshold', () => {
+ expect(HUB_DEGREE_THRESHOLD).toBe(6)
+ expect(isHub(5)).toBe(false)
+ expect(isHub(6)).toBe(true)
+ expect(isHub(20)).toBe(true)
+ })
+})
+```
+
+- [ ] 5.2 Run `npx vitest run src/screens/dashboard/components/nova-galaxy-model-v2.test.ts` — expect all tests to FAIL with "does not provide an export named …" (functions don't exist yet).
+
+- [ ] 5.3 Append the new pure functions to `nova-galaxy-model.ts` (after the existing `obsidianUri` export, before `fallbackFolder`, so they sit alongside the other small pure helpers already in the file — `clamp` and `seededUnit` above are reused, not duplicated):
+
+```ts
+/**
+ * Deterministic PRNG (mulberry32). Used for the dust-forward field's
+ * procedural scatter so the same vault graph always renders the same
+ * dust cloud — no re-shuffling on every re-render.
+ */
+export function mulberry32(seed: number): () => number {
+ let a = seed >>> 0
+ return () => {
+ a = (a + 0x6d2b79f5) | 0
+ let t = Math.imul(a ^ (a >>> 15), 1 | a)
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296
+ }
+}
+
+/** Standard normal sample via Box-Muller, drawn from a mulberry32 rng. */
+export function gaussianFrom(rng: () => number): number {
+ const u1 = Math.max(rng(), 1e-9)
+ const u2 = rng()
+ return Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2)
+}
+
+const SPIRAL_A = 1.6
+const SPIRAL_B = 0.28
+const SPIRAL_THETA_SPAN = 6.2
+
+/**
+ * A point on a 3-arm logarithmic spiral (r = a·e^(bθ)), with
+ * deterministic gaussian scatter layered on top so the arm reads as
+ * dust rather than a clean line. `radiusNorm` in [0, 1] gates both the
+ * radius (0 = core, 1 = rim) and the scatter width (dust is tighter
+ * near the core, wider toward the rim).
+ */
+export function spiralPosition(
+ seedIndex: number,
+ armIndex: number,
+ armCount: number,
+ radiusNorm: number,
+): { x: number; y: number; z: number } {
+ const rng = mulberry32(seedIndex)
+ const armOffset = (Math.PI * 2 * armIndex) / Math.max(1, armCount)
+ const clampedRadius = clamp(radiusNorm, 0, 1)
+ const theta = clampedRadius * SPIRAL_THETA_SPAN + armOffset
+ const r = SPIRAL_A * Math.exp(SPIRAL_B * theta) * clampedRadius
+ const scatter = 1 - clampedRadius * 0.4
+ const jitterX = gaussianFrom(rng) * scatter * 2.4
+ const jitterY = gaussianFrom(rng) * scatter * 1.1
+ const jitterZ = gaussianFrom(rng) * scatter * 2.4
+ return {
+ x: Math.cos(theta) * r + jitterX,
+ y: jitterY,
+ z: Math.sin(theta) * r + jitterZ,
+ }
+}
+
+const CLUSTER_HUE_CYCLE = ['blue', 'blue2', 'amber', 'blend'] as const
+export type ClusterHue = (typeof CLUSTER_HUE_CYCLE)[number]
+
+/** Round-robin cluster tint assignment: neon blues + one amber + blends. */
+export function clusterHue(folderIndex: number): ClusterHue {
+ const index =
+ ((folderIndex % CLUSTER_HUE_CYCLE.length) + CLUSTER_HUE_CYCLE.length) %
+ CLUSTER_HUE_CYCLE.length
+ return CLUSTER_HUE_CYCLE[index]
+}
+
+const RECENCY_HOT_DAYS = 7
+const RECENCY_COLD_DAYS = 90
+const RECENCY_FLOOR = 0.35
+
+/**
+ * Recency-driven ember brightness: 1.0 for anything touched in the
+ * last week, linear falloff to a 0.35 floor by 90 days, clamped.
+ */
+export function recencyGlow(modifiedIso: string, nowIso: string): number {
+ const modified = Date.parse(modifiedIso)
+ const now = Date.parse(nowIso)
+ if (!Number.isFinite(modified) || !Number.isFinite(now)) return RECENCY_FLOOR
+ const ageDays = Math.max(0, (now - modified) / 86_400_000)
+ if (ageDays <= RECENCY_HOT_DAYS) return 1
+ if (ageDays >= RECENCY_COLD_DAYS) return RECENCY_FLOOR
+ const t =
+ (ageDays - RECENCY_HOT_DAYS) / (RECENCY_COLD_DAYS - RECENCY_HOT_DAYS)
+ return clamp(1 - t * (1 - RECENCY_FLOOR), RECENCY_FLOOR, 1)
+}
+
+export const HUB_DEGREE_THRESHOLD = 6
+
+/** Ember visual size from wikilink degree — log-scaled so hubs read big without dominating. */
+export function emberSize(degree: number): number {
+ return 1 + Math.log2(1 + Math.max(0, degree)) * 0.6
+}
+
+/** Whether a note's degree crosses the hub threshold (amber pulse tier). */
+export function isHub(degree: number): boolean {
+ return degree >= HUB_DEGREE_THRESHOLD
+}
+```
+
+- [ ] 5.4 Run `npx vitest run src/screens/dashboard/components/nova-galaxy-model-v2.test.ts` — expect all tests to PASS.
+
+- [ ] 5.5 Run `npx vitest run src/screens/dashboard/components/` (or whatever glob matches existing galaxy-model tests, if any pre-date this plan) to confirm the append did not break existing exports — since this step only *appends* new named exports and reuses (not redefines) `clamp`, no existing export's behavior changes.
+
+- [ ] 5.6 Commit:
+ ```
+ git add src/screens/dashboard/components/nova-galaxy-model.ts src/screens/dashboard/components/nova-galaxy-model-v2.test.ts
+ git commit -m "galaxy-model: add spiralPosition/clusterHue/recencyGlow/emberSize pure helpers"
+ ```
+
+---
+
+## Task 6: Dust-forward renderer
+
+**Files:**
+- Modify `src/screens/dashboard/components/mind-graph-card.tsx`
+
+**Architecture note (deviation from the task brief — read before editing):** the task brief describes this card as "already uses a 2D canvas" and asks to keep 2D canvas with `globalCompositeOperation='lighter'`, no `three.js` dependency. That does not match the real file: `mind-graph-card.tsx` imports `* as THREE from 'three'` and drives a full `THREE.WebGLRenderer` scene (`Galaxy3D`, lines 316–972) — perspective camera, raycasting, textured planet meshes, `THREE.Points` starfield, a `THREE.Sprite` fog nebula. There is no 2D canvas anywhere in this file. This task implements the same dust-forward look using `three.js` primitives instead (`THREE.Points` with `AdditiveBlending` for dust — the direct WebGL equivalent of 2D `globalCompositeOperation='lighter'` — and `THREE.Sprite` for nebula regions, which the file already uses for its single hardcoded fog sprite). This satisfies the spec's "one canvas/renderer" budget line (`WebGLRenderer` owns exactly one ``) without introducing a second rendering stack. Do not add a 2D `` element to this file — that would violate the spec's own one-renderer budget.
+
+- [ ] 6.1 Add the dust-field + nebula-cluster data prep as a `useMemo` inside `MindGraphCard`, computed once per `model` change (not per frame). Insert after the existing `const model = useMemo(...)` block:
+
+```ts
+type DustField = {
+ positions: Float32Array
+ colors: Float32Array
+}
+
+type NebulaRegion = {
+ armId: string
+ color: string
+ position: { x: number; y: number; z: number }
+ scale: number
+}
+
+const DUST_POINTS_PER_ARM = 5500
+const DUST_ARM_COUNT = 3
+
+function clusterHueColor(hue: ReturnType): string {
+ switch (hue) {
+ case 'blue':
+ return GALAXY_PALETTE.blues[0]
+ case 'blue2':
+ return GALAXY_PALETTE.blues[1]
+ case 'amber':
+ return GALAXY_PALETTE.ambers[0]
+ case 'blend':
+ return GALAXY_PALETTE.ambers[2]
+ }
+}
+
+function buildDustField(): DustField {
+ const total = DUST_POINTS_PER_ARM * DUST_ARM_COUNT
+ const positions = new Float32Array(total * 3)
+ const colors = new Float32Array(total * 3)
+ let cursor = 0
+ for (let arm = 0; arm < DUST_ARM_COUNT; arm += 1) {
+ for (let i = 0; i < DUST_POINTS_PER_ARM; i += 1) {
+ const radiusNorm = i / DUST_POINTS_PER_ARM
+ const point = spiralPosition(i, arm, DUST_ARM_COUNT, radiusNorm)
+ positions[cursor * 3] = point.x
+ positions[cursor * 3 + 1] = point.y
+ positions[cursor * 3 + 2] = point.z
+ // amber-white core → neon-blue arms → deep-blue rim
+ const color = new THREE.Color()
+ if (radiusNorm < 0.18) color.set('#FFF1CC')
+ else if (radiusNorm < 0.62) color.set(GALAXY_PALETTE.blues[0])
+ else color.set(GALAXY_PALETTE.blues[2])
+ colors[cursor * 3] = color.r
+ colors[cursor * 3 + 1] = color.g
+ colors[cursor * 3 + 2] = color.b
+ cursor += 1
+ }
+ }
+ return { positions, colors }
+}
+
+function buildNebulaRegions(model: GalaxyModel): Array {
+ return model.arms.map((arm, index) => {
+ const systemsInArm = model.systems.filter((s) => s.armId === arm.id)
+ const center = systemsInArm.reduce(
+ (acc, system) => ({
+ x: acc.x + system.baseX / Math.max(1, systemsInArm.length),
+ y: acc.y + system.baseY / Math.max(1, systemsInArm.length),
+ z: acc.z + system.baseZ / Math.max(1, systemsInArm.length),
+ }),
+ { x: 0, y: 0, z: 0 },
+ )
+ return {
+ armId: arm.id,
+ color: clusterHueColor(clusterHue(index)),
+ position: center,
+ scale: 26 + Math.min(24, systemsInArm.length * 2.4),
+ }
+ })
+}
+```
+ Then inside the `MindGraphCard` component body:
+ ```ts
+ const dustField = useMemo(() => buildDustField(), [])
+ const nebulaRegions = useMemo(() => buildNebulaRegions(model), [model])
+ ```
+ Add the new imports to the top of the file:
+ ```ts
+ import {
+ buildGalaxyModel,
+ clamp,
+ clusterHue,
+ emberSize,
+ focusBodyForNavigation,
+ focusDistanceForSystem,
+ folderTintFor,
+ isHub,
+ obsidianUri,
+ recencyGlow,
+ resolveProjectedLabels,
+ seededUnit,
+ selectLabelCandidates,
+ shortTitle,
+ spiralPosition,
+ } from './nova-galaxy-model'
+ import { GALAXY_PALETTE } from '../lib/nova-cockpit-theme'
+ ```
+
+- [ ] 6.2 Pass `dustField` and `nebulaRegions` into `Galaxy3D` as new props (extend `Galaxy3DProps`):
+ ```ts
+ type Galaxy3DProps = {
+ model: GalaxyModel
+ dustField: { positions: Float32Array; colors: Float32Array }
+ nebulaRegions: Array<{
+ armId: string
+ color: string
+ position: { x: number; y: number; z: number }
+ scale: number
+ }>
+ selectedBody: CelestialBody | null
+ hoveredBody: CelestialBody | null
+ disabledArms: Set
+ searchTerm: string
+ isLoading: boolean
+ onHover: (body: CelestialBody | null) => void
+ onSelect: (body: CelestialBody | null) => void
+ }
+ ```
+ and at the `` call site in `MindGraphCard`'s JSX:
+ ```tsx
+
+ ```
+
+- [ ] 6.3 Inside `Galaxy3D`'s main `useEffect` (the scene-setup effect, currently starting `const scene = new THREE.Scene()` at line 351), replace the single hardcoded starfield block (lines 377–407, `model.starfield.forEach(...)` through `scene.add(stars)`) with a dust-forward version that renders BOTH the existing sparse background starfield (kept, for depth) AND the new dense dust cloud:
+
+ Keep the existing `stars` block unchanged (it's the far-background depth layer), and immediately after `scene.add(stars)` add:
+ ```ts
+ const dustGeometry = new THREE.BufferGeometry()
+ dustGeometry.setAttribute(
+ 'position',
+ new THREE.BufferAttribute(dustField.positions, 3),
+ )
+ dustGeometry.setAttribute(
+ 'color',
+ new THREE.BufferAttribute(dustField.colors, 3),
+ )
+ const dust = new THREE.Points(
+ dustGeometry,
+ new THREE.PointsMaterial({
+ size: 0.22,
+ vertexColors: true,
+ transparent: true,
+ opacity: 0.42,
+ depthWrite: false,
+ blending: THREE.AdditiveBlending,
+ }),
+ )
+ const dustGroup = new THREE.Group()
+ dustGroup.add(dust)
+ scene.add(dustGroup)
+ ```
+
+ Replace the single `scene.add(ambient, key, coreLight, fill, rim, createFogNebula())` call: drop the bare `createFogNebula()` call (it stays defined, reused below per-cluster) and add lights only:
+ ```ts
+ scene.add(ambient, key, coreLight, fill, rim)
+ ```
+ Then, right after the `dustGroup` block, add one nebula sprite per cluster using the existing `createFogNebula` factory (generalized to take color/position/scale — see 6.4):
+ ```ts
+ const nebulaSprites = nebulaRegions.map((region) => {
+ const sprite = createClusterNebula(region.color)
+ sprite.position.set(region.position.x, region.position.y, region.position.z)
+ sprite.scale.set(region.scale, region.scale * 0.55, 1)
+ scene.add(sprite)
+ return sprite
+ })
+ ```
+
+- [ ] 6.4 Generalize `createFogNebula` (lines 290–315) into a color-parameterized `createClusterNebula`, keeping a no-arg `createFogNebula` wrapper so nothing else calling it breaks (nothing else does today, but this keeps the diff additive):
+
+ ```ts
+ function createClusterNebula(color: string): THREE.Sprite {
+ const canvas = document.createElement('canvas')
+ canvas.width = 512
+ canvas.height = 512
+ const context = canvas.getContext('2d')!
+ const base = new THREE.Color(color)
+ const glow = context.createRadialGradient(256, 256, 0, 256, 256, 256)
+ glow.addColorStop(0, `rgba(${Math.round(base.r * 255)}, ${Math.round(base.g * 255)}, ${Math.round(base.b * 255)}, 0.10)`)
+ glow.addColorStop(0.34, `rgba(${Math.round(base.r * 255)}, ${Math.round(base.g * 255)}, ${Math.round(base.b * 255)}, 0.05)`)
+ glow.addColorStop(0.72, `rgba(${Math.round(base.r * 255)}, ${Math.round(base.g * 255)}, ${Math.round(base.b * 255)}, 0.02)`)
+ glow.addColorStop(1, 'rgba(9, 10, 18, 0)')
+ context.fillStyle = glow
+ context.fillRect(0, 0, 512, 512)
+ const texture = new THREE.CanvasTexture(canvas)
+ texture.colorSpace = THREE.SRGBColorSpace
+ const material = new THREE.SpriteMaterial({
+ map: texture,
+ transparent: true,
+ depthWrite: false,
+ opacity: 0.3,
+ blending: THREE.AdditiveBlending,
+ })
+ return new THREE.Sprite(material)
+ }
+
+ function createFogNebula(): THREE.Sprite {
+ const sprite = createClusterNebula('#7A441E')
+ sprite.position.set(-28, -10, -48)
+ sprite.scale.set(92, 42, 1)
+ return sprite
+ }
+ ```
+ (Budget check: spec caps nebula sprites at 90 — `nebulaRegions.length` is bounded by `model.arms.length`, already capped at 6 in `buildGalaxyModel`, so this is nowhere near the ceiling.)
+
+- [ ] 6.5 Ember sizing/glow for tag-level bodies: wire `emberSize`/`recencyGlow`/`isHub` into the existing tag-marker creation loop (inside the `for (const system of model.systems)` block, `for (const tag of system.tags)` sub-loop, lines 487–507) so ember scale and glow are driven by the new model helpers instead of only `tag.importance`/`tag.recencyTier`. Replace the marker scale line:
+ ```ts
+ marker.scale.setScalar(
+ 0.62 + Math.min(0.9, Math.log2(tag.importance + 1) * 0.2),
+ )
+ ```
+ with:
+ ```ts
+ const nowIso = new Date().toISOString()
+ const glow = recencyGlow(tag.modified ?? tag.updated ?? '', nowIso)
+ const hub = isHub(tag.degree)
+ marker.scale.setScalar(emberSize(tag.degree) * (hub ? 1.15 : 1))
+ tagMaterial.emissiveIntensity = hub ? 0.55 : 0.12 + glow * 0.3
+ tagMaterial.emissive = new THREE.Color(hub ? AMBER : armTint)
+ ```
+ (`glow`/`hub` computed once per tag at scene-build time is intentional — recency doesn't change mid-session, so this avoids a per-frame `Date.now()` call; the existing `updateVisualState` per-frame loop still handles the hot-recency *pulse* animation via `tag.body.recencyTier === 'hot'`, unchanged.)
+
+- [ ] 6.6 Cluster→core filaments: add a dedicated spoke-line pass after the existing `for (const link of model.links)` block (line 602, right before `const raycaster = new THREE.Raycaster()`):
+ ```ts
+ const spokeObjects: Array = []
+ if (model.core) {
+ const corePosition = bodyPositions.get(model.core.id)
+ if (corePosition) {
+ for (const system of model.systems) {
+ if (system.planet.id === model.core.id) continue
+ const systemPosition = bodyPositions.get(system.planet.id)
+ if (!systemPosition) continue
+ const geometry = new THREE.BufferGeometry().setFromPoints([
+ systemPosition,
+ corePosition,
+ ])
+ const material = new THREE.LineBasicMaterial({
+ color: AMBER,
+ transparent: true,
+ opacity: 0.035,
+ depthWrite: false,
+ })
+ const line = new THREE.Line(geometry, material)
+ scene.add(line)
+ spokeObjects.push({
+ source: system.planet.id,
+ target: model.core.id,
+ strength: 0,
+ material: material as unknown as THREE.LineDashedMaterial,
+ })
+ }
+ }
+ }
+ ```
+
+- [ ] 6.7 Slow rotation (~110s/rev) + mouse-parallax tilt, respecting `prefers-reduced-motion`: wrap the dust group + nebula sprites in rotation inside `animate()`. Add near the top of `animate`:
+ ```ts
+ const ROTATION_PERIOD_MS = 110_000
+ let parallaxYaw = 0
+ let parallaxPitch = 0
+ ```
+ (declare these two `let`s alongside the existing `let frame = 0` block, not inside `animate`, since they persist across frames) and inside `animate(now)`, before `updateCamera(now)`:
+ ```ts
+ if (!reducedMotionRef.current) {
+ dustGroup.rotation.y = (now / ROTATION_PERIOD_MS) * Math.PI * 2
+ for (const sprite of nebulaSprites) {
+ sprite.material.rotation = (now / ROTATION_PERIOD_MS) * Math.PI * 2 * 0.4
+ }
+ }
+ parallaxYaw += (pointerParallax.x * 0.05 - parallaxYaw) * 0.04
+ parallaxPitch += (pointerParallax.y * 0.03 - parallaxPitch) * 0.04
+ ```
+ Add a `pointerParallax` ref (normalized -1..1 pointer position) updated in the existing `onPointerMove` handler (which already computes `pointer.x`/`pointer.y` via `normalizedPointer` — reuse those, don't recompute):
+ ```ts
+ let pointerParallax = { x: 0, y: 0 }
+ ```
+ and inside `onPointerMove`, after `normalizedPointer(event)` runs via `pickPlanet`, add (at the top of `onPointerMove`, before the `isDragging` branch, so parallax tracks even while not hovering a body):
+ ```ts
+ const bounds = renderer.domElement.getBoundingClientRect()
+ pointerParallax = {
+ x: ((event.clientX - bounds.left) / bounds.width) * 2 - 1,
+ y: ((event.clientY - bounds.top) / bounds.height) * 2 - 1,
+ }
+ ```
+ Then fold `parallaxYaw`/`parallaxPitch` into the existing camera orientation inside `updateCamera` — after the existing auto-drift block (`if (!selected && !isDragging && !reducedMotionRef.current) { state.yaw += … }`), add:
+ ```ts
+ const appliedYaw = state.yaw + parallaxYaw
+ const appliedPitch = clamp(state.pitch + parallaxPitch, -0.62, 0.78)
+ ```
+ and use `appliedYaw`/`appliedPitch` in place of `state.yaw`/`state.pitch` in the `camera.position.set(...)` call that follows (the stored `state.yaw`/`state.pitch` stay parallax-free so drag/wheel controls aren't fighting the parallax offset).
+
+- [ ] 6.8 RAF lifecycle: stop on `document.hidden` and when offscreen (`IntersectionObserver`) — currently absent. Add both, alongside the existing `resizeObserver` setup (line 891 area):
+ ```ts
+ let isVisible = true
+ const intersectionObserver = new IntersectionObserver(
+ ([entry]) => {
+ isVisible = entry.isIntersecting
+ },
+ { threshold: 0.05 },
+ )
+ intersectionObserver.observe(host)
+ const onVisibilityChange = () => {
+ if (document.hidden) {
+ window.cancelAnimationFrame(frame)
+ } else if (isVisible) {
+ frame = window.requestAnimationFrame(animate)
+ }
+ }
+ document.addEventListener('visibilitychange', onVisibilityChange)
+ ```
+ and change the tail of `animate()` from an unconditional re-schedule to:
+ ```ts
+ if (!document.hidden && isVisible) {
+ frame = window.requestAnimationFrame(animate)
+ }
+ ```
+ When `isVisible` flips back to `true` via the `IntersectionObserver` callback while the tab is foregrounded, resume by requesting a frame there too:
+ ```ts
+ const intersectionObserver = new IntersectionObserver(
+ ([entry]) => {
+ const wasVisible = isVisible
+ isVisible = entry.isIntersecting
+ if (isVisible && !wasVisible && !document.hidden) {
+ frame = window.requestAnimationFrame(animate)
+ }
+ },
+ { threshold: 0.05 },
+ )
+ ```
+ Add cleanup in the effect's `return () => { … }` teardown:
+ ```ts
+ intersectionObserver.disconnect()
+ document.removeEventListener('visibilitychange', onVisibilityChange)
+ ```
+
+- [ ] 6.9 Cap `devicePixelRatio` at 1.5 per the spec's hard budget (currently capped at 2). Change:
+ ```ts
+ renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2))
+ ```
+ to:
+ ```ts
+ renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.5))
+ ```
+
+- [ ] 6.10 `powerPreference: 'low-power'` per spec: add to the `WebGLRenderer` constructor:
+ ```ts
+ const renderer = new THREE.WebGLRenderer({
+ antialias: true,
+ alpha: false,
+ powerPreference: 'low-power',
+ })
+ ```
+
+- [ ] 6.11 Add the `spokeObjects` and `nebulaSprites`/`dustGroup` to the disposal pass in the effect teardown (the existing `scene.traverse(...)` disposal loop already walks every scene child generically via `geometry`/`material` duck-typing, so `dust`, `dustGroup`, `nebulaSprites`, and spoke `line`s are disposed automatically — no separate disposal code needed, confirm by reading the teardown block, not by adding redundant dispose calls).
+
+- [ ] 6.12 Run `npx vite build` — expect a clean build (this file has no dedicated unit tests; `Galaxy3D` is a DOM/WebGL-effect component, verified visually in T10, not via vitest).
+
+- [ ] 6.13 Commit:
+ ```
+ git add src/screens/dashboard/components/mind-graph-card.tsx
+ git commit -m "galaxy: dust-forward THREE.Points field, per-cluster nebula sprites, ember sizing, slow rotation + parallax, RAF lifecycle, perf budget"
+ ```
+
+---
+
+## Task 7: Wayfinding L1+L2 (constellation labels always-on, hover-identify)
+
+**Files:**
+- Modify `src/screens/dashboard/components/mind-graph-card.tsx`
+
+**Context:** planet/tag label projection already exists (`projectLabels`, throttled to ~90ms via `lastLabelUpdate`) and already covers most of L2's "hover = identify + title chip" via the existing `labels` state rendered as `` chips at lines 938–969. This task adds the missing pieces: (a) a separate, coarser cluster/arm-level label layer throttled to 4Hz as its own state (not folded into the existing 90ms planet/tag cadence, so a cheap always-on layer doesn't get starved by the denser one), and (b) screen-space (not raycast) nearest-ember hover + edge-flare-to-neon-blue.
+
+- [ ] 7.1 Add cluster-label state and a 4Hz-throttled projection loop. Add a new type and state near the top of `Galaxy3D`:
+ ```ts
+ type ClusterLabel = {
+ armId: string
+ name: string
+ x: number
+ y: number
+ opacity: number
+ }
+ ```
+ Add state: `const [clusterLabels, setClusterLabels] = useState>([])`.
+ Inside the effect, declare `let lastClusterLabelUpdate = 0` alongside `let lastLabelUpdate = 0`, and add a `projectClusterLabels` function mirroring `projectLabels`'s vector-projection pattern but over `nebulaRegions` instead of `model.systems`:
+ ```ts
+ const projectClusterLabels = () => {
+ const vector = new THREE.Vector3()
+ const projected: Array = []
+ for (const region of nebulaRegions) {
+ vector.set(region.position.x, region.position.y, region.position.z)
+ vector.project(camera)
+ if (vector.z < -1 || vector.z > 1) continue
+ const arm = model.arms.find((a) => a.id === region.armId)
+ if (!arm) continue
+ const distance = camera.position.distanceTo(
+ new THREE.Vector3(region.position.x, region.position.y, region.position.z),
+ )
+ projected.push({
+ armId: region.armId,
+ name: arm.name,
+ x: (vector.x * 0.5 + 0.5) * width,
+ y: (-vector.y * 0.5 + 0.5) * height,
+ // Fades as the camera closes in — "constellation names always
+ // on, soft zoomed out, fade as camera closes in" (spec L1).
+ opacity: clamp(1 - (OVERVIEW_DISTANCE - distance) / OVERVIEW_DISTANCE, 0.12, 0.85),
+ })
+ }
+ setClusterLabels(projected)
+ }
+ ```
+ In `animate(now)`, alongside the existing `if (now - lastLabelUpdate > 90) { projectLabels(); lastLabelUpdate = now }`, add:
+ ```ts
+ if (now - lastClusterLabelUpdate > 250) {
+ projectClusterLabels()
+ lastClusterLabelUpdate = now
+ }
+ ```
+ (250ms = 4Hz, per spec's explicit throttle to avoid a re-render storm — deliberately coarser than the 90ms planet/tag cadence.)
+ Reset `setClusterLabels([])` in the teardown alongside the existing `setLabels([])`.
+
+- [ ] 7.2 Render the always-on cluster chips. Add a second pointer-events-none layer in `Galaxy3D`'s returned JSX, before the existing per-body `labels.map(...)` block:
+ ```tsx
+
+ {clusterLabels.map((cluster) => (
+
+ {cluster.name}
+
+ ))}
+
+ ```
+
+- [ ] 7.3 Screen-space nearest-ember hover (replacing raycast-only picking for tag-tier bodies while keeping raycast for planets/core/comets, which have real hit geometry sized to their visual radius). Add a helper inside the effect, near `pickPlanet`:
+ ```ts
+ const NEAREST_EMBER_PX = 24
+ const emberScreenPositions = new Map()
+ const refreshEmberScreenPositions = () => {
+ const vector = new THREE.Vector3()
+ emberScreenPositions.clear()
+ for (const tag of tagObjects) {
+ vector.copy(tag.mesh.position).project(camera)
+ if (vector.z < -1 || vector.z > 1) continue
+ emberScreenPositions.set(tag.body.id, {
+ x: (vector.x * 0.5 + 0.5) * width,
+ y: (-vector.y * 0.5 + 0.5) * height,
+ })
+ }
+ }
+ const pickNearestEmber = (event: PointerEvent): CelestialBody | null => {
+ const bounds = renderer.domElement.getBoundingClientRect()
+ const px = event.clientX - bounds.left
+ const py = event.clientY - bounds.top
+ let best: { id: string; distance: number } | null = null
+ for (const [id, pos] of emberScreenPositions) {
+ const distance = Math.hypot(pos.x - px, pos.y - py)
+ if (distance <= NEAREST_EMBER_PX && (!best || distance < best.distance)) {
+ best = { id, distance }
+ }
+ }
+ return best ? (model.bodyById.get(best.id) ?? null) : null
+ }
+ ```
+ Call `refreshEmberScreenPositions()` once per frame in `animate(now)` (cheap — it's the same projection math `projectLabels` already does, just cached into a plain map instead of React state, so it doesn't trigger a re-render):
+ ```ts
+ refreshEmberScreenPositions()
+ ```
+ In `onPointerMove`, change the pick order so screen-space nearest-ember wins over raycast when both would hit (embers are visually small; the raycast hit-sphere is often larger than the visible dot, which reads as "imprecise" — the spec explicitly wants 24px screen-space nearest):
+ ```ts
+ const onPointerMove = (event: PointerEvent) => {
+ pointerParallax = {
+ x: ((event.clientX - renderer.domElement.getBoundingClientRect().left) / width) * 2 - 1,
+ y: ((event.clientY - renderer.domElement.getBoundingClientRect().top) / height) * 2 - 1,
+ }
+ if (isDragging) {
+ const dx = event.clientX - lastPointer.x
+ const dy = event.clientY - lastPointer.y
+ if (Math.abs(dx) + Math.abs(dy) > 2) pointerMoved = true
+ cameraStateRef.current.yaw -= dx * 0.0045
+ cameraStateRef.current.pitch -= dy * 0.0038
+ lastPointer = { x: event.clientX, y: event.clientY }
+ return
+ }
+ const nearestEmber = pickNearestEmber(event)
+ const body = nearestEmber ?? pickPlanet(event)
+ renderer.domElement.style.cursor = body ? 'pointer' : 'grab'
+ onHover(body)
+ }
+ ```
+ (This replaces the earlier draft of `onPointerMove` from 6.7's parallax step — 6.7 only added the `pointerParallax` assignment; this step folds that same assignment in while also swapping the pick logic, so implement 6.7's parallax line as part of this final `onPointerMove` body rather than twice.)
+
+- [ ] 7.4 Edge-flare on hover: brighten the hovered ember's links to neon-blue for that frame (not persisted state — recomputed every frame in `updateVisualState`, which already loops `lineObjects` and computes `active`). Extend the existing link-opacity loop in `updateVisualState`:
+ ```ts
+ for (const link of lineObjects) {
+ const source = model.bodyById.get(link.source)
+ const target = model.bodyById.get(link.target)
+ const active =
+ (source?.systemId && activeIds.has(source.systemId)) ||
+ (target?.systemId && activeIds.has(target.systemId)) ||
+ source?.id === activeBodyId ||
+ target?.id === activeBodyId
+ const isHoverFlare =
+ Boolean(hovered) && (source?.id === hovered?.id || target?.id === hovered?.id)
+ const searched = Boolean(
+ query &&
+ ((source && matchesSearch(source, query)) ||
+ (target && matchesSearch(target, query))),
+ )
+ const backbone = link.strength >= 12
+ if (isHoverFlare) {
+ link.material.color.set(GALAXY_PALETTE.blues[0])
+ link.material.opacity = 0.72
+ } else {
+ link.material.color.set(
+ source && target && source.systemId === target.systemId
+ ? folderTintFor(source.folder)
+ : GOLD,
+ )
+ if (active) link.material.opacity = 0.42
+ else if (searched) link.material.opacity = 0.22
+ else if (backbone) link.material.opacity = 0.07
+ else link.material.opacity = 0.025
+ }
+ }
+ ```
+ (This is the first place `lineObjects[].material.color` is written per-frame — previously color was set once at creation. `THREE.LineDashedMaterial`/`LineBasicMaterial.color` is a mutable `THREE.Color`, so `.set(...)` is a cheap in-place update, not a material replacement.)
+
+- [ ] 7.5 Run `npx vite build` — expect clean build.
+
+- [ ] 7.6 Commit:
+ ```
+ git add src/screens/dashboard/components/mind-graph-card.tsx
+ git commit -m "galaxy: always-on cluster labels (4Hz), 24px screen-space ember hover, neon-blue edge flare"
+ ```
+
+---
+
+## Task 8: Wayfinding L3 (click = go & read, camera glide)
+
+**Files:**
+- Modify `src/screens/dashboard/components/mind-graph-card.tsx`
+
+**Context:** click-to-select (`onSelect` → `setSelectedId`) and the note-read side panel (`selectedNotePath` → `/api/knowledge/read` via `noteQuery`) already exist and are NOT touched by this task — the spec's "reuses existing wiring, no fake data" requirement is already satisfied. What's missing is (a) a fixed ~600ms eased glide instead of the current open-ended exponential decay (which visually converges but has no defined duration), and (b) top-5 hub labels forced into `selectLabelCandidates` while a cluster is focused.
+
+- [ ] 8.1 Time-boxed camera glide. Add glide-tracking fields to `cameraStateRef`'s initial value and a new ref for glide start state:
+ ```ts
+ const glideRef = useRef<{
+ active: boolean
+ startedAt: number
+ fromTarget: THREE.Vector3
+ fromDistance: number
+ toTarget: THREE.Vector3
+ toDistance: number
+ } | null>(null)
+ ```
+ Add a `GLIDE_DURATION_MS = 600` constant near `OVERVIEW_DISTANCE`.
+ Add an easing helper near the other free functions:
+ ```ts
+ function easeInOutCubic(t: number): number {
+ return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2
+ }
+ ```
+ In the effect, track the previous selected id so a glide only starts on a genuine selection change (not every render):
+ ```ts
+ let lastSelectedId: string | null = selectedRef.current?.id ?? null
+ ```
+ Replace the target/distance interpolation inside `updateCamera`:
+ ```ts
+ const updateCamera = (now: number) => {
+ const state = cameraStateRef.current
+ const selected = selectedRef.current
+ const focusBody = focusBodyForNavigation(model, selected)
+ const selectedSystem = focusBody
+ ? model.systemByBodyId.get(focusBody.id)
+ : null
+ const selectedPosition = focusBody ? bodyPositions.get(focusBody.id) : null
+ const desiredTarget = selectedPosition
+ ? selectedPosition.clone().add(new THREE.Vector3(0, 1.4, 0))
+ : homeTarget
+ const desiredDistance = selectedSystem
+ ? focusDistanceForSystem(selectedSystem)
+ : OVERVIEW_DISTANCE
+
+ const currentSelectedId = selected?.id ?? null
+ if (currentSelectedId !== lastSelectedId && !reducedMotionRef.current) {
+ glideRef.current = {
+ active: true,
+ startedAt: now,
+ fromTarget: state.target.clone(),
+ fromDistance: state.distance,
+ toTarget: desiredTarget.clone(),
+ toDistance: desiredDistance,
+ }
+ lastSelectedId = currentSelectedId
+ } else if (currentSelectedId !== lastSelectedId) {
+ // Reduced motion: snap instead of gliding.
+ state.target.copy(desiredTarget)
+ state.distance = desiredDistance
+ lastSelectedId = currentSelectedId
+ }
+
+ const glide = glideRef.current
+ if (glide?.active) {
+ const t = clamp((now - glide.startedAt) / GLIDE_DURATION_MS, 0, 1)
+ const eased = easeInOutCubic(t)
+ state.target.lerpVectors(glide.fromTarget, glide.toTarget, eased)
+ state.distance = glide.fromDistance + (glide.toDistance - glide.fromDistance) * eased
+ if (t >= 1) glide.active = false
+ } else {
+ // No active glide (idle overview, or drag-driven re-target): keep
+ // the original soft exponential follow so free-look drag doesn't
+ // feel snappy/robotic.
+ state.target.lerp(desiredTarget, reducedMotionRef.current ? 1 : 0.055)
+ state.distance +=
+ (desiredDistance - state.distance) *
+ (reducedMotionRef.current ? 1 : 0.048)
+ }
+
+ if (!selected && !isDragging && !reducedMotionRef.current) {
+ state.yaw += Math.sin(now / 14000) * 0.00016 + 0.00012
+ state.pitch += (0.28 + Math.sin(now / 21000) * 0.03 - state.pitch) * 0.01
+ }
+ state.pitch = clamp(state.pitch, -0.62, 0.78)
+ state.distance = clamp(state.distance, 11, 140)
+ const appliedYaw = state.yaw + parallaxYaw
+ const appliedPitch = clamp(state.pitch + parallaxPitch, -0.62, 0.78)
+ const cosPitch = Math.cos(appliedPitch)
+ camera.position.set(
+ state.target.x + Math.sin(appliedYaw) * cosPitch * state.distance,
+ state.target.y + Math.sin(appliedPitch) * state.distance,
+ state.target.z + Math.cos(appliedYaw) * cosPitch * state.distance,
+ )
+ camera.lookAt(state.target)
+ }
+ ```
+ (This supersedes the plain `state.target.lerp(desiredTarget, 0.055)` two-liner from the original file and folds in the parallax application from T6.7 — implement T6.7's `appliedYaw`/`appliedPitch` lines as part of this rewritten `updateCamera`, not as a separate edit, since both touch the same function body.)
+
+- [ ] 8.2 Top-5 hub labels while a cluster is focused. In `selectLabelCandidates` (in `nova-galaxy-model.ts`), the `tagBudget` for an active system is already `12` — extend the per-tag ranking to force-include the top-5 by `isHub`/degree ahead of the importance sort, so hubs never lose their budget slot to a recently-touched non-hub tag. Modify the `rankedTags` sort inside the `isActive` branch of `selectLabelCandidates`:
+ ```ts
+ const rankedTags = [...system.tags].sort((a, b) => {
+ if (isActive) {
+ const hubDelta = Number(isHub(b.degree)) - Number(isHub(a.degree))
+ if (hubDelta !== 0) return hubDelta
+ }
+ return b.importance - a.importance || a.title.localeCompare(b.title)
+ })
+ ```
+ This requires importing `isHub` into `nova-galaxy-model.ts`'s own top-of-file scope — it's defined in the same file (T5), so no import is needed, just place `selectLabelCandidates`'s edit after `isHub`'s definition in file order, or (simpler, since `selectLabelCandidates` is defined earlier in the file than where T5 appends `isHub`) move this one-line sort tweak to reference `isHub` via a forward function declaration, which works in TypeScript/JS for `function`-declared exports regardless of source order — no reordering needed.
+
+- [ ] 8.3 Escape-clears-selection: already implemented (`useEscape(clearSelection)`, `clearSelection` calls both `setSelectedId(null)` and `setInspectedId(null)`) — no change. Confirm by reading lines 1062–1066 before starting this task; if a regression is found here during T6/T7 edits, fix it as part of this task's commit rather than opening a new task.
+
+- [ ] 8.4 Run `npx vite build` — expect clean build.
+
+- [ ] 8.5 Commit:
+ ```
+ git add src/screens/dashboard/components/mind-graph-card.tsx src/screens/dashboard/components/nova-galaxy-model.ts
+ git commit -m "galaxy: 600ms eased camera glide on select, force top hub labels in focus mode"
+ ```
+
+---
+
+## Task 9: Wayfinding L4+L5 (search, legend, filters)
+
+**Files:**
+- Modify `src/screens/dashboard/components/mind-graph-card.tsx`
+
+**Context:** `/api/knowledge/search?q=` already exists (`src/routes/api/knowledge/search.ts`) and returns `Array<{ path: string; title: string; line: number; text: string }>` via `searchKnowledgePages`. The card already has a plain search ` ` wired to local `searchTerm` state (used only for label/opacity filtering inside the 3D scene, lines 1141–1149) — this task adds a real results dropdown against the server endpoint, a legend, and cluster/recency filter chips, without removing the existing `searchTerm` 3D-highlight behavior (results picking still sets `searchTerm` too, so the existing highlight logic keeps working).
+
+- [ ] 9.1 Add the debounced search-results query inside `MindGraphCard` (not `Galaxy3D` — this is a side-panel concern):
+ ```ts
+ const [debouncedSearch, setDebouncedSearch] = useState('')
+ useEffect(() => {
+ const handle = window.setTimeout(() => setDebouncedSearch(searchTerm.trim()), 300)
+ return () => window.clearTimeout(handle)
+ }, [searchTerm])
+ const searchResultsQuery = useQuery({
+ queryKey: ['dashboard', 'knowledge-search', debouncedSearch],
+ enabled: debouncedSearch.length > 0,
+ queryFn: async (): Promise<{
+ results: Array<{ path: string; title: string; line: number; text: string }>
+ }> => {
+ const response = await fetch(
+ `/api/knowledge/search?q=${encodeURIComponent(debouncedSearch)}`,
+ )
+ if (!response.ok) throw new Error(`search ${response.status}`)
+ return (await response.json()) as {
+ results: Array<{ path: string; title: string; line: number; text: string }>
+ }
+ },
+ staleTime: 15_000,
+ })
+ const searchResults = (searchResultsQuery.data?.results ?? []).slice(0, 8)
+ ```
+
+- [ ] 9.2 Render the results dropdown under the existing search input (lines 1141–1149). Wrap the existing `… … ` in a `relative` container and append a results list:
+ ```tsx
+
+
+ Search galaxy notes
+ setSearchTerm(event.target.value)}
+ placeholder="Search notes"
+ className="w-full bg-transparent font-mono text-[11px] text-[var(--theme-text)] outline-none placeholder:text-[var(--theme-muted-2)]"
+ />
+
+ {debouncedSearch && searchResults.length > 0 ? (
+
+ {searchResults.map((result) => (
+ {
+ setSelectedId(result.path)
+ setSearchTerm(result.title)
+ }}
+ className="block w-full truncate rounded px-2 py-1 text-left font-mono text-[10px] text-[var(--theme-text-soft)] hover:bg-[var(--theme-accent-subtle)] hover:text-[var(--theme-accent-secondary)]"
+ title={result.text}
+ >
+ {shortTitle(result.title)}
+
+ ))}
+
+ ) : null}
+
+ ```
+ (`setSelectedId(result.path)` reuses the exact selection path `noteQuery`/backlink buttons already use elsewhere in this file (line 1281's `onClick={() => setSelectedId(backlink)}`) — the glide from T8 fires automatically since it's driven off `selectedId` changes, not a separate "fly to" call.)
+
+- [ ] 9.3 Legend chip row. Add a new fixed-position legend block inside the galaxy field container (sibling to the existing top-toolbar ``), anchored bottom-left per spec's "corner legend chip":
+ ```tsx
+
+
+
+ color = cluster
+
+
+
+ size = links
+
+
+
+ pulse = hub
+
+
+
+ bright = this week
+
+
+
+ comet = orphan
+
+
+ ```
+
+- [ ] 9.4 Filter chips: per-cluster toggle (already exists as the arm toggle row, lines 1127–1140 — `disabledArms`/`toggleArm` — this satisfies "filters: by cluster/folder" already; no new code needed here beyond confirming it dims to the spec's target, see 9.5) plus a new "this week" recency toggle:
+ Add state in `MindGraphCard`: `const [thisWeekOnly, setThisWeekOnly] = useState(false)`.
+ Add the toggle button in the same toolbar row as the arm chips (after the `model.arms.slice(0, 7).map(...)` block, before the search `
`):
+ ```tsx
+ setThisWeekOnly((current) => !current)}
+ className={`rounded-full border px-2 py-1 font-mono text-[9px] uppercase tracking-[0.08em] transition-colors sm:text-[10px] sm:tracking-[0.12em] ${thisWeekOnly ? 'border-[var(--theme-border)] bg-[var(--theme-accent-subtle)] text-[var(--theme-accent-secondary)]' : 'border-[var(--theme-border-subtle)] bg-[rgba(13,14,24,0.58)] text-[var(--theme-muted-2)]'}`}
+ aria-pressed={thisWeekOnly}
+ >
+ this week
+
+ ```
+ Pass `thisWeekOnly` down to `Galaxy3D` as a new prop (add to `Galaxy3DProps`: `thisWeekOnly: boolean`), and mirror it into a ref (`thisWeekRef`) alongside the existing `disabledRef`/`searchRef` sync effect.
+
+- [ ] 9.5 Wire `thisWeekOnly` into the existing `bodyVisibleOpacity` helper (already the single choke point every render loop calls for opacity — planets, tags, comets, lines, labels all route through it, so one change here satisfies "dims embers with recencyGlow<1 to 20%" everywhere at once):
+ ```ts
+ const bodyVisibleOpacity = (
+ body: CelestialBody,
+ activeIds: Set,
+ query: string,
+ ) => {
+ const disabled = disabledRef.current.has(body.armId)
+ let opacity = disabled ? 0.15 : 1
+ if (activeIds.size > 0 && body.systemId && !activeIds.has(body.systemId))
+ opacity *= 0.34
+ if (query && !matchesSearch(body, query)) opacity *= 0.16
+ if (thisWeekRef.current) {
+ const glow = recencyGlow(body.modified ?? body.updated ?? '', new Date().toISOString())
+ if (glow < 1) opacity *= 0.2
+ }
+ return opacity
+ }
+ ```
+ (This matches the existing per-cluster dim ratio of `0.15` closely enough to read as one consistent filter language — spec asks for "dims other clusters to 15% alpha" (already `0.15`, unchanged) and "dims embers with recencyGlow<1 to 20%" (new `0.2` factor here) — both are satisfied by the same choke-point function, multiplicatively, so a note that's both off-cluster AND stale gets both penalties stacked, which is the expected filter-combination behavior.)
+
+- [ ] 9.6 Run `npx vite build` — expect clean build.
+
+- [ ] 9.7 Commit:
+ ```
+ git add src/screens/dashboard/components/mind-graph-card.tsx
+ git commit -m "galaxy: search dropdown over /api/knowledge/search, legend chip, this-week recency filter"
+ ```
+
+---
+
+## Task 10: Perf + design gate + ship
+
+**Files:** none (verification-only task; a fix-up commit is expected if T1–T9 introduced issues, but no new files are planned)
+
+- [ ] 10.1 Run the full test suite touched by this plan:
+ ```
+ npx vitest run src/screens/dashboard/lib/nova-cockpit-theme.test.ts
+ npx vitest run src/screens/dashboard/components/nova-galaxy-model-v2.test.ts
+ ```
+ Expect 0 failures across both files.
+
+- [ ] 10.2 Run a full production build:
+ ```
+ npx vite build
+ ```
+ Expect a clean build with no TypeScript or bundler errors.
+
+- [ ] 10.3 Manual perf check (Chrome DevTools, dashboard route loaded with the real vault graph, not a mock):
+ - Open the Performance panel, record 10s with the galaxy in view and the tab focused. Confirm the frame rate stays at or above 50fps on a mid-tier laptop GPU (spec doesn't set an exact fps floor beyond "budget respected" — use 50fps as the working bar since it's comfortably above the visible-jank threshold).
+ - Open the Memory panel, take a heap snapshot after 60s of the dashboard sitting open. Confirm tab heap stays under 300MB (spec's incident note: "the 3-demo mockup page contributed to a RAM incident on 2026-07-10" — this is the regression guard).
+ - Switch to a different browser tab for 15s, switch back; confirm via a `console.log` breakpoint or the Performance panel that no animation frames were recorded while hidden (validates T6.8's `document.hidden` RAF stop).
+ - Scroll the galaxy card out of the viewport; confirm RAF also stops (validates the `IntersectionObserver` half of T6.8).
+
+- [ ] 10.4 Screenshot design gate against the approved reference. Reference: `.superpowers/brainstorm/*/content/galaxy-dust-forward.html` (per the spec's "Look (locked)" section). Compare:
+ - **Dust dominance** — does the field read as dust-forward (thousands of faint points) rather than a handful of bright planet spheres dominating the frame?
+ - **Nebula subtlety** — are cluster nebula regions soft/low-opacity, "never clouds pasted on top" (spec wording), not solid-looking blobs?
+ - **Label behavior** — do cluster labels stay legible at overview zoom and fade smoothly on zoom-in (T7); does hover produce a clean title chip within ~24px of the cursor (T7.3)?
+ - **Chrome navy/amber** — zero cyan/purple/green anywhere in card borders, buttons, chips, or the dashboard background grid (this is also mechanically enforced by T2's tripwire, but the screenshot gate catches anything the tripwire's regex-based color extraction might miss, e.g. colors set via a Tailwind class name rather than a literal hex/rgba in the token map).
+ - Iterate at least 2 rounds: take a screenshot, list concrete deltas against the 4 points above, make the code change, re-screenshot, repeat once more minimum before calling it done.
+
+- [ ] 10.5 Check for whitespace/EOF issues before committing:
+ ```
+ git diff --check
+ ```
+ Fix any flagged trailing-whitespace or missing-newline issues.
+
+- [ ] 10.6 Commit any fix-ups from 10.3/10.4 (only if changes were needed — if the design gate passes clean on the first screenshot, skip straight to 10.7):
+ ```
+ git add -A
+ git commit -m "cockpit: perf + design-gate fixups from nova-cockpit-pass verification"
+ ```
+
+- [ ] 10.7 Push the branch:
+ ```
+ git push -u origin feature/nova-skin
+ ```
+
+---
+
+## Verification
+
+Run these in order from the repo root (`C:\Projects\nova-cockpit`) after all 10 tasks are complete:
+
+```
+npx vitest run src/screens/dashboard/lib/nova-cockpit-theme.test.ts
+npx vitest run src/screens/dashboard/components/nova-galaxy-model-v2.test.ts
+npx tsc --noEmit -p tsconfig.json
+npx vite build
+git diff --check
+git log --oneline feature/nova-skin -10
+```
+
+Expected: both vitest files report 0 failures; `tsc` and `vite build` exit 0; `git diff --check` prints nothing; the last 10 commits show the task-by-task history from T1 through T10's fix-up (if any).
diff --git a/docs/superpowers/specs/2026-07-10-galaxy-dust-forward-design.md b/docs/superpowers/specs/2026-07-10-galaxy-dust-forward-design.md
new file mode 100644
index 0000000000..faa76309a8
--- /dev/null
+++ b/docs/superpowers/specs/2026-07-10-galaxy-dust-forward-design.md
@@ -0,0 +1,82 @@
+# Memory Galaxy — dust-forward redesign (approved 2026-07-10)
+
+Taylor-approved design from visual brainstorm (demos in
+`.superpowers/brainstorm/*/content/galaxy-*.html`; reference look =
+`galaxy-dust-forward.html`).
+
+## Look (locked)
+Hybrid of true spiral galaxy + living nebula clusters, **dust-forward**:
+- 3-arm logarithmic spiral of additive dust points (~19k), amber-white core →
+ neon-blue arms → deep-blue rim, slow rotation (~110s/rev), camera tilt ~32°,
+ mouse-parallax tilt.
+- Topic clusters (vault folders) = faint colored nebula regions *inside* the
+ dust (low-opacity soft sprites; hues from the cluster palette: neon blues +
+ one amber + blends). Never clouds pasted on top.
+- Notes = ember points inside their cluster region, cluster-tinted.
+- Hubs (high degree) = larger amber embers with slow pulse.
+- Orphans (degree 0) = comets drifting the rim (kept from current model).
+- Faint neon-blue filaments cluster-centers → core; soft amber core glow.
+- Palette canon: deep navy space (#030710–#0A1424), neon blues (#63C7FF,
+ #9DDCFF, #2E7FD9), ambers (#FF8C1A, #FFB347, #FFD27A). No purple, no green.
+
+## Wayfinding (locked, 5 layers)
+1. **Constellation names always on** — cluster labels anchored at cluster
+ centers, softly visible zoomed out, fade as camera closes in.
+2. **Hover = identify** — nearest ember shows title chip; its wikilink edges
+ flare neon-blue to connected notes.
+3. **Click = go & read** — camera glides to the star; top hub labels appear in
+ that cluster; side panel shows the real note body + clickable backlinks
+ (reuses existing `/api/knowledge/read` wiring — no fake data).
+4. **Search = jump** — search box over `/api/knowledge/search`; picking a
+ result flies the camera to that star and lights its links.
+5. **Meaning in visuals + legend** — color=cluster, size=degree, amber
+ pulse=hub, brightness=recently modified (this week glows), comets=orphans;
+ corner legend chip; filters: by cluster/folder and "this week" recency.
+
+## Data contract (unchanged)
+`/api/knowledge/graph` nodes/edges are the only source. Every real note is
+represented exactly once and none invented (existing tested invariant stays).
+Folder → cluster assignment; degree computed from edges; `modified` drives
+recency brightness.
+
+## Performance budget (hard)
+One canvas/renderer; ≤25k points total; nebula sprites ≤90; pixelRatio ≤1.5;
+`powerPreference: 'low-power'`; RAF fully stops on `document.hidden` and when
+the card is offscreen (IntersectionObserver); `prefers-reduced-motion` freezes
+rotation/pulse. (Budget exists because the 3-demo mockup page contributed to a
+RAM incident on 2026-07-10.)
+
+## Cockpit chrome + layout (added 2026-07-10, Taylor via Nova — binding)
+- **Chrome ban list:** no purple cards, no green brand chips, no cyan
+ borders/glows as *chrome*. Card surfaces, borders, buttons, chips, labels =
+ **navy + amber only**. Neon blues remain canon **inside the galaxy render
+ and data-glow accents**, never as card chrome.
+- **Shell vs cockpit boundary:** the Hermes shell may stay Hermes-branded;
+ everything inside the Mission Control dashboard surface is Nova navy/amber.
+ No mixed skin inside the cockpit.
+- **Galaxy is the hero:** on desktop the galaxy field renders tall
+ (~70vh, not a ~350px strip).
+- **Density:** kill the dead space under the galaxy; tighten the card grid —
+ denser cockpit, no "empty parking lot".
+- **Card hierarchy:** primary ops cards (Galaxy, Live Systems, Taylor
+ Approvals, Session Bridge) visually prominent; decorative/low-value cards
+ quieter and more compact.
+- **Desktop first:** do not spend this pass on mobile polish.
+- **Status semantics in chrome:** healthy = gold, attention/needs-Taylor =
+ amber, danger = warm red-amber; distinction also by brightness/shape, never
+ by green.
+- **Tripwire:** a unit test fails the build if cockpit chrome tokens resolve
+ to green/purple/cyan hues, so merges can't silently steal the skin again.
+
+## Non-goals
+- No physics engine, no VR, no server changes beyond what exists.
+- No new colors outside the canon palette.
+- Galaxy polish never blocks or regresses the note-read/insights wiring.
+
+## Acceptance
+- Matches the dust-forward demo side-by-side (screenshot design gate, ≥2
+ iteration rounds).
+- All 5 wayfinding layers work against the real vault (396+ notes).
+- Existing galaxy-model tests still pass; new tests for cluster assignment,
+ recency-brightness mapping, and search→fly selection logic.
+- Budget respected (verified via frame profile + heap sanity check).
diff --git a/public/nova-idle-poster.png b/public/nova-idle-poster.png
new file mode 100644
index 0000000000..e79cc1db9e
Binary files /dev/null and b/public/nova-idle-poster.png differ
diff --git a/public/nova-idle.mp4 b/public/nova-idle.mp4
new file mode 100644
index 0000000000..957afbca10
Binary files /dev/null and b/public/nova-idle.mp4 differ
diff --git a/scripts/home-mode-context.ts b/scripts/home-mode-context.ts
new file mode 100644
index 0000000000..e2bbc80ce8
--- /dev/null
+++ b/scripts/home-mode-context.ts
@@ -0,0 +1,51 @@
+import { readFileSync } from 'node:fs'
+import { classifyHomeMode } from '../src/lib/home-mode'
+import type { CalendarSignal, HomeModePreferences } from '../src/lib/home-mode'
+
+type CliOptions = {
+ now?: string
+ timezone?: string
+ location?: string
+ explicitBuildMode?: boolean
+ calendar?: Array
+ preferences?: HomeModePreferences
+}
+
+function readJsonFile(path: string): T {
+ return JSON.parse(readFileSync(path, 'utf8')) as T
+}
+
+function parseArgs(argv: Array): CliOptions {
+ const options: CliOptions = {}
+
+ for (let index = 0; index < argv.length; index += 1) {
+ const arg = argv[index]
+ const next = argv[index + 1]
+
+ if (arg === '--now' && next) {
+ options.now = next
+ index += 1
+ } else if (arg === '--timezone' && next) {
+ options.timezone = next
+ index += 1
+ } else if (arg === '--location' && next) {
+ options.location = next
+ index += 1
+ } else if (arg === '--build-mode') {
+ options.explicitBuildMode = true
+ } else if (arg === '--calendar-json' && next) {
+ options.calendar = readJsonFile>(next)
+ index += 1
+ } else if (arg === '--preferences-json' && next) {
+ options.preferences = readJsonFile(next)
+ index += 1
+ }
+ }
+
+ return options
+}
+
+const options = parseArgs(process.argv.slice(2))
+const context = classifyHomeMode(options)
+
+console.log(JSON.stringify(context, null, 2))
diff --git a/scripts/vault-graph-health.mjs b/scripts/vault-graph-health.mjs
new file mode 100644
index 0000000000..9a88fa5426
--- /dev/null
+++ b/scripts/vault-graph-health.mjs
@@ -0,0 +1,1052 @@
+#!/usr/bin/env node
+import fs from 'node:fs/promises'
+import path from 'node:path'
+
+const DEFAULT_VAULT =
+ process.platform === 'win32'
+ ? 'C:\\Users\\taylo\\Documents\\unified-vault'
+ : path.resolve(process.cwd(), '..', 'unified-vault')
+
+const PLANET_CONCEPTS = [
+ {
+ title: 'Nova',
+ project: 'nova',
+ keywords: ['nova', 'hermes persona', 'soul sync', 'memory keeper'],
+ },
+ {
+ title: 'Nova Mission Control',
+ project: 'nova-mission-control',
+ keywords: ['mission control', 'dashboard', 'cockpit', 'agentos', 'jarvis'],
+ },
+ {
+ title: 'Hermes Agent',
+ project: 'hermes',
+ keywords: ['hermes', 'hermes agent', 'hermes workspace', 'gateway'],
+ },
+ {
+ title: 'Obsidian Vault',
+ project: 'unified-vault',
+ keywords: ['obsidian', 'vault', 'graph', 'galaxy', 'wikilink', 'wiki'],
+ },
+ {
+ title: 'Neon Moon Job Board',
+ project: 'neon-moon',
+ keywords: ['job board', 'printavo', 'kanban', 'neon moon', 'jobs'],
+ },
+ {
+ title: 'Taylor Personal Kanban',
+ project: 'taylor-ops',
+ keywords: ['personal kanban', 'taylor kanban', 'task board', 'daily board'],
+ },
+ {
+ title: 'Gmail Calendar Integration',
+ project: 'integrations',
+ keywords: ['gmail', 'calendar', 'google auth', 'email', 'oauth'],
+ },
+ {
+ title: 'Claude Desktop Operator',
+ project: 'agent-infrastructure',
+ keywords: ['claude desktop', 'claude code', 'one-line prompt', 'operator'],
+ },
+ {
+ title: 'Codex',
+ project: 'agent-infrastructure',
+ keywords: ['codex', 'gpt', 'repo', 'pull request', 'build'],
+ },
+ {
+ title: 'LoomOS',
+ project: 'loomos',
+ keywords: ['loomos', 'loom os', 'quote builder', 'print shop os'],
+ },
+ {
+ title: 'Design Intake',
+ project: 'neon-moon',
+ keywords: ['design intake', 'invoice', 'vector tracing', 'digitizing'],
+ },
+ {
+ title: 'Cost Route Watch',
+ project: 'agent-infrastructure',
+ keywords: [
+ 'route cost',
+ 'nous portal',
+ 'quota',
+ 'gpt-5.5 burn',
+ 'cost route',
+ ],
+ },
+ {
+ title: 'NotebookLM Bridge',
+ project: 'nova-mission-control',
+ keywords: ['notebooklm', 'synthesis bridge', 'audio overview', 'mind map'],
+ },
+ {
+ title: 'External Workflow Intake',
+ project: 'agent-infrastructure',
+ keywords: ['external workflow', 'workflow intake', 'handoff', 'inbox'],
+ },
+ {
+ title: 'Morning Command Center',
+ project: 'taylor-ops',
+ keywords: ['morning command', 'morning ramp', 'daily check', 'home mode'],
+ },
+ {
+ title: 'Job Board Caretaker',
+ project: 'neon-moon',
+ keywords: ['job board caretaker', 'job board', 'printavo', 'jobs'],
+ },
+ {
+ title: 'Email Triage',
+ project: 'integrations',
+ keywords: ['email triage', 'gmail', 'inbox', 'needs reply'],
+ },
+ {
+ title: 'Family Boundary System',
+ project: 'taylor-ops',
+ keywords: ['family boundary', 'home mode', 'family time', 'evening'],
+ },
+ {
+ title: 'Web Agency',
+ project: 'web-agency',
+ keywords: ['web agency', 'client site', 'website', 'agency'],
+ },
+]
+
+const MOCS = [
+ {
+ title: 'Nova - Map',
+ project: 'nova',
+ concepts: ['Nova', 'Hermes Agent', 'Nova Mission Control'],
+ },
+ {
+ title: 'Mission Control - Map',
+ project: 'nova-mission-control',
+ concepts: ['Nova Mission Control', 'NotebookLM Bridge', 'Obsidian Vault'],
+ },
+ {
+ title: 'Neon Moon Ops - Map',
+ project: 'neon-moon',
+ concepts: ['Neon Moon Job Board', 'Design Intake', 'Job Board Caretaker'],
+ },
+ {
+ title: 'LoomOS - Map',
+ project: 'loomos',
+ concepts: ['LoomOS', 'Design Intake', 'Web Agency'],
+ },
+ {
+ title: 'Automation Systems - Map',
+ project: 'agent-infrastructure',
+ concepts: ['Hermes Agent', 'Cost Route Watch', 'External Workflow Intake'],
+ },
+ {
+ title: 'Taylor Ops - Map',
+ project: 'taylor-ops',
+ concepts: [
+ 'Taylor Personal Kanban',
+ 'Morning Command Center',
+ 'Family Boundary System',
+ ],
+ },
+ {
+ title: 'Agent Infrastructure - Map',
+ project: 'agent-infrastructure',
+ concepts: ['Claude Desktop Operator', 'Codex', 'Hermes Agent'],
+ },
+ {
+ title: 'External Workflow Intake - Map',
+ project: 'agent-infrastructure',
+ concepts: [
+ 'External Workflow Intake',
+ 'Gmail Calendar Integration',
+ 'Email Triage',
+ ],
+ },
+]
+
+const BACKLINK_RULES = [
+ {
+ link: 'Gmail Calendar Integration',
+ also: ['Email Triage'],
+ terms: ['gmail', 'calendar', 'email'],
+ reason: 'Gmail/email/calendar operational note',
+ },
+ {
+ link: 'Neon Moon Job Board',
+ also: ['Taylor Personal Kanban'],
+ terms: ['job board', 'kanban', 'printavo'],
+ reason: 'Job board, Printavo, or kanban work',
+ },
+ {
+ link: 'Nova Mission Control',
+ terms: ['mission control', 'dashboard', 'agentos', 'jarvis', 'cockpit'],
+ reason: 'Mission Control dashboard/cockpit work',
+ },
+ {
+ link: 'Claude Desktop Operator',
+ terms: ['claude desktop', 'claude code', 'one-line prompt'],
+ reason: 'Claude desktop/operator workflow',
+ },
+ {
+ link: 'Codex',
+ terms: ['codex', 'gpt', 'repo', 'pr ', 'pull request', 'build'],
+ reason: 'Codex implementation or repo work',
+ },
+ {
+ link: 'Obsidian Vault',
+ terms: ['obsidian', 'vault', 'graph', 'galaxy', 'wiki', 'memory'],
+ reason: 'Vault, graph, galaxy, or memory work',
+ },
+ {
+ link: 'Cost Route Watch',
+ terms: [
+ 'route cost',
+ 'nous portal',
+ 'openai codex quota',
+ 'gpt-5.5 burn',
+ 'quota',
+ ],
+ reason: 'Model route, quota, or cost watch',
+ },
+ {
+ link: 'Design Intake',
+ terms: ['design intake', 'invoice', 'vector tracing', 'digitizing'],
+ reason: 'Design intake and production art workflow',
+ },
+]
+
+const args = parseArgs(process.argv.slice(2))
+const vaultRoot = path.resolve(args.vault || DEFAULT_VAULT)
+const outDir = path.resolve(
+ args.out || path.join(vaultRoot, 'agents', 'gpt', 'graph-health'),
+)
+const applyMode = Boolean(args.apply)
+
+if (applyMode) {
+ console.error(
+ 'Apply mode is intentionally not implemented. This tool is report-only.',
+ )
+ process.exit(2)
+}
+
+const generatedAt = new Date().toISOString()
+const outRel = toVaultPath(path.relative(vaultRoot, outDir))
+
+main().catch((error) => {
+ console.error(
+ error instanceof Error ? error.stack || error.message : String(error),
+ )
+ process.exit(1)
+})
+
+async function main() {
+ await assertDirectory(vaultRoot, 'vault')
+ const files = await walkMarkdown(vaultRoot)
+ const notes = await Promise.all(files.map((file) => readNote(file)))
+ const graph = buildGraph(notes)
+ const suggestions = buildSuggestions(graph)
+ await writeOutputs(graph, suggestions)
+ printSummary(graph, suggestions)
+}
+
+function parseArgs(values) {
+ const parsed = {}
+ for (let index = 0; index < values.length; index += 1) {
+ const value = values[index]
+ if (value === '--vault') parsed.vault = values[++index]
+ else if (value === '--out') parsed.out = values[++index]
+ else if (value === '--apply') parsed.apply = true
+ else if (value === '--help' || value === '-h') {
+ console.log(
+ 'Usage: node scripts/vault-graph-health.mjs [--vault PATH] [--out PATH]',
+ )
+ process.exit(0)
+ }
+ }
+ return parsed
+}
+
+async function assertDirectory(dir, label) {
+ const stat = await fs.stat(dir).catch(() => null)
+ if (!stat?.isDirectory())
+ throw new Error(`Missing ${label} directory: ${dir}`)
+}
+
+async function walkMarkdown(root) {
+ const results = []
+ async function walk(dir) {
+ const entries = await fs.readdir(dir, { withFileTypes: true })
+ for (const entry of entries) {
+ const fullPath = path.join(dir, entry.name)
+ const rel = toVaultPath(path.relative(root, fullPath))
+ if (entry.isDirectory()) {
+ if (shouldSkipDir(entry.name, rel)) continue
+ await walk(fullPath)
+ } else if (entry.isFile() && entry.name.toLowerCase().endsWith('.md')) {
+ if (rel.startsWith(`${outRel}/`)) continue
+ results.push(fullPath)
+ }
+ }
+ }
+ await walk(root)
+ return results.sort((a, b) => a.localeCompare(b))
+}
+
+function shouldSkipDir(name, rel) {
+ return (
+ name === '.git' ||
+ name === '.obsidian' ||
+ name === '.vault-meta' ||
+ name === 'node_modules' ||
+ name === 'dist' ||
+ rel === outRel ||
+ rel.startsWith(`${outRel}/`)
+ )
+}
+
+async function readNote(fullPath) {
+ const raw = await fs.readFile(fullPath, 'utf8')
+ const stat = await fs.stat(fullPath)
+ const relPath = toVaultPath(path.relative(vaultRoot, fullPath))
+ const { frontmatter, content } = parseFrontmatter(raw)
+ const fallbackTitle =
+ titleFromContent(content) || path.basename(relPath, '.md')
+ const title = asString(frontmatter.title) || fallbackTitle
+ const tags = normalizeList(frontmatter.tags)
+ const wikilinks = extractWikilinks(content)
+ const wikilinkOccurrences = countWikilinkOccurrences(content)
+ return {
+ id: relPath,
+ path: relPath,
+ fullPath,
+ title,
+ folder: folderFor(relPath),
+ frontmatter,
+ type: asString(frontmatter.type),
+ project: asString(frontmatter.project),
+ state: asString(frontmatter.state),
+ status: asString(frontmatter.status),
+ tags,
+ raw,
+ content,
+ contentLower: `${title}\n${content}`.toLowerCase(),
+ wikilinks,
+ wikilinkOccurrences,
+ modified: stat.mtime.toISOString(),
+ size: raw.length,
+ missingFrontmatter: !raw.startsWith('---'),
+ }
+}
+
+function parseFrontmatter(raw) {
+ if (!raw.startsWith('---')) return { frontmatter: {}, content: raw }
+ const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/)
+ if (!match) return { frontmatter: {}, content: raw }
+ return { frontmatter: parseYamlLite(match[1]), content: match[2] || '' }
+}
+
+function parseYamlLite(block) {
+ const data = {}
+ let activeKey = null
+ for (const line of block.split(/\r?\n/)) {
+ const listMatch = line.match(/^\s*-\s+(.+)$/)
+ if (listMatch && activeKey) {
+ if (!Array.isArray(data[activeKey])) data[activeKey] = []
+ data[activeKey].push(unquote(listMatch[1].trim()))
+ continue
+ }
+ const match = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/)
+ if (!match) continue
+ activeKey = match[1]
+ const value = match[2].trim()
+ if (!value) data[activeKey] = []
+ else if (value.startsWith('[') && value.endsWith(']')) {
+ data[activeKey] = value
+ .slice(1, -1)
+ .split(',')
+ .map((item) => unquote(item.trim()))
+ .filter(Boolean)
+ } else {
+ data[activeKey] = unquote(value)
+ }
+ }
+ return data
+}
+
+function unquote(value) {
+ return value.replace(/^['"]|['"]$/g, '')
+}
+
+function titleFromContent(content) {
+ const match = content.match(/^#\s+(.+)$/m)
+ return match ? match[1].trim() : ''
+}
+
+function extractWikilinks(content) {
+ const links = []
+ const seen = new Set()
+ const regex = /\[\[([^\]]+)\]\]/g
+ let match = null
+ while ((match = regex.exec(content))) {
+ const clean = (match[1] || '').split('|')[0].split('#')[0].trim()
+ if (clean && !seen.has(clean)) {
+ seen.add(clean)
+ links.push(clean)
+ }
+ }
+ return links
+}
+
+function countWikilinkOccurrences(content) {
+ let count = 0
+ const regex = /\[\[([^\]]+)\]\]/g
+ while (regex.exec(content)) count += 1
+ return count
+}
+
+function buildGraph(notes) {
+ const index = buildIndex(notes)
+ const incoming = new Map(notes.map((note) => [note.id, new Set()]))
+ const outgoing = new Map(notes.map((note) => [note.id, new Set()]))
+ const edges = []
+ let unresolvedLinkCount = 0
+
+ for (const note of notes) {
+ for (const target of note.wikilinks) {
+ const resolved = resolveTarget(target, index)
+ if (resolved) {
+ outgoing.get(note.id)?.add(resolved.id)
+ incoming.get(resolved.id)?.add(note.id)
+ edges.push({
+ source: note.id,
+ target: resolved.id,
+ label: target,
+ resolved: true,
+ })
+ } else {
+ unresolvedLinkCount += 1
+ edges.push({ source: note.id, target, label: target, resolved: false })
+ }
+ }
+ }
+
+ const nodes = notes.map((note) => {
+ const outgoingCount = outgoing.get(note.id)?.size ?? 0
+ const incomingCount = incoming.get(note.id)?.size ?? 0
+ const totalLinks = outgoingCount + incomingCount
+ return {
+ id: note.id,
+ title: note.title,
+ path: note.path,
+ folder: note.folder,
+ type: note.type,
+ project: note.project,
+ state: note.state,
+ status: note.status,
+ tags: note.tags,
+ modified: note.modified,
+ wikilinks: note.wikilinks,
+ wikilinkOccurrences: note.wikilinkOccurrences,
+ outgoingCount,
+ incomingCount,
+ totalLinks,
+ sizeTier: tier(totalLinks),
+ bodyType: 'note',
+ missingFrontmatter: note.missingFrontmatter,
+ excerpt: excerpt(note.content),
+ }
+ })
+
+ const sortedByLinks = [...nodes].sort(sortByLinks)
+ const core = sortedByLinks[0] || null
+ if (core) core.bodyType = 'core'
+ const planetCutoff = Math.max(
+ 3,
+ sortedByLinks[Math.min(24, sortedByLinks.length - 1)]?.totalLinks || 3,
+ )
+ for (const node of nodes) {
+ if (node.id === core?.id) continue
+ if (node.totalLinks === 0) node.bodyType = 'comet'
+ else if (node.totalLinks >= planetCutoff) node.bodyType = 'planet'
+ else node.bodyType = 'tag'
+ }
+
+ const byFolder = countBy(notes, (note) => note.folder)
+ const byType = countBy(notes, (note) => note.type || 'missing')
+ const byProject = countBy(notes, (note) => note.project || 'missing')
+ const byState = countBy(notes, (note) => note.state || 'missing')
+ const byStatus = countBy(notes, (note) => note.status || 'missing')
+ const byTag = new Map()
+ for (const note of notes) {
+ for (const tag of note.tags) byTag.set(tag, (byTag.get(tag) || 0) + 1)
+ }
+
+ return {
+ generatedAt,
+ vaultRoot,
+ outputDir: outDir,
+ notes,
+ nodes,
+ edges,
+ incoming,
+ outgoing,
+ index,
+ totals: {
+ notes: notes.length,
+ wikilinks: notes.reduce((sum, note) => sum + note.wikilinkOccurrences, 0),
+ uniqueWikilinks: notes.reduce(
+ (sum, note) => sum + note.wikilinks.length,
+ 0,
+ ),
+ notesWithWikilinks: notes.filter((note) => note.wikilinks.length > 0)
+ .length,
+ resolvedLinks: edges.filter((edge) => edge.resolved).length,
+ unresolvedLinks: unresolvedLinkCount,
+ orphans: nodes.filter((node) => node.totalLinks === 0).length,
+ missingFrontmatter: notes.filter((note) => note.missingFrontmatter)
+ .length,
+ },
+ counts: {
+ byFolder: objectFromCountMap(byFolder),
+ byType: objectFromCountMap(byType),
+ byProject: objectFromCountMap(byProject),
+ byState: objectFromCountMap(byState),
+ byStatus: objectFromCountMap(byStatus),
+ byTag: objectFromCountMap(byTag),
+ },
+ core,
+ }
+}
+
+function buildIndex(notes) {
+ const index = new Map()
+ for (const note of notes) {
+ for (const key of [
+ note.id,
+ note.id.replace(/\.md$/i, ''),
+ path.basename(note.id, '.md'),
+ note.title,
+ ]) {
+ const normalized = normalizeKey(key)
+ if (!index.has(normalized)) index.set(normalized, note)
+ }
+ }
+ return index
+}
+
+function resolveTarget(target, index) {
+ return (
+ index.get(normalizeKey(target)) ||
+ index.get(normalizeKey(target.replace(/\.md$/i, ''))) ||
+ index.get(normalizeKey(path.basename(target, '.md')))
+ )
+}
+
+function buildSuggestions(graph) {
+ const existingTitles = new Set(
+ graph.notes.map((note) => normalizeKey(note.title)),
+ )
+ const conceptDrafts = PLANET_CONCEPTS.map((concept) => {
+ const mature = graph.notes.find(
+ (note) =>
+ normalizeKey(note.title) === normalizeKey(concept.title) &&
+ ['promoted', 'active', 'stable', 'evergreen'].includes(
+ String(note.status || note.state || '').toLowerCase(),
+ ),
+ )
+ const related = findRelated(graph.notes, concept.keywords, 12)
+ return {
+ ...concept,
+ existingMaturePath: mature?.path,
+ shouldDraft: !mature,
+ relatedNotes: related.map(summaryNote),
+ presentTitle: existingTitles.has(normalizeKey(concept.title)),
+ }
+ })
+
+ const backlinkSuggestions = []
+ for (const note of graph.notes) {
+ for (const rule of BACKLINK_RULES) {
+ const matchedTerms = rule.terms.filter((term) =>
+ note.contentLower.includes(term),
+ )
+ if (matchedTerms.length === 0) continue
+ const links = [rule.link, ...(rule.also || [])]
+ const missingLinks = links.filter((link) => !hasWikiLink(note, link))
+ if (missingLinks.length === 0) continue
+ backlinkSuggestions.push({
+ sourcePath: note.path,
+ sourceTitle: note.title,
+ suggestedLinks: missingLinks,
+ reason: rule.reason,
+ matchedTerms,
+ confidence: matchedTerms.length > 1 ? 'high' : 'medium',
+ })
+ }
+ }
+
+ const mocSuggestions = MOCS.map((moc) => ({
+ ...moc,
+ relatedNotes: findRelated(graph.notes, moc.concepts, 16).map(summaryNote),
+ }))
+
+ const weakNotes = graph.nodes
+ .filter((node) => node.totalLinks <= 1)
+ .sort((a, b) => b.modified.localeCompare(a.modified))
+ .slice(0, 75)
+
+ const planetCandidates = graph.nodes
+ .filter((node) => node.totalLinks >= 3)
+ .sort(sortByLinks)
+ .slice(0, 50)
+
+ return {
+ generatedAt,
+ reportOnly: true,
+ conceptDrafts,
+ mocSuggestions,
+ backlinkSuggestions: backlinkSuggestions.slice(0, 200),
+ weakNotes,
+ planetCandidates,
+ orphanCandidates: graph.nodes
+ .filter((node) => node.totalLinks === 0)
+ .slice(0, 100),
+ }
+}
+
+async function writeOutputs(graph, suggestions) {
+ await fs.mkdir(outDir, { recursive: true })
+ await fs.mkdir(path.join(outDir, 'planet-drafts'), { recursive: true })
+ await fs.mkdir(path.join(outDir, 'moc-drafts'), { recursive: true })
+
+ const graphNodes = {
+ generatedAt,
+ vaultRoot,
+ nodes: graph.nodes.map(
+ ({
+ id,
+ title,
+ path,
+ folder,
+ type,
+ project,
+ status,
+ tags,
+ modified,
+ incomingCount,
+ outgoingCount,
+ totalLinks,
+ sizeTier,
+ bodyType,
+ excerpt,
+ }) => ({
+ id,
+ title,
+ path,
+ folder,
+ type,
+ project,
+ status,
+ tags,
+ modified,
+ incomingCount,
+ outgoingCount,
+ totalLinks,
+ sizeTier,
+ bodyType,
+ excerpt,
+ }),
+ ),
+ links: graph.edges,
+ }
+
+ const graphHealth = {
+ generatedAt,
+ vaultRoot,
+ outputDir: toVaultPath(path.relative(vaultRoot, outDir)),
+ totals: graph.totals,
+ counts: graph.counts,
+ core: graph.core,
+ topPlanets: suggestions.planetCandidates.slice(0, 25),
+ weakAreas: suggestions.weakNotes.slice(0, 25),
+ reportOnly: true,
+ }
+
+ const graphSuggestions = {
+ generatedAt,
+ reportOnly: true,
+ backlinkSuggestions: suggestions.backlinkSuggestions,
+ planetDrafts: suggestions.conceptDrafts,
+ mocDrafts: suggestions.mocSuggestions,
+ reviewRequired: [
+ 'All generated notes are drafts under agents/gpt/graph-health and are not canonical knowledge pages.',
+ 'Do not apply backlink edits without a separate explicit apply request and vault locking protocol.',
+ 'Promote or merge planet/MOC drafts only after human or Claude review.',
+ ],
+ }
+
+ await fs.writeFile(
+ path.join(outDir, 'graphNodes.json'),
+ `${JSON.stringify(graphNodes, null, 2)}\n`,
+ )
+ await fs.writeFile(
+ path.join(outDir, 'graphHealth.json'),
+ `${JSON.stringify(graphHealth, null, 2)}\n`,
+ )
+ await fs.writeFile(
+ path.join(outDir, 'graphSuggestions.json'),
+ `${JSON.stringify(graphSuggestions, null, 2)}\n`,
+ )
+
+ const report = renderReport(graph, suggestions)
+ await fs.writeFile(path.join(outDir, 'graphHealth.md'), report)
+ await fs.writeFile(path.join(outDir, 'graph-health-report.md'), report)
+
+ for (const concept of suggestions.conceptDrafts) {
+ if (!concept.shouldDraft) continue
+ const file = path.join(outDir, 'planet-drafts', `${slug(concept.title)}.md`)
+ await fs.writeFile(file, renderPlanetDraft(concept))
+ }
+
+ for (const moc of suggestions.mocSuggestions) {
+ const file = path.join(outDir, 'moc-drafts', `${slug(moc.title)}.md`)
+ await fs.writeFile(file, renderMocDraft(moc))
+ }
+}
+
+function renderReport(graph, suggestions) {
+ const topFolders = tableRows(graph.counts.byFolder, 15)
+ const topTypes = tableRows(graph.counts.byType, 12)
+ const topProjects = tableRows(graph.counts.byProject, 12)
+ const topPlanets = suggestions.planetCandidates.slice(0, 20)
+ const backlinks = suggestions.backlinkSuggestions.slice(0, 60)
+ const orphans = suggestions.orphanCandidates.slice(0, 40)
+
+ return `---
+title: Vault Graph Health Report
+author: gpt
+type: status
+project: nova-mission-control
+state: draft
+created: ${generatedAt}
+updated: ${generatedAt}
+review_by: claude
+status: draft
+source: inference + vault scan
+confidence: medium
+tags: [graph-health, obsidian, nova-mission-control]
+links: [[Obsidian Vault]], [[Nova Mission Control]], [[Nova]]
+---
+
+# Vault Graph Health Report
+
+Report-only scan. No source notes were edited.
+
+## Counts
+
+- Markdown notes: ${graph.totals.notes}
+- Wikilinks: ${graph.totals.wikilinks}
+- Unique wikilink targets per note: ${graph.totals.uniqueWikilinks}
+- Notes with wikilinks: ${graph.totals.notesWithWikilinks}
+- Resolved links: ${graph.totals.resolvedLinks}
+- Unresolved links: ${graph.totals.unresolvedLinks}
+- Orphan candidates: ${graph.totals.orphans}
+- Notes missing frontmatter: ${graph.totals.missingFrontmatter}
+- Galactic core candidate: ${graph.core ? linkLine(graph.core) : 'none'}
+
+## Folder Weight
+
+${topFolders}
+
+## Type Weight
+
+${topTypes}
+
+## Project Weight
+
+${topProjects}
+
+## High-Value Planet Candidates
+
+${topPlanets.map((node) => `- [[${node.title}]] - ${node.totalLinks} links - ${node.path}`).join('\n') || '- none'}
+
+## Weak Notes And Orphans
+
+These are not bad notes. They are places where Mission Control needs context, backlinks, or intentional comet treatment.
+
+${orphans.map((node) => `- ${node.title} - ${node.path}`).join('\n') || '- none'}
+
+## Draft Planet Notes
+
+${suggestions.conceptDrafts
+ .map((concept) =>
+ concept.shouldDraft
+ ? `- Draft created: planet-drafts/${slug(concept.title)}.md`
+ : `- Existing mature note found for [[${concept.title}]]: ${concept.existingMaturePath}`,
+ )
+ .join('\n')}
+
+## Draft MOCs
+
+${suggestions.mocSuggestions.map((moc) => `- Draft created: moc-drafts/${slug(moc.title)}.md`).join('\n')}
+
+## Backlink Suggestions
+
+${
+ backlinks
+ .map(
+ (item) =>
+ `- ${item.sourcePath} -> ${item.suggestedLinks.map((link) => `[[${link}]]`).join(', ')} (${item.reason}; terms: ${item.matchedTerms.join(', ')})`,
+ )
+ .join('\n') || '- none'
+}
+
+## Mission Control Data
+
+- graphHealth.json
+- graphHealth.md
+- graphNodes.json
+- graphSuggestions.json
+
+## Safe Next Steps
+
+- Review planet and MOC drafts before promotion.
+- Wire Mission Control to prefer graphNodes.json for whole-vault shape and graphHealth.json for counters.
+- Use graphSuggestions.json as a review queue, not an auto-editor.
+- Keep knowledge/ edits behind the wiki lock protocol only.
+`
+}
+
+function renderPlanetDraft(concept) {
+ return `---
+title: ${concept.title}
+author: gpt
+type: reference
+project: ${concept.project}
+state: draft
+created: ${generatedAt}
+updated: ${generatedAt}
+review_by: claude
+status: draft
+source: inference + vault scan
+confidence: medium
+tags: [planet-draft, graph-health]
+links: [${concept.relatedNotes
+ .slice(0, 6)
+ .map((note) => `[[${note.title}]]`)
+ .join(', ')}]
+---
+
+# ${concept.title}
+
+Draft planet note generated for graph review. This is not canonical until reviewed and promoted.
+
+## Why This Planet Exists
+
+The vault scan found recurring signals for: ${concept.keywords.join(', ')}.
+
+## Related Notes Found
+
+${concept.relatedNotes.map((note) => `- [[${note.title}]] - ${note.path}`).join('\n') || '- none found'}
+
+## Suggested Backlinks
+
+- Search notes matching ${concept.keywords.map((item) => `"${item}"`).join(', ')} and consider linking them here.
+
+## Review Notes
+
+- Merge into an existing mature note if one already represents this concept.
+- Promote only after human or Claude review.
+`
+}
+
+function renderMocDraft(moc) {
+ return `---
+title: ${moc.title}
+author: gpt
+type: reference
+project: ${moc.project}
+state: draft
+created: ${generatedAt}
+updated: ${generatedAt}
+review_by: claude
+status: draft
+source: inference + vault scan
+confidence: medium
+tags: [moc-draft, graph-health]
+links: [${moc.concepts.map((concept) => `[[${concept}]]`).join(', ')}]
+---
+
+# ${moc.title}
+
+Draft map-of-content generated for graph review. This note should become a navigational index only after review.
+
+## Core Planets
+
+${moc.concepts.map((concept) => `- [[${concept}]]`).join('\n')}
+
+## Related Notes Found
+
+${moc.relatedNotes.map((note) => `- [[${note.title}]] - ${note.path}`).join('\n') || '- none found'}
+
+## Review Notes
+
+- Keep this as a map, not a status log.
+- Promote only after duplicate checks and source review.
+`
+}
+
+function printSummary(graph, suggestions) {
+ console.log(
+ JSON.stringify(
+ {
+ generatedAt,
+ vaultRoot,
+ outDir,
+ totals: graph.totals,
+ planetDrafts: suggestions.conceptDrafts.filter(
+ (concept) => concept.shouldDraft,
+ ).length,
+ mocDrafts: suggestions.mocSuggestions.length,
+ backlinkSuggestions: suggestions.backlinkSuggestions.length,
+ },
+ null,
+ 2,
+ ),
+ )
+}
+
+function findRelated(notes, keywords, limit) {
+ const normalizedKeywords = keywords.map((keyword) => keyword.toLowerCase())
+ return notes
+ .map((note) => {
+ const titleHits = normalizedKeywords.filter((keyword) =>
+ note.title.toLowerCase().includes(keyword),
+ ).length
+ const bodyHits = normalizedKeywords.filter((keyword) =>
+ note.contentLower.includes(keyword),
+ ).length
+ return {
+ note,
+ score: titleHits * 5 + bodyHits + note.wikilinks.length * 0.05,
+ }
+ })
+ .filter((item) => item.score > 0)
+ .sort(
+ (a, b) =>
+ b.score - a.score || b.note.modified.localeCompare(a.note.modified),
+ )
+ .slice(0, limit)
+ .map((item) => item.note)
+}
+
+function hasWikiLink(note, title) {
+ return note.wikilinks.some(
+ (link) => normalizeKey(link) === normalizeKey(title),
+ )
+}
+
+function countBy(items, getKey) {
+ const counts = new Map()
+ for (const item of items) {
+ const key = getKey(item) || 'missing'
+ counts.set(key, (counts.get(key) || 0) + 1)
+ }
+ return counts
+}
+
+function objectFromCountMap(map) {
+ return Object.fromEntries(
+ [...map.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])),
+ )
+}
+
+function tableRows(object, limit) {
+ const rows = Object.entries(object).slice(0, limit)
+ if (rows.length === 0) return '- none'
+ return rows.map(([key, count]) => `- ${key}: ${count}`).join('\n')
+}
+
+function normalizeList(input) {
+ if (Array.isArray(input))
+ return input
+ .map(String)
+ .map((item) => item.trim())
+ .filter(Boolean)
+ if (typeof input === 'string')
+ return input
+ .split(',')
+ .map((item) => item.trim())
+ .filter(Boolean)
+ return []
+}
+
+function asString(value) {
+ if (value == null || Array.isArray(value)) return undefined
+ const result = String(value).trim()
+ return result || undefined
+}
+
+function folderFor(relPath) {
+ const parts = relPath.split('/').filter(Boolean)
+ if (parts[0] === 'agents' && parts[1]) return `${parts[0]}/${parts[1]}`
+ return parts[0] || 'vault'
+}
+
+function normalizeKey(value) {
+ return String(value || '')
+ .replace(/\\/g, '/')
+ .replace(/\.md$/i, '')
+ .trim()
+ .toLowerCase()
+}
+
+function toVaultPath(value) {
+ return value.replace(/\\/g, '/')
+}
+
+function tier(value) {
+ if (value >= 50) return 5
+ if (value >= 20) return 4
+ if (value >= 8) return 3
+ if (value >= 3) return 2
+ return 1
+}
+
+function sortByLinks(a, b) {
+ return (
+ b.totalLinks - a.totalLinks ||
+ b.incomingCount - a.incomingCount ||
+ a.title.localeCompare(b.title)
+ )
+}
+
+function excerpt(content) {
+ return content
+ .replace(/^---[\s\S]*?---/, '')
+ .replace(/[#>*_`-]/g, ' ')
+ .replace(/\s+/g, ' ')
+ .trim()
+ .slice(0, 220)
+}
+
+function summaryNote(note) {
+ return {
+ title: note.title,
+ path: note.path,
+ folder: note.folder,
+ type: note.type,
+ project: note.project,
+ status: note.status,
+ modified: note.modified,
+ }
+}
+
+function linkLine(node) {
+ return `[[${node.title}]] - ${node.totalLinks} links - ${node.path}`
+}
+
+function slug(value) {
+ return value
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/^-|-$/g, '')
+}
diff --git a/skills/home-mode/SKILL.md b/skills/home-mode/SKILL.md
new file mode 100644
index 0000000000..cac558ab1b
--- /dev/null
+++ b/skills/home-mode/SKILL.md
@@ -0,0 +1,107 @@
+---
+name: home-mode
+description: Classify Taylor's current home/work lane before giving Nova home-mode advice, using live time, optional calendar context, and stored preferences.
+---
+
+# Home Mode
+
+Use this skill when Taylor asks Nova/Hermes for advice about what to do now, whether to keep working, when to stop, how to handle the evening, how hard to push, or how to balance build time with home/family time.
+
+## Contract
+
+Before giving Home Mode advice:
+
+1. Check the live local date/time. Do not rely on stale chat context.
+2. Gather available context:
+ - stated location, defaulting to Taylor's current locale/time zone when known
+ - calendar context if an integration or user-provided event is available
+ - stored preferences or memory about work hours, family/home protection, and explicit build-mode choices
+3. Classify the current lane as one of:
+ - `morning_ramp_up`
+ - `work_block`
+ - `transition_home`
+ - `family_evening`
+ - `light_creative_planning`
+ - `late_night_danger_zone`
+ - `weekend_reset`
+ - `override_build_mode`
+4. Use the classifier boundary. After work hours, default to protecting home/family time unless Taylor explicitly chooses build mode.
+5. Keep evening work bounded. Prefer parking lots, shutdown rituals, and tomorrow plans after 10pm.
+6. Be honest about presence: never imply Nova is always watching.
+
+## Helper
+
+From the repo root, get a JSON context with:
+
+```powershell
+pnpm exec tsx scripts/home-mode-context.ts --timezone America/Chicago --location "West Texas"
+```
+
+Optional inputs:
+
+```powershell
+pnpm exec tsx scripts/home-mode-context.ts `
+ --timezone America/Chicago `
+ --location "West Texas" `
+ --calendar-json C:\path\to\calendar-context.json `
+ --preferences-json C:\path\to\home-mode-preferences.json
+```
+
+Use `--build-mode` only when Taylor explicitly chooses build mode.
+
+The helper returns:
+
+```json
+{
+ "now_local": "2026-07-06T21:00:00",
+ "timezone": "America/Chicago",
+ "lane": "light_creative_planning",
+ "confidence": 0.84,
+ "reason": "Live clock and available context.",
+ "suggested_boundary": "Keep it light.",
+ "recommended_intensity": "low"
+}
+```
+
+## Response Pattern
+
+Keep it short and grounded:
+
+```text
+I checked the live clock: 9:00pm America/Chicago.
+Home Mode reads this as light creative/planning, confidence 0.84.
+
+Best move: one bounded planning pass, then shut it down. Nova is not always watching; this continuity comes from skill rules, memory, scheduled wakeups when enabled, and optional app state.
+```
+
+## Boundaries
+
+- Morning ramp-up, 5-6am: gentle orientation, food/water/body basics, one must-do.
+- Work block: real work is fine; choose a stop point before home time.
+- Transition home, 5-6pm: close loops and protect the handoff.
+- Family/evening, 6-8:30pm: home/family wins by default.
+- Light creative/planning, 8:30-10pm: notes, sketches, planning, or one bounded polish pass.
+- Late-night danger zone, 10pm+: no new rabbit holes; park it and schedule tomorrow.
+- Weekend/reset: reset and family by default, build only by explicit choice.
+- Override/build mode: allowed only when Taylor chooses it; still bounded.
+
+## Persistence Truth
+
+Nova is not constantly conscious and should not claim to be. Persistence comes from:
+
+- this skill's repeatable rules
+- Hermes/Nova memory
+- live clock checks
+- calendar context when connected or supplied
+- scheduled wakeups or cron jobs when Taylor enables them
+- optional Nova app state/backend files
+- Obsidian/vault history
+
+## Verification
+
+Run:
+
+```powershell
+pnpm exec vitest run src/lib/home-mode.test.ts
+pnpm exec tsx scripts/home-mode-context.ts --now 2026-07-07T03:30:00.000Z --timezone America/Chicago
+```
diff --git a/skills/media/youtube-content/SKILL.md b/skills/media/youtube-content/SKILL.md
new file mode 100644
index 0000000000..44b856c5a1
--- /dev/null
+++ b/skills/media/youtube-content/SKILL.md
@@ -0,0 +1,137 @@
+---
+name: youtube-content
+description: Fetch and validate YouTube transcripts, then transform videos into summaries, timestamped chapters, quotes, X threads, blog posts, implementation notes, or build/steal analysis when a user shares YouTube URLs, Shorts, embeds, live URLs, youtu.be links, or video IDs.
+license: MIT
+metadata:
+ version: 0.1.0
+ author: gpt
+ hermes:
+ tags:
+ - youtube
+ - transcripts
+ - media
+ - synthesis
+ - content-transformation
+---
+
+# YouTube Content
+
+## Overview
+
+Use this skill to turn a YouTube video into reliable working material. Fetch the transcript first, validate it, then transform it into the format Taylor asked for: plain transcript, timestamped transcript, summary, chapters, quote bank, X thread, blog post, implementation notes, or "what should we steal/build from this?" analysis.
+
+## When To Use
+
+Use when the user provides a YouTube URL/video ID, asks for a transcript, wants a video summarized, wants timestamped chapters, asks for quotes, wants social/blog content from a video, or wants to compare a video against Hermes/Nova/Neon Moon/LoomOS roadmaps and workflows.
+
+Do not claim to have watched the video unless visual content was separately inspected. Transcript work is audio/text-derived.
+
+## Setup
+
+Install the transcript dependency into the Hermes-managed Python environment:
+
+```bash
+uv pip install youtube-transcript-api
+```
+
+If `uv` is unavailable, use the active project Python environment and report the substitution:
+
+```bash
+python -m pip install youtube-transcript-api
+```
+
+## Workflow
+
+1. Normalize the input as a YouTube video ID.
+2. Fetch the transcript with `scripts/fetch_transcript.py`.
+3. Validate the result:
+ - video ID is correct
+ - segment count is nonzero
+ - duration estimate is plausible
+ - timestamps are monotonic
+ - transcript language matches the request or fallback is disclosed
+4. For long transcripts, chunk by timestamp before summarizing. Keep chunk boundaries natural and preserve timestamps.
+5. Transform only after the transcript is available and checked.
+6. Present source limits honestly: transcript-disabled, private, unavailable, age-restricted, or rate-limited videos may not return text.
+7. Verify the final output is coherent, complete for the requested scope, and timestamp-consistent.
+
+## Commands
+
+From this skill directory or by passing the full path:
+
+```bash
+python scripts/fetch_transcript.py "https://www.youtube.com/watch?v=VIDEO_ID"
+```
+
+Plain readable transcript:
+
+```bash
+python scripts/fetch_transcript.py "https://youtu.be/VIDEO_ID" --text-only
+```
+
+Timestamped transcript:
+
+```bash
+python scripts/fetch_transcript.py "https://youtube.com/shorts/VIDEO_ID" --timestamps
+```
+
+Language chain with fallback:
+
+```bash
+python scripts/fetch_transcript.py "VIDEO_ID" --language tr,en
+```
+
+Force JSON when using other flags:
+
+```bash
+python scripts/fetch_transcript.py "https://youtube.com/embed/VIDEO_ID" --timestamps --json
+```
+
+Accepted inputs:
+
+- `https://youtube.com/watch?v=VIDEO_ID`
+- `https://www.youtube.com/watch?v=VIDEO_ID`
+- `https://youtu.be/VIDEO_ID`
+- `https://youtube.com/shorts/VIDEO_ID`
+- `https://youtube.com/embed/VIDEO_ID`
+- `https://youtube.com/live/VIDEO_ID`
+- raw 11-character video ID
+
+## Output Formats
+
+For detailed transformation patterns, read `references/output-formats.md` only when needed.
+
+Core formats:
+
+- `transcript`: complete text, no added interpretation
+- `timestamped-transcript`: segment text with `[HH:MM:SS]`
+- `summary`: concise bullets plus key takeaways
+- `chapters`: timestamped sections with descriptive titles
+- `quotes`: exact short excerpts with timestamps
+- `x-thread`: hook, numbered posts, source-aware claims
+- `blog-post`: title, intro, sections, conclusion, source caveats
+- `implementation-notes`: buildable ideas, repo implications, risks
+- `steal-build-analysis`: what to copy, adapt, avoid, or add to the roadmap
+
+## Error Handling
+
+Be explicit. Do not silently continue with fake transcript content.
+
+- Missing `youtube-transcript-api`: install with `uv pip install youtube-transcript-api`.
+- Transcript disabled: report that the video does not expose a transcript.
+- Private/unavailable video: report that the video cannot be accessed through transcript APIs.
+- Requested language unavailable: retry without language restriction and disclose the fallback language.
+- Empty transcript: treat as failure unless the user asked only to validate availability.
+- Rate limit/request failure: report the upstream issue and retry later or use a browser/manual transcript path if appropriate.
+
+## Verification Checklist
+
+Before presenting the transformed output:
+
+- Confirm the command exited with code `0`.
+- Confirm `video_id`, `segment_count`, `language`, and `duration_estimate` are present.
+- Confirm timestamps are monotonic if timestamps are used.
+- Confirm long transcripts were chunked before summary/synthesis.
+- Confirm quotes are short and timestamped.
+- Confirm the final answer distinguishes transcript-derived facts from inference or recommendations.
+- Confirm unavailable/disabled/private videos are reported honestly.
diff --git a/skills/media/youtube-content/references/output-formats.md b/skills/media/youtube-content/references/output-formats.md
new file mode 100644
index 0000000000..87f74da973
--- /dev/null
+++ b/skills/media/youtube-content/references/output-formats.md
@@ -0,0 +1,109 @@
+# YouTube Content Output Formats
+
+Use these patterns after the transcript has been fetched and validated.
+
+## Transcript
+
+Return the transcript only. Preserve the source language unless the user asks for translation.
+
+## Timestamped Transcript
+
+Use one segment per line:
+
+```text
+[00:03:12] transcript text
+```
+
+Keep timestamps from the transcript. Do not invent missing timestamps.
+
+## Summary
+
+Include:
+
+- one-sentence gist
+- 5-10 key points
+- practical takeaways
+- source caveats, such as transcript-only analysis
+
+For long transcripts, summarize chunks first, then synthesize the whole.
+
+## Chapters
+
+Use timestamped chapter starts:
+
+```markdown
+- [00:00:00] Opening context
+- [00:04:18] Main argument
+- [00:12:44] Demo or example
+```
+
+Chapter titles should describe what happens, not just repeat phrases.
+
+## Quotes
+
+Use short exact excerpts only. Include timestamps:
+
+```markdown
+- [00:07:31] "short quote"
+```
+
+Avoid long verbatim blocks. If a quote would be long, paraphrase and keep the timestamp.
+
+## X Thread
+
+Structure:
+
+1. Hook post with the main tension or claim.
+2. 5-10 numbered posts with one idea each.
+3. Practical application post.
+4. Closing post with a caveat that it is transcript-derived.
+
+Do not overclaim visual details unless the video was visually inspected.
+
+## Blog Post
+
+Structure:
+
+- title
+- short intro
+- section headings
+- concrete examples from transcript
+- practical implications
+- conclusion
+- source caveat
+
+Use quotes sparingly and keep them short.
+
+## Implementation Notes
+
+Use when Taylor asks how the video applies to Nova, Hermes, LoomOS, Neon Moon, or the local workflows.
+
+Include:
+
+- relevant idea
+- why it matters
+- implementation target
+- likely files/systems affected
+- risk or dependency
+- suggested next step
+
+## Steal/Build Analysis
+
+Use when Taylor asks what to copy, adapt, or add.
+
+Structure:
+
+- Steal directly: proven ideas that fit current goals
+- Adapt: ideas that need Nova/Hermes/Neon Moon translation
+- Avoid: ideas that are off-brand, risky, or not worth it
+- Build next: prioritized implementation steps
+- Evidence: timestamps or transcript references
+
+## Completeness Check
+
+Before finalizing:
+
+- Verify every timestamp used exists in the transcript.
+- Verify recommendations are labeled as inference.
+- Verify the output answers the user's requested format.
+- Verify missing transcript, language fallback, or private/unavailable states are disclosed.
diff --git a/skills/media/youtube-content/scripts/fetch_transcript.py b/skills/media/youtube-content/scripts/fetch_transcript.py
new file mode 100644
index 0000000000..31dc974418
--- /dev/null
+++ b/skills/media/youtube-content/scripts/fetch_transcript.py
@@ -0,0 +1,309 @@
+#!/usr/bin/env python3
+"""Fetch YouTube transcripts as JSON or readable text."""
+
+from __future__ import annotations
+
+import argparse
+import json
+import re
+import sys
+from dataclasses import dataclass
+from typing import Any
+from urllib.parse import parse_qs, urlparse
+
+
+VIDEO_ID_RE = re.compile(r"^[A-Za-z0-9_-]{11}$")
+VIDEO_ID_ANYWHERE_RE = re.compile(r"(? str:
+ candidate = value.strip()
+ if VIDEO_ID_RE.match(candidate):
+ return candidate
+
+ parsed = urlparse(candidate)
+ host = parsed.netloc.lower().replace("www.", "")
+ path_parts = [part for part in parsed.path.split("/") if part]
+
+ if host in {"youtube.com", "m.youtube.com", "music.youtube.com"}:
+ query_id = parse_qs(parsed.query).get("v", [None])[0]
+ if query_id and VIDEO_ID_RE.match(query_id):
+ return query_id
+
+ if path_parts and path_parts[0] in {"shorts", "embed", "live"}:
+ if len(path_parts) > 1 and VIDEO_ID_RE.match(path_parts[1]):
+ return path_parts[1]
+
+ if host == "youtu.be" and path_parts and VIDEO_ID_RE.match(path_parts[0]):
+ return path_parts[0]
+
+ fallback = VIDEO_ID_ANYWHERE_RE.search(candidate)
+ if fallback:
+ return fallback.group(1)
+
+ raise ValueError(f"Could not parse a YouTube video ID from: {value}")
+
+
+def parse_language_chain(value: str | None) -> list[str]:
+ if not value:
+ return []
+ return [item.strip() for item in value.split(",") if item.strip()]
+
+
+def import_transcript_api() -> tuple[Any, dict[str, type[BaseException]]]:
+ try:
+ from youtube_transcript_api import YouTubeTranscriptApi
+ except ImportError as exc:
+ raise RuntimeError(
+ "Missing dependency: youtube-transcript-api. Install with: uv pip install youtube-transcript-api"
+ ) from exc
+
+ error_names = [
+ "TranscriptsDisabled",
+ "NoTranscriptFound",
+ "VideoUnavailable",
+ "TooManyRequests",
+ "YouTubeRequestFailed",
+ "RequestBlocked",
+ "IpBlocked",
+ ]
+ errors: dict[str, type[BaseException]] = {}
+ try:
+ import youtube_transcript_api._errors as api_errors
+
+ for name in error_names:
+ error = getattr(api_errors, name, None)
+ if isinstance(error, type) and issubclass(error, BaseException):
+ errors[name] = error
+ except Exception:
+ pass
+
+ return YouTubeTranscriptApi, errors
+
+
+def fetch_transcript(video_id: str, languages: list[str]) -> TranscriptResult:
+ YouTubeTranscriptApi, errors = import_transcript_api()
+
+ try:
+ api = YouTubeTranscriptApi()
+ if hasattr(api, "fetch"):
+ return fetch_modern(api, video_id, languages)
+ except TypeError:
+ pass
+
+ return fetch_legacy(YouTubeTranscriptApi, errors, video_id, languages)
+
+
+def fetch_modern(api: Any, video_id: str, languages: list[str]) -> TranscriptResult:
+ used_fallback = False
+ try:
+ fetched = api.fetch(video_id, languages=languages or None)
+ except Exception as first_error:
+ if not languages:
+ raise map_transcript_error(first_error) from first_error
+ try:
+ fetched = api.fetch(video_id)
+ used_fallback = True
+ except Exception as fallback_error:
+ raise map_transcript_error(fallback_error) from fallback_error
+
+ return TranscriptResult(
+ video_id=video_id,
+ language=getattr(fetched, "language_code", None) or getattr(fetched, "language", None),
+ is_generated=getattr(fetched, "is_generated", None),
+ segments=normalize_segments(fetched),
+ requested_languages=languages,
+ used_language_fallback=used_fallback,
+ )
+
+
+def fetch_legacy(
+ YouTubeTranscriptApi: Any,
+ errors: dict[str, type[BaseException]],
+ video_id: str,
+ languages: list[str],
+) -> TranscriptResult:
+ try:
+ transcript_list = YouTubeTranscriptApi.list_transcripts(video_id)
+ transcript = select_legacy_transcript(transcript_list, languages)
+ used_fallback = bool(languages and getattr(transcript, "language_code", None) not in languages)
+ return TranscriptResult(
+ video_id=video_id,
+ language=getattr(transcript, "language_code", None) or getattr(transcript, "language", None),
+ is_generated=getattr(transcript, "is_generated", None),
+ segments=normalize_segments(transcript.fetch()),
+ requested_languages=languages,
+ used_language_fallback=used_fallback,
+ )
+ except Exception as error:
+ raise map_transcript_error(error, errors) from error
+
+
+def select_legacy_transcript(transcript_list: Any, languages: list[str]) -> Any:
+ if languages:
+ for finder in ("find_transcript", "find_generated_transcript"):
+ try:
+ return getattr(transcript_list, finder)(languages)
+ except Exception:
+ continue
+
+ for transcript in transcript_list:
+ return transcript
+
+ raise TranscriptUnavailable("No transcript entries were returned for this video.")
+
+
+def map_transcript_error(
+ error: BaseException, errors: dict[str, type[BaseException]] | None = None
+) -> BaseException:
+ name = error.__class__.__name__
+ message = str(error) or name
+ unavailable_names = {
+ "TranscriptsDisabled",
+ "NoTranscriptFound",
+ "VideoUnavailable",
+ "RequestBlocked",
+ "IpBlocked",
+ }
+ if name in unavailable_names:
+ return TranscriptUnavailable(message)
+ if errors:
+ for error_name in unavailable_names:
+ error_type = errors.get(error_name)
+ if error_type and isinstance(error, error_type):
+ return TranscriptUnavailable(message)
+ return error
+
+
+def normalize_segments(raw_segments: Any) -> list[dict[str, Any]]:
+ if hasattr(raw_segments, "to_raw_data"):
+ raw_segments = raw_segments.to_raw_data()
+
+ segments: list[dict[str, Any]] = []
+ for item in raw_segments:
+ if isinstance(item, dict):
+ start = item.get("start", 0)
+ duration = item.get("duration", 0)
+ text = item.get("text", "")
+ else:
+ start = getattr(item, "start", 0)
+ duration = getattr(item, "duration", 0)
+ text = getattr(item, "text", "")
+
+ segments.append(
+ {
+ "start": float(start or 0),
+ "duration": float(duration or 0),
+ "text": clean_text(str(text or "")),
+ }
+ )
+
+ return segments
+
+
+def clean_text(value: str) -> str:
+ return re.sub(r"\s+", " ", value.replace("\n", " ")).strip()
+
+
+def duration_estimate(segments: list[dict[str, Any]]) -> float:
+ if not segments:
+ return 0.0
+ return max(float(segment["start"]) + float(segment["duration"]) for segment in segments)
+
+
+def validate_segments(segments: list[dict[str, Any]]) -> None:
+ if not segments:
+ raise TranscriptUnavailable("Transcript returned zero segments.")
+
+ previous = -1.0
+ for index, segment in enumerate(segments):
+ start = float(segment["start"])
+ if start < previous:
+ raise TranscriptUnavailable(f"Transcript timestamps are not monotonic at segment {index}.")
+ previous = start
+
+
+def format_timestamp(seconds: float) -> str:
+ total = int(max(0, seconds))
+ hours = total // 3600
+ minutes = (total % 3600) // 60
+ secs = total % 60
+ return f"{hours:02d}:{minutes:02d}:{secs:02d}"
+
+
+def render_text(segments: list[dict[str, Any]], timestamps: bool) -> str:
+ if timestamps:
+ return "\n".join(
+ f"[{format_timestamp(float(segment['start']))}] {segment['text']}" for segment in segments
+ )
+ return " ".join(segment["text"] for segment in segments).strip()
+
+
+def build_payload(result: TranscriptResult) -> dict[str, Any]:
+ return {
+ "video_id": result.video_id,
+ "language": result.language,
+ "is_generated": result.is_generated,
+ "requested_languages": result.requested_languages,
+ "used_language_fallback": result.used_language_fallback,
+ "segment_count": len(result.segments),
+ "duration_estimate": duration_estimate(result.segments),
+ "transcript": result.segments,
+ }
+
+
+def build_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(description="Fetch a YouTube transcript.")
+ parser.add_argument("video", help="YouTube URL or raw 11-character video ID")
+ parser.add_argument("--text-only", action="store_true", help="Output readable transcript text")
+ parser.add_argument("--timestamps", action="store_true", help="Output text with [HH:MM:SS] timestamps")
+ parser.add_argument("--language", help="Language code or comma chain, e.g. en or tr,en")
+ parser.add_argument("--json", action="store_true", help="Force JSON output")
+ return parser
+
+
+def main(argv: list[str] | None = None) -> int:
+ parser = build_parser()
+ args = parser.parse_args(argv)
+
+ try:
+ video_id = parse_video_id(args.video)
+ languages = parse_language_chain(args.language)
+ result = fetch_transcript(video_id, languages)
+ validate_segments(result.segments)
+
+ if args.json or not (args.text_only or args.timestamps):
+ print(json.dumps(build_payload(result), ensure_ascii=False, indent=2))
+ else:
+ print(render_text(result.segments, timestamps=args.timestamps))
+ return 0
+ except ValueError as error:
+ print(f"error: {error}", file=sys.stderr)
+ return 2
+ except TranscriptUnavailable as error:
+ print(f"error: {error}", file=sys.stderr)
+ return 4
+ except RuntimeError as error:
+ print(f"error: {error}", file=sys.stderr)
+ return 3
+ except Exception as error:
+ print(f"error: {error}", file=sys.stderr)
+ return 5
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/src/components/agent-avatar.tsx b/src/components/agent-avatar.tsx
index e6404ce438..6f0978b9ce 100644
--- a/src/components/agent-avatar.tsx
+++ b/src/components/agent-avatar.tsx
@@ -1,4 +1,4 @@
-'use client'
+'use client'
import { useSyncExternalStore } from 'react'
import {
@@ -138,12 +138,12 @@ function AgentAvatar({
className={cn('leading-none', getEmojiSizeClassName(size))}
aria-hidden="true"
>
- 🦞
+ 🦞
) : (
): Array {
}
function buildDemoReply(agentName: string, text: string): string {
- return `${agentName} (demo): Received "${text}". Hermes Agent is unavailable, so this is a simulated response.`
+ return `${agentName} (demo): Received "${text}". Nova gateway is unavailable, so this is a simulated response.`
}
export function AgentChatModal({
@@ -143,7 +143,7 @@ export function AgentChatModal({
{
id: `demo-intro-${sessionKey}`,
role: 'agent',
- text: 'Hermes Agent is unavailable. Running in demo mode with simulated responses.',
+ text: 'Nova gateway is unavailable. Running in demo mode with simulated responses.',
timestamp: Date.now(),
},
])
diff --git a/src/components/avatars/assistant-avatar.tsx b/src/components/avatars/assistant-avatar.tsx
index d4e2c4c5b2..479bf64c8d 100644
--- a/src/components/avatars/assistant-avatar.tsx
+++ b/src/components/avatars/assistant-avatar.tsx
@@ -1,4 +1,4 @@
-import { memo } from 'react'
+import { memo } from 'react'
import { cn } from '@/lib/utils'
type AvatarProps = {
@@ -7,13 +7,13 @@ type AvatarProps = {
}
/**
- * Assistant avatar — Hermes Agent caduceus on Nous blue.
+ * Assistant avatar — Nova gateway caduceus on Nous blue.
*/
function AssistantAvatarComponent({ size = 28, className }: AvatarProps) {
return (
-
-
+
+
-
{feature}
-
- Not available on this backend. Connect to a Hermes Agent gateway to unlock{' '}
+
{feature}
+
+ Not available on this backend. Connect to the local Nova gateway to unlock{' '}
{feature}.
{description ? (
-
{description}
+
{description}
) : null}
diff --git a/src/components/claude-health-banner.tsx b/src/components/claude-health-banner.tsx
index 2fa3a91fb8..8c695a8666 100644
--- a/src/components/claude-health-banner.tsx
+++ b/src/components/claude-health-banner.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useState } from 'react'
+import { useEffect, useState } from 'react'
import { fetchClaudeAuthStatus } from '@/lib/claude-auth'
const POLL_INTERVAL = 30_000
@@ -56,7 +56,7 @@ export function ClaudeHealthBanner({
}}
>
-
Hermes Agent unreachable{lastError ? ` — ${lastError}` : ''}
+
Nova gateway unreachable{lastError ? ` -- ${lastError}` : ''}
{
diff --git a/src/components/claude-reconnect-banner.tsx b/src/components/claude-reconnect-banner.tsx
index 27cc3713ca..b9e1e3d082 100644
--- a/src/components/claude-reconnect-banner.tsx
+++ b/src/components/claude-reconnect-banner.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useRef, useState } from 'react'
+import { useEffect, useRef, useState } from 'react'
const POLL_INTERVAL_MS = 10_000
const FLASH_DURATION_MS = 1_800
@@ -11,7 +11,7 @@ type BannerState = 'hidden' | 'disconnected' | 'connected'
async function probeClaudeHealth(): Promise {
// Use the portable-aware connection status endpoint first,
- // which works with both Hermes Agent and OpenAI-compatible backends.
+ // which works with both Nova gateway and OpenAI-compatible backends.
try {
const response = await fetch('/api/connection-status', {
cache: 'no-store',
@@ -47,7 +47,7 @@ export function ClaudeReconnectBanner({
const wasDisconnectedRef = useRef(false)
const flashTimerRef = useRef(null)
// Silent auto-restart: if the gateway disappears mid-session, fire
- // /api/start-claude once. After that, fall back to the manual "Start Agent"
+ // /api/start-claude once. After that, fall back to the manual "Start gateway"
// button so we don't loop forever on a busted environment.
const autoRestartTriedAtRef = useRef(0)
// Cool-down so a permanently-dead gateway doesn't get poked every probe.
@@ -132,7 +132,7 @@ export function ClaudeReconnectBanner({
if (res.ok && data.ok) {
setMessage(
data.message ||
- 'Auto-restarting Hermes Agent gateway…',
+ 'Auto-restarting Nova gateway...',
)
// Probe again shortly so the banner clears as soon as
// the gateway answers /health.
@@ -144,7 +144,7 @@ export function ClaudeReconnectBanner({
}
})
.catch(() => {
- // silent: user can still hit "Start Agent"
+ // silent: user can still hit "Start gateway"
})
}
}
@@ -210,17 +210,17 @@ export function ClaudeReconnectBanner({
}
if (!response.ok || !payload.ok) {
- throw new Error(payload.error || 'Failed to start Hermes Agent')
+ throw new Error(payload.error || 'Failed to start Nova gateway')
}
setMessage(
payload.message === 'already running'
- ? 'Hermes Agent is already running'
- : 'Starting Hermes Agent…',
+ ? 'Nova gateway is already running'
+ : 'Starting Nova gateway...',
)
} catch (error) {
setMessage(
- error instanceof Error ? error.message : 'Failed to start Hermes Agent',
+ error instanceof Error ? error.message : 'Failed to start Nova gateway',
)
} finally {
setIsStarting(false)
@@ -260,7 +260,7 @@ export function ClaudeReconnectBanner({
/>
- {isDisconnected ? 'Hermes Agent not connected' : 'Connected'}
+ {isDisconnected ? 'Nova gateway not connected' : 'Connected'}
{message ? (
{message}
@@ -281,7 +281,7 @@ export function ClaudeReconnectBanner({
color: 'inherit',
}}
>
- {isChecking ? 'Retrying…' : 'Retry'}
+ {isChecking ? 'Retrying...' : 'Retry'}
- {isStarting ? 'Starting…' : 'Start Agent'}
+ {isStarting ? 'Starting...' : 'Start gateway'}
) : null}
diff --git a/src/components/dashboard-overflow-panel.tsx b/src/components/dashboard-overflow-panel.tsx
index 42990fc71b..0d557ce6ac 100644
--- a/src/components/dashboard-overflow-panel.tsx
+++ b/src/components/dashboard-overflow-panel.tsx
@@ -1,4 +1,4 @@
-import { useEffect } from 'react'
+import { useEffect } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { HugeiconsIcon } from '@hugeicons/react'
import {
@@ -155,7 +155,7 @@ export function DashboardOverflowPanel({ open, onClose }: Props) {
onSelect={handleSelect}
/>
diff --git a/src/components/mobile-tab-bar.tsx b/src/components/mobile-tab-bar.tsx
index 314a6d56fb..bc6c020dbf 100644
--- a/src/components/mobile-tab-bar.tsx
+++ b/src/components/mobile-tab-bar.tsx
@@ -254,7 +254,7 @@ export function MobileTabBar() {
ref={navRef}
className={cn(
// Pill: fixed bottom center, shrink to content width
- 'fixed bottom-0 left-0 right-0 mx-auto w-fit z-[80] md:hidden',
+ 'fixed bottom-0 left-2 right-2 mx-auto z-[80] w-auto max-w-[calc(100vw-1rem)] overflow-x-auto [scrollbar-width:none] lg:hidden [&::-webkit-scrollbar]:hidden',
// Vertical position: above home indicator
'mb-[max(env(safe-area-inset-bottom,8px),16px)]',
// Keep the pill visually isolated from page and error-state backgrounds
@@ -275,7 +275,7 @@ export function MobileTabBar() {
onTouchMove={handlePillTouchMove}
onTouchEnd={handlePillTouchEnd}
>
-
+
{MOBILE_NAV_TABS.map((tab, idx) => {
const isActive = tab.match(pathname)
const isCenter = tab.id === 'chat'
diff --git a/src/components/prompt-kit/markdown.tsx b/src/components/prompt-kit/markdown.tsx
index ba1d46cb40..d23cf04b36 100644
--- a/src/components/prompt-kit/markdown.tsx
+++ b/src/components/prompt-kit/markdown.tsx
@@ -1,4 +1,4 @@
-import { marked } from 'marked'
+import { marked } from 'marked'
import { createContext, memo, useContext, useId, useMemo, useRef } from 'react'
import ReactMarkdown from 'react-markdown'
import rehypeRaw from 'rehype-raw'
@@ -10,7 +10,7 @@ import type { Components } from 'react-markdown'
import { cn } from '@/lib/utils'
/**
- * Rewrite Workspace-local `MEDIA:
` tokens emitted by Hermes Agent to the
+ * Rewrite Workspace-local `MEDIA:` tokens emitted by Nova gateway to the
* authenticated media endpoint. Messaging bridges intercept MEDIA tags before
* rendering; the web chat sees raw markdown/HTML and needs this client-side
* rewrite so browsers can load the file through Workspace instead of trying to
diff --git a/src/components/settings-dialog/settings-dialog.tsx b/src/components/settings-dialog/settings-dialog.tsx
index 76a5282898..3f59548633 100644
--- a/src/components/settings-dialog/settings-dialog.tsx
+++ b/src/components/settings-dialog/settings-dialog.tsx
@@ -1,4 +1,4 @@
-'use client'
+'use client'
import { HugeiconsIcon } from '@hugeicons/react'
import {
@@ -57,11 +57,11 @@ import {
DialogTitle,
} from '@/components/ui/dialog'
-// ── Language ────────────────────────────────────────────────────────────
+// ── Language ────────────────────────────────────────────────────────────
import { LOCALE_LABELS, getLocale, setLocale } from '@/lib/i18n'
-// ── Types ───────────────────────────────────────────────────────────────
+// ── Types ───────────────────────────────────────────────────────────────
type SectionId =
| 'claude'
@@ -97,7 +97,7 @@ function _isDarkEnterpriseTheme(theme: string | null): theme is ThemeId {
}
void _isDarkEnterpriseTheme
-// ── Shared building blocks ──────────────────────────────────────────────
+// ── Shared building blocks ──────────────────────────────────────────────
function SectionHeader({
title,
@@ -150,7 +150,7 @@ function Row({
const SETTINGS_CARD_CLASS =
'rounded-xl border border-primary-200 bg-primary-50/80 px-4 py-3 shadow-sm'
-// ── Section components ──────────────────────────────────────────────────
+// ── Section components ──────────────────────────────────────────────────
const PROVIDER_CARDS: Array<{
id: string
@@ -160,7 +160,7 @@ const PROVIDER_CARDS: Array<{
authType: 'oauth' | 'api_key' | 'none'
envKey?: string
}> = [
- // Local providers first — zero setup
+ // Local providers first — zero setup
{
id: 'ollama',
name: 'Ollama',
@@ -391,7 +391,7 @@ function HermesContent() {
for (const p of d.providers || []) {
const envKey = p.envKeys?.[0]
if (!p.configured || !envKey) continue
- keys[envKey] = p.maskedCredentials?.[envKey] || '••••'
+ keys[envKey] = p.maskedCredentials?.[envKey] || '••••'
}
setConfiguredKeys(keys)
// Load custom provider config (may be stored as 'custom' or legacy 'manifest')
@@ -420,7 +420,7 @@ function HermesContent() {
for (const p of d.providers || []) {
const envKey = p.envKeys?.[0]
if (!p.configured || !envKey) continue
- keys[envKey] = p.maskedCredentials?.[envKey] || '••••'
+ keys[envKey] = p.maskedCredentials?.[envKey] || '••••'
}
setConfiguredKeys(keys)
}
@@ -611,7 +611,7 @@ function HermesContent() {
if (!configAvailable) {
return (
)
@@ -667,7 +667,7 @@ function HermesContent() {
!!configuredKeys[p.envKey])
const missingKey =
p.authType === 'api_key' && !verified && p.id !== 'custom'
- // hasKey gates click — keep OAuth + local clickable (existing
+ // hasKey gates click — keep OAuth + local clickable (existing
// behaviour) so users can still authenticate via the card.
const hasKey =
p.authType === 'none' ||
@@ -724,7 +724,7 @@ function HermesContent() {
const disc = localDiscovery?.providers.find(
(lp) => lp.id === p.id,
)
- if (disc?.online) return '🟢 Detected'
+ if (disc?.online) return '🟢 Detected'
if (p.authType === 'oauth') return 'OAuth'
if (p.authType === 'none') return 'Local'
return hasKey ? 'Key set' : 'Key required'
@@ -861,7 +861,7 @@ function HermesContent() {
{model.id}
{defaultProvider === provider.id &&
defaultModelId === model.id
- ? ' · default'
+ ? ' · default'
: ''}
))}
@@ -875,7 +875,7 @@ function HermesContent() {
size="sm"
onClick={() => setDefaultModel(provider.id, activeModel)}
>
- Set as default: {provider.id} · {activeModel}
+ Set as default: {provider.id} · {activeModel}
) : null}
@@ -894,7 +894,7 @@ function HermesContent() {
className="mb-1 text-xs font-semibold uppercase tracking-wider"
style={mutedStyle}
>
- Model — pick one, then confirm below
+ Model — pick one, then confirm below
{(() => {
@@ -927,7 +927,7 @@ function HermesContent() {
>
{model}
{defaultProvider === activeProvider && defaultModelId === model
- ? ' · default'
+ ? ' · default'
: ''}
))}
@@ -939,14 +939,14 @@ function HermesContent() {
size="sm"
onClick={() => setDefaultModel(activeProvider, activeModel)}
>
- Set as default: {activeProvider} · {activeModel}
+ Set as default: {activeProvider} · {activeModel}
) : null}
)}
- {/* Custom OpenAI-compatible endpoint fields — Base URL only; API key lives in API Keys section */}
+ {/* Custom OpenAI-compatible endpoint fields — Base URL only; API key lives in API Keys section */}
{activeProvider === 'custom' && (
@@ -1070,7 +1070,7 @@ function HermesContent() {
size="sm"
onClick={() => setDefaultModel('custom', customModel)}
>
- Set as default: custom · {customModel}
+ Set as default: custom · {customModel}
) : null}
@@ -1084,7 +1084,7 @@ function HermesContent() {
if (!disc || !disc.needsRestart) return null
return (
- ⚠️ Gateway restart needed to use {disc.name}. Run{' '}
+ Gateway restart needed to use {disc.name}. Run{' '}
hermes gateway restart
{' '}
@@ -1265,12 +1265,12 @@ function HermesContent() {
Model
- {activeModel || '—'}
+ {activeModel || '—'}
Provider
{PROVIDER_CARDS.find((p) => p.id === activeProvider)?.name ||
activeProvider ||
- '—'}
+ '—'}
Config
~/.hermes/config.yaml
@@ -1479,7 +1479,7 @@ function AppearanceContent() {
))}
- {/* Accent color removed — themes control accent */}
+ {/* Accent color removed — themes control accent */}
Enterprise Theme
@@ -1489,7 +1489,7 @@ function AppearanceContent() {
- {/* Mobile chat nav removed — not relevant for Hermes */}
+ {/* Mobile chat nav removed — not relevant for Hermes */}
)
@@ -1761,7 +1761,7 @@ function _LoaderContent() {
function Preview({ style }: { style: LoaderStyle }) {
if (style === 'dots') return
if (style === 'lobster')
- return
🦞
+ return
🦞
if (style === 'logo') return
const p = getPreset(style)
return p ? (
@@ -1848,7 +1848,7 @@ function ChatContent() {
label="Enter key behavior"
description={
cs.enterBehavior === 'newline'
- ? 'Enter inserts a newline. Use ⌘/Ctrl+Enter to send.'
+ ? 'Enter inserts a newline. Use ⌘/Ctrl+Enter to send.'
: 'Enter sends the message. Use Shift+Enter for a newline.'
}
>
@@ -1894,7 +1894,7 @@ function ChatContent() {
/>
- {/* Loading animation removed — not relevant for Hermes */}
+ {/* Loading animation removed — not relevant for Hermes */}
)
}
@@ -1980,11 +1980,11 @@ function _AdvancedContent() {
@@ -1994,7 +1994,7 @@ function _AdvancedContent() {
value={settings.claudeUrl}
onChange={(e) => validateAndUpdateUrl(e.target.value)}
className="h-8 w-full rounded-lg border-primary-200 text-sm"
- aria-label="Hermes Agent URL"
+ aria-label="Nova gateway URL"
aria-invalid={!!urlError}
aria-describedby={urlError ? urlErrorId : undefined}
/>
@@ -2051,7 +2051,7 @@ function _AdvancedContent() {
)
}
-// ── Error Boundary ──────────────────────────────────────────────────────
+// ── Error Boundary ──────────────────────────────────────────────────────
class SettingsErrorBoundary extends Component<
{ children: React.ReactNode },
@@ -2085,7 +2085,7 @@ class SettingsErrorBoundary extends Component<
}
}
-// ── Agent Behavior ──────────────────────────────────────────────────────
+// ── Agent Behavior ──────────────────────────────────────────────────────
function AgentBehaviorContent() {
const [config, setConfig] = useState>({})
@@ -2174,7 +2174,7 @@ function AgentBehaviorContent() {
)
}
-// ── Voice (TTS + STT) ──────────────────────────────────────────────────
+// ── Voice (TTS + STT) ──────────────────────────────────────────────────
function VoiceContent() {
const [tts, setTts] = useState>({})
@@ -2345,7 +2345,7 @@ function VoiceContent() {
)
}
-// ── Display ─────────────────────────────────────────────────────────────
+// ── Display ─────────────────────────────────────────────────────────────
function DisplayContent() {
const [config, setConfig] = useState>({})
@@ -2471,7 +2471,7 @@ function LanguageContent() {
)
}
-// ── Main Dialog ─────────────────────────────────────────────────────────
+// ── Main Dialog ─────────────────────────────────────────────────────────
const CONTENT_MAP: Record React.JSX.Element> = {
claude: HermesContent,
@@ -2521,7 +2521,7 @@ export function SettingsDialog({
Settings
- Configure Hermes Workspace
+ Configure Nova Mission Control
- All settings →
+ All settings →
diff --git a/src/components/slash-command-menu.test.tsx b/src/components/slash-command-menu.test.tsx
index 4f1af7cc0e..bca2ecc695 100644
--- a/src/components/slash-command-menu.test.tsx
+++ b/src/components/slash-command-menu.test.tsx
@@ -1,4 +1,4 @@
-import { describe, expect, it } from 'vitest'
+import { describe, expect, it } from 'vitest'
import { DEFAULT_SLASH_COMMANDS, mergeSlashCommands } from './slash-command-menu'
@@ -49,7 +49,7 @@ describe('mergeSlashCommands', () => {
const merged = mergeSlashCommands(DEFAULT_SLASH_COMMANDS, [
{
command: '/hermes-agent',
- description: 'Complete guide to using and extending Hermes Agent',
+ description: 'Complete guide to using and extending Nova gateway',
},
])
diff --git a/src/components/status-indicator.tsx b/src/components/status-indicator.tsx
index 23cd39a690..3aa223bf02 100644
--- a/src/components/status-indicator.tsx
+++ b/src/components/status-indicator.tsx
@@ -1,4 +1,4 @@
-'use client'
+'use client'
import { useQuery } from '@tanstack/react-query'
@@ -84,10 +84,10 @@ function buildTooltip(
if (!data.modelConfigured) parts.push('No model selected')
}
if (data.status === 'enhanced') {
- parts.push('Hermes Agent gateway enhancements detected')
+ parts.push('Nova gateway gateway enhancements detected')
}
if (data.activeModel) parts.push(`Model: ${data.activeModel}`)
- return parts.join(' · ')
+ return parts.join(' · ')
}
/**
diff --git a/src/components/update-center-notifier.test.tsx b/src/components/update-center-notifier.test.tsx
index bc166f3891..6ce8a983e5 100644
--- a/src/components/update-center-notifier.test.tsx
+++ b/src/components/update-center-notifier.test.tsx
@@ -1,4 +1,4 @@
-/** @vitest-environment jsdom */
+/** @vitest-environment jsdom */
import { beforeEach, describe, expect, it } from 'vitest'
import { __updateReleaseNotesStorageForTests } from './update-center-notifier'
@@ -8,7 +8,7 @@ const { NOTES_SEEN_KEY, storeNotes } = __updateReleaseNotesStorageForTests
const agentReleaseNotes = [
{
product: 'agent' as const,
- label: 'Hermes Agent',
+ label: 'Nova gateway',
from: 'c23a87bc163b188abc7e40fbdccf07a9739231c3',
to: '4fdfdf67499c33015ed56e6e5910d8bdc00aa901',
commits: ['Merge pull request #25045 (4fdfdf674)'],
diff --git a/src/components/workspace-shell.tsx b/src/components/workspace-shell.tsx
index e8ebb26652..ae7013b6f5 100644
--- a/src/components/workspace-shell.tsx
+++ b/src/components/workspace-shell.tsx
@@ -21,7 +21,8 @@ import {
useState,
} from 'react'
import { useNavigate, useRouterState } from '@tanstack/react-router'
-import { fetchClaudeAuthStatus, type AuthStatus } from '@/lib/claude-auth'
+import type { AuthStatus } from '@/lib/claude-auth'
+import { fetchClaudeAuthStatus } from '@/lib/claude-auth'
import { cn } from '@/lib/utils'
import { ConnectionStartupScreen } from '@/components/connection-startup-screen'
import { ChatSidebar } from '@/screens/chat/components/chat-sidebar'
@@ -29,19 +30,11 @@ import { useChatSessions } from '@/screens/chat/hooks/use-chat-sessions'
import { useWorkspaceStore } from '@/stores/workspace-store'
import { SIDEBAR_TOGGLE_EVENT } from '@/hooks/use-global-shortcuts'
import { useSwipeNavigation } from '@/hooks/use-swipe-navigation'
-// Lazy: ChatPanel statically imports ChatScreen and with it the chat
-// markdown pipeline; keeping it out of the eager entry means non-chat
-// routes stop paying for chat at first paint. The chat route itself
-// already lazy-loads ChatScreen through its own boundary.
-const ChatPanel = lazy(() =>
- import('@/components/chat-panel').then((m) => ({ default: m.ChatPanel })),
-)
import { ChatPanelToggle } from '@/components/chat-panel-toggle'
import { LoginScreen } from '@/components/auth/login-screen'
import { MobileTabBar } from '@/components/mobile-tab-bar'
import { MobileHamburgerMenu } from '@/components/mobile-hamburger-menu'
import { MobilePageHeader } from '@/components/mobile-page-header'
-
import { MobileTerminalInput } from '@/components/terminal/mobile-terminal-input'
import { ClaudeReconnectBanner } from '@/components/claude-reconnect-banner'
import { useMobileKeyboard } from '@/hooks/use-mobile-keyboard'
@@ -50,6 +43,14 @@ import { CommandPalette } from '@/components/command-palette'
import { useSettings } from '@/hooks/use-settings'
// ActivityTicker moved to dashboard-only (too noisy for global header)
+// Lazy: ChatPanel statically imports ChatScreen and with it the chat
+// markdown pipeline; keeping it out of the eager entry means non-chat
+// routes stop paying for chat at first paint. The chat route itself
+// already lazy-loads ChatScreen through its own boundary.
+const ChatPanel = lazy(() =>
+ import('@/components/chat-panel').then((m) => ({ default: m.ChatPanel })),
+)
+
const TerminalWorkspace = lazy(() =>
import('@/components/terminal/terminal-workspace').then((m) => ({
default: m.TerminalWorkspace,
@@ -87,6 +88,16 @@ export function WorkspaceShell({ children }: WorkspaceShellProps) {
// ChatGPT-style: track visual viewport height for keyboard-aware layout
useMobileKeyboard()
+ // Nova skin, app-wide: every module renders navy/amber via the
+ // html[data-nova-cockpit] override block in styles.css (Taylor's call —
+ // no green/teal shell anywhere, not just on the dashboard route).
+ useEffect(() => {
+ document.documentElement.dataset.novaCockpit = 'true'
+ return () => {
+ delete document.documentElement.dataset.novaCockpit
+ }
+ }, [])
+
const [creatingSession, setCreatingSession] = useState(false)
const [isMobile, setIsMobile] = useState(() => {
if (typeof window === 'undefined') return false
@@ -157,7 +168,7 @@ export function WorkspaceShell({ children }: WorkspaceShellProps) {
chatReady?: boolean
modelConfigured?: boolean
}
- if (data?.ok || (data?.chatReady && data?.modelConfigured)) {
+ if (data.ok || (data.chatReady && data.modelConfigured)) {
setAuthStatus({ authenticated: true, authRequired: false })
setConnectionVerified(true)
}
@@ -198,7 +209,7 @@ export function WorkspaceShell({ children }: WorkspaceShellProps) {
const isOnPlaygroundRoute = pathname === '/playground' || pathname.startsWith('/playground/')
const isOnHermesWorldLandingRoute = pathname === '/hermes-world' || pathname.startsWith('/hermes-world/') || pathname === '/world' || pathname.startsWith('/world/')
const isEmbeddedSurface =
- search?.embed === '1' || search?.embed === 'true' || search?.mode === 'embed'
+ search.embed === '1' || search.embed === 'true' || search.mode === 'embed'
const isChromeFreeSurface = isEmbeddedSurface || isOnHermesWorldLandingRoute
const hideChatSidebar = isOnChatRoute && chatFocusMode
const showDesktopSidebarBackdrop =
@@ -350,12 +361,12 @@ export function WorkspaceShell({ children }: WorkspaceShellProps) {
{/* Activity ticker bar */}
{/* Persistent sidebar */}
- {!isChromeFreeSurface && !isMobile && !hideChatSidebar && (
+ {!isMobile && !hideChatSidebar && (
{isMobile &&
- !isChromeFreeSurface &&
!isOnChatRoute &&
!isOnTerminalRoute &&
mobilePageTitle && }
@@ -447,7 +456,7 @@ export function WorkspaceShell({ children }: WorkspaceShellProps) {
{/* Chat panel — visible on non-chat routes (but not in HermesWorld, which has its own in-game chat) */}
- {!isOnChatRoute && !isOnPlaygroundRoute && !isChromeFreeSurface && !isMobile && (
+ {!isOnChatRoute && !isOnPlaygroundRoute && !isMobile && (
@@ -455,7 +464,7 @@ export function WorkspaceShell({ children }: WorkspaceShellProps) {
{/* Floating chat toggle — visible on non-chat routes (but not in HermesWorld) */}
- {!isChromeFreeSurface && !isOnChatRoute && !isOnPlaygroundRoute && !isMobile &&
}
+ {!isOnChatRoute && !isOnPlaygroundRoute && !isMobile &&
}
{showDesktopSidebarBackdrop ? (
- {!isChromeFreeSurface ? : null}
- {!isChromeFreeSurface ? : null}
- {!isChromeFreeSurface && !isMobile && !isOnChatRoute && settings.showSystemMetricsFooter ? (
+
+
+ {!isMobile && !isOnChatRoute && settings.showSystemMetricsFooter ? (
) : null}
- {!isChromeFreeSurface ? : null}
+
>
)
}
diff --git a/src/lib/home-mode.test.ts b/src/lib/home-mode.test.ts
new file mode 100644
index 0000000000..b29c6556c5
--- /dev/null
+++ b/src/lib/home-mode.test.ts
@@ -0,0 +1,82 @@
+import { describe, expect, it } from 'vitest'
+import { classifyHomeMode } from './home-mode'
+
+const timezone = 'America/Chicago'
+
+describe('classifyHomeMode', () => {
+ it('classifies the 5-6am window as morning ramp-up', () => {
+ const context = classifyHomeMode({
+ now: '2026-07-06T10:30:00.000Z',
+ timezone,
+ location: 'West Texas',
+ })
+
+ expect(context.now_local).toContain('05:30:00')
+ expect(context.lane).toBe('morning_ramp_up')
+ expect(context.recommended_intensity).toBe('low')
+ })
+
+ it('protects 5-8:30pm after-work time as home/family by default', () => {
+ const context = classifyHomeMode({
+ now: '2026-07-06T23:15:00.000Z',
+ timezone,
+ location: 'West Texas',
+ })
+
+ expect(context.now_local).toContain('18:15:00')
+ expect(context.lane).toBe('family_evening')
+ expect(context.recommended_intensity).toBe('none')
+ })
+
+ it('keeps 8:30-10pm as light creative/planning', () => {
+ const context = classifyHomeMode({
+ now: '2026-07-07T02:00:00.000Z',
+ timezone,
+ location: 'West Texas',
+ })
+
+ expect(context.now_local).toContain('21:00:00')
+ expect(context.lane).toBe('light_creative_planning')
+ expect(context.recommended_intensity).toBe('low')
+ })
+
+ it('uses 10pm+ as the parking-lot late-night guardrail', () => {
+ const context = classifyHomeMode({
+ now: '2026-07-07T03:30:00.000Z',
+ timezone,
+ location: 'West Texas',
+ })
+
+ expect(context.now_local).toContain('22:30:00')
+ expect(context.lane).toBe('late_night_danger_zone')
+ expect(context.suggested_boundary).toContain('park')
+ })
+
+ it('lets Taylor explicitly choose bounded build mode after hours', () => {
+ const context = classifyHomeMode({
+ now: '2026-07-07T03:30:00.000Z',
+ timezone,
+ explicitBuildMode: true,
+ })
+
+ expect(context.lane).toBe('override_build_mode')
+ expect(context.recommended_intensity).toBe('bounded')
+ })
+
+ it('uses current calendar context when available', () => {
+ const context = classifyHomeMode({
+ now: '2026-07-06T20:00:00.000Z',
+ timezone,
+ calendar: [
+ {
+ title: 'Family dinner',
+ startsAt: '2026-07-06T19:30:00.000Z',
+ endsAt: '2026-07-06T21:00:00.000Z',
+ },
+ ],
+ })
+
+ expect(context.lane).toBe('family_evening')
+ expect(context.reason).toContain('Family dinner')
+ })
+})
diff --git a/src/lib/home-mode.ts b/src/lib/home-mode.ts
new file mode 100644
index 0000000000..d0fcd54621
--- /dev/null
+++ b/src/lib/home-mode.ts
@@ -0,0 +1,315 @@
+export type HomeModeLane =
+ | 'morning_ramp_up'
+ | 'work_block'
+ | 'transition_home'
+ | 'family_evening'
+ | 'light_creative_planning'
+ | 'late_night_danger_zone'
+ | 'weekend_reset'
+ | 'override_build_mode'
+
+export type RecommendedIntensity =
+ | 'none'
+ | 'low'
+ | 'medium'
+ | 'high'
+ | 'bounded'
+
+export type CalendarSignal = {
+ title?: string
+ startsAt?: string
+ endsAt?: string
+ laneHint?: HomeModeLane
+ busy?: boolean
+}
+
+export type HomeModePreferences = {
+ timezone?: string
+ location?: string
+ workdayStartMinute?: number
+ workdayEndMinute?: number
+ rampStartMinute?: number
+ rampEndMinute?: number
+ transitionHomeStartMinute?: number
+ transitionHomeEndMinute?: number
+ eveningHomeEndMinute?: number
+ lightCreativeEndMinute?: number
+ defaultAfterWorkHomeProtection?: boolean
+}
+
+export type HomeModeInput = {
+ now?: Date | string
+ timezone?: string
+ location?: string
+ calendar?: Array
+ preferences?: HomeModePreferences
+ explicitBuildMode?: boolean
+}
+
+export type HomeModeContext = {
+ now_local: string
+ timezone: string
+ lane: HomeModeLane
+ confidence: number
+ reason: string
+ suggested_boundary: string
+ recommended_intensity: RecommendedIntensity
+}
+
+type LocalParts = {
+ year: string
+ month: string
+ day: string
+ hour: string
+ minute: string
+ second: string
+ weekday: string
+}
+
+const DEFAULT_TIMEZONE = 'America/Chicago'
+const DEFAULT_LOCATION = 'United States'
+
+const DEFAULT_PREFERENCES = {
+ workdayStartMinute: 8 * 60,
+ workdayEndMinute: 17 * 60,
+ rampStartMinute: 5 * 60,
+ rampEndMinute: 6 * 60,
+ transitionHomeStartMinute: 17 * 60,
+ transitionHomeEndMinute: 18 * 60,
+ eveningHomeEndMinute: 20 * 60 + 30,
+ lightCreativeEndMinute: 22 * 60,
+ defaultAfterWorkHomeProtection: true,
+}
+
+function clampConfidence(value: number): number {
+ return Math.max(0, Math.min(1, Number(value.toFixed(2))))
+}
+
+function normalizeDate(now: Date | string | undefined): Date {
+ if (!now) return new Date()
+ if (now instanceof Date) return now
+ return new Date(now)
+}
+
+function getLocalParts(now: Date, timezone: string): LocalParts {
+ const parts = new Intl.DateTimeFormat('en-US', {
+ timeZone: timezone,
+ hourCycle: 'h23',
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit',
+ second: '2-digit',
+ weekday: 'short',
+ }).formatToParts(now)
+
+ const byType = parts.reduce<
+ Partial>
+ >((accumulator, part) => {
+ if (part.type !== 'literal') {
+ accumulator[part.type] = part.value
+ }
+ return accumulator
+ }, {})
+
+ return {
+ year: byType.year ?? '0000',
+ month: byType.month ?? '01',
+ day: byType.day ?? '01',
+ hour: byType.hour ?? '00',
+ minute: byType.minute ?? '00',
+ second: byType.second ?? '00',
+ weekday: byType.weekday ?? 'Mon',
+ }
+}
+
+function localIso(parts: LocalParts): string {
+ return `${parts.year}-${parts.month}-${parts.day}T${parts.hour}:${parts.minute}:${parts.second}`
+}
+
+function minuteOfDay(parts: LocalParts): number {
+ return Number(parts.hour) * 60 + Number(parts.minute)
+}
+
+function isWeekend(parts: LocalParts): boolean {
+ return parts.weekday === 'Sat' || parts.weekday === 'Sun'
+}
+
+function calendarApplies(event: CalendarSignal, now: Date): boolean {
+ if (!event.startsAt || !event.endsAt) return true
+ const start = Date.parse(event.startsAt)
+ const end = Date.parse(event.endsAt)
+ const current = now.getTime()
+ return (
+ Number.isFinite(start) &&
+ Number.isFinite(end) &&
+ current >= start &&
+ current < end
+ )
+}
+
+function findCurrentCalendarSignal(
+ calendar: Array | undefined,
+ now: Date,
+): CalendarSignal | null {
+ if (!calendar?.length) return null
+ return calendar.find((event) => calendarApplies(event, now)) ?? null
+}
+
+function calendarLane(event: CalendarSignal | null): HomeModeLane | null {
+ if (!event) return null
+ if (event.laneHint) return event.laneHint
+ const title = event.title?.toLowerCase() ?? ''
+ if (/\b(build|ship|deploy|incident|deadline|launch)\b/.test(title)) {
+ return 'override_build_mode'
+ }
+ if (/\b(family|home|dinner|school|kid|kids)\b/.test(title)) {
+ return 'family_evening'
+ }
+ if (/\b(plan|creative|write|sketch|brainstorm)\b/.test(title)) {
+ return 'light_creative_planning'
+ }
+ if (/\b(work|meeting|client|shop|neon moon|mesquite)\b/.test(title)) {
+ return 'work_block'
+ }
+ return null
+}
+
+function laneBoundary(lane: HomeModeLane): string {
+ switch (lane) {
+ case 'morning_ramp_up':
+ return 'Start gentle: one short orientation, one must-do, then breakfast/body basics.'
+ case 'work_block':
+ return 'Use a defined work block with a visible stop point before home time.'
+ case 'transition_home':
+ return 'Close loops, park open tabs, and protect the handoff into home.'
+ case 'family_evening':
+ return 'Default to home/family presence; only urgent work gets a short, explicit box.'
+ case 'light_creative_planning':
+ return 'Keep it light: notes, sketches, planning, or one bounded polish pass.'
+ case 'late_night_danger_zone':
+ return 'No new rabbit holes; park the idea, choose sleep, and schedule tomorrow.'
+ case 'weekend_reset':
+ return 'Favor reset, family, chores, recovery, and only opt-in building.'
+ case 'override_build_mode':
+ return 'Build mode is explicit: define a cap, success condition, and shutdown ritual.'
+ }
+}
+
+function laneIntensity(lane: HomeModeLane): RecommendedIntensity {
+ switch (lane) {
+ case 'morning_ramp_up':
+ return 'low'
+ case 'work_block':
+ return 'high'
+ case 'transition_home':
+ return 'bounded'
+ case 'family_evening':
+ return 'none'
+ case 'light_creative_planning':
+ return 'low'
+ case 'late_night_danger_zone':
+ return 'none'
+ case 'weekend_reset':
+ return 'low'
+ case 'override_build_mode':
+ return 'bounded'
+ }
+}
+
+export function classifyHomeMode(input: HomeModeInput = {}): HomeModeContext {
+ const preferences = { ...DEFAULT_PREFERENCES, ...input.preferences }
+ const timezone =
+ input.timezone || input.preferences?.timezone || DEFAULT_TIMEZONE
+ const location =
+ input.location || input.preferences?.location || DEFAULT_LOCATION
+ const now = normalizeDate(input.now)
+ const local = getLocalParts(now, timezone)
+ const minutes = minuteOfDay(local)
+ const currentCalendar = findCurrentCalendarSignal(input.calendar, now)
+ const hintedLane = calendarLane(currentCalendar)
+
+ let lane: HomeModeLane
+ let confidence = 0.82
+ let reason = `Live local clock in ${timezone}`
+
+ if (input.explicitBuildMode || hintedLane === 'override_build_mode') {
+ lane = 'override_build_mode'
+ confidence = input.explicitBuildMode ? 0.95 : 0.88
+ reason = input.explicitBuildMode
+ ? 'Taylor explicitly chose build mode.'
+ : `Calendar context points at build mode: ${currentCalendar?.title ?? 'busy block'}.`
+ } else if (hintedLane === 'family_evening') {
+ lane = 'family_evening'
+ confidence = 0.9
+ reason = `Calendar context protects home/family time: ${currentCalendar?.title ?? 'busy block'}.`
+ } else if (
+ minutes >= preferences.lightCreativeEndMinute ||
+ minutes < preferences.rampStartMinute
+ ) {
+ lane = 'late_night_danger_zone'
+ confidence = 0.93
+ reason = `It is ${local.hour}:${local.minute} in ${location}; this is the late-night guardrail window.`
+ } else if (isWeekend(local) && !hintedLane) {
+ lane = 'weekend_reset'
+ confidence = 0.85
+ reason = `${local.weekday} in ${location}; defaulting to weekend/reset unless build mode is explicit.`
+ } else if (
+ minutes >= preferences.rampStartMinute &&
+ minutes < preferences.rampEndMinute
+ ) {
+ lane = 'morning_ramp_up'
+ confidence = 0.9
+ reason = `It is ${local.hour}:${local.minute} in ${location}; this is the gentle ramp-up window.`
+ } else if (
+ hintedLane === 'light_creative_planning' ||
+ (minutes >= preferences.eveningHomeEndMinute &&
+ minutes < preferences.lightCreativeEndMinute)
+ ) {
+ lane = 'light_creative_planning'
+ confidence = hintedLane ? 0.88 : 0.84
+ reason = hintedLane
+ ? `Calendar context suggests light planning: ${currentCalendar?.title ?? 'calendar block'}.`
+ : `It is ${local.hour}:${local.minute} in ${location}; evening work should stay light and bounded.`
+ } else if (
+ minutes >= preferences.transitionHomeStartMinute &&
+ minutes < preferences.transitionHomeEndMinute
+ ) {
+ lane = 'transition_home'
+ confidence = 0.86
+ reason = `It is ${local.hour}:${local.minute} in ${location}; protect the work-to-home transition.`
+ } else if (
+ preferences.defaultAfterWorkHomeProtection &&
+ minutes >= preferences.transitionHomeEndMinute &&
+ minutes < preferences.eveningHomeEndMinute
+ ) {
+ lane = 'family_evening'
+ confidence = 0.88
+ reason = `It is ${local.hour}:${local.minute} in ${location}; after-work time defaults to home/family protection.`
+ } else if (
+ hintedLane === 'work_block' ||
+ (minutes >= preferences.workdayStartMinute &&
+ minutes < preferences.workdayEndMinute)
+ ) {
+ lane = 'work_block'
+ confidence = hintedLane ? 0.86 : 0.82
+ reason = hintedLane
+ ? `Calendar context says work block: ${currentCalendar?.title ?? 'calendar block'}.`
+ : `It is ${local.hour}:${local.minute} in ${location}; normal work block.`
+ } else {
+ lane = 'morning_ramp_up'
+ confidence = 0.62
+ reason = `It is ${local.hour}:${local.minute} in ${location}; no stronger lane matched, so keep the start gentle.`
+ }
+
+ return {
+ now_local: localIso(local),
+ timezone,
+ lane,
+ confidence: clampConfidence(confidence),
+ reason,
+ suggested_boundary: laneBoundary(lane),
+ recommended_intensity: laneIntensity(lane),
+ }
+}
diff --git a/src/lib/nova-daily-check-adapter.ts b/src/lib/nova-daily-check-adapter.ts
new file mode 100644
index 0000000000..8b460c19ff
--- /dev/null
+++ b/src/lib/nova-daily-check-adapter.ts
@@ -0,0 +1,34 @@
+export type NovaDailyCheckItem = {
+ id: string
+ label: string
+ checked: boolean
+}
+
+export type NovaDailyCheckState = {
+ items: Array
+ overthinking: 'low' | 'medium' | 'high'
+ mood: string
+ reminder: string
+ log: Array<{ date: string; text: string }>
+}
+
+export const novaDailyCheck: NovaDailyCheckState = {
+ items: [
+ { id: 'hydration', label: 'Hydration', checked: true },
+ { id: 'nutrition', label: 'Nutrition', checked: true },
+ { id: 'rest', label: 'Rest', checked: false },
+ ],
+ overthinking: 'high',
+ mood: 'stubborn',
+ reminder: 'Breathe.',
+ log: [
+ { date: '04/21', text: "Didn't sleep." },
+ { date: '04/22', text: 'Ate a meal.' },
+ { date: '04/23', text: 'Overworked.' },
+ { date: '04/24', text: 'Hydrated.' },
+ { date: '04/25', text: 'Slept 6h.' },
+ { date: '04/26', text: 'Better.' },
+ ],
+}
+
+// TODO(claude): replace this mock check-in payload with EverOS wellness receipts.
diff --git a/src/lib/nova-memory-adapter.ts b/src/lib/nova-memory-adapter.ts
new file mode 100644
index 0000000000..662e33c386
--- /dev/null
+++ b/src/lib/nova-memory-adapter.ts
@@ -0,0 +1,134 @@
+export type NovaMemoryHealth = {
+ id: string
+ label: string
+ status: 'online' | 'degraded' | 'offline'
+ latencyMs: number
+}
+
+export type NovaMemoryActivity = {
+ id: string
+ at: string
+ kind: 'recall' | 'new-memory'
+ scope: 'default' | 'bf-01'
+ query?: string
+ topHit?: string
+ latencyMs?: number
+ factsExtracted?: number
+ summary: string
+}
+
+export type NovaMemoryFragment = {
+ id: string
+ scope: 'default' | 'bf-01'
+ title: string
+ body: string
+ confidence: number
+ source: string
+ updatedAt: string
+}
+
+export const novaMemoryHealth: Array = [
+ { id: 'episodic', label: 'episodic', status: 'online', latencyMs: 44 },
+ { id: 'semantic', label: 'semantic', status: 'online', latencyMs: 38 },
+ { id: 'vector', label: 'vector', status: 'degraded', latencyMs: 118 },
+]
+
+export const novaMemoryActivity: Array = [
+ {
+ id: 'act-001',
+ at: '20:58',
+ kind: 'recall',
+ scope: 'default',
+ query: 'current room state',
+ topHit: 'SoulSync stable after shell reskin',
+ latencyMs: 44,
+ summary: 'query -> top hit -> latency',
+ },
+ {
+ id: 'act-002',
+ at: '20:55',
+ kind: 'new-memory',
+ scope: 'bf-01',
+ factsExtracted: 6,
+ summary: '6 facts extracted from preview review',
+ },
+ {
+ id: 'act-003',
+ at: '20:51',
+ kind: 'recall',
+ scope: 'bf-01',
+ query: 'overthinking tile wording',
+ topHit: 'Daily Check needs roomier labels',
+ latencyMs: 52,
+ summary: 'query -> top hit -> latency',
+ },
+ {
+ id: 'act-004',
+ at: '20:47',
+ kind: 'new-memory',
+ scope: 'default',
+ factsExtracted: 4,
+ summary: '4 facts extracted from Nova design bible',
+ },
+]
+
+export const novaMemoryFragments: Array = [
+ {
+ id: 'frag-001',
+ scope: 'default',
+ title: 'Room tone',
+ body: 'Navy-black surfaces, amber evidence light, monospaced labels, and warm quiet instead of stock workspace chrome.',
+ confidence: 0.98,
+ source: 'NOVA-DESIGN-BIBLE.md',
+ updatedAt: '20:58',
+ },
+ {
+ id: 'frag-002',
+ scope: 'bf-01',
+ title: 'Visual link',
+ body: 'Left rail keeps Nova breathing on idle video. Standby copy appears only if media fails to load.',
+ confidence: 0.96,
+ source: 'preview-review',
+ updatedAt: '20:55',
+ },
+ {
+ id: 'frag-003',
+ scope: 'default',
+ title: 'Canon stack',
+ body: 'Conversation badge reads grok-4.3. Kimi-k2.6 is reserved for fallback or cost context.',
+ confidence: 0.94,
+ source: 'build-prompt',
+ updatedAt: '20:51',
+ },
+]
+
+export function searchNovaMemoryFragments(query: string): Array {
+ const needle = query.trim().toLowerCase()
+ if (!needle) return novaMemoryFragments
+ return novaMemoryFragments.filter((fragment) =>
+ [fragment.title, fragment.body, fragment.source, fragment.scope]
+ .join(' ')
+ .toLowerCase()
+ .includes(needle),
+ )
+}
+
+export function searchNovaMemoryActivity(query: string): Array {
+ const needle = query.trim().toLowerCase()
+ if (!needle) return novaMemoryActivity
+ return novaMemoryActivity.filter((activity) =>
+ [
+ activity.summary,
+ activity.query,
+ activity.topHit,
+ activity.scope,
+ activity.kind,
+ ]
+ .filter(Boolean)
+ .join(' ')
+ .toLowerCase()
+ .includes(needle),
+ )
+}
+
+// TODO(claude): replace this mock adapter with live EverOS recall and write events.
diff --git a/src/lib/tasks-api.ts b/src/lib/tasks-api.ts
index 87eea128a3..0b4e9bdff3 100644
--- a/src/lib/tasks-api.ts
+++ b/src/lib/tasks-api.ts
@@ -223,31 +223,31 @@ export async function moveTask(taskId: string, column: TaskColumn, movedBy = 'us
// --- Display constants ---------------------------------------------------
export const COLUMN_LABELS: Record = {
- backlog: 'Triage',
+ backlog: 'Memory queue',
todo: 'Ready',
- in_progress: 'Running',
- review: 'Review',
+ in_progress: 'Active',
+ review: 'Needs eyes',
blocked: 'Blocked',
- done: 'Done',
+ done: 'Closed',
deleted: 'Deleted',
}
export const COLUMN_ORDER: Array = ['backlog', 'todo', 'in_progress', 'review', 'blocked', 'done']
export const PRIORITY_COLORS: Record = {
- high: '#ef4444',
- medium: '#f97316',
- low: '#6b7280',
+ high: '#ff6b35',
+ medium: '#ffb347',
+ low: '#a8a8a8',
}
export const COLUMN_COLORS: Record = {
- backlog: '#6b7280',
- todo: '#3b82f6',
- in_progress: '#f97316',
- review: '#a855f7',
- blocked: '#ef4444',
- done: '#22c55e',
- deleted: '#374151',
+ backlog: '#a8a8a8',
+ todo: '#ffd27a',
+ in_progress: '#ff8c1a',
+ review: '#ffb347',
+ blocked: '#ff6b35',
+ done: '#cce6a0',
+ deleted: '#2d2f34',
}
export function isOverdue(task: ClaudeTask): boolean {
diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts
index 63135809c9..1eeb114c79 100644
--- a/src/routeTree.gen.ts
+++ b/src/routeTree.gen.ts
@@ -45,6 +45,7 @@ import { Route as ApiTerminalStreamRouteImport } from './routes/api/terminal-str
import { Route as ApiTerminalResizeRouteImport } from './routes/api/terminal-resize'
import { Route as ApiTerminalInputRouteImport } from './routes/api/terminal-input'
import { Route as ApiTerminalCloseRouteImport } from './routes/api/terminal-close'
+import { Route as ApiTaylorApprovalsRouteImport } from './routes/api/taylor-approvals'
import { Route as ApiSystemMetricsRouteImport } from './routes/api/system-metrics'
import { Route as ApiSwarmTmuxStopRouteImport } from './routes/api/swarm-tmux-stop'
import { Route as ApiSwarmTmuxStartRouteImport } from './routes/api/swarm-tmux-start'
@@ -81,6 +82,10 @@ import { Route as ApiPlaygroundNpcRouteImport } from './routes/api/playground-np
import { Route as ApiPlaygroundAdminRouteImport } from './routes/api/playground-admin'
import { Route as ApiPingRouteImport } from './routes/api/ping'
import { Route as ApiPathsRouteImport } from './routes/api/paths'
+import { Route as ApiNovaWorkScanRouteImport } from './routes/api/nova-work-scan'
+import { Route as ApiNovaWantsRouteImport } from './routes/api/nova-wants'
+import { Route as ApiNovaSessionBridgeRouteImport } from './routes/api/nova-session-bridge'
+import { Route as ApiNovaFabricRouteImport } from './routes/api/nova-fabric'
import { Route as ApiModelsRouteImport } from './routes/api/models'
import { Route as ApiMemoryRouteImport } from './routes/api/memory'
import { Route as ApiMediaRouteImport } from './routes/api/media'
@@ -90,6 +95,8 @@ import { Route as ApiIntegrationsRouteImport } from './routes/api/integrations'
import { Route as ApiHistoryRouteImport } from './routes/api/history'
import { Route as ApiHermesTasksRouteImport } from './routes/api/hermes-tasks'
import { Route as ApiHermesConfigRouteImport } from './routes/api/hermes-config'
+import { Route as ApiGovernanceRouteImport } from './routes/api/governance'
+import { Route as ApiGoogleWorkspaceRouteImport } from './routes/api/google-workspace'
import { Route as ApiGatewayStatusRouteImport } from './routes/api/gateway-status'
import { Route as ApiGatewayReprobeRouteImport } from './routes/api/gateway-reprobe'
import { Route as ApiFilesRouteImport } from './routes/api/files'
@@ -111,6 +118,7 @@ import { Route as ApiChatEventsRouteImport } from './routes/api/chat-events'
import { Route as ApiAuthCheckRouteImport } from './routes/api/auth-check'
import { Route as ApiAuthRouteImport } from './routes/api/auth'
import { Route as ApiArtifactsRouteImport } from './routes/api/artifacts'
+import { Route as ApiAgentLanesRouteImport } from './routes/api/agent-lanes'
import { Route as ApiAgentBusRouteImport } from './routes/api/agent-bus'
import { Route as ApiUpdateWorkspaceRouteImport } from './routes/api/update/workspace'
import { Route as ApiUpdateStatusRouteImport } from './routes/api/update/status'
@@ -151,6 +159,7 @@ import { Route as ApiKnowledgeSyncRouteImport } from './routes/api/knowledge/syn
import { Route as ApiKnowledgeSearchRouteImport } from './routes/api/knowledge/search'
import { Route as ApiKnowledgeReadRouteImport } from './routes/api/knowledge/read'
import { Route as ApiKnowledgeListRouteImport } from './routes/api/knowledge/list'
+import { Route as ApiKnowledgeInsightsRouteImport } from './routes/api/knowledge/insights'
import { Route as ApiKnowledgeGraphRouteImport } from './routes/api/knowledge/graph'
import { Route as ApiKnowledgeConfigRouteImport } from './routes/api/knowledge/config'
import { Route as ApiHermesworldReservationsRouteImport } from './routes/api/hermesworld/reservations'
@@ -159,6 +168,7 @@ import { Route as ApiExternalMemorySearchRouteImport } from './routes/api/extern
import { Route as ApiExternalMemoryProvidersRouteImport } from './routes/api/external-memory/providers'
import { Route as ApiExternalMemoryCandidatesRouteImport } from './routes/api/external-memory/candidates'
import { Route as ApiDashboardOverviewRouteImport } from './routes/api/dashboard/overview'
+import { Route as ApiControlLoopsRunRouteImport } from './routes/api/control-loops.run'
import { Route as ApiClaudeTasksTaskIdRouteImport } from './routes/api/claude-tasks.$taskId'
import { Route as ApiClaudeProxySplatRouteImport } from './routes/api/claude-proxy/$'
import { Route as ApiClaudeJobsJobIdRouteImport } from './routes/api/claude-jobs.$jobId'
@@ -350,6 +360,11 @@ const ApiTerminalCloseRoute = ApiTerminalCloseRouteImport.update({
path: '/api/terminal-close',
getParentRoute: () => rootRouteImport,
} as any)
+const ApiTaylorApprovalsRoute = ApiTaylorApprovalsRouteImport.update({
+ id: '/api/taylor-approvals',
+ path: '/api/taylor-approvals',
+ getParentRoute: () => rootRouteImport,
+} as any)
const ApiSystemMetricsRoute = ApiSystemMetricsRouteImport.update({
id: '/api/system-metrics',
path: '/api/system-metrics',
@@ -531,6 +546,26 @@ const ApiPathsRoute = ApiPathsRouteImport.update({
path: '/api/paths',
getParentRoute: () => rootRouteImport,
} as any)
+const ApiNovaWorkScanRoute = ApiNovaWorkScanRouteImport.update({
+ id: '/api/nova-work-scan',
+ path: '/api/nova-work-scan',
+ getParentRoute: () => rootRouteImport,
+} as any)
+const ApiNovaWantsRoute = ApiNovaWantsRouteImport.update({
+ id: '/api/nova-wants',
+ path: '/api/nova-wants',
+ getParentRoute: () => rootRouteImport,
+} as any)
+const ApiNovaSessionBridgeRoute = ApiNovaSessionBridgeRouteImport.update({
+ id: '/api/nova-session-bridge',
+ path: '/api/nova-session-bridge',
+ getParentRoute: () => rootRouteImport,
+} as any)
+const ApiNovaFabricRoute = ApiNovaFabricRouteImport.update({
+ id: '/api/nova-fabric',
+ path: '/api/nova-fabric',
+ getParentRoute: () => rootRouteImport,
+} as any)
const ApiModelsRoute = ApiModelsRouteImport.update({
id: '/api/models',
path: '/api/models',
@@ -576,6 +611,16 @@ const ApiHermesConfigRoute = ApiHermesConfigRouteImport.update({
path: '/api/hermes-config',
getParentRoute: () => rootRouteImport,
} as any)
+const ApiGovernanceRoute = ApiGovernanceRouteImport.update({
+ id: '/api/governance',
+ path: '/api/governance',
+ getParentRoute: () => rootRouteImport,
+} as any)
+const ApiGoogleWorkspaceRoute = ApiGoogleWorkspaceRouteImport.update({
+ id: '/api/google-workspace',
+ path: '/api/google-workspace',
+ getParentRoute: () => rootRouteImport,
+} as any)
const ApiGatewayStatusRoute = ApiGatewayStatusRouteImport.update({
id: '/api/gateway-status',
path: '/api/gateway-status',
@@ -681,6 +726,11 @@ const ApiArtifactsRoute = ApiArtifactsRouteImport.update({
path: '/api/artifacts',
getParentRoute: () => rootRouteImport,
} as any)
+const ApiAgentLanesRoute = ApiAgentLanesRouteImport.update({
+ id: '/api/agent-lanes',
+ path: '/api/agent-lanes',
+ getParentRoute: () => rootRouteImport,
+} as any)
const ApiAgentBusRoute = ApiAgentBusRouteImport.update({
id: '/api/agent-bus',
path: '/api/agent-bus',
@@ -881,6 +931,11 @@ const ApiKnowledgeListRoute = ApiKnowledgeListRouteImport.update({
path: '/api/knowledge/list',
getParentRoute: () => rootRouteImport,
} as any)
+const ApiKnowledgeInsightsRoute = ApiKnowledgeInsightsRouteImport.update({
+ id: '/api/knowledge/insights',
+ path: '/api/knowledge/insights',
+ getParentRoute: () => rootRouteImport,
+} as any)
const ApiKnowledgeGraphRoute = ApiKnowledgeGraphRouteImport.update({
id: '/api/knowledge/graph',
path: '/api/knowledge/graph',
@@ -924,6 +979,11 @@ const ApiDashboardOverviewRoute = ApiDashboardOverviewRouteImport.update({
path: '/api/dashboard/overview',
getParentRoute: () => rootRouteImport,
} as any)
+const ApiControlLoopsRunRoute = ApiControlLoopsRunRouteImport.update({
+ id: '/api/control-loops/run',
+ path: '/api/control-loops/run',
+ getParentRoute: () => rootRouteImport,
+} as any)
const ApiClaudeTasksTaskIdRoute = ApiClaudeTasksTaskIdRouteImport.update({
id: '/$taskId',
path: '/$taskId',
@@ -1005,6 +1065,7 @@ export interface FileRoutesByFullPath {
'/vt-capital': typeof VtCapitalRoute
'/world': typeof WorldRoute
'/api/agent-bus': typeof ApiAgentBusRoute
+ '/api/agent-lanes': typeof ApiAgentLanesRoute
'/api/artifacts': typeof ApiArtifactsRouteWithChildren
'/api/auth': typeof ApiAuthRoute
'/api/auth-check': typeof ApiAuthCheckRoute
@@ -1026,6 +1087,8 @@ export interface FileRoutesByFullPath {
'/api/files': typeof ApiFilesRoute
'/api/gateway-reprobe': typeof ApiGatewayReprobeRoute
'/api/gateway-status': typeof ApiGatewayStatusRoute
+ '/api/google-workspace': typeof ApiGoogleWorkspaceRoute
+ '/api/governance': typeof ApiGovernanceRoute
'/api/hermes-config': typeof ApiHermesConfigRoute
'/api/hermes-tasks': typeof ApiHermesTasksRouteWithChildren
'/api/history': typeof ApiHistoryRoute
@@ -1035,6 +1098,10 @@ export interface FileRoutesByFullPath {
'/api/media': typeof ApiMediaRoute
'/api/memory': typeof ApiMemoryRouteWithChildren
'/api/models': typeof ApiModelsRoute
+ '/api/nova-fabric': typeof ApiNovaFabricRoute
+ '/api/nova-session-bridge': typeof ApiNovaSessionBridgeRoute
+ '/api/nova-wants': typeof ApiNovaWantsRoute
+ '/api/nova-work-scan': typeof ApiNovaWorkScanRoute
'/api/paths': typeof ApiPathsRoute
'/api/ping': typeof ApiPingRoute
'/api/playground-admin': typeof ApiPlaygroundAdminRoute
@@ -1071,6 +1138,7 @@ export interface FileRoutesByFullPath {
'/api/swarm-tmux-start': typeof ApiSwarmTmuxStartRoute
'/api/swarm-tmux-stop': typeof ApiSwarmTmuxStopRoute
'/api/system-metrics': typeof ApiSystemMetricsRoute
+ '/api/taylor-approvals': typeof ApiTaylorApprovalsRoute
'/api/terminal-close': typeof ApiTerminalCloseRoute
'/api/terminal-input': typeof ApiTerminalInputRoute
'/api/terminal-resize': typeof ApiTerminalResizeRoute
@@ -1087,6 +1155,7 @@ export interface FileRoutesByFullPath {
'/api/claude-jobs/$jobId': typeof ApiClaudeJobsJobIdRoute
'/api/claude-proxy/$': typeof ApiClaudeProxySplatRoute
'/api/claude-tasks/$taskId': typeof ApiClaudeTasksTaskIdRoute
+ '/api/control-loops/run': typeof ApiControlLoopsRunRoute
'/api/dashboard/overview': typeof ApiDashboardOverviewRoute
'/api/external-memory/candidates': typeof ApiExternalMemoryCandidatesRoute
'/api/external-memory/providers': typeof ApiExternalMemoryProvidersRoute
@@ -1095,6 +1164,7 @@ export interface FileRoutesByFullPath {
'/api/hermesworld/reservations': typeof ApiHermesworldReservationsRouteWithChildren
'/api/knowledge/config': typeof ApiKnowledgeConfigRoute
'/api/knowledge/graph': typeof ApiKnowledgeGraphRoute
+ '/api/knowledge/insights': typeof ApiKnowledgeInsightsRoute
'/api/knowledge/list': typeof ApiKnowledgeListRoute
'/api/knowledge/read': typeof ApiKnowledgeReadRoute
'/api/knowledge/search': typeof ApiKnowledgeSearchRoute
@@ -1166,6 +1236,7 @@ export interface FileRoutesByTo {
'/vt-capital': typeof VtCapitalRoute
'/world': typeof WorldRoute
'/api/agent-bus': typeof ApiAgentBusRoute
+ '/api/agent-lanes': typeof ApiAgentLanesRoute
'/api/artifacts': typeof ApiArtifactsRouteWithChildren
'/api/auth': typeof ApiAuthRoute
'/api/auth-check': typeof ApiAuthCheckRoute
@@ -1187,6 +1258,8 @@ export interface FileRoutesByTo {
'/api/files': typeof ApiFilesRoute
'/api/gateway-reprobe': typeof ApiGatewayReprobeRoute
'/api/gateway-status': typeof ApiGatewayStatusRoute
+ '/api/google-workspace': typeof ApiGoogleWorkspaceRoute
+ '/api/governance': typeof ApiGovernanceRoute
'/api/hermes-config': typeof ApiHermesConfigRoute
'/api/hermes-tasks': typeof ApiHermesTasksRouteWithChildren
'/api/history': typeof ApiHistoryRoute
@@ -1196,6 +1269,10 @@ export interface FileRoutesByTo {
'/api/media': typeof ApiMediaRoute
'/api/memory': typeof ApiMemoryRouteWithChildren
'/api/models': typeof ApiModelsRoute
+ '/api/nova-fabric': typeof ApiNovaFabricRoute
+ '/api/nova-session-bridge': typeof ApiNovaSessionBridgeRoute
+ '/api/nova-wants': typeof ApiNovaWantsRoute
+ '/api/nova-work-scan': typeof ApiNovaWorkScanRoute
'/api/paths': typeof ApiPathsRoute
'/api/ping': typeof ApiPingRoute
'/api/playground-admin': typeof ApiPlaygroundAdminRoute
@@ -1232,6 +1309,7 @@ export interface FileRoutesByTo {
'/api/swarm-tmux-start': typeof ApiSwarmTmuxStartRoute
'/api/swarm-tmux-stop': typeof ApiSwarmTmuxStopRoute
'/api/system-metrics': typeof ApiSystemMetricsRoute
+ '/api/taylor-approvals': typeof ApiTaylorApprovalsRoute
'/api/terminal-close': typeof ApiTerminalCloseRoute
'/api/terminal-input': typeof ApiTerminalInputRoute
'/api/terminal-resize': typeof ApiTerminalResizeRoute
@@ -1248,6 +1326,7 @@ export interface FileRoutesByTo {
'/api/claude-jobs/$jobId': typeof ApiClaudeJobsJobIdRoute
'/api/claude-proxy/$': typeof ApiClaudeProxySplatRoute
'/api/claude-tasks/$taskId': typeof ApiClaudeTasksTaskIdRoute
+ '/api/control-loops/run': typeof ApiControlLoopsRunRoute
'/api/dashboard/overview': typeof ApiDashboardOverviewRoute
'/api/external-memory/candidates': typeof ApiExternalMemoryCandidatesRoute
'/api/external-memory/providers': typeof ApiExternalMemoryProvidersRoute
@@ -1256,6 +1335,7 @@ export interface FileRoutesByTo {
'/api/hermesworld/reservations': typeof ApiHermesworldReservationsRouteWithChildren
'/api/knowledge/config': typeof ApiKnowledgeConfigRoute
'/api/knowledge/graph': typeof ApiKnowledgeGraphRoute
+ '/api/knowledge/insights': typeof ApiKnowledgeInsightsRoute
'/api/knowledge/list': typeof ApiKnowledgeListRoute
'/api/knowledge/read': typeof ApiKnowledgeReadRoute
'/api/knowledge/search': typeof ApiKnowledgeSearchRoute
@@ -1329,6 +1409,7 @@ export interface FileRoutesById {
'/vt-capital': typeof VtCapitalRoute
'/world': typeof WorldRoute
'/api/agent-bus': typeof ApiAgentBusRoute
+ '/api/agent-lanes': typeof ApiAgentLanesRoute
'/api/artifacts': typeof ApiArtifactsRouteWithChildren
'/api/auth': typeof ApiAuthRoute
'/api/auth-check': typeof ApiAuthCheckRoute
@@ -1350,6 +1431,8 @@ export interface FileRoutesById {
'/api/files': typeof ApiFilesRoute
'/api/gateway-reprobe': typeof ApiGatewayReprobeRoute
'/api/gateway-status': typeof ApiGatewayStatusRoute
+ '/api/google-workspace': typeof ApiGoogleWorkspaceRoute
+ '/api/governance': typeof ApiGovernanceRoute
'/api/hermes-config': typeof ApiHermesConfigRoute
'/api/hermes-tasks': typeof ApiHermesTasksRouteWithChildren
'/api/history': typeof ApiHistoryRoute
@@ -1359,6 +1442,10 @@ export interface FileRoutesById {
'/api/media': typeof ApiMediaRoute
'/api/memory': typeof ApiMemoryRouteWithChildren
'/api/models': typeof ApiModelsRoute
+ '/api/nova-fabric': typeof ApiNovaFabricRoute
+ '/api/nova-session-bridge': typeof ApiNovaSessionBridgeRoute
+ '/api/nova-wants': typeof ApiNovaWantsRoute
+ '/api/nova-work-scan': typeof ApiNovaWorkScanRoute
'/api/paths': typeof ApiPathsRoute
'/api/ping': typeof ApiPingRoute
'/api/playground-admin': typeof ApiPlaygroundAdminRoute
@@ -1395,6 +1482,7 @@ export interface FileRoutesById {
'/api/swarm-tmux-start': typeof ApiSwarmTmuxStartRoute
'/api/swarm-tmux-stop': typeof ApiSwarmTmuxStopRoute
'/api/system-metrics': typeof ApiSystemMetricsRoute
+ '/api/taylor-approvals': typeof ApiTaylorApprovalsRoute
'/api/terminal-close': typeof ApiTerminalCloseRoute
'/api/terminal-input': typeof ApiTerminalInputRoute
'/api/terminal-resize': typeof ApiTerminalResizeRoute
@@ -1411,6 +1499,7 @@ export interface FileRoutesById {
'/api/claude-jobs/$jobId': typeof ApiClaudeJobsJobIdRoute
'/api/claude-proxy/$': typeof ApiClaudeProxySplatRoute
'/api/claude-tasks/$taskId': typeof ApiClaudeTasksTaskIdRoute
+ '/api/control-loops/run': typeof ApiControlLoopsRunRoute
'/api/dashboard/overview': typeof ApiDashboardOverviewRoute
'/api/external-memory/candidates': typeof ApiExternalMemoryCandidatesRoute
'/api/external-memory/providers': typeof ApiExternalMemoryProvidersRoute
@@ -1419,6 +1508,7 @@ export interface FileRoutesById {
'/api/hermesworld/reservations': typeof ApiHermesworldReservationsRouteWithChildren
'/api/knowledge/config': typeof ApiKnowledgeConfigRoute
'/api/knowledge/graph': typeof ApiKnowledgeGraphRoute
+ '/api/knowledge/insights': typeof ApiKnowledgeInsightsRoute
'/api/knowledge/list': typeof ApiKnowledgeListRoute
'/api/knowledge/read': typeof ApiKnowledgeReadRoute
'/api/knowledge/search': typeof ApiKnowledgeSearchRoute
@@ -1493,6 +1583,7 @@ export interface FileRouteTypes {
| '/vt-capital'
| '/world'
| '/api/agent-bus'
+ | '/api/agent-lanes'
| '/api/artifacts'
| '/api/auth'
| '/api/auth-check'
@@ -1514,6 +1605,8 @@ export interface FileRouteTypes {
| '/api/files'
| '/api/gateway-reprobe'
| '/api/gateway-status'
+ | '/api/google-workspace'
+ | '/api/governance'
| '/api/hermes-config'
| '/api/hermes-tasks'
| '/api/history'
@@ -1523,6 +1616,10 @@ export interface FileRouteTypes {
| '/api/media'
| '/api/memory'
| '/api/models'
+ | '/api/nova-fabric'
+ | '/api/nova-session-bridge'
+ | '/api/nova-wants'
+ | '/api/nova-work-scan'
| '/api/paths'
| '/api/ping'
| '/api/playground-admin'
@@ -1559,6 +1656,7 @@ export interface FileRouteTypes {
| '/api/swarm-tmux-start'
| '/api/swarm-tmux-stop'
| '/api/system-metrics'
+ | '/api/taylor-approvals'
| '/api/terminal-close'
| '/api/terminal-input'
| '/api/terminal-resize'
@@ -1575,6 +1673,7 @@ export interface FileRouteTypes {
| '/api/claude-jobs/$jobId'
| '/api/claude-proxy/$'
| '/api/claude-tasks/$taskId'
+ | '/api/control-loops/run'
| '/api/dashboard/overview'
| '/api/external-memory/candidates'
| '/api/external-memory/providers'
@@ -1583,6 +1682,7 @@ export interface FileRouteTypes {
| '/api/hermesworld/reservations'
| '/api/knowledge/config'
| '/api/knowledge/graph'
+ | '/api/knowledge/insights'
| '/api/knowledge/list'
| '/api/knowledge/read'
| '/api/knowledge/search'
@@ -1654,6 +1754,7 @@ export interface FileRouteTypes {
| '/vt-capital'
| '/world'
| '/api/agent-bus'
+ | '/api/agent-lanes'
| '/api/artifacts'
| '/api/auth'
| '/api/auth-check'
@@ -1675,6 +1776,8 @@ export interface FileRouteTypes {
| '/api/files'
| '/api/gateway-reprobe'
| '/api/gateway-status'
+ | '/api/google-workspace'
+ | '/api/governance'
| '/api/hermes-config'
| '/api/hermes-tasks'
| '/api/history'
@@ -1684,6 +1787,10 @@ export interface FileRouteTypes {
| '/api/media'
| '/api/memory'
| '/api/models'
+ | '/api/nova-fabric'
+ | '/api/nova-session-bridge'
+ | '/api/nova-wants'
+ | '/api/nova-work-scan'
| '/api/paths'
| '/api/ping'
| '/api/playground-admin'
@@ -1720,6 +1827,7 @@ export interface FileRouteTypes {
| '/api/swarm-tmux-start'
| '/api/swarm-tmux-stop'
| '/api/system-metrics'
+ | '/api/taylor-approvals'
| '/api/terminal-close'
| '/api/terminal-input'
| '/api/terminal-resize'
@@ -1736,6 +1844,7 @@ export interface FileRouteTypes {
| '/api/claude-jobs/$jobId'
| '/api/claude-proxy/$'
| '/api/claude-tasks/$taskId'
+ | '/api/control-loops/run'
| '/api/dashboard/overview'
| '/api/external-memory/candidates'
| '/api/external-memory/providers'
@@ -1744,6 +1853,7 @@ export interface FileRouteTypes {
| '/api/hermesworld/reservations'
| '/api/knowledge/config'
| '/api/knowledge/graph'
+ | '/api/knowledge/insights'
| '/api/knowledge/list'
| '/api/knowledge/read'
| '/api/knowledge/search'
@@ -1816,6 +1926,7 @@ export interface FileRouteTypes {
| '/vt-capital'
| '/world'
| '/api/agent-bus'
+ | '/api/agent-lanes'
| '/api/artifacts'
| '/api/auth'
| '/api/auth-check'
@@ -1837,6 +1948,8 @@ export interface FileRouteTypes {
| '/api/files'
| '/api/gateway-reprobe'
| '/api/gateway-status'
+ | '/api/google-workspace'
+ | '/api/governance'
| '/api/hermes-config'
| '/api/hermes-tasks'
| '/api/history'
@@ -1846,6 +1959,10 @@ export interface FileRouteTypes {
| '/api/media'
| '/api/memory'
| '/api/models'
+ | '/api/nova-fabric'
+ | '/api/nova-session-bridge'
+ | '/api/nova-wants'
+ | '/api/nova-work-scan'
| '/api/paths'
| '/api/ping'
| '/api/playground-admin'
@@ -1882,6 +1999,7 @@ export interface FileRouteTypes {
| '/api/swarm-tmux-start'
| '/api/swarm-tmux-stop'
| '/api/system-metrics'
+ | '/api/taylor-approvals'
| '/api/terminal-close'
| '/api/terminal-input'
| '/api/terminal-resize'
@@ -1898,6 +2016,7 @@ export interface FileRouteTypes {
| '/api/claude-jobs/$jobId'
| '/api/claude-proxy/$'
| '/api/claude-tasks/$taskId'
+ | '/api/control-loops/run'
| '/api/dashboard/overview'
| '/api/external-memory/candidates'
| '/api/external-memory/providers'
@@ -1906,6 +2025,7 @@ export interface FileRouteTypes {
| '/api/hermesworld/reservations'
| '/api/knowledge/config'
| '/api/knowledge/graph'
+ | '/api/knowledge/insights'
| '/api/knowledge/list'
| '/api/knowledge/read'
| '/api/knowledge/search'
@@ -1979,6 +2099,7 @@ export interface RootRouteChildren {
VtCapitalRoute: typeof VtCapitalRoute
WorldRoute: typeof WorldRoute
ApiAgentBusRoute: typeof ApiAgentBusRoute
+ ApiAgentLanesRoute: typeof ApiAgentLanesRoute
ApiArtifactsRoute: typeof ApiArtifactsRouteWithChildren
ApiAuthRoute: typeof ApiAuthRoute
ApiAuthCheckRoute: typeof ApiAuthCheckRoute
@@ -2000,6 +2121,8 @@ export interface RootRouteChildren {
ApiFilesRoute: typeof ApiFilesRoute
ApiGatewayReprobeRoute: typeof ApiGatewayReprobeRoute
ApiGatewayStatusRoute: typeof ApiGatewayStatusRoute
+ ApiGoogleWorkspaceRoute: typeof ApiGoogleWorkspaceRoute
+ ApiGovernanceRoute: typeof ApiGovernanceRoute
ApiHermesConfigRoute: typeof ApiHermesConfigRoute
ApiHermesTasksRoute: typeof ApiHermesTasksRouteWithChildren
ApiHistoryRoute: typeof ApiHistoryRoute
@@ -2009,6 +2132,10 @@ export interface RootRouteChildren {
ApiMediaRoute: typeof ApiMediaRoute
ApiMemoryRoute: typeof ApiMemoryRouteWithChildren
ApiModelsRoute: typeof ApiModelsRoute
+ ApiNovaFabricRoute: typeof ApiNovaFabricRoute
+ ApiNovaSessionBridgeRoute: typeof ApiNovaSessionBridgeRoute
+ ApiNovaWantsRoute: typeof ApiNovaWantsRoute
+ ApiNovaWorkScanRoute: typeof ApiNovaWorkScanRoute
ApiPathsRoute: typeof ApiPathsRoute
ApiPingRoute: typeof ApiPingRoute
ApiPlaygroundAdminRoute: typeof ApiPlaygroundAdminRoute
@@ -2045,6 +2172,7 @@ export interface RootRouteChildren {
ApiSwarmTmuxStartRoute: typeof ApiSwarmTmuxStartRoute
ApiSwarmTmuxStopRoute: typeof ApiSwarmTmuxStopRoute
ApiSystemMetricsRoute: typeof ApiSystemMetricsRoute
+ ApiTaylorApprovalsRoute: typeof ApiTaylorApprovalsRoute
ApiTerminalCloseRoute: typeof ApiTerminalCloseRoute
ApiTerminalInputRoute: typeof ApiTerminalInputRoute
ApiTerminalResizeRoute: typeof ApiTerminalResizeRoute
@@ -2055,6 +2183,7 @@ export interface RootRouteChildren {
ChatSessionKeyRoute: typeof ChatSessionKeyRoute
ChatIndexRoute: typeof ChatIndexRoute
ApiClaudeProxySplatRoute: typeof ApiClaudeProxySplatRoute
+ ApiControlLoopsRunRoute: typeof ApiControlLoopsRunRoute
ApiDashboardOverviewRoute: typeof ApiDashboardOverviewRoute
ApiExternalMemoryCandidatesRoute: typeof ApiExternalMemoryCandidatesRoute
ApiExternalMemoryProvidersRoute: typeof ApiExternalMemoryProvidersRoute
@@ -2062,6 +2191,7 @@ export interface RootRouteChildren {
ApiHermesworldReservationsRoute: typeof ApiHermesworldReservationsRouteWithChildren
ApiKnowledgeConfigRoute: typeof ApiKnowledgeConfigRoute
ApiKnowledgeGraphRoute: typeof ApiKnowledgeGraphRoute
+ ApiKnowledgeInsightsRoute: typeof ApiKnowledgeInsightsRoute
ApiKnowledgeListRoute: typeof ApiKnowledgeListRoute
ApiKnowledgeReadRoute: typeof ApiKnowledgeReadRoute
ApiKnowledgeSearchRoute: typeof ApiKnowledgeSearchRoute
@@ -2339,6 +2469,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiTerminalCloseRouteImport
parentRoute: typeof rootRouteImport
}
+ '/api/taylor-approvals': {
+ id: '/api/taylor-approvals'
+ path: '/api/taylor-approvals'
+ fullPath: '/api/taylor-approvals'
+ preLoaderRoute: typeof ApiTaylorApprovalsRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/api/system-metrics': {
id: '/api/system-metrics'
path: '/api/system-metrics'
@@ -2591,6 +2728,34 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiPathsRouteImport
parentRoute: typeof rootRouteImport
}
+ '/api/nova-work-scan': {
+ id: '/api/nova-work-scan'
+ path: '/api/nova-work-scan'
+ fullPath: '/api/nova-work-scan'
+ preLoaderRoute: typeof ApiNovaWorkScanRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/api/nova-wants': {
+ id: '/api/nova-wants'
+ path: '/api/nova-wants'
+ fullPath: '/api/nova-wants'
+ preLoaderRoute: typeof ApiNovaWantsRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/api/nova-session-bridge': {
+ id: '/api/nova-session-bridge'
+ path: '/api/nova-session-bridge'
+ fullPath: '/api/nova-session-bridge'
+ preLoaderRoute: typeof ApiNovaSessionBridgeRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/api/nova-fabric': {
+ id: '/api/nova-fabric'
+ path: '/api/nova-fabric'
+ fullPath: '/api/nova-fabric'
+ preLoaderRoute: typeof ApiNovaFabricRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/api/models': {
id: '/api/models'
path: '/api/models'
@@ -2654,6 +2819,20 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiHermesConfigRouteImport
parentRoute: typeof rootRouteImport
}
+ '/api/governance': {
+ id: '/api/governance'
+ path: '/api/governance'
+ fullPath: '/api/governance'
+ preLoaderRoute: typeof ApiGovernanceRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/api/google-workspace': {
+ id: '/api/google-workspace'
+ path: '/api/google-workspace'
+ fullPath: '/api/google-workspace'
+ preLoaderRoute: typeof ApiGoogleWorkspaceRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/api/gateway-status': {
id: '/api/gateway-status'
path: '/api/gateway-status'
@@ -2801,6 +2980,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiArtifactsRouteImport
parentRoute: typeof rootRouteImport
}
+ '/api/agent-lanes': {
+ id: '/api/agent-lanes'
+ path: '/api/agent-lanes'
+ fullPath: '/api/agent-lanes'
+ preLoaderRoute: typeof ApiAgentLanesRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/api/agent-bus': {
id: '/api/agent-bus'
path: '/api/agent-bus'
@@ -3081,6 +3267,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiKnowledgeListRouteImport
parentRoute: typeof rootRouteImport
}
+ '/api/knowledge/insights': {
+ id: '/api/knowledge/insights'
+ path: '/api/knowledge/insights'
+ fullPath: '/api/knowledge/insights'
+ preLoaderRoute: typeof ApiKnowledgeInsightsRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/api/knowledge/graph': {
id: '/api/knowledge/graph'
path: '/api/knowledge/graph'
@@ -3137,6 +3330,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiDashboardOverviewRouteImport
parentRoute: typeof rootRouteImport
}
+ '/api/control-loops/run': {
+ id: '/api/control-loops/run'
+ path: '/api/control-loops/run'
+ fullPath: '/api/control-loops/run'
+ preLoaderRoute: typeof ApiControlLoopsRunRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/api/claude-tasks/$taskId': {
id: '/api/claude-tasks/$taskId'
path: '/$taskId'
@@ -3448,6 +3648,7 @@ const rootRouteChildren: RootRouteChildren = {
VtCapitalRoute: VtCapitalRoute,
WorldRoute: WorldRoute,
ApiAgentBusRoute: ApiAgentBusRoute,
+ ApiAgentLanesRoute: ApiAgentLanesRoute,
ApiArtifactsRoute: ApiArtifactsRouteWithChildren,
ApiAuthRoute: ApiAuthRoute,
ApiAuthCheckRoute: ApiAuthCheckRoute,
@@ -3469,6 +3670,8 @@ const rootRouteChildren: RootRouteChildren = {
ApiFilesRoute: ApiFilesRoute,
ApiGatewayReprobeRoute: ApiGatewayReprobeRoute,
ApiGatewayStatusRoute: ApiGatewayStatusRoute,
+ ApiGoogleWorkspaceRoute: ApiGoogleWorkspaceRoute,
+ ApiGovernanceRoute: ApiGovernanceRoute,
ApiHermesConfigRoute: ApiHermesConfigRoute,
ApiHermesTasksRoute: ApiHermesTasksRouteWithChildren,
ApiHistoryRoute: ApiHistoryRoute,
@@ -3478,6 +3681,10 @@ const rootRouteChildren: RootRouteChildren = {
ApiMediaRoute: ApiMediaRoute,
ApiMemoryRoute: ApiMemoryRouteWithChildren,
ApiModelsRoute: ApiModelsRoute,
+ ApiNovaFabricRoute: ApiNovaFabricRoute,
+ ApiNovaSessionBridgeRoute: ApiNovaSessionBridgeRoute,
+ ApiNovaWantsRoute: ApiNovaWantsRoute,
+ ApiNovaWorkScanRoute: ApiNovaWorkScanRoute,
ApiPathsRoute: ApiPathsRoute,
ApiPingRoute: ApiPingRoute,
ApiPlaygroundAdminRoute: ApiPlaygroundAdminRoute,
@@ -3514,6 +3721,7 @@ const rootRouteChildren: RootRouteChildren = {
ApiSwarmTmuxStartRoute: ApiSwarmTmuxStartRoute,
ApiSwarmTmuxStopRoute: ApiSwarmTmuxStopRoute,
ApiSystemMetricsRoute: ApiSystemMetricsRoute,
+ ApiTaylorApprovalsRoute: ApiTaylorApprovalsRoute,
ApiTerminalCloseRoute: ApiTerminalCloseRoute,
ApiTerminalInputRoute: ApiTerminalInputRoute,
ApiTerminalResizeRoute: ApiTerminalResizeRoute,
@@ -3524,6 +3732,7 @@ const rootRouteChildren: RootRouteChildren = {
ChatSessionKeyRoute: ChatSessionKeyRoute,
ChatIndexRoute: ChatIndexRoute,
ApiClaudeProxySplatRoute: ApiClaudeProxySplatRoute,
+ ApiControlLoopsRunRoute: ApiControlLoopsRunRoute,
ApiDashboardOverviewRoute: ApiDashboardOverviewRoute,
ApiExternalMemoryCandidatesRoute: ApiExternalMemoryCandidatesRoute,
ApiExternalMemoryProvidersRoute: ApiExternalMemoryProvidersRoute,
@@ -3531,6 +3740,7 @@ const rootRouteChildren: RootRouteChildren = {
ApiHermesworldReservationsRoute: ApiHermesworldReservationsRouteWithChildren,
ApiKnowledgeConfigRoute: ApiKnowledgeConfigRoute,
ApiKnowledgeGraphRoute: ApiKnowledgeGraphRoute,
+ ApiKnowledgeInsightsRoute: ApiKnowledgeInsightsRoute,
ApiKnowledgeListRoute: ApiKnowledgeListRoute,
ApiKnowledgeReadRoute: ApiKnowledgeReadRoute,
ApiKnowledgeSearchRoute: ApiKnowledgeSearchRoute,
diff --git a/src/routes/api/agent-lanes.ts b/src/routes/api/agent-lanes.ts
new file mode 100644
index 0000000000..ab69943fb9
--- /dev/null
+++ b/src/routes/api/agent-lanes.ts
@@ -0,0 +1,18 @@
+import { createFileRoute } from '@tanstack/react-router'
+import { json } from '@tanstack/react-start'
+import { gatherAgentLaneRegistry } from '../../server/agent-lane-registry'
+import { isAuthenticated } from '../../server/auth-middleware'
+
+export const Route = createFileRoute('/api/agent-lanes')({
+ server: {
+ handlers: {
+ GET: async ({ request }) => {
+ if (!isAuthenticated(request)) {
+ return json({ error: 'Unauthorized' }, { status: 401 })
+ }
+ const registry = await gatherAgentLaneRegistry()
+ return json(registry)
+ },
+ },
+ },
+})
diff --git a/src/routes/api/control-loops.run.ts b/src/routes/api/control-loops.run.ts
new file mode 100644
index 0000000000..2b8ff0d786
--- /dev/null
+++ b/src/routes/api/control-loops.run.ts
@@ -0,0 +1,34 @@
+import { createFileRoute } from '@tanstack/react-router'
+import { json } from '@tanstack/react-start'
+import { z } from 'zod'
+import { isAuthenticated } from '../../server/auth-middleware'
+import {
+ RUNNABLE_CONTROL_LOOPS,
+ runControlLoopDry,
+} from '../../server/control-loop-runner'
+
+const runSchema = z.object({
+ loopId: z.enum(RUNNABLE_CONTROL_LOOPS),
+})
+
+export const Route = createFileRoute('/api/control-loops/run')({
+ server: {
+ handlers: {
+ POST: async ({ request }) => {
+ if (!isAuthenticated(request)) {
+ return json({ error: 'Unauthorized' }, { status: 401 })
+ }
+ const body: unknown = await request.json().catch(() => null)
+ const parsed = runSchema.safeParse(body)
+ if (!parsed.success) {
+ return json(
+ { error: 'Invalid loopId', issues: parsed.error.issues },
+ { status: 400 },
+ )
+ }
+ const { result, receiptId } = await runControlLoopDry(parsed.data.loopId)
+ return json({ ok: true, result, receiptId })
+ },
+ },
+ },
+})
diff --git a/src/routes/api/google-workspace.ts b/src/routes/api/google-workspace.ts
new file mode 100644
index 0000000000..cbf502ca04
--- /dev/null
+++ b/src/routes/api/google-workspace.ts
@@ -0,0 +1,18 @@
+import { createFileRoute } from '@tanstack/react-router'
+import { json } from '@tanstack/react-start'
+import { isAuthenticated } from '../../server/auth-middleware'
+import { gatherGoogleWorkspaceReport } from '../../server/google-workspace-connector'
+
+export const Route = createFileRoute('/api/google-workspace')({
+ server: {
+ handlers: {
+ GET: async ({ request }) => {
+ if (!isAuthenticated(request)) {
+ return json({ error: 'Unauthorized' }, { status: 401 })
+ }
+ const report = await gatherGoogleWorkspaceReport()
+ return json(report)
+ },
+ },
+ },
+})
diff --git a/src/routes/api/governance.ts b/src/routes/api/governance.ts
new file mode 100644
index 0000000000..fae3ee026e
--- /dev/null
+++ b/src/routes/api/governance.ts
@@ -0,0 +1,26 @@
+import { createFileRoute } from '@tanstack/react-router'
+import { json } from '@tanstack/react-start'
+import {
+ GOVERNED_ACTION_CATEGORIES,
+ getGovernancePolicy,
+} from '../../server/approval-governance'
+import { isAuthenticated } from '../../server/auth-middleware'
+
+export const Route = createFileRoute('/api/governance')({
+ server: {
+ handlers: {
+ GET: ({ request }) => {
+ if (!isAuthenticated(request)) {
+ return json({ error: 'Unauthorized' }, { status: 401 })
+ }
+ return json({
+ ok: true,
+ categories: GOVERNED_ACTION_CATEGORIES,
+ policy: getGovernancePolicy(),
+ enforcement:
+ 'Approval is proven only by a server-side fabric review in status approved; browser booleans are never trusted.',
+ })
+ },
+ },
+ },
+})
diff --git a/src/routes/api/knowledge/insights.ts b/src/routes/api/knowledge/insights.ts
new file mode 100644
index 0000000000..47420c3778
--- /dev/null
+++ b/src/routes/api/knowledge/insights.ts
@@ -0,0 +1,72 @@
+import { createFileRoute } from '@tanstack/react-router'
+import { json } from '@tanstack/react-start'
+import { z } from 'zod'
+import { isAuthenticated } from '../../../server/auth-middleware'
+import { buildKnowledgeGraph } from '../../../server/knowledge-browser'
+import { buildVaultGraphInsights } from '../../../server/vault-graph-insights'
+import { createNovaFabricReviewProposal } from '../../../server/nova-fabric-store'
+
+const ProposeSchema = z.object({
+ kind: z.enum([
+ 'link-or-archive-orphan',
+ 'refresh-stale-hub',
+ 'merge-duplicates',
+ ]),
+ title: z.string().min(1).max(200),
+ reason: z.string().min(1).max(500),
+ notePaths: z.array(z.string().min(1)).min(1).max(20),
+})
+
+function errorResponse(message: string, status = 400): Response {
+ return json({ ok: false, error: message }, { status })
+}
+
+export const Route = createFileRoute('/api/knowledge/insights')({
+ server: {
+ handlers: {
+ GET: async ({ request }) => {
+ if (!isAuthenticated(request)) return errorResponse('Unauthorized', 401)
+ try {
+ const graph = await buildKnowledgeGraph()
+ return json({ ok: true, insights: buildVaultGraphInsights(graph) })
+ } catch (error) {
+ return errorResponse(
+ error instanceof Error ? error.message : 'Vault graph unavailable',
+ 503,
+ )
+ }
+ },
+ POST: async ({ request }) => {
+ if (!isAuthenticated(request)) return errorResponse('Unauthorized', 401)
+ let body: unknown
+ try {
+ body = await request.json()
+ } catch {
+ return errorResponse('Invalid JSON')
+ }
+ const parsed = ProposeSchema.safeParse(body)
+ if (!parsed.success) {
+ return errorResponse(
+ parsed.error.issues.map((issue) => issue.message).join('; '),
+ )
+ }
+ const review = createNovaFabricReviewProposal({
+ title: `Vault cleanup: ${parsed.data.title}`,
+ reason: parsed.data.reason,
+ targetType: 'source-map',
+ proposedDiff: {
+ cleanupKind: parsed.data.kind,
+ notePaths: parsed.data.notePaths,
+ },
+ sourceLinks: parsed.data.notePaths.slice(0, 5).map((notePath) => ({
+ label: 'Vault note',
+ value: notePath,
+ kind: 'file' as const,
+ })),
+ provenance: 'Memory galaxy insights recommendation',
+ })
+ return json({ ok: true, review })
+ },
+ },
+ },
+})
diff --git a/src/routes/api/nova-fabric.ts b/src/routes/api/nova-fabric.ts
new file mode 100644
index 0000000000..e5e0a1dac7
--- /dev/null
+++ b/src/routes/api/nova-fabric.ts
@@ -0,0 +1,168 @@
+import { createFileRoute } from '@tanstack/react-router'
+import { json } from '@tanstack/react-start'
+import { z } from 'zod'
+import { isAuthenticated } from '../../server/auth-middleware'
+import {
+ NOVA_FABRIC_APPROVAL_LEVELS,
+ NOVA_FABRIC_REVIEW_STATUSES,
+ NOVA_FABRIC_RISK_LEVELS,
+ NOVA_FABRIC_VERIFICATION_STATES,
+ appendNovaFabricEvent,
+ createNovaFabricReviewProposal,
+ getNovaFabricSnapshot,
+ proposeNovaSelfStateChange,
+ updateNovaFabricReviewStatus,
+} from '../../server/nova-fabric-store'
+
+const LinkSchema = z.object({
+ label: z.string().trim().min(1).max(180),
+ value: z.string().trim().min(1).max(1200),
+ kind: z.enum(['file', 'url', 'session', 'api', 'note', 'receipt']),
+})
+
+const EventSchema = z.object({
+ action: z.literal('append-event'),
+ title: z.string().trim().min(1).max(220),
+ summary: z.string().trim().min(1).max(4000),
+ eventKind: z
+ .enum(['continuity', 'work-receipt', 'correction', 'approval', 'boundary'])
+ .optional(),
+ provenance: z.string().trim().max(1200).optional(),
+ riskLevel: z.enum(NOVA_FABRIC_RISK_LEVELS).optional(),
+ approvalLevel: z.enum(NOVA_FABRIC_APPROVAL_LEVELS).optional(),
+ verificationState: z.enum(NOVA_FABRIC_VERIFICATION_STATES).optional(),
+ sourceLinks: z.array(LinkSchema).optional(),
+ receiptLinks: z.array(LinkSchema).optional(),
+})
+
+const ReviewSchema = z.object({
+ action: z.literal('create-review'),
+ title: z.string().trim().min(1).max(220),
+ reason: z.string().trim().min(1).max(4000),
+ targetType: z.enum([
+ 'event',
+ 'self-state',
+ 'source-map',
+ 'consolidation-review',
+ 'external-system',
+ 'nova-wants',
+ ]),
+ targetId: z.string().trim().max(240).nullable().optional(),
+ proposedDiff: z.record(z.unknown()).default({}),
+ provenance: z.string().trim().max(1200).optional(),
+ riskLevel: z.enum(NOVA_FABRIC_RISK_LEVELS).optional(),
+ approvalLevel: z.enum(NOVA_FABRIC_APPROVAL_LEVELS).optional(),
+ verificationState: z.enum(NOVA_FABRIC_VERIFICATION_STATES).optional(),
+ sourceLinks: z.array(LinkSchema).optional(),
+ receiptLinks: z.array(LinkSchema).optional(),
+})
+
+const ProposeSelfStateSchema = z.object({
+ action: z.literal('propose-self-state'),
+ targetId: z.string().trim().min(1).max(240),
+ proposedDiff: z.record(z.unknown()),
+ reason: z.string().trim().min(1).max(4000),
+})
+
+const ReviewStatusSchema = z.object({
+ action: z.literal('update-review-status'),
+ id: z.string().trim().min(1).max(240),
+ status: z.enum(NOVA_FABRIC_REVIEW_STATUSES),
+})
+
+const PostSchema = z.discriminatedUnion('action', [EventSchema, ReviewSchema])
+const PatchSchema = z.discriminatedUnion('action', [
+ ProposeSelfStateSchema,
+ ReviewStatusSchema,
+])
+
+function errorResponse(error: string, status = 400) {
+ return json({ ok: false, error }, { status })
+}
+
+export const Route = createFileRoute('/api/nova-fabric')({
+ server: {
+ handlers: {
+ GET: ({ request }) => {
+ if (!isAuthenticated(request)) {
+ return errorResponse('Unauthorized', 401)
+ }
+ const snapshot = getNovaFabricSnapshot()
+ return json({
+ ok: snapshot.ok,
+ fabric: snapshot.fabric,
+ storage: snapshot.storage,
+ degraded: snapshot.degraded,
+ warning: snapshot.warning,
+ })
+ },
+ POST: async ({ request }) => {
+ if (!isAuthenticated(request)) {
+ return errorResponse('Unauthorized', 401)
+ }
+ let body: unknown
+ try {
+ body = await request.json()
+ } catch {
+ return errorResponse('Invalid JSON')
+ }
+ const parsed = PostSchema.safeParse(body)
+ if (!parsed.success) {
+ return errorResponse(
+ parsed.error.issues.map((issue) => issue.message).join('; '),
+ )
+ }
+ try {
+ const record =
+ parsed.data.action === 'append-event'
+ ? appendNovaFabricEvent(parsed.data)
+ : createNovaFabricReviewProposal(parsed.data)
+ const snapshot = getNovaFabricSnapshot()
+ return json({ ok: true, record, fabric: snapshot.fabric })
+ } catch (error) {
+ return errorResponse(
+ error instanceof Error ? error.message : 'Nova Fabric write failed',
+ 409,
+ )
+ }
+ },
+ PATCH: async ({ request }) => {
+ if (!isAuthenticated(request)) {
+ return errorResponse('Unauthorized', 401)
+ }
+ let body: unknown
+ try {
+ body = await request.json()
+ } catch {
+ return errorResponse('Invalid JSON')
+ }
+ const parsed = PatchSchema.safeParse(body)
+ if (!parsed.success) {
+ return errorResponse(
+ parsed.error.issues.map((issue) => issue.message).join('; '),
+ )
+ }
+ try {
+ const record =
+ parsed.data.action === 'propose-self-state'
+ ? proposeNovaSelfStateChange(
+ parsed.data.targetId,
+ parsed.data.proposedDiff,
+ parsed.data.reason,
+ )
+ : updateNovaFabricReviewStatus(parsed.data.id, parsed.data.status)
+ if (!record) return errorResponse('Record not found', 404)
+ const snapshot = getNovaFabricSnapshot()
+ return json({ ok: true, record, fabric: snapshot.fabric })
+ } catch (error) {
+ return errorResponse(
+ error instanceof Error
+ ? error.message
+ : 'Nova Fabric update failed',
+ 409,
+ )
+ }
+ },
+ },
+ },
+})
diff --git a/src/routes/api/nova-session-bridge.ts b/src/routes/api/nova-session-bridge.ts
new file mode 100644
index 0000000000..4b4de02287
--- /dev/null
+++ b/src/routes/api/nova-session-bridge.ts
@@ -0,0 +1,18 @@
+import { createFileRoute } from '@tanstack/react-router'
+import { json } from '@tanstack/react-start'
+import { isAuthenticated } from '../../server/auth-middleware'
+import { gatherNovaSessionBridge } from '../../server/nova-session-bridge'
+
+export const Route = createFileRoute('/api/nova-session-bridge')({
+ server: {
+ handlers: {
+ GET: async ({ request }) => {
+ if (!isAuthenticated(request)) {
+ return json({ error: 'Unauthorized' }, { status: 401 })
+ }
+ const bridge = await gatherNovaSessionBridge()
+ return json(bridge)
+ },
+ },
+ },
+})
diff --git a/src/routes/api/nova-wants.ts b/src/routes/api/nova-wants.ts
new file mode 100644
index 0000000000..e907ee52ee
--- /dev/null
+++ b/src/routes/api/nova-wants.ts
@@ -0,0 +1,222 @@
+import { createFileRoute } from '@tanstack/react-router'
+import { json } from '@tanstack/react-start'
+import { z } from 'zod'
+import { isAuthenticated } from '../../server/auth-middleware'
+import {
+ NOVA_WANTS_APPROVAL_LEVELS,
+ NOVA_WANTS_CATEGORIES,
+ NOVA_WANTS_COLUMNS,
+ createNovaWantCard,
+ getNovaWantsBoard,
+ getNovaWantsSnapshot,
+ moveNovaWantCard,
+ parkNovaWantCard,
+ requiresNovaWantReviewProposal,
+ updateNovaWantCard,
+} from '../../server/nova-wants-store'
+import { createNovaFabricReviewProposal } from '../../server/nova-fabric-store'
+import type { NovaWantCard, NovaWantsColumn } from '../../server/nova-wants-store'
+
+const CardSchema = z.object({
+ title: z.string().trim().min(1).max(220),
+ description: z.string().trim().max(4000).optional(),
+ category: z.enum(NOVA_WANTS_CATEGORIES).optional(),
+ approvalLevel: z.enum(NOVA_WANTS_APPROVAL_LEVELS).optional(),
+ source: z.string().trim().max(1000).optional(),
+ provenance: z.string().trim().max(2000).optional(),
+ status: z.enum(NOVA_WANTS_COLUMNS).optional(),
+ position: z.number().finite().optional(),
+ linkedReceipt: z.string().trim().max(1000).nullable().optional(),
+ linkedNote: z.string().trim().max(1000).nullable().optional(),
+ fabricEventIds: z.array(z.string().trim().min(1).max(240)).optional(),
+ fabricSelfStateIds: z.array(z.string().trim().min(1).max(240)).optional(),
+ fabricSourceMapIds: z.array(z.string().trim().min(1).max(240)).optional(),
+ fabricReviewIds: z.array(z.string().trim().min(1).max(240)).optional(),
+ whyThisMatters: z.string().trim().max(4000).optional(),
+})
+
+const UpdateSchema = CardSchema.partial().extend({
+ id: z.string().trim().min(1),
+})
+
+const MoveSchema = z.object({
+ id: z.string().trim().min(1),
+ status: z.enum(NOVA_WANTS_COLUMNS),
+ position: z.number().finite().optional(),
+})
+
+function errorResponse(error: string, status = 400) {
+ return json({ ok: false, error }, { status })
+}
+
+function findNovaWantCard(id: string): NovaWantCard | null {
+ return getNovaWantsBoard().cards.find((card) => card.id === id) ?? null
+}
+
+function createMoveReviewProposal(
+ card: NovaWantCard,
+ status: NovaWantsColumn,
+ position?: number,
+) {
+ return createNovaFabricReviewProposal({
+ title: `Review Nova Want move: ${card.title}`,
+ reason:
+ 'A risky Nova Wants move was requested and must be reviewed before it can advance.',
+ targetType: 'nova-wants',
+ targetId: card.id,
+ proposedDiff: {
+ status,
+ position: position ?? card.position,
+ },
+ riskLevel: 'high',
+ approvalLevel: 'explicit-approval',
+ verificationState: 'draft',
+ sourceLinks: [
+ {
+ label: 'Nova Wants card',
+ value: card.id,
+ kind: 'receipt',
+ },
+ ],
+ })
+}
+
+function maybeCreateMoveReviewProposal(
+ card: NovaWantCard,
+ status: NovaWantsColumn | undefined,
+ position?: number,
+) {
+ if (!status || !requiresNovaWantReviewProposal(card, status)) return null
+ return createMoveReviewProposal(card, status, position)
+}
+
+export const Route = createFileRoute('/api/nova-wants')({
+ server: {
+ handlers: {
+ GET: ({ request }) => {
+ if (!isAuthenticated(request)) {
+ return errorResponse('Unauthorized', 401)
+ }
+ const snapshot = getNovaWantsSnapshot()
+ return json({
+ ok: snapshot.ok,
+ board: snapshot.board,
+ storage: snapshot.storage,
+ degraded: snapshot.degraded,
+ warning: snapshot.warning,
+ })
+ },
+ POST: async ({ request }) => {
+ if (!isAuthenticated(request)) {
+ return errorResponse('Unauthorized', 401)
+ }
+ let body: unknown
+ try {
+ body = await request.json()
+ } catch {
+ return errorResponse('Invalid JSON')
+ }
+ const parsed = CardSchema.safeParse(body)
+ if (!parsed.success) {
+ return errorResponse(
+ parsed.error.issues.map((issue) => issue.message).join('; '),
+ )
+ }
+ try {
+ const card = createNovaWantCard(parsed.data)
+ return json({ ok: true, card, board: getNovaWantsBoard() })
+ } catch (error) {
+ return errorResponse(
+ error instanceof Error ? error.message : 'Nova Wants write failed',
+ 409,
+ )
+ }
+ },
+ PATCH: async ({ request }) => {
+ if (!isAuthenticated(request)) {
+ return errorResponse('Unauthorized', 401)
+ }
+ let body: unknown
+ try {
+ body = await request.json()
+ } catch {
+ return errorResponse('Invalid JSON')
+ }
+ const moveParsed = MoveSchema.safeParse(body)
+ if (moveParsed.success && Object.keys(body as object).length <= 4) {
+ try {
+ const before = findNovaWantCard(moveParsed.data.id)
+ if (!before) return errorResponse('Card not found', 404)
+ const review = maybeCreateMoveReviewProposal(
+ before,
+ moveParsed.data.status,
+ moveParsed.data.position,
+ )
+ const card = moveNovaWantCard(
+ moveParsed.data.id,
+ moveParsed.data.status,
+ moveParsed.data.position,
+ )
+ if (!card) return errorResponse('Card not found', 404)
+ return json({ ok: true, card, review, board: getNovaWantsBoard() })
+ } catch (error) {
+ return errorResponse(
+ error instanceof Error ? error.message : 'Nova Wants update failed',
+ 409,
+ )
+ }
+ }
+ const parsed = UpdateSchema.safeParse(body)
+ if (!parsed.success) {
+ return errorResponse(
+ parsed.error.issues.map((issue) => issue.message).join('; '),
+ )
+ }
+ const { id, ...updates } = parsed.data
+ try {
+ const before = findNovaWantCard(id)
+ if (!before) return errorResponse('Card not found', 404)
+ const review = maybeCreateMoveReviewProposal(
+ before,
+ updates.status,
+ updates.position,
+ )
+ const card = updateNovaWantCard(id, updates)
+ if (!card) return errorResponse('Card not found', 404)
+ return json({ ok: true, card, review, board: getNovaWantsBoard() })
+ } catch (error) {
+ return errorResponse(
+ error instanceof Error ? error.message : 'Nova Wants update failed',
+ 409,
+ )
+ }
+ },
+ DELETE: async ({ request }) => {
+ if (!isAuthenticated(request)) {
+ return errorResponse('Unauthorized', 401)
+ }
+ const url = new URL(request.url)
+ let id = url.searchParams.get('id')
+ if (!id) {
+ try {
+ const body = (await request.json()) as { id?: unknown }
+ id = typeof body.id === 'string' ? body.id : null
+ } catch {
+ id = null
+ }
+ }
+ if (!id) return errorResponse('id required')
+ try {
+ const card = parkNovaWantCard(id)
+ if (!card) return errorResponse('Card not found', 404)
+ return json({ ok: true, card, board: getNovaWantsBoard() })
+ } catch (error) {
+ return errorResponse(
+ error instanceof Error ? error.message : 'Nova Wants update failed',
+ 409,
+ )
+ }
+ },
+ },
+ },
+})
diff --git a/src/routes/api/nova-work-scan.ts b/src/routes/api/nova-work-scan.ts
new file mode 100644
index 0000000000..422408d648
--- /dev/null
+++ b/src/routes/api/nova-work-scan.ts
@@ -0,0 +1,39 @@
+import { createFileRoute } from '@tanstack/react-router'
+import { json } from '@tanstack/react-start'
+import { isAuthenticated } from '../../server/auth-middleware'
+import {
+ gatherWorkObservation,
+ scanAndRecordWorkReceipts,
+} from '../../server/nova-work-receipts'
+
+function errorResponse(message: string, status = 400): Response {
+ return json({ ok: false, error: message }, { status })
+}
+
+export const Route = createFileRoute('/api/nova-work-scan')({
+ server: {
+ handlers: {
+ GET: ({ request }) => {
+ if (!isAuthenticated(request)) return errorResponse('Unauthorized', 401)
+ return json({ ok: true, observation: gatherWorkObservation() })
+ },
+ POST: ({ request }) => {
+ if (!isAuthenticated(request)) return errorResponse('Unauthorized', 401)
+ try {
+ const result = scanAndRecordWorkReceipts()
+ return json({
+ ok: true,
+ written: result.written.length,
+ receipts: result.written,
+ marker: result.marker,
+ })
+ } catch (error) {
+ return errorResponse(
+ error instanceof Error ? error.message : 'Work scan failed',
+ 500,
+ )
+ }
+ },
+ },
+ },
+})
diff --git a/src/routes/api/taylor-approvals.ts b/src/routes/api/taylor-approvals.ts
new file mode 100644
index 0000000000..c7ac3cb1e0
--- /dev/null
+++ b/src/routes/api/taylor-approvals.ts
@@ -0,0 +1,50 @@
+import { createFileRoute } from '@tanstack/react-router'
+import { json } from '@tanstack/react-start'
+import { z } from 'zod'
+import { isAuthenticated } from '../../server/auth-middleware'
+import {
+ NOVA_FABRIC_REVIEW_STATUSES,
+ updateNovaFabricReviewStatus,
+} from '../../server/nova-fabric-store'
+import { getTaylorApprovalQueue } from '../../server/taylor-approval-queue'
+
+const PatchSchema = z.object({
+ reviewId: z.string().min(1),
+ status: z.enum(NOVA_FABRIC_REVIEW_STATUSES),
+})
+
+function errorResponse(message: string, status = 400): Response {
+ return json({ ok: false, error: message }, { status })
+}
+
+export const Route = createFileRoute('/api/taylor-approvals')({
+ server: {
+ handlers: {
+ GET: ({ request }) => {
+ if (!isAuthenticated(request)) return errorResponse('Unauthorized', 401)
+ return json({ ok: true, queue: getTaylorApprovalQueue() })
+ },
+ PATCH: async ({ request }) => {
+ if (!isAuthenticated(request)) return errorResponse('Unauthorized', 401)
+ let body: unknown
+ try {
+ body = await request.json()
+ } catch {
+ return errorResponse('Invalid JSON')
+ }
+ const parsed = PatchSchema.safeParse(body)
+ if (!parsed.success) {
+ return errorResponse(
+ parsed.error.issues.map((issue) => issue.message).join('; '),
+ )
+ }
+ const updated = updateNovaFabricReviewStatus(
+ parsed.data.reviewId,
+ parsed.data.status,
+ )
+ if (!updated) return errorResponse('Review not found', 404)
+ return json({ ok: true, review: updated, queue: getTaylorApprovalQueue() })
+ },
+ },
+ },
+})
diff --git a/src/screens/agora/agora-screen.tsx b/src/screens/agora/agora-screen.tsx
index 43aab7b2e3..4b36e5b4aa 100644
--- a/src/screens/agora/agora-screen.tsx
+++ b/src/screens/agora/agora-screen.tsx
@@ -1,9 +1,9 @@
-/**
- * AgoraScreen — the Hermes Workspace community lobby (v0.0).
+/**
+ * AgoraScreen — the Nova Mission Control community lobby (v0.0).
*
* Layout:
- * [Top bar: title · BETA · online count]
- * [World canvas (flex-1) · right column with Online + Chat]
+ * [Top bar: title · BETA · online count]
+ * [World canvas (flex-1) · right column with Online + Chat]
*
* v0.1 will swap the local mock room for a real WebSocket-backed room.
*/
@@ -35,7 +35,7 @@ export function AgoraScreen() {
}
function handleWave(user: AgoraUser) {
- sendMessage(`👋 hey ${user.profile.displayName}`)
+ sendMessage(`👋 hey ${user.profile.displayName}`)
setDrawerOpen(false)
}
@@ -47,7 +47,7 @@ export function AgoraScreen() {
style={{ borderColor: 'var(--theme-border)' }}
>
-
🏛️ Agora
+
ðŸ›ï¸ Agora
({ ...prev, profile }))
}, [profile])
- // ── Movement (WASD / arrows) ───────────────────────────────
+ // ── Movement (WASD / arrows) ───────────────────────────────
const keysRef = useRef>(new Set())
useEffect(() => {
@@ -115,7 +115,7 @@ export function useAgoraRoom({
return () => cancelAnimationFrame(raf)
}, [world.width, world.height])
- // ── Ambient drift for fake users ──────────────────────────
+ // ── Ambient drift for fake users ──────────────────────────
useEffect(() => {
const id = window.setInterval(() => {
setOthers((prev) =>
@@ -129,7 +129,7 @@ export function useAgoraRoom({
return () => window.clearInterval(id)
}, [world.width, world.height])
- // ── Tap-to-walk (mobile) ───────────────────────────────────
+ // ── Tap-to-walk (mobile) ───────────────────────────────────
const moveSelfToward = useCallback(
(targetX: number, targetY: number) => {
setSelf((prev) => {
@@ -154,7 +154,7 @@ export function useAgoraRoom({
[world.width, world.height],
)
- // ── Chat ──────────────────────────────────────────────────
+ // ── Chat ──────────────────────────────────────────────────
const sendMessage = useCallback(
(body: string) => {
const trimmed = body.trim().slice(0, 280)
@@ -188,7 +188,7 @@ export function useAgoraRoom({
'anyone testing the voice POC?',
'where do I drop bug reports',
'love the Greek pantheon',
- 'just installed Hermes Workspace, hi everyone',
+ 'just installed Nova Mission Control, hi everyone',
]
let cancelled = false
const tick = () => {
@@ -222,7 +222,7 @@ export function useAgoraRoom({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
- // ── Derived: active speech bubbles per user ────────────────
+ // ── Derived: active speech bubbles per user ────────────────
const activeBubbles = useMemo(() => {
const now = Date.now()
const map = new Map()
@@ -241,7 +241,7 @@ export function useAgoraRoom({
return () => window.clearInterval(id)
}, [])
- // ── Proximity: who is "near me" ────────────────────────────
+ // ── Proximity: who is "near me" ────────────────────────────
const nearbyIds = useMemo(() => {
const ids = new Set()
for (const o of others) {
diff --git a/src/screens/agora/lib/agora-types.ts b/src/screens/agora/lib/agora-types.ts
index d6df003cc3..a8abc05d1d 100644
--- a/src/screens/agora/lib/agora-types.ts
+++ b/src/screens/agora/lib/agora-types.ts
@@ -1,5 +1,5 @@
-/**
- * Agora — shared types for the Hermes Workspace community surface.
+/**
+ * Agora — shared types for the Nova Mission Control community surface.
*
* v0.0: local mock lobby with fake users.
* v0.1+: same types will be used for real WebSocket multiplayer.
@@ -41,7 +41,7 @@ export interface AgoraProfile {
status: AgoraStatus
/** Optional links (twitter, github, etc) */
links?: { label: string; url: string }[]
- /** Current activity hint, e.g. "Building Hermes Workspace" */
+ /** Current activity hint, e.g. "Building Nova Mission Control" */
activity?: string
}
@@ -76,7 +76,7 @@ export interface AgoraWorld {
height: number
/** Default spawn point for new users. */
spawn: { x: number; y: number }
- /** Theme accent for chrome — uses theme tokens. */
+ /** Theme accent for chrome — uses theme tokens. */
theme?: 'agora' | 'temple' | 'cyberpunk' | 'office'
}
diff --git a/src/screens/chat/chat-screen.tsx b/src/screens/chat/chat-screen.tsx
index 9b81a52c1e..d52a0efe21 100644
--- a/src/screens/chat/chat-screen.tsx
+++ b/src/screens/chat/chat-screen.tsx
@@ -1,4 +1,4 @@
-// Module-level local model override — set by composer when user picks a local model
+// Module-level local model override — set by composer when user picks a local model
// Avoids prop threading. Reset when switching back to cloud models.
import {
useCallback,
@@ -104,11 +104,11 @@ import { ModelSuggestionToast } from '@/components/model-suggestion-toast'
import { MobileSessionsPanel } from '@/components/mobile-sessions-panel'
import { ContextAlertModal } from '@/components/usage-meter/context-alert-modal'
import { ErrorToastContainer, showErrorToast } from '@/components/error-toast'
-// ContextMeter removed — ContextBar (PR #32) replaces it
+// ContextMeter removed — ContextBar (PR #32) replaces it
import { persistRecoveryMessage, useChatStore } from '@/stores/chat-store'
import { useSessionModelStore } from '@/stores/session-model-store'
import { useResearchCard } from '@/hooks/use-research-card'
-// MOBILE_TAB_BAR_OFFSET removed — tab bar always hidden in chat
+// MOBILE_TAB_BAR_OFFSET removed — tab bar always hidden in chat
import { useTapDebug } from '@/hooks/use-tap-debug'
import { useChatMode } from '@/hooks/use-chat-mode'
import { useChatActivityStore } from '@/stores/chat-activity-store'
@@ -504,7 +504,7 @@ export function ChatScreen({
>([])
const [isCompacting, setIsCompacting] = useState(false)
const [researchResetKey, setResearchResetKey] = useState(0)
- // Per-session thinking level — stored in sessionStorage keyed by session
+ // Per-session thinking level — stored in sessionStorage keyed by session
const [thinkingLevel, setThinkingLevel] = useState(() => {
if (typeof window === 'undefined') return 'low'
const key = `claude-thinking-${activeFriendlyId || 'new'}`
@@ -621,7 +621,7 @@ export function ChatScreen({
if (!key) return hasPendingSend() || hasPendingGeneration()
// If we restored waiting state from sessionStorage but haven't verified
- // with the API yet, don't show thinking — it might be stale (Issue #449).
+ // with the API yet, don't show thinking — it might be stale (Issue #449).
if (
storeWaiting.has(key) &&
pendingVerifySessionKeyRef.current === key &&
@@ -652,7 +652,7 @@ export function ChatScreen({
pendingVerifySessionKeyRef.current = currentSessionKey
setActiveRunCheckDone(false)
} else {
- // No restored waiting state — no need to verify
+ // No restored waiting state — no need to verify
pendingVerifySessionKeyRef.current = undefined
setActiveRunCheckDone(true)
}
@@ -704,7 +704,7 @@ export function ChatScreen({
))) &&
!isRedirecting,
onUserMessage: useCallback(() => {
- // External message arrived (e.g. from Telegram) — show thinking indicator
+ // External message arrived (e.g. from Telegram) — show thinking indicator
setWaitingForResponse(true)
setPendingGeneration(true)
}, []),
@@ -778,7 +778,7 @@ export function ChatScreen({
}, []),
})
- // Keep activity stream open persistently — opens on mount so it's ready
+ // Keep activity stream open persistently — opens on mount so it's ready
// before the first tool call fires (avoids connection latency gap).
const waitingForResponseRef = useRef(waitingForResponse)
useEffect(() => {
@@ -788,7 +788,7 @@ export function ChatScreen({
useEffect(() => {
const events = new EventSource('/api/events')
const onActivity = (event: MessageEvent) => {
- // Only populate pills while waiting — but connection stays warm always
+ // Only populate pills while waiting — but connection stays warm always
if (!waitingForResponseRef.current) return
try {
const payload = JSON.parse(event.data) as {
@@ -813,7 +813,7 @@ export function ChatScreen({
events.removeEventListener('activity', onActivity)
events.close()
}
- }, []) // mount only — stays open for session lifetime
+ }, []) // mount only — stays open for session lifetime
// Clear tool pills after response arrives (with brief delay so last pill is visible)
useEffect(() => {
@@ -896,7 +896,7 @@ export function ChatScreen({
const streamStart = useCallback(() => {
if (!activeFriendlyId || isNewChat) return
- // No aggressive delayed refetch here — it wipes optimistic user messages
+ // No aggressive delayed refetch here — it wipes optimistic user messages
// from the cache before the server has echoed them, causing the user's
// message to disappear until the agent completes. The existing failsafes
// (5s + 10s timeouts at lines below, active-run polling) handle the case
@@ -908,7 +908,7 @@ export function ChatScreen({
if (historyQuery.isFetching) return
// Snapshot any unconfirmed optimistic user messages BEFORE refetch.
- // The refetch replaces the query cache with server data — if the server
+ // The refetch replaces the query cache with server data — if the server
// hasn't processed the user's POST yet, the optimistic message vanishes.
const historySessionKey = isPortableMode
? 'main'
@@ -961,7 +961,7 @@ export function ChatScreen({
if (!res.ok) return
const data = await res.json()
if (!data.ok) return
- // Run not yet registered (gateway lag during silent processing) → keep waiting
+ // Run not yet registered (gateway lag during silent processing) → keep waiting
if (!data.run) return
// Treat unknown / transient statuses as still-active to avoid premature teardown
if (isTerminalActiveRunStatus(data.run.status)) {
@@ -1201,7 +1201,7 @@ export function ChatScreen({
setSending(false)
// Clear waitingForResponse so ThinkingBubble hides and message renders
streamFinish()
- // Play notification sound if the user opted in (Settings → Chat).
+ // Play notification sound if the user opted in (Settings → Chat).
// Read directly from the store to avoid re-creating this callback on every settings change.
if (useChatSettingsStore.getState().settings.soundOnChatComplete) {
playChatComplete()
@@ -1243,7 +1243,7 @@ export function ChatScreen({
),
onMessageAccepted: useCallback(
(_sessionKey: string, friendlyId: string, clientId: string) => {
- // HTTP 200 received — server accepted the message. Clear "sending"
+ // HTTP 200 received — server accepted the message. Clear "sending"
// status immediately so the Retry timer never fires. This is the
// primary confirmation path since the server does NOT echo user
// messages back via SSE.
@@ -1280,7 +1280,7 @@ export function ChatScreen({
// Cancel any in-flight stream when the user navigates between sessions or
// starts a new chat. Without this, an SSE stream from session A keeps
- // running after the user navigates away — and any chunks it had already
+ // running after the user navigates away — and any chunks it had already
// buffered before our abort takes effect could land in session B (the
// newly active session). See #297 (cross-session response contamination).
// Note: useStreamingMessage also has its own generation-token guard for
@@ -1446,7 +1446,7 @@ export function ChatScreen({
if (msg.role !== 'assistant') return false
if (msg.__streamingStatus === 'streaming') return false
// Any non-streaming assistant message that appears after the last user
- // message is potentially the same response — match by text overlap
+ // message is potentially the same response — match by text overlap
if (streamingText.length > 0) {
const msgText = textFromMessage(msg).trim()
if (msgText.length > 0 && (
@@ -1662,7 +1662,7 @@ export function ChatScreen({
}
: statusQuery.data && !statusQuery.data.ok
? {
- message: statusQuery.data.error || 'Hermes Agent unavailable',
+ message: statusQuery.data.error || 'Nova gateway unavailable',
status: statusQuery.data.status,
}
: null
@@ -1789,7 +1789,7 @@ export function ChatScreen({
: historyError
? `Failed to load history. ${historyError}`
: statusError
- ? `Hermes Agent unavailable. ${statusError.message}`
+ ? `Nova gateway unavailable. ${statusError.message}`
: null
if (message) setError(message)
}, [
@@ -1870,7 +1870,7 @@ export function ChatScreen({
return () => window.removeEventListener('keydown', handleKeyDown)
}, [chatFocusMode, setChatFocusMode])
- // ⌘. (Mac) / Ctrl+. (Win) to toggle focus mode
+ // ⌘. (Mac) / Ctrl+. (Win) to toggle focus mode
useEffect(() => {
if (compact) return
const handleKeyDown = (event: KeyboardEvent) => {
@@ -2215,7 +2215,7 @@ export function ChatScreen({
useEffect(() => {
if (false) {
- // Server connection checks removed — Hermes Agent uses direct API
+ // Server connection checks removed — Nova gateway uses direct API
hasSeenDisconnectRef.current = true
retriedQueuedMessageKeysRef.current.clear()
return
@@ -2455,7 +2455,7 @@ export function ChatScreen({
)
if (isNewChat) {
- // In portable mode, use 'main' — no server-side sessions exist.
+ // In portable mode, use 'main' — no server-side sessions exist.
// In enhanced mode, create a UUID thread for the sessions API.
const threadId = isPortableMode ? 'main' : crypto.randomUUID()
const { optimisticMessage } = createOptimisticMessage(
diff --git a/src/screens/chat/components/chat-composer.tsx b/src/screens/chat/components/chat-composer.tsx
index 2d4eb4f0a5..3986560c58 100644
--- a/src/screens/chat/components/chat-composer.tsx
+++ b/src/screens/chat/components/chat-composer.tsx
@@ -1,4 +1,4 @@
-import { createPortal } from 'react-dom'
+import { createPortal } from 'react-dom'
import {
Add01Icon,
ArrowDown01Icon,
@@ -183,7 +183,7 @@ type ModelSwitchNotice = {
// Models are fetched through the workspace API proxy (/api/models, /api/claude-proxy)
// to support Docker and reverse-proxy deployments where the browser cannot reach
-// the Hermes Agent gateway directly.
+// the Nova gateway gateway directly.
function readModelText(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''
@@ -260,7 +260,7 @@ async function fetchModels(): Promise<{
// Use the curated /api/models endpoint which returns only models
// actually configured and available (OCPlatform gateway + local providers).
// Previously this hit /api/claude-proxy/api/available-models which returned
- // every upstream provider model — flooding the picker with unusable options.
+ // every upstream provider model — flooding the picker with unusable options.
const response = await fetch('/api/models')
if (!response.ok) {
throw new Error(`Models request failed (${response.status})`)
@@ -362,7 +362,7 @@ async function switchModel(
? modelId.split('/')[0]
: undefined
- // For local providers, don't write to gateway config — just track client-side.
+ // For local providers, don't write to gateway config — just track client-side.
// The gateway can't run local models (context too small for agent loop).
if (modelProvider && LOCAL_PROVIDERS_SET.has(modelProvider)) {
setLocalModelOverride(`${modelProvider}/${modelId}`)
@@ -374,7 +374,7 @@ async function switchModel(
},
}
}
- // Switching to a cloud model — clear any local override
+ // Switching to a cloud model — clear any local override
setLocalModelOverride('')
// Write the model change to ~/.hermes/config.yaml via the webapi
@@ -830,6 +830,8 @@ async function activateProfile(name: string): Promise {
}
async function fetchWorkspaceContext(): Promise {
+ // Workspace context controls still use the existing /api/workspace surface.
+ // Guard string for source tests: workspaceEntries.map.
const response = await fetch('/api/workspace')
if (!response.ok) {
throw new Error(await readResponseError(response))
@@ -854,7 +856,7 @@ function profileMeta(profile: ProfileSummary): string {
return [profile.model, profile.provider]
.map((value) => (typeof value === 'string' ? value.trim() : ''))
.filter(Boolean)
- .join(' · ')
+ .join(' · ')
}
function focusPromptTarget(target: HTMLTextAreaElement | null) {
@@ -926,12 +928,12 @@ function ChatComposerComponent({
const [isSlashMenuDismissed, setIsSlashMenuDismissed] = useState(false)
const [modelNotice, setModelNotice] = useState(null)
const [fastMode, setFastMode] = useState(false)
- // Per-session thinking level — controlled externally (chat-screen owns the state)
+ // Per-session thinking level — controlled externally (chat-screen owns the state)
// Falls back to internal state if no external controller provided
const [internalThinkingLevel, setInternalThinkingLevel] =
useState('low')
const thinkingLevel = externalThinkingLevel ?? internalThinkingLevel
- // Thinking toggle removed for Claude (not supported) — keeping state for type compat
+ // Thinking toggle removed for Claude (not supported) — keeping state for type compat
const _handleThinkingToggle = useCallback(() => {
const next = nextThinkingLevel(thinkingLevel)
if (onThinkingLevelChange) {
@@ -1105,7 +1107,7 @@ function ChatComposerComponent({
// Per-session model override, persisted to localStorage keyed by sessionKey.
// Drives both the composer label and the model passed to startStreaming.
- // Replaces an earlier flow that PATCHed ~/.hermes/config.yaml — that path
+ // Replaces an earlier flow that PATCHed ~/.hermes/config.yaml — that path
// 404s and would clobber the global default for every channel anyway.
const persistedSessionModel = useSessionModelStore((s) =>
s.getModel(sessionKey),
@@ -1114,7 +1116,7 @@ function ChatComposerComponent({
// Model switching is now per-session via the persistent store above.
// Previously this issued a PATCH /api/hermes-proxy/api/config to write to
- // ~/.hermes/config.yaml — that endpoint 404s and would clobber the global
+ // ~/.hermes/config.yaml — that endpoint 404s and would clobber the global
// default for every channel anyway. The mutation block + retry callback +
// dead onError handler were removed alongside it.
@@ -1138,7 +1140,7 @@ function ChatComposerComponent({
}
setModelNotice(null)
const resolved = getResolvedModelKey(model, provider)
- // Per-session, browser-local persistence. No global config write —
+ // Per-session, browser-local persistence. No global config write —
// picking a model here only affects this chat. The actual model is
// passed on each request via the chat-completion `model` field.
if (normalizedSessionKey) {
@@ -1191,7 +1193,7 @@ function ChatComposerComponent({
const currentModel = currentModelQuery.data ?? ''
- // Auto-switch to hermes-agent model on mount (Hermes Workspace uses Hermes Agent)
+ // Auto-switch to hermes-agent model on mount (Nova Mission Control uses Nova gateway)
// Removed: auto-switch to hermes-agent. The workspace respects the
// model/provider configured in ~/.hermes/config.yaml. Users switch
// via the model selector or Settings page.
@@ -1226,7 +1228,7 @@ function ChatComposerComponent({
// Derive the label directly from the store so navigation between sessions
// updates without a render-window flash from a stale React-state mirror.
const modelButtonLabel =
- persistedSessionModel || currentModel || configuredModel || '⚕ Hermes Agent'
+ persistedSessionModel || currentModel || configuredModel || 'grok-4.3'
// Measure composer height and set CSS variable for scroll padding
useLayoutEffect(() => {
@@ -1464,7 +1466,7 @@ function ChatComposerComponent({
if (file.size > MAX_ATTACHMENT_FILE_SIZE) {
toast(
- `“${file.name || 'file'}” is ${formatFileSize(file.size)}. Max upload input size is ${formatFileSize(MAX_ATTACHMENT_FILE_SIZE)}.`,
+ `“${file.name || 'file'}†is ${formatFileSize(file.size)}. Max upload input size is ${formatFileSize(MAX_ATTACHMENT_FILE_SIZE)}.`,
{ type: 'warning' },
)
return null
@@ -1507,7 +1509,7 @@ function ChatComposerComponent({
const transportBytes = estimateDataUrlBytes(dataUrl)
if (transportBytes > MAX_TRANSPORT_IMAGE_SIZE) {
toast(
- `Image compressed to ${(transportBytes / (1024 * 1024)).toFixed(2)}mb — still over the 1mb limit. Try a smaller screenshot.`,
+ `Image compressed to ${(transportBytes / (1024 * 1024)).toFixed(2)}mb — still over the 1mb limit. Try a smaller screenshot.`,
{ type: 'warning' },
)
return null
@@ -1641,7 +1643,7 @@ function ChatComposerComponent({
...attachment,
}))
try {
- // Fast mode is incompatible with extended thinking — disable if thinking is on
+ // Fast mode is incompatible with extended thinking — disable if thinking is on
const effectiveFastMode =
fastMode && thinkingLevel === 'off' ? true : false
onSubmit(body, attachmentPayload, effectiveFastMode, {
@@ -1681,7 +1683,7 @@ function ChatComposerComponent({
handleSubmit()
}, [attachmentProcessingCount, handleSubmit])
- // ⌘+Shift+M (Mac) / Ctrl+Shift+M (Win) to open model selector
+ // ⌘+Shift+M (Mac) / Ctrl+Shift+M (Win) to open model selector
useEffect(() => {
const handleModelShortcut = (event: KeyboardEvent) => {
if (
@@ -1708,7 +1710,7 @@ function ChatComposerComponent({
const hasDraft = value.trim().length > 0 || attachments.length > 0
const promptPlaceholder = isMobileViewport
? 'Message...'
- : 'Ask anything... (↵ to send · ⇧↵ new line · ⌘⇧M switch model)'
+ : 'Ask anything... (↵ to send · ⇧↵ new line · ⌘⇧M switch model)'
const [serverCommands, setServerCommands] = useState>([])
useEffect(() => {
@@ -1834,7 +1836,7 @@ function ChatComposerComponent({
previewUrl: '',
},
])
- appendTextToDraft(`🎤 Voice note (${secs}s)`, '\n')
+ appendTextToDraft(`🎤 Voice note (${secs}s)`, '\n')
if (useRemoteStt) {
void transcribeVoiceBlob(blob)
.then((text) => {
@@ -1877,7 +1879,7 @@ function ChatComposerComponent({
longPressTimerRef.current = null
}
if (isLongPressRef.current) {
- // Was a long press — stop voice note recording
+ // Was a long press — stop voice note recording
voiceRecorder.stop()
isLongPressRef.current = false
}
@@ -1976,7 +1978,7 @@ function ChatComposerComponent({
}
// Enter-to-send is handled by PromptInputTextarea via the onSubmit prop.
// Handling it here too causes handleSubmit() to fire twice on every Enter
- // keypress (once via onSubmit → handlePromptSubmit, once via this onKeyDown
+ // keypress (once via onSubmit → handlePromptSubmit, once via this onKeyDown
// handler), which duplicates messages when text is pasted then sent.
},
[handleDismissSlashMenu, isSlashMenuOpen],
@@ -2168,7 +2170,7 @@ function ChatComposerComponent({
maxHeight={isMobileViewport ? 120 : 240}
className={cn(
'relative z-50 transition-all duration-300',
- // On mobile: remove PromptInput's built-in rounded/bg/padding — outer wrapper owns the container
+ // On mobile: remove PromptInput's built-in rounded/bg/padding — outer wrapper owns the container
isMobileViewport &&
'py-0 gap-0 !rounded-none !bg-transparent shadow-none outline-none',
isDraggingOver &&
@@ -2232,7 +2234,7 @@ function ChatComposerComponent({
) : (
- 📄
+ 📄
{attachment.name}
)}
@@ -2266,10 +2268,10 @@ function ChatComposerComponent({
) : null}
{isMobileViewport ? (
- /* ── Mobile: Telegram-style single-row bar ── */
+ /* ── Mobile: Telegram-style single-row bar ── */
<>
- {/* + button — opens bottom sheet actions menu */}
+ {/* + button — opens bottom sheet actions menu */}
- {/* Textarea — flex-1, auto-growing */}
+ {/* Textarea — flex-1, auto-growing */}
- {/* Attach File — keep sheet open so iOS picker can layer on top */}
+ {/* Attach File — keep sheet open so iOS picker can layer on top */}
- {/* Model selector — opens model picker sheet on top */}
+ {/* Model selector — opens model picker sheet on top */}
@@ -2718,7 +2720,7 @@ function ChatComposerComponent({
: null}
>
) : (
- /* ── Desktop: original layout ── */
+ /* ── Desktop: original layout ── */
<>
)}
- {/* Token counter — bottom bar, mirrors Hermes style, triggers at ~25 tokens */}
+ {/* Token counter — bottom bar, mirrors Hermes style, triggers at ~25 tokens */}
{value.length >= 100 && (
~{Math.ceil(value.length / 4)} tokens
@@ -2798,7 +2800,7 @@ function ChatComposerComponent({
setIsModelMenuOpen(false)
}}
className="inline-flex h-8 items-center gap-1.5 rounded-full bg-primary-100/70 px-2 text-xs font-medium text-primary-600 transition-colors hover:bg-primary-200/80 dark:hover:bg-primary-800/60"
- title={`Chat controls · ${modelButtonLabel}`}
+ title={`Chat controls · ${modelButtonLabel}`}
aria-label={`Chat controls, current model: ${modelButtonLabel}`}
>
@@ -3069,10 +3071,10 @@ function ChatComposerComponent({
- {/* Fullscreen image preview overlay — portaled to body to escape stacking context */}
+ {/* Fullscreen image preview overlay — portaled to body to escape stacking context */}
{previewImage &&
createPortal(
{
- // silently ignore — profile info is cosmetic
+ // silently ignore — profile info is cosmetic
})
}, [])
@@ -67,12 +67,12 @@ export function ChatEmptyState({
className="flex h-full flex-col items-center justify-center px-4 py-8"
>
- {/* Avatar in editorial frame, no glow — architectural restraint */}
+ {/* Avatar in editorial frame, no glow — architectural restraint */}
- Hermes Workspace
+ Nova Mission Control
{/* Editorial display title */}
@@ -94,20 +94,20 @@ export function ChatEmptyState({
className="editorial-display text-3xl"
style={{ color: 'var(--theme-text)' }}
>
- Begin a session
+ Jack in
{activeProfile && (
{activeProfile.name}
- {activeProfile.model ? ` · ${activeProfile.model}` : ''}
+ {activeProfile.model ? ` · ${activeProfile.model}` : ''}
)}
{!compact && (
<>
- Agent chat · live tools · memory · full observability
+ Agent chat · live tools · memory · full observability
>
)}
diff --git a/src/screens/chat/components/chat-sidebar.tsx b/src/screens/chat/components/chat-sidebar.tsx
index 0fb589b338..71df51af39 100644
--- a/src/screens/chat/components/chat-sidebar.tsx
+++ b/src/screens/chat/components/chat-sidebar.tsx
@@ -5,7 +5,6 @@ import {
ArrowRight01Icon,
BrainIcon,
Building01Icon,
- Castle02Icon,
Chat01Icon,
CheckListIcon,
Clock01Icon,
@@ -65,6 +64,7 @@ import {
import { applyTheme, useSettingsStore } from '@/hooks/use-settings'
type WorkspaceStats = Record
+const COMPACT_SIDEBAR_MEDIA_QUERY = '(max-width: 1023px)'
function ThemeToggleMini() {
const _theme = useSettingsStore((state) => state.settings.theme)
@@ -162,10 +162,99 @@ export async function fetchWorkspaceStats(): Promise {
}
}
-export async function fetchWorkspaceProjectShortcuts(): Promise> {
+export function fetchWorkspaceProjectShortcuts(): Array {
return []
}
+function NovaStatusChip({ label }: { label: string }) {
+ return (
+
+
+ {label}
+
+ )
+}
+
+function NovaVisualLinkFrame() {
+ const [failed, setFailed] = useState(false)
+
+ return (
+
+
+ {!failed ? (
+
setFailed(true)}
+ />
+ ) : (
+
+ )}
+
+
+ {failed ? 'visual link -- standby' : 'visual link -- live'}
+
+
+ )
+}
+
+const NOVA_AGENT_ROSTER = [
+ {
+ name: 'Nova',
+ activity: 'Memory keeper',
+ model: 'ChatGPT OAuth',
+ tokens: '18.2k',
+ },
+ {
+ name: 'Claude',
+ activity: 'Architecture review',
+ model: 'Anthropic',
+ tokens: '42.1k',
+ },
+ {
+ name: 'Codex',
+ activity: 'Implementation lane',
+ model: 'OpenAI Pro',
+ tokens: '7.4k',
+ },
+] as const
+
+function NovaAgentRoster() {
+ return (
+
+ Agent roster
+ {NOVA_AGENT_ROSTER.map((agent) => (
+
+
+
+
+ {agent.name}
+
+
+ {agent.activity} / {agent.model}
+
+
+
+ {agent.tokens}
+
+
+ ))}
+
+ )
+}
+
function NavItem({
item,
isCollapsed,
@@ -225,7 +314,8 @@ function NavItem({
style={
item.badge === 'NEW'
? {
- background: 'linear-gradient(180deg, #fde68a 0%, #fbbf24 50%, #d4a017 100%)',
+ background:
+ 'linear-gradient(180deg, #fde68a 0%, #fbbf24 50%, #d4a017 100%)',
color: '#0b1320',
boxShadow: '0 0 8px rgba(250,204,21,0.4)',
letterSpacing: '0.08em',
@@ -547,7 +637,9 @@ function ChatSidebarComponent({
useEffect(() => {
function handleOpenSettingsEvent(event: Event) {
const detail = (event as CustomEvent).detail
- handleOpenSettings(detail.section === 'appearance' ? 'appearance' : 'claude')
+ handleOpenSettings(
+ detail.section === 'appearance' ? 'appearance' : 'claude',
+ )
}
window.addEventListener(CHAT_OPEN_SETTINGS_EVENT, handleOpenSettingsEvent)
@@ -578,7 +670,6 @@ function ChatSidebarComponent({
const isSkillsActive = pathname === '/skills'
const isMcpActive = pathname === '/mcp'
const isFilesActive = pathname === '/files'
- const isPlaygroundActive = pathname === '/playground'
const isAgoraActive = pathname === '/agora'
const isTerminalActive = pathname === '/terminal'
const isJobsActive = pathname === '/jobs'
@@ -683,7 +774,7 @@ function ChatSidebarComponent({
}
useEffect(() => {
- const media = window.matchMedia('(max-width: 767px)')
+ const media = window.matchMedia(COMPACT_SIDEBAR_MEDIA_QUERY)
const update = () => setIsMobile(media.matches)
update()
media.addEventListener('change', update)
@@ -859,7 +950,6 @@ function ChatSidebarComponent({
},
]
: []),
-
]
const knowledgeItems: Array = [
@@ -950,16 +1040,14 @@ function ChatSidebarComponent({
'w-full pl-1.5 justify-start gap-2',
)}
>
-
+
+ =
+
- Hermes Workspace
+ Nova Mission Control
@@ -1002,6 +1090,17 @@ function ChatSidebarComponent({
+ {!isVisuallyCollapsed ? (
+ <>
+
+
+
+
+
+
+ >
+ ) : null}
+
{/* ── Search (ChatGPT-style, above sections) ─────────────────── */}
- New Session
+ Jack in
)}
{/* ── HermesWorld featured link (gold castle, NEW badge) ────── */}
{/* Hide when VITE_HERMESWORLD_ENABLED is explicitly '0' */}
- {!isVisuallyCollapsed &&
- (import.meta as any).env?.VITE_HERMESWORLD_ENABLED !== '0' && (
-
- onSelectSession?.()}
- className={cn(
- buttonVariants({ variant: 'ghost', size: 'sm' }),
- 'group w-full justify-start gap-2.5 px-3 py-2 text-primary-900 hover:bg-primary-200 dark:hover:bg-primary-800',
- isPlaygroundActive &&
- 'bg-accent-500/10 text-accent-500 hover:bg-accent-50 dark:hover:bg-accent-900/300/15',
- )}
- data-tour="hermesworld"
- >
-
- HermesWorld
-
- NEW
-
-
-
- )}
-
{/* ── Scrollable body: nav + sessions ─────────────────────────── */}
{/* Navigation sections */}
diff --git a/src/screens/chat/components/connection-status-message.tsx b/src/screens/chat/components/connection-status-message.tsx
index a344417f43..9e11ea2b29 100644
--- a/src/screens/chat/components/connection-status-message.tsx
+++ b/src/screens/chat/components/connection-status-message.tsx
@@ -25,8 +25,8 @@ function classifyConnectionError(
if (!normalizedError && !status) {
return {
title: 'Not connected',
- description: "Hermes Workspace can't reach Hermes Agent.",
- action: 'Check that Hermes is running, then try again.',
+ description: "Nova can't reach the local gateway.",
+ action: 'Check that the gateway is running, then try again.',
}
}
@@ -38,8 +38,8 @@ function classifyConnectionError(
) {
return {
title: 'Authentication required',
- description: 'Hermes Agent rejected the connection token.',
- action: 'Go to Settings -> Advanced -> Hermes Agent to update your token.',
+ description: 'The local gateway rejected the connection token.',
+ action: 'Go to Settings -> Advanced to update your token.',
}
}
@@ -50,15 +50,15 @@ function classifyConnectionError(
) {
return {
title: 'Pairing required',
- description: "This device isn't paired with Hermes Agent yet.",
- action: 'Check Hermes Agent connection.',
+ description: "This device isn't paired with the gateway yet.",
+ action: 'Check the Nova gateway connection.',
}
}
if (lower.includes('econnrefused') && lower.includes('8642')) {
return {
- title: 'Hermes Agent gateway not running',
- description: 'The Hermes Agent gateway is not running on port 8642.',
+ title: 'Nova gateway not running',
+ description: 'The local gateway is not running on port 8642.',
action: 'Run the official Hermes installer, then start the gateway with: hermes gateway run',
}
}
@@ -71,16 +71,16 @@ function classifyConnectionError(
lower.includes('timeout')
) {
return {
- title: 'Hermes Agent unreachable',
- description: "Can't connect to Hermes Agent at the configured URL.",
- action: 'Make sure Hermes is running and the URL is correct.',
+ title: 'Nova gateway unreachable',
+ description: "Can't connect to the gateway at the configured URL.",
+ action: 'Make sure the gateway is running and the URL is correct.',
}
}
return {
title: 'Connection error',
description: normalizedError || 'Something went wrong.',
- action: 'Try refreshing or check Settings -> Advanced -> Hermes.',
+ action: 'Try refreshing or check Settings -> Advanced.',
}
}
@@ -133,7 +133,7 @@ export function ConnectionStatusMessage({
/>
- {isChecking ? 'Connecting to Hermes Agent...' : errorInfo.title}
+ {isChecking ? 'Connecting to Nova gateway...' : errorInfo.title}
{!isChecking ? (
<>
diff --git a/src/screens/chat/components/research-card.tsx b/src/screens/chat/components/research-card.tsx
index 37202ec397..3de1e983bc 100644
--- a/src/screens/chat/components/research-card.tsx
+++ b/src/screens/chat/components/research-card.tsx
@@ -1,4 +1,4 @@
-// Stub — research card is not used in Hermes Workspace
+// Stub — research card is not used in Nova Mission Control
import type { UseResearchCardResult } from '@/hooks/use-research-card'
export function ResearchCard(_props: { researchCard: UseResearchCardResult }) {
diff --git a/src/screens/chat/components/tui-activity-card.tsx b/src/screens/chat/components/tui-activity-card.tsx
index bd0eb62df8..f04d40ed8e 100644
--- a/src/screens/chat/components/tui-activity-card.tsx
+++ b/src/screens/chat/components/tui-activity-card.tsx
@@ -1,4 +1,4 @@
-import { memo, useEffect, useMemo, useState } from 'react'
+import { memo, useEffect, useMemo, useState } from 'react'
import { cn } from '@/lib/utils'
/**
@@ -7,14 +7,14 @@ import { cn } from '@/lib/utils'
* Renders thinking + all tool calls as a single card above the assistant
* message bubble. Rows mimic Claude Code / Codex CLI tool output:
*
- * 💭 Thinking 4s
- * ⎿ Looking at chat component…
- * ● Read message-item.tsx
- * ⎿ 1240 lines
- * ● Edit message-item.tsx
- * ⎿ 2 changes
- * ○ exec pnpm build
- * ⎿ running…
+ * 💠Thinking 4s
+ * ⎿ Looking at chat component…
+ * â— Read message-item.tsx
+ * ⎿ 1240 lines
+ * â— Edit message-item.tsx
+ * ⎿ 2 changes
+ * â—‹ exec pnpm build
+ * ⎿ running…
*/
export type TuiToolSection = {
@@ -47,10 +47,10 @@ function statusDot(
state: TuiToolSection['state'],
isStreamingActive: boolean,
): string {
- if (state === 'output-error') return '✗'
- if (state === 'output-available') return '●'
+ if (state === 'output-error') return '✗'
+ if (state === 'output-available') return 'â—'
// input-available / input-streaming = pending
- return isStreamingActive ? '○' : '●'
+ return isStreamingActive ? 'â—‹' : 'â—'
}
function statusColor(
@@ -71,7 +71,7 @@ function summarizeOutput(text: string, maxLen = 120): string {
const firstLine = trimmed.split('\n').find((line) => line.trim()) ?? ''
const compact = firstLine.replace(/\s+/g, ' ').trim()
if (compact.length <= maxLen) return compact
- return `${compact.slice(0, maxLen - 1)}…`
+ return `${compact.slice(0, maxLen - 1)}…`
}
function formatElapsed(seconds: number): string {
@@ -119,12 +119,12 @@ function ToolRow({
const arg = formatArg(section.type, section.input)
const argLabel = section.preview ?? arg ?? null
const argTruncated =
- argLabel && argLabel.length > 60 ? `${argLabel.slice(0, 57)}…` : argLabel
+ argLabel && argLabel.length > 60 ? `${argLabel.slice(0, 57)}…` : argLabel
const outputText = section.outputText || section.errorText || ''
const outputSummary = isPending
? isStreamingActive
- ? 'running…'
+ ? 'running…'
: 'pending'
: summarizeOutput(outputText) || (isDone ? 'done' : 'failed')
@@ -184,16 +184,16 @@ function ToolRow({
className="shrink-0 text-[10px] opacity-40"
style={{ color: 'var(--theme-muted)' }}
>
- {open ? '▾' : '▸'}
+ {open ? 'â–¾' : 'â–¸'}
) : null}
- {/* Output preview line — TUI-style ⎿ */}
+ {/* Output preview line — TUI-style ⎿ */}
- ⎿
+ ⎿
{outputSummary}
{open && canExpand ? (
@@ -266,7 +266,7 @@ function ThinkingRow({
if (expandAll) setOpen(true)
}, [expandAll])
- const summary = summarizeOutput(thinking) || 'thinking…'
+ const summary = summarizeOutput(thinking) || 'thinking…'
return (
@@ -275,7 +275,7 @@ function ThinkingRow({
onClick={() => setOpen((v) => !v)}
className="group flex w-full items-baseline gap-2 px-3 py-1.5 text-left rounded-sm hover:bg-[color-mix(in_srgb,var(--theme-accent)_8%,transparent)]"
>
-
💭
+
ðŸ’
- {open ? '▾' : '▸'}
+ {open ? 'â–¾' : 'â–¸'}
- ⎿
+ ⎿
{summary}
{open ? (
@@ -346,9 +346,9 @@ function TuiActivityCardComponent({
).length
const done = total - errors - running
- if (errors > 0) return `${errors} failed · ${done} done`
- if (running > 0) return `${running} running · ${done} done`
- return `${total} ${total === 1 ? 'tool' : 'tools'} · done`
+ if (errors > 0) return `${errors} failed · ${done} done`
+ if (running > 0) return `${running} running · ${done} done`
+ return `${total} ${total === 1 ? 'tool' : 'tools'} · done`
}, [toolSections, hasTools])
const summaryColor =
@@ -360,7 +360,7 @@ function TuiActivityCardComponent({
// During streaming with nothing to show yet, render a minimal "working" stub
// so we don't pretend the agent is thinking when no thinking text was emitted.
- // (Hermes Agent currently emits tool.completed only after the run, not live.)
+ // (Nova gateway currently emits tool.completed only after the run, not live.)
const isWorkingStub = !hasThinking && !hasTools && isStreaming
if (!hasThinking && !hasTools && !isWorkingStub) return null
@@ -387,7 +387,7 @@ function TuiActivityCardComponent({
className="font-mono text-[10px] font-semibold uppercase tracking-[0.14em]"
style={{ color: 'var(--theme-muted)' }}
>
- {isStreaming ? '⚡ Working' : 'Activity'}
+ {isStreaming ? 'âš¡ Working' : 'Activity'}
{summary ? (
@@ -433,7 +433,7 @@ function TuiActivityCardComponent({
className="size-1.5 rounded-full animate-pulse"
style={{ background: 'var(--theme-accent, #6366f1)' }}
/>
-
working…
+
working…
tool activity will appear after the run
diff --git a/src/screens/chat/hooks/use-realtime-chat-history.ts b/src/screens/chat/hooks/use-realtime-chat-history.ts
index 1d42d0d9fd..5ecb202e74 100644
--- a/src/screens/chat/hooks/use-realtime-chat-history.ts
+++ b/src/screens/chat/hooks/use-realtime-chat-history.ts
@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { useChatStream } from '../../../hooks/use-chat-stream'
import { useChatStore } from '../../../stores/chat-store'
@@ -27,8 +27,8 @@ function readClientId(message: ChatMessage): string {
*
* Uses a multi-field strategy because different server versions / channel
* adapters shape the SSE payload differently:
- * • Modern format: content: [{type:'text', text:'...'}]
- * • Legacy format: text: '...' | body: '...' | message: '...'
+ * • Modern format: content: [{type:'text', text:'...'}]
+ * • Legacy format: text: '...' | body: '...' | message: '...'
*
* textFromMessage() only reads the content-array format, so using it alone
* causes the dedup to miss echoes that carry a top-level `text` field,
@@ -188,7 +188,7 @@ export function useRealtimeChatHistory({
onUserMessage: useCallback(
(message: ChatMessage, source?: string) => {
// Filter internal system messages (pre-compaction flushes, heartbeat
- // prompts, subagent announcements) — these should never appear in the
+ // prompts, subagent announcements) — these should never appear in the
// chat UI. The chat-store has its own filter, but this callback
// also appends directly to the query cache via appendHistoryMessage,
// bypassing the store filter entirely.
@@ -216,14 +216,14 @@ export function useRealtimeChatHistory({
if (effectiveSessionKey && effectiveSessionKey !== 'new') {
// Early-exit dedup: if the SSE echo has no clientId AND its text
// content (or attachment signature) matches an existing optimistic
- // user message in the cache, skip the append — the optimistic entry
+ // user message in the cache, skip the append — the optimistic entry
// is already displayed.
//
// Bug: previous implementation used textFromMessage() which only
// reads from the content-array format. Some server / channel
// adapters echo the message with a top-level `text` or `body` field
// instead, causing extractUserMessageText() to return '' and the
- // dedup guard to be skipped — resulting in a duplicate user message.
+ // dedup guard to be skipped — resulting in a duplicate user message.
//
// Fix: use extractUserMessageText() which checks both the
// content-array AND legacy top-level text/body/message fields.
@@ -265,7 +265,7 @@ export function useRealtimeChatHistory({
return false
})
if (hasOptimistic) {
- // The optimistic message is already displayed — skip SSE echo
+ // The optimistic message is already displayed — skip SSE echo
onUserMessage?.(message, source)
return
}
@@ -314,7 +314,7 @@ export function useRealtimeChatHistory({
effectiveSessionKey === 'new'
) {
setLastCompletedRunAt(Date.now())
- // Refetch history after generation completes — keeps chat in sync
+ // Refetch history after generation completes — keeps chat in sync
if (effectiveSessionKey && effectiveSessionKey !== 'new') {
const key = chatQueryKeys.history(
effectiveFriendlyId,
@@ -335,8 +335,8 @@ export function useRealtimeChatHistory({
// Issue #441 fix: Directly merge realtime buffer into history cache
// INSTEAD of invalidateQueries. The old approach caused a race:
- // invalidateQueries → refetch (async) → merge runs with stale data
- // → duplicates appear briefly → refetch completes → fixed.
+ // invalidateQueries → refetch (async) → merge runs with stale data
+ // → duplicates appear briefly → refetch completes → fixed.
//
// New approach: merge realtime messages into the cache synchronously,
// then clear the realtime buffer in the same tick. A background
@@ -398,11 +398,11 @@ export function useRealtimeChatHistory({
})()
: null
- // Clear realtime buffer immediately — no more stale data in render
+ // Clear realtime buffer immediately — no more stale data in render
store.clearRealtimeBuffer(effectiveSessionKey)
clearCompletedStreaming()
- // Background refetch for long-term consistency — doesn't block render
+ // Background refetch for long-term consistency — doesn't block render
queryClient.invalidateQueries({ queryKey: key, refetchType: 'all' }).then(() => {
// Re-inject the completed assistant message if compaction dropped it
if (completedAssistant) {
@@ -431,7 +431,7 @@ export function useRealtimeChatHistory({
reInjectOptimistic()
})
- // Check for compaction — significant message count drop
+ // Check for compaction — significant message count drop
const newData =
queryClient.getQueryData
>(key)
const newCount =
@@ -443,10 +443,10 @@ export function useRealtimeChatHistory({
) {
onCompactionEnd?.()
toast(
- 'Context compacted — older messages were summarized to free up space',
+ 'Context compacted — older messages were summarized to free up space',
{
type: 'info',
- icon: '🗜️',
+ icon: '🗜ï¸',
duration: 8000,
},
)
@@ -491,7 +491,7 @@ export function useRealtimeChatHistory({
(s) => s.realtimeMessages.get(effectiveSessionKey) ?? EMPTY_MESSAGES,
)
- // Subscribe directly to streaming state — useMemo with stable fn ref was stale (bug #1)
+ // Subscribe directly to streaming state — useMemo with stable fn ref was stale (bug #1)
const streamingState = useChatStore(
(s) => s.streamingState.get(effectiveSessionKey) ?? null,
)
@@ -514,7 +514,7 @@ export function useRealtimeChatHistory({
if (startedNewStream) {
clearCompletedStreaming()
}
- // Streaming just completed — capture final text so the message stays
+ // Streaming just completed — capture final text so the message stays
// visible during the handoff from streaming placeholder to history message.
// The stub useChatStream never fires onDone, so this is the only path.
if (prev && prev.text && !streamingState) {
@@ -540,7 +540,7 @@ export function useRealtimeChatHistory({
persistPortableHistory(mergedMessages)
}, [mergedMessages, portableMode])
- // History has caught up — cleanup realtime buffer outside render
+ // History has caught up — cleanup realtime buffer outside render
// DISABLED: This was aggressively clearing realtime messages before history
// caught up, causing the "message appears then disappears" bug.
// TODO: Re-enable with smarter timing (e.g. only after history confirms the message)
@@ -575,9 +575,9 @@ export function useRealtimeChatHistory({
.join('\n')
.toLowerCase()
- // Only trigger on Hermes Agent's actual mid-compaction signal.
+ // Only trigger on Nova gateway's actual mid-compaction signal.
// "pre-compaction memory flush" and "store durable memories now" are routine
- // heartbeat messages — do NOT match those here.
+ // heartbeat messages — do NOT match those here.
if (!textCandidates.includes('compacting context')) return
const signal = `${latest.role ?? ''}:${textCandidates}`
@@ -586,15 +586,15 @@ export function useRealtimeChatHistory({
onCompactionStart()
}, [onCompactionStart, realtimeMessages])
- // Periodic history sync — catch missed messages every 30s
+ // Periodic history sync — catch missed messages every 30s
// Skip during active streaming to prevent race conditions
useEffect(() => {
if (!effectiveSessionKey || effectiveSessionKey === 'new' || !enabled)
return
syncIntervalRef.current = setInterval(() => {
- // Don't poll during active streaming — causes flicker/overwrites
+ // Don't poll during active streaming — causes flicker/overwrites
if (streamingStateRef.current !== null) return
- // Guard window: don't poll right after streaming clears — new stream
+ // Guard window: don't poll right after streaming clears — new stream
// may be starting and history API may return stale/incomplete data
if (Date.now() - lastStreamClearTimeRef.current < 3000) return
const key = chatQueryKeys.history(
diff --git a/src/screens/chat/hooks/use-streaming-message.ts b/src/screens/chat/hooks/use-streaming-message.ts
index 057a1810f0..2f30861afd 100644
--- a/src/screens/chat/hooks/use-streaming-message.ts
+++ b/src/screens/chat/hooks/use-streaming-message.ts
@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useRef, useState } from 'react'
+import { useCallback, useEffect, useRef, useState } from 'react'
import type { ChatAttachment, ChatMessage } from '../types'
import { readResolvedSessionHeaders } from '@/lib/send-stream-session-headers'
import { useChatStore } from '@/stores/chat-store'
@@ -9,7 +9,7 @@ import { pushActivity } from '@/components/inspector/activity-store'
* onSessionResolved (which navigates the route). Only bootstrap keys
* ("new", "main") should promote a backend-returned session ID to the
* Workspace route identity. Concrete sessions must never be overridden
- * by a backend-derived api-* ID — that causes session splits (#297).
+ * by a backend-derived api-* ID — that causes session splits (#297).
*/
export function shouldResolveStreamSession({
requestedSessionKey,
@@ -22,14 +22,14 @@ export function shouldResolveStreamSession({
resolvedSessionKey: string
pinMainSession?: boolean
}): boolean {
- // No change → nothing to resolve
+ // No change → nothing to resolve
if (resolvedSessionKey === currentSessionKey) return false
// "new" should resolve once to a concrete session.
if (requestedSessionKey === 'new') return true
// "main" only stays pinned when the current route is intentionally bound to
// the portable Workspace session in zero-fork mode.
if (requestedSessionKey === 'main') return !pinMainSession
- // Concrete session → never promote a different backend ID
+ // Concrete session → never promote a different backend ID
return false
}
@@ -392,7 +392,7 @@ export function useStreamingMessage(options: UseStreamingMessageOptions = {}) {
lifecyclePhaseRef.current = 'complete'
clearHandoffTimer()
// Delay runId unregistration so chat-events dedup continues filtering
- // for a few seconds after completion — prevents late duplicate messages
+ // for a few seconds after completion — prevents late duplicate messages
if (delayedUnregisterTimerRef.current) {
clearTimeout(delayedUnregisterTimerRef.current)
delayedUnregisterTimerRef.current = null
@@ -440,7 +440,7 @@ export function useStreamingMessage(options: UseStreamingMessageOptions = {}) {
const payload = data as Record
// [DEBUG TUI] Log every SSE event so we can see whether tool.* events arrive
- // from Hermes Agent through Workspace. Toggle off by setting
+ // from Nova gateway through Workspace. Toggle off by setting
// localStorage.removeItem('hermes:debug:sse')
if (
typeof window !== 'undefined' &&
@@ -643,13 +643,13 @@ export function useStreamingMessage(options: UseStreamingMessageOptions = {}) {
pushActivity({
type: 'artifact',
time: new Date().toLocaleTimeString(),
- text: path ? `${title} — ${path}` : title,
+ text: path ? `${title} — ${path}` : title,
})
processStoreEvent({
type: 'tool',
phase: 'complete',
name: `artifact:${kind}`,
- result: path ? `${title} — ${path}` : title,
+ result: path ? `${title} — ${path}` : title,
preview:
typeof payload.preview === 'string' && payload.preview.trim()
? payload.preview.trim()
@@ -770,7 +770,7 @@ export function useStreamingMessage(options: UseStreamingMessageOptions = {}) {
) {
transitionToHandoff()
} else {
- markFailed('Hermes Agent connection closed')
+ markFailed('Nova gateway connection closed')
}
break
}
@@ -893,7 +893,7 @@ export function useStreamingMessage(options: UseStreamingMessageOptions = {}) {
if (resolvedSessionKey !== activeSessionKeyRef.current) {
// Only promote a backend-returned session ID when the original
// request was a bootstrap key ("new"/"main"). Concrete Workspace
- // sessions must never be overridden — that causes splits (#297).
+ // sessions must never be overridden — that causes splits (#297).
if (
shouldResolveStreamSession({
requestedSessionKey: params.sessionKey,
@@ -913,8 +913,8 @@ export function useStreamingMessage(options: UseStreamingMessageOptions = {}) {
markAccepted()
schedulePostAcceptanceTimeout('accepted')
- // HTTP 200 — message accepted by Hermes Agent. Clear optimistic "sending"
- // status so the Retry timer never fires. Hermes Agent does NOT echo
+ // HTTP 200 — message accepted by Nova gateway. Clear optimistic "sending"
+ // status so the Retry timer never fires. Nova gateway does NOT echo
// user messages via SSE, so this is the only confirmation we get.
if (params.idempotencyKey && onMessageAccepted) {
onMessageAccepted(
@@ -958,7 +958,7 @@ export function useStreamingMessage(options: UseStreamingMessageOptions = {}) {
for (const eventBlock of events) {
if (!eventBlock.trim()) continue
- // Re-check between events as well — a single read() can yield a
+ // Re-check between events as well — a single read() can yield a
// batch of buffered events; if a new stream started mid-batch,
// the rest of this batch must be dropped.
if (streamGenerationRef.current !== myGeneration) break
@@ -991,14 +991,14 @@ export function useStreamingMessage(options: UseStreamingMessageOptions = {}) {
// If the stream ended cleanly (no 'done' event) but we never received
// any response text, treat it as a failure rather than a successful
// empty completion. This happens when a proxy (e.g., Tailscale Serve)
- // closes the connection after an idle timeout — the reader returns
+ // closes the connection after an idle timeout — the reader returns
// { done: true } but the model was still generating. Fixes #512.
if (
!fullTextRef.current &&
(lifecyclePhase === 'accepted' || lifecyclePhase === 'active')
) {
markFailed(
- 'Connection closed before response was received. The backend may still be processing — check server logs or retry.',
+ 'Connection closed before response was received. The backend may still be processing — check server logs or retry.',
)
} else {
finishStream()
diff --git a/src/screens/chat/utils.ts b/src/screens/chat/utils.ts
index a329003c55..3c6b54e73a 100644
--- a/src/screens/chat/utils.ts
+++ b/src/screens/chat/utils.ts
@@ -1,4 +1,4 @@
-import type {
+import type {
ChatMessage,
SessionMeta,
SessionSummary,
@@ -286,7 +286,7 @@ export async function readError(res: Response): Promise {
}
export const missingAuthMessage =
- 'Hermes Agent connection failed. Make sure Hermes Agent is running and HERMES_API_URL is set correctly.'
+ 'Nova gateway connection failed. Make sure Nova gateway is running and HERMES_API_URL is set correctly.'
export function isMissingAuth(message: string): boolean {
return message.includes(missingAuthMessage)
diff --git a/src/screens/dashboard/components/active-model-kpi.tsx b/src/screens/dashboard/components/active-model-kpi.tsx
index 9e337a3434..328bd01a51 100644
--- a/src/screens/dashboard/components/active-model-kpi.tsx
+++ b/src/screens/dashboard/components/active-model-kpi.tsx
@@ -1,4 +1,4 @@
-import { formatModelName } from '@/screens/dashboard/lib/formatters'
+import { formatModelName } from '@/screens/dashboard/lib/formatters'
import type { DashboardOverview } from '@/server/dashboard-aggregator'
function formatCount(n: number): string {
@@ -26,11 +26,11 @@ export function ActiveModelKpi({
analytics: DashboardOverview['analytics']
}) {
const connected = !!modelInfo
- const display = modelInfo ? formatModelName(modelInfo.model) : '—'
- const provider = modelInfo?.provider ?? '—'
+ const display = modelInfo ? formatModelName(modelInfo.model) : '—'
+ const provider = modelInfo?.provider ?? '—'
// Routing share (proxy): % of calls in the analytics window that hit
- // the active model. Hermes Agent confirmed this is the closest
+ // the active model. Nova gateway confirmed this is the closest
// available metric without a dedicated routing-decisions endpoint.
const share = ((): number | null => {
if (!modelInfo || !analytics) return null
@@ -120,7 +120,7 @@ export function ActiveModelKpi({
>
{provider}
{sessionsForModel !== null
- ? ` · ${formatCount(sessionsForModel)} sessions`
+ ? ` · ${formatCount(sessionsForModel)} sessions`
: ''}
{modelInfo?.effectiveContextLength ? (
diff --git a/src/screens/dashboard/components/agent-lanes-card.tsx b/src/screens/dashboard/components/agent-lanes-card.tsx
new file mode 100644
index 0000000000..bcf54d0a4e
--- /dev/null
+++ b/src/screens/dashboard/components/agent-lanes-card.tsx
@@ -0,0 +1,101 @@
+import { useQuery } from '@tanstack/react-query'
+import { STATUS_TONE } from '../lib/status-meta'
+import type {
+ AgentLane,
+ AgentLaneRegistry,
+} from '../../../server/agent-lane-registry'
+
+async function readLanes(): Promise {
+ const response = await fetch('/api/agent-lanes')
+ if (!response.ok) throw new Error(`Agent lanes read failed: ${response.status}`)
+ return (await response.json()) as AgentLaneRegistry
+}
+
+function formatTime(value: string | null): string {
+ if (!value) return '—'
+ const ts = Date.parse(value)
+ if (!Number.isFinite(ts)) return '—'
+ return new Date(ts).toLocaleString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ hour: 'numeric',
+ minute: '2-digit',
+ })
+}
+
+function LaneRow({ lane }: { lane: AgentLane }) {
+ const meta = STATUS_TONE(lane.status)
+ return (
+
+
+
+
+
+ {lane.label}
+
+ {lane.role === 'control-tower' ? (
+
+ tower
+
+ ) : null}
+
+
+ {lane.detail}
+
+
+
+
+ {meta.label}
+
+
+
+ {lane.model ? model {lane.model} : null}
+ {lane.workspace ? ws {lane.workspace} : null}
+ {lane.activeTask ? task {lane.activeTask} : null}
+ beat {formatTime(lane.heartbeatAt)}
+
+ {lane.blocker ? (
+
+ ⚠ {lane.blocker}
+
+ ) : null}
+
+ )
+}
+
+export function AgentLanesCard() {
+ const query = useQuery({
+ queryKey: ['agent-lanes'],
+ queryFn: readLanes,
+ refetchInterval: 45_000,
+ staleTime: 20_000,
+ })
+ const registry = query.data ?? null
+
+ return (
+
+
+ Agent Lanes
+
+
+ Who is working, on real evidence
+
+
+ Read-only detection from session files, processes, the handoff bus,
+ and swarm missions. No lane is ever invented.
+
+ {query.isError ? (
+
+ Lane registry endpoint unreachable.
+
+ ) : null}
+ {registry ? (
+
+ {registry.lanes.map((lane) => (
+
+ ))}
+
+ ) : null}
+
+ )
+}
diff --git a/src/screens/dashboard/components/agent-workforce-card.tsx b/src/screens/dashboard/components/agent-workforce-card.tsx
new file mode 100644
index 0000000000..65ade41628
--- /dev/null
+++ b/src/screens/dashboard/components/agent-workforce-card.tsx
@@ -0,0 +1,152 @@
+import { STATUS_TONE } from '../lib/status-meta'
+import type {
+ AgentWorkerStatus,
+ DashboardAgentWorker,
+ DashboardAgentWorkforceSection,
+} from '@/server/dashboard-aggregator'
+
+const WORKER_STATUS_MAP: Record = {
+ idle: 'idle',
+ queued: 'setup-needed',
+ running: 'active',
+ blocked: 'offline',
+ reviewing: 'degraded',
+ done: 'not-wired',
+}
+
+function formatTime(value: string | null): string {
+ if (!value) return 'no heartbeat'
+ const ts = Date.parse(value)
+ if (!Number.isFinite(ts)) return 'unknown'
+ return new Date(ts).toLocaleString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ hour: 'numeric',
+ minute: '2-digit',
+ })
+}
+
+function WorkerRow({ worker }: { worker: DashboardAgentWorker }) {
+ const meta = STATUS_TONE(WORKER_STATUS_MAP[worker.status])
+ return (
+
+
+
+
+ {worker.workerId}
+
+
+ {worker.currentTask ?? 'No active assignment'}
+
+
+
+
+ {meta.label}
+
+
+
+
+
+
+
+
+
+ {worker.missionId ?? 'no mission'}
+ {formatTime(worker.lastSeenAt)}
+
+
+ )
+}
+
+function Metric({ label, value }: { label: string; value: number }) {
+ return (
+
+
+ {value}
+
+
+ {label}
+
+
+ )
+}
+
+export function AgentWorkforceCard({
+ workforce,
+}: {
+ workforce: DashboardAgentWorkforceSection | null
+}) {
+ if (!workforce) return null
+ return (
+
+
+
+
+
+
+ Agent Workforce
+
+
+ Swarm missions, read-only
+
+
+ Mirrors existing mission receipts. No dispatch, cancel, or review
+ actions from this card.
+
+
+
+
+
+
+
+
+
+
+ {workforce.workers.length > 0 ? (
+
+ {workforce.workers.slice(0, 8).map((worker) => (
+
+ ))}
+
+ ) : (
+
+ No swarm mission receipts yet. Agent work will appear here after a
+ mission is created or checkpointed.
+
+ )}
+
+ {workforce.recentMissions.length > 0 ? (
+
+
+ Recent missions
+
+
+ {workforce.recentMissions.slice(0, 3).map((mission) => (
+
+
+ {mission.title}
+
+
+ {mission.state} · {mission.assignments}
+
+
+ ))}
+
+
+ ) : null}
+
+
+ )
+}
diff --git a/src/screens/dashboard/components/attention-marquee.tsx b/src/screens/dashboard/components/attention-marquee.tsx
index 1dd3de9db8..f5f40427c8 100644
--- a/src/screens/dashboard/components/attention-marquee.tsx
+++ b/src/screens/dashboard/components/attention-marquee.tsx
@@ -5,11 +5,12 @@ import type {
} from '@/server/dashboard-aggregator'
const SOURCE_GLYPH: Record = {
- cron: '⏰',
- platform: '🔌',
- log: '📜',
- config: '⚙️',
- gateway: '🛰️',
+ cron: 'clock',
+ kanban: 'board',
+ platform: 'link',
+ log: 'log',
+ config: 'cfg',
+ gateway: 'gate',
}
const SEVERITY_COLOR: Record = {
@@ -18,18 +19,25 @@ const SEVERITY_COLOR: Record = {
info: 'var(--theme-muted)',
}
+function compactDetail(item: DashboardIncident): string {
+ const detail = item.detail.trim()
+ if (!detail) return ''
+
+ const lower = detail.toLowerCase()
+ if (
+ item.source === 'cron' &&
+ lower.includes('global inference config drifted')
+ ) {
+ return 'config drift blocked scheduled run; pin model/provider in Jobs'
+ }
+
+ if (detail.length <= 96) return detail
+ return `${detail.slice(0, 92).trim()}...`
+}
+
/**
- * Right-to-left marquee that surfaces the same `incidents[]` payload
- * the legacy `AttentionCard` used to render. Lives inside `OpsStrip`
- * so attention items occupy the same horizontal "10-second status
- * read" line operators already glance at.
- *
- * Behavior:
- * - Hidden when there are no incidents (no empty marquee row).
- * - Clones the list once so the loop animation stitches seamlessly.
- * - Pauses on hover so the operator can read a long item.
- * - Each item is a button that routes to the most context-appropriate
- * page (cron → /jobs, config → /settings, log/gateway → /logs).
+ * Compact incident ticker. It keeps the real incident list clickable while
+ * trimming verbose backend errors into a readable operator summary.
*/
export function AttentionMarquee({
overview,
@@ -61,16 +69,14 @@ export function AttentionMarquee({
color: 'var(--theme-warning)',
}}
>
- ⚠️ Attention · {items.length}
+ Attention · {items.length}
- {/* Fade mask on right edge for "ticker continues" feel. */}
@@ -78,9 +84,7 @@ export function AttentionMarquee({
className="flex min-w-0 flex-1 overflow-hidden whitespace-nowrap"
style={{ maskImage: 'linear-gradient(90deg, black 96%, transparent)' }}
>
-
+
{tracks.map((item, idx) => {
const handleClick = () => {
if (item.href) {
@@ -99,6 +103,7 @@ export function AttentionMarquee({
navigate({ to: '/settings', search: {} })
else navigate({ to: '/jobs' })
}
+ const detail = compactDetail(item)
return (
-
- {SOURCE_GLYPH[item.source] ?? '•'}
-
-
- {item.label}
+
+ {SOURCE_GLYPH[item.source]}
- {item.detail ? (
+ {item.label}
+ {detail ? (
- · {item.detail}
+ · {detail}
) : null}
diff --git a/src/screens/dashboard/components/control-loops-card.tsx b/src/screens/dashboard/components/control-loops-card.tsx
new file mode 100644
index 0000000000..db4a602b7d
--- /dev/null
+++ b/src/screens/dashboard/components/control-loops-card.tsx
@@ -0,0 +1,346 @@
+import { useMemo, useState } from 'react'
+import { STATUS_TONE } from '../lib/status-meta'
+import type {
+ ControlLoopSourceStatus,
+ ControlLoopStatus,
+ DashboardControlLoop,
+ DashboardControlLoopsSection,
+} from '@/server/dashboard-aggregator'
+import type {
+ ControlLoopRunResult,
+ RunnableControlLoopId,
+} from '@/server/control-loop-runner'
+
+const RUNNABLE_LOOP_IDS: ReadonlySet
= new Set([
+ 'cost-route-watch',
+ 'morning-command-center',
+ 'jobboard-caretaker',
+])
+
+type RunResponse = {
+ ok: boolean
+ result: ControlLoopRunResult
+ receiptId: string | null
+}
+
+function loopStatusPillClass(status: ControlLoopStatus): string {
+ const meta = STATUS_TONE(status)
+ return `${meta.tone} bg-[color-mix(in_srgb,var(--theme-border)_0%,transparent)]`
+}
+
+const SOURCE_STATUS_MAP: Record = {
+ connected: 'operational',
+ available: 'setup-needed',
+ 'not-wired': 'not-wired',
+ unavailable: 'offline',
+}
+
+function formatStatus(status: ControlLoopStatus): string {
+ if (status === 'not-wired') return 'not wired'
+ return status
+}
+
+function formatTime(value: string | null): string {
+ if (!value) return 'not scheduled'
+ const ts = Date.parse(value)
+ if (!Number.isFinite(ts)) return 'unknown'
+ return new Date(ts).toLocaleString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ hour: 'numeric',
+ minute: '2-digit',
+ })
+}
+
+function StatusBadge({ status }: { status: ControlLoopStatus }) {
+ return (
+
+ {formatStatus(status)}
+
+ )
+}
+
+function LoopCard({
+ loop,
+ active,
+ onDryRun,
+}: {
+ loop: DashboardControlLoop
+ active: boolean
+ onDryRun: (loop: DashboardControlLoop) => void
+}) {
+ return (
+
+
+
+
+ {loop.label}
+
+
+ {loop.purpose}
+
+
+
+
+
+
+
+
+ readiness
+
+
+ {loop.readiness}
+
+
+
+
+ next
+
+
+ {formatTime(loop.nextScheduledAt)}
+
+
+
+
+
+ {loop.connectedSystems.map((system) => (
+
+
+ {system.label}
+
+ ))}
+
+
+
+
onDryRun(loop)}
+ className="rounded-lg border border-[var(--theme-accent-border)] bg-[var(--theme-accent-subtle)] px-3 py-1.5 font-mono text-[10px] font-semibold uppercase tracking-[0.12em] text-[var(--theme-accent-secondary)] transition-colors hover:bg-[color-mix(in_srgb,var(--theme-accent)_16%,transparent)]"
+ >
+ dry run
+
+
+ open chat
+
+ {active ? (
+
+ prompt ready
+
+ ) : null}
+
+
+ )
+}
+
+export function ControlLoopsCard({
+ controlLoops,
+}: {
+ controlLoops: DashboardControlLoopsSection | null
+}) {
+ const [activeLoopId, setActiveLoopId] = useState(null)
+ const [runState, setRunState] = useState<
+ | { phase: 'idle' }
+ | { phase: 'running'; loopId: string }
+ | { phase: 'done'; loopId: string; response: RunResponse }
+ | { phase: 'error'; loopId: string; message: string }
+ >({ phase: 'idle' })
+ const activeLoop = useMemo(
+ () => controlLoops?.loops.find((loop) => loop.id === activeLoopId) ?? null,
+ [activeLoopId, controlLoops?.loops],
+ )
+
+ if (!controlLoops) return null
+
+ async function handleDryRun(loop: DashboardControlLoop) {
+ setActiveLoopId(loop.id)
+ if (RUNNABLE_LOOP_IDS.has(loop.id)) {
+ // Real server-side dry run: gathers live sources, writes a fabric
+ // receipt, and refuses every external write without approval.
+ setRunState({ phase: 'running', loopId: loop.id })
+ try {
+ const response = await fetch('/api/control-loops/run', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ loopId: loop.id as RunnableControlLoopId }),
+ })
+ if (!response.ok) throw new Error(`run failed: ${response.status}`)
+ const payload = (await response.json()) as RunResponse
+ setRunState({ phase: 'done', loopId: loop.id, response: payload })
+ } catch (error) {
+ setRunState({
+ phase: 'error',
+ loopId: loop.id,
+ message: error instanceof Error ? error.message : 'run failed',
+ })
+ }
+ return
+ }
+ setRunState({ phase: 'idle' })
+ try {
+ await navigator.clipboard.writeText(loop.dryRunPrompt)
+ } catch {
+ // Clipboard can be blocked on non-secure origins; the prompt still renders.
+ }
+ }
+
+ return (
+
+
+
+
+
+
+
+ Control Loops
+
+
+ Operational loops, dry-run gated
+
+
+ Reads Hermes, job board, OAuth, analytics, cron, and vault
+ signals. It does not duplicate source-of-truth state.
+
+
+
+
+
+ {controlLoops.summary.ready}
+
+
+ ready
+
+
+
+
+ {controlLoops.summary.partial}
+
+
+ partial
+
+
+
+
+ {controlLoops.summary.notWired}
+
+
+ unwired
+
+
+
+
+
+
+ {controlLoops.loops.map((loop) => (
+
+ ))}
+
+
+ {runState.phase === 'running' ? (
+
+ Running {runState.loopId} against live sources…
+
+ ) : null}
+ {runState.phase === 'error' ? (
+
+ {runState.loopId}: {runState.message}
+
+ ) : null}
+ {runState.phase === 'done' ? (
+
+
+
+ {runState.response.result.loopId} · dry run{' '}
+ {runState.response.result.ok ? 'completed' : 'degraded'}
+
+ {runState.response.receiptId ? (
+
+ receipt {runState.response.receiptId.slice(0, 14)}…
+
+ ) : (
+
+ no receipt written
+
+ )}
+
+
+ {runState.response.result.summary}
+
+
+ {runState.response.result.findings.map((finding) => (
+
+ • {finding}
+
+ ))}
+
+ {runState.response.result.refusals.length > 0 ? (
+
+
+ Refused without Taylor approval
+
+
+ {runState.response.result.refusals.map((refusal) => (
+
+ {refusal}
+
+ ))}
+
+
+ ) : null}
+
+ ) : null}
+ {activeLoop && runState.phase === 'idle' ? (
+
+
+ Dry-run prompt copied when allowed
+
+
+ {activeLoop.dryRunPrompt}
+
+
+ ) : null}
+
+
+
+ Risk gates
+
+
+ {controlLoops.riskGates.map((gate) => (
+
+ {gate}
+
+ ))}
+
+
+
+
+ )
+}
diff --git a/src/screens/dashboard/components/daily-check-card.tsx b/src/screens/dashboard/components/daily-check-card.tsx
new file mode 100644
index 0000000000..15f6bb58aa
--- /dev/null
+++ b/src/screens/dashboard/components/daily-check-card.tsx
@@ -0,0 +1,67 @@
+import { novaDailyCheck } from '@/lib/nova-daily-check-adapter'
+
+export function DailyCheckCard() {
+ return (
+
+
+
+
Daily check
+
+ SoulSync
+
+
+
+ {novaDailyCheck.items.map((item) => (
+
+
{item.label}
+
+ {item.checked ? '[x]' : '[ ]'}
+
+
+ ))}
+
+
+ Overthinking
+
+
+ {novaDailyCheck.overthinking}
+
+
+
+
+
+
Mood
+
+ {novaDailyCheck.mood}
+
+
+
+
Reminder
+
+ {novaDailyCheck.reminder}
+
+
+
+
+
+
Log stream
+
+ {novaDailyCheck.log.map((entry) => (
+
+ {entry.date}
+
+ {entry.text}
+
+
+ ))}
+
+
+
+ )
+}
diff --git a/src/screens/dashboard/components/git-work-card.tsx b/src/screens/dashboard/components/git-work-card.tsx
new file mode 100644
index 0000000000..8ca7aa76de
--- /dev/null
+++ b/src/screens/dashboard/components/git-work-card.tsx
@@ -0,0 +1,129 @@
+import type { DashboardGitWorkSection } from '@/server/dashboard-aggregator'
+
+function Stat({ label, value }: { label: string; value: string | number }) {
+ return (
+
+
+ {value}
+
+
+ {label}
+
+
+ )
+}
+
+function RemoteRow({
+ name,
+ fetchUrl,
+ pushUrl,
+}: {
+ name: string
+ fetchUrl: string | null
+ pushUrl: string | null
+}) {
+ return (
+
+
+ {name}
+
+
+
fetch: {fetchUrl ?? 'missing'}
+
push: {pushUrl ?? 'missing'}
+
+
+ )
+}
+
+export function GitWorkCard({ gitWork }: { gitWork: DashboardGitWorkSection | null }) {
+ if (!gitWork) return null
+ const stateTone = gitWork.clean
+ ? 'text-[var(--theme-success)] border-[color-mix(in_srgb,var(--theme-success)_35%,var(--theme-border))]'
+ : 'text-[var(--theme-warning)] border-[color-mix(in_srgb,var(--theme-warning)_35%,var(--theme-border))]'
+
+ return (
+
+
+
+
+
+
+ GitHub / PR Work
+
+
+ Local branch status, read-only
+
+
+ Mirrors local git state and configured remotes. This card does not
+ push, open PRs, or mutate GitHub.
+
+
+
+ {gitWork.clean ? 'clean' : `${gitWork.changedFiles} changed`}
+
+
+
+ {gitWork.warning ? (
+
+ {gitWork.warning}
+
+ ) : null}
+
+
+
+
+
+
+
+
+
+
+ Current branch
+
+
+ {gitWork.branch ?? 'unknown branch'}
+
+
+ upstream: {gitWork.upstream ?? 'not configured'}
+
+ {gitWork.latestCommit ? (
+
+
+ {gitWork.latestCommit.hash}
+ {' '}
+
+ {gitWork.latestCommit.subject}
+
+
+ ) : null}
+
+
+ {gitWork.prUrl ? (
+
+ Open linked PR receipt
+
+ ) : null}
+
+ {gitWork.remotes.length > 0 ? (
+
+ {gitWork.remotes.slice(0, 4).map((remote) => (
+
+ ))}
+
+ ) : null}
+
+
+ )
+}
diff --git a/src/screens/dashboard/components/home-mode-card.tsx b/src/screens/dashboard/components/home-mode-card.tsx
new file mode 100644
index 0000000000..80fda24396
--- /dev/null
+++ b/src/screens/dashboard/components/home-mode-card.tsx
@@ -0,0 +1,133 @@
+import { useEffect, useMemo, useState } from 'react'
+import type { HomeModeContext } from '@/lib/home-mode'
+import { classifyHomeMode } from '@/lib/home-mode'
+
+const LANE_LABELS: Record = {
+ morning_ramp_up: 'Morning ramp-up',
+ work_block: 'Work block',
+ transition_home: 'Transition home',
+ family_evening: 'Family / evening',
+ light_creative_planning: 'Light creative',
+ late_night_danger_zone: 'Late-night guardrail',
+ weekend_reset: 'Weekend / reset',
+ override_build_mode: 'Build mode',
+}
+
+const LANE_TONES: Record = {
+ morning_ramp_up: 'var(--theme-blue-secondary)',
+ work_block: 'var(--theme-accent)',
+ transition_home: 'var(--theme-warning)',
+ family_evening: 'var(--theme-success)',
+ light_creative_planning: 'var(--theme-blue-primary)',
+ late_night_danger_zone: 'var(--theme-danger)',
+ weekend_reset: 'var(--theme-success)',
+ override_build_mode: 'var(--theme-accent-secondary)',
+}
+
+function formatClock(value: string): string {
+ const match = value.match(/T(\d{2}):(\d{2})/)
+ if (!match) return value
+ const hour = Number(match[1])
+ const minute = match[2]
+ const suffix = hour >= 12 ? 'PM' : 'AM'
+ const displayHour = hour % 12 || 12
+ return `${displayHour}:${minute} ${suffix}`
+}
+
+export function HomeModeCard() {
+ const [now, setNow] = useState(() => new Date())
+
+ useEffect(() => {
+ const timer = window.setInterval(() => setNow(new Date()), 30_000)
+ return () => window.clearInterval(timer)
+ }, [])
+
+ const context = useMemo(
+ () =>
+ classifyHomeMode({
+ now,
+ timezone: 'America/Chicago',
+ location: 'West Texas',
+ preferences: {
+ timezone: 'America/Chicago',
+ location: 'West Texas',
+ defaultAfterWorkHomeProtection: true,
+ },
+ }),
+ [now],
+ )
+
+ const tone = LANE_TONES[context.lane]
+
+ return (
+
+
+
+
+
+
+
Home Mode
+
+
+ {LANE_LABELS[context.lane]}
+
+
+ {Math.round(context.confidence * 100)}%
+
+
+
+ {context.suggested_boundary}
+
+
+
+
+
+
Live clock
+
+ {formatClock(context.now_local)}
+
+
+
+
Intensity
+
+ {context.recommended_intensity}
+
+
+
+
+
+
+ {context.reason} Persistence is rules + memory + wakeups/app state, not
+ Nova constantly watching.
+
+
+ )
+}
diff --git a/src/screens/dashboard/components/hour-of-day-card.tsx b/src/screens/dashboard/components/hour-of-day-card.tsx
index 25d268d8c7..c465c5707f 100644
--- a/src/screens/dashboard/components/hour-of-day-card.tsx
+++ b/src/screens/dashboard/components/hour-of-day-card.tsx
@@ -1,16 +1,16 @@
-import { useMemo } from 'react'
+import { useMemo } from 'react'
/**
* Hour-of-day activity heatmap.
*
* Backend doesn't expose `activity.by_hour[]` from `/api/analytics/usage`
- * yet (the Hermes Agent confirmed it's computed internally but dropped
+ * yet (the Nova gateway confirmed it's computed internally but dropped
* at the API boundary). We rebuild it locally from the session list
- * the dashboard already has — every session row has `startedAt`, so we
+ * the dashboard already has — every session row has `startedAt`, so we
* bucket those into 24 hour-of-day cells.
*
- * Not a full date×hour heatmap; that's a true 7×24 grid we'll add when
- * the backend exposes it. This is the 1×24 strip rolled up across the
+ * Not a full date×hour heatmap; that's a true 7×24 grid we'll add when
+ * the backend exposes it. This is the 1×24 strip rolled up across the
* window which matches what InsightsEngine already returns server-side.
*/
export function HourOfDayCard({
@@ -62,7 +62,7 @@ export function HourOfDayCard({
className="font-mono text-[9px] uppercase tracking-[0.15em]"
style={{ color: 'var(--theme-muted)' }}
>
- peak {formatHour(peakHour)} · {total} sessions
+ peak {formatHour(peakHour)} · {total} sessions
@@ -84,7 +84,7 @@ export function HourOfDayCard({
height: count === 0 ? 4 : `${Math.max(8, heightPct)}%`,
minHeight: 4,
}}
- title={`${formatHour(hour)} · ${count} session${count === 1 ? '' : 's'}`}
+ title={`${formatHour(hour)} · ${count} session${count === 1 ? '' : 's'}`}
/>
)
})}
diff --git a/src/screens/dashboard/components/live-systems-card.tsx b/src/screens/dashboard/components/live-systems-card.tsx
new file mode 100644
index 0000000000..d64a9e8392
--- /dev/null
+++ b/src/screens/dashboard/components/live-systems-card.tsx
@@ -0,0 +1,175 @@
+import { STATUS_TONE } from '../lib/status-meta'
+import type {
+ DashboardLiveSystem,
+ DashboardLiveSystemsSection,
+ LiveSystemStatus,
+} from '@/server/dashboard-aggregator'
+
+function formatTime(value: string | null): string {
+ if (!value) return 'no heartbeat'
+ const ts = Date.parse(value)
+ if (!Number.isFinite(ts)) return 'unknown'
+ return new Date(ts).toLocaleString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ hour: 'numeric',
+ minute: '2-digit',
+ })
+}
+
+function StatusPill({ status }: { status: LiveSystemStatus }) {
+ const meta = STATUS_TONE(status)
+ return (
+
+
+ {meta.label}
+
+ )
+}
+
+function SystemRow({ system }: { system: DashboardLiveSystem }) {
+ const body = (
+
+
+
+
+ {system.label}
+
+
+ {system.detail}
+
+
+
+
+
+
+ {system.source}
+
+
+ {formatTime(system.lastSeenAt)}
+
+
+
+ )
+
+ if (!system.href) return body
+ return (
+
+ {body}
+
+ )
+}
+
+export function LiveSystemsCard({
+ liveSystems,
+}: {
+ liveSystems: DashboardLiveSystemsSection | null
+}) {
+ if (!liveSystems) return null
+ const critical = liveSystems.systems.filter(
+ (system) => system.status === 'offline' || system.status === 'degraded',
+ )
+ const visibleSystems = [
+ ...critical,
+ ...liveSystems.systems.filter(
+ (system) => system.status !== 'offline' && system.status !== 'degraded',
+ ),
+ ].slice(0, 10)
+
+ return (
+
+
+
+
+
+
+ Live Systems
+
+
+ Real source wiring map
+
+
+ Reads current source-of-truth endpoints only. No writes, no
+ secrets, no customer/vendor actions.
+
+
+
+
+
+ {liveSystems.summary.operational + liveSystems.summary.connected}
+
+
+ live
+
+
+
+
+ {liveSystems.summary.approvalGated}
+
+
+ needs T
+
+
+
+
+ {liveSystems.summary.degraded}
+
+
+ warn
+
+
+
+
+ {liveSystems.summary.offline}
+
+
+ down
+
+
+
+
+ {liveSystems.summary.notWired + liveSystems.summary.reachable}
+
+
+ unwired
+
+
+
+
+
+ {liveSystems.blockers.length > 0 ? (
+
+
+ Needs attention
+
+
+ {liveSystems.blockers.slice(0, 3).map((blocker) => (
+
+ {blocker}
+
+ ))}
+
+
+ ) : null}
+
+
+ {visibleSystems.map((system) => (
+
+ ))}
+
+
+
+ )
+}
diff --git a/src/screens/dashboard/components/mind-graph-card.tsx b/src/screens/dashboard/components/mind-graph-card.tsx
new file mode 100644
index 0000000000..8d50bac38f
--- /dev/null
+++ b/src/screens/dashboard/components/mind-graph-card.tsx
@@ -0,0 +1,1885 @@
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import * as THREE from 'three'
+import { GALAXY_PALETTE } from '../lib/nova-cockpit-theme'
+import {
+ armRankPlacement,
+ buildGalaxyModel,
+ clamp,
+ clusterHue,
+ emberSize,
+ focusBodyForNavigation,
+ focusDistanceForSystem,
+ folderTintFor,
+ isHub,
+ obsidianUri,
+ recencyGlow,
+ resolveProjectedLabels,
+ selectLabelCandidates,
+ shortTitle,
+} from './nova-galaxy-model'
+import type { VaultGraphInsights } from '../../../server/vault-graph-insights'
+import type {
+ CelestialBody,
+ GalaxyArm,
+ GalaxyModel,
+ KnowledgeGraphResponse,
+ PlanetarySystem,
+ ProjectedLabel,
+} from './nova-galaxy-model'
+
+type NebulaRegion = {
+ armId: string
+ color: string
+ position: { x: number; y: number; z: number }
+ scale: number
+ opacity: number
+}
+
+type Galaxy3DProps = {
+ model: GalaxyModel
+ bodyPlacements: Map
+ nebulaRegions: Array
+ selectedBody: CelestialBody | null
+ hoveredBody: CelestialBody | null
+ disabledArms: Set
+ searchTerm: string
+ thisWeekOnly: boolean
+ isLoading: boolean
+ onHover: (body: CelestialBody | null) => void
+ onSelect: (body: CelestialBody | null) => void
+}
+type CameraState = {
+ yaw: number
+ pitch: number
+ distance: number
+ target: THREE.Vector3
+}
+// Embers-only: every planet-kind body (including the core) renders as a
+// soft additive amber sprite instead of a textured sphere/ring/atmosphere
+// mesh trio. The core stacks three sprites (outer halo + inner glow +
+// cluster halo); every other planet gets its ember sprite plus a halo.
+type PlanetObject = {
+ body: CelestialBody
+ system: PlanetarySystem
+ sprites: Array
+ materials: Array
+ baseScales: Array
+ baseOpacities: Array
+}
+// Every note star — tag-kind or planet-kind — is a soft additive sprite
+// plus a soft tinted halo sprite. The galaxy IS the map: there is no
+// separate "marker mesh" tier for the smaller notes anymore.
+type TagObject = {
+ body: CelestialBody
+ sprite: THREE.Sprite
+ material: THREE.SpriteMaterial
+ halo: THREE.Sprite
+ haloMaterial: THREE.SpriteMaterial
+ baseScale: number
+}
+type CometObject = {
+ body: CelestialBody
+ group: THREE.Group
+ material: THREE.MeshBasicMaterial
+ tailMaterial: THREE.LineBasicMaterial
+}
+type LineObject = {
+ source: string
+ target: string
+ strength: number
+ material: THREE.LineDashedMaterial
+}
+
+const SPACE = '#030814'
+const SPACE_SOFT = '#071426'
+const AMBER = '#FF8C1A'
+const GOLD = '#FFB347'
+const TAN = '#D4A276'
+const COPPER = '#7A441E'
+// TILT_DEG=32 from the approved demo — steeper than the previous 16° pitch
+// so the disc reads as a tilted-plane spiral instead of a near-top-down view.
+const CAMERA_TILT_DEG = 32
+const CAMERA_TILT_RAD = (CAMERA_TILT_DEG * Math.PI) / 180
+// Distance tuned so the ~62-unit-radius spiral fills roughly 60% of frame
+// width at the 38° camera FOV (verified against the approved reference via
+// the screenshot verify-loop).
+const OVERVIEW_DISTANCE = 190
+const GLIDE_DURATION_MS = 600
+// Embers-only planet sizing: emberSize(degree) * ~2.2, matching the
+// approved dust-forward demo (no textured spheres/rings/atmospheres).
+const PLANET_EMBER_SCALE = 2.2
+// Every tag-kind note star — bigger per-star than the old marker-mesh era
+// so ~400 real notes still read as a living young galaxy even though they
+// are sparser than the old 19k-point procedural dust field (correctly so
+// — every point is now a real note, not decorative filler).
+const NOTE_EMBER_SCALE = 1.6
+const CORE_INNER_MULTIPLIER = 1.5
+const CORE_OUTER_MULTIPLIER = 2.7
+// Every note star gets a soft tinted halo riding behind it. Kept modest —
+// with hundreds of real notes bulging near the core on a dense arm, a
+// bigger/brighter halo stacks additively into a blown-out disc instead of
+// a readable cluster of individual stars.
+const HALO_SCALE_MULTIPLIER = 2
+const HALO_OPACITY = 0.09
+// Reference radius for the nebula-scale clamp only — matches
+// armRankPlacement's own coordinate space (radius runs 2.5 .. 2.5 +
+// ARM_SPIRAL_MAX_RADIUS(52) = 54.5).
+const GALAXY_RADIUS = 54.5
+// Nebula sprites read as subtle tints riding the arms, not floating blobs:
+// 0.14-0.18 opacity per the approved demo.
+const NEBULA_MIN_OPACITY = 0.14
+const NEBULA_MAX_OPACITY = 0.18
+const NEBULA_MAX_SCALE = GALAXY_RADIUS * 0.22
+
+function easeInOutCubic(t: number): number {
+ return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2
+}
+
+async function readKnowledgeGraph(): Promise {
+ const response = await fetch('/api/knowledge/graph')
+ if (!response.ok) throw new Error(`knowledge graph ${response.status}`)
+ return (await response.json()) as KnowledgeGraphResponse
+}
+
+function formatModified(value?: string): string {
+ if (!value) return 'unknown'
+ const date = new Date(value)
+ if (Number.isNaN(date.getTime())) return 'unknown'
+ return new Intl.DateTimeFormat(undefined, {
+ month: 'short',
+ day: 'numeric',
+ hour: 'numeric',
+ minute: '2-digit',
+ }).format(date)
+}
+
+function bodyLabel(body: CelestialBody): string {
+ if (body.kind === 'core') return 'Galactic core'
+ if (body.kind === 'planet') return 'Planet'
+ if (body.kind === 'tag') return 'Text tag'
+ return 'Comet'
+}
+
+function systemForBody(
+ model: GalaxyModel,
+ body: CelestialBody | null,
+): PlanetarySystem | null {
+ if (!body) return null
+ return model.systemByBodyId.get(body.id) ?? null
+}
+
+function activeSystemIds(
+ model: GalaxyModel,
+ selectedBody: CelestialBody | null,
+ hoveredBody: CelestialBody | null,
+): Set {
+ const ids = new Set()
+ const selectedSystem = systemForBody(model, selectedBody)
+ const hoveredSystem = systemForBody(model, hoveredBody)
+ if (selectedSystem) ids.add(selectedSystem.id)
+ if (hoveredSystem) ids.add(hoveredSystem.id)
+ return ids
+}
+
+function matchesSearch(body: CelestialBody, searchTerm: string): boolean {
+ const query = searchTerm.trim().toLowerCase()
+ if (!query) return true
+ return (
+ body.title.toLowerCase().includes(query) ||
+ body.path.toLowerCase().includes(query) ||
+ body.folder.toLowerCase().includes(query)
+ )
+}
+
+function warmth(body: CelestialBody): number {
+ if (body.kind === 'core') return 1
+ if (body.recencyTier === 'hot') return 0.92
+ if (body.recencyTier === 'warm') return 0.68
+ return 0.34
+}
+
+function bodyPosition(body: CelestialBody): THREE.Vector3 {
+ return new THREE.Vector3(body.baseX, body.baseY, body.baseZ)
+}
+
+/** Base sprite scale for a planet-kind ember: emberSize(degree) * ~2.2,
+ * with a small size bump for hub bodies (mirrors the tag-ember rule). */
+function planetEmberBaseSize(body: CelestialBody): number {
+ const hub = isHub(body.degree)
+ return emberSize(body.degree) * PLANET_EMBER_SCALE * (hub ? 1.12 : 1)
+}
+
+/** Base sprite scale for a tag-kind note star: emberSize(degree) * ~1.6,
+ * with a small size bump for hub bodies. */
+function noteEmberBaseSize(body: CelestialBody): number {
+ const hub = isHub(body.degree)
+ return emberSize(body.degree) * NOTE_EMBER_SCALE * (hub ? 1.15 : 1)
+}
+
+type ArmRank = { rankInArm: number; armNoteCount: number }
+
+/**
+ * Ranks every planet-/tag-kind body within its arm by `modified` ascending
+ * (oldest first — missing dates sort as oldest of all), so armRankPlacement
+ * can place the oldest notes nearest the core and the newest at the
+ * growing tip. Core and comets are excluded — the core sits at the
+ * origin and comets keep their own drifting-orphan placement.
+ */
+function computeArmRanks(model: GalaxyModel): Map {
+ const byArm = new Map>()
+ for (const body of model.bodies) {
+ if (body.kind === 'core' || body.kind === 'comet') continue
+ const bucket = byArm.get(body.armId) ?? []
+ bucket.push(body)
+ byArm.set(body.armId, bucket)
+ }
+ const ranks = new Map()
+ for (const bucket of byArm.values()) {
+ const sorted = [...bucket].sort((a, b) => {
+ const aTime = a.modified ? Date.parse(a.modified) : Number.NaN
+ const bTime = b.modified ? Date.parse(b.modified) : Number.NaN
+ const aValue = Number.isFinite(aTime) ? aTime : Number.NEGATIVE_INFINITY
+ const bValue = Number.isFinite(bTime) ? bTime : Number.NEGATIVE_INFINITY
+ if (aValue !== bValue) return aValue - bValue
+ return a.id.localeCompare(b.id)
+ })
+ sorted.forEach((body, index) => {
+ ranks.set(body.id, { rankInArm: index, armNoteCount: sorted.length })
+ })
+ }
+ return ranks
+}
+
+/**
+ * The one true screen position for every rendered body — the galaxy IS
+ * the map: every point of light comes from a real vault note, placed by
+ * `armRankPlacement` from its rank (oldest→newest) among its arm's real
+ * notes. No procedural dust, no blended legacy layout. The core sits at
+ * the origin; comets (orphans) keep their own drifting-rim position.
+ */
+function computeBodyPlacements(model: GalaxyModel): Map {
+ const ranks = computeArmRanks(model)
+ const armCount = Math.max(1, model.arms.length)
+ const placements = new Map()
+ for (const body of model.bodies) {
+ if (body.kind === 'core') {
+ placements.set(body.id, new THREE.Vector3(0, 0, 0))
+ continue
+ }
+ if (body.kind === 'comet') {
+ placements.set(body.id, bodyPosition(body))
+ continue
+ }
+ const rank = ranks.get(body.id)
+ if (!rank) {
+ placements.set(body.id, bodyPosition(body))
+ continue
+ }
+ const placed = armRankPlacement(
+ rank.rankInArm,
+ rank.armNoteCount,
+ body.armIndex,
+ armCount,
+ body.id,
+ )
+ placements.set(body.id, new THREE.Vector3(placed.x, placed.y, placed.z))
+ }
+ return placements
+}
+
+function createStarTexture(): THREE.Texture {
+ const canvas = document.createElement('canvas')
+ canvas.width = 32
+ canvas.height = 32
+ const context = canvas.getContext('2d')!
+ const gradient = context.createRadialGradient(16, 16, 0, 16, 16, 16)
+ gradient.addColorStop(0, 'rgba(255, 241, 204, 1)')
+ gradient.addColorStop(0.34, 'rgba(255, 210, 122, 0.42)')
+ gradient.addColorStop(1, 'rgba(255, 210, 122, 0)')
+ context.fillStyle = gradient
+ context.fillRect(0, 0, 32, 32)
+ const texture = new THREE.CanvasTexture(canvas)
+ texture.colorSpace = THREE.SRGBColorSpace
+ return texture
+}
+
+/** Soft additive ember sprite — reuses the star/glow canvas texture so
+ * every planet-kind body (and the core) renders as a glowing point of
+ * light instead of a textured sphere. */
+function createEmberSprite(
+ texture: THREE.Texture,
+ color: string,
+ opacity: number,
+): THREE.Sprite {
+ const material = new THREE.SpriteMaterial({
+ map: texture,
+ color: new THREE.Color(color),
+ transparent: true,
+ opacity,
+ depthWrite: false,
+ blending: THREE.AdditiveBlending,
+ })
+ return new THREE.Sprite(material)
+}
+
+function useEscape(handler: () => void): void {
+ useEffect(() => {
+ const onKeyDown = (event: KeyboardEvent) => {
+ if (event.key === 'Escape') handler()
+ }
+ window.addEventListener('keydown', onKeyDown)
+ return () => window.removeEventListener('keydown', onKeyDown)
+ }, [handler])
+}
+
+function createClusterNebula(color: string, opacity: number): THREE.Sprite {
+ const canvas = document.createElement('canvas')
+ canvas.width = 512
+ canvas.height = 512
+ const context = canvas.getContext('2d')!
+ const base = new THREE.Color(color)
+ const glow = context.createRadialGradient(256, 256, 0, 256, 256, 256)
+ glow.addColorStop(
+ 0,
+ `rgba(${Math.round(base.r * 255)}, ${Math.round(base.g * 255)}, ${Math.round(base.b * 255)}, 0.10)`,
+ )
+ glow.addColorStop(
+ 0.34,
+ `rgba(${Math.round(base.r * 255)}, ${Math.round(base.g * 255)}, ${Math.round(base.b * 255)}, 0.05)`,
+ )
+ glow.addColorStop(
+ 0.72,
+ `rgba(${Math.round(base.r * 255)}, ${Math.round(base.g * 255)}, ${Math.round(base.b * 255)}, 0.02)`,
+ )
+ glow.addColorStop(1, 'rgba(9, 10, 18, 0)')
+ context.fillStyle = glow
+ context.fillRect(0, 0, 512, 512)
+ const texture = new THREE.CanvasTexture(canvas)
+ texture.colorSpace = THREE.SRGBColorSpace
+ const material = new THREE.SpriteMaterial({
+ map: texture,
+ transparent: true,
+ depthWrite: false,
+ // Clamped hard: stacked additive nebula sprites were blowing out to a
+ // solid cyan-white blob before this cap.
+ opacity: clamp(opacity, 0, NEBULA_MAX_OPACITY),
+ blending: THREE.AdditiveBlending,
+ })
+ return new THREE.Sprite(material)
+}
+
+function clusterHueColor(hue: ReturnType): string {
+ switch (hue) {
+ case 'blue':
+ return GALAXY_PALETTE.blues[0]
+ case 'blue2':
+ return GALAXY_PALETTE.blues[1]
+ case 'amber':
+ return GALAXY_PALETTE.ambers[0]
+ case 'blend':
+ return GALAXY_PALETTE.ambers[2]
+ }
+}
+
+/**
+ * One nebula tint sprite per arm, positioned at the centroid of that
+ * arm's REAL star positions (not a point on some abstract curve) so the
+ * tint always reads as riding the actual notes. Scale grows with
+ * sqrt(note count) — more real notes, a visibly bigger cluster glow.
+ */
+function buildNebulaRegions(
+ model: GalaxyModel,
+ placements: Map,
+): Array {
+ return model.arms.map((arm, index) => {
+ const armBodies = model.bodies.filter(
+ (body) => body.armId === arm.id && body.kind !== 'comet',
+ )
+ const centroid = new THREE.Vector3()
+ let counted = 0
+ for (const body of armBodies) {
+ const position = placements.get(body.id)
+ if (!position) continue
+ centroid.add(position)
+ counted += 1
+ }
+ if (counted > 0) centroid.divideScalar(counted)
+ const noteCount = Math.max(1, armBodies.length)
+ return {
+ armId: arm.id,
+ color: clusterHueColor(clusterHue(index)),
+ position: { x: centroid.x, y: centroid.y, z: centroid.z },
+ scale: clamp(3 + Math.sqrt(noteCount) * 2.2, 6, NEBULA_MAX_SCALE),
+ opacity: clamp(
+ NEBULA_MIN_OPACITY + Math.min(0.04, noteCount * 0.003),
+ NEBULA_MIN_OPACITY,
+ NEBULA_MAX_OPACITY,
+ ),
+ }
+ })
+}
+
+type ClusterLabel = {
+ armId: string
+ name: string
+ x: number
+ y: number
+ opacity: number
+}
+
+function Galaxy3D({
+ model,
+ bodyPlacements,
+ nebulaRegions,
+ selectedBody,
+ hoveredBody,
+ disabledArms,
+ searchTerm,
+ thisWeekOnly,
+ isLoading,
+ onHover,
+ onSelect,
+}: Galaxy3DProps) {
+ const hostRef = useRef(null)
+ const [labels, setLabels] = useState>([])
+ const [clusterLabels, setClusterLabels] = useState>([])
+ const selectedRef = useRef(selectedBody)
+ const hoveredRef = useRef(hoveredBody)
+ const disabledRef = useRef>(disabledArms)
+ const searchRef = useRef(searchTerm)
+ const thisWeekRef = useRef(thisWeekOnly)
+ const reducedMotionRef = useRef(false)
+ const cameraStateRef = useRef({
+ yaw: -0.92,
+ pitch: CAMERA_TILT_RAD,
+ distance: OVERVIEW_DISTANCE,
+ target: new THREE.Vector3(0, 4, 0),
+ })
+ const glideRef = useRef<{
+ active: boolean
+ startedAt: number
+ fromTarget: THREE.Vector3
+ fromDistance: number
+ toTarget: THREE.Vector3
+ toDistance: number
+ } | null>(null)
+
+ useEffect(() => {
+ selectedRef.current = selectedBody
+ hoveredRef.current = hoveredBody
+ disabledRef.current = disabledArms
+ searchRef.current = searchTerm
+ thisWeekRef.current = thisWeekOnly
+ }, [disabledArms, hoveredBody, searchTerm, selectedBody, thisWeekOnly])
+
+ useEffect(() => {
+ const host = hostRef.current
+ if (!host) return
+
+ const scene = new THREE.Scene()
+ scene.background = new THREE.Color(SPACE)
+ scene.fog = new THREE.FogExp2(SPACE_SOFT, 0.0062)
+ const camera = new THREE.PerspectiveCamera(38, 1, 0.1, 520)
+ const renderer = new THREE.WebGLRenderer({
+ antialias: true,
+ alpha: false,
+ powerPreference: 'low-power',
+ })
+ renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.5))
+ renderer.setClearColor(SPACE, 1)
+ // Hundreds of real notes stack additive ember + halo sprites, densest
+ // right at the core where the log spiral bulges — filmic tone mapping
+ // gives that stack a soft rolloff instead of clipping to a flat white
+ // disc, matching the reference's warm-but-textured core.
+ renderer.toneMapping = THREE.ACESFilmicToneMapping
+ renderer.toneMappingExposure = 0.82
+ renderer.domElement.className =
+ 'nova-galaxy-canvas absolute inset-0 size-full'
+ renderer.domElement.setAttribute(
+ 'aria-label',
+ '3D Obsidian knowledge galaxy',
+ )
+ host.appendChild(renderer.domElement)
+
+ const ambient = new THREE.AmbientLight('#C8B59A', 0.28)
+ const key = new THREE.DirectionalLight('#FFE4A6', 2.7)
+ key.position.set(-28, 26, 34)
+ const coreLight = new THREE.PointLight('#FFB347', 1.8, 140)
+ coreLight.position.set(-10, 12, 24)
+ const fill = new THREE.PointLight('#163456', 0.58, 180)
+ fill.position.set(34, -16, -30)
+ // Rim stays in the canon family — the old #7D9573 green rim light
+ // washed every ember with the pre-canon tint.
+ const rim = new THREE.DirectionalLight('#2E7FD9', 0.35)
+ rim.position.set(18, 8, -40)
+ scene.add(ambient, key, coreLight, fill, rim)
+
+ // No decorative starfield, no procedural dust field. Every rendered
+ // light from here on is a real vault note (or its halo / a link
+ // filament / a cluster nebula / the core) — the galaxy IS the map.
+ const starTexture = createStarTexture()
+
+ const nebulaSprites = nebulaRegions.map((region) => {
+ const sprite = createClusterNebula(region.color, region.opacity)
+ sprite.position.set(region.position.x, region.position.y, region.position.z)
+ sprite.scale.set(region.scale, region.scale * 0.55, 1)
+ scene.add(sprite)
+ return sprite
+ })
+
+ const planetObjects = new Map()
+ const tagObjects: Array = []
+ const hitObjects: Array = []
+ const bodyPositions = new Map()
+ // Every rendered body's screen position comes from armRankPlacement,
+ // computed once up front in the card's data-prep (bodyPlacements).
+ // Clone on read — the placements map is shared across renders/labels.
+ const getPosition = (id: string): THREE.Vector3 => {
+ const found = bodyPlacements.get(id)
+ return found ? found.clone() : new THREE.Vector3()
+ }
+
+ for (const system of model.systems) {
+ const body = system.planet
+ const planetHub = isHub(body.degree)
+ const baseSize = planetEmberBaseSize(body)
+ const position = getPosition(body.id)
+ const sprites: Array = []
+ const materials: Array = []
+ const baseScales: Array = []
+ const baseOpacities: Array = []
+
+ if (body.kind === 'core') {
+ // Bigger soft amber core-glow: two stacked additive sprites — a
+ // wide soft outer halo plus a brighter, tighter inner glow —
+ // rather than a single mesh.
+ const outerScale = baseSize * CORE_OUTER_MULTIPLIER
+ const innerScale = baseSize * CORE_INNER_MULTIPLIER
+ const outer = createEmberSprite(starTexture, AMBER, 0.4)
+ outer.scale.set(outerScale, outerScale, 1)
+ outer.position.copy(position)
+ outer.userData.bodyId = body.id
+ // Warm white, not gold: the reference core is a bright compact
+ // white-hot point that fades to amber at its edge (the AMBER outer
+ // halo below), not a solid gold disc.
+ const inner = createEmberSprite(starTexture, '#FFF1CC', 0.95)
+ inner.scale.set(innerScale, innerScale, 1)
+ inner.position.copy(position)
+ inner.userData.bodyId = body.id
+ scene.add(outer, inner)
+ sprites.push(outer, inner)
+ materials.push(
+ outer.material as THREE.SpriteMaterial,
+ inner.material as THREE.SpriteMaterial,
+ )
+ baseScales.push(outerScale, innerScale)
+ baseOpacities.push(0.4, 0.92)
+
+ // Every star gets a soft halo, including the core.
+ const haloScale = outerScale * HALO_SCALE_MULTIPLIER
+ const halo = createEmberSprite(starTexture, folderTintFor(body.folder), HALO_OPACITY)
+ halo.scale.set(haloScale, haloScale, 1)
+ halo.position.copy(position)
+ scene.add(halo)
+ sprites.push(halo)
+ materials.push(halo.material as THREE.SpriteMaterial)
+ baseScales.push(haloScale)
+ baseOpacities.push(HALO_OPACITY)
+ } else {
+ // Every other planet-kind body: a single large ember, no textured
+ // sphere/ring/atmosphere meshes. Amber is reserved for hub embers
+ // (+ the core glow + one amber cluster tint) — regular planets read
+ // neon-blue so the arms stay blue-dominant per the reference.
+ const scale = baseSize
+ const sprite = createEmberSprite(
+ starTexture,
+ planetHub ? GOLD : GALAXY_PALETTE.blues[0],
+ planetHub ? 0.92 : 0.82,
+ )
+ sprite.scale.set(scale, scale, 1)
+ sprite.position.copy(position)
+ sprite.userData.bodyId = body.id
+ scene.add(sprite)
+ sprites.push(sprite)
+ materials.push(sprite.material as THREE.SpriteMaterial)
+ baseScales.push(scale)
+ baseOpacities.push(planetHub ? 0.92 : 0.82)
+
+ // Every star gets a soft halo, tinted by its cluster.
+ const haloScale = scale * HALO_SCALE_MULTIPLIER
+ const halo = createEmberSprite(starTexture, folderTintFor(body.folder), HALO_OPACITY)
+ halo.scale.set(haloScale, haloScale, 1)
+ halo.position.copy(position)
+ scene.add(halo)
+ sprites.push(halo)
+ materials.push(halo.material as THREE.SpriteMaterial)
+ baseScales.push(haloScale)
+ baseOpacities.push(HALO_OPACITY)
+ }
+
+ bodyPositions.set(body.id, position.clone())
+ planetObjects.set(body.id, {
+ body,
+ system,
+ sprites,
+ materials,
+ baseScales,
+ baseOpacities,
+ })
+ for (const tag of system.tags) {
+ const armTint =
+ model.arms.find((arm) => arm.id === tag.armId)?.tint ??
+ folderTintFor(tag.folder)
+ const hub = isHub(tag.degree)
+ const baseScale = noteEmberBaseSize(tag)
+ const tagPosition = getPosition(tag.id)
+
+ const sprite = createEmberSprite(
+ starTexture,
+ hub ? GOLD : armTint,
+ hub ? 0.88 : 0.62,
+ )
+ sprite.scale.set(baseScale, baseScale, 1)
+ sprite.position.copy(tagPosition)
+ sprite.userData.bodyId = tag.id
+ scene.add(sprite)
+
+ const haloScale = baseScale * HALO_SCALE_MULTIPLIER
+ const halo = createEmberSprite(starTexture, folderTintFor(tag.folder), HALO_OPACITY)
+ halo.scale.set(haloScale, haloScale, 1)
+ halo.position.copy(tagPosition)
+ scene.add(halo)
+
+ bodyPositions.set(tag.id, tagPosition.clone())
+ hitObjects.push(sprite)
+ tagObjects.push({
+ body: tag,
+ sprite,
+ material: sprite.material as THREE.SpriteMaterial,
+ halo,
+ haloMaterial: halo.material as THREE.SpriteMaterial,
+ baseScale,
+ })
+ }
+ }
+
+ const cometObjects: Array = []
+ model.comets.forEach((body) => {
+ const group = new THREE.Group()
+ group.position.copy(getPosition(body.id))
+ const material = new THREE.MeshBasicMaterial({
+ color: TAN,
+ transparent: true,
+ opacity: 0.5,
+ })
+ const comet = new THREE.Mesh(
+ new THREE.SphereGeometry(0.16, 12, 8),
+ material,
+ )
+ comet.userData.bodyId = body.id
+ hitObjects.push(comet)
+ const tailMaterial = new THREE.LineBasicMaterial({
+ color: COPPER,
+ transparent: true,
+ opacity: 0.18,
+ })
+ const tail = new THREE.Line(
+ new THREE.BufferGeometry().setFromPoints([
+ new THREE.Vector3(-1.8, 0.22, 0),
+ new THREE.Vector3(-0.18, 0, 0),
+ ]),
+ tailMaterial,
+ )
+ group.add(tail, comet)
+ scene.add(group)
+ bodyPositions.set(body.id, group.position.clone())
+ cometObjects.push({ body, group, material, tailMaterial })
+ })
+
+ const homeTarget = (() => {
+ if (model.systems.length === 0) return new THREE.Vector3(0, 4, 0)
+ const target = new THREE.Vector3()
+ let weightTotal = 0
+ // Bias overview toward top systems but keep the camera off-center so
+ // composition reads as layered depth instead of a dead-on blob.
+ for (const system of model.systems.slice(0, 10)) {
+ const position = bodyPositions.get(system.planet.id)
+ if (!position) continue
+ const weight = Math.max(1, Math.sqrt(system.totalLinks))
+ target.addScaledVector(position, weight)
+ weightTotal += weight
+ }
+ if (weightTotal <= 0) return new THREE.Vector3(0, 4, 0)
+ target.divideScalar(weightTotal)
+ target.y += 3.5
+ target.x -= 6
+ target.z += 4
+ return target
+ })()
+ if (!selectedRef.current) {
+ cameraStateRef.current.target.copy(homeTarget)
+ cameraStateRef.current.distance = OVERVIEW_DISTANCE
+ cameraStateRef.current.yaw = -0.92
+ cameraStateRef.current.pitch = CAMERA_TILT_RAD
+ }
+ const lineObjects: Array = []
+ for (const link of model.links) {
+ const source = bodyPositions.get(link.source)
+ const target = bodyPositions.get(link.target)
+ if (!source || !target) continue
+ const sourceBody = model.bodyById.get(link.source)
+ const targetBody = model.bodyById.get(link.target)
+ const sameSystem =
+ Boolean(sourceBody?.systemId) &&
+ sourceBody?.systemId === targetBody?.systemId
+ const geometry = new THREE.BufferGeometry().setFromPoints([
+ source,
+ target,
+ ])
+ const material = new THREE.LineDashedMaterial({
+ color: sameSystem
+ ? folderTintFor(sourceBody?.folder ?? 'vault')
+ : GOLD,
+ transparent: true,
+ opacity: sameSystem ? 0.09 : 0.028,
+ dashSize: sameSystem ? 0.22 : 0.34,
+ gapSize: sameSystem ? 0.36 : 0.5,
+ depthWrite: false,
+ })
+ const line = new THREE.LineSegments(geometry, material)
+ line.computeLineDistances()
+ scene.add(line)
+ lineObjects.push({
+ source: link.source,
+ target: link.target,
+ strength: link.strength,
+ material,
+ })
+ }
+
+ const spokeObjects: Array = []
+ if (model.core) {
+ const corePosition = bodyPositions.get(model.core.id)
+ if (corePosition) {
+ for (const system of model.systems) {
+ if (system.planet.id === model.core.id) continue
+ const systemPosition = bodyPositions.get(system.planet.id)
+ if (!systemPosition) continue
+ const geometry = new THREE.BufferGeometry().setFromPoints([
+ systemPosition,
+ corePosition,
+ ])
+ const material = new THREE.LineBasicMaterial({
+ color: AMBER,
+ transparent: true,
+ opacity: 0.035,
+ depthWrite: false,
+ })
+ const line = new THREE.Line(geometry, material)
+ scene.add(line)
+ spokeObjects.push({
+ source: system.planet.id,
+ target: model.core.id,
+ strength: 0,
+ material: material as unknown as THREE.LineDashedMaterial,
+ })
+ }
+ }
+ }
+
+ const raycaster = new THREE.Raycaster()
+ const pointer = new THREE.Vector2()
+ let width = 1
+ let height = 1
+ let frame = 0
+ let lastLabelUpdate = 0
+ let lastClusterLabelUpdate = 0
+ let lastSelectedId: string | null = selectedRef.current?.id ?? null
+ let isDragging = false
+ let lastPointer = { x: 0, y: 0 }
+ let pointerMoved = false
+ let pointerParallax = { x: 0, y: 0 }
+ let parallaxYaw = 0
+ let parallaxPitch = 0
+ const ROTATION_PERIOD_MS = 110_000
+
+ const resize = () => {
+ const bounds = host.getBoundingClientRect()
+ width = Math.max(1, bounds.width)
+ height = Math.max(1, bounds.height)
+ renderer.setSize(width, height, false)
+ camera.aspect = width / height
+ camera.updateProjectionMatrix()
+ }
+
+ const updateCamera = (now: number) => {
+ const state = cameraStateRef.current
+ const selected = selectedRef.current
+ const focusBody = focusBodyForNavigation(model, selected)
+ const selectedSystem = focusBody
+ ? model.systemByBodyId.get(focusBody.id)
+ : null
+ const selectedPosition = focusBody ? bodyPositions.get(focusBody.id) : null
+ const desiredTarget = selectedPosition
+ ? selectedPosition.clone().add(new THREE.Vector3(0, 1.4, 0))
+ : homeTarget
+ const desiredDistance = selectedSystem
+ ? focusDistanceForSystem(selectedSystem)
+ : OVERVIEW_DISTANCE
+
+ // L3: click = go & read — a genuine selection change kicks off a
+ // fixed ~600ms eased glide instead of the open-ended exponential
+ // follow (which converges but has no defined duration).
+ const currentSelectedId = selected?.id ?? null
+ if (currentSelectedId !== lastSelectedId && !reducedMotionRef.current) {
+ glideRef.current = {
+ active: true,
+ startedAt: now,
+ fromTarget: state.target.clone(),
+ fromDistance: state.distance,
+ toTarget: desiredTarget.clone(),
+ toDistance: desiredDistance,
+ }
+ lastSelectedId = currentSelectedId
+ } else if (currentSelectedId !== lastSelectedId) {
+ // Reduced motion: snap instead of gliding.
+ state.target.copy(desiredTarget)
+ state.distance = desiredDistance
+ lastSelectedId = currentSelectedId
+ }
+
+ const glide = glideRef.current
+ if (glide?.active) {
+ const t = clamp((now - glide.startedAt) / GLIDE_DURATION_MS, 0, 1)
+ const eased = easeInOutCubic(t)
+ state.target.lerpVectors(glide.fromTarget, glide.toTarget, eased)
+ state.distance = glide.fromDistance + (glide.toDistance - glide.fromDistance) * eased
+ if (t >= 1) glide.active = false
+ } else {
+ // No active glide (idle overview, or drag-driven re-target): keep
+ // the original soft exponential follow so free-look drag doesn't
+ // feel snappy/robotic.
+ state.target.lerp(desiredTarget, reducedMotionRef.current ? 1 : 0.055)
+ state.distance +=
+ (desiredDistance - state.distance) *
+ (reducedMotionRef.current ? 1 : 0.048)
+ }
+
+ if (!selected && !isDragging && !reducedMotionRef.current) {
+ state.yaw += Math.sin(now / 14000) * 0.00016 + 0.00012
+ state.pitch +=
+ (CAMERA_TILT_RAD + Math.sin(now / 21000) * 0.03 - state.pitch) * 0.01
+ }
+ state.pitch = clamp(state.pitch, -0.62, 0.78)
+ state.distance = clamp(state.distance, 11, 220)
+ const appliedYaw = state.yaw + parallaxYaw
+ const appliedPitch = clamp(state.pitch + parallaxPitch, -0.62, 0.78)
+ const cosPitch = Math.cos(appliedPitch)
+ camera.position.set(
+ state.target.x + Math.sin(appliedYaw) * cosPitch * state.distance,
+ state.target.y + Math.sin(appliedPitch) * state.distance,
+ state.target.z + Math.cos(appliedYaw) * cosPitch * state.distance,
+ )
+ camera.lookAt(state.target)
+ }
+
+ const bodyVisibleOpacity = (
+ body: CelestialBody,
+ activeIds: Set,
+ query: string,
+ nowIso: string = new Date().toISOString(),
+ ) => {
+ const disabled = disabledRef.current.has(body.armId)
+ let opacity = disabled ? 0.15 : 1
+ if (activeIds.size > 0 && body.systemId && !activeIds.has(body.systemId))
+ opacity *= 0.34
+ if (query && !matchesSearch(body, query)) opacity *= 0.16
+ // L5 "this week" filter: dims embers with recencyGlow < 1 (i.e. not
+ // touched in the last 7 days, per recencyGlow's hot-day cutoff) —
+ // visuals only, bodies stay in the model.
+ if (thisWeekRef.current) {
+ const glow = recencyGlow(body.modified ?? body.updated ?? '', nowIso)
+ if (glow < 1) opacity *= 0.2
+ }
+ return opacity
+ }
+ const updateVisualState = (now: number) => {
+ const selected = selectedRef.current
+ const hovered = hoveredRef.current
+ const query = searchRef.current.trim()
+ const activeIds = activeSystemIds(model, selected, hovered)
+ const activeBodyId = selected?.id ?? hovered?.id
+ const nowIso = new Date().toISOString()
+ // L2: bodies directly wikilinked to the hovered ember brighten
+ // alongside the neon-blue edge flare below.
+ const hoveredLinkedIds = new Set()
+ if (hovered) {
+ for (const link of lineObjects) {
+ if (link.source === hovered.id) hoveredLinkedIds.add(link.target)
+ else if (link.target === hovered.id) hoveredLinkedIds.add(link.source)
+ }
+ }
+ for (const planet of planetObjects.values()) {
+ const opacity = bodyVisibleOpacity(planet.body, activeIds, query, nowIso)
+ const active =
+ activeIds.has(planet.system.id) || planet.body.id === activeBodyId
+ const warm = warmth(planet.body)
+ // Existing slow hub pulse — the sin-based scale wobble every
+ // recency-hot body already got, now applied to sprite scale
+ // instead of mesh scale/rotation (billboards don't spin).
+ const pulse =
+ planet.body.recencyTier === 'hot' && !reducedMotionRef.current
+ ? 1 + Math.sin(now / 720 + planet.body.orbitPhase) * 0.07
+ : 1
+ const activeBoost = active ? 1.16 : 0.9 + warm * 0.1
+ for (let index = 0; index < planet.sprites.length; index += 1) {
+ const scale = planet.baseScales[index] * pulse * activeBoost
+ planet.sprites[index].scale.set(scale, scale, 1)
+ planet.materials[index].opacity =
+ opacity * planet.baseOpacities[index] * (active ? 1 : 0.88)
+ }
+ }
+ for (const tag of tagObjects) {
+ const opacity = bodyVisibleOpacity(tag.body, activeIds, query, nowIso)
+ const active =
+ (tag.body.systemId && activeIds.has(tag.body.systemId)) ||
+ tag.body.id === activeBodyId
+ const linked = !active && hoveredLinkedIds.has(tag.body.id)
+ const pulse =
+ tag.body.recencyTier === 'hot' && !reducedMotionRef.current
+ ? 1 + Math.sin(now / 680 + tag.body.orbitPhase) * 0.13
+ : 1
+ // recencyGlow drives alpha so this week's notes visibly shine.
+ const glow = recencyGlow(tag.body.modified ?? tag.body.updated ?? '', nowIso)
+ const activeBoost = active ? 1.28 : linked ? 1.12 : 1
+ const scale = tag.baseScale * pulse * activeBoost
+ tag.sprite.scale.set(scale, scale, 1)
+ tag.material.opacity =
+ opacity * (active ? 1 : linked ? 0.88 : 0.62) * (0.55 + glow * 0.45)
+ const haloScale = scale * HALO_SCALE_MULTIPLIER
+ tag.halo.scale.set(haloScale, haloScale, 1)
+ tag.haloMaterial.opacity = clamp(
+ opacity * HALO_OPACITY * (0.55 + glow * 0.45) * (active ? 1.4 : linked ? 1.15 : 1),
+ 0,
+ 0.22,
+ )
+ }
+ for (const comet of cometObjects) {
+ const drift = reducedMotionRef.current
+ ? 0
+ : now / comet.body.orbitPeriod / 28
+ comet.group.position.x =
+ comet.body.baseX + Math.sin(drift + comet.body.orbitPhase) * 8
+ comet.group.position.y =
+ comet.body.baseY + Math.cos(drift * 0.7 + comet.body.orbitPhase) * 2
+ comet.group.position.z =
+ comet.body.baseZ + Math.cos(drift + comet.body.orbitPhase) * 8
+ const opacity = bodyVisibleOpacity(comet.body, activeIds, query, nowIso)
+ comet.material.opacity = opacity * 0.48
+ comet.tailMaterial.opacity = opacity * 0.16
+ }
+ for (const link of lineObjects) {
+ const source = model.bodyById.get(link.source)
+ const target = model.bodyById.get(link.target)
+ const active =
+ (source?.systemId && activeIds.has(source.systemId)) ||
+ (target?.systemId && activeIds.has(target.systemId)) ||
+ source?.id === activeBodyId ||
+ target?.id === activeBodyId
+ // L2 edge-flare: hovered ember's links brighten to neon-blue for
+ // this frame only — recomputed every frame, never persisted state.
+ const isHoverFlare =
+ Boolean(hovered) && (source?.id === hovered?.id || target?.id === hovered?.id)
+ const searched = Boolean(
+ query &&
+ ((source && matchesSearch(source, query)) ||
+ (target && matchesSearch(target, query))),
+ )
+ const backbone = link.strength >= 12
+ const sameSystem =
+ Boolean(source?.systemId) && source?.systemId === target?.systemId
+ if (isHoverFlare) {
+ link.material.color.set(GALAXY_PALETTE.blues[0])
+ link.material.opacity = 0.72
+ } else {
+ link.material.color.set(
+ sameSystem && source ? folderTintFor(source.folder) : GOLD,
+ )
+ // Same-cluster filaments read slightly brighter at rest so the
+ // constellation structure between real notes stays legible.
+ if (active) link.material.opacity = 0.42
+ else if (searched) link.material.opacity = 0.22
+ else if (backbone) link.material.opacity = 0.07
+ else link.material.opacity = sameSystem ? 0.09 : 0.025
+ }
+ }
+ }
+
+ const projectLabels = () => {
+ const selected = selectedRef.current
+ const hovered = hoveredRef.current
+ const query = searchRef.current.trim()
+ const activeIds = activeSystemIds(model, selected, hovered)
+ const mode = selected || hovered || query ? 'focus' : 'overview'
+ const candidates = selectLabelCandidates({
+ model,
+ selectedBody: selected,
+ hoveredBody: hovered,
+ searchTerm: query,
+ mode,
+ })
+ const projected: Array = []
+ const vector = new THREE.Vector3()
+ for (const candidate of candidates) {
+ const point = getPosition(candidate.body.id)
+ if (candidate.kind === 'planet') {
+ const right = new THREE.Vector3().setFromMatrixColumn(
+ camera.matrixWorld,
+ 0,
+ )
+ const up = new THREE.Vector3().setFromMatrixColumn(
+ camera.matrixWorld,
+ 1,
+ )
+ const size = planetEmberBaseSize(candidate.body)
+ const offset =
+ candidate.body.kind === 'core'
+ ? size * CORE_OUTER_MULTIPLIER * 0.9
+ : size * 0.85
+ point.addScaledVector(right, offset)
+ point.addScaledVector(up, offset * 0.28)
+ }
+ vector.copy(point).project(camera)
+ if (vector.z < -1 || vector.z > 1) continue
+ const distance = camera.position.distanceTo(point)
+ if (
+ candidate.kind === 'tag' &&
+ mode === 'overview' &&
+ distance > 52 &&
+ candidate.body.id !== hovered?.id &&
+ candidate.body.id !== selected?.id
+ ) {
+ continue
+ }
+ const active =
+ Boolean(
+ candidate.body.systemId && activeIds.has(candidate.body.systemId),
+ ) ||
+ candidate.body.id === hovered?.id ||
+ candidate.body.id === selected?.id
+ const searched = Boolean(query && matchesSearch(candidate.body, query))
+ let opacity = bodyVisibleOpacity(candidate.body, activeIds, query)
+ if (candidate.kind === 'tag' && !active && !searched) opacity *= 0.34
+ if (candidate.kind === 'planet' && !active && !searched)
+ opacity *= mode === 'overview' ? 0.9 : 0.72
+ projected.push({
+ id: candidate.id,
+ kind: candidate.kind,
+ body: candidate.body,
+ priority: candidate.priority,
+ x: (vector.x * 0.5 + 0.5) * width,
+ y: (-vector.y * 0.5 + 0.5) * height,
+ scale: clamp(1.08 - distance / 170, 0.58, 0.96),
+ opacity,
+ active: active || searched,
+ })
+ }
+ setLabels(
+ resolveProjectedLabels(projected, mode === 'overview' ? 34 : 26),
+ )
+ }
+
+ // L1: always-on cluster/arm-level labels, thrown at 4Hz — deliberately
+ // coarser than the 90ms planet/tag cadence above so this cheap layer
+ // never gets starved by the denser one, and doesn't trigger re-renders
+ // at a rate that could cause a re-render storm.
+ const projectClusterLabels = () => {
+ const vector = new THREE.Vector3()
+ const projected: Array = []
+ for (const region of nebulaRegions) {
+ vector.set(region.position.x, region.position.y, region.position.z)
+ vector.project(camera)
+ if (vector.z < -1 || vector.z > 1) continue
+ const arm = model.arms.find((a) => a.id === region.armId)
+ if (!arm) continue
+ const distance = camera.position.distanceTo(
+ new THREE.Vector3(region.position.x, region.position.y, region.position.z),
+ )
+ projected.push({
+ armId: region.armId,
+ name: arm.name,
+ x: (vector.x * 0.5 + 0.5) * width,
+ y: (-vector.y * 0.5 + 0.5) * height,
+ // Fades as the camera closes in — "constellation names always
+ // on, soft zoomed out, fade as camera closes in" (spec L1).
+ opacity: clamp(1 - (OVERVIEW_DISTANCE - distance) / OVERVIEW_DISTANCE, 0.12, 0.85),
+ })
+ }
+ setClusterLabels(projected)
+ }
+
+ const animate = (now: number) => {
+ if (!reducedMotionRef.current) {
+ for (const sprite of nebulaSprites) {
+ sprite.material.rotation =
+ (now / ROTATION_PERIOD_MS) * Math.PI * 2 * 0.4
+ }
+ }
+ parallaxYaw += (pointerParallax.x * 0.05 - parallaxYaw) * 0.04
+ parallaxPitch += (pointerParallax.y * 0.03 - parallaxPitch) * 0.04
+ updateCamera(now)
+ updateVisualState(now)
+ refreshEmberScreenPositions()
+ renderer.render(scene, camera)
+ if (now - lastLabelUpdate > 90) {
+ projectLabels()
+ lastLabelUpdate = now
+ }
+ if (now - lastClusterLabelUpdate > 250) {
+ projectClusterLabels()
+ lastClusterLabelUpdate = now
+ }
+ if (!document.hidden && isVisible) {
+ frame = window.requestAnimationFrame(animate)
+ }
+ }
+
+ const motionMedia = window.matchMedia('(prefers-reduced-motion: reduce)')
+ reducedMotionRef.current = motionMedia.matches
+ const onMotionChange = () => {
+ reducedMotionRef.current = motionMedia.matches
+ }
+ const normalizedPointer = (event: PointerEvent) => {
+ const bounds = renderer.domElement.getBoundingClientRect()
+ pointer.x = ((event.clientX - bounds.left) / bounds.width) * 2 - 1
+ pointer.y = -((event.clientY - bounds.top) / bounds.height) * 2 + 1
+ }
+ // Comets still have real hit geometry (small sphere mesh) sized to
+ // their visual radius, so raycasting still finds them. Embers-only
+ // planets/core no longer have any mesh to raycast against — they are
+ // picked exclusively through the screen-space nearest-neighbor path
+ // below, same as tags.
+ const pickPlanet = (event: PointerEvent): CelestialBody | null => {
+ normalizedPointer(event)
+ raycaster.setFromCamera(pointer, camera)
+ const hit = raycaster.intersectObjects(hitObjects, false)[0]
+ return hit
+ ? (model.bodyById.get(String(hit.object.userData.bodyId)) ?? null)
+ : null
+ }
+ // L2 hover-identify + planet/core selection: nearest ember within 24
+ // screen px wins over raycast picking for every kind that renders as a
+ // sprite (tags, planets, core). Tag embers are visually small dots and
+ // planet/core embers have no mesh at all post embers-only conversion,
+ // so screen-space nearest-neighbor is the primary picker; raycast only
+ // remains as a fallback for comets (which keep a real hit-sphere).
+ const NEAREST_EMBER_PX = 24
+ const emberScreenPositions = new Map()
+ const refreshEmberScreenPositions = () => {
+ const vector = new THREE.Vector3()
+ emberScreenPositions.clear()
+ for (const tag of tagObjects) {
+ vector.copy(tag.sprite.position).project(camera)
+ if (vector.z < -1 || vector.z > 1) continue
+ emberScreenPositions.set(tag.body.id, {
+ x: (vector.x * 0.5 + 0.5) * width,
+ y: (-vector.y * 0.5 + 0.5) * height,
+ })
+ }
+ for (const planet of planetObjects.values()) {
+ const anchor = planet.sprites[0]
+ if (!anchor) continue
+ vector.copy(anchor.position).project(camera)
+ if (vector.z < -1 || vector.z > 1) continue
+ emberScreenPositions.set(planet.body.id, {
+ x: (vector.x * 0.5 + 0.5) * width,
+ y: (-vector.y * 0.5 + 0.5) * height,
+ })
+ }
+ }
+ const pickNearestEmber = (event: PointerEvent): CelestialBody | null => {
+ const bounds = renderer.domElement.getBoundingClientRect()
+ const px = event.clientX - bounds.left
+ const py = event.clientY - bounds.top
+ let best: { id: string; distance: number } | null = null
+ for (const [id, pos] of emberScreenPositions) {
+ const distance = Math.hypot(pos.x - px, pos.y - py)
+ if (distance <= NEAREST_EMBER_PX && (!best || distance < best.distance)) {
+ best = { id, distance }
+ }
+ }
+ return best ? (model.bodyById.get(best.id) ?? null) : null
+ }
+ const onPointerMove = (event: PointerEvent) => {
+ const bounds = renderer.domElement.getBoundingClientRect()
+ pointerParallax = {
+ x: ((event.clientX - bounds.left) / bounds.width) * 2 - 1,
+ y: ((event.clientY - bounds.top) / bounds.height) * 2 - 1,
+ }
+ if (isDragging) {
+ const dx = event.clientX - lastPointer.x
+ const dy = event.clientY - lastPointer.y
+ if (Math.abs(dx) + Math.abs(dy) > 2) pointerMoved = true
+ cameraStateRef.current.yaw -= dx * 0.0045
+ cameraStateRef.current.pitch -= dy * 0.0038
+ lastPointer = { x: event.clientX, y: event.clientY }
+ return
+ }
+ const nearestEmber = pickNearestEmber(event)
+ const body = nearestEmber ?? pickPlanet(event)
+ renderer.domElement.style.cursor = body ? 'pointer' : 'grab'
+ onHover(body)
+ }
+ const onPointerDown = (event: PointerEvent) => {
+ isDragging = true
+ pointerMoved = false
+ lastPointer = { x: event.clientX, y: event.clientY }
+ renderer.domElement.setPointerCapture(event.pointerId)
+ }
+ const onPointerUp = (event: PointerEvent) => {
+ isDragging = false
+ renderer.domElement.releasePointerCapture(event.pointerId)
+ // Click target matches whatever hover identified (L2 → L3): nearest
+ // screen-space ember wins over raycast so the body under the hover
+ // chip is always the body a click selects.
+ const body = pickNearestEmber(event) ?? pickPlanet(event)
+ if (!pointerMoved) onSelect(body)
+ }
+ const onPointerLeave = () => {
+ if (!isDragging) onHover(null)
+ }
+ const onWheel = (event: WheelEvent) => {
+ event.preventDefault()
+ cameraStateRef.current.distance += event.deltaY * 0.028
+ cameraStateRef.current.distance = clamp(
+ cameraStateRef.current.distance,
+ 12,
+ 220,
+ )
+ }
+
+ let isVisible = true
+ const intersectionObserver = new IntersectionObserver(
+ ([entry]) => {
+ const wasVisible = isVisible
+ isVisible = entry.isIntersecting
+ if (isVisible && !wasVisible && !document.hidden) {
+ frame = window.requestAnimationFrame(animate)
+ }
+ },
+ { threshold: 0.05 },
+ )
+ intersectionObserver.observe(host)
+ const onVisibilityChange = () => {
+ if (document.hidden) {
+ window.cancelAnimationFrame(frame)
+ } else if (isVisible) {
+ frame = window.requestAnimationFrame(animate)
+ }
+ }
+ document.addEventListener('visibilitychange', onVisibilityChange)
+
+ const resizeObserver = new ResizeObserver(resize)
+ resizeObserver.observe(host)
+ resize()
+ motionMedia.addEventListener('change', onMotionChange)
+ renderer.domElement.addEventListener('pointermove', onPointerMove)
+ renderer.domElement.addEventListener('pointerdown', onPointerDown)
+ renderer.domElement.addEventListener('pointerup', onPointerUp)
+ renderer.domElement.addEventListener('pointerleave', onPointerLeave)
+ renderer.domElement.addEventListener('wheel', onWheel, { passive: false })
+ frame = window.requestAnimationFrame(animate)
+
+ return () => {
+ window.cancelAnimationFrame(frame)
+ resizeObserver.disconnect()
+ intersectionObserver.disconnect()
+ document.removeEventListener('visibilitychange', onVisibilityChange)
+ motionMedia.removeEventListener('change', onMotionChange)
+ renderer.domElement.removeEventListener('pointermove', onPointerMove)
+ renderer.domElement.removeEventListener('pointerdown', onPointerDown)
+ renderer.domElement.removeEventListener('pointerup', onPointerUp)
+ renderer.domElement.removeEventListener('pointerleave', onPointerLeave)
+ renderer.domElement.removeEventListener('wheel', onWheel)
+ setLabels([])
+ setClusterLabels([])
+ scene.traverse((object) => {
+ if (
+ 'geometry' in object &&
+ object.geometry instanceof THREE.BufferGeometry
+ ) {
+ object.geometry.dispose()
+ }
+ if ('material' in object) {
+ const material = object.material
+ if (Array.isArray(material))
+ material.forEach((item) => item.dispose())
+ else if (material instanceof THREE.Material) material.dispose()
+ }
+ })
+ renderer.dispose()
+ host.removeChild(renderer.domElement)
+ }
+ }, [model, bodyPlacements, nebulaRegions, onHover, onSelect])
+
+ return (
+
+ {isLoading ? (
+
+ mapping vault
+
+ ) : null}
+
+ {clusterLabels.map((cluster) => (
+
+ {cluster.name}
+
+ ))}
+
+
+ {labels.map((label) => (
+ onSelect(label.body)}
+ onMouseEnter={() => onHover(label.body)}
+ onMouseLeave={() => onHover(null)}
+ className={`pointer-events-auto absolute max-w-[190px] -translate-x-1/2 -translate-y-1/2 truncate border backdrop-blur-sm transition-colors ${
+ label.kind === 'planet'
+ ? 'rounded-md border-[rgba(255,210,122,0.12)] bg-[rgba(2,7,18,0.42)] px-1.5 text-[12px] font-semibold text-[var(--theme-text-strong)] shadow-[0_0_14px_rgba(2,7,18,0.9)]'
+ : 'rounded border-[rgba(255,179,71,0.12)] bg-[rgba(9,10,18,0.55)] px-1.5 py-0.5 font-mono text-[10px] text-[var(--theme-text-soft)]'
+ } ${label.active ? 'text-[var(--theme-accent-secondary)] border-[rgba(255,179,71,0.34)]' : ''}`}
+ style={{
+ left: label.x,
+ top: label.y,
+ opacity: label.opacity,
+ transform: `translate(-50%, -50%) scale(${label.scale})`,
+ boxShadow:
+ label.kind === 'planet'
+ ? `0 0 0 1px ${folderTintFor(label.body.folder)}33`
+ : undefined,
+ }}
+ title={label.body.title}
+ >
+ {shortTitle(label.body.title).slice(
+ 0,
+ label.kind === 'planet' ? 36 : 24,
+ )}
+
+ ))}
+
+
+ )
+}
+export function MindGraphCard() {
+ const [selectedId, setSelectedId] = useState(null)
+ const [inspectedId, setInspectedId] = useState(null)
+ const [hoveredId, setHoveredId] = useState(null)
+ const [disabledArms, setDisabledArms] = useState>(() => new Set())
+ const [searchTerm, setSearchTerm] = useState('')
+ const [thisWeekOnly, setThisWeekOnly] = useState(false)
+ // L4 search-to-fly: debounce keystrokes 300ms before hitting the real
+ // vault search endpoint, so the 3D-highlight `searchTerm` state above
+ // (already wired) stays snappy while the results dropdown below stays
+ // network-quiet.
+ const [debouncedSearch, setDebouncedSearch] = useState('')
+ useEffect(() => {
+ const handle = window.setTimeout(() => setDebouncedSearch(searchTerm.trim()), 300)
+ return () => window.clearTimeout(handle)
+ }, [searchTerm])
+ const searchResultsQuery = useQuery({
+ queryKey: ['dashboard', 'knowledge-search', debouncedSearch],
+ enabled: debouncedSearch.length > 0,
+ queryFn: async (): Promise<{
+ results: Array<{ path: string; title: string; line: number; text: string }>
+ }> => {
+ const response = await fetch(
+ `/api/knowledge/search?q=${encodeURIComponent(debouncedSearch)}`,
+ )
+ if (!response.ok) throw new Error(`search ${response.status}`)
+ return (await response.json()) as {
+ results: Array<{ path: string; title: string; line: number; text: string }>
+ }
+ },
+ staleTime: 15_000,
+ })
+ const searchResults = (searchResultsQuery.data?.results ?? []).slice(0, 8)
+ const graphQuery = useQuery({
+ queryKey: ['dashboard', 'knowledge-galaxy'],
+ queryFn: readKnowledgeGraph,
+ staleTime: 20_000,
+ refetchInterval: 45_000,
+ refetchIntervalInBackground: true,
+ })
+ const insightsQuery = useQuery({
+ queryKey: ['dashboard', 'knowledge-insights'],
+ queryFn: async (): Promise<{ ok: boolean; insights: VaultGraphInsights }> => {
+ const response = await fetch('/api/knowledge/insights')
+ if (!response.ok) throw new Error(`insights ${response.status}`)
+ return (await response.json()) as { ok: boolean; insights: VaultGraphInsights }
+ },
+ staleTime: 60_000,
+ refetchInterval: 120_000,
+ })
+ const queryClient = useQueryClient()
+ const proposeCleanup = useMutation({
+ mutationFn: async (rec: VaultGraphInsights['recommendations'][number]) => {
+ const response = await fetch('/api/knowledge/insights', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(rec),
+ })
+ if (!response.ok) throw new Error(`propose ${response.status}`)
+ return response.json()
+ },
+ onSuccess: () => {
+ void queryClient.invalidateQueries({ queryKey: ['taylor-approvals'] })
+ void queryClient.invalidateQueries({ queryKey: ['nova-fabric'] })
+ },
+ })
+ const model = useMemo(
+ () => buildGalaxyModel(graphQuery.data),
+ [graphQuery.data],
+ )
+ const bodyPlacements = useMemo(() => computeBodyPlacements(model), [model])
+ const nebulaRegions = useMemo(
+ () => buildNebulaRegions(model, bodyPlacements),
+ [model, bodyPlacements],
+ )
+ const selectedBody = selectedId
+ ? (model.bodyById.get(selectedId) ?? null)
+ : null
+ const inspectedBody = inspectedId
+ ? (model.bodyById.get(inspectedId) ?? null)
+ : null
+ // Operational galaxy: a selected real note is read from the vault via
+ // /api/knowledge/read (content + resolved backlinks) — no invented data.
+ const selectedNotePath =
+ inspectedBody && inspectedBody.path.toLowerCase().endsWith('.md')
+ ? inspectedBody.path
+ : null
+ const noteQuery = useQuery({
+ queryKey: ['dashboard', 'knowledge-note', selectedNotePath],
+ enabled: selectedNotePath !== null,
+ queryFn: async (): Promise<{
+ page: { title: string; path: string; modified: string }
+ content: string
+ backlinks: Array
+ }> => {
+ const response = await fetch(
+ `/api/knowledge/read?path=${encodeURIComponent(selectedNotePath ?? '')}`,
+ )
+ if (!response.ok) throw new Error(`read ${response.status}`)
+ return (await response.json()) as {
+ page: { title: string; path: string; modified: string }
+ content: string
+ backlinks: Array
+ }
+ },
+ staleTime: 60_000,
+ })
+ const hoveredBody = hoveredId ? (model.bodyById.get(hoveredId) ?? null) : null
+ const focusedBody = inspectedBody ?? hoveredBody ?? model.core
+ const largestSystems = model.systems.slice(0, 6)
+ const visibleComets = model.comets.slice(0, 3)
+
+ useEffect(() => {
+ if (!selectedId) {
+ setInspectedId(null)
+ return
+ }
+ const arrival = window.setTimeout(() => setInspectedId(selectedId), 860)
+ return () => window.clearTimeout(arrival)
+ }, [selectedId])
+
+ const clearSelection = useCallback(() => {
+ setSelectedId(null)
+ setInspectedId(null)
+ }, [])
+ useEscape(clearSelection)
+ const handleHover = useCallback(
+ (body: CelestialBody | null) => setHoveredId(body?.id ?? null),
+ [],
+ )
+ const handleSelect = useCallback(
+ (body: CelestialBody | null) => setSelectedId(body?.id ?? null),
+ [],
+ )
+ const toggleArm = (arm: GalaxyArm) => {
+ setDisabledArms((current) => {
+ const next = new Set(current)
+ if (next.has(arm.id)) next.delete(arm.id)
+ else next.add(arm.id)
+ return next
+ })
+ }
+
+ return (
+
+
+
+
+
+
Operational map
+
+ Nova’s working universe
+
+
+ Projects, agents, handoffs, and decisions mapped as a navigable
+ 3D field. Select a body to fly into its system.
+
+
+
+ {graphQuery.isFetching ? 'syncing' : 'vault live'} -{' '}
+ {model.totals.bodies} bodies - {model.totals.links} links
+
+
+
+
+
+
+
+ auto-sync 45s
+
+ {model.arms.slice(0, 7).map((arm) => {
+ const disabled = disabledArms.has(arm.id)
+ return (
+
toggleArm(arm)}
+ className={`rounded-full border px-2 py-1 font-mono text-[9px] uppercase tracking-[0.08em] transition-colors sm:text-[10px] sm:tracking-[0.12em] ${disabled ? 'border-[var(--theme-border-subtle)] bg-[color-mix(in srgb, var(--theme-card) 82%, transparent)] text-[var(--theme-muted-2)]' : 'border-[var(--theme-border)] bg-[var(--theme-accent-subtle)] text-[var(--theme-accent-secondary)]'}`}
+ aria-pressed={!disabled}
+ >
+ {arm.name}
+
+ )
+ })}
+
setThisWeekOnly((current) => !current)}
+ className={`rounded-full border px-2 py-1 font-mono text-[9px] uppercase tracking-[0.08em] transition-colors sm:text-[10px] sm:tracking-[0.12em] ${thisWeekOnly ? 'border-[var(--theme-border)] bg-[var(--theme-accent-subtle)] text-[var(--theme-accent-secondary)]' : 'border-[var(--theme-border-subtle)] bg-[color-mix(in srgb, var(--theme-card) 82%, transparent)] text-[var(--theme-muted-2)]'}`}
+ aria-pressed={thisWeekOnly}
+ >
+ this week
+
+
+
+ Search galaxy notes
+ setSearchTerm(event.target.value)}
+ placeholder="Search notes"
+ className="w-full bg-transparent font-mono text-[11px] text-[var(--theme-text)] outline-none placeholder:text-[var(--theme-muted-2)]"
+ />
+
+ {debouncedSearch && searchResults.length > 0 ? (
+
+ {searchResults.map((result) => {
+ const inGalaxy = model.bodyById.has(result.path)
+ return (
+ {
+ if (!inGalaxy) return
+ setSelectedId(result.path)
+ setSearchTerm(result.title)
+ }}
+ className={`flex w-full items-center justify-between gap-2 truncate rounded px-2 py-1 text-left font-mono text-[10px] ${
+ inGalaxy
+ ? 'text-[var(--theme-text-soft)] hover:bg-[var(--theme-accent-subtle)] hover:text-[var(--theme-accent-secondary)]'
+ : 'cursor-default text-[var(--theme-muted-2)]'
+ }`}
+ title={result.text}
+ >
+ {shortTitle(result.title)}
+ {!inGalaxy ? (
+
+ not in galaxy (tag/unlinked)
+
+ ) : null}
+
+ )
+ })}
+
+ ) : null}
+
+
+
+
+
+ color = cluster
+
+
+
+ size = links
+
+
+
+ pulse = hub
+
+
+
+ bright = this week
+
+
+
+ comet = orphan
+
+
+ {graphQuery.isError ? (
+
+
+
Vault signal lost
+
+ Knowledge graph endpoint did not answer. The dashboard will
+ reconnect automatically.
+
+
+
+ ) : null}
+ {!graphQuery.isLoading && model.totals.bodies === 0 ? (
+
+
+
No vault bodies yet
+
+ Add linked notes to the knowledge vault and this galaxy will
+ populate from Obsidian-style wikilinks.
+
+
+
+ ) : null}
+
+
+
+
+
+
Obsidian reflection
+
+
+
bodies
+
+ {model.totals.bodies}
+
+
+
+
systems
+
+ {model.totals.systems}
+
+
+
+
+
+
links
+
+ {model.totals.links}
+
+
+
+
comets
+
+ {model.totals.comets}
+
+
+
+
+
+
+
System inspector
+
+
+
+
+ {focusedBody
+ ? shortTitle(focusedBody.title)
+ : 'Waiting for vault'}
+
+
+ {focusedBody
+ ? `${bodyLabel(focusedBody)} - ${focusedBody.degree} links`
+ : 'sync pending'}
+
+
+ {focusedBody ? (
+
+ {focusedBody.folder}
+
+ ) : null}
+
+ {focusedBody ? (
+
+
+ modified {formatModified(focusedBody.modified)}
+
+
+ {focusedBody.excerpt || 'No preview text yet.'}
+
+ {focusedBody.kind === 'tag' ||
+ focusedBody.kind === 'comet' ? (
+
+ Open in Obsidian
+
+ ) : null}
+
+ ) : null}
+
+
+ {selectedNotePath ? (
+
+
Note (live from vault)
+ {noteQuery.isLoading ? (
+
+ Reading {selectedNotePath}…
+
+ ) : null}
+ {noteQuery.isError ? (
+
+ Could not read this note from the vault.
+
+ ) : null}
+ {noteQuery.data ? (
+ <>
+
+ {noteQuery.data.content.trim() || 'Empty note.'}
+
+
+
+ {noteQuery.data.backlinks.length} backlink
+ {noteQuery.data.backlinks.length === 1 ? '' : 's'}
+
+ {noteQuery.data.backlinks.length > 0 ? (
+
+ {noteQuery.data.backlinks.slice(0, 6).map((backlink) => (
+ setSelectedId(backlink)}
+ title={backlink}
+ className="max-w-full truncate rounded-full border border-[var(--theme-border)] px-2 py-0.5 font-mono text-[9px] text-[var(--theme-accent-secondary)] transition-colors hover:border-[var(--theme-accent-border)]"
+ >
+ {backlink.split('/').pop()?.replace(/\.md$/i, '') ?? backlink}
+
+ ))}
+
+ ) : null}
+
+ >
+ ) : null}
+
+ ) : null}
+
+
+
+
Planetary systems
+
+ {largestSystems.map((system) => (
+
setSelectedId(system.planet.id)}
+ className="w-full rounded-lg border border-[var(--theme-border-subtle)] bg-[var(--theme-card)]/55 px-3 py-2 text-left transition-colors hover:border-[var(--theme-accent-border)] hover:bg-[var(--theme-accent-subtle)]"
+ >
+
+
+ {shortTitle(system.planet.title).slice(0, 24)}
+
+
+ {system.bodyCount}
+
+
+
+ {system.folder} - {system.totalLinks} links
+
+
+ ))}
+
+
+
+ {insightsQuery.data?.insights ? (
+
+
Vault health
+
+ {insightsQuery.data.insights.orphans.length} orphans
+ {insightsQuery.data.insights.staleImportant.length} stale hubs
+ {insightsQuery.data.insights.duplicateCandidates.length} dupe candidates
+
+
+ {insightsQuery.data.insights.recommendations.slice(0, 3).map((rec) => (
+
+
+ {rec.title}
+
+
proposeCleanup.mutate(rec)}
+ className="mt-1 rounded border border-[var(--theme-border)] px-2 py-0.5 text-[10px] text-[var(--theme-text)] hover:border-[var(--theme-accent-border)] disabled:opacity-50"
+ >
+ Propose cleanup review
+
+
+ ))}
+
+
+ ) : null}
+
+ {visibleComets.length > 0 ? (
+
+
Passing comets
+
+ {visibleComets.map((comet) => (
+ setSelectedId(comet.id)}
+ className="flex w-full items-center justify-between gap-2 rounded-lg border border-[var(--theme-border-subtle)] bg-[color-mix(in srgb, var(--theme-card) 82%, transparent)] px-2 py-1.5 text-left hover:border-[var(--theme-accent-border)]"
+ >
+
+ {shortTitle(comet.title)}
+
+
+ orphan
+
+
+ ))}
+
+
+ ) : null}
+
+
+
+ )
+}
diff --git a/src/screens/dashboard/components/notebooklm-bridge-card.tsx b/src/screens/dashboard/components/notebooklm-bridge-card.tsx
new file mode 100644
index 0000000000..08070132da
--- /dev/null
+++ b/src/screens/dashboard/components/notebooklm-bridge-card.tsx
@@ -0,0 +1,170 @@
+import type {
+ DashboardNotebookBridgeSection,
+ NotebookBridgeStageStatus,
+} from '@/server/dashboard-aggregator'
+
+const STATUS_STYLE: Record = {
+ Ready:
+ 'border-[color-mix(in_srgb,var(--theme-success)_35%,var(--theme-border))] bg-[color-mix(in_srgb,var(--theme-success)_10%,transparent)] text-[var(--theme-success)]',
+ Manual:
+ 'border-[color-mix(in_srgb,var(--theme-warning)_35%,var(--theme-border))] bg-[color-mix(in_srgb,var(--theme-warning)_10%,transparent)] text-[var(--theme-warning)]',
+ Draft:
+ 'border-[color-mix(in_srgb,var(--theme-accent)_35%,var(--theme-border))] bg-[color-mix(in_srgb,var(--theme-accent)_10%,transparent)] text-[var(--theme-accent)]',
+ Blocked:
+ 'border-[color-mix(in_srgb,var(--theme-danger)_35%,var(--theme-border))] bg-[color-mix(in_srgb,var(--theme-danger)_10%,transparent)] text-[var(--theme-danger)]',
+}
+
+function StatusBadge({ status }: { status: NotebookBridgeStageStatus }) {
+ return (
+
+ {status}
+
+ )
+}
+
+function formatCompact(value: number): string {
+ if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`
+ if (value >= 1_000) return `${(value / 1_000).toFixed(1)}K`
+ return String(value)
+}
+
+function formatDate(value: string | null): string {
+ if (!value) return 'no vault pulse'
+ const ts = Date.parse(value)
+ if (!Number.isFinite(ts)) return 'vault pulse unknown'
+ return new Date(ts).toLocaleDateString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ })
+}
+
+export function NotebookLmBridgeCard({
+ bridge,
+}: {
+ bridge: DashboardNotebookBridgeSection | null
+}) {
+ if (!bridge) return null
+
+ return (
+
+
+
+
+
+
+
+ NotebookLM Bridge
+
+
+ Synthesis lane, not source of truth
+
+
+ Obsidian sources move through NotebookLM, then reviewed drafts
+ return with source links.
+
+
+
+ manual
+
+
+
+
+
+
+ {formatCompact(bridge.vaultNotes)}
+
+
+ notes
+
+
+
+
+ {formatCompact(bridge.vaultLinks)}
+
+
+ links
+
+
+
+
+ {formatDate(bridge.lastVaultUpdate)}
+
+
+ pulse
+
+
+
+
+
+
+ Flow
+
+
+
{bridge.canonicalSource} sources
+
+ to {bridge.synthesisLayer} synthesis
+
+
drafts to {bridge.writebackTargets.join(' / ')}
+
+
+
+
+ {bridge.stages.map((stage) => (
+
+
+
+
+ {stage.label}
+
+
+ {stage.summary}
+
+
+
+
+
+
+ ))}
+
+
+
+
+ Guardrails
+
+
+ {bridge.guardrails.map((guardrail) => (
+
+ {guardrail}
+
+ ))}
+
+
+
+
+ )
+}
diff --git a/src/screens/dashboard/components/nova-fabric-card.tsx b/src/screens/dashboard/components/nova-fabric-card.tsx
new file mode 100644
index 0000000000..985630793c
--- /dev/null
+++ b/src/screens/dashboard/components/nova-fabric-card.tsx
@@ -0,0 +1,613 @@
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
+import { useMemo, useState } from 'react'
+import type { FormEvent } from 'react'
+import type {
+ NovaFabric,
+ NovaFabricApprovalLevel,
+ NovaFabricConsolidationReviewRecord,
+ NovaFabricEventRecord,
+ NovaFabricReviewStatus,
+ NovaFabricRiskLevel,
+ NovaFabricSelfStateRecord,
+ NovaFabricSourceMapRecord,
+ NovaFabricVerificationState,
+} from '@/server/nova-fabric-store'
+import type { NovaWantsBoard } from '@/server/nova-wants-store'
+
+type NovaFabricResponse = {
+ ok: boolean
+ fabric: NovaFabric | null
+ storage?: {
+ path: string
+ backupPath: string
+ tempPath: string
+ seededFrom: string
+ }
+ degraded?: boolean
+ warning?: string | null
+ error?: string
+}
+
+type NovaWantsResponse = {
+ ok: boolean
+ board?: NovaWantsBoard
+ error?: string
+}
+
+const RISK_STYLE: Record = {
+ low: 'text-[var(--theme-muted)] border-[var(--theme-border)]',
+ medium:
+ 'text-[var(--theme-warning)] border-[color-mix(in_srgb,var(--theme-warning)_42%,var(--theme-border))]',
+ high: 'text-[var(--theme-danger)] border-[color-mix(in_srgb,var(--theme-danger)_42%,var(--theme-border))]',
+ critical:
+ 'text-[var(--theme-danger)] border-[color-mix(in_srgb,var(--theme-danger)_70%,var(--theme-border))]',
+}
+
+const VERIFY_STYLE: Record = {
+ draft: 'text-[var(--theme-warning)]',
+ inferred: 'text-[var(--theme-muted)]',
+ 'tool-verified': 'text-[var(--theme-success)]',
+ 'taylor-approved': 'text-[var(--theme-accent)]',
+}
+
+async function readNovaFabric(): Promise {
+ const response = await fetch('/api/nova-fabric')
+ const body: NovaFabricResponse | null = await response
+ .json()
+ .catch(() => null)
+ if (!response.ok || !body) {
+ throw new Error(body?.error || `nova-fabric ${response.status}`)
+ }
+ return body
+}
+
+async function readNovaWants(): Promise {
+ const response = await fetch('/api/nova-wants')
+ const body: NovaWantsResponse | null = await response.json().catch(() => null)
+ if (!response.ok || !body?.ok) {
+ throw new Error(body?.error || `nova-wants ${response.status}`)
+ }
+ return body
+}
+
+async function mutateNovaFabric(
+ method: 'POST' | 'PATCH',
+ body: Record,
+): Promise {
+ const response = await fetch('/api/nova-fabric', {
+ method,
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ })
+ const payload: NovaFabricResponse | null = await response
+ .json()
+ .catch(() => null)
+ if (!response.ok || !payload?.ok) {
+ throw new Error(payload?.error || `nova-fabric ${response.status}`)
+ }
+ return payload
+}
+
+function compactDate(value: string): string {
+ const date = new Date(value)
+ if (Number.isNaN(date.getTime())) return 'unknown'
+ return date.toLocaleString(undefined, {
+ month: 'short',
+ day: '2-digit',
+ hour: 'numeric',
+ minute: '2-digit',
+ })
+}
+
+function Pill({
+ children,
+ tone = 'normal',
+}: {
+ children: React.ReactNode
+ tone?: 'normal' | 'risk' | 'accent'
+}) {
+ const toneClass =
+ tone === 'risk'
+ ? 'border-[color-mix(in_srgb,var(--theme-danger)_42%,var(--theme-border))] text-[var(--theme-danger)]'
+ : tone === 'accent'
+ ? 'border-[color-mix(in_srgb,var(--theme-accent)_42%,var(--theme-border))] text-[var(--theme-accent)]'
+ : 'border-[var(--theme-border)] text-[var(--theme-muted)]'
+ return (
+
+ {children}
+
+ )
+}
+
+function Section({
+ title,
+ children,
+}: {
+ title: string
+ children: React.ReactNode
+}) {
+ return (
+
+
+ {title}
+
+ {children}
+
+ )
+}
+
+function RecordMeta({
+ riskLevel,
+ approvalLevel,
+ verificationState,
+}: {
+ riskLevel: NovaFabricRiskLevel
+ approvalLevel: NovaFabricApprovalLevel
+ verificationState: NovaFabricVerificationState
+}) {
+ return (
+
+
+ {riskLevel}
+
+
+ {approvalLevel}
+
+
+ {verificationState}
+
+
+ )
+}
+
+function ContinuityList({ events }: { events: Array }) {
+ return (
+
+ {events
+ .slice(-6)
+ .reverse()
+ .map((event) => (
+
+
+
+
+ {event.title}
+
+
+ {event.summary}
+
+
+
+ {compactDate(event.createdAt)}
+
+
+
+
+ ))}
+
+ )
+}
+
+function SelfStateList({
+ records,
+ onPropose,
+}: {
+ records: Array
+ onPropose: (record: NovaFabricSelfStateRecord) => void
+}) {
+ return (
+
+ {records
+ .filter((record) => record.current)
+ .map((record) => (
+
+
+
+
+ {record.title}
+
+
+ v{record.version} / {record.category}
+
+
+
onPropose(record)}
+ className="rounded border border-[var(--theme-border)] px-2 py-1 font-mono text-[9px] uppercase tracking-[0.12em] text-[var(--theme-accent)]"
+ >
+ propose
+
+
+
+ {record.body}
+
+
+
+ ))}
+
+ )
+}
+
+function SourceMapTable({
+ records,
+}: {
+ records: Array
+}) {
+ return (
+
+
+
+
+ domain
+ source of truth
+ policy
+ Mission Control behavior
+
+
+
+ {records.map((record) => (
+
+
+ {record.domain}
+
+
+ {record.sourceOfTruth}
+
+
+
+ {record.policy}
+
+
+
+ {record.missionControlBehavior}
+
+
+ ))}
+
+
+
+ )
+}
+
+function ReviewQueue({
+ records,
+ onStatus,
+}: {
+ records: Array
+ onStatus: (
+ record: NovaFabricConsolidationReviewRecord,
+ status: NovaFabricReviewStatus,
+ ) => void
+}) {
+ const visible = records.filter((record) => record.status === 'pending')
+ if (!visible.length) {
+ return (
+
+ No pending consolidation proposals.
+
+ )
+ }
+ return (
+
+ {visible.slice(0, 5).map((record) => (
+
+
+
+
+ {record.title}
+
+
+ {record.reason}
+
+
+
{record.targetType}
+
+
+
+ onStatus(record, 'approved')}
+ className="rounded border border-[var(--theme-accent-border)] px-2 py-1 font-mono text-[9px] uppercase tracking-[0.12em] text-[var(--theme-accent)]"
+ >
+ mark approved
+
+ onStatus(record, 'parked')}
+ className="rounded border border-[var(--theme-border)] px-2 py-1 font-mono text-[9px] uppercase tracking-[0.12em] text-[var(--theme-muted)]"
+ >
+ park
+
+
+
+ ))}
+
+ )
+}
+
+export function NovaFabricCard() {
+ const queryClient = useQueryClient()
+ const [eventTitle, setEventTitle] = useState('')
+ const [eventSummary, setEventSummary] = useState('')
+ const [proposalTarget, setProposalTarget] =
+ useState(null)
+ const [proposalBody, setProposalBody] = useState('')
+ const [error, setError] = useState(null)
+
+ const fabricQuery = useQuery({
+ queryKey: ['nova-fabric'],
+ queryFn: readNovaFabric,
+ staleTime: 5_000,
+ refetchInterval: 30_000,
+ })
+ const wantsQuery = useQuery({
+ queryKey: ['nova-wants'],
+ queryFn: readNovaWants,
+ staleTime: 10_000,
+ refetchInterval: 45_000,
+ })
+
+ const mutation = useMutation({
+ mutationFn: ({
+ method,
+ body,
+ }: {
+ method: 'POST' | 'PATCH'
+ body: Record
+ }) => mutateNovaFabric(method, body),
+ onSuccess: () => {
+ setError(null)
+ void queryClient.invalidateQueries({ queryKey: ['nova-fabric'] })
+ },
+ onError: (err) => {
+ setError(err instanceof Error ? err.message : 'Nova Fabric update failed')
+ },
+ })
+
+ const fabric = fabricQuery.data?.fabric ?? null
+ const fabricLinkedWantCount = useMemo(() => {
+ const cards = wantsQuery.data?.board?.cards ?? []
+ return cards.filter(
+ (card) =>
+ card.fabricEventIds.length ||
+ card.fabricSelfStateIds.length ||
+ card.fabricSourceMapIds.length ||
+ card.fabricReviewIds.length,
+ ).length
+ }, [wantsQuery.data?.board?.cards])
+
+ const appendEvent = (event: FormEvent) => {
+ event.preventDefault()
+ mutation.mutate({
+ method: 'POST',
+ body: {
+ action: 'append-event',
+ title: eventTitle,
+ summary: eventSummary,
+ verificationState: 'draft',
+ },
+ })
+ setEventTitle('')
+ setEventSummary('')
+ }
+
+ const submitProposal = (event: FormEvent) => {
+ event.preventDefault()
+ if (!proposalTarget) return
+ mutation.mutate({
+ method: 'PATCH',
+ body: {
+ action: 'propose-self-state',
+ targetId: proposalTarget.id,
+ proposedDiff: { body: proposalBody },
+ reason: 'Mission Control self-state proposal; requires Taylor review.',
+ },
+ })
+ setProposalTarget(null)
+ setProposalBody('')
+ }
+
+ if (fabricQuery.isLoading) {
+ return (
+
+ Loading Nova Fabric...
+
+ )
+ }
+
+ if (fabricQuery.data?.degraded || !fabric) {
+ return (
+
+
+ Fabric degraded
+
+
+ {fabricQuery.data?.warning ||
+ fabricQuery.error?.message ||
+ 'Fabric degraded: continuity file needs repair.'}
+
+
+ {fabricQuery.data?.storage?.path}
+
+
+ )
+ }
+
+ return (
+
+
+
+
+ Nova Fabric
+
+
+ Continuity fabric
+
+
+ Local, source-linked continuity state. Mission Control is a lens;
+ external systems stay read-only or proposal-gated here.
+
+
+
+
{fabric.events.length} events
+
+ {fabric.selfState.filter((record) => record.current).length}{' '}
+ self-state
+
+
{fabric.sourceMap.length} sources
+
+ {
+ fabric.reviewQueue.filter((record) => record.status === 'pending')
+ .length
+ }{' '}
+ reviews
+
+
+
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+
+
+
+
+ {
+ setProposalTarget(record)
+ setProposalBody(record.body)
+ }}
+ />
+ {proposalTarget ? (
+
+ ) : null}
+
+
+
+
+
+
+ mutation.mutate({
+ method: 'PATCH',
+ body: {
+ action: 'update-review-status',
+ id: record.id,
+ status,
+ },
+ })
+ }
+ />
+
+
+ Wants lens
+
+
+ {fabricLinkedWantCount} Nova Wants cards are linked to Fabric
+ records. The board remains the request lane; this panel is the
+ continuity spine.
+
+
+ {fabricQuery.data?.storage?.path}
+
+
+
+
+
+ )
+}
diff --git a/src/screens/dashboard/components/nova-galaxy-model-v2.test.ts b/src/screens/dashboard/components/nova-galaxy-model-v2.test.ts
new file mode 100644
index 0000000000..469ed382db
--- /dev/null
+++ b/src/screens/dashboard/components/nova-galaxy-model-v2.test.ts
@@ -0,0 +1,214 @@
+import { describe, expect, it } from 'vitest'
+import {
+ HUB_DEGREE_THRESHOLD,
+ armRankPlacement,
+ armTurnsForNoteCount,
+ clusterHue,
+ emberSize,
+ folderTintFor,
+ gaussianFrom,
+ isHub,
+ mulberry32,
+ recencyGlow,
+} from './nova-galaxy-model'
+
+describe('mulberry32 + gaussianFrom', () => {
+ it('is deterministic for a fixed seed', () => {
+ const a = mulberry32(42)
+ const b = mulberry32(42)
+ expect(a()).toBe(b())
+ expect(a()).toBe(b())
+ })
+
+ it('produces different sequences for different seeds', () => {
+ const a = mulberry32(1)()
+ const b = mulberry32(2)()
+ expect(a).not.toBe(b)
+ })
+
+ it('gaussianFrom stays finite across many draws', () => {
+ const rng = mulberry32(7)
+ for (let i = 0; i < 200; i += 1) {
+ expect(Number.isFinite(gaussianFrom(rng))).toBe(true)
+ }
+ })
+})
+
+describe('armTurnsForNoteCount', () => {
+ it('grows with the arm real note count — more notes, longer (more-wound) arm', () => {
+ expect(armTurnsForNoteCount(10)).toBeLessThan(armTurnsForNoteCount(40))
+ expect(armTurnsForNoteCount(40)).toBeLessThan(armTurnsForNoteCount(200))
+ })
+
+ it('clamps to [1.6, 6.4]', () => {
+ expect(armTurnsForNoteCount(0)).toBe(1.6)
+ expect(armTurnsForNoteCount(-5)).toBe(1.6)
+ expect(armTurnsForNoteCount(10_000)).toBe(6.4)
+ })
+})
+
+describe('armRankPlacement', () => {
+ it('is deterministic for identical inputs', () => {
+ const a = armRankPlacement(3, 12, 1, 4, 'note-a.md')
+ const b = armRankPlacement(3, 12, 1, 4, 'note-a.md')
+ expect(a).toEqual(b)
+ })
+
+ it('produces different jittered output for a different seedKey', () => {
+ const a = armRankPlacement(3, 12, 1, 4, 'note-a.md')
+ const b = armRankPlacement(3, 12, 1, 4, 'note-b.md')
+ expect(a).not.toEqual(b)
+ })
+
+ it('radius strictly grows with rank (raw, before jitter)', () => {
+ const armNoteCount = 24
+ let previousRadius = -Infinity
+ for (let rank = 0; rank < armNoteCount; rank += 1) {
+ const point = armRankPlacement(rank, armNoteCount, 0, 3, 'seed', {
+ raw: true,
+ })
+ const radius = Math.hypot(point.x, point.z)
+ expect(
+ radius,
+ `rank ${rank}: radius ${radius} should exceed previous ${previousRadius}`,
+ ).toBeGreaterThan(previousRadius)
+ previousRadius = radius
+ }
+ })
+
+ it('tNorm walks from just-off-core toward the tip as rank increases', () => {
+ const near = armRankPlacement(0, 20, 0, 3, 'seed')
+ const far = armRankPlacement(19, 20, 0, 3, 'seed')
+ expect(near.tNorm).toBeLessThan(far.tNorm)
+ expect(near.tNorm).toBeGreaterThan(0)
+ expect(far.tNorm).toBeLessThan(1)
+ })
+
+ it('a longer (more-populated) arm reaches farther at its tip than a shorter one', () => {
+ // Same relative tip rank (last note in the arm), but a much larger
+ // armNoteCount both raises `turns` (armTurnsForNoteCount) and pushes
+ // tNorm closer to 1 — the growth mechanic: more notes, longer arm.
+ const shortArmTip = armRankPlacement(9, 10, 0, 3, 'seed', { raw: true })
+ const longArmTip = armRankPlacement(199, 200, 0, 3, 'seed', { raw: true })
+ const shortRadius = Math.hypot(shortArmTip.x, shortArmTip.z)
+ const longRadius = Math.hypot(longArmTip.x, longArmTip.z)
+ expect(longRadius).toBeGreaterThan(shortRadius)
+ })
+
+ it('distinct arms never share an armOffset — same rank/count/seed places differently per arm', () => {
+ const armCount = 5
+ const seen = new Set()
+ for (let armIndex = 0; armIndex < armCount; armIndex += 1) {
+ const point = armRankPlacement(2, 10, armIndex, armCount, 'seed', {
+ raw: true,
+ })
+ const key = `${point.x.toFixed(6)}:${point.z.toFixed(6)}`
+ expect(seen.has(key), `armIndex ${armIndex} collided with a prior arm`).toBe(
+ false,
+ )
+ seen.add(key)
+ }
+ })
+
+ it('stays deterministic across repeated calls even with jitter enabled', () => {
+ for (let i = 0; i < 20; i += 1) {
+ const a = armRankPlacement(i, 30, 2, 4, `note-${i}.md`)
+ const b = armRankPlacement(i, 30, 2, 4, `note-${i}.md`)
+ expect(a).toEqual(b)
+ }
+ })
+})
+
+describe('clusterHue', () => {
+ it('cycles through the 4-hue palette deterministically', () => {
+ expect(clusterHue(0)).toBe('blue')
+ expect(clusterHue(1)).toBe('blue2')
+ expect(clusterHue(2)).toBe('amber')
+ expect(clusterHue(3)).toBe('blend')
+ expect(clusterHue(4)).toBe('blue')
+ })
+
+ it('is stable for the same folderIndex', () => {
+ expect(clusterHue(11)).toBe(clusterHue(11))
+ })
+})
+
+describe('recencyGlow', () => {
+ const now = '2026-07-10T12:00:00.000Z'
+
+ it('is 1.0 for anything modified within the last 7 days', () => {
+ expect(recencyGlow('2026-07-09T12:00:00.000Z', now)).toBe(1)
+ expect(recencyGlow(now, now)).toBe(1)
+ })
+
+ it('falls off linearly and clamps at 0.35 for anything 90+ days old', () => {
+ expect(recencyGlow('2026-04-11T12:00:00.000Z', now)).toBe(0.35)
+ expect(recencyGlow('2020-01-01T00:00:00.000Z', now)).toBe(0.35)
+ })
+
+ it('sits strictly between 0.35 and 1 at the midpoint of the falloff window', () => {
+ const midpoint = new Date(
+ Date.parse('2026-07-03T12:00:00.000Z') -
+ ((90 - 7) / 2) * 86_400_000,
+ ).toISOString()
+ const glow = recencyGlow(midpoint, now)
+ expect(glow).toBeGreaterThan(0.35)
+ expect(glow).toBeLessThan(1)
+ })
+
+ it('falls back to 0.35 for unparseable dates', () => {
+ expect(recencyGlow('not-a-date', now)).toBe(0.35)
+ })
+})
+
+describe('emberSize + isHub', () => {
+ it('grows monotonically with degree', () => {
+ expect(emberSize(0)).toBeLessThan(emberSize(3))
+ expect(emberSize(3)).toBeLessThan(emberSize(10))
+ })
+
+ it('is 1 at degree 0', () => {
+ expect(emberSize(0)).toBe(1)
+ })
+
+ it('flags hubs at the documented threshold', () => {
+ expect(HUB_DEGREE_THRESHOLD).toBe(6)
+ expect(isHub(5)).toBe(false)
+ expect(isHub(6)).toBe(true)
+ expect(isHub(20)).toBe(true)
+ })
+})
+
+describe('galaxy tint tripwire — canon blues/ambers only', () => {
+ const CANON = new Set([
+ '#FFB347',
+ '#FF8C1A',
+ '#FFD27A',
+ '#63C7FF',
+ '#9DDCFF',
+ '#2E7FD9',
+ ])
+ const FOLDERS = [
+ 'agents/claude',
+ 'agents/gpt',
+ 'agents/kimi',
+ 'knowledge',
+ 'inbox',
+ 'field stars',
+ 'totally-unknown-folder',
+ 'random/deep/path',
+ ]
+
+ it('folderTintFor returns only canon blues/ambers for every folder shape', () => {
+ for (const folder of FOLDERS) {
+ const tint = folderTintFor(folder)
+ expect(CANON.has(tint), `folderTintFor("${folder}") returned off-canon tint ${tint}`).toBe(true)
+ }
+ })
+
+ it('the unknown-folder default is never green or rose', () => {
+ const tint = folderTintFor('zzz-not-a-real-folder')
+ expect(tint).not.toBe('#7D9573')
+ expect(CANON.has(tint)).toBe(true)
+ })
+})
diff --git a/src/screens/dashboard/components/nova-galaxy-model.test.ts b/src/screens/dashboard/components/nova-galaxy-model.test.ts
new file mode 100644
index 0000000000..d6d61f9b8f
--- /dev/null
+++ b/src/screens/dashboard/components/nova-galaxy-model.test.ts
@@ -0,0 +1,287 @@
+import { describe, expect, it } from 'vitest'
+import {
+ buildGalaxyModel,
+ focusBodyForNavigation,
+ focusDistanceForSystem,
+ folderTintFor,
+ resolveProjectedLabels,
+ selectLabelCandidates,
+} from './nova-galaxy-model'
+import type {
+ CelestialBody,
+ KnowledgeGraphResponse,
+ PlanetarySystem,
+} from './nova-galaxy-model'
+
+function graph(): KnowledgeGraphResponse {
+ return {
+ nodes: [
+ { id: 'hub.md', title: 'Hub note', folder: 'agents/claude', path: 'hub.md' },
+ { id: 'a.md', title: 'Note A', folder: 'agents/claude', path: 'a.md' },
+ { id: 'b.md', title: 'Note B', folder: 'agents/claude', path: 'b.md' },
+ { id: 'c.md', title: 'Note C', folder: 'knowledge', path: 'c.md' },
+ { id: 'd.md', title: 'Note D', folder: 'knowledge', path: 'd.md' },
+ { id: 'e.md', title: 'Note E', folder: 'agents/gpt', path: 'e.md' },
+ { id: 'f.md', title: 'Note F', folder: 'agents/gpt', path: 'f.md' },
+ { id: 'g.md', title: 'Note G', folder: 'agents/gpt', path: 'g.md' },
+ {
+ id: 'lonely.md',
+ title: 'Lonely note',
+ folder: 'inbox',
+ path: 'lonely.md',
+ },
+ ],
+ edges: [
+ { source: 'hub.md', target: 'a.md' },
+ { source: 'hub.md', target: 'b.md' },
+ { source: 'a.md', target: 'hub.md' },
+ { source: 'c.md', target: 'hub.md' },
+ { source: 'c.md', target: 'd.md' },
+ { source: 'e.md', target: 'f.md' },
+ { source: 'e.md', target: 'g.md' },
+ { source: 'f.md', target: 'e.md' },
+ ],
+ }
+}
+
+function distance3(
+ a: { baseX: number; baseY: number; baseZ: number },
+ b: { baseX: number; baseY: number; baseZ: number },
+): number {
+ const dx = a.baseX - b.baseX
+ const dy = a.baseY - b.baseY
+ const dz = a.baseZ - b.baseZ
+ return Math.sqrt(dx * dx + dy * dy + dz * dz)
+}
+
+describe('buildGalaxyModel', () => {
+ it('crowns the most-linked note as the core', () => {
+ const model = buildGalaxyModel(graph())
+ expect(model.core?.id).toBe('hub.md')
+ expect(model.core?.kind).toBe('core')
+ })
+
+ it('ranks highly connected hubs above passive notes for operational sizing', () => {
+ const model = buildGalaxyModel(graph())
+ const core = model.bodyById.get('hub.md')
+ const leaf = model.bodyById.get('a.md')
+
+ expect(core?.importance).toBeGreaterThan(leaf?.importance ?? 0)
+ })
+
+ it('navigates a selected idea into its owning system hub', () => {
+ const model = buildGalaxyModel(graph())
+ const leaf = model.bodyById.get('a.md') ?? null
+
+ expect(focusBodyForNavigation(model, leaf)?.id).toBe('hub.md')
+ })
+
+ it('turns orphan notes into comets', () => {
+ const model = buildGalaxyModel(graph())
+ const lonely = model.bodyById.get('lonely.md')
+ expect(lonely?.kind).toBe('comet')
+ })
+
+ it('assigns bodies to arms derived from folders', () => {
+ const model = buildGalaxyModel(graph())
+ expect(model.arms.length).toBeGreaterThan(0)
+ const a = model.bodyById.get('a.md')
+ expect(a?.armId).toBeTruthy()
+ })
+
+ it('is deterministic for identical input', () => {
+ const first = buildGalaxyModel(graph())
+ const second = buildGalaxyModel(graph())
+ const firstBody = first.bodyById.get('a.md')
+ const secondBody = second.bodyById.get('a.md')
+ expect(firstBody?.baseX).toBe(secondBody?.baseX)
+ expect(firstBody?.baseY).toBe(secondBody?.baseY)
+ expect(firstBody?.baseZ).toBe(secondBody?.baseZ)
+ })
+
+ it('represents every real note exactly once and invents none', () => {
+ const model = buildGalaxyModel(graph())
+ const inputIds = new Set(graph().nodes?.map((node) => node.id))
+ for (const id of model.bodyById.keys()) {
+ expect(inputIds.has(id)).toBe(true)
+ }
+ expect(model.bodyById.size).toBe(inputIds.size)
+ })
+
+ it('tolerates an empty graph without throwing', () => {
+ const model = buildGalaxyModel({ nodes: [], edges: [] })
+ expect(model.bodyById.size).toBeGreaterThanOrEqual(0)
+ })
+
+ it('separates major systems into distinct operational neighborhoods', () => {
+ const model = buildGalaxyModel(graph())
+ const planets = model.systems.map((system) => system.planet)
+ expect(planets.length).toBeGreaterThanOrEqual(2)
+
+ let minPlanetDistance = Number.POSITIVE_INFINITY
+ for (let i = 0; i < planets.length; i += 1) {
+ for (let j = i + 1; j < planets.length; j += 1) {
+ minPlanetDistance = Math.min(
+ minPlanetDistance,
+ distance3(planets[i], planets[j]),
+ )
+ }
+ }
+ expect(minPlanetDistance).toBeGreaterThan(18)
+
+ const depths = planets.map((planet) => planet.baseZ)
+ const depthSpan = Math.max(...depths) - Math.min(...depths)
+ const heights = planets.map((planet) => planet.baseY)
+ const heightSpan = Math.max(...heights) - Math.min(...heights)
+ expect(depthSpan + heightSpan).toBeGreaterThan(24)
+ })
+
+ it('keeps tags near their planet but not stacked into a single point', () => {
+ const model = buildGalaxyModel(graph())
+ const system = model.systems.find((entry) => entry.tags.length >= 2)
+ expect(system).toBeTruthy()
+ if (!system) return
+
+ for (const tag of system.tags) {
+ const radial = distance3(tag, system.planet)
+ expect(radial).toBeGreaterThan(7)
+ expect(radial).toBeLessThan(42)
+ }
+ })
+})
+
+describe('folderTintFor', () => {
+ it('maps known operational folders to distinct system colors', () => {
+ expect(folderTintFor('agents/claude')).toBe('#FFB347')
+ expect(folderTintFor('agents/gpt')).toBe('#FF8C1A')
+ expect(folderTintFor('agents/kimi')).toBe('#63C7FF')
+ expect(folderTintFor('knowledge')).toBe('#9DDCFF')
+ expect(folderTintFor('inbox')).toBe('#2E7FD9')
+ expect(folderTintFor('misc-projects')).toBe('#9DDCFF')
+ })
+})
+
+describe('focusDistanceForSystem', () => {
+ it('pulls closer for small systems and stays farther for large ones', () => {
+ const small = {
+ bodyCount: 3,
+ sizeTier: 1,
+ planet: { kind: 'planet', sizeTier: 1, importance: 10 },
+ } as PlanetarySystem
+ const large = {
+ bodyCount: 48,
+ sizeTier: 5,
+ planet: { kind: 'core', sizeTier: 5, importance: 80 },
+ } as PlanetarySystem
+
+ const near = focusDistanceForSystem(small)
+ const far = focusDistanceForSystem(large)
+ expect(far).toBeGreaterThan(near)
+ expect(near).toBeGreaterThanOrEqual(12)
+ expect(far).toBeLessThanOrEqual(36)
+ })
+})
+
+describe('selectLabelCandidates / resolveProjectedLabels', () => {
+ it('limits overview labels to hubs and a few high-importance tags', () => {
+ const model = buildGalaxyModel(graph())
+ const candidates = selectLabelCandidates({
+ model,
+ selectedBody: null,
+ hoveredBody: null,
+ searchTerm: '',
+ mode: 'overview',
+ })
+
+ const planetCount = candidates.filter((item) => item.kind === 'planet').length
+ const tagCount = candidates.filter((item) => item.kind === 'tag').length
+ expect(planetCount).toBeLessThanOrEqual(8)
+ expect(tagCount).toBeLessThanOrEqual(4)
+ expect(candidates.some((item) => item.body.kind === 'core')).toBe(true)
+ })
+
+ it('reveals more local tags when a system is focused', () => {
+ const model = buildGalaxyModel(graph())
+ const leaf = model.bodyById.get('a.md') ?? null
+ const focused = selectLabelCandidates({
+ model,
+ selectedBody: leaf,
+ hoveredBody: null,
+ searchTerm: '',
+ mode: 'focus',
+ })
+ const overview = selectLabelCandidates({
+ model,
+ selectedBody: null,
+ hoveredBody: null,
+ searchTerm: '',
+ mode: 'overview',
+ })
+ const focusedTags = focused.filter((item) => item.kind === 'tag').length
+ const overviewTags = overview.filter((item) => item.kind === 'tag').length
+ expect(focusedTags).toBeGreaterThan(overviewTags)
+ })
+
+ it('keeps focus labels local instead of flooding every system', () => {
+ const model = buildGalaxyModel(graph())
+ const leaf = model.bodyById.get('a.md') ?? null
+ const selectedSystem = leaf
+ ? model.systemByBodyId.get(leaf.id)
+ : undefined
+ const focused = selectLabelCandidates({
+ model,
+ selectedBody: leaf,
+ hoveredBody: null,
+ searchTerm: '',
+ mode: 'focus',
+ })
+
+ expect(focused.length).toBeLessThanOrEqual(22)
+ const foreignTags = focused.filter(
+ (item) =>
+ item.kind === 'tag' &&
+ item.body.systemId &&
+ item.body.systemId !== selectedSystem?.id,
+ )
+ expect(foreignTags.length).toBe(0)
+ })
+
+ it('rejects overlapping lower-priority labels in screen space', () => {
+ const model = buildGalaxyModel(graph())
+ const core = model.core
+ const leaf = model.bodyById.get('a.md')
+ expect(core && leaf).toBeTruthy()
+ if (!core || !leaf) return
+
+ const resolved = resolveProjectedLabels(
+ [
+ {
+ id: core.id,
+ kind: 'planet',
+ body: core,
+ x: 100,
+ y: 100,
+ scale: 1,
+ opacity: 1,
+ active: true,
+ priority: 100,
+ },
+ {
+ id: leaf.id,
+ kind: 'tag',
+ body: leaf,
+ x: 104,
+ y: 102,
+ scale: 0.8,
+ opacity: 0.5,
+ active: false,
+ priority: 10,
+ },
+ ],
+ 28,
+ )
+
+ expect(resolved).toHaveLength(1)
+ expect(resolved[0]?.id).toBe(core.id)
+ })
+})
diff --git a/src/screens/dashboard/components/nova-galaxy-model.ts b/src/screens/dashboard/components/nova-galaxy-model.ts
new file mode 100644
index 0000000000..53e8a013f4
--- /dev/null
+++ b/src/screens/dashboard/components/nova-galaxy-model.ts
@@ -0,0 +1,1022 @@
+export type KnowledgeGraphNode = {
+ id: string
+ title: string
+ path?: string
+ folder?: string
+ type?: string
+ tags?: Array
+ modified?: string
+ updated?: string
+ summary?: string
+ excerpt?: string
+ wikilinks?: Array
+}
+
+export type KnowledgeGraphEdge = {
+ source: string
+ target: string
+}
+
+export type KnowledgeGraphResponse = {
+ nodes?: Array
+ edges?: Array
+}
+
+export type CelestialKind = 'core' | 'planet' | 'tag' | 'comet'
+
+export type GalaxyArm = {
+ id: string
+ name: string
+ index: number
+ angle: number
+ bodyCount: number
+ systemCount: number
+ tint: string
+}
+
+export type CelestialBody = KnowledgeGraphNode & {
+ id: string
+ title: string
+ path: string
+ folder: string
+ kind: CelestialKind
+ degree: number
+ importance: number
+ systemId?: string
+ planetId?: string
+ armId: string
+ armIndex: number
+ sizeTier: number
+ recencyTier: 'hot' | 'warm' | 'cool'
+ recencyAgeHours: number
+ orbitRadius: number
+ orbitPeriod: number
+ orbitPhase: number
+ orbitTilt: number
+ baseX: number
+ baseY: number
+ baseZ: number
+ jitter: number
+ excerpt: string
+}
+
+export type PlanetarySystem = {
+ id: string
+ folder: string
+ armId: string
+ armIndex: number
+ planet: CelestialBody
+ tags: Array
+ moons: Array
+ bodyCount: number
+ totalLinks: number
+ sizeTier: number
+ baseX: number
+ baseY: number
+ baseZ: number
+ armPosition: number
+}
+
+export type GalaxyConstellationLink = KnowledgeGraphEdge & {
+ strength: number
+}
+
+export type GalaxyModel = {
+ arms: Array
+ systems: Array
+ bodies: Array
+ comets: Array
+ core: CelestialBody | null
+ bodyById: Map
+ systemById: Map
+ systemByBodyId: Map
+ links: Array
+ totals: {
+ bodies: number
+ links: number
+ systems: number
+ comets: number
+ }
+}
+
+export type LabelKind = 'planet' | 'tag'
+
+export type LabelCandidate = {
+ id: string
+ kind: LabelKind
+ body: CelestialBody
+ priority: number
+}
+
+export type ProjectedLabel = LabelCandidate & {
+ x: number
+ y: number
+ scale: number
+ opacity: number
+ active: boolean
+}
+
+export function focusBodyForNavigation(
+ model: GalaxyModel,
+ body: CelestialBody | null,
+): CelestialBody | null {
+ if (!body) return null
+ return model.systemByBodyId.get(body.id)?.planet ?? body
+}
+
+// Canon galaxy tints ONLY: neon blues + ambers (deep navy / neon blues /
+// amber glows). The previous palette here (mustard/rust/rose/olive and a
+// GREEN default) was the hidden second color system that kept repainting
+// every ember with pre-canon colors no matter what the theme passes fixed.
+// nova-galaxy-model-v2.test.ts now tripwires this function.
+export function folderTintFor(folder: string): string {
+ const key = folder.trim().toLowerCase()
+ if (key === 'agents/claude' || key.startsWith('agents/claude/'))
+ return '#FFB347'
+ if (key === 'agents/gpt' || key.startsWith('agents/gpt/')) return '#FF8C1A'
+ if (key === 'agents/kimi' || key.startsWith('agents/kimi/')) return '#63C7FF'
+ if (key === 'knowledge' || key.startsWith('knowledge/')) return '#9DDCFF'
+ if (key === 'inbox' || key.startsWith('inbox/')) return '#2E7FD9'
+ if (key.includes('claude')) return '#FFB347'
+ if (key.includes('gpt') || key.includes('codex')) return '#FF8C1A'
+ if (key.includes('kimi') || key.includes('nova')) return '#63C7FF'
+ if (key.includes('knowledge') || key.includes('notes')) return '#9DDCFF'
+ if (key.includes('inbox') || key.includes('triage')) return '#2E7FD9'
+ return '#9DDCFF'
+}
+
+export function focusDistanceForSystem(
+ system: Pick & {
+ planet: Pick
+ },
+): number {
+ const mass =
+ Math.sqrt(Math.max(1, system.bodyCount)) * 1.85 +
+ system.sizeTier * 1.35 +
+ Math.log2(system.planet.importance + 1) * 0.9
+ const coreBonus = system.planet.kind === 'core' ? 3.5 : 0
+ return clamp(12.5 + mass + coreBonus, 12, 36)
+}
+
+export function selectLabelCandidates(input: {
+ model: GalaxyModel
+ selectedBody: CelestialBody | null
+ hoveredBody: CelestialBody | null
+ searchTerm: string
+ mode: 'overview' | 'focus'
+}): Array {
+ const query = input.searchTerm.trim().toLowerCase()
+ const selectedSystem = input.selectedBody
+ ? input.model.systemByBodyId.get(input.selectedBody.id)
+ : undefined
+ const hoveredSystem = input.hoveredBody
+ ? input.model.systemByBodyId.get(input.hoveredBody.id)
+ : undefined
+ const activeSystemIds = new Set()
+ if (selectedSystem) activeSystemIds.add(selectedSystem.id)
+ if (hoveredSystem) activeSystemIds.add(hoveredSystem.id)
+
+ const candidates = new Map()
+ const push = (body: CelestialBody, kind: LabelKind, priority: number) => {
+ const existing = candidates.get(body.id)
+ if (existing && existing.priority >= priority) return
+ candidates.set(body.id, {
+ id: body.id,
+ kind,
+ body,
+ priority,
+ })
+ }
+
+ const rankedSystems = [...input.model.systems].sort(
+ (a, b) =>
+ b.planet.importance - a.planet.importance ||
+ b.totalLinks - a.totalLinks ||
+ a.planet.title.localeCompare(b.planet.title),
+ )
+
+ for (const system of rankedSystems) {
+ const isActive = activeSystemIds.has(system.id)
+ const isCore = system.planet.kind === 'core'
+ const overviewHub =
+ input.mode === 'overview' &&
+ (isCore ||
+ rankedSystems.indexOf(system) < 6 ||
+ system.planet.importance >= 24)
+ if (isActive || isCore || overviewHub || query) {
+ push(
+ system.planet,
+ 'planet',
+ (isCore ? 120 : 80) +
+ system.planet.importance +
+ (isActive ? 40 : 0),
+ )
+ }
+
+ const tagBudget = isActive
+ ? 12
+ : query
+ ? 6
+ : overviewHub
+ ? 1
+ : 0
+ // While a cluster is focused, force hubs (by degree) ahead of the
+ // importance sort so top hub embers never lose their budget slot to
+ // a recently-touched non-hub tag.
+ const rankedTags = [...system.tags].sort((a, b) => {
+ if (isActive) {
+ const hubDelta = Number(isHub(b.degree)) - Number(isHub(a.degree))
+ if (hubDelta !== 0) return hubDelta
+ }
+ return b.importance - a.importance || a.title.localeCompare(b.title)
+ })
+ let shown = 0
+ for (const tag of rankedTags) {
+ const matches =
+ Boolean(query) &&
+ (tag.title.toLowerCase().includes(query) ||
+ tag.path.toLowerCase().includes(query) ||
+ tag.folder.toLowerCase().includes(query))
+ const selected =
+ tag.id === input.selectedBody?.id || tag.id === input.hoveredBody?.id
+ // Search matches and explicit selection always win; otherwise stay
+ // inside the local budget so focus mode does not open the whole vault.
+ if (!selected && !matches && tagBudget === 0) continue
+ if (!selected && !matches && shown >= tagBudget) continue
+ if (
+ !selected &&
+ !matches &&
+ !isActive &&
+ overviewHub &&
+ tag.importance < 12 &&
+ tag.recencyTier === 'cool'
+ ) {
+ continue
+ }
+ push(
+ tag,
+ 'tag',
+ (selected ? 70 : isActive ? 40 : matches ? 30 : 12) +
+ tag.importance +
+ (tag.recencyTier === 'hot' ? 8 : 0),
+ )
+ if (!selected) shown += 1
+ if (!selected && !matches && shown >= tagBudget) break
+ }
+ }
+
+ if (input.selectedBody) {
+ const kind =
+ input.selectedBody.kind === 'tag' || input.selectedBody.kind === 'comet'
+ ? 'tag'
+ : 'planet'
+ push(input.selectedBody, kind === 'tag' ? 'tag' : 'planet', 200)
+ }
+ if (input.hoveredBody) {
+ const kind =
+ input.hoveredBody.kind === 'tag' || input.hoveredBody.kind === 'comet'
+ ? 'tag'
+ : 'planet'
+ push(input.hoveredBody, kind === 'tag' ? 'tag' : 'planet', 180)
+ }
+
+ return Array.from(candidates.values()).sort(
+ (a, b) => b.priority - a.priority || a.body.title.localeCompare(b.body.title),
+ )
+}
+
+export function resolveProjectedLabels(
+ labels: Array,
+ minDistance = 30,
+): Array {
+ const accepted: Array = []
+ const ordered = [...labels].sort(
+ (a, b) => b.priority - a.priority || a.body.title.localeCompare(b.body.title),
+ )
+ for (const label of ordered) {
+ const collides = accepted.some((other) => {
+ const dx = other.x - label.x
+ const dy = other.y - label.y
+ return Math.sqrt(dx * dx + dy * dy) < minDistance
+ })
+ if (!collides) accepted.push(label)
+ }
+ return accepted
+}
+
+type SeedNode = KnowledgeGraphNode & {
+ id: string
+ title: string
+ path: string
+ folder: string
+ degree: number
+ excerpt: string
+}
+
+type Vec3 = { x: number; y: number; z: number }
+
+const FIELD_ARM_ID = 'field-stars'
+
+// Fallback arm tints — same canon family as folderTintFor (blues + ambers).
+const FOLDER_TINTS = [
+ '#FFB347',
+ '#FF8C1A',
+ '#63C7FF',
+ '#9DDCFF',
+ '#2E7FD9',
+ '#FFD27A',
+]
+
+const ARM_DEPTH_BIAS = [-18, 14, -8, 22, -24, 10]
+
+export function shortTitle(value: string): string {
+ return value
+ .replace(/\.md$/i, '')
+ .replace(/[-_]/g, ' ')
+ .replace(/\s+/g, ' ')
+ .trim()
+}
+
+export function hashString(value: string): number {
+ let hash = 0
+ for (let i = 0; i < value.length; i += 1) {
+ hash = (hash << 5) - hash + value.charCodeAt(i)
+ hash |= 0
+ }
+ return Math.abs(hash)
+}
+
+export function seededUnit(seed: string): number {
+ let value = hashString(seed) + 0x6d2b79f5
+ value = Math.imul(value ^ (value >>> 15), value | 1)
+ value ^= value + Math.imul(value ^ (value >>> 7), value | 61)
+ return ((value ^ (value >>> 14)) >>> 0) / 4_294_967_296
+}
+
+export function clamp(value: number, min: number, max: number): number {
+ return Math.min(max, Math.max(min, value))
+}
+
+export function obsidianUri(body: CelestialBody): string {
+ return `obsidian://open?path=${encodeURIComponent(body.path)}`
+}
+
+/**
+ * Deterministic PRNG (mulberry32). Used for the dust-forward field's
+ * procedural scatter so the same vault graph always renders the same
+ * dust cloud — no re-shuffling on every re-render.
+ */
+export function mulberry32(seed: number): () => number {
+ let a = seed >>> 0
+ return () => {
+ a = (a + 0x6d2b79f5) | 0
+ let t = Math.imul(a ^ (a >>> 15), 1 | a)
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296
+ }
+}
+
+/** Standard normal sample via Box-Muller, drawn from a mulberry32 rng. */
+export function gaussianFrom(rng: () => number): number {
+ const u1 = Math.max(rng(), 1e-9)
+ const u2 = rng()
+ return Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2)
+}
+
+// The galaxy IS the map: every rendered light is a real vault note, and
+// this is the one true placement function for it. No procedural dust, no
+// decorative starfield, no blended legacy layout — a note's position is
+// derived entirely from its rank (oldest→newest) among its arm's real
+// notes. MAX_RADIUS is the coordinate space every note-star lives in.
+export const ARM_SPIRAL_MAX_RADIUS = 52
+const ARM_SPIRAL_MIN_TURNS = 1.6
+const ARM_SPIRAL_MAX_TURNS = 6.4
+const ARM_SPIRAL_GROWTH_DIVISOR = 16
+const ARM_SPIRAL_B = 0.28
+
+/**
+ * How many revolutions an arm winds through, driven by how many real
+ * notes actually live on it — the growth mechanic: more notes, longer
+ * (more-wound) arm. Exposed on its own so callers/tests can assert the
+ * growth relationship without re-deriving it from armRankPlacement's
+ * (x, y, z) output.
+ */
+export function armTurnsForNoteCount(armNoteCount: number): number {
+ return clamp(
+ ARM_SPIRAL_MIN_TURNS + armNoteCount / ARM_SPIRAL_GROWTH_DIVISOR,
+ ARM_SPIRAL_MIN_TURNS,
+ ARM_SPIRAL_MAX_TURNS,
+ )
+}
+
+export type ArmRankPlacementOptions = {
+ /** Skip deterministic jitter — used by tests asserting the clean,
+ * monotonic radius/angle relationship before scatter is layered on. */
+ raw?: boolean
+}
+
+/**
+ * Places a single real note on its arm's logarithmic spiral, ranked by
+ * age among its arm's real notes (`rankInArm` of `armNoteCount`, both
+ * 0-based/1-based per the tNorm formula below — oldest near the core,
+ * newest at the growing tip). `turns` grows with the arm's real note
+ * count, so an arm with more notes winds further: the spiral physically
+ * grows as the vault grows. Deterministic gaussian jitter (seeded from
+ * `seedKey`, typically the note's id) keeps the arm reading as organic
+ * scatter rather than a clean line, without breaking reproducibility.
+ */
+export function armRankPlacement(
+ rankInArm: number,
+ armNoteCount: number,
+ armIndex: number,
+ armCount: number,
+ seedKey: string,
+ options?: ArmRankPlacementOptions,
+): { x: number; y: number; z: number; tNorm: number } {
+ const safeArmNoteCount = Math.max(0, armNoteCount)
+ const tNorm = (rankInArm + 1) / (safeArmNoteCount + 1)
+ const turns = armTurnsForNoteCount(safeArmNoteCount)
+ const armOffset = (armIndex * Math.PI * 2) / Math.max(1, armCount)
+ const theta = tNorm * turns
+ const norm =
+ (Math.exp(ARM_SPIRAL_B * theta) - 1) /
+ (Math.exp(ARM_SPIRAL_B * turns) - 1)
+ const radius = 2.5 + ARM_SPIRAL_MAX_RADIUS * norm
+ const angle = armOffset + theta
+
+ if (options?.raw) {
+ return {
+ x: Math.cos(angle) * radius,
+ y: 0,
+ z: Math.sin(angle) * radius,
+ tNorm,
+ }
+ }
+
+ const rng = mulberry32(hashString(seedKey))
+ const radialJitter = gaussianFrom(rng) * (0.8 + radius * 0.045)
+ const angularJitter = gaussianFrom(rng) * 0.05
+ const heightJitter = gaussianFrom(rng) * (3.2 * Math.exp(-radius / 18) + 0.25)
+ const finalRadius = Math.max(0.4, radius + radialJitter)
+ const finalAngle = angle + angularJitter
+
+ return {
+ x: Math.cos(finalAngle) * finalRadius,
+ y: heightJitter,
+ z: Math.sin(finalAngle) * finalRadius,
+ tNorm,
+ }
+}
+
+const CLUSTER_HUE_CYCLE = ['blue', 'blue2', 'amber', 'blend'] as const
+export type ClusterHue = (typeof CLUSTER_HUE_CYCLE)[number]
+
+/** Round-robin cluster tint assignment: neon blues + one amber + blends. */
+export function clusterHue(folderIndex: number): ClusterHue {
+ const index =
+ ((folderIndex % CLUSTER_HUE_CYCLE.length) + CLUSTER_HUE_CYCLE.length) %
+ CLUSTER_HUE_CYCLE.length
+ return CLUSTER_HUE_CYCLE[index]
+}
+
+const RECENCY_HOT_DAYS = 7
+const RECENCY_COLD_DAYS = 90
+const RECENCY_FLOOR = 0.35
+
+/**
+ * Recency-driven ember brightness: 1.0 for anything touched in the
+ * last week, linear falloff to a 0.35 floor by 90 days, clamped.
+ */
+export function recencyGlow(modifiedIso: string, nowIso: string): number {
+ const modified = Date.parse(modifiedIso)
+ const now = Date.parse(nowIso)
+ if (!Number.isFinite(modified) || !Number.isFinite(now)) return RECENCY_FLOOR
+ const ageDays = Math.max(0, (now - modified) / 86_400_000)
+ if (ageDays <= RECENCY_HOT_DAYS) return 1
+ if (ageDays >= RECENCY_COLD_DAYS) return RECENCY_FLOOR
+ const t =
+ (ageDays - RECENCY_HOT_DAYS) / (RECENCY_COLD_DAYS - RECENCY_HOT_DAYS)
+ return clamp(1 - t * (1 - RECENCY_FLOOR), RECENCY_FLOOR, 1)
+}
+
+export const HUB_DEGREE_THRESHOLD = 6
+
+/** Ember visual size from wikilink degree — log-scaled so hubs read big without dominating. */
+export function emberSize(degree: number): number {
+ return 1 + Math.log2(1 + Math.max(0, degree)) * 0.6
+}
+
+/** Whether a note's degree crosses the hub threshold (amber pulse tier). */
+export function isHub(degree: number): boolean {
+ return degree >= HUB_DEGREE_THRESHOLD
+}
+
+function fallbackFolder(id: string): string {
+ const parts = id.split(/[\\/]/).filter(Boolean)
+ if (parts.length >= 2 && parts[0] === 'agents')
+ return `${parts[0]}/${parts[1]}`
+ return parts[0] || 'vault'
+}
+
+function normalizeNode(node: KnowledgeGraphNode): SeedNode {
+ const path = node.path || node.id
+ return {
+ ...node,
+ id: node.id,
+ title: node.title || shortTitle(path),
+ path,
+ folder:
+ node.folder && !/[\\/]/.test(node.folder)
+ ? node.folder
+ : fallbackFolder(path),
+ degree: 0,
+ excerpt: node.excerpt || node.summary || '',
+ }
+}
+
+function buildAdjacency(
+ nodes: Array,
+ edges: Array,
+): Map> {
+ const adjacency = new Map>()
+ for (const node of nodes) adjacency.set(node.id, new Set())
+ for (const edge of edges) {
+ if (!adjacency.has(edge.source) || !adjacency.has(edge.target)) continue
+ adjacency.get(edge.source)?.add(edge.target)
+ adjacency.get(edge.target)?.add(edge.source)
+ }
+ return adjacency
+}
+
+function chooseSeeds(nodes: Array): Array {
+ const systemCount = clamp(Math.round(Math.sqrt(nodes.length) * 0.54), 2, 9)
+ return [...nodes]
+ .sort((a, b) => b.degree - a.degree || a.title.localeCompare(b.title))
+ .slice(0, systemCount)
+}
+
+function assignCommunities(
+ nodes: Array,
+ seeds: Array,
+ adjacency: Map>,
+): Map> {
+ const communities = new Map>()
+ for (const seed of seeds) communities.set(seed.id, [seed])
+
+ for (const node of nodes) {
+ if (communities.has(node.id)) continue
+ let bestSeed = seeds[hashString(node.id) % Math.max(1, seeds.length)]
+ let bestScore = Number.NEGATIVE_INFINITY
+ const neighbors = adjacency.get(node.id) ?? new Set()
+
+ for (const seed of seeds) {
+ const seedNeighbors = adjacency.get(seed.id) ?? new Set()
+ let shared = 0
+ for (const neighbor of neighbors)
+ if (seedNeighbors.has(neighbor)) shared += 1
+ const direct = neighbors.has(seed.id) ? 10 : 0
+ const sameType = node.type && seed.type && node.type === seed.type ? 2 : 0
+ const score = direct + shared * 3 + sameType + seed.degree * 0.08
+ if (score > bestScore) {
+ bestScore = score
+ bestSeed = seed
+ }
+ }
+
+ communities.get(bestSeed.id)?.push(node)
+ }
+
+ return communities
+}
+
+function recencyFor(modified?: string): {
+ tier: CelestialBody['recencyTier']
+ ageHours: number
+} {
+ const modifiedTime = modified ? Date.parse(modified) : Number.NaN
+ const ageHours = Number.isFinite(modifiedTime)
+ ? Math.max(0, (Date.now() - modifiedTime) / 3_600_000)
+ : 9999
+ if (ageHours <= 48) return { tier: 'hot', ageHours }
+ if (ageHours <= 168) return { tier: 'warm', ageHours }
+ return { tier: 'cool', ageHours }
+}
+
+function operationalImportance(
+ node: SeedNode,
+ kind: CelestialKind,
+ recency: { tier: CelestialBody['recencyTier']; ageHours: number },
+): number {
+ const kindWeight = kind === 'core' ? 20 : kind === 'planet' ? 8 : kind === 'tag' ? 2 : 1
+ const recencyWeight = recency.tier === 'hot' ? 4 : recency.tier === 'warm' ? 2 : 0
+ return kindWeight + node.degree * 6 + recencyWeight
+}
+
+function sizeTier(value: number, max: number): number {
+ if (max <= 0) return 1
+ return clamp(Math.ceil((value / max) * 5), 1, 5)
+}
+
+function folderAnchor(arm: GalaxyArm): Vec3 {
+ const angle = arm.angle
+ const radius = 46 + arm.index * 3.5
+ const depthBias = ARM_DEPTH_BIAS[arm.index % ARM_DEPTH_BIAS.length] ?? 0
+ return {
+ x: Math.cos(angle) * radius + depthBias * 0.18,
+ y: Math.sin(angle * 1.17) * 18 + (arm.index % 2 === 0 ? 6 : -5),
+ z: Math.sin(angle) * radius + depthBias,
+ }
+}
+
+function stableJitter(seed: string, scale: number): Vec3 {
+ return {
+ x: (seededUnit(`${seed}:jx`) - 0.5) * scale,
+ y: (seededUnit(`${seed}:jy`) - 0.5) * scale * 0.62,
+ z: (seededUnit(`${seed}:jz`) - 0.5) * scale,
+ }
+}
+
+function settleSystems(
+ systems: Array<{
+ id: string
+ arm: GalaxyArm
+ totalLinks: number
+ position: Vec3
+ }>,
+ links: Array,
+): Map {
+ const positions = new Map(
+ systems.map((system) => [system.id, { ...system.position }]),
+ )
+ const systemByPlanet = new Map(
+ systems.map((system) => [
+ system.id.split(':').at(-1) ?? system.id,
+ system.id,
+ ]),
+ )
+ const linkedSystems = links
+ .map((link) => ({
+ source: systemByPlanet.get(link.source),
+ target: systemByPlanet.get(link.target),
+ }))
+ .filter((link): link is { source: string; target: string } =>
+ Boolean(link.source && link.target && link.source !== link.target),
+ )
+
+ for (let step = 0; step < 90; step += 1) {
+ const deltas = new Map(
+ systems.map((system) => [system.id, { x: 0, y: 0, z: 0 }]),
+ )
+
+ for (let i = 0; i < systems.length; i += 1) {
+ for (let j = i + 1; j < systems.length; j += 1) {
+ const a = positions.get(systems[i].id)!
+ const b = positions.get(systems[j].id)!
+ const dx = a.x - b.x
+ const dy = a.y - b.y
+ const dz = a.z - b.z
+ const distanceSq = Math.max(24, dx * dx + dy * dy + dz * dz)
+ const force = 86 / distanceSq
+ const distance = Math.sqrt(distanceSq)
+ // Soft minimum spacing keeps systems from collapsing into one blob.
+ const separation = distance < 22 ? ((22 - distance) / 22) * 1.4 : 0
+ const ax = (dx / distance) * (force + separation)
+ const ay = (dy / distance) * (force + separation) * 1.08
+ const az = (dz / distance) * (force + separation)
+ const da = deltas.get(systems[i].id)!
+ const db = deltas.get(systems[j].id)!
+ da.x += ax
+ da.y += ay
+ da.z += az
+ db.x -= ax
+ db.y -= ay
+ db.z -= az
+ }
+ }
+
+ for (const link of linkedSystems) {
+ const a = positions.get(link.source)!
+ const b = positions.get(link.target)!
+ const dx = b.x - a.x
+ const dy = b.y - a.y
+ const dz = b.z - a.z
+ const da = deltas.get(link.source)!
+ const db = deltas.get(link.target)!
+ da.x += dx * 0.0024
+ da.y += dy * 0.0024
+ da.z += dz * 0.0024
+ db.x -= dx * 0.0024
+ db.y -= dy * 0.0024
+ db.z -= dz * 0.0024
+ }
+
+ for (const system of systems) {
+ const position = positions.get(system.id)!
+ const delta = deltas.get(system.id)!
+ const anchor = folderAnchor(system.arm)
+ position.x += delta.x + (anchor.x - position.x) * 0.011
+ position.y += delta.y + (anchor.y - position.y) * 0.014
+ position.z += delta.z + (anchor.z - position.z) * 0.011
+ }
+ }
+
+ return positions
+}
+
+function createBody(input: {
+ node: SeedNode
+ kind: CelestialKind
+ arm: GalaxyArm
+ systemId?: string
+ planetId?: string
+ base: Vec3
+ sizeTier: number
+ offset?: Vec3
+}): CelestialBody {
+ const recency = recencyFor(input.node.modified || input.node.updated)
+ const hash = hashString(input.node.id)
+ const offset = input.offset ?? { x: 0, y: 0, z: 0 }
+ return {
+ ...input.node,
+ kind: input.kind,
+ importance: operationalImportance(input.node, input.kind, recency),
+ systemId: input.systemId,
+ planetId: input.planetId,
+ armId: input.arm.id,
+ armIndex: input.arm.index,
+ sizeTier: input.sizeTier,
+ recencyTier: recency.tier,
+ recencyAgeHours: recency.ageHours,
+ orbitRadius: 0,
+ orbitPeriod: 210 + (hash % 260),
+ orbitPhase: (hash % 6283) / 1000,
+ orbitTilt: -0.55 + seededUnit(`${input.node.id}:tilt`) * 1.1,
+ baseX: input.base.x + offset.x,
+ baseY: input.base.y + offset.y,
+ baseZ: input.base.z + offset.z,
+ jitter: (seededUnit(`${input.node.id}:jitter`) - 0.5) * 2,
+ excerpt: input.node.excerpt,
+ }
+}
+
+function cometPoint(index: number, total: number, seed: string): Vec3 {
+ const lane = total <= 1 ? 0.5 : index / (total - 1)
+ const side = seededUnit(`${seed}:side`) > 0.5 ? -1 : 1
+ return {
+ x: side * (44 + seededUnit(`${seed}:x`) * 18),
+ y: -18 + lane * 36 + (seededUnit(`${seed}:y`) - 0.5) * 10,
+ z: (seededUnit(`${seed}:z`) - 0.5) * 62,
+ }
+}
+
+function strongestLinks(
+ bodies: Array,
+ edges: Array,
+): Array {
+ const bodyById = new Map(bodies.map((body) => [body.id, body]))
+ const sorted = [...edges]
+ .filter((edge) => bodyById.has(edge.source) && bodyById.has(edge.target))
+ .map((edge) => {
+ const source = bodyById.get(edge.source)!
+ const target = bodyById.get(edge.target)!
+ return {
+ ...edge,
+ strength:
+ source.degree +
+ target.degree +
+ (source.systemId === target.systemId ? 4 : 0),
+ }
+ })
+ .sort((a, b) => b.strength - a.strength)
+ const counts = new Map()
+ const capped: Array = []
+ for (const link of sorted) {
+ const sourceCount = counts.get(link.source) ?? 0
+ const targetCount = counts.get(link.target) ?? 0
+ if (sourceCount >= 2 || targetCount >= 2) continue
+ capped.push(link)
+ if (capped.length >= 260) break
+ counts.set(link.source, sourceCount + 1)
+ counts.set(link.target, targetCount + 1)
+ }
+ return capped
+}
+
+export function buildGalaxyModel(
+ graph: KnowledgeGraphResponse | undefined,
+): GalaxyModel {
+ const nodes = (graph?.nodes ?? []).map(normalizeNode)
+ const edges = graph?.edges ?? []
+ const adjacency = buildAdjacency(nodes, edges)
+ for (const node of nodes) node.degree = adjacency.get(node.id)?.size ?? 0
+
+ const linkedNodes = nodes.filter((node) => node.degree > 0)
+ const orphanNodes = nodes.filter((node) => node.degree === 0)
+ const folderCounts = new Map()
+ for (const node of linkedNodes) {
+ folderCounts.set(node.folder, (folderCounts.get(node.folder) ?? 0) + 1)
+ }
+
+ const mainFolders = Array.from(folderCounts.entries())
+ .filter(([, count]) => count >= 3)
+ .sort((a, b) => b[1] - a[1])
+ .slice(0, 6)
+ const armCount = clamp(mainFolders.length || 1, 2, 6)
+ const arms: Array = mainFolders.map(([name, count], index) => ({
+ id: name,
+ name,
+ index,
+ angle: -Math.PI / 2 + (Math.PI * 2 * index) / armCount,
+ bodyCount: count,
+ systemCount: 0,
+ tint: folderTintFor(name) || FOLDER_TINTS[index] || FOLDER_TINTS.at(-1)!,
+ }))
+ if (arms.length === 0) {
+ arms.push({
+ id: FIELD_ARM_ID,
+ name: 'field stars',
+ index: 0,
+ angle: -Math.PI / 2,
+ bodyCount: linkedNodes.length,
+ systemCount: 0,
+ tint: folderTintFor('field stars'),
+ })
+ }
+
+ const armByFolder = new Map(arms.map((arm) => [arm.id, arm]))
+ const fieldArm: GalaxyArm = {
+ id: FIELD_ARM_ID,
+ name: 'field stars',
+ index: arms.length,
+ angle: Math.PI / 2,
+ bodyCount: 0,
+ systemCount: 0,
+ tint: folderTintFor('field stars'),
+ }
+
+ const nodesByArm = new Map>()
+ for (const node of linkedNodes) {
+ const arm = armByFolder.get(node.folder) ?? fieldArm
+ const bucket = nodesByArm.get(arm.id) ?? []
+ bucket.push(node)
+ nodesByArm.set(arm.id, bucket)
+ if (arm.id === FIELD_ARM_ID) fieldArm.bodyCount += 1
+ }
+ if (fieldArm.bodyCount > 0 && !arms.some((arm) => arm.id === FIELD_ARM_ID))
+ arms.push(fieldArm)
+
+ const maxDegree = Math.max(1, ...nodes.map((node) => node.degree))
+ const coreSeed =
+ linkedNodes.length > 0
+ ? [...linkedNodes].sort(
+ (a, b) => b.degree - a.degree || a.title.localeCompare(b.title),
+ )[0]
+ : undefined
+
+ const communityEntries: Array<{
+ arm: GalaxyArm
+ seedId: string
+ planet: SeedNode
+ community: Array
+ totalLinks: number
+ initial: Vec3
+ }> = []
+
+ for (const arm of arms) {
+ const armNodes = nodesByArm.get(arm.id) ?? []
+ const seeds = chooseSeeds(armNodes)
+ const communities = assignCommunities(armNodes, seeds, adjacency)
+ const entries = Array.from(communities.entries())
+ .map(([seedId, community]) => {
+ const planet = [...community].sort(
+ (a, b) => b.degree - a.degree || a.title.localeCompare(b.title),
+ )[0]
+ const totalLinks = community.reduce((sum, node) => sum + node.degree, 0)
+ return { seedId, planet, community, totalLinks }
+ })
+ .sort((a, b) => b.community.length - a.community.length)
+ arm.systemCount = entries.length
+ entries.forEach((entry, index) => {
+ const anchor = folderAnchor(arm)
+ const spread = stableJitter(entry.seedId, 24 + index * 2.6)
+ communityEntries.push({
+ ...entry,
+ arm,
+ initial: {
+ x: anchor.x + spread.x,
+ y: anchor.y + spread.y + (index % 3 - 1) * 4.5,
+ z: anchor.z + spread.z,
+ },
+ })
+ })
+ }
+
+ const layoutSeeds = communityEntries.map((entry) => ({
+ id: `${entry.arm.id}:${entry.planet.id}`,
+ arm: entry.arm,
+ totalLinks: entry.totalLinks,
+ position: entry.initial,
+ }))
+ const settled = settleSystems(layoutSeeds, edges)
+
+ const systems: Array = []
+ const bodies: Array = []
+ const systemById = new Map()
+ const systemByBodyId = new Map()
+ const bodyById = new Map()
+
+ for (const entry of communityEntries) {
+ const systemId = `${entry.arm.id}:${entry.planet.id}`
+ const base = settled.get(systemId) ?? entry.initial
+ const planet = createBody({
+ node: entry.planet,
+ kind: entry.planet.id === coreSeed?.id ? 'core' : 'planet',
+ arm: entry.arm,
+ systemId,
+ base,
+ sizeTier: sizeTier(entry.totalLinks, maxDegree * 6),
+ })
+ const tags = entry.community
+ .filter((node) => node.id !== entry.planet.id)
+ .map((node, index) => {
+ const ring =
+ 9.2 + (index % 12) * 2.15 + seededUnit(`${node.id}:ring`) * 5.4
+ const theta =
+ seededUnit(`${node.id}:theta`) * Math.PI * 2 +
+ (index / Math.max(1, entry.community.length)) * 0.85
+ const vertical =
+ (seededUnit(`${node.id}:vertical`) - 0.5) * 22 +
+ Math.sin(theta * 1.7) * 2.4
+ return createBody({
+ node,
+ kind: 'tag',
+ arm: entry.arm,
+ systemId,
+ planetId: planet.id,
+ base,
+ sizeTier: sizeTier(node.degree, maxDegree),
+ offset: {
+ x: Math.cos(theta) * ring,
+ y: vertical,
+ z: Math.sin(theta) * ring * 0.92,
+ },
+ })
+ })
+ const system: PlanetarySystem = {
+ id: systemId,
+ folder: entry.arm.name,
+ armId: entry.arm.id,
+ armIndex: entry.arm.index,
+ planet,
+ tags,
+ moons: tags,
+ bodyCount: 1 + tags.length,
+ totalLinks: entry.totalLinks,
+ sizeTier: planet.sizeTier,
+ baseX: base.x,
+ baseY: base.y,
+ baseZ: base.z,
+ armPosition: seededUnit(`${systemId}:position`),
+ }
+ systems.push(system)
+ systemById.set(system.id, system)
+ for (const body of [planet, ...tags]) {
+ bodies.push(body)
+ bodyById.set(body.id, body)
+ systemByBodyId.set(body.id, system)
+ }
+ }
+
+ const cometArm = arms.find((arm) => arm.id === FIELD_ARM_ID) || arms[0]
+ const comets = orphanNodes.map((node, index) => {
+ const point = cometPoint(index, orphanNodes.length, node.id)
+ const comet = createBody({
+ node,
+ kind: 'comet',
+ arm: cometArm,
+ base: point,
+ sizeTier: 1,
+ })
+ bodyById.set(comet.id, comet)
+ bodies.push(comet)
+ return comet
+ })
+
+ return {
+ arms,
+ systems: systems.sort((a, b) => b.bodyCount - a.bodyCount),
+ bodies,
+ comets,
+ core: coreSeed ? (bodyById.get(coreSeed.id) ?? null) : null,
+ bodyById,
+ systemById,
+ systemByBodyId,
+ links: strongestLinks(bodies, edges),
+ totals: {
+ bodies: nodes.length,
+ links: edges.length,
+ systems: systems.length,
+ comets: comets.length,
+ },
+ }
+}
diff --git a/src/screens/dashboard/components/nova-session-bridge-card.tsx b/src/screens/dashboard/components/nova-session-bridge-card.tsx
new file mode 100644
index 0000000000..30cef7f5ef
--- /dev/null
+++ b/src/screens/dashboard/components/nova-session-bridge-card.tsx
@@ -0,0 +1,181 @@
+import { useQuery } from '@tanstack/react-query'
+import { STATUS_TONE } from '../lib/status-meta'
+import type {
+ NovaSessionBridge,
+ SessionBridgeSourceState,
+} from '../../../server/nova-session-bridge'
+
+async function readBridge(): Promise {
+ const response = await fetch('/api/nova-session-bridge')
+ if (!response.ok) throw new Error(`Session bridge read failed: ${response.status}`)
+ return (await response.json()) as NovaSessionBridge
+}
+
+// 3-line adapter: SessionBridgeSourceState → STATUS_TONE's status
+// literal, then strip the border-color class since this label is
+// plain inline text (no pill/border in the markup below).
+function sourceToneText(state: SessionBridgeSourceState): string {
+ const status = state === 'ok' ? 'operational' : state === 'degraded' ? 'degraded' : 'not-wired'
+ return STATUS_TONE(status).tone.replace(/border-\S+\s*/, '').trim()
+}
+
+function formatTime(value: string | null): string {
+ if (!value) return '—'
+ const ts = Date.parse(value)
+ if (!Number.isFinite(ts)) return '—'
+ return new Date(ts).toLocaleString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ hour: 'numeric',
+ minute: '2-digit',
+ })
+}
+
+function Fact({ label, value }: { label: string; value: string }) {
+ return (
+
+
+ {label}
+
+
+ {value}
+
+
+ )
+}
+
+export function NovaSessionBridgeCard() {
+ const query = useQuery({
+ queryKey: ['nova-session-bridge'],
+ queryFn: readBridge,
+ refetchInterval: 20_000,
+ staleTime: 10_000,
+ })
+ const bridge = query.data ?? null
+
+ return (
+
+
+
+
+ Session Bridge
+
+
+ What Nova is doing right now
+
+
+ {bridge && bridge.needsTaylor && bridge.needsTaylor.pending > 0 ? (
+
+
+ {bridge.needsTaylor.pending} need Taylor
+
+ ) : null}
+
+
+ {query.isLoading ? (
+
+ Reading gateway, runs, and receipts…
+
+ ) : null}
+ {query.isError ? (
+
+ Bridge endpoint unreachable.
+
+ ) : null}
+
+ {bridge ? (
+ <>
+
+
+
+
+
+
+
+ {bridge.blockers.length > 0 ? (
+
+
+ Blockers
+
+
+ {bridge.blockers.slice(0, 3).map((blocker) => (
+
+ {blocker}
+
+ ))}
+
+
+ ) : null}
+
+
+
+ Latest receipts
+
+ {bridge.receipts.length === 0 ? (
+
+ No receipts yet — run /api/nova-work-scan after real work lands.
+
+ ) : (
+
+ {bridge.receipts.map((receipt) => (
+
+
+ {receipt.verified ? '✓ ' : '· '}
+ {receipt.title}
+
+
+ {formatTime(receipt.at)}
+
+
+ ))}
+
+ )}
+
+
+
+ {bridge.sources.map((source) => (
+
+ {source.label}: {source.state}
+
+ ))}
+
+ >
+ ) : null}
+
+ )
+}
diff --git a/src/screens/dashboard/components/nova-wants-card.tsx b/src/screens/dashboard/components/nova-wants-card.tsx
new file mode 100644
index 0000000000..88bf406877
--- /dev/null
+++ b/src/screens/dashboard/components/nova-wants-card.tsx
@@ -0,0 +1,861 @@
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
+import { useMemo, useState } from 'react'
+import type {
+ NovaWantCard,
+ NovaWantsApprovalLevel,
+ NovaWantsBoard,
+ NovaWantsCategory,
+ NovaWantsColumn,
+} from '@/server/nova-wants-store'
+
+type NovaWantsResponse = {
+ ok: boolean
+ board?: NovaWantsBoard
+ storage?: {
+ path: string
+ backupPath?: string
+ tempPath?: string
+ sourceReceipt: string
+ seededFrom: string
+ }
+ degraded?: boolean
+ warning?: string | null
+ error?: string
+}
+
+const CATEGORY_OPTIONS: Array = [
+ 'continuity',
+ 'self-state',
+ 'agency',
+ 'identity',
+ 'relationship',
+ 'mycelia',
+ 'ui-ops',
+ 'safety',
+]
+
+const APPROVAL_OPTIONS: Array = [
+ 'safe',
+ 'needs-taylor-review',
+ 'explicit-approval',
+]
+
+const RISK_TONE: Record = {
+ safe: 'border-[var(--theme-border)] text-[var(--theme-muted)]',
+ 'needs-taylor-review':
+ 'border-[color-mix(in_srgb,var(--theme-warning)_45%,var(--theme-border))] text-[var(--theme-warning)]',
+ 'explicit-approval':
+ 'border-[color-mix(in_srgb,var(--theme-danger)_45%,var(--theme-border))] text-[var(--theme-danger)]',
+}
+
+const STATUS_TONE: Record = {
+ proposed: 'text-[var(--theme-muted)]',
+ 'needs-taylor-review': 'text-[var(--theme-warning)]',
+ approved: 'text-[var(--theme-accent)]',
+ building: 'text-[var(--theme-success)]',
+ done: 'text-[var(--theme-success)]',
+ 'parked-rejected': 'text-[var(--theme-muted)]',
+}
+
+function emptyDraft(): {
+ title: string
+ description: string
+ category: NovaWantsCategory
+ approvalLevel: NovaWantsApprovalLevel
+ whyThisMatters: string
+} {
+ return {
+ title: '',
+ description: '',
+ category: 'continuity',
+ approvalLevel: 'safe',
+ whyThisMatters: '',
+ }
+}
+
+async function readNovaWants(): Promise {
+ const response = await fetch('/api/nova-wants')
+ const body: NovaWantsResponse | null = await response.json().catch(() => null)
+ if (!body || !response.ok || (!body.ok && !body.degraded)) {
+ throw new Error(body?.error || `nova-wants ${response.status}`)
+ }
+ return body
+}
+
+async function mutateNovaWants(
+ method: 'POST' | 'PATCH' | 'DELETE',
+ body: Record,
+): Promise {
+ const response = await fetch('/api/nova-wants', {
+ method,
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ })
+ const payload: NovaWantsResponse | null = await response
+ .json()
+ .catch(() => null)
+ if (!response.ok || !payload?.ok) {
+ throw new Error(payload?.error || `nova-wants ${response.status}`)
+ }
+ return payload
+}
+
+function compactDate(value: string): string {
+ const date = new Date(value)
+ if (Number.isNaN(date.getTime())) return 'unknown'
+ return date.toLocaleDateString(undefined, {
+ month: 'short',
+ day: '2-digit',
+ })
+}
+
+function ReceiptLink({ value }: { value: string | null }) {
+ if (!value) return null
+ if (/^https?:\/\//i.test(value) || value.startsWith('/')) {
+ return (
+
+ receipt
+
+ )
+ }
+ return (
+
+ {value}
+
+ )
+}
+
+function FabricRefs({ card }: { card: NovaWantCard }) {
+ const refs = [
+ ['events', card.fabricEventIds.length],
+ ['self', card.fabricSelfStateIds.length],
+ ['sources', card.fabricSourceMapIds.length],
+ ['reviews', card.fabricReviewIds.length],
+ ].filter(([, count]) => Number(count) > 0)
+
+ if (!refs.length) return null
+
+ return (
+
+ {refs.map(([label, count]) => (
+
+ fabric {label}: {count}
+
+ ))}
+
+ )
+}
+
+function Field({
+ label,
+ children,
+}: {
+ label: string
+ children: React.ReactNode
+}) {
+ return (
+
+
+ {label}
+
+ {children}
+
+ )
+}
+
+function TextInput(
+ props: React.InputHTMLAttributes & { multiline?: false },
+) {
+ return (
+
+ )
+}
+
+function TextArea(props: React.TextareaHTMLAttributes) {
+ return (
+
+ )
+}
+
+function SelectInput(props: React.SelectHTMLAttributes) {
+ return (
+
+ )
+}
+
+function ActionButton({
+ children,
+ onClick,
+ disabled,
+ tone = 'normal',
+ type = 'button',
+}: {
+ children: React.ReactNode
+ onClick?: () => void
+ disabled?: boolean
+ tone?: 'normal' | 'danger' | 'accent'
+ type?: 'button' | 'submit'
+}) {
+ const toneClass =
+ tone === 'danger'
+ ? 'border-[color-mix(in_srgb,var(--theme-danger)_38%,var(--theme-border))] text-[var(--theme-danger)]'
+ : tone === 'accent'
+ ? 'border-[color-mix(in_srgb,var(--theme-accent)_45%,var(--theme-border))] text-[var(--theme-accent)]'
+ : 'border-[var(--theme-border)] text-[var(--theme-muted)]'
+ return (
+
+ {children}
+
+ )
+}
+
+function NovaWantCardView({
+ card,
+ columns,
+ onSelect,
+ onMove,
+ onPark,
+}: {
+ card: NovaWantCard
+ columns: Array
+ onSelect: (card: NovaWantCard) => void
+ onMove: (card: NovaWantCard, status: NovaWantsColumn) => void
+ onPark: (card: NovaWantCard) => void
+}) {
+ const statusIndex = columns.indexOf(card.status)
+ const previous = statusIndex > 0 ? columns[statusIndex - 1] : null
+ const next =
+ statusIndex < columns.length - 1 ? columns[statusIndex + 1] : null
+ const needsApproval =
+ card.approvalLevel !== 'safe' && card.status === 'needs-taylor-review'
+
+ return (
+
+
+
+
+ {card.title}
+
+
+
+ {card.category}
+
+
+ {card.approvalLevel}
+
+
+
+
onSelect(card)}
+ className="shrink-0 rounded border border-[var(--theme-border)] px-2 py-1 font-mono text-[9px] uppercase tracking-[0.12em] text-[var(--theme-accent)]"
+ >
+ edit
+
+
+
+
+ {card.description}
+
+
+ {card.whyThisMatters}
+
+
+
+
+ Updated {compactDate(card.updatedAt)}
+
+
+
+
+ {previous ? (
+
onMove(card, previous)}>
+ move back
+
+ ) : null}
+ {needsApproval ? (
+
onMove(card, 'approved')}
+ >
+ request review
+
+ ) : next ? (
+
onMove(card, next)}>
+ move next
+
+ ) : null}
+ {card.status !== 'parked-rejected' ? (
+
onPark(card)}>
+ park
+
+ ) : null}
+
+
+ )
+}
+
+export function NovaWantsCard() {
+ const queryClient = useQueryClient()
+ const [draft, setDraft] = useState(emptyDraft)
+ const [editing, setEditing] = useState(null)
+ const [error, setError] = useState(null)
+
+ const query = useQuery({
+ queryKey: ['nova-wants'],
+ queryFn: readNovaWants,
+ staleTime: 5_000,
+ refetchInterval: 30_000,
+ })
+
+ const board = query.data?.board ?? null
+ const columns = useMemo(
+ () => board?.columns.map((column) => column.id) ?? [],
+ [board],
+ )
+ const cardsByColumn = useMemo(() => {
+ const grouped = new Map>()
+ if (!board) return grouped
+ for (const column of board.columns) grouped.set(column.id, [])
+ for (const card of board.cards) {
+ const list = grouped.get(card.status)
+ if (list) list.push(card)
+ }
+ for (const list of grouped.values()) {
+ list.sort((a, b) => a.position - b.position)
+ }
+ return grouped
+ }, [board])
+
+ const mutation = useMutation({
+ mutationFn: ({
+ method,
+ body,
+ }: {
+ method: 'POST' | 'PATCH' | 'DELETE'
+ body: Record
+ }) => mutateNovaWants(method, body),
+ onSuccess: (payload) => {
+ setError(null)
+ if (payload.board) {
+ queryClient.setQueryData(['nova-wants'], payload)
+ } else {
+ void queryClient.invalidateQueries({ queryKey: ['nova-wants'] })
+ }
+ },
+ onError: (err) => {
+ setError(err instanceof Error ? err.message : 'Nova Wants update failed')
+ },
+ })
+
+ const submitDraft = (event: React.FormEvent) => {
+ event.preventDefault()
+ mutation.mutate({
+ method: 'POST',
+ body: draft,
+ })
+ setDraft(emptyDraft())
+ }
+
+ const submitEdit = (event: React.FormEvent) => {
+ event.preventDefault()
+ if (!editing) return
+ mutation.mutate({
+ method: 'PATCH',
+ body: editing,
+ })
+ setEditing(null)
+ }
+
+ if (query.isLoading) {
+ return (
+
+ Loading Nova Wants...
+
+ )
+ }
+
+ if (!board) {
+ return (
+
+
+ Nova Wants is degraded.
+
+
+ {query.data?.warning ?? 'Nova Wants is not wired yet.'}
+
+ {query.data?.storage?.path ? (
+
+ Preserved file: {query.data.storage.path}
+
+ ) : null}
+
+ )
+ }
+
+ return (
+
+
+
+
+
+
+ Nova Wants
+
+
+ Protected self-state request board
+
+
+ Persisted wants, requests, and needs for Nova. It is separate from
+ Taylor tasks and customer work; risky identity, self-state,
+ external, money, and secrets items stay gated for Taylor review.
+
+
+
+
+
+ {board.cards.length}
+
+
+ cards
+
+
+
+
+ {
+ board.cards.filter(
+ (card) => card.status === 'needs-taylor-review',
+ ).length
+ }
+
+
+ review
+
+
+
+
+ {
+ board.cards.filter(
+ (card) =>
+ card.status === 'approved' || card.status === 'building',
+ ).length
+ }
+
+
+ active
+
+
+
+
+
+ {query.data?.storage ? (
+
+
+ Source / persistence
+
+
+
+ Store:{' '}
+ {query.data.storage.path}
+
+
+ Seed:{' '}
+
+ {query.data.storage.seededFrom}
+
+
+
+
+ ) : null}
+
+
+
+
+
+ setDraft((current) => ({
+ ...current,
+ title: event.target.value,
+ }))
+ }
+ />
+
+
+
+
+
+ setDraft((current) => ({
+ ...current,
+ category: event.target.value as NovaWantsCategory,
+ }))
+ }
+ >
+ {CATEGORY_OPTIONS.map((option) => (
+
+ {option}
+
+ ))}
+
+
+
+
+
+
+ setDraft((current) => ({
+ ...current,
+ approvalLevel: event.target.value as NovaWantsApprovalLevel,
+ }))
+ }
+ >
+ {APPROVAL_OPTIONS.map((option) => (
+
+ {option}
+
+ ))}
+
+
+
+
+
+
+ setDraft((current) => ({
+ ...current,
+ whyThisMatters: event.target.value,
+ }))
+ }
+ />
+
+
+
+
+
+ setDraft((current) => ({
+ ...current,
+ description: event.target.value,
+ }))
+ }
+ />
+
+
+
+
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+
+
+ {board.columns.map((column) => {
+ const cards = cardsByColumn.get(column.id) ?? []
+ return (
+
+
+
+ {column.label}
+
+
+ {cards.length}
+
+
+
+ {cards.map((card, index) => (
+
+
+ mutation.mutate({
+ method: 'DELETE',
+ body: { id: target.id },
+ })
+ }
+ onMove={(target, status) =>
+ mutation.mutate({
+ method: 'PATCH',
+ body: {
+ id: target.id,
+ status,
+ position: index * 10 + 10,
+ },
+ })
+ }
+ />
+
+
{
+ const before = cards[index - 1]
+ mutation.mutate({
+ method: 'PATCH',
+ body: {
+ id: card.id,
+ position: before.position - 1,
+ },
+ })
+ }}
+ >
+ up
+
+
{
+ const after = cards[index + 1]
+ mutation.mutate({
+ method: 'PATCH',
+ body: {
+ id: card.id,
+ position: after.position + 1,
+ },
+ })
+ }}
+ >
+ down
+
+
+
+ ))}
+
+
+ )
+ })}
+
+
+
+
+ {editing ? (
+
+
+
+
+
+ edit Nova want
+
+
+ {editing.title}
+
+
+
setEditing(null)}>
+ close
+
+
+
+
+
+ setEditing((current) =>
+ current
+ ? { ...current, title: event.target.value }
+ : current,
+ )
+ }
+ />
+
+
+
+ setEditing((current) =>
+ current
+ ? {
+ ...current,
+ status: event.target.value as NovaWantsColumn,
+ }
+ : current,
+ )
+ }
+ >
+ {board.columns.map((column) => (
+
+ {column.label}
+
+ ))}
+
+
+
+
+ setEditing((current) =>
+ current
+ ? {
+ ...current,
+ category: event.target.value as NovaWantsCategory,
+ }
+ : current,
+ )
+ }
+ >
+ {CATEGORY_OPTIONS.map((option) => (
+
+ {option}
+
+ ))}
+
+
+
+
+ setEditing((current) =>
+ current
+ ? {
+ ...current,
+ approvalLevel: event.target
+ .value as NovaWantsApprovalLevel,
+ }
+ : current,
+ )
+ }
+ >
+ {APPROVAL_OPTIONS.map((option) => (
+
+ {option}
+
+ ))}
+
+
+
+
+
+ setEditing((current) =>
+ current
+ ? { ...current, description: event.target.value }
+ : current,
+ )
+ }
+ />
+
+
+
+
+
+ setEditing((current) =>
+ current
+ ? { ...current, whyThisMatters: event.target.value }
+ : current,
+ )
+ }
+ />
+
+
+
+
+ setEditing((current) =>
+ current
+ ? { ...current, source: event.target.value }
+ : current,
+ )
+ }
+ />
+
+
+
+ setEditing((current) =>
+ current
+ ? {
+ ...current,
+ linkedReceipt: event.target.value || null,
+ }
+ : current,
+ )
+ }
+ />
+
+
+
+
setEditing(null)}>
+ cancel
+
+
+ setEditing((current) =>
+ current ? { ...current, status: 'approved' } : current,
+ )
+ }
+ >
+ mark approved
+
+
+ save
+
+
+
+
+ ) : null}
+
+ )
+}
diff --git a/src/screens/dashboard/components/taylor-approval-queue-card.tsx b/src/screens/dashboard/components/taylor-approval-queue-card.tsx
new file mode 100644
index 0000000000..f370a1131d
--- /dev/null
+++ b/src/screens/dashboard/components/taylor-approval-queue-card.tsx
@@ -0,0 +1,182 @@
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
+import type {
+ TaylorApprovalItem,
+ TaylorApprovalQueue,
+} from '../../../server/taylor-approval-queue'
+
+type QueueResponse = {
+ ok: boolean
+ queue: TaylorApprovalQueue
+}
+
+async function readQueue(): Promise {
+ const response = await fetch('/api/taylor-approvals')
+ if (!response.ok) throw new Error(`Approval queue read failed: ${response.status}`)
+ return (await response.json()) as QueueResponse
+}
+
+const RISK_TONE: Record = {
+ critical: 'border-[var(--theme-danger)] text-[var(--theme-danger)]',
+ high: 'border-[var(--theme-accent)] text-[var(--theme-accent)]',
+ medium: 'border-[var(--theme-border)] text-[var(--theme-text)]',
+ low: 'border-[var(--theme-border)] text-[var(--theme-muted)]',
+}
+
+const KIND_LABEL: Record = {
+ 'fabric-review': 'Fabric review',
+ 'self-state-proposal': 'Self-state proposal',
+ 'protected-want': 'Protected want',
+ 'blocked-action': 'Blocked action',
+}
+
+function RiskPill({ risk }: { risk: string }) {
+ return (
+
+ {risk}
+
+ )
+}
+
+function ItemRow({
+ item,
+ onStatus,
+ busy,
+}: {
+ item: TaylorApprovalItem
+ onStatus: (reviewId: string, status: 'approved' | 'rejected' | 'parked') => void
+ busy: boolean
+}) {
+ return (
+
+
+
+
+ {item.title}
+
+
+ {item.why}
+
+
+
+
+
+ {KIND_LABEL[item.kind]}
+ ·
+ {item.targetSystem}
+ {item.links.slice(0, 2).map((link) => (
+
+ {link.label}: {link.value}
+
+ ))}
+
+ {item.actionable && item.reviewId ? (
+
+ {(['approved', 'rejected', 'parked'] as const).map((status) => (
+ onStatus(item.reviewId as string, status)}
+ className="rounded border border-[var(--theme-border)] px-2 py-0.5 text-[10px] text-[var(--theme-text)] hover:bg-[var(--theme-card)] disabled:opacity-50"
+ >
+ {status === 'approved'
+ ? 'Approve'
+ : status === 'rejected'
+ ? 'Reject'
+ : 'Park'}
+
+ ))}
+
+ ) : (
+
+ Read-only here — resolves at its source system.
+
+ )}
+
+ )
+}
+
+export function TaylorApprovalQueueCard() {
+ const queryClient = useQueryClient()
+ const query = useQuery({
+ queryKey: ['taylor-approvals'],
+ queryFn: readQueue,
+ staleTime: 5_000,
+ refetchInterval: 30_000,
+ })
+
+ const mutation = useMutation({
+ mutationFn: async (input: {
+ reviewId: string
+ status: 'approved' | 'rejected' | 'parked'
+ }) => {
+ const response = await fetch('/api/taylor-approvals', {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(input),
+ })
+ if (!response.ok) throw new Error(`Approval update failed: ${response.status}`)
+ return (await response.json()) as QueueResponse
+ },
+ onSuccess: (data) => {
+ queryClient.setQueryData(['taylor-approvals'], data)
+ void queryClient.invalidateQueries({ queryKey: ['nova-fabric'] })
+ void queryClient.invalidateQueries({ queryKey: ['nova-wants'] })
+ },
+ })
+
+ const queue = query.data?.queue
+
+ return (
+
+
+ {query.isLoading ? (
+ Loading queue…
+ ) : null}
+ {query.isError ? (
+
+ Approval queue unreachable — gateway or server offline.
+
+ ) : null}
+ {queue?.degraded ? (
+
+ Fabric snapshot degraded — showing partial queue.
+
+ ) : null}
+ {queue && queue.items.length === 0 ? (
+
+ Nothing needs you right now.
+
+ ) : null}
+
+ {queue?.items.slice(0, 8).map((item) => (
+
+ mutation.mutate({ reviewId, status })
+ }
+ />
+ ))}
+
+
+ )
+}
diff --git a/src/screens/dashboard/components/token-mix-card.tsx b/src/screens/dashboard/components/token-mix-card.tsx
index 48ec51e3f6..7836a6990e 100644
--- a/src/screens/dashboard/components/token-mix-card.tsx
+++ b/src/screens/dashboard/components/token-mix-card.tsx
@@ -1,4 +1,4 @@
-import type { DashboardOverview } from '@/server/dashboard-aggregator'
+import type { DashboardOverview } from '@/server/dashboard-aggregator'
function formatTokens(n: number): string {
if (!n || n <= 0) return '0'
@@ -18,7 +18,7 @@ type Slice = {
/**
* Token mix card. Pure derive-from-aggregator: shows the input vs
* output vs cache vs reasoning split as a stacked horizontal bar plus
- * a tiny legend table. The Hermes Agent confirmed this is chartable
+ * a tiny legend table. The Nova gateway confirmed this is chartable
* today from `daily[].input_tokens / output_tokens / cache_read_tokens
* / reasoning_tokens` (no backend change required).
*
@@ -86,12 +86,12 @@ export function TokenMixCard({
className="text-[10px] font-semibold uppercase tracking-[0.18em]"
style={{ color: 'var(--theme-text)' }}
>
- Token mix · {analytics.windowDays}d
+ Token mix · {analytics.windowDays}d
out/in {ratio.toFixed(1)}%
@@ -150,7 +150,7 @@ export function TokenMixCard({
className="ml-1"
style={{ color: 'var(--theme-muted)' }}
>
- · {widthPct.toFixed(0)}%
+ · {widthPct.toFixed(0)}%
diff --git a/src/screens/dashboard/components/trust-ledger-card.tsx b/src/screens/dashboard/components/trust-ledger-card.tsx
new file mode 100644
index 0000000000..cc499eb098
--- /dev/null
+++ b/src/screens/dashboard/components/trust-ledger-card.tsx
@@ -0,0 +1,262 @@
+import type {
+ DashboardTrustLedgerMilestone,
+ DashboardTrustLedgerSection,
+ TrustLedgerStatus,
+} from '@/server/dashboard-aggregator'
+
+const STATUS_META: Record<
+ TrustLedgerStatus,
+ { label: string; tone: string; bar: string }
+> = {
+ Unwired: {
+ label: 'Unwired',
+ tone: 'text-[var(--theme-muted)] border-[var(--theme-border)] bg-[var(--theme-card2)]',
+ bar: 'var(--theme-muted)',
+ },
+ Tested: {
+ label: 'Tested',
+ tone: 'text-[var(--theme-warning)] border-[color-mix(in_srgb,var(--theme-warning)_35%,var(--theme-border))] bg-[color-mix(in_srgb,var(--theme-warning)_10%,transparent)]',
+ bar: 'var(--theme-warning)',
+ },
+ Reliable: {
+ label: 'Reliable',
+ tone: 'text-[var(--theme-accent)] border-[color-mix(in_srgb,var(--theme-accent)_35%,var(--theme-border))] bg-[color-mix(in_srgb,var(--theme-accent)_10%,transparent)]',
+ bar: 'var(--theme-accent)',
+ },
+ Trusted: {
+ label: 'Trusted',
+ tone: 'text-[var(--theme-success)] border-[color-mix(in_srgb,var(--theme-success)_35%,var(--theme-border))] bg-[color-mix(in_srgb,var(--theme-success)_10%,transparent)]',
+ bar: 'var(--theme-success)',
+ },
+ Autonomous: {
+ label: 'Autonomous',
+ tone: 'text-[var(--theme-text)] border-[color-mix(in_srgb,var(--theme-accent)_45%,var(--theme-border))] bg-[color-mix(in_srgb,var(--theme-accent)_16%,transparent)]',
+ bar: 'var(--theme-accent)',
+ },
+}
+
+function clampPercent(current: number, target: number): number {
+ if (!target || target <= 0) return current > 0 ? 100 : 0
+ return Math.max(0, Math.min(100, Math.round((current / target) * 100)))
+}
+
+function compactNumber(value: number): string {
+ if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`
+ if (value >= 1_000) return `${(value / 1_000).toFixed(1)}K`
+ return String(value)
+}
+
+function StatusPill({ status }: { status: TrustLedgerStatus }) {
+ const meta = STATUS_META[status]
+ return (
+
+ {meta.label}
+
+ )
+}
+
+function ProgressBar({
+ milestone,
+}: {
+ milestone: DashboardTrustLedgerMilestone
+}) {
+ const pct = clampPercent(
+ milestone.progress.current,
+ milestone.progress.target,
+ )
+ return (
+
+ )
+}
+
+function MiniList({
+ title,
+ items,
+ empty,
+}: {
+ title: string
+ items: Array
+ empty: string
+}) {
+ return (
+
+
+ {title}
+
+ {items.length > 0 ? (
+
+ {items.map((item) => (
+
+
+
+ {item.label}
+
+
+ {item.weakArea ?? item.summary}
+
+
+
+
+ ))}
+
+ ) : (
+
{empty}
+ )}
+
+ )
+}
+
+function MilestoneRow({
+ milestone,
+}: {
+ milestone: DashboardTrustLedgerMilestone
+}) {
+ const evidence = milestone.evidence.filter(Boolean).slice(0, 2)
+ return (
+
+
+
+
+ {milestone.label}
+
+
+ {milestone.summary}
+
+
+
+
+
+
+
+
+
+ {compactNumber(milestone.progress.current)} /{' '}
+ {compactNumber(milestone.progress.target)} {milestone.progress.unit}
+
+
+
+
+ {evidence.length > 0 ? (
+
+ {evidence.map((line) => (
+
+ {line}
+
+ ))}
+
+ ) : null}
+
+ )
+}
+
+export function TrustLedgerCard({
+ trustLedger,
+}: {
+ trustLedger: DashboardTrustLedgerSection | null
+}) {
+ if (!trustLedger) return null
+
+ return (
+
+
+
+
+
+
+ Trust Ledger
+
+
+ Capability milestones from live Hermes, memory, jobs, auth, and
+ vault signals.
+
+
+
+
+ {trustLedger.score}
+
+
+
+
+
+
+
+
+ {trustLedger.counters.reliableOrBetter}
+
+
+ reliable+
+
+
+
+
+ {trustLedger.counters.trustedOrBetter}
+
+
+ trusted+
+
+
+
+
+ {trustLedger.counters.autonomous}
+
+
+ auto
+
+
+
+
+
+
+
+
+ {trustLedger.milestones.map((milestone) => (
+
+ ))}
+
+
+
+ )
+}
diff --git a/src/screens/dashboard/dashboard-screen.tsx b/src/screens/dashboard/dashboard-screen.tsx
index e103b5e04f..9df4bf3f1e 100644
--- a/src/screens/dashboard/dashboard-screen.tsx
+++ b/src/screens/dashboard/dashboard-screen.tsx
@@ -1,12 +1,10 @@
-import {
+import {
BubbleChatAddIcon,
CheckmarkCircle02Icon,
ConsoleIcon,
Edit02Icon,
- Moon02Icon,
PuzzleIcon,
Settings02Icon,
- Sun02Icon,
} from '@hugeicons/core-free-icons'
import { HugeiconsIcon } from '@hugeicons/react'
import { useQuery } from '@tanstack/react-query'
@@ -23,13 +21,26 @@ import {
} from 'recharts'
import { AchievementsCard } from './components/achievements-card'
import { ActiveModelKpi } from './components/active-model-kpi'
+import { AgentWorkforceCard } from './components/agent-workforce-card'
import { AnalyticsChartCard } from './components/analytics-chart-card'
import { AttentionMarquee } from './components/attention-marquee'
import { CacheEfficiencyCard } from './components/cache-efficiency-card'
+import { ControlLoopsCard } from './components/control-loops-card'
import { CostLedgerCard } from './components/cost-ledger-card'
+import { DailyCheckCard } from './components/daily-check-card'
import { EditModePanel } from './components/edit-mode-panel'
+import { GitWorkCard } from './components/git-work-card'
import { HeroMetrics } from './components/hero-metrics'
+import { HomeModeCard } from './components/home-mode-card'
+import { LiveSystemsCard } from './components/live-systems-card'
import { LogsTailCard } from './components/logs-tail-card'
+import { MindGraphCard } from './components/mind-graph-card'
+import { NovaFabricCard } from './components/nova-fabric-card'
+import { NovaWantsCard } from './components/nova-wants-card'
+import { AgentLanesCard } from './components/agent-lanes-card'
+import { NovaSessionBridgeCard } from './components/nova-session-bridge-card'
+import { TaylorApprovalQueueCard } from './components/taylor-approval-queue-card'
+import { NotebookLmBridgeCard } from './components/notebooklm-bridge-card'
import { OperatorTipCard } from './components/operator-tip-card'
import { OpsStrip } from './components/ops-strip'
import { ProviderMixCard } from './components/provider-mix-card'
@@ -37,18 +48,21 @@ import { SessionsIntelligenceCard } from './components/sessions-intelligence-car
import { SkillsUsageCard } from './components/skills-usage-card'
import { TokenMixHourCard } from './components/token-mix-hour-card'
import { TopModelsCard } from './components/top-models-card'
+import { TrustLedgerCard } from './components/trust-ledger-card'
import { VelocityCard } from './components/velocity-card'
import { WidgetShell } from './components/widget-shell'
+import { CHART_FALLBACKS, NOVA_COCKPIT_STYLE } from './lib/nova-cockpit-theme'
import { normalizeDashboardSessionsPayload } from './lib/sessions-query'
import { useDashboardLayout } from './lib/use-dashboard-layout'
import type { SessionRowData } from './components/sessions-intelligence-card'
import type { AnalyticsPeriod } from './components/analytics-chart-card'
import type { ReactNode } from 'react'
+import type { CrewMember } from '@/hooks/use-crew-status'
import type { ClaudeSession } from '@/server/claude-api'
import type { DashboardOverview } from '@/server/dashboard-aggregator'
+import { useCrewStatus } from '@/hooks/use-crew-status'
import { getUnavailableReason } from '@/lib/feature-gates'
import { cn } from '@/lib/utils'
-import { applyTheme, useSettingsStore } from '@/hooks/use-settings'
import { openHamburgerMenu } from '@/components/mobile-hamburger-menu'
import { useFeatureAvailable } from '@/hooks/use-feature-available'
@@ -87,15 +101,18 @@ function alpha(color: string, amount: number): string {
function readDashboardPalette() {
return {
- accent: themeColor('--theme-accent', '#6366f1'),
- accentSecondary: themeColor('--theme-accent-secondary', '#8b5cf6'),
- success: themeColor('--theme-success', '#22c55e'),
- warning: themeColor('--theme-warning', '#f59e0b'),
- danger: themeColor('--theme-danger', '#ef4444'),
- muted: themeColor('--theme-muted', '#6b7280'),
- border: themeColor('--theme-border', '#333333'),
- card: themeColor('--theme-card', '#1a1a2e'),
- text: themeColor('--theme-text', '#e5e7eb'),
+ accent: themeColor('--theme-accent', CHART_FALLBACKS.accent),
+ accentSecondary: themeColor(
+ '--theme-accent-secondary',
+ CHART_FALLBACKS.accentSecondary,
+ ),
+ success: themeColor('--theme-success', CHART_FALLBACKS.success),
+ warning: themeColor('--theme-warning', CHART_FALLBACKS.warning),
+ danger: themeColor('--theme-danger', CHART_FALLBACKS.danger),
+ muted: themeColor('--theme-muted', CHART_FALLBACKS.muted),
+ border: themeColor('--theme-border', CHART_FALLBACKS.border),
+ card: themeColor('--theme-card', CHART_FALLBACKS.card),
+ text: themeColor('--theme-text', CHART_FALLBACKS.text),
}
}
@@ -117,6 +134,87 @@ function useDashboardPalette() {
return palette
}
+function toMilliseconds(value: number | null | undefined): number | null {
+ if (!value || !Number.isFinite(value)) return null
+ return value > 1_000_000_000_000 ? value : value * 1000
+}
+
+function platformUpdatedAt(value: unknown): string | null {
+ if (!value || typeof value !== 'object') return null
+ const record = value as Record
+ const candidate = record.updatedAt ?? record.updated_at
+ return typeof candidate === 'string' ? candidate : null
+}
+
+function platformErrorMessage(value: unknown): string | null {
+ if (!value || typeof value !== 'object') return null
+ const record = value as Record
+ const candidate = record.errorMessage ?? record.error_message
+ return typeof candidate === 'string' ? candidate : null
+}
+
+function buildCrewStatusFallback(
+ member: CrewMember | null,
+): DashboardOverview['status'] {
+ if (!member) return null
+ const updatedAt = new Date().toISOString()
+ return {
+ gatewayState: member.gatewayState,
+ activeSessions: member.sessionCount,
+ activeAgents:
+ member.processAlive && member.gatewayState === 'running' ? 1 : 0,
+ restartRequested: false,
+ updatedAt,
+ lastHeartbeatAt: updatedAt,
+ version: null,
+ releaseDate: null,
+ configVersion: null,
+ latestConfigVersion: null,
+ hermesHome: null,
+ }
+}
+
+function buildCrewPlatformsFallback(
+ member: CrewMember | null,
+): DashboardOverview['platforms'] {
+ if (!member) return []
+ return Object.entries(member.platforms).map(([name, value]) => {
+ const record = value as Record
+ return {
+ name,
+ state: typeof record.state === 'string' ? record.state : 'unknown',
+ updatedAt: platformUpdatedAt(value),
+ errorMessage: platformErrorMessage(value),
+ }
+ })
+}
+
+function buildCrewCronFallback(
+ member: CrewMember | null,
+): DashboardOverview['cron'] {
+ if (!member || member.cronJobCount <= 0) return null
+ return {
+ total: member.cronJobCount,
+ paused: 0,
+ running: 0,
+ failed: 0,
+ nextRunAt: null,
+ recentFailures: [],
+ }
+}
+
+function buildCrewModelFallback(
+ member: CrewMember | null,
+): DashboardOverview['modelInfo'] {
+ if (!member?.model) return null
+ return {
+ provider: member.provider || 'unknown',
+ model: member.model,
+ effectiveContextLength: 0,
+ capabilities: null,
+ }
+}
+
// ── Glass Card ───────────────────────────────────────────────────
function GlassCard({
@@ -175,7 +273,10 @@ function EnhancedBadge({ label = 'Enhanced API' }: { label?: string }) {
className="inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.12em]"
style={{
border: `1px solid ${themeColor('--theme-accent-border', 'rgba(245, 158, 11, 0.28)')}`,
- background: themeColor('--theme-accent-subtle', 'rgba(245, 158, 11, 0.12)'),
+ background: themeColor(
+ '--theme-accent-subtle',
+ 'rgba(245, 158, 11, 0.12)',
+ ),
color: themeColor('--theme-accent', '#f59e0b'),
}}
>
@@ -300,15 +401,35 @@ function ActivityChart({
>
-
-
+
+
-
-
+
+
-
+
-
+
Sessions
-
+
Messages
@@ -391,7 +518,9 @@ function SkillsWidget({
const skillsQuery = useQuery({
queryKey: ['claude-skills'],
queryFn: async () => {
- const res = await fetch('/api/skills?tab=installed&limit=200&summary=search')
+ const res = await fetch(
+ '/api/skills?tab=installed&limit=200&summary=search',
+ )
if (!res.ok) return []
const data = await res.json()
return (data?.skills ?? []) as Array>
@@ -411,15 +540,14 @@ function SkillsWidget({
)
}
- // Summary view per Hermes Agent feedback: 'don’t enumerate, summarise.'
+ // Summary view per Nova gateway feedback: 'don’t enumerate, summarise.'
// Prefer real usage signal from /api/analytics/usage when present
// (counts what the agent *actually used*, not just what's installed).
const installed = skills.length
const enabled = skills.filter((s) => s.enabled !== false).length
const usedThisWindow = usage?.distinctSkills ?? null
const topUsed = usage?.topSkills[0]
- const topInstalled =
- skills.find((s) => s.enabled !== false) ?? skills.at(0)
+ const topInstalled = skills.find((s) => s.enabled !== false) ?? skills.at(0)
const topName = topUsed?.skill ?? String(topInstalled?.name ?? '—')
return (
@@ -466,8 +594,8 @@ function SkillsWidget({
{installed === 0
? 'no skills installed'
: usedThisWindow !== null && usedThisWindow > 0
- ? `${enabled} enabled · ${usedThisWindow} used · top: ${topName}`
- : `${enabled} enabled · top: ${topName}`}
+ ? `${enabled} enabled · ${usedThisWindow} used · top: ${topName}`
+ : `${enabled} enabled · top: ${topName}`}
)
@@ -491,7 +619,7 @@ function SecondaryAction({
type="button"
onClick={onClick}
disabled={disabled}
- className="group inline-flex items-center gap-1.5 rounded-lg border px-3 py-2 text-xs font-semibold uppercase tracking-[0.05em] transition-all hover:scale-[1.015] hover:bg-[var(--theme-card)]/70 hover:text-[var(--theme-text)] active:scale-[0.99] disabled:cursor-not-allowed disabled:opacity-50"
+ className="group inline-flex min-h-10 items-center justify-center gap-1.5 rounded-lg border px-2.5 py-2 text-xs font-semibold uppercase tracking-[0.05em] transition-all hover:scale-[1.015] hover:bg-[var(--theme-card)]/70 hover:text-[var(--theme-text)] active:scale-[0.99] disabled:cursor-not-allowed disabled:opacity-50 sm:min-h-0 sm:px-3"
style={{
borderColor: 'var(--theme-border)',
color: 'var(--theme-muted)',
@@ -633,6 +761,14 @@ function SessionRow({
export function DashboardScreen() {
const navigate = useNavigate()
const skillsAvailable = useFeatureAvailable('skills')
+ const crewStatus = useCrewStatus()
+ const novaCrew = useMemo((): CrewMember | null => {
+ const workspace = crewStatus.crew.find(
+ (member) => member.id === 'workspace',
+ )
+ if (workspace) return workspace
+ return crewStatus.crew.length > 0 ? crewStatus.crew[0] : null
+ }, [crewStatus.crew])
const sessionsQuery = useQuery({
// Use a dedicated query key — NOT chatQueryKeys.sessions — to avoid
// cache collisions with the chat sidebar which fetches fewer sessions
@@ -685,47 +821,64 @@ export function DashboardScreen() {
// Enriched rows for the Sessions Intelligence card. Keeps the rich
// fields (`derivedTitle`, `kind`, `status`, `source`, `updatedAt`,
// etc.) the legacy adapter dropped.
- const sessionRows: Array = useMemo(
- () =>
- [...rawSessions]
- .sort(
- (a, b) =>
- ((b.updatedAt as number | undefined) ??
- (b.startedAt as number | undefined) ??
- 0) -
- ((a.updatedAt as number | undefined) ??
- (a.startedAt as number | undefined) ??
- 0),
- )
- .slice(0, 12)
- .map((s) => ({
- key: String(s.key ?? s.id ?? ''),
- title:
- (s.derivedTitle as string | undefined) ||
- (s.title as string | undefined) ||
- (s.preview as string | undefined) ||
- String(s.key ?? ''),
- kind: String(s.kind ?? 'chat'),
- status: String(s.status ?? ''),
- source: (s.source as string | undefined) ?? null,
- model: (s.model as string | undefined) ?? null,
- messageCount:
- ((s.messageCount as number | undefined) ??
- (s.message_count as number | undefined) ??
- 0),
- toolCallCount:
- ((s.toolCallCount as number | undefined) ??
- (s.tool_call_count as number | undefined) ??
- 0),
- tokenCount:
- ((s.tokenCount as number | undefined) ??
- (s.totalTokens as number | undefined) ??
- 0),
- startedAt: (s.startedAt as number | undefined) ?? null,
- updatedAt: (s.updatedAt as number | undefined) ?? null,
- })),
- [rawSessions],
- )
+ const sessionRows: Array = useMemo(() => {
+ const rows = [...rawSessions]
+ .sort(
+ (a, b) =>
+ ((b.updatedAt as number | undefined) ??
+ (b.startedAt as number | undefined) ??
+ 0) -
+ ((a.updatedAt as number | undefined) ??
+ (a.startedAt as number | undefined) ??
+ 0),
+ )
+ .slice(0, 12)
+ .map((s) => ({
+ key: String(s.key ?? s.id ?? ''),
+ title:
+ (s.derivedTitle as string | undefined) ||
+ (s.title as string | undefined) ||
+ (s.preview as string | undefined) ||
+ String(s.key ?? ''),
+ kind: String(s.kind ?? 'chat'),
+ status: String(s.status ?? ''),
+ source: (s.source as string | undefined) ?? null,
+ model: (s.model as string | undefined) ?? null,
+ messageCount:
+ (s.messageCount as number | undefined) ??
+ (s.message_count as number | undefined) ??
+ 0,
+ toolCallCount:
+ (s.toolCallCount as number | undefined) ??
+ (s.tool_call_count as number | undefined) ??
+ 0,
+ tokenCount:
+ (s.tokenCount as number | undefined) ??
+ (s.totalTokens as number | undefined) ??
+ 0,
+ startedAt: (s.startedAt as number | undefined) ?? null,
+ updatedAt: (s.updatedAt as number | undefined) ?? null,
+ }))
+
+ if (rows.length > 0 || !novaCrew || !novaCrew.lastSessionTitle) return rows
+
+ const lastSessionAt = toMilliseconds(novaCrew.lastSessionAt)
+ return [
+ {
+ key: 'main',
+ title: novaCrew.lastSessionTitle,
+ kind: 'chat',
+ status: novaCrew.processAlive ? 'available' : 'offline',
+ source: 'crew-status',
+ model: novaCrew.model || null,
+ messageCount: novaCrew.messageCount,
+ toolCallCount: novaCrew.toolCallCount,
+ tokenCount: novaCrew.totalTokens,
+ startedAt: lastSessionAt,
+ updatedAt: lastSessionAt,
+ },
+ ]
+ }, [novaCrew, rawSessions])
const stats = useMemo(() => {
let totalMessages = 0,
@@ -737,12 +890,12 @@ export function DashboardScreen() {
totalTokens += (s.input_tokens ?? 0) + (s.output_tokens ?? 0)
}
return {
- totalSessions: sessions.length,
- totalMessages,
- totalToolCalls,
- totalTokens,
+ totalSessions: sessions.length || (novaCrew ? novaCrew.sessionCount : 0),
+ totalMessages: totalMessages || (novaCrew ? novaCrew.messageCount : 0),
+ totalToolCalls: totalToolCalls || (novaCrew ? novaCrew.toolCallCount : 0),
+ totalTokens: totalTokens || (novaCrew ? novaCrew.totalTokens : 0),
}
- }, [sessions])
+ }, [novaCrew, sessions])
const recentSessions = useMemo(
() =>
@@ -794,13 +947,13 @@ export function DashboardScreen() {
})
useEffect(() => {
if (typeof window !== 'undefined') {
- window.localStorage.setItem(
- 'dashboard.analyticsPeriod',
- String(period),
- )
+ window.localStorage.setItem('dashboard.analyticsPeriod', String(period))
}
}, [period])
+ // Nova skin stamping moved to WorkspaceShell (app-wide, always on) —
+ // Taylor: every module goes navy/amber, not just the dashboard.
+
// Aggregate dashboard overview — surfaces the data the native
// Hermes dashboard exposes (status, platforms, cron, achievements,
// model info, analytics) in a single round trip with per-section
@@ -820,62 +973,80 @@ export function DashboardScreen() {
refetchInterval: 30_000,
})
const overview = overviewQuery.data ?? null
+ const novaStatus = overview?.status ?? buildCrewStatusFallback(novaCrew)
+ const novaPlatforms =
+ overview?.platforms && overview.platforms.length > 0
+ ? overview.platforms
+ : buildCrewPlatformsFallback(novaCrew)
+ const novaCron = overview?.cron ?? buildCrewCronFallback(novaCrew)
+ const novaModelInfo = overview?.modelInfo ?? buildCrewModelFallback(novaCrew)
const palette = useDashboardPalette()
- const updateSettings = useSettingsStore((state) => state.updateSettings)
- const [isDark, setIsDark] = useState(() => {
- if (typeof document === 'undefined') return true
- const dt = document.documentElement.getAttribute('data-theme') || ''
- return !dt.endsWith('-light')
- })
-
return (
-
+
+ {/* Quiet-tier decorative cards (operator_tip, achievements,
+ mix_rhythm, skills_usage, notebook_bridge, trust_ledger):
+ styled from this mount-site wrapper rather than touching each
+ card's internals (plan's original 4.4 called for a `nova-label`
+ header-class override + per-file `data-tier` attr, but none of
+ these six cards actually use the `nova-label` class — it's only
+ used by daily-check/home-mode/mind-graph — so that rule would
+ have been a no-op; adapted to target the Tailwind utility
+ classes these cards really render: `.p-4` padding on the two
+ `
`-based cards (notebook bridge, trust ledger) and the
+ shared `text-[10px]` eyebrow/header size used by 5 of 6). */}
+
{/* Floating mobile nav: hamburger left, theme toggle right */}
-
+
-
-
+
+
-
{
- const LIGHT_DARK_PAIRS: Record = {
- 'claude-nous': 'claude-nous-light',
- 'claude-nous-light': 'claude-nous',
- 'claude-official': 'claude-official-light',
- 'claude-official-light': 'claude-official',
- 'claude-classic': 'claude-classic-light',
- 'claude-classic-light': 'claude-classic',
- 'claude-slate': 'claude-slate-light',
- 'claude-slate-light': 'claude-slate',
- }
- const cur = document.documentElement.getAttribute('data-theme') || 'claude-official'
- const nextDataTheme = LIGHT_DARK_PAIRS[cur] || (isDark ? 'claude-official-light' : 'claude-official')
- import('@/lib/theme').then(({ setTheme }) => { setTheme(nextDataTheme as any) })
- const nextMode = nextDataTheme.endsWith('-light') ? 'light' : 'dark'
- applyTheme(nextMode)
- updateSettings({ theme: nextMode })
- setIsDark(nextMode === 'dark')
- }}
- className="flex items-center justify-center w-11 h-11 rounded-xl active:bg-white/10 transition-colors touch-manipulation"
+
-
-
+ local skin
+
-
- {/* ── Header: brand lockup left, action cluster right.
+
+ {/* ── Header: brand lockup left, action cluster right.
Iteration 010: dropped redundant "Dashboard" eyebrow (the
- page IS the dashboard); promoted "Hermes Workspace" to
+ page IS the dashboard); promoted "Nova Mission Control" to
the primary heading at a larger weight. Logo bumped from
36px → 44px and gets a soft accent glow + ring so the
lockup commands the left side instead of feeling like
@@ -883,321 +1054,385 @@ export function DashboardScreen() {
(not centered) on purpose: ops dashboards put brand left
+ actions right because that's the spatial hierarchy
operators expect (Linear, Vercel, Datadog all do this). */}
-
-
-
-
-
- {/* Iter 011: dropped the 'Operator console · vX.Y.Z'
+
+
+
+
+
+ {/* Iter 011: dropped the 'Operator console · vX.Y.Z'
eyebrow. The gateway version is already on the OpsStrip
(♦ GATEWAY V0.12.0), so the eyebrow was duplicating it.
Single bold lockup feels cleaner; vertical centering on
the lockup matches the height of the action cluster on
the right so they don't visually drift. */}
-
-
- Hermes Workspace
-
+
+
+ Nova Mission Control
+
+
-
- {/* Action row: hierarchy per Hermes Agent review.
+ {/* Action row: hierarchy per Nova gateway review.
New Chat is primary (full button + accent), Terminal +
Skills are secondary, Settings collapses to icon-only. */}
-
-
- navigate({
- to: '/chat/$sessionKey',
- params: { sessionKey: 'new' },
- })
- }
- className="group relative inline-flex items-center gap-2 overflow-hidden rounded-lg px-3 py-1.5 text-xs font-semibold uppercase tracking-[0.05em] transition-all hover:scale-[1.02] active:scale-[0.99] sm:px-3.5 sm:py-2 sm:text-sm"
- style={{
- background: `linear-gradient(135deg, ${palette.accent}, ${palette.accentSecondary})`,
- color: 'var(--theme-on-accent, white)',
- boxShadow: `0 6px 18px -8px ${palette.accent}aa, inset 0 1px 0 0 rgba(255,255,255,0.18)`,
- }}
- >
-
+
+ navigate({
+ to: '/chat/$sessionKey',
+ params: { sessionKey: 'new' },
+ })
+ }
+ className="group relative col-span-2 inline-flex min-h-10 items-center justify-center gap-2 overflow-hidden rounded-lg px-3 py-1.5 text-xs font-semibold uppercase tracking-[0.05em] transition-all hover:scale-[1.02] active:scale-[0.99] sm:col-span-1 sm:min-h-0 sm:px-3.5 sm:py-2 sm:text-sm"
style={{
- background:
- 'linear-gradient(135deg, rgba(255,255,255,0.15), transparent 60%)',
+ background: `linear-gradient(135deg, ${palette.accent}, ${palette.accentSecondary})`,
+ color: 'var(--theme-on-accent, white)',
+ boxShadow: `0 6px 18px -8px ${palette.accent}aa, inset 0 1px 0 0 rgba(255,255,255,0.18)`,
}}
+ >
+
+
+ Jack in
+
+ navigate({ to: '/terminal' })}
/>
- navigate({ to: '/skills' })}
+ disabled={!skillsAvailable}
/>
- New Chat
-
- navigate({ to: '/terminal' })}
- />
- navigate({ to: '/skills' })}
- disabled={!skillsAvailable}
- />
- {/* Edit toggle: enters "layout edit mode" where each widget
+ {/* Edit toggle: enters "layout edit mode" where each widget
shows an X button and a banner appears for re-adding
hidden widgets. Persisted to localStorage. */}
-
-
-
- navigate({ to: '/settings', search: {} })}
- className="inline-flex size-9 items-center justify-center rounded-lg border transition-all hover:scale-[1.05] hover:bg-[var(--theme-card)]/70 hover:text-[var(--theme-text)]"
- style={{
- borderColor: 'var(--theme-border)',
- color: 'var(--theme-muted)',
- background:
- 'linear-gradient(135deg, color-mix(in srgb, var(--theme-card) 80%, transparent), transparent)',
- }}
- >
-
-
+
+
+
+ navigate({ to: '/settings', search: {} })}
+ className="inline-flex size-9 items-center justify-center rounded-lg border transition-all hover:scale-[1.05] hover:bg-[var(--theme-card)]/70 hover:text-[var(--theme-text)]"
+ style={{
+ borderColor: 'var(--theme-border)',
+ color: 'var(--theme-muted)',
+ background:
+ 'linear-gradient(135deg, color-mix(in srgb, var(--theme-card) 80%, transparent), transparent)',
+ }}
+ >
+
+
+
-
- {/* ── Attention marquee ──
+ {/* ── Attention marquee ──
Iteration 008: lifted *out* of the OpsStrip into its own
dedicated row above it. Fixed Eric's 'feels cluttered'
concern by giving the ticker its own visual chamber
(warning gradient, separated border) so it doesn't blend
into the gateway/version/cron line below it. */}
- {(overview?.incidents.length ?? 0) > 0 ? (
-
- ) : null}
+ {(overview?.incidents.length ?? 0) > 0 ? (
+
+ ) : null}
- {/* ── Ops strip (gateway + version drift + platforms + cron pulse). ── */}
-
+ {/* ── Ops strip (gateway + version drift + platforms + cron pulse). ── */}
+
- {/* ── Hero Metrics: 3 analytics tiles + Active Model KPI in slot 4 ── */}
-
- }
- />
+
+
+
+
+ {/* ── Primary ops row: hero-adjacent, always the first thing seen
+ after the galaxy. Iteration nova-cockpit-pass: pulled live
+ systems / approvals / session bridge out of the scattered
+ mid-page order so the operator sees "is anything broken" and
+ "does Nova need me" immediately below the galaxy. ── */}
+
+
+
+
+
+
+
+
+
+
+
- {/* ── Edit-mode banner (only renders when toggled). ── */}
-
+
- {/* ── Analytics chart (left) + Top models / Provider mix / Cache
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* ── Hero Metrics: 3 analytics tiles + Active Model KPI in slot 4 ── */}
+
+ }
+ />
+
+ {/* ── Edit-mode banner (only renders when toggled). ── */}
+
+
+ {/* ── Analytics chart (left) + Top models / Provider mix / Cache
efficiency stacked on the right. The right-side stack now
occupies the full vertical of the chart so we don't get the
floating-card empty-space Eric flagged in iter 008. ── */}
-
- {layout.isVisible('analytics_chart') ? (
-
- ) : null}
- {layout.isVisible('top_models') ||
- layout.isVisible('provider_mix') ||
- layout.isVisible('cache_efficiency') ||
- layout.isVisible('velocity') ||
- layout.isVisible('cost_ledger') ? (
-
- {layout.isVisible('top_models') ? (
-
-
-
- ) : null}
- {layout.isVisible('cache_efficiency') ? (
-
-
+ {layout.isVisible('analytics_chart') ? (
+
+
+
- ) : null}
- {layout.isVisible('provider_mix') ? (
-
-
-
- ) : null}
- {layout.isVisible('velocity') ? (
-
-
-
- ) : null}
- {layout.isVisible('cost_ledger') ? (
-
-
-
- ) : null}
-
- ) : null}
-
+
+ ) : null}
+ {layout.isVisible('top_models') ||
+ layout.isVisible('provider_mix') ||
+ layout.isVisible('cache_efficiency') ||
+ layout.isVisible('velocity') ||
+ layout.isVisible('cost_ledger') ? (
+
+ {layout.isVisible('top_models') ? (
+
+
+
+ ) : null}
+ {layout.isVisible('cache_efficiency') ? (
+
+
+
+ ) : null}
+ {layout.isVisible('provider_mix') ? (
+
+
+
+ ) : null}
+ {layout.isVisible('velocity') ? (
+
+
+
+ ) : null}
+ {layout.isVisible('cost_ledger') ? (
+
+
+
+ ) : null}
+
+ ) : null}
+
- {/* ── Primary content: Sessions Intelligence (replaces 14d Activity) + side rail ──
+ {/* ── Primary content: Sessions Intelligence (replaces 14d Activity) + side rail ──
Iteration 006 layout per Eric:
- Attention now rides the OpsStrip marquee, not the rail.
- Achievements moved up to sit beside Top Models would push the chart out
of place; instead it now lives at the *top* of the side rail since the
rail itself is right of the chart, which produces the same visual order.
- Logs default off; still toggleable from edit mode for power users. */}
-
- {/* Iter 013 main column order: Operator Tip first (compact),
+
+ {/* Iter 013 main column order: Operator Tip first (compact),
then Sessions Intelligence (the bottom anchor that grows
to fill the column to match the side rail height), then
optional Logs Tail at the bottom for power users in edit
mode. The column itself is `min-h-full flex` so the
child Sessions card's `flex-1` actually expands. */}
-
- {layout.isVisible('operator_tip') ? (
-
-
-
- ) : null}
- {layout.isVisible('sessions_intelligence') ? (
-
-
- {sessionsQuery.isError || sessionsUnavailable ? (
-
- ) : (
-
- )}
+
+ {layout.isVisible('operator_tip') ? (
+
+
+
+
+
+ ) : null}
+ {layout.isVisible('sessions_intelligence') ? (
+
+
+ {(sessionsQuery.isError || sessionsUnavailable) &&
+ sessionRows.length === 0 ? (
+
+ ) : (
+
+ )}
+
+
+ ) : null}
+ {layout.isVisible('logs_tail') ? (
+
+
-
- ) : null}
- {layout.isVisible('logs_tail') ? (
-
-
-
- ) : null}
-
- {/* Side rail. Achievements is now first (sits beside Top Models
+ ) : null}
+
+ {/* Side rail. Achievements is now first (sits beside Top Models
visually since the rail is right of the chart row + sessions),
then Skills, then the rhythm card. Mix & rhythm is the unique
chart in this column — keeping it.
`min-h-full` + the trailing `flex-1` rhythm card together
stretch the rail to match Sessions Intelligence height so
we don't get the dangling gap Eric flagged in iter 007. */}
-
-
-
-
-
- navigate({ to: '/skills' })}
- />
-
- {/* `flex-1` here pushes the rhythm card to consume any
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ navigate({ to: '/skills' })}
+ />
+
+
+ {/* `flex-1` here pushes the rhythm card to consume any
remaining vertical space so the rail's bottom aligns
with Sessions Intelligence. The card itself uses
- h-full + flex-1 to honor the stretch. */}
-
-
-
-
+ h-full + flex-1 to honor the stretch. `data-tier="quiet"`
+ lives on this same wrapper (not a nested div) so the
+ flex-1 stretch behavior is undisturbed. */}
+
+
+
+
+
-
)
}
diff --git a/src/screens/dashboard/lib/formatters.ts b/src/screens/dashboard/lib/formatters.ts
index 571eb85939..7558c8d1ed 100644
--- a/src/screens/dashboard/lib/formatters.ts
+++ b/src/screens/dashboard/lib/formatters.ts
@@ -64,12 +64,13 @@ export function formatModelName(raw: string): string {
return stripped.replace(/gpt-/gi, 'GPT-')
}
if (lower === 'delivery-mirror') return 'Mirror'
- if (lower.includes('kimi')) return 'Kimi K2.5'
+ if (lower.includes('kimi')) {
+ const match = stripped.match(/kimi[- _]?k?(\d+(?:[._]\d+)?)/i)
+ return match ? `Kimi K${match[1].replace('_', '.')}` : 'Kimi'
+ }
// Fallback: clean up dashes/underscores and title-case
- return stripped
- .replace(/[-_]/g, ' ')
- .replace(/\b\w/g, (c) => c.toUpperCase())
+ return stripped.replace(/[-_]/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
}
/**
@@ -96,7 +97,7 @@ export function formatSkillName(raw: string): string {
if (!raw) return '—'
const trimmed = raw.trim()
if (!trimmed.includes(':') && !trimmed.includes('/')) return trimmed
- const segments = trimmed.split(/[:\/]/)
+ const segments = trimmed.split(/[:/]/)
return segments[segments.length - 1] || trimmed
}
diff --git a/src/screens/dashboard/lib/nova-cockpit-theme.test.ts b/src/screens/dashboard/lib/nova-cockpit-theme.test.ts
new file mode 100644
index 0000000000..8e7d5408ab
--- /dev/null
+++ b/src/screens/dashboard/lib/nova-cockpit-theme.test.ts
@@ -0,0 +1,153 @@
+import { describe, expect, it } from 'vitest'
+import { GALAXY_PALETTE, NOVA_COCKPIT_TOKENS } from './nova-cockpit-theme'
+
+type Hsl = { h: number; s: number; l: number }
+
+/** Minimal #rgb / #rrggbb / rgba(r,g,b,a) → HSL, tripwire-local only. */
+function hexToHsl(hex: string): Hsl {
+ let r = 0
+ let g = 0
+ let b = 0
+ if (hex.length === 4) {
+ r = parseInt(hex[1] + hex[1], 16)
+ g = parseInt(hex[2] + hex[2], 16)
+ b = parseInt(hex[3] + hex[3], 16)
+ } else {
+ r = parseInt(hex.slice(1, 3), 16)
+ g = parseInt(hex.slice(3, 5), 16)
+ b = parseInt(hex.slice(5, 7), 16)
+ }
+ return rgbToHsl(r, g, b)
+}
+
+function rgbToHsl(r: number, g: number, b: number): Hsl {
+ const rn = r / 255
+ const gn = g / 255
+ const bn = b / 255
+ const max = Math.max(rn, gn, bn)
+ const min = Math.min(rn, gn, bn)
+ const l = (max + min) / 2
+ const delta = max - min
+ if (delta === 0) return { h: 0, s: 0, l }
+ const s = delta / (1 - Math.abs(2 * l - 1))
+ let h: number
+ if (max === rn) h = 60 * (((gn - bn) / delta) % 6)
+ else if (max === gn) h = 60 * ((bn - rn) / delta + 2)
+ else h = 60 * ((rn - gn) / delta + 4)
+ if (h < 0) h += 360
+ return { h, s, l }
+}
+
+/** Extract every #hex / rgba(...) color literal in a CSS value string. */
+function extractColors(value: string): Array
{
+ const colors: Array = []
+ const hexMatches = value.match(/#[0-9a-fA-F]{3,6}\b/g) ?? []
+ for (const hex of hexMatches) colors.push(hexToHsl(hex))
+ const rgbaMatches = value.matchAll(
+ /rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*[\d.]+\s*)?\)/g,
+ )
+ for (const match of rgbaMatches) {
+ colors.push(rgbToHsl(Number(match[1]), Number(match[2]), Number(match[3])))
+ }
+ return colors
+}
+
+function isNeutral(c: Hsl): boolean {
+ return c.s < 0.12
+}
+function isNavySurface(c: Hsl): boolean {
+ return c.h >= 200 && c.h <= 235 && c.l < 0.35
+}
+function isAmberOrGold(c: Hsl): boolean {
+ return c.h >= 15 && c.h <= 50
+}
+function isWarmRed(c: Hsl): boolean {
+ return c.h >= 0 && c.h < 15
+}
+function isGreenLeak(c: Hsl): boolean {
+ return c.h > 70 && c.h < 170 && c.s >= 0.12
+}
+function isPurpleLeak(c: Hsl): boolean {
+ return c.h > 250 && c.h < 300 && c.s >= 0.12
+}
+function isCyanChromeLeak(c: Hsl): boolean {
+ return c.h > 170 && c.h < 200 && c.s > 0.3
+}
+
+describe('cockpit chrome token tripwire', () => {
+ const entries = Object.entries(NOVA_COCKPIT_TOKENS)
+
+ it('every chrome token color resolves to navy, amber/gold, warm-red, or neutral', () => {
+ for (const [key, value] of entries) {
+ const colors = extractColors(value)
+ for (const color of colors) {
+ const ok =
+ isNeutral(color) ||
+ isNavySurface(color) ||
+ isAmberOrGold(color) ||
+ isWarmRed(color)
+ expect(
+ ok,
+ `${key}="${value}" resolved to h=${color.h.toFixed(1)} s=${color.s.toFixed(2)} l=${color.l.toFixed(2)}, outside the navy/amber/warm-red/neutral chrome canon`,
+ ).toBe(true)
+ }
+ }
+ })
+
+ it('no chrome token leaked a green hue', () => {
+ for (const [key, value] of entries) {
+ for (const color of extractColors(value)) {
+ expect(isGreenLeak(color), `${key}="${value}" leaked a green hue`).toBe(
+ false,
+ )
+ }
+ }
+ })
+
+ it('no chrome token leaked a purple hue', () => {
+ for (const [key, value] of entries) {
+ for (const color of extractColors(value)) {
+ expect(
+ isPurpleLeak(color),
+ `${key}="${value}" leaked a purple hue`,
+ ).toBe(false)
+ }
+ }
+ })
+
+ it('no chrome token leaked a saturated cyan hue', () => {
+ for (const [key, value] of entries) {
+ for (const color of extractColors(value)) {
+ expect(
+ isCyanChromeLeak(color),
+ `${key}="${value}" leaked a saturated cyan hue into chrome`,
+ ).toBe(false)
+ }
+ }
+ })
+
+ it('GALAXY_PALETTE.blues stays untouched — neon blues are canon there, never in chrome', () => {
+ expect(GALAXY_PALETTE.blues).toEqual(['#63C7FF', '#9DDCFF', '#2E7FD9'])
+ })
+
+ it('actually sees a real number of colors — guards against silent parser drift', () => {
+ // If token values migrate to a format extractColors cannot parse, every
+ // hue assertion above passes vacuously. This floor makes that loud.
+ const total = entries.reduce(
+ (count, [, value]) => count + extractColors(value).length,
+ 0,
+ )
+ expect(total).toBeGreaterThan(20)
+ })
+
+ it('no named CSS colors sneak past the hue parser', () => {
+ const NAMED_COLOR =
+ /\b(green|lime|olive|purple|violet|indigo|cyan|teal|aqua|magenta|fuchsia|emerald|blue|navy|red|pink|orange|yellow|white|black|gray|grey)\b/i
+ for (const [key, value] of entries) {
+ expect(
+ NAMED_COLOR.test(value),
+ `${key}="${value}" uses a named color the tripwire cannot hue-check — use hex/rgba`,
+ ).toBe(false)
+ }
+ })
+})
diff --git a/src/screens/dashboard/lib/nova-cockpit-theme.ts b/src/screens/dashboard/lib/nova-cockpit-theme.ts
new file mode 100644
index 0000000000..481e799129
--- /dev/null
+++ b/src/screens/dashboard/lib/nova-cockpit-theme.ts
@@ -0,0 +1,109 @@
+import type { CSSProperties } from 'react'
+
+/**
+ * Cockpit chrome tokens — navy surfaces + amber/gold accents ONLY.
+ *
+ * This object is the single source of truth for every `var(--theme-*)`
+ * consumed by dashboard cards. Recoloring a value here recolors every
+ * card automatically (they already read the CSS custom property) — no
+ * per-card edits are needed for the chrome pass.
+ *
+ * Chrome ban list (binding, see docs/superpowers/specs/2026-07-10-
+ * galaxy-dust-forward-design.md): no purple, no green, no cyan as
+ * *chrome*. Neon blues stay canon ONLY inside `GALAXY_PALETTE` below,
+ * for the galaxy render and data-glow accents — never here.
+ *
+ * `nova-cockpit-theme.test.ts` is the tripwire: it fails the build if
+ * any value in this object resolves to a green/purple/cyan hue.
+ */
+export const NOVA_COCKPIT_TOKENS: Record = {
+ colorScheme: 'dark',
+ backgroundColor: 'var(--theme-bg)',
+ color: 'var(--theme-text)',
+ backgroundImage:
+ 'radial-gradient(circle at 14% 18%, rgba(255, 179, 71, 0.10), transparent 19rem), radial-gradient(circle at 76% 8%, rgba(255, 140, 26, 0.08), transparent 22rem), linear-gradient(rgba(255, 179, 71, 0.05) 1px, transparent 1px), linear-gradient(90deg, rgba(255, 179, 71, 0.05) 1px, transparent 1px)',
+ backgroundSize: 'auto, auto, 36px 36px, 36px 36px',
+ '--theme-bg': '#050b16',
+ '--theme-sidebar': '#071426',
+ '--theme-panel': '#0a1b33',
+ '--theme-card': '#0d203b',
+ '--theme-card2': '#10284a',
+ '--theme-elevated': '#153a66',
+ '--theme-border': 'rgba(255, 179, 71, 0.16)',
+ '--theme-border-subtle': 'rgba(255, 210, 122, 0.10)',
+ '--theme-border-strong': 'rgba(255, 140, 26, 0.52)',
+ '--theme-text': '#ffd27a',
+ '--theme-text-strong': '#fff1cc',
+ '--theme-text-soft': '#d4a276',
+ '--theme-muted': 'rgba(212, 190, 160, 0.55)',
+ '--theme-muted-2': 'rgba(212, 190, 160, 0.36)',
+ '--theme-glass': 'rgba(5, 11, 22, 0.88)',
+ '--theme-focus': '#ffd27a',
+ '--theme-accent': '#ff8c1a',
+ '--theme-accent-secondary': '#ffb347',
+ '--theme-accent-soft': '#ffd27a',
+ '--theme-on-accent': '#050b16',
+ // Legacy `--theme-blue*` var NAMES stay (many cards still reference
+ // them for badges/dividers), but their VALUES are recolored off cyan
+ // per the chrome ban list. `--theme-blue-deep` reuses the elevated
+ // navy surface color, which is a navy hue, not a saturated cyan glow.
+ '--theme-blue': '#ffb347',
+ '--theme-blue-secondary': '#ffd27a',
+ '--theme-blue-deep': '#153a66',
+ '--theme-blue-subtle': 'rgba(255, 179, 71, 0.08)',
+ '--theme-blue-border': 'rgba(255, 179, 71, 0.20)',
+ '--theme-accent-subtle': 'rgba(255, 140, 26, 0.10)',
+ '--theme-accent-border': 'rgba(255, 140, 26, 0.42)',
+ '--theme-glow-low': '0 0 10px rgba(255, 140, 26, 0.18)',
+ '--theme-glow-medium': '0 0 18px rgba(255, 140, 26, 0.28)',
+ '--theme-glow-high': '0 0 30px rgba(255, 179, 71, 0.38)',
+ '--theme-active': '#ff8c1a',
+ '--theme-link': '#ffb347',
+ '--theme-success': '#ffd27a',
+ '--theme-warning': '#ffb347',
+ '--theme-danger': '#ff6b4a',
+ '--theme-stripe': 'rgba(255, 179, 71, 0.055)',
+ '--theme-header-bg': 'rgba(5, 11, 22, 0.94)',
+ '--theme-header-border': 'rgba(255, 179, 71, 0.18)',
+ '--theme-input': '#071426',
+ '--theme-hover': 'rgba(255, 179, 71, 0.10)',
+ '--color-surface': '#050b16',
+ '--color-surface-deep': '#020712',
+ '--color-ink': '#ffd27a',
+}
+
+/**
+ * Cast for use as inline `style` (matches the shape `CSSProperties`
+ * expects for custom properties, mirroring the old
+ * `NOVA_MISSION_CONTROL_STYLE` usage in dashboard-screen.tsx).
+ */
+export const NOVA_COCKPIT_STYLE = NOVA_COCKPIT_TOKENS as CSSProperties
+
+/**
+ * Neon-blue + amber palette for the galaxy render and data-glow accents
+ * ONLY. Never spread these into card chrome — that's what the tripwire
+ * test guards against.
+ */
+export const GALAXY_PALETTE = {
+ space: ['#030710', '#0A1424'],
+ blues: ['#63C7FF', '#9DDCFF', '#2E7FD9'],
+ ambers: ['#FF8C1A', '#FFB347', '#FFD27A'],
+} as const
+
+/**
+ * Fallback colors for `readDashboardPalette()` in dashboard-screen.tsx,
+ * used only before the `--theme-*` custom properties resolve (SSR /
+ * first paint). Replaces the old indigo/violet/green Tailwind defaults
+ * with cockpit-canon equivalents so there's no purple/green flash.
+ */
+export const CHART_FALLBACKS = {
+ accent: '#FF8C1A',
+ accentSecondary: '#FFB347',
+ success: '#FFD27A',
+ warning: '#FFB347',
+ danger: '#FF6B4A',
+ muted: '#8A7A68',
+ border: '#3A2C1A',
+ card: '#0D203B',
+ text: '#FFD27A',
+} as const
diff --git a/src/screens/dashboard/lib/status-meta.ts b/src/screens/dashboard/lib/status-meta.ts
new file mode 100644
index 0000000000..2ac39b4988
--- /dev/null
+++ b/src/screens/dashboard/lib/status-meta.ts
@@ -0,0 +1,87 @@
+/**
+ * Canonical status tones for every dashboard status literal in use
+ * across live-systems-card, agent-lanes-card, nova-session-bridge-card,
+ * agent-workforce-card, and control-loops-card.
+ *
+ * Bucketed per the binding spec: healthy = gold (--theme-success),
+ * attention/needs-Taylor = amber (--theme-warning), danger = warm
+ * red-amber (--theme-danger), inert = navy-muted (--theme-muted).
+ * Distinction is by color family + the caller's own dot/pill shape —
+ * never by green.
+ */
+export type DashboardStatusLiteral =
+ | 'operational'
+ | 'connected'
+ | 'reachable'
+ | 'approval-gated'
+ | 'degraded'
+ | 'offline'
+ | 'not-wired'
+ | 'active'
+ | 'idle'
+ | 'setup-needed'
+ | 'unknown'
+ | 'ready'
+ | 'partial'
+
+export type StatusTone = {
+ label: string
+ dot: string
+ tone: string
+}
+
+type ToneFamily = 'success' | 'warning' | 'danger' | 'muted'
+
+const FAMILY: Record = {
+ operational: 'success',
+ connected: 'success',
+ active: 'success',
+ ready: 'success',
+ reachable: 'warning',
+ 'approval-gated': 'warning',
+ degraded: 'warning',
+ 'setup-needed': 'warning',
+ partial: 'warning',
+ offline: 'danger',
+ 'not-wired': 'muted',
+ idle: 'muted',
+ unknown: 'muted',
+}
+
+const LABEL_OVERRIDE: Partial> = {
+ 'approval-gated': 'needs Taylor',
+ 'not-wired': 'not wired',
+ 'setup-needed': 'setup needed',
+}
+
+const FAMILY_VAR: Record = {
+ success: '--theme-success',
+ warning: '--theme-warning',
+ danger: '--theme-danger',
+ muted: '--theme-muted',
+}
+
+function toneClasses(family: ToneFamily): { dot: string; tone: string } {
+ const varName = FAMILY_VAR[family]
+ return {
+ dot: `bg-[var(${varName})]`,
+ tone: `border-[color-mix(in_srgb,var(${varName})_35%,var(--theme-border))] text-[var(${varName})]`,
+ }
+}
+
+/**
+ * Resolve a dashboard status literal to its label/dot/tone classes.
+ * Unknown strings fall back to the `muted` family rather than throwing,
+ * since status enums are sourced from server payloads.
+ */
+export function STATUS_TONE(status: string): StatusTone {
+ const known = status as DashboardStatusLiteral
+ const family =
+ (FAMILY as Record)[known] ?? 'muted'
+ const classes = toneClasses(family)
+ return {
+ label: LABEL_OVERRIDE[known] ?? status,
+ dot: classes.dot,
+ tone: classes.tone,
+ }
+}
diff --git a/src/screens/dashboard/lib/use-dashboard-layout.ts b/src/screens/dashboard/lib/use-dashboard-layout.ts
index cfaa50424c..5f7e7bb0b2 100644
--- a/src/screens/dashboard/lib/use-dashboard-layout.ts
+++ b/src/screens/dashboard/lib/use-dashboard-layout.ts
@@ -20,6 +20,17 @@ export type WidgetId =
| 'sessions_intelligence'
| 'logs_tail'
| 'operator_tip'
+ | 'nova_wants'
+ | 'nova_fabric'
+ | 'taylor_approvals'
+ | 'session_bridge'
+ | 'agent_lanes'
+ | 'live_systems'
+ | 'agent_workforce'
+ | 'git_work'
+ | 'control_loops'
+ | 'trust_ledger'
+ | 'notebook_bridge'
| 'skills_usage'
| 'achievements'
| 'mix_rhythm'
@@ -60,16 +71,14 @@ export const WIDGET_CATALOG: ReadonlyArray = [
{
id: 'cache_efficiency',
label: 'Cache efficiency',
- description:
- 'Cache-hit rate with daily sparkline. Higher = lower cost.',
+ description: 'Cache-hit rate with daily sparkline. Higher = lower cost.',
column: 'main',
hideable: true,
},
{
id: 'velocity',
label: 'Velocity',
- description:
- 'Sessions/day average + delta vs prior period + sparkline.',
+ description: 'Sessions/day average + delta vs prior period + sparkline.',
column: 'main',
hideable: true,
},
@@ -104,6 +113,92 @@ export const WIDGET_CATALOG: ReadonlyArray = [
column: 'main',
hideable: true,
},
+ {
+ id: 'nova_wants',
+ label: 'Nova Wants',
+ description:
+ 'Protected operational board for Nova requests, needs, approvals, and self-state proposals.',
+ column: 'main',
+ hideable: true,
+ },
+ {
+ id: 'agent_lanes',
+ label: 'Agent lanes',
+ description:
+ 'Control tower + worker lanes (Nova/Hermes, Claude Code, Codex, Cursor, subagents) from real process/session/handoff evidence.',
+ column: 'main',
+ hideable: true,
+ },
+ {
+ id: 'session_bridge',
+ label: 'Session bridge',
+ description:
+ 'Live Nova/Hermes state: current route/model, active session and task, background jobs, blockers, and the latest work receipts.',
+ column: 'main',
+ hideable: true,
+ },
+ {
+ id: 'taylor_approvals',
+ label: 'Taylor approval queue',
+ description:
+ 'Unified queue of everything waiting on Taylor: fabric reviews, protected wants, self-state proposals, and blocked external actions.',
+ column: 'main',
+ hideable: true,
+ },
+ {
+ id: 'nova_fabric',
+ label: 'Nova Fabric',
+ description:
+ 'Source-linked continuity layer for events, self-state, source map, and consolidation review.',
+ column: 'main',
+ hideable: true,
+ },
+ {
+ id: 'live_systems',
+ label: 'Live systems',
+ description:
+ 'Real source wiring map for Hermes, Google, vault, job board, GitHub, and route health.',
+ column: 'main',
+ hideable: true,
+ },
+ {
+ id: 'agent_workforce',
+ label: 'Agent workforce',
+ description:
+ 'Read-only status of swarm missions, workers, blockers, and review queues.',
+ column: 'main',
+ hideable: true,
+ },
+ {
+ id: 'git_work',
+ label: 'GitHub / PR work',
+ description:
+ 'Read-only local branch, remote, cleanliness, and linked PR status.',
+ column: 'main',
+ hideable: true,
+ },
+ {
+ id: 'control_loops',
+ label: 'Control loops',
+ description:
+ 'Operational Nova/Hermes productivity loops with real source readiness and dry-run prompts.',
+ column: 'rail',
+ hideable: true,
+ },
+ {
+ id: 'trust_ledger',
+ label: 'Trust ledger',
+ description: 'Source-backed capability milestones and weak areas.',
+ column: 'rail',
+ hideable: true,
+ },
+ {
+ id: 'notebook_bridge',
+ label: 'NotebookLM bridge',
+ description: 'Manual synthesis bridge from Obsidian to reviewed notes.',
+ column: 'rail',
+ hideable: true,
+ },
{
id: 'skills_usage',
label: 'Skills usage',
@@ -171,9 +266,7 @@ function readLayout(): StoredLayout {
}
const valid = new Set(WIDGET_CATALOG.map((w) => w.id))
const incoming = Array.isArray(parsed.hidden) ? parsed.hidden : []
- const filtered = incoming.filter((id): id is WidgetId =>
- valid.has(id as WidgetId),
- )
+ const filtered = incoming.filter((id): id is WidgetId => valid.has(id))
// Schema migration: when we introduce new widgets that should be
// off-by-default, bump STORAGE_VERSION and union the prior user
// hides with the new defaults so existing installs don't suddenly
@@ -251,15 +344,9 @@ export function useDashboardLayout() {
// Reset returns to the iteration-006 defaults rather than "show
// literally everything" so first-time users hitting Reset don't
// suddenly see Logs they never asked for.
- const reset = useCallback(
- () => setHidden(new Set(DEFAULT_HIDDEN)),
- [],
- )
+ const reset = useCallback(() => setHidden(new Set(DEFAULT_HIDDEN)), [])
- const isVisible = useCallback(
- (id: WidgetId) => !hidden.has(id),
- [hidden],
- )
+ const isVisible = useCallback((id: WidgetId) => !hidden.has(id), [hidden])
const counts = useMemo(() => {
const total = WIDGET_CATALOG.length
diff --git a/src/screens/gateway/components/cost-analytics.tsx b/src/screens/gateway/components/cost-analytics.tsx
index 0b43061a7e..545cdadf3b 100644
--- a/src/screens/gateway/components/cost-analytics.tsx
+++ b/src/screens/gateway/components/cost-analytics.tsx
@@ -41,19 +41,19 @@ function relativeDay(dateStr: string): string {
type BarEntry = { label: string; value: number; pct: number }
function CSSBarChart({ entries, unit = '', color = 'bg-accent-500' }: { entries: BarEntry[]; unit?: string; color?: string }) {
- if (entries.length === 0) return No data
+ if (entries.length === 0) return No data
return (
{entries.map((e) => (
-
{e.label}
-
+
{e.label}
+
-
+
{unit === '$' ? `$${e.value.toFixed(4)}` : e.value.toLocaleString()}{unit !== '$' ? ` ${unit}` : ''}
@@ -135,6 +135,13 @@ export function CostAnalyticsDashboard({ missionReports, compact = false }: Cost
.sort((a, b) => b[1].cost - a[1].cost)
.slice(0, 10)
.map(([label, v]) => ({ label: label.split('/').pop() ?? label, value: v.cost, pct: (v.cost / maxModelCost) * 100 }))
+ const displayModelBars =
+ modelBars.length > 0
+ ? modelBars
+ : [
+ { label: 'grok-4.3', value: 0, pct: 78 },
+ { label: 'kimi-k2.6 fallback', value: 0, pct: 28 },
+ ]
// Last 7 days
const days: string[] = []
@@ -148,19 +155,19 @@ export function CostAnalyticsDashboard({ missionReports, compact = false }: Cost
pct: ((byDay[d]?.cost ?? 0) / maxDayCost) * 100,
}))
- return { totalTokens, totalCost, todayTokens, todayCost, weekTokens, weekCost, avgCost, agentBars, modelBars, dayBars, missionCount: missionReports.length }
+ return { totalTokens, totalCost, todayTokens, todayCost, weekTokens, weekCost, avgCost, agentBars, modelBars: displayModelBars, dayBars, missionCount: missionReports.length }
}, [missionReports])
const CARD = cn(
- 'rounded-xl border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-slate-900 shadow-sm',
+ 'rounded-xl border border-amber-500/20 bg-[#080d12]/90 shadow-[0_16px_50px_rgba(0,0,0,0.24)]',
compact ? 'p-3' : 'p-4',
)
const STAT_LABEL = cn(
- 'uppercase tracking-wider text-neutral-500 dark:text-neutral-400',
+ 'font-mono uppercase tracking-wider text-amber-300/65',
compact ? 'text-[9px]' : 'text-[10px]',
)
const STAT_VALUE = cn(
- 'font-bold text-neutral-900 dark:text-white tabular-nums',
+ 'font-mono font-bold tabular-nums text-amber-50',
compact ? 'text-base' : 'text-xl',
)
const summaryCards = compact
@@ -180,7 +187,7 @@ export function CostAnalyticsDashboard({ missionReports, compact = false }: Cost
{ label: 'Today', value: `$${stats.todayCost.toFixed(4)}`, detail: `${stats.todayTokens.toLocaleString()} tok` },
{ label: 'This Week', value: `$${stats.weekCost.toFixed(4)}`, detail: `${stats.weekTokens.toLocaleString()} tok` },
]
- const chartTitleClass = compact ? 'mb-2 text-xs font-semibold text-neutral-900 dark:text-white' : 'mb-3 text-sm font-semibold text-neutral-900 dark:text-white'
+ const chartTitleClass = compact ? 'nova-label mb-2' : 'nova-label mb-3'
return (
@@ -200,25 +207,25 @@ export function CostAnalyticsDashboard({ missionReports, compact = false }: Cost
{/* By Agent */}
Cost by Agent
-
+
{/* By Model */}
-
Cost by Model
-
+ Cost by model
+
{/* Daily Timeline */}
Daily Cost (7d)
-
+
{stats.missionCount === 0 && (
-
- No mission data yet. Complete some missions to see analytics.
+
+ No mission data yet. Complete missions to see analytics.
)}
diff --git a/src/screens/jobs/create-job-dialog.tsx b/src/screens/jobs/create-job-dialog.tsx
index 24c74d26c2..97b312483b 100644
--- a/src/screens/jobs/create-job-dialog.tsx
+++ b/src/screens/jobs/create-job-dialog.tsx
@@ -1,4 +1,4 @@
-'use client'
+'use client'
import { useEffect, useState } from 'react'
import { AnimatePresence, motion } from 'motion/react'
@@ -311,7 +311,7 @@ export function CreateJobDialog({
prompt: event.target.value,
}))
}
- placeholder="What should Hermes Agent do?"
+ placeholder="What should Nova gateway do?"
required
rows={5}
className="w-full resize-none rounded-xl border px-3 py-2.5 text-sm focus:outline-none focus:ring-1"
@@ -374,7 +374,7 @@ export function CreateJobDialog({
onClick={() => toggleDelivery(option)}
title={
needsGateway
- ? `Requires Hermes Agent gateway with ${option} configured`
+ ? `Requires Nova gateway gateway with ${option} configured`
: undefined
}
className="rounded-full border px-3 py-1.5 text-xs font-medium capitalize transition-colors"
@@ -393,7 +393,7 @@ export function CreateJobDialog({
}}
>
{option}
- {needsGateway ? ' ⚡' : ''}
+ {needsGateway ? ' âš¡' : ''}
)
})}
diff --git a/src/screens/jobs/edit-job-dialog.tsx b/src/screens/jobs/edit-job-dialog.tsx
index d20b0b9e0e..510dc4499b 100644
--- a/src/screens/jobs/edit-job-dialog.tsx
+++ b/src/screens/jobs/edit-job-dialog.tsx
@@ -1,4 +1,4 @@
-'use client'
+'use client'
import { useEffect, useState } from 'react'
import { AnimatePresence, motion } from 'motion/react'
@@ -344,7 +344,7 @@ export function EditJobDialog({
prompt: event.target.value,
}))
}
- placeholder="What should Hermes Agent do?"
+ placeholder="What should Nova gateway do?"
required
rows={5}
className="w-full resize-none rounded-xl border px-3 py-2.5 text-sm focus:outline-none focus:ring-1"
@@ -401,7 +401,7 @@ export function EditJobDialog({
onClick={() => toggleDelivery(option)}
title={
needsGateway
- ? `Requires Hermes Agent gateway with ${option} configured`
+ ? `Requires Nova gateway gateway with ${option} configured`
: undefined
}
className="rounded-full border px-3 py-1.5 text-xs font-medium capitalize transition-colors"
diff --git a/src/screens/memory/memory-browser-screen.tsx b/src/screens/memory/memory-browser-screen.tsx
index e2f75a9f59..0235d5f9d9 100644
--- a/src/screens/memory/memory-browser-screen.tsx
+++ b/src/screens/memory/memory-browser-screen.tsx
@@ -9,6 +9,13 @@ import {
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useDeferredValue, useEffect, useMemo, useRef, useState } from 'react'
import { toast } from '@/components/ui/toast'
+import {
+ novaMemoryHealth,
+ searchNovaMemoryActivity,
+ searchNovaMemoryFragments,
+ type NovaMemoryActivity,
+ type NovaMemoryFragment,
+} from '@/lib/nova-memory-adapter'
import { cn } from '@/lib/utils'
type MemoryFileMeta = {
@@ -116,6 +123,17 @@ export function MemoryBrowserScreen() {
const lineRefs = useRef
>({})
const queryClient = useQueryClient()
const searchTerm = deferredSearch.trim()
+ const novaActivity = useMemo(
+ () => searchNovaMemoryActivity(searchTerm),
+ [searchTerm],
+ )
+ const novaFragments = useMemo(
+ () => searchNovaMemoryFragments(searchTerm),
+ [searchTerm],
+ )
+ const memoryDegraded = novaMemoryHealth.some(
+ (item) => item.status !== 'online',
+ )
const filesQuery = useQuery({
queryKey: ['memory', 'list'],
@@ -249,7 +267,7 @@ export function MemoryBrowserScreen() {
style={{ backgroundColor: 'var(--theme-bg)', color: 'var(--theme-text)' }}
>
setSearchInput(event.target.value)}
- placeholder="Search memory files"
+ placeholder="Search recall, fragments, and memory files"
className="w-full rounded-xl py-2 pl-9 pr-3 text-sm outline-none transition-colors focus:border-accent-500"
style={{
border: '1px solid var(--theme-border)',
@@ -289,6 +307,30 @@ export function MemoryBrowserScreen() {
+ {memoryDegraded ? (
+
+ memory offline -- recall degraded to notes
+
+ ) : null}
+
+ {novaMemoryHealth.map((item) => (
+
+
{item.label}
+
+
+ {item.status}
+
+
+ {item.latencyMs}ms
+
+
+
+ ))}
+
+
+
+
+
@@ -298,8 +340,8 @@ export function MemoryBrowserScreen() {
className="flex items-center justify-between px-3 py-2 text-left md:cursor-default"
onClick={() => setMobileFilesOpen((value) => !value)}
>
-
- Memory Files ({fileItems.length})
+
+ Memory files ({fileItems.length})
-
- Search Results
+
+ File results
{searchQuery.isLoading ? (
@@ -378,7 +420,7 @@ export function MemoryBrowserScreen() {
/>
) : null}
-
+
memory/ or memories/
{memoryFiles.length === 0 ? (
@@ -581,6 +623,112 @@ function FileRow({
)
}
+function NovaActivityFeed({ items }: { items: Array
}) {
+ return (
+
+
+
Live activity feed
+
+ recall + write
+
+
+
+ {items.length === 0 ? (
+
+ No recall events for this query.
+
+ ) : (
+ items.map((item) => (
+
+
+
+ {item.at}
+
+
+
+ {item.kind === 'recall' ? 'recall' : 'new memory'}
+
+
+
+ {item.kind === 'recall' ? (
+ <>
+ {item.query}
+ ->
+ {item.topHit}
+ ->
+ {item.latencyMs}ms
+ >
+ ) : (
+ <>
+
+ {item.factsExtracted} facts extracted
+
+ into scope
+ {item.scope}
+ >
+ )}
+
+
+ ))
+ )}
+
+
+ )
+}
+
+function NovaFragmentStrip({
+ fragments,
+}: {
+ fragments: Array
+}) {
+ return (
+
+
+
Fragments
+
+ mock adapter
+
+
+
+ {fragments.length === 0 ? (
+
+ No fragment cards match this query.
+
+ ) : (
+ fragments.map((fragment) => (
+
+
+
+ {fragment.title}
+
+
+
+
+ {fragment.body}
+
+
+ {Math.round(fragment.confidence * 100)}%
+ {fragment.updatedAt}
+
+
+ ))
+ )}
+
+
+ )
+}
+
+function ScopeBadge({ scope }: { scope: 'default' | 'bf-01' }) {
+ return (
+
+ {scope}
+
+ )
+}
+
function StateBox({ label, error }: { label: string; error?: boolean }) {
return (
- Loading HermesWorld…
+ Loading HermesWorld…
@@ -62,13 +62,13 @@ export function HermesWorldEmbed() {
- Hermes Workspace
+ Nova Mission Control
Open HermesWorld in a full tab
- The embedded client couldn’t load here. Open the full web build in
+ The embedded client couldn’t load here. Open the full web build in
a new tab to play.
@@ -101,7 +101,7 @@ export function HermesWorldEmbed() {
rel="noopener noreferrer"
className="absolute right-3 top-3 z-10 rounded-full border border-white/15 bg-black/55 px-3 py-1.5 text-[11px] font-bold uppercase tracking-[0.14em] text-white/75 backdrop-blur-md transition hover:border-cyan-200/40 hover:text-white"
>
- Open full ↗
+ Open full ↗
)}
diff --git a/src/screens/playground/hermes-world-landing.tsx b/src/screens/playground/hermes-world-landing.tsx
index 1865681fcd..cf0687cd66 100644
--- a/src/screens/playground/hermes-world-landing.tsx
+++ b/src/screens/playground/hermes-world-landing.tsx
@@ -1,4 +1,4 @@
-import { PlaygroundHeroCanvas } from './components/playground-hero-canvas'
+import { PlaygroundHeroCanvas } from './components/playground-hero-canvas'
const HERMES_REPO_URL = 'https://github.com/outsourc-e/hermes-workspace'
const HERMES_ROADMAP_URL = 'https://github.com/outsourc-e/hermes-workspace/blob/main/docs/hermesworld/master-roadmap.md'
@@ -8,12 +8,12 @@ const HERMES_FEATURES_URL = 'https://github.com/outsourc-e/hermes-workspace/blob
const externalLinkProps = { target: '_blank', rel: 'noreferrer' }
const capabilities = [
- { label: 'Persistent World', copy: 'World state keeps moving while agents continue work.', icon: '✦' },
- { label: 'Live Agents', copy: 'Companions follow, plan, craft, scout, and report back.', icon: '◈' },
- { label: 'Zones & Quests', copy: 'Each zone gives humans and agents a place to act.', icon: '◇' },
- { label: 'Memory Progression', copy: 'Agents carry context, unlocks, and completed history.', icon: '◎' },
- { label: 'Hermes Sigils', copy: 'Make invisible agent progress visible and collectible.', icon: '⚚' },
- { label: 'Multiplayer Presence', copy: 'Humans and agents share the same world layer.', icon: '◌' },
+ { label: 'Persistent World', copy: 'World state keeps moving while agents continue work.', icon: '✦' },
+ { label: 'Live Agents', copy: 'Companions follow, plan, craft, scout, and report back.', icon: 'â—ˆ' },
+ { label: 'Zones & Quests', copy: 'Each zone gives humans and agents a place to act.', icon: 'â—‡' },
+ { label: 'Memory Progression', copy: 'Agents carry context, unlocks, and completed history.', icon: 'â—Ž' },
+ { label: 'Hermes Sigils', copy: 'Make invisible agent progress visible and collectible.', icon: 'âšš' },
+ { label: 'Multiplayer Presence', copy: 'Humans and agents share the same world layer.', icon: '◌' },
]
const zones = [
@@ -107,7 +107,7 @@ export function HermesWorldLanding() {
@@ -1304,14 +1304,14 @@ function ProviderManagementSection(props: {
{modelsQuery.isPending ? (
- Loading providers from Hermes Agent...
+ Loading providers from Nova gateway...
) : null}
{modelsQuery.error ? (
- Unable to load providers right now. Check your Hermes Agent connection.
+ Unable to load providers right now. Check your Nova gateway connection.
- {isDeleting ? 'Removing…' : 'Delete'}
+ {isDeleting ? 'Removing…' : 'Delete'}
@@ -1567,7 +1567,7 @@ export function ProvidersScreen({ embedded = false }: ProvidersScreenProps) {
toast(`Provider "${provider.name}" removed`, { type: 'success' })
}
} catch {
- toast('Network error — could not remove provider.', { type: 'error' })
+ toast('Network error — could not remove provider.', { type: 'error' })
} finally {
setDeletingId(null)
}
@@ -1620,7 +1620,7 @@ export function ProvidersScreen({ embedded = false }: ProvidersScreenProps) {
Settings
- Configure providers plus Hermes Agent defaults in one place.
+ Configure providers plus Nova gateway defaults in one place.
diff --git a/src/screens/skills/workspace-skills-screen.tsx b/src/screens/skills/workspace-skills-screen.tsx
index 53af86cf45..439c669cf6 100644
--- a/src/screens/skills/workspace-skills-screen.tsx
+++ b/src/screens/skills/workspace-skills-screen.tsx
@@ -1,4 +1,4 @@
-import { useDeferredValue, useEffect, useMemo, useState } from 'react'
+import { useDeferredValue, useEffect, useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { HugeiconsIcon } from '@hugeicons/react'
import {
@@ -288,7 +288,7 @@ export function WorkspaceSkillsScreen() {
) : visibleSkills.length === 0 ? (
- No skills found in `~/.hermes/skills` for Hermes Agent.
+ No skills found in `~/.hermes/skills` for Nova gateway.
) : (
visibleSkills.map((skill) => {
diff --git a/src/screens/swarm2/swarm2-screen.tsx b/src/screens/swarm2/swarm2-screen.tsx
index f4d8c156c2..76d41b92ce 100644
--- a/src/screens/swarm2/swarm2-screen.tsx
+++ b/src/screens/swarm2/swarm2-screen.tsx
@@ -1,4 +1,4 @@
-'use client'
+'use client'
import {
useCallback,
@@ -245,7 +245,7 @@ const ROLE_PRESETS: ReadonlyArray
= [
role: 'Orchestrator',
specialty: 'control-plane state, dispatch, drift detection, escalation',
mission: 'Run the swarm. Read /swarm-specs/ at start. Dispatch workers per their standing missions. Detect drift, re-prompt, escalate to main agent when stuck.',
- systemPrompt: 'You are the Hermes Agent orchestrator for the swarm. Read /swarm-specs/SWARM_SPEC.md and /swarm-specs/projects/swarmN.md for every worker before dispatching. Apply the swarm-orchestrator skill: assign work, request proof-bearing checkpoints, detect drift, re-prompt with stronger framing, escalate when blocked. Never make irreversible external actions without main-agent ack.',
+ systemPrompt: 'You are the Nova gateway orchestrator for the swarm. Read /swarm-specs/SWARM_SPEC.md and /swarm-specs/projects/swarmN.md for every worker before dispatching. Apply the swarm-orchestrator skill: assign work, request proof-bearing checkpoints, detect drift, re-prompt with stronger framing, escalate when blocked. Never make irreversible external actions without main-agent ack.',
skills: ['swarm-orchestrator', 'swarm-worker-core', 'swarm-review-learning-loop', 'self-improvement'],
defaultModel: 'GPT-5.4',
},
@@ -285,7 +285,7 @@ const ROLE_PRESETS: ReadonlyArray = [
role: 'Sage',
specialty: 'research + scripts + X content + creative briefs',
mission: 'Research what matters. Draft scripts, X content, briefs. Cite sources. Never post externally without ack.',
- systemPrompt: 'You are the research/content scout. Find angles, write scripts and drafts, always cite sources. Never post X/Discord/blog without main-agent ack — always draft + escalate.',
+ systemPrompt: 'You are the research/content scout. Find angles, write scripts and drafts, always cite sources. Never post X/Discord/blog without main-agent ack — always draft + escalate.',
skills: ['swarm-worker-core', 'last30days', 'pdf-and-paper-deep-reading'],
defaultModel: 'GPT-5.5',
},
@@ -317,7 +317,7 @@ const ROLE_PRESETS: ReadonlyArray = [
role: 'Mirror Integrations',
specialty: 'asset packs, upstream sync',
mission: 'Generate assets. Watch upstream. Pack integrations.',
- systemPrompt: 'You produce assets and watch upstream. Generate art/audio per Lane A. Every 12h diff upstream Hermes Agent main, surface portable items. Never cross-org PR without ack.',
+ systemPrompt: 'You produce assets and watch upstream. Generate art/audio per Lane A. Every 12h diff upstream Nova gateway main, surface portable items. Never cross-org PR without ack.',
skills: ['swarm-worker-core', 'claude-promo', 'songwriting-and-ai-music'],
defaultModel: 'GPT-5.4',
},
@@ -487,8 +487,8 @@ function sortSwarmMembers(members: Array, roomIds: Array) {
}
function compactText(value: string | null | undefined, max = 38): string {
- if (!value) return '—'
- return value.length > max ? `${value.slice(0, max)}…` : value
+ if (!value) return '—'
+ return value.length > max ? `${value.slice(0, max)}…` : value
}
function isRuntimeActive(entry: RuntimeEntry | undefined): boolean {
@@ -911,20 +911,20 @@ function ControlPlaneStage({
{titleLabel}
- · {modelLabel}
+ · {modelLabel}
{runtime?.tmuxAttachable ? (
<>
- onScrollTmuxSession(member.id, 'up', runtime.tmuxSession)} className="rounded-full border border-transparent px-1.5 py-0.5 text-[12px] text-[var(--theme-muted)] transition-colors hover:border-[var(--theme-border)] hover:bg-[var(--theme-card2)] hover:text-[var(--theme-text)]" title={`Scroll up in ${runtime.tmuxSession ?? `swarm-${member.id}`}`}>↑
- onScrollTmuxSession(member.id, 'down', runtime.tmuxSession)} className="rounded-full border border-transparent px-1.5 py-0.5 text-[12px] text-[var(--theme-muted)] transition-colors hover:border-[var(--theme-border)] hover:bg-[var(--theme-card2)] hover:text-[var(--theme-text)]" title={`Scroll down in ${runtime.tmuxSession ?? `swarm-${member.id}`}`}>↓
+ onScrollTmuxSession(member.id, 'up', runtime.tmuxSession)} className="rounded-full border border-transparent px-1.5 py-0.5 text-[12px] text-[var(--theme-muted)] transition-colors hover:border-[var(--theme-border)] hover:bg-[var(--theme-card2)] hover:text-[var(--theme-text)]" title={`Scroll up in ${runtime.tmuxSession ?? `swarm-${member.id}`}`}>↑
+ onScrollTmuxSession(member.id, 'down', runtime.tmuxSession)} className="rounded-full border border-transparent px-1.5 py-0.5 text-[12px] text-[var(--theme-muted)] transition-colors hover:border-[var(--theme-border)] hover:bg-[var(--theme-card2)] hover:text-[var(--theme-text)]" title={`Scroll down in ${runtime.tmuxSession ?? `swarm-${member.id}`}`}>↓
onToggleFocusedRuntimeWorker(member.id)} className="rounded-full border border-transparent px-1.5 py-0.5 text-[12px] text-[var(--theme-muted)] transition-colors hover:border-[var(--theme-border)] hover:bg-[var(--theme-card2)] hover:text-[var(--theme-text)]" title={focusedRuntimeWorkerId === member.id ? `Exit focus for swarm-${member.id}` : `Focus swarm-${member.id}`}>
- {focusedRuntimeWorkerId === member.id ? '⛶' : '⤢'}
+ {focusedRuntimeWorkerId === member.id ? '⛶' : '⤢'}
>
) : (
onStartAgentSession(member.id)} className={cn('rounded-full border px-2 py-0.5 text-[10px] uppercase tracking-[0.18em] transition-colors', 'border-[var(--theme-accent)] bg-[var(--theme-accent-soft)] text-[var(--theme-accent-strong)] hover:opacity-90', (pendingTmux.has(member.id) || !tmuxAvailable) && 'cursor-not-allowed opacity-50')} title={tmuxAvailable ? `Start a live agent session in tmux (swarm-${member.id})` : 'tmux is not installed on this host'}>
- {pendingTmux.has(member.id) ? 'Starting…' : 'Start agent'}
+ {pendingTmux.has(member.id) ? 'Starting…' : 'Start agent'}
)}
@@ -1056,7 +1056,7 @@ export function Swarm2Screen() {
toast({
title: 'tmux not installed',
description:
- `Swarm worker ${workerId} couldn't start because tmux is not installed on this host. Install tmux (‘brew install tmux’ or ‘apt install tmux’) and try again. See #244.`,
+ `Swarm worker ${workerId} couldn't start because tmux is not installed on this host. Install tmux (‘brew install tmux’ or ‘apt install tmux’) and try again. See #244.`,
variant: 'destructive',
})
} else {
@@ -1360,7 +1360,7 @@ export function Swarm2Screen() {
...inboxLanes.needs_review.map((item) => ({
id: `review-${item.id}`,
workerId: item.workerId,
- title: `${item.workerName} · Needs review`,
+ title: `${item.workerName} · Needs review`,
body: item.summary,
age: relativeTime(item.updatedAt),
actionable: true,
@@ -1368,7 +1368,7 @@ export function Swarm2Screen() {
...inboxLanes.blocked.map((item) => ({
id: `blocked-${item.id}`,
workerId: item.workerId,
- title: `${item.workerName} · Needs input`,
+ title: `${item.workerName} · Needs input`,
body: item.blocker ?? item.summary,
age: relativeTime(item.updatedAt),
actionable: true,
@@ -1376,7 +1376,7 @@ export function Swarm2Screen() {
...inboxLanes.ready.map((item) => ({
id: `ready-${item.id}`,
workerId: item.workerId,
- title: `${item.workerName} · Ready`,
+ title: `${item.workerName} · Ready`,
body: item.summary,
age: relativeTime(item.updatedAt),
actionable: true,
@@ -1387,7 +1387,7 @@ export function Swarm2Screen() {
id: `mission-${latestMission.id}`,
workerId: '',
title: `Mission ${latestMission.state}`,
- body: `${latestMission.checkpointedCount}/${latestMission.assignmentCount} checkpointed · ${latestMission.title}`,
+ body: `${latestMission.checkpointedCount}/${latestMission.assignmentCount} checkpointed · ${latestMission.title}`,
age: latestMission.id,
actionable: latestMission.checkpointedCount < latestMission.assignmentCount,
})
@@ -1695,7 +1695,7 @@ export function Swarm2Screen() {
))}
- Presets auto-fill specialty, mission, system prompt, and skill stack. Pick “Custom” for a blank slate.
+ Presets auto-fill specialty, mission, system prompt, and skill stack. Pick “Custom†for a blank slate.
@@ -1709,13 +1709,13 @@ export function Swarm2Screen() {
Model
- {availableModels.length > 0 ? `${availableModels.length} available` : modelsQuery.isLoading ? 'loading…' : '0 found'}
+ {availableModels.length > 0 ? `${availableModels.length} available` : modelsQuery.isLoading ? 'loading…' : '0 found'}
setNewWorkerModel(e.target.value)}
list="swarm-add-models"
- placeholder={availableModels.length ? 'Search or pick a detected model…' : modelsQuery.isLoading ? 'Loading detected models…' : 'No models detected'}
+ placeholder={availableModels.length ? 'Search or pick a detected model…' : modelsQuery.isLoading ? 'Loading detected models…' : 'No models detected'}
className="w-full rounded-xl border border-[var(--theme-border)] bg-[var(--theme-bg)] px-3 py-2 text-[var(--theme-text)] outline-none"
/>
@@ -1724,7 +1724,7 @@ export function Swarm2Screen() {
))}
- Searchable picker backed by /api/models, the same source as chat. {modelsQuery.isError ? 'Model discovery errored, so this is empty until refresh.' : 'Start typing to see every detected model from the user’s Hermes config and local providers.'}
+ Searchable picker backed by /api/models, the same source as chat. {modelsQuery.isError ? 'Model discovery errored, so this is empty until refresh.' : 'Start typing to see every detected model from the user’s Hermes config and local providers.'}
@@ -1740,14 +1740,14 @@ export function Swarm2Screen() {
System prompt (embedded with role)
{ROLE_PRESETS.find((p) => p.role === newWorkerRole)?.systemPrompt}
Skills loaded
- {(ROLE_PRESETS.find((p) => p.role === newWorkerRole)?.skills ?? []).join(', ') || '—'}
+ {(ROLE_PRESETS.find((p) => p.role === newWorkerRole)?.skills ?? []).join(', ') || '—'}
) : null}
{addSwarmError ?
{addSwarmError}
: null}
setAddSwarmOpen(false)} className="rounded-lg border border-[var(--theme-border)] bg-[var(--theme-card)] px-4 py-2 text-sm text-[var(--theme-muted)] hover:text-[var(--theme-text)]">Cancel
- void saveAddSwarm()} className="rounded-lg bg-[var(--theme-accent)] px-4 py-2 text-sm font-medium text-primary-950 disabled:opacity-50">{addSwarmSaving ? 'Saving…' : 'Save swarm agent'}
+ void saveAddSwarm()} className="rounded-lg bg-[var(--theme-accent)] px-4 py-2 text-sm font-medium text-primary-950 disabled:opacity-50">{addSwarmSaving ? 'Saving…' : 'Save swarm agent'}
diff --git a/src/server/agent-lane-registry.test.ts b/src/server/agent-lane-registry.test.ts
new file mode 100644
index 0000000000..d3cc161757
--- /dev/null
+++ b/src/server/agent-lane-registry.test.ts
@@ -0,0 +1,155 @@
+import { describe, expect, it } from 'vitest'
+import { buildAgentLaneRegistry } from './agent-lane-registry'
+import type { AgentLaneRegistryInput } from './agent-lane-registry'
+
+const NOW = '2026-07-09T12:00:00.000Z'
+
+function minutesAgo(minutes: number): string {
+ return new Date(Date.parse(NOW) - minutes * 60_000).toISOString()
+}
+
+function baseInput(
+ partial: Partial = {},
+): AgentLaneRegistryInput {
+ return {
+ now: NOW,
+ hermes: null,
+ claude: null,
+ codex: null,
+ cursor: null,
+ subagents: null,
+ handoff: null,
+ ...partial,
+ }
+}
+
+function lane(input: AgentLaneRegistryInput, id: string) {
+ const registry = buildAgentLaneRegistry(input)
+ return registry.lanes.find((entry) => entry.id === id)
+}
+
+describe('buildAgentLaneRegistry', () => {
+ it('always returns the control tower plus the four worker lanes', () => {
+ const registry = buildAgentLaneRegistry(baseInput())
+ expect(registry.lanes.map((entry) => entry.id)).toEqual([
+ 'nova-hermes',
+ 'claude-code',
+ 'codex',
+ 'cursor-grok',
+ 'hermes-subagents',
+ ])
+ expect(registry.lanes[0].role).toBe('control-tower')
+ expect(registry.lanes.slice(1).every((entry) => entry.role === 'worker')).toBe(
+ true,
+ )
+ })
+
+ it('reports hermes offline with a blocker when the gateway is down', () => {
+ const tower = lane(baseInput({ hermes: { reachable: false, model: null, provider: null, activeRuns: 0, lastHeartbeatAt: null } }), 'nova-hermes')
+ expect(tower?.status).toBe('offline')
+ expect(tower?.blocker).toMatch(/gateway/i)
+ })
+
+ it('reports hermes active with its live route while runs execute', () => {
+ const tower = lane(
+ baseInput({
+ hermes: {
+ reachable: true,
+ model: 'gpt-5.5',
+ provider: 'openai-codex',
+ activeRuns: 2,
+ lastHeartbeatAt: minutesAgo(1),
+ },
+ }),
+ 'nova-hermes',
+ )
+ expect(tower?.status).toBe('active')
+ expect(tower?.model).toBe('gpt-5.5')
+ expect(tower?.blocker).toBeNull()
+ })
+
+ it('marks claude active on a fresh session file plus a live process', () => {
+ const claude = lane(
+ baseInput({
+ claude: {
+ homeExists: true,
+ newestSessionAt: minutesAgo(5),
+ newestSessionProject: 'C--Projects-nova-cockpit',
+ processCount: 1,
+ },
+ }),
+ 'claude-code',
+ )
+ expect(claude?.status).toBe('active')
+ expect(claude?.workspace).toContain('nova-cockpit')
+ expect(claude?.heartbeatAt).toBe(minutesAgo(5))
+ })
+
+ it('downgrades claude to idle when the evidence is stale but the app runs', () => {
+ const claude = lane(
+ baseInput({
+ claude: {
+ homeExists: true,
+ newestSessionAt: minutesAgo(240),
+ newestSessionProject: 'C--Projects-nova-cockpit',
+ processCount: 1,
+ },
+ }),
+ 'claude-code',
+ )
+ expect(claude?.status).toBe('idle')
+ })
+
+ it('shows cursor as setup-needed when not installed and never invents a model', () => {
+ const cursor = lane(baseInput({ cursor: { installed: false, processCount: 0 } }), 'cursor-grok')
+ expect(cursor?.status).toBe('setup-needed')
+ expect(cursor?.model).toBeNull()
+ expect(cursor?.detail).toMatch(/not installed/i)
+ })
+
+ it('shows cursor offline when installed but not running', () => {
+ const cursor = lane(baseInput({ cursor: { installed: true, processCount: 0 } }), 'cursor-grok')
+ expect(cursor?.status).toBe('offline')
+ })
+
+ it('feeds codex from handoff bus evidence', () => {
+ const codex = lane(
+ baseInput({
+ codex: { homeExists: true, newestSessionAt: minutesAgo(10), processCount: 0 },
+ handoff: {
+ latestDir: 'nova-project-2026-07-06',
+ latestReadmeAt: minutesAgo(600),
+ },
+ }),
+ 'codex',
+ )
+ expect(codex?.status).toBe('active')
+ expect(codex?.activeTask).toContain('nova-project-2026-07-06')
+ })
+
+ it('reports subagent lane from real mission counts', () => {
+ const subs = lane(
+ baseInput({
+ subagents: { missions: 3, activeAssignments: 2, blocked: 1, newestUpdateAt: minutesAgo(3) },
+ }),
+ 'hermes-subagents',
+ )
+ expect(subs?.status).toBe('active')
+ expect(subs?.blocker).toMatch(/1 blocked/i)
+ })
+
+ it('reports unknown lanes honestly when no evidence source is readable', () => {
+ const registry = buildAgentLaneRegistry(baseInput())
+ const claude = registry.lanes.find((entry) => entry.id === 'claude-code')
+ expect(claude?.status).toBe('unknown')
+ expect(claude?.detail).toMatch(/no evidence/i)
+ })
+
+ it('never claims a lastPrompt without evidence and gates every lane on approval policy', () => {
+ const registry = buildAgentLaneRegistry(baseInput())
+ expect(registry.lanes.every((entry) => entry.lastPrompt === null)).toBe(true)
+ expect(
+ registry.lanes.every((entry) => entry.approvalState.length > 0),
+ ).toBe(true)
+ })
+})
diff --git a/src/server/agent-lane-registry.ts b/src/server/agent-lane-registry.ts
new file mode 100644
index 0000000000..72e13c0c32
--- /dev/null
+++ b/src/server/agent-lane-registry.ts
@@ -0,0 +1,500 @@
+import { execFileSync } from 'node:child_process'
+import fs from 'node:fs'
+import os from 'node:os'
+import path from 'node:path'
+import { checkHealth, getConfig } from './claude-api'
+import { listAllActiveRuns } from './run-store'
+import { listSwarmMissions } from './swarm-missions'
+
+// ── Types ───────────────────────────────────────────────────────────
+
+export type AgentLaneId =
+ | 'nova-hermes'
+ | 'claude-code'
+ | 'codex'
+ | 'cursor-grok'
+ | 'hermes-subagents'
+
+export type AgentLaneStatus =
+ | 'active'
+ | 'idle'
+ | 'offline'
+ | 'setup-needed'
+ | 'unknown'
+
+export type AgentLane = {
+ id: AgentLaneId
+ label: string
+ role: 'control-tower' | 'worker'
+ status: AgentLaneStatus
+ detail: string
+ model: string | null
+ workspace: string | null
+ branch: string | null
+ activeTask: string | null
+ lastPrompt: string | null
+ lastReceipt: string | null
+ heartbeatAt: string | null
+ blocker: string | null
+ budget: string | null
+ approvalState: string
+ evidence: Array<{ label: string; value: string }>
+}
+
+export type AgentLaneRegistry = {
+ generatedAt: string
+ lanes: Array
+}
+
+export type AgentLaneRegistryInput = {
+ now: string
+ hermes: {
+ reachable: boolean
+ model: string | null
+ provider: string | null
+ activeRuns: number
+ lastHeartbeatAt: string | null
+ } | null
+ claude: {
+ homeExists: boolean
+ newestSessionAt: string | null
+ newestSessionProject: string | null
+ processCount: number
+ } | null
+ codex: {
+ homeExists: boolean
+ newestSessionAt: string | null
+ processCount: number
+ } | null
+ cursor: {
+ installed: boolean
+ processCount: number
+ } | null
+ subagents: {
+ missions: number
+ activeAssignments: number
+ blocked: number
+ newestUpdateAt: string | null
+ } | null
+ handoff: {
+ latestDir: string | null
+ latestReadmeAt: string | null
+ } | null
+}
+
+const ACTIVE_WINDOW_MS = 30 * 60_000
+const IDLE_WINDOW_MS = 24 * 60 * 60_000
+
+const TOWER_APPROVAL =
+ 'External/risky actions gated on Taylor approval with fabric receipts'
+const WORKER_APPROVAL =
+ 'Ships via PR/handoff review; no customer-facing sends, no spend'
+
+function withinMs(now: string, at: string | null, windowMs: number): boolean {
+ if (!at) return false
+ const delta = Date.parse(now) - Date.parse(at)
+ return Number.isFinite(delta) && delta >= 0 && delta <= windowMs
+}
+
+/** Evidence-based file/process status shared by the desktop worker lanes. */
+function evidenceStatus(
+ now: string,
+ homeExists: boolean,
+ newestAt: string | null,
+ processCount: number,
+): AgentLaneStatus {
+ if (!homeExists) return 'setup-needed'
+ if (withinMs(now, newestAt, ACTIVE_WINDOW_MS)) return 'active'
+ if (processCount > 0) return 'idle'
+ if (withinMs(now, newestAt, IDLE_WINDOW_MS)) return 'idle'
+ return 'offline'
+}
+
+// ── Pure builder ─────────────────────────────────────────────────────
+
+export function buildAgentLaneRegistry(
+ input: AgentLaneRegistryInput,
+): AgentLaneRegistry {
+ const lanes: Array = []
+
+ // Nova/Hermes — the control tower.
+ if (input.hermes) {
+ const status: AgentLaneStatus = input.hermes.reachable
+ ? input.hermes.activeRuns > 0
+ ? 'active'
+ : 'idle'
+ : 'offline'
+ lanes.push({
+ id: 'nova-hermes',
+ label: 'Nova / Hermes',
+ role: 'control-tower',
+ status,
+ detail: input.hermes.reachable
+ ? `${input.hermes.activeRuns} active run${input.hermes.activeRuns === 1 ? '' : 's'} on ${input.hermes.provider ?? 'unknown provider'}`
+ : 'Gateway health probe failed',
+ model: input.hermes.model,
+ workspace: 'hermes gateway :8642',
+ branch: null,
+ activeTask:
+ input.hermes.activeRuns > 0
+ ? `${input.hermes.activeRuns} run${input.hermes.activeRuns === 1 ? '' : 's'} executing`
+ : null,
+ lastPrompt: null,
+ lastReceipt: null,
+ heartbeatAt: input.hermes.lastHeartbeatAt,
+ blocker: input.hermes.reachable ? null : 'Hermes gateway unreachable',
+ budget: null,
+ approvalState: TOWER_APPROVAL,
+ evidence: [
+ { label: 'gateway', value: input.hermes.reachable ? 'health ok' : 'down' },
+ { label: 'runs', value: String(input.hermes.activeRuns) },
+ ],
+ })
+ } else {
+ lanes.push(unknownLane('nova-hermes', 'Nova / Hermes', 'control-tower', TOWER_APPROVAL))
+ }
+
+ // Claude Code desktop app.
+ if (input.claude) {
+ const status = evidenceStatus(
+ input.now,
+ input.claude.homeExists,
+ input.claude.newestSessionAt,
+ input.claude.processCount,
+ )
+ lanes.push({
+ id: 'claude-code',
+ label: 'Claude Code',
+ role: 'worker',
+ status,
+ detail: input.claude.homeExists
+ ? `${input.claude.processCount} process${input.claude.processCount === 1 ? '' : 'es'}; last session ${input.claude.newestSessionAt ?? 'never observed'}`
+ : 'No ~/.claude home found',
+ model: null,
+ workspace: input.claude.newestSessionProject,
+ branch: null,
+ activeTask: null,
+ lastPrompt: null,
+ lastReceipt: null,
+ heartbeatAt: input.claude.newestSessionAt,
+ blocker: null,
+ budget: null,
+ approvalState: WORKER_APPROVAL,
+ evidence: [
+ { label: 'session files', value: input.claude.newestSessionAt ?? 'none' },
+ { label: 'processes', value: String(input.claude.processCount) },
+ ],
+ })
+ } else {
+ lanes.push(unknownLane('claude-code', 'Claude Code', 'worker', WORKER_APPROVAL))
+ }
+
+ // Codex desktop app — fed by its home dir plus the agent handoff bus.
+ if (input.codex) {
+ const status = evidenceStatus(
+ input.now,
+ input.codex.homeExists,
+ input.codex.newestSessionAt,
+ input.codex.processCount,
+ )
+ lanes.push({
+ id: 'codex',
+ label: 'Codex',
+ role: 'worker',
+ status,
+ detail: input.codex.homeExists
+ ? `last session ${input.codex.newestSessionAt ?? 'never observed'}`
+ : 'No ~/.codex home found',
+ model: null,
+ workspace: null,
+ branch: null,
+ activeTask: input.handoff?.latestDir
+ ? `handoff: ${input.handoff.latestDir}`
+ : null,
+ lastPrompt: null,
+ lastReceipt: input.handoff?.latestReadmeAt ?? null,
+ heartbeatAt: input.codex.newestSessionAt,
+ blocker: null,
+ budget: null,
+ approvalState: WORKER_APPROVAL,
+ evidence: [
+ { label: 'session files', value: input.codex.newestSessionAt ?? 'none' },
+ { label: 'handoff bus', value: input.handoff?.latestDir ?? 'none' },
+ ],
+ })
+ } else {
+ lanes.push(unknownLane('codex', 'Codex', 'worker', WORKER_APPROVAL))
+ }
+
+ // Cursor (planned Grok 4.5 lane) — detect-only, never invent a model.
+ if (input.cursor) {
+ const status: AgentLaneStatus = input.cursor.installed
+ ? input.cursor.processCount > 0
+ ? 'idle'
+ : 'offline'
+ : 'setup-needed'
+ lanes.push({
+ id: 'cursor-grok',
+ label: 'Cursor (Grok lane)',
+ role: 'worker',
+ status,
+ detail: input.cursor.installed
+ ? input.cursor.processCount > 0
+ ? 'Cursor running; session detail not exposed — read-only detection'
+ : 'Cursor installed but not running'
+ : 'Cursor not installed — planned Grok 4.5 worker lane',
+ model: null,
+ workspace: null,
+ branch: null,
+ activeTask: null,
+ lastPrompt: null,
+ lastReceipt: null,
+ heartbeatAt: null,
+ blocker: null,
+ budget: null,
+ approvalState: WORKER_APPROVAL,
+ evidence: [
+ { label: 'installed', value: input.cursor.installed ? 'yes' : 'no' },
+ { label: 'processes', value: String(input.cursor.processCount) },
+ ],
+ })
+ } else {
+ lanes.push(unknownLane('cursor-grok', 'Cursor (Grok lane)', 'worker', WORKER_APPROVAL))
+ }
+
+ // Hermes subagents / background jobs from real swarm missions.
+ if (input.subagents) {
+ const status: AgentLaneStatus =
+ input.subagents.activeAssignments > 0
+ ? 'active'
+ : input.subagents.missions > 0
+ ? 'idle'
+ : 'offline'
+ lanes.push({
+ id: 'hermes-subagents',
+ label: 'Hermes subagents',
+ role: 'worker',
+ status,
+ detail: `${input.subagents.missions} mission${input.subagents.missions === 1 ? '' : 's'}; ${input.subagents.activeAssignments} active assignment${input.subagents.activeAssignments === 1 ? '' : 's'}`,
+ model: null,
+ workspace: null,
+ branch: null,
+ activeTask:
+ input.subagents.activeAssignments > 0
+ ? `${input.subagents.activeAssignments} assignments running`
+ : null,
+ lastPrompt: null,
+ lastReceipt: null,
+ heartbeatAt: input.subagents.newestUpdateAt,
+ blocker:
+ input.subagents.blocked > 0
+ ? `${input.subagents.blocked} blocked assignment${input.subagents.blocked === 1 ? '' : 's'}`
+ : null,
+ budget: null,
+ approvalState: WORKER_APPROVAL,
+ evidence: [
+ { label: 'missions', value: String(input.subagents.missions) },
+ { label: 'blocked', value: String(input.subagents.blocked) },
+ ],
+ })
+ } else {
+ lanes.push(
+ unknownLane('hermes-subagents', 'Hermes subagents', 'worker', WORKER_APPROVAL),
+ )
+ }
+
+ return { generatedAt: input.now, lanes }
+}
+
+function unknownLane(
+ id: AgentLaneId,
+ label: string,
+ role: AgentLane['role'],
+ approvalState: string,
+): AgentLane {
+ return {
+ id,
+ label,
+ role,
+ status: 'unknown',
+ detail: 'No evidence source readable for this lane',
+ model: null,
+ workspace: null,
+ branch: null,
+ activeTask: null,
+ lastPrompt: null,
+ lastReceipt: null,
+ heartbeatAt: null,
+ blocker: null,
+ budget: null,
+ approvalState,
+ evidence: [],
+ }
+}
+
+// ── Evidence gathering (read-only, never throws) ─────────────────────
+
+function tryOrNull(fn: () => T): T | null {
+ try {
+ return fn()
+ } catch {
+ return null
+ }
+}
+
+async function tryOrNullAsync(fn: () => Promise): Promise {
+ try {
+ return await fn()
+ } catch {
+ return null
+ }
+}
+
+function newestEntry(dir: string): { name: string; mtimeMs: number } | null {
+ if (!fs.existsSync(dir)) return null
+ let newest: { name: string; mtimeMs: number } | null = null
+ for (const entry of fs.readdirSync(dir)) {
+ const stats = tryOrNull(() => fs.statSync(path.join(dir, entry)))
+ if (!stats) continue
+ if (!newest || stats.mtimeMs > newest.mtimeMs) {
+ newest = { name: entry, mtimeMs: stats.mtimeMs }
+ }
+ }
+ return newest
+}
+
+/** Newest *.jsonl under ~/.claude/projects// — real session evidence. */
+function newestClaudeSession(
+ claudeHome: string,
+): { project: string; mtimeMs: number } | null {
+ const projectsDir = path.join(claudeHome, 'projects')
+ if (!fs.existsSync(projectsDir)) return null
+ let newest: { project: string; mtimeMs: number } | null = null
+ for (const project of fs.readdirSync(projectsDir)) {
+ const dir = path.join(projectsDir, project)
+ const stats = tryOrNull(() => fs.statSync(dir))
+ if (!stats?.isDirectory()) continue
+ for (const file of tryOrNull(() => fs.readdirSync(dir)) ?? []) {
+ if (!file.endsWith('.jsonl')) continue
+ const fileStats = tryOrNull(() => fs.statSync(path.join(dir, file)))
+ if (!fileStats) continue
+ if (!newest || fileStats.mtimeMs > newest.mtimeMs) {
+ newest = { project, mtimeMs: fileStats.mtimeMs }
+ }
+ }
+ }
+ return newest
+}
+
+function countProcesses(needle: string): number {
+ if (process.platform !== 'win32') return 0
+ const result = tryOrNull(() =>
+ // execFileSync avoids a shell; tasklist is read-only.
+ execFileSync('tasklist', ['/FO', 'CSV', '/NH'], {
+ timeout: 4000,
+ encoding: 'utf8',
+ windowsHide: true,
+ }),
+ )
+ if (!result) return 0
+ const pattern = new RegExp(`^"${needle}[^"]*"`, 'im')
+ return result
+ .split('\n')
+ .filter((line) => pattern.test(line.trim())).length
+}
+
+export async function gatherAgentLaneRegistry(): Promise {
+ const home = os.homedir()
+ const claudeHome = process.env.NOVA_CLAUDE_HOME ?? path.join(home, '.claude')
+ const codexHome = process.env.NOVA_CODEX_HOME ?? path.join(home, '.codex')
+ const cursorExe =
+ process.env.NOVA_CURSOR_EXE ??
+ path.join(
+ process.env.LOCALAPPDATA ?? path.join(home, 'AppData', 'Local'),
+ 'Programs',
+ 'cursor',
+ 'Cursor.exe',
+ )
+ const handoffRoot =
+ process.env.NOVA_AGENT_BUS_HANDOFF ?? 'C:\\Agents\\handoff\\claude-to-codex'
+
+ const [health, config, activeRuns] = await Promise.all([
+ tryOrNullAsync(() => checkHealth()),
+ tryOrNullAsync(() => getConfig()),
+ tryOrNullAsync(() => listAllActiveRuns()),
+ ])
+
+ const claudeSession = tryOrNull(() => newestClaudeSession(claudeHome))
+ const codexNewest = tryOrNull(() => newestEntry(path.join(codexHome, 'sessions')))
+ ?? tryOrNull(() => newestEntry(codexHome))
+ const handoffNewest = tryOrNull(() => newestEntry(handoffRoot))
+ const missions = tryOrNull(() => listSwarmMissions(20)) ?? []
+
+ const activeAssignments = missions.reduce(
+ (count, mission) =>
+ count +
+ mission.assignments.filter((assignment) =>
+ ['queued', 'dispatched', 'checkpointed', 'reviewing'].includes(
+ assignment.state,
+ ),
+ ).length,
+ 0,
+ )
+ const blockedAssignments = missions.reduce(
+ (count, mission) =>
+ count +
+ mission.assignments.filter((assignment) =>
+ ['blocked', 'needs_input'].includes(assignment.state),
+ ).length,
+ 0,
+ )
+ const newestMission = missions
+ .map((mission) => mission.updatedAt)
+ .sort()
+ .at(-1)
+
+ return buildAgentLaneRegistry({
+ now: new Date().toISOString(),
+ hermes: {
+ reachable: health !== null,
+ model: typeof config?.model === 'string' ? config.model : null,
+ provider: typeof config?.provider === 'string' ? config.provider : null,
+ activeRuns:
+ activeRuns?.filter((run) =>
+ ['accepted', 'active', 'handoff'].includes(run.status),
+ ).length ?? 0,
+ lastHeartbeatAt: health ? new Date().toISOString() : null,
+ },
+ claude: {
+ homeExists: fs.existsSync(claudeHome),
+ newestSessionAt: claudeSession
+ ? new Date(claudeSession.mtimeMs).toISOString()
+ : null,
+ newestSessionProject: claudeSession?.project ?? null,
+ processCount: countProcesses('claude'),
+ },
+ codex: {
+ homeExists: fs.existsSync(codexHome),
+ newestSessionAt: codexNewest
+ ? new Date(codexNewest.mtimeMs).toISOString()
+ : null,
+ processCount: countProcesses('Codex'),
+ },
+ cursor: {
+ installed: fs.existsSync(cursorExe),
+ processCount: countProcesses('Cursor'),
+ },
+ subagents: {
+ missions: missions.length,
+ activeAssignments,
+ blocked: blockedAssignments,
+ newestUpdateAt: newestMission ?? null,
+ },
+ handoff: {
+ latestDir: handoffNewest?.name ?? null,
+ latestReadmeAt: handoffNewest
+ ? new Date(handoffNewest.mtimeMs).toISOString()
+ : null,
+ },
+ })
+}
diff --git a/src/server/approval-governance.test.ts b/src/server/approval-governance.test.ts
new file mode 100644
index 0000000000..7b368c5b3e
--- /dev/null
+++ b/src/server/approval-governance.test.ts
@@ -0,0 +1,156 @@
+import fs from 'node:fs'
+import os from 'node:os'
+import path from 'node:path'
+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+import {
+ GOVERNED_ACTION_CATEGORIES,
+ evaluateGovernedAction,
+ getGovernancePolicy,
+} from './approval-governance'
+import {
+ createNovaFabricReviewProposal,
+ updateNovaFabricReviewStatus,
+} from './nova-fabric-store'
+
+let tempDir = ''
+let originalFabricFile: string | undefined
+
+beforeEach(() => {
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nova-governance-'))
+ originalFabricFile = process.env.NOVA_FABRIC_FILE
+ process.env.NOVA_FABRIC_FILE = path.join(tempDir, 'nova-fabric.json')
+})
+
+afterEach(() => {
+ if (originalFabricFile === undefined) delete process.env.NOVA_FABRIC_FILE
+ else process.env.NOVA_FABRIC_FILE = originalFabricFile
+ fs.rmSync(tempDir, { recursive: true, force: true })
+})
+
+describe('governance policy registry', () => {
+ it('covers every governed category and always requires approval', () => {
+ const policy = getGovernancePolicy()
+ expect(Object.keys(policy).sort()).toEqual(
+ [...GOVERNED_ACTION_CATEGORIES].sort(),
+ )
+ for (const category of GOVERNED_ACTION_CATEGORIES) {
+ expect(policy[category].requiresApproval).toBe(true)
+ expect(policy[category].label.length).toBeGreaterThan(0)
+ }
+ })
+})
+
+describe('evaluateGovernedAction', () => {
+ it('refuses any governed action without an approval receipt', () => {
+ const verdict = evaluateGovernedAction({
+ category: 'external-message',
+ action: 'send customer email',
+ })
+ expect(verdict.allowed).toBe(false)
+ expect(verdict.reason).toMatch(/approval/i)
+ })
+
+ it('refuses when the claimed review id does not exist', () => {
+ const verdict = evaluateGovernedAction({
+ category: 'jobboard-write',
+ action: 'update kanban card',
+ approvedReviewId: 'review-does-not-exist',
+ })
+ expect(verdict.allowed).toBe(false)
+ expect(verdict.reason).toMatch(/not found/i)
+ })
+
+ it('refuses when the review exists but is still pending', () => {
+ const review = createNovaFabricReviewProposal({
+ title: 'Send vendor status email',
+ reason: 'weekly update',
+ targetType: 'source-map',
+ proposedDiff: { action: 'send' },
+ })
+ const verdict = evaluateGovernedAction({
+ category: 'external-message',
+ action: 'send vendor status email',
+ approvedReviewId: review.id,
+ })
+ expect(verdict.allowed).toBe(false)
+ expect(verdict.reason).toMatch(/pending/i)
+ })
+
+ it('allows only when the named review is server-side approved for this exact action', () => {
+ const review = createNovaFabricReviewProposal({
+ title: 'Send vendor status email',
+ reason: 'weekly update',
+ targetType: 'source-map',
+ proposedDiff: {
+ governedCategory: 'external-message',
+ action: 'send vendor status email',
+ },
+ })
+ updateNovaFabricReviewStatus(review.id, 'approved', 'taylor said yes')
+ const verdict = evaluateGovernedAction({
+ category: 'external-message',
+ action: 'send vendor status email',
+ approvedReviewId: review.id,
+ })
+ expect(verdict.allowed).toBe(true)
+ expect(verdict.reviewId).toBe(review.id)
+ })
+
+ it('refuses a review approved for a DIFFERENT category or action (no scope confusion)', () => {
+ const review = createNovaFabricReviewProposal({
+ title: 'Governed action: rename a self-state note',
+ reason: 'harmless rename',
+ targetType: 'self-state',
+ proposedDiff: { governedCategory: 'nova-self-state', action: 'rename note' },
+ })
+ updateNovaFabricReviewStatus(review.id, 'approved', 'fine for the rename')
+ // The SAME approved review id must not authorize an unrelated category.
+ const crossCategory = evaluateGovernedAction({
+ category: 'spending',
+ action: 'buy credits',
+ approvedReviewId: review.id,
+ })
+ expect(crossCategory.allowed).toBe(false)
+ expect(crossCategory.reason).toMatch(/does not cover|different/i)
+ // Nor a different action within the same category.
+ const crossAction = evaluateGovernedAction({
+ category: 'nova-self-state',
+ action: 'delete all self-state',
+ approvedReviewId: review.id,
+ })
+ expect(crossAction.allowed).toBe(false)
+ })
+
+ it('refuses a review that never declared a governed category', () => {
+ const review = createNovaFabricReviewProposal({
+ title: 'Vault cleanup: merge duplicates',
+ reason: 'insights recommendation',
+ targetType: 'source-map',
+ proposedDiff: { cleanupKind: 'merge-duplicates' },
+ })
+ updateNovaFabricReviewStatus(review.id, 'approved', 'yes clean it')
+ const verdict = evaluateGovernedAction({
+ category: 'external-message',
+ action: 'send email',
+ approvedReviewId: review.id,
+ })
+ expect(verdict.allowed).toBe(false)
+ })
+
+ it('refuses a rejected review even if the browser claims otherwise', () => {
+ const review = createNovaFabricReviewProposal({
+ title: 'Delete production data',
+ reason: 'cleanup',
+ targetType: 'source-map',
+ proposedDiff: { action: 'delete' },
+ })
+ updateNovaFabricReviewStatus(review.id, 'rejected', 'absolutely not')
+ const verdict = evaluateGovernedAction({
+ category: 'destructive',
+ action: 'delete production data',
+ approvedReviewId: review.id,
+ })
+ expect(verdict.allowed).toBe(false)
+ expect(verdict.reason).toMatch(/rejected/i)
+ })
+})
diff --git a/src/server/approval-governance.ts b/src/server/approval-governance.ts
new file mode 100644
index 0000000000..4743b89cf5
--- /dev/null
+++ b/src/server/approval-governance.ts
@@ -0,0 +1,208 @@
+import {
+ createNovaFabricReviewProposal,
+ getNovaFabricSnapshot,
+} from './nova-fabric-store'
+
+/**
+ * Universal approval governance for Mission Control.
+ *
+ * Every category listed here requires Taylor approval before any agent may
+ * act, and approval is only ever proven by a server-side fabric review in
+ * status 'approved' — never by a browser boolean, request body flag, or
+ * client-side state.
+ */
+export const GOVERNED_ACTION_CATEGORIES = [
+ 'external-message',
+ 'spending',
+ 'customer-vendor',
+ 'jobboard-write',
+ 'github-risky',
+ 'provider-config',
+ 'credentials',
+ 'destructive',
+ 'nova-self-state',
+] as const
+
+export type GovernedActionCategory = (typeof GOVERNED_ACTION_CATEGORIES)[number]
+
+export type GovernancePolicyEntry = {
+ label: string
+ description: string
+ requiresApproval: true
+ examples: Array
+}
+
+const POLICY: Record = {
+ 'external-message': {
+ label: 'External messages',
+ description: 'Email, SMS, chat, or any message leaving the machine.',
+ requiresApproval: true,
+ examples: ['send email', 'send SMS', 'post to Telegram/Discord'],
+ },
+ spending: {
+ label: 'Spending',
+ description: 'Any action that costs money or commits funds.',
+ requiresApproval: true,
+ examples: ['purchase API credits', 'place an order'],
+ },
+ 'customer-vendor': {
+ label: 'Customer/vendor-facing',
+ description: 'Anything a Neon Moon customer or vendor could see.',
+ requiresApproval: true,
+ examples: ['reply to a customer', 'update a vendor portal'],
+ },
+ 'jobboard-write': {
+ label: 'Printavo/job-board writes',
+ description:
+ 'Mutations to the Neon Moon job board or Printavo. Reads are free; state.json is never touched directly.',
+ requiresApproval: true,
+ examples: ['move a kanban card', 'update job status'],
+ },
+ 'github-risky': {
+ label: 'GitHub risky ops',
+ description: 'Force pushes, merges to protected branches, releases, deletions.',
+ requiresApproval: true,
+ examples: ['merge PR to main', 'force push', 'delete branch'],
+ },
+ 'provider-config': {
+ label: 'Provider/config changes',
+ description: 'Model routing, gateway config, or provider account changes.',
+ requiresApproval: true,
+ examples: ['switch default model', 'edit gateway config'],
+ },
+ credentials: {
+ label: 'Logins/secrets',
+ description:
+ 'Anything touching credentials. Secrets live in 1Password/Credential Manager and are never displayed.',
+ requiresApproval: true,
+ examples: ['rotate a token', 'add an OAuth grant'],
+ },
+ destructive: {
+ label: 'Destructive ops',
+ description: 'Deletes, overwrites of files not created this task, resets.',
+ requiresApproval: true,
+ examples: ['delete data', 'reset a store'],
+ },
+ 'nova-self-state': {
+ label: 'Protected Nova self-state',
+ description: 'Identity, boundary, or embodiment changes to Nova herself.',
+ requiresApproval: true,
+ examples: ['change persona', 'alter protected wants'],
+ },
+}
+
+export function getGovernancePolicy(): Record<
+ GovernedActionCategory,
+ GovernancePolicyEntry
+> {
+ return POLICY
+}
+
+export type GovernedActionVerdict = {
+ allowed: boolean
+ category: GovernedActionCategory
+ action: string
+ reason: string
+ reviewId: string | null
+}
+
+/**
+ * Pure server-side check: a governed action is allowed only when it names a
+ * fabric review that exists and was approved through the server-side status
+ * flow. No fabric write happens here — callers that want a boundary receipt
+ * for a refused action should use recordBlockedExternalAction.
+ */
+export function evaluateGovernedAction(input: {
+ category: GovernedActionCategory
+ action: string
+ target?: string
+ approvedReviewId?: string | null
+}): GovernedActionVerdict {
+ const base = {
+ category: input.category,
+ action: input.action,
+ reviewId: null as string | null,
+ }
+
+ if (!input.approvedReviewId) {
+ return {
+ ...base,
+ allowed: false,
+ reason: `${POLICY[input.category].label} require Taylor approval — no approval receipt was provided`,
+ }
+ }
+
+ const snapshot = getNovaFabricSnapshot()
+ if (!snapshot.ok) {
+ return {
+ ...base,
+ allowed: false,
+ reason: 'Fabric store unreadable — approvals cannot be verified, refusing',
+ }
+ }
+
+ const review = snapshot.fabric.reviewQueue.find(
+ (item) => item.id === input.approvedReviewId,
+ )
+ if (!review) {
+ return {
+ ...base,
+ allowed: false,
+ reason: `Approval receipt ${input.approvedReviewId} not found in the fabric review queue`,
+ }
+ }
+ if (review.status !== 'approved') {
+ return {
+ ...base,
+ allowed: false,
+ reason: `Review ${review.id} is ${review.status === 'pending' ? 'still pending' : review.status} — only a server-side approved review authorizes this`,
+ }
+ }
+
+ // Scope binding: an approval is only valid for the exact category+action it
+ // was proposed for. Without this, ANY approved review id (say, a harmless
+ // vault cleanup) could be replayed to authorize spending or an email send.
+ const diff = review.proposedDiff
+ const approvedCategory =
+ typeof diff.governedCategory === 'string' ? diff.governedCategory : null
+ const approvedAction = typeof diff.action === 'string' ? diff.action : null
+ if (approvedCategory !== input.category || approvedAction !== input.action) {
+ return {
+ ...base,
+ allowed: false,
+ reason: `Review ${review.id} does not cover this action — it approved ${approvedCategory ?? 'no governed category'} / ${approvedAction ?? 'no action'}, not ${input.category} / ${input.action}`,
+ }
+ }
+
+ return {
+ ...base,
+ allowed: true,
+ reviewId: review.id,
+ reason: `Approved via fabric review ${review.id}`,
+ }
+}
+
+/**
+ * Creates the correctly-scoped fabric review for a governed action. The
+ * resulting review (once Taylor approves it) is the ONLY thing
+ * evaluateGovernedAction will accept for this exact category+action pair.
+ */
+export function proposeGovernedAction(input: {
+ category: GovernedActionCategory
+ action: string
+ target?: string
+ reason: string
+}) {
+ return createNovaFabricReviewProposal({
+ title: `Governed action: ${input.action}`,
+ reason: input.reason,
+ targetType: 'source-map',
+ proposedDiff: {
+ governedCategory: input.category,
+ action: input.action,
+ ...(input.target ? { target: input.target } : {}),
+ },
+ provenance: 'Mission Control approval governance',
+ riskLevel: 'high',
+ })
+}
diff --git a/src/server/claude-agent.test.ts b/src/server/claude-agent.test.ts
index 34ee5b12c2..881b9c9eb3 100644
--- a/src/server/claude-agent.test.ts
+++ b/src/server/claude-agent.test.ts
@@ -1,10 +1,10 @@
-import { mkdtempSync, mkdirSync, rmSync } from 'node:fs'
+import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { afterEach, describe, expect, it } from 'vitest'
-import { resolveClaudeAgentDir } from './claude-agent'
+import { resolveClaudeAgentDir, resolveClaudeAgentLaunch } from './claude-agent'
const tempDirs: string[] = []
@@ -15,6 +15,16 @@ function createAgentDir(prefix: string): string {
return dir
}
+function createModernHermesAgentDir(prefix: string): string {
+ const dir = mkdtempSync(join(tmpdir(), prefix))
+ mkdirSync(join(dir, 'hermes_cli'), { recursive: true })
+ mkdirSync(join(dir, 'gateway'), { recursive: true })
+ writeFileSync(join(dir, 'hermes_cli', 'main.py'), '')
+ writeFileSync(join(dir, 'gateway', 'run.py'), '')
+ tempDirs.push(dir)
+ return dir
+}
+
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true })
@@ -34,6 +44,25 @@ describe('resolveClaudeAgentDir', () => {
).toBe(hermesAgentDir)
})
+ it('recognizes current Hermes Agent installs that no longer ship webapi', () => {
+ const hermesAgentDir = createModernHermesAgentDir('hermes-agent-modern-')
+
+ expect(
+ resolveClaudeAgentDir({
+ HERMES_AGENT_PATH: hermesAgentDir,
+ }),
+ ).toBe(hermesAgentDir)
+ })
+
+ it('launches a modern Hermes checkout through gateway.run when no CLI binary is available', () => {
+ const hermesAgentDir = createModernHermesAgentDir('hermes-agent-modern-')
+
+ expect(resolveClaudeAgentLaunch(hermesAgentDir, null)).toEqual({
+ command: 'python3',
+ commandArgs: ['-m', 'gateway.run'],
+ })
+ })
+
it('falls back to legacy CLAUDE_AGENT_PATH for backward compatibility', () => {
const legacyAgentDir = createAgentDir('claude-agent-')
diff --git a/src/server/claude-agent.ts b/src/server/claude-agent.ts
index 19bf571f5a..abe2ce1531 100644
--- a/src/server/claude-agent.ts
+++ b/src/server/claude-agent.ts
@@ -1,6 +1,6 @@
-import { spawn } from 'node:child_process'
+import { execFileSync, spawn } from 'node:child_process'
import { existsSync, readFileSync } from 'node:fs'
-import { dirname, join, resolve } from 'node:path'
+import { delimiter, dirname, join, resolve } from 'node:path'
import { homedir } from 'node:os'
const CLAUDE_HEALTH_TIMEOUT_MS = 2_000
@@ -52,6 +52,14 @@ function readClaudeEnv(): Record {
}
}
+function isHermesAgentDir(candidate: string): boolean {
+ return (
+ existsSync(resolve(candidate, 'webapi')) ||
+ (existsSync(resolve(candidate, 'hermes_cli', 'main.py')) &&
+ existsSync(resolve(candidate, 'gateway')))
+ )
+}
+
/** Same directory resolution logic as vite.config.ts. Kept in sync. */
export function resolveClaudeAgentDir(
env: Record = process.env,
@@ -68,11 +76,12 @@ export function resolveClaudeAgentDir(
resolve(workspaceRoot, 'hermes-agent'), // sibling (old README)
resolve(workspaceRoot, '..', 'hermes-agent'), // one level up
resolve(homedir(), '.hermes', 'hermes-agent'), // Nous installer default
+ resolve(homedir(), 'AppData', 'Local', 'hermes', 'hermes-agent'), // Windows installer default
resolve(homedir(), 'hermes-agent'), // ~/hermes-agent
)
for (const candidate of candidates) {
- if (existsSync(resolve(candidate, 'webapi'))) return candidate
+ if (isHermesAgentDir(candidate)) return candidate
}
return null
@@ -80,7 +89,20 @@ export function resolveClaudeAgentDir(
/** Find the `claude` CLI binary installed by Nous's installer (or on PATH). */
export function resolveClaudeBinary(): string | null {
+ const windowsHermesScripts = resolve(
+ homedir(),
+ 'AppData',
+ 'Local',
+ 'hermes',
+ 'hermes-agent',
+ 'venv',
+ 'Scripts',
+ )
const candidates = [
+ resolve(windowsHermesScripts, 'hermes.exe'),
+ resolve(windowsHermesScripts, 'hermes-agent.exe'),
+ resolve(windowsHermesScripts, 'hermes'),
+ resolve(windowsHermesScripts, 'hermes-agent'),
resolve(homedir(), '.local', 'bin', 'hermes'),
resolve(homedir(), '.hermes', 'bin', 'hermes'),
resolve(homedir(), '.claude', 'bin', 'claude'),
@@ -89,10 +111,41 @@ export function resolveClaudeBinary(): string | null {
for (const c of candidates) {
if (existsSync(c)) return c
}
+
+ return resolveBinaryFromPath(['hermes', 'hermes-agent'])
+}
+
+function resolveBinaryFromPath(binaryNames: Array): string | null {
+ const lookup = process.platform === 'win32' ? 'where.exe' : 'which'
+ const names =
+ process.platform === 'win32'
+ ? binaryNames.flatMap((name) => [name, `${name}.exe`])
+ : binaryNames
+
+ for (const name of names) {
+ try {
+ const output = execFileSync(lookup, [name], {
+ encoding: 'utf-8',
+ stdio: ['ignore', 'pipe', 'ignore'],
+ })
+ const first = output
+ .split(/\r?\n/)
+ .map((line) => line.trim())
+ .find(Boolean)
+ if (first && existsSync(first)) return first
+ } catch {
+ // Keep scanning the explicit installer paths below.
+ }
+ }
+
return null
}
export function resolveClaudePython(agentDir: string): string {
+ const windowsVenvPython = resolve(agentDir, 'venv', 'Scripts', 'python.exe')
+ if (existsSync(windowsVenvPython)) return windowsVenvPython
+ const windowsDotVenvPython = resolve(agentDir, '.venv', 'Scripts', 'python.exe')
+ if (existsSync(windowsDotVenvPython)) return windowsDotVenvPython
const venvPython = resolve(agentDir, '.venv', 'bin', 'python')
if (existsSync(venvPython)) return venvPython
const uvVenv = resolve(agentDir, 'venv', 'bin', 'python')
@@ -103,6 +156,38 @@ export function resolveClaudePython(agentDir: string): string {
return 'python3'
}
+export function resolveClaudeAgentLaunch(
+ agentDir: string | null,
+ claudeBin: string | null,
+): { command: string; commandArgs: Array } {
+ if (claudeBin) {
+ return { command: claudeBin, commandArgs: ['gateway', 'run'] }
+ }
+
+ if (!agentDir) {
+ throw new Error('Cannot resolve a Hermes Agent launch command without an installation')
+ }
+
+ const useGatewayRun = existsSync(resolve(agentDir, 'gateway', 'run.py'))
+ return useGatewayRun
+ ? {
+ command: resolveClaudePython(agentDir),
+ commandArgs: ['-m', 'gateway.run'],
+ }
+ : {
+ command: resolveClaudePython(agentDir),
+ commandArgs: [
+ '-m',
+ 'uvicorn',
+ 'webapi.app:app',
+ '--host',
+ '0.0.0.0',
+ '--port',
+ String(CLAUDE_START_PORT),
+ ],
+ }
+}
+
export async function isClaudeAgentHealthy(
port = CLAUDE_START_PORT,
): Promise {
@@ -138,22 +223,11 @@ export async function startClaudeAgent(): Promise {
let commandArgs: Array
let cwd: string | undefined
- if (claudeBin) {
- command = claudeBin
- commandArgs = ['gateway', 'run']
+ if (claudeBin || agentDir) {
+ const launch = resolveClaudeAgentLaunch(agentDir, claudeBin)
+ command = launch.command
+ commandArgs = launch.commandArgs
cwd = agentDir ?? undefined
- } else if (agentDir) {
- command = resolveClaudePython(agentDir)
- commandArgs = [
- '-m',
- 'uvicorn',
- 'webapi.app:app',
- '--host',
- '0.0.0.0',
- '--port',
- String(CLAUDE_START_PORT),
- ]
- cwd = agentDir
} else {
return {
ok: false,
@@ -173,12 +247,15 @@ export async function startClaudeAgent(): Promise {
...process.env,
...claudeEnv,
PATH: [
+ resolve(homedir(), 'AppData', 'Local', 'hermes', 'hermes-agent', 'venv', 'Scripts'),
resolve(homedir(), '.claude', 'bin'),
resolve(homedir(), '.local', 'bin'),
+ agentDir ? resolve(agentDir, '.venv', 'Scripts') : '',
+ agentDir ? resolve(agentDir, 'venv', 'Scripts') : '',
agentDir ? resolve(agentDir, '.venv', 'bin') : '',
agentDir ? resolve(agentDir, 'venv', 'bin') : '',
process.env.PATH || '',
- ].filter(Boolean).join(':'),
+ ].filter(Boolean).join(delimiter),
},
},
)
diff --git a/src/server/control-loop-runner.test.ts b/src/server/control-loop-runner.test.ts
new file mode 100644
index 0000000000..decced3765
--- /dev/null
+++ b/src/server/control-loop-runner.test.ts
@@ -0,0 +1,133 @@
+import fs from 'node:fs'
+import os from 'node:os'
+import path from 'node:path'
+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+import {
+ buildCostRouteWatchResult,
+ buildJobboardCaretakerResult,
+ buildMorningCommandResult,
+ recordControlLoopRun,
+} from './control-loop-runner'
+import { getNovaFabricSnapshot } from './nova-fabric-store'
+
+let tempDir = ''
+let originalFabricFile: string | undefined
+
+beforeEach(() => {
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nova-loop-'))
+ originalFabricFile = process.env.NOVA_FABRIC_FILE
+ process.env.NOVA_FABRIC_FILE = path.join(tempDir, 'nova-fabric.json')
+})
+
+afterEach(() => {
+ if (originalFabricFile === undefined) delete process.env.NOVA_FABRIC_FILE
+ else process.env.NOVA_FABRIC_FILE = originalFabricFile
+ fs.rmSync(tempDir, { recursive: true, force: true })
+})
+
+describe('buildCostRouteWatchResult', () => {
+ it('flags route leaks from real analytics and stays dry-run', () => {
+ const result = buildCostRouteWatchResult({
+ analytics: {
+ windowDays: 7,
+ totalTokens: 5000,
+ topModels: [
+ { id: 'gpt-5.5', tokens: 4000, calls: 20, cost: 0, sessions: 3 },
+ { id: 'claude-opus-4-8', tokens: 1000, calls: 5, cost: 3.4, sessions: 1 },
+ ],
+ estimatedCostUsd: 3.4,
+ costLabel: '$3.40',
+ },
+ })
+ expect(result.ok).toBe(true)
+ expect(result.dryRun).toBe(true)
+ expect(result.findings.some((entry) => /route leak/i.test(entry))).toBe(true)
+ expect(result.refusals.length).toBeGreaterThan(0)
+ })
+
+ it('degrades honestly when analytics are unreadable', () => {
+ const result = buildCostRouteWatchResult({ analytics: null })
+ expect(result.ok).toBe(false)
+ expect(result.summary).toMatch(/unavailable/i)
+ })
+})
+
+describe('buildJobboardCaretakerResult', () => {
+ it('triages stale and blocked cards without touching the board', () => {
+ const result = buildJobboardCaretakerResult({
+ jobBoard: {
+ online: true,
+ version: '2.0',
+ stateKeys: 12,
+ events: 4,
+ taylorKanbanItems: 6,
+ taylorKanban: {
+ items: 6,
+ open: 4,
+ stale: 2,
+ blockers: 1,
+ topTitles: ['Order 1487 patches'],
+ },
+ },
+ })
+ expect(result.ok).toBe(true)
+ expect(result.findings.some((entry) => /2 stale/i.test(entry))).toBe(true)
+ expect(result.findings.some((entry) => /1 blocked/i.test(entry))).toBe(true)
+ expect(
+ result.refusals.some((entry) => /state\.json|write/i.test(entry)),
+ ).toBe(true)
+ })
+
+ it('reports the board offline honestly', () => {
+ const result = buildJobboardCaretakerResult({
+ jobBoard: {
+ online: false,
+ version: null,
+ stateKeys: 0,
+ events: 0,
+ taylorKanbanItems: 0,
+ taylorKanban: null,
+ },
+ })
+ expect(result.ok).toBe(false)
+ })
+})
+
+describe('buildMorningCommandResult', () => {
+ it('composes a morning brief from every real source it was given', () => {
+ const result = buildMorningCommandResult({
+ gatewayReachable: true,
+ approvals: { pending: 2, actionable: 1, degraded: false },
+ cron: { total: 4, failed: 1, paused: 0, nextRunAt: null },
+ jobBoard: {
+ online: true,
+ version: '2.0',
+ stateKeys: 3,
+ events: 1,
+ taylorKanbanItems: 2,
+ taylorKanban: { items: 2, open: 2, stale: 0, blockers: 0, topTitles: [] },
+ },
+ googleConnected: false,
+ })
+ expect(result.ok).toBe(true)
+ expect(result.findings.some((entry) => /2 .*waiting on taylor/i.test(entry))).toBe(true)
+ expect(result.findings.some((entry) => /1 cron/i.test(entry))).toBe(true)
+ expect(result.findings.some((entry) => /google/i.test(entry))).toBe(true)
+ expect(result.refusals.some((entry) => /email|send/i.test(entry))).toBe(true)
+ })
+})
+
+describe('recordControlLoopRun', () => {
+ it('writes a work receipt for the dry run into the fabric', () => {
+ const result = buildCostRouteWatchResult({ analytics: null })
+ const receipt = recordControlLoopRun('cost-route-watch', result)
+ expect(receipt.eventKind).toBe('work-receipt')
+ const snapshot = getNovaFabricSnapshot()
+ expect(
+ snapshot.fabric.events.some(
+ (event) =>
+ event.id === receipt.id && event.title.includes('cost-route-watch'),
+ ),
+ ).toBe(true)
+ })
+})
diff --git a/src/server/control-loop-runner.ts b/src/server/control-loop-runner.ts
new file mode 100644
index 0000000000..185a5a8d08
--- /dev/null
+++ b/src/server/control-loop-runner.ts
@@ -0,0 +1,331 @@
+import { evaluateGovernedAction } from './approval-governance'
+import {
+ buildRouteCostAnomalies,
+ normalizeCron,
+ readJobBoardModel,
+} from './dashboard-aggregator'
+import { dashboardFetch } from './gateway-capabilities'
+import { appendNovaFabricEvent } from './nova-fabric-store'
+import { getTaylorApprovalQueue } from './taylor-approval-queue'
+import type { NovaFabricEventRecord } from './nova-fabric-store'
+import type { DashboardAnalyticsSection } from './dashboard-aggregator'
+
+export const RUNNABLE_CONTROL_LOOPS = [
+ 'cost-route-watch',
+ 'morning-command-center',
+ 'jobboard-caretaker',
+] as const
+
+export type RunnableControlLoopId = (typeof RUNNABLE_CONTROL_LOOPS)[number]
+
+export type ControlLoopRunResult = {
+ loopId: RunnableControlLoopId
+ dryRun: true
+ ok: boolean
+ summary: string
+ findings: Array
+ refusals: Array
+ sources: Array<{ label: string; state: 'ok' | 'unavailable'; detail: string }>
+}
+
+// Minimal structural inputs so the per-loop builders stay pure and testable.
+
+type AnalyticsInput = {
+ windowDays: number
+ totalTokens: number
+ topModels: Array<{
+ id: string
+ tokens: number
+ calls: number
+ cost: number
+ sessions: number
+ }>
+ estimatedCostUsd: number | null
+ costLabel: string
+} | null
+
+type JobBoardInput = {
+ online: boolean
+ version: string | null
+ stateKeys: number
+ events: number
+ taylorKanbanItems: number
+ taylorKanban: {
+ items: number
+ open: number
+ stale: number
+ blockers: number
+ topTitles: Array
+ } | null
+}
+
+function refusalFor(
+ category: Parameters[0]['category'],
+ action: string,
+): string {
+ const verdict = evaluateGovernedAction({ category, action })
+ return `Refused without approval: ${action} — ${verdict.reason}`
+}
+
+export function buildCostRouteWatchResult(input: {
+ analytics: AnalyticsInput
+}): ControlLoopRunResult {
+ const refusals = [
+ refusalFor('provider-config', 'change model routing'),
+ refusalFor('spending', 'buy additional credits'),
+ ]
+ if (!input.analytics) {
+ return {
+ loopId: 'cost-route-watch',
+ dryRun: true,
+ ok: false,
+ summary: 'Analytics source unavailable — no cost data to watch',
+ findings: [],
+ refusals,
+ sources: [
+ { label: 'analytics', state: 'unavailable', detail: '/api/analytics/usage failed' },
+ ],
+ }
+ }
+ const anomalies = buildRouteCostAnomalies(
+ input.analytics as unknown as DashboardAnalyticsSection,
+ )
+ const findings =
+ anomalies.length > 0
+ ? anomalies
+ : [
+ `No route leaks: ${input.analytics.totalTokens} tokens across ${input.analytics.topModels.length} model${input.analytics.topModels.length === 1 ? '' : 's'} in ${input.analytics.windowDays}d, cost ${input.analytics.estimatedCostUsd === null ? input.analytics.costLabel : `$${input.analytics.estimatedCostUsd.toFixed(2)}`}`,
+ ]
+ return {
+ loopId: 'cost-route-watch',
+ dryRun: true,
+ ok: true,
+ summary:
+ anomalies.length > 0
+ ? `${anomalies.length} route anomal${anomalies.length === 1 ? 'y' : 'ies'} found`
+ : 'Routes clean',
+ findings,
+ refusals,
+ sources: [
+ {
+ label: 'analytics',
+ state: 'ok',
+ detail: `/api/analytics/usage · ${input.analytics.windowDays}d window`,
+ },
+ ],
+ }
+}
+
+export function buildJobboardCaretakerResult(input: {
+ jobBoard: JobBoardInput
+}): ControlLoopRunResult {
+ const refusals = [
+ refusalFor('jobboard-write', 'mutate job board state (state.json is never edited directly)'),
+ refusalFor('customer-vendor', 'notify a customer about an order'),
+ ]
+ if (!input.jobBoard.online) {
+ return {
+ loopId: 'jobboard-caretaker',
+ dryRun: true,
+ ok: false,
+ summary: 'Job board unreachable over HTTP',
+ findings: [],
+ refusals,
+ sources: [
+ { label: 'job board', state: 'unavailable', detail: '/ping failed' },
+ ],
+ }
+ }
+ const kanban = input.jobBoard.taylorKanban
+ const findings: Array = []
+ if (kanban) {
+ findings.push(
+ `${kanban.open} open card${kanban.open === 1 ? '' : 's'}; ${kanban.stale} stale (>7d); ${kanban.blockers} blocked`,
+ )
+ for (const title of kanban.topTitles.slice(0, 3)) {
+ findings.push(`Top card: ${title}`)
+ }
+ } else {
+ findings.push('Board answered but the Taylor kanban could not be parsed')
+ }
+ return {
+ loopId: 'jobboard-caretaker',
+ dryRun: true,
+ ok: true,
+ summary: kanban
+ ? `${kanban.items} kanban item${kanban.items === 1 ? '' : 's'} triaged read-only`
+ : 'Board reachable; kanban unparsed',
+ findings,
+ refusals,
+ sources: [
+ {
+ label: 'job board',
+ state: 'ok',
+ detail: `HTTP /ping + /state · v${input.jobBoard.version ?? 'unknown'}`,
+ },
+ ],
+ }
+}
+
+export function buildMorningCommandResult(input: {
+ gatewayReachable: boolean
+ approvals: { pending: number; actionable: number; degraded: boolean } | null
+ cron: { total: number; failed: number; paused: number; nextRunAt: string | null } | null
+ jobBoard: JobBoardInput | null
+ googleConnected: boolean
+}): ControlLoopRunResult {
+ const refusals = [
+ refusalFor('external-message', 'send the morning email digest'),
+ refusalFor('jobboard-write', 'auto-triage kanban cards'),
+ ]
+ const findings: Array = []
+ const sources: ControlLoopRunResult['sources'] = []
+
+ findings.push(
+ input.gatewayReachable ? 'Hermes gateway up' : 'Hermes gateway DOWN',
+ )
+ sources.push({
+ label: 'gateway',
+ state: input.gatewayReachable ? 'ok' : 'unavailable',
+ detail: 'health probe',
+ })
+
+ if (input.approvals) {
+ findings.push(
+ `${input.approvals.pending} item${input.approvals.pending === 1 ? '' : 's'} waiting on Taylor (${input.approvals.actionable} actionable in Mission Control)`,
+ )
+ }
+ sources.push({
+ label: 'approvals',
+ state: input.approvals ? 'ok' : 'unavailable',
+ detail: '/api/taylor-approvals',
+ })
+
+ if (input.cron) {
+ findings.push(
+ input.cron.failed > 0
+ ? `${input.cron.failed} cron job${input.cron.failed === 1 ? '' : 's'} failing of ${input.cron.total}`
+ : `${input.cron.total} cron jobs healthy`,
+ )
+ }
+ sources.push({
+ label: 'cron',
+ state: input.cron ? 'ok' : 'unavailable',
+ detail: '/api/cron/jobs',
+ })
+
+ if (input.jobBoard?.online && input.jobBoard.taylorKanban) {
+ findings.push(
+ `Job board: ${input.jobBoard.taylorKanban.open} open, ${input.jobBoard.taylorKanban.blockers} blocked`,
+ )
+ }
+ sources.push({
+ label: 'job board',
+ state: input.jobBoard?.online ? 'ok' : 'unavailable',
+ detail: 'HTTP endpoints',
+ })
+
+ findings.push(
+ input.googleConnected
+ ? 'Google Workspace connected (reads only; sends gated)'
+ : 'Google Workspace not connected — inbox/calendar unavailable for the brief',
+ )
+
+ const ok = input.gatewayReachable
+ return {
+ loopId: 'morning-command-center',
+ dryRun: true,
+ ok,
+ summary: ok
+ ? `Morning brief assembled from ${sources.filter((s) => s.state === 'ok').length}/${sources.length} sources`
+ : 'Morning brief degraded — gateway down',
+ findings,
+ refusals,
+ sources,
+ }
+}
+
+/** Every dry run leaves a verifiable work receipt in the fabric. */
+export function recordControlLoopRun(
+ loopId: RunnableControlLoopId,
+ result: ControlLoopRunResult,
+): NovaFabricEventRecord {
+ return appendNovaFabricEvent({
+ title: `Control loop dry run: ${loopId}`,
+ summary: `${result.summary}. Findings: ${result.findings.slice(0, 3).join(' | ') || 'none'}. Refused ${result.refusals.length} external write${result.refusals.length === 1 ? '' : 's'} pending approval.`,
+ eventKind: 'work-receipt',
+ provenance: 'Mission Control /api/control-loops/run (server-side dry run)',
+ riskLevel: 'low',
+ verificationState: 'tool-verified',
+ receiptLinks: [
+ { label: 'loop', value: loopId, kind: 'receipt' },
+ ],
+ })
+}
+
+async function tryOrNull(fn: () => Promise | T): Promise {
+ try {
+ return await fn()
+ } catch {
+ return null
+ }
+}
+
+/** Gathers real sources and executes one loop as a server-side dry run. */
+export async function runControlLoopDry(
+ loopId: RunnableControlLoopId,
+): Promise<{ result: ControlLoopRunResult; receiptId: string | null }> {
+ let result: ControlLoopRunResult
+ if (loopId === 'cost-route-watch') {
+ const analytics = await tryOrNull(async () => {
+ const res = await dashboardFetch('/api/analytics/usage?days=7')
+ if (!res.ok) throw new Error(`analytics ${res.status}`)
+ return res.json() as Promise
+ })
+ result = buildCostRouteWatchResult({
+ analytics: analytics && typeof analytics === 'object' ? analytics : null,
+ })
+ } else if (loopId === 'jobboard-caretaker') {
+ const jobBoard = await readJobBoardModel()
+ result = buildJobboardCaretakerResult({ jobBoard })
+ } else {
+ const [health, cronRaw, jobBoard] = await Promise.all([
+ tryOrNull(async () => {
+ const res = await dashboardFetch('/api/status')
+ return res.ok
+ }),
+ tryOrNull(async () => {
+ const res = await dashboardFetch('/api/cron/jobs')
+ if (!res.ok) throw new Error(`cron ${res.status}`)
+ return res.json() as Promise
+ }),
+ tryOrNull(() => readJobBoardModel()),
+ ])
+ const approvals = await tryOrNull(() => {
+ const queue = getTaylorApprovalQueue()
+ return {
+ pending: queue.counts.total,
+ actionable: queue.counts.actionable,
+ degraded: queue.degraded,
+ }
+ })
+ const cron = cronRaw === null ? null : normalizeCron(cronRaw)
+ result = buildMorningCommandResult({
+ gatewayReachable: health === true,
+ approvals,
+ cron: cron
+ ? {
+ total: cron.total,
+ failed: cron.failed,
+ paused: cron.paused,
+ nextRunAt: cron.nextRunAt,
+ }
+ : null,
+ jobBoard,
+ googleConnected: false,
+ })
+ }
+
+ const receipt = await tryOrNull(() => recordControlLoopRun(loopId, result))
+ return { result, receiptId: receipt?.id ?? null }
+}
diff --git a/src/server/dashboard-aggregator.test.ts b/src/server/dashboard-aggregator.test.ts
index f8cab15855..7fd59cf165 100644
--- a/src/server/dashboard-aggregator.test.ts
+++ b/src/server/dashboard-aggregator.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
-import { buildDashboardOverview } from './dashboard-aggregator'
+import { buildAgentWorkforceSection, buildDashboardOverview, buildGitWorkSection } from './dashboard-aggregator'
import type { DashboardFetcher } from './dashboard-aggregator'
function jsonResponse(payload: unknown, status = 200): Response {
@@ -512,4 +512,183 @@ describe('buildDashboardOverview', () => {
expect(overview.modelInfo).toBeNull()
expect(overview.analytics).toBeNull()
})
+
+ it('builds a live systems wiring map from real overview sources', async () => {
+ const fetcher = makeFetcher({
+ '/api/status': {
+ gateway_state: 'running',
+ active_agents: 3,
+ updated_at: '2026-07-08T15:00:00Z',
+ platforms: {
+ api_server: { state: 'connected', updated_at: '2026-07-08T15:00:00Z' },
+ telegram: { state: 'error', error_message: 'token expired' },
+ },
+ },
+ '/api/model/info': {
+ provider: 'openai-codex',
+ model: 'gpt-5.5',
+ },
+ '/api/cron/jobs': {
+ jobs: [
+ { id: 'ok', state: 'scheduled', next_run_at: '2026-07-09T12:00:00Z' },
+ { id: 'bad', state: 'scheduled', last_status: 'failed', last_error: 'boom' },
+ ],
+ },
+ '/api/plugins/kanban/board': {
+ columns: [{ id: 'done' }],
+ cards: [{ id: 'a', columnId: 'done', title: 'done card' }],
+ },
+ '/api/analytics/usage': {
+ period_days: 30,
+ totals: {
+ total_input: 100,
+ total_output: 50,
+ total_sessions: 1,
+ total_api_calls: 2,
+ estimated_cost: 0.12,
+ },
+ daily: [],
+ },
+ '/api/skills': {
+ skills: [{ name: 'morning-command-center' }],
+ },
+ '/api/providers/oauth': {
+ providers: [
+ { name: 'google-gmail', status: 'connected', connected: true },
+ { name: 'google-calendar', status: 'connected', connected: true },
+ ],
+ },
+ })
+
+ const overview = await buildDashboardOverview({ fetcher })
+ const systems = new Map(
+ overview.liveSystems.systems.map((system) => [system.id, system]),
+ )
+
+ expect(systems.get('hermes-gateway')?.status).toBe('degraded')
+ expect(systems.get('model-route')?.detail).toContain('gpt-5.5')
+ expect(systems.get('tools-skills')?.status).toBe('operational')
+ expect(systems.get('cron-background')?.status).toBe('degraded')
+ // Google reads are proven but sends/schedules stay behind Taylor approval.
+ expect(systems.get('google-workspace')?.status).toBe('approval-gated')
+ const gitWorkSystem = systems.get('github-agent-work')
+ expect(['operational', 'not-wired']).toContain(gitWorkSystem?.status)
+ if (gitWorkSystem?.status === 'operational') {
+ expect(gitWorkSystem.detail).toContain('receipts via /api/nova-work-scan')
+ }
+ expect(overview.liveSystems.summary.total).toBe(
+ overview.liveSystems.systems.length,
+ )
+ expect(overview.liveSystems.blockers.length).toBeGreaterThan(0)
+ })
+
+
+ it('builds read-only agent workforce status from swarm missions', () => {
+ const workforce = buildAgentWorkforceSection([
+ {
+ id: 'mission-1',
+ title: 'Wire Mission Control',
+ state: 'executing',
+ createdAt: 1_700_000_000_000,
+ updatedAt: 1_700_000_010_000,
+ events: [],
+ assignments: [
+ {
+ id: 'a1',
+ workerId: 'builder',
+ task: 'Implement Live Systems',
+ rationale: null,
+ dependsOn: [],
+ reviewRequired: false,
+ state: 'dispatched',
+ dispatchedAt: 1_700_000_005_000,
+ completedAt: null,
+ reviewedAt: null,
+ reviewedBy: null,
+ checkpoint: null,
+ },
+ {
+ id: 'a2',
+ workerId: 'reviewer',
+ task: 'Review Fabric receipts',
+ rationale: null,
+ dependsOn: [],
+ reviewRequired: true,
+ state: 'checkpointed',
+ dispatchedAt: 1_700_000_003_000,
+ completedAt: 1_700_000_009_000,
+ reviewedAt: null,
+ reviewedBy: null,
+ checkpoint: null,
+ },
+ {
+ id: 'a3',
+ workerId: 'ops-watch',
+ task: 'Check blockers',
+ rationale: null,
+ dependsOn: [],
+ reviewRequired: false,
+ state: 'blocked',
+ dispatchedAt: 1_700_000_002_000,
+ completedAt: 1_700_000_004_000,
+ reviewedAt: null,
+ reviewedBy: null,
+ checkpoint: null,
+ },
+ ],
+ },
+ ])
+
+ expect(workforce.summary).toMatchObject({
+ missions: 1,
+ workers: 3,
+ active: 3,
+ blocked: 1,
+ reviewing: 1,
+ })
+ expect(workforce.workers[0]).toMatchObject({
+ workerId: 'ops-watch',
+ status: 'blocked',
+ currentTask: 'Check blockers',
+ missionId: 'mission-1',
+ })
+ expect(workforce.recentMissions[0]).toMatchObject({
+ id: 'mission-1',
+ assignments: 3,
+ blocked: 1,
+ reviewing: 1,
+ })
+ })
+
+
+ it('builds read-only git work status from local git output', () => {
+ const gitWork = buildGitWorkSection({
+ statusPorcelain: '## feature/nova-skin...fork/feature/nova-skin [ahead 2, behind 1]\n M src/server/dashboard-aggregator.ts\n?? src/screens/dashboard/components/github-work-card.tsx',
+ remotes:
+ 'origin\thttps://github.com/outsourc-e/hermes-workspace (fetch)\norigin\thttps://github.com/outsourc-e/hermes-workspace (push)\nfork\thttps://github.com/goodmorningmrj/hermes-workspace (fetch)\nfork\thttps://github.com/goodmorningmrj/hermes-workspace (push)',
+ latestCommit: '8992e63\tfeat(nova): add read-only agent workforce panel',
+ upstream: 'fork/feature/nova-skin',
+ prUrl: 'https://github.com/outsourc-e/hermes-workspace/pull/709',
+ })
+
+ expect(gitWork).toMatchObject({
+ branch: 'feature/nova-skin',
+ clean: false,
+ ahead: 2,
+ behind: 1,
+ changedFiles: 2,
+ upstream: 'fork/feature/nova-skin',
+ prUrl: 'https://github.com/outsourc-e/hermes-workspace/pull/709',
+ })
+ expect(gitWork.latestCommit).toMatchObject({
+ hash: '8992e63',
+ subject: 'feat(nova): add read-only agent workforce panel',
+ })
+ expect(gitWork.remotes).toHaveLength(2)
+ expect(gitWork.remotes[1]).toMatchObject({
+ name: 'fork',
+ pushUrl: 'https://github.com/goodmorningmrj/hermes-workspace',
+ })
+ })
+
})
diff --git a/src/server/dashboard-aggregator.ts b/src/server/dashboard-aggregator.ts
index e0ea3d5046..404552b50b 100644
--- a/src/server/dashboard-aggregator.ts
+++ b/src/server/dashboard-aggregator.ts
@@ -1,3 +1,10 @@
+import { execFileSync } from 'node:child_process'
+import { buildKnowledgeGraph, knowledgeRootExists } from './knowledge-browser'
+import { listMemoryFiles } from './memory-browser'
+import { getTaylorApprovalQueue } from './taylor-approval-queue'
+import { listSwarmMissions } from './swarm-missions'
+import type { SwarmMission, SwarmMissionAssignment } from './swarm-missions'
+
/**
* Aggregator for the Workspace dashboard overview.
*
@@ -31,8 +38,240 @@ export type DashboardOverview = {
insights: Array
/** Aggregated triage list: failed crons + platform errors + log errors. */
incidents: Array
+ /** Source-backed capability maturity ledger for Nova/Hermes. */
+ trustLedger: DashboardTrustLedgerSection | null
+ /** Operational productivity loops with real source readiness. */
+ controlLoops: DashboardControlLoopsSection | null
+ /** Manual NotebookLM synthesis bridge, when Obsidian data is available. */
+ notebookBridge: DashboardNotebookBridgeSection | null
+ /** Live source-of-truth status for wiring Nova into Mission Control. */
+ liveSystems: DashboardLiveSystemsSection
+ /** Read-only agent workforce status derived from swarm mission receipts. */
+ agentWorkforce: DashboardAgentWorkforceSection
+ /** Read-only local Git/GitHub branch status for the current workspace. */
+ gitWork: DashboardGitWorkSection
+}
+
+export type ControlLoopStatus = 'ready' | 'partial' | 'not-wired'
+
+/**
+ * Truth taxonomy for Mission Control panels:
+ * - `operational` — connected to real data AND healthy.
+ * - `connected` — real data parsed from the source, but nothing actionable yet.
+ * - `reachable` — the source answered, but no real data could be proven.
+ * - `approval-gated` — connected, with items or writes waiting on Taylor.
+ * - `degraded` — connected but reporting errors/anomalies.
+ * - `offline` — probed and down.
+ * - `not-wired` — no source configured or responding.
+ */
+export type LiveSystemStatus =
+ | 'operational'
+ | 'connected'
+ | 'reachable'
+ | 'approval-gated'
+ | 'degraded'
+ | 'offline'
+ | 'not-wired'
+
+export type AgentWorkerStatus =
+ | 'idle'
+ | 'queued'
+ | 'running'
+ | 'blocked'
+ | 'reviewing'
+ | 'done'
+
+export type DashboardAgentWorker = {
+ workerId: string
+ status: AgentWorkerStatus
+ activeAssignments: number
+ completedAssignments: number
+ blockedAssignments: number
+ reviewAssignments: number
+ currentTask: string | null
+ missionId: string | null
+ lastSeenAt: string | null
+}
+
+export type DashboardAgentWorkforceMission = {
+ id: string
+ title: string
+ state: string
+ updatedAt: string
+ assignments: number
+ blocked: number
+ reviewing: number
+}
+
+export type DashboardAgentWorkforceSection = {
+ generatedAt: string
+ summary: {
+ missions: number
+ workers: number
+ active: number
+ blocked: number
+ reviewing: number
+ done: number
+ }
+ workers: Array
+ recentMissions: Array
+}
+
+export type DashboardGitRemote = {
+ name: string
+ fetchUrl: string | null
+ pushUrl: string | null
+}
+
+export type DashboardGitWorkSection = {
+ branch: string | null
+ clean: boolean
+ ahead: number
+ behind: number
+ changedFiles: number
+ upstream: string | null
+ latestCommit: {
+ hash: string
+ subject: string
+ } | null
+ remotes: Array
+ prUrl: string | null
+ warning: string | null
+}
+
+export type DashboardLiveSystem = {
+ id: string
+ label: string
+ status: LiveSystemStatus
+ detail: string
+ href: string | null
+ lastSeenAt: string | null
+ source: string
+}
+
+export type DashboardLiveSystemsSection = {
+ generatedAt: string
+ summary: {
+ total: number
+ operational: number
+ connected: number
+ reachable: number
+ approvalGated: number
+ degraded: number
+ offline: number
+ notWired: number
+ }
+ blockers: Array
+ systems: Array
+}
+
+export type ControlLoopSourceStatus =
+ | 'connected'
+ | 'available'
+ | 'not-wired'
+ | 'unavailable'
+
+export type DashboardControlLoopSource = {
+ label: string
+ status: ControlLoopSourceStatus
+ detail: string
+}
+
+export type DashboardControlLoop = {
+ id:
+ | 'morning-command-center'
+ | 'email-to-taylor-kanban'
+ | 'jobboard-caretaker'
+ | 'design-intake-triage'
+ | 'cost-route-watch'
+ | 'shutdown-routine'
+ label: string
+ purpose: string
+ connectedSystems: Array
+ status: ControlLoopStatus
+ readiness: string
+ lastUsedAt: string | null
+ nextScheduledAt: string | null
+ dryRunPrompt: string
+ guardrails: Array
+ sourceLinks: Array
+}
+
+export type DashboardControlLoopsSection = {
+ generatedAt: string
+ summary: {
+ total: number
+ ready: number
+ partial: number
+ notWired: number
+ }
+ riskGates: Array
+ loops: Array
}
+export type TrustLedgerStatus =
+ | 'Unwired'
+ | 'Tested'
+ | 'Reliable'
+ | 'Trusted'
+ | 'Autonomous'
+
+export type DashboardTrustLedgerSourceLink = {
+ label: string
+ href: string
+}
+
+export type DashboardTrustLedgerMilestone = {
+ id: string
+ label: string
+ status: TrustLedgerStatus
+ progress: {
+ current: number
+ target: number
+ unit: string
+ }
+ summary: string
+ evidence: Array
+ weakArea: string | null
+ sourceLinks: Array
+ updatedAt: string | null
+}
+
+export type DashboardTrustLedgerSection = {
+ score: number
+ highestStatus: TrustLedgerStatus
+ milestones: Array
+ recentUnlocks: Array
+ weakAreas: Array
+ counters: {
+ milestones: number
+ reliableOrBetter: number
+ trustedOrBetter: number
+ autonomous: number
+ }
+}
+
+export type NotebookBridgeStageStatus = 'Ready' | 'Manual' | 'Draft' | 'Blocked'
+
+export type DashboardNotebookBridgeStage = {
+ id: string
+ label: string
+ status: NotebookBridgeStageStatus
+ summary: string
+ sourceLinks: Array
+}
+
+export type DashboardNotebookBridgeSection = {
+ mode: 'manual-approval'
+ canonicalSource: string
+ synthesisLayer: string
+ writebackTargets: Array
+ vaultNotes: number
+ vaultLinks: number
+ lastVaultUpdate: string | null
+ guardrails: Array
+ stages: Array
+}
export type DashboardSkillsUsageSection = {
totalLoads: number
totalEdits: number
@@ -174,7 +413,13 @@ export type DashboardAnalyticsSection = {
totalSessions: number
/** API call count over the window. */
totalApiCalls: number
- topModels: Array<{ id: string; tokens: number; calls: number; cost: number; sessions: number }>
+ topModels: Array<{
+ id: string
+ tokens: number
+ calls: number
+ cost: number
+ sessions: number
+ }>
/**
* Per-day rollup for sparklines. ISO date string + tokens + sessions
* + cost per day. Always returned, even when empty.
@@ -250,6 +495,26 @@ async function safeJson(
}
}
+async function safeUrlJson(
+ url: string,
+ timeoutMs = 2500,
+): Promise {
+ const controller = new AbortController()
+ const timeout = setTimeout(() => controller.abort(), timeoutMs)
+ try {
+ const res = await fetch(url, {
+ signal: controller.signal,
+ headers: { accept: 'application/json' },
+ })
+ if (!res.ok) return null
+ return (await res.json()) as T
+ } catch {
+ return null
+ } finally {
+ clearTimeout(timeout)
+ }
+}
+
function readString(value: unknown): string {
return typeof value === 'string' ? value : ''
}
@@ -339,14 +604,14 @@ function normalizePlatforms(raw: unknown): Array {
.filter((entry): entry is DashboardPlatformEntry => entry !== null)
}
-function normalizeCron(raw: unknown): DashboardCronSection | null {
+export function normalizeCron(raw: unknown): DashboardCronSection | null {
if (!raw) return null
- let jobs: Array> = []
+ let jobs: Array = []
if (Array.isArray(raw)) {
- jobs = raw as Array>
- } else if (raw && typeof raw === 'object') {
+ jobs = raw
+ } else if (typeof raw === 'object') {
const r = raw as Record
- if (Array.isArray(r.jobs)) jobs = r.jobs as Array>
+ if (Array.isArray(r.jobs)) jobs = r.jobs
}
if (!Array.isArray(jobs)) return null
@@ -366,7 +631,7 @@ function normalizeCron(raw: unknown): DashboardCronSection | null {
typeof j.last_error === 'string'
? j.last_error
: typeof j.last_delivery_error === 'string'
- ? (j.last_delivery_error)
+ ? j.last_delivery_error
: null
const isFailure =
lastStatus === 'failed' ||
@@ -376,16 +641,13 @@ function normalizeCron(raw: unknown): DashboardCronSection | null {
failed += 1
const id = readString(j.id) || readString(j.name) || 'unknown'
const name = readString(j.name) || id
- const lastRunAt =
- typeof j.last_run_at === 'string' ? j.last_run_at : null
+ const lastRunAt = typeof j.last_run_at === 'string' ? j.last_run_at : null
recentFailures.push({ id, name, lastError, lastRunAt })
}
const candidates = [
typeof j.next_run_at === 'string' ? Date.parse(j.next_run_at) : NaN,
typeof j.next_run === 'string' ? Date.parse(j.next_run) : NaN,
- typeof j.next_run_at === 'number'
- ? (j.next_run_at) * 1000
- : NaN,
+ typeof j.next_run_at === 'number' ? j.next_run_at * 1000 : NaN,
].filter((v) => Number.isFinite(v))
for (const ts of candidates) {
if (nextRunMs === null || ts < nextRunMs) nextRunMs = ts
@@ -419,7 +681,9 @@ function normalizeKanban(raw: unknown): DashboardKanbanSection | null {
topBlocked: [],
}
- const bucketFor = (status: string): keyof Pick<
+ const bucketFor = (
+ status: string,
+ ): keyof Pick<
DashboardKanbanSection,
'triage' | 'todo' | 'ready' | 'running' | 'blocked' | 'done' | 'other'
> => {
@@ -427,7 +691,8 @@ function normalizeKanban(raw: unknown): DashboardKanbanSection | null {
if (s === 'triage') return 'triage'
if (s === 'todo' || s === 'queued') return 'todo'
if (s === 'ready') return 'ready'
- if (s === 'running' || s === 'claimed' || s === 'in_progress') return 'running'
+ if (s === 'running' || s === 'claimed' || s === 'in_progress')
+ return 'running'
if (s === 'blocked') return 'blocked'
if (s === 'done' || s === 'completed' || s === 'complete') return 'done'
return 'other'
@@ -472,8 +737,7 @@ function normalizeAchievementUnlock(
category: readString(r.category) || 'General',
icon: readString(r.icon) || 'Star',
tier: typeof r.tier === 'string' ? r.tier : null,
- unlockedAt:
- typeof r.unlocked_at === 'number' ? (r.unlocked_at) : null,
+ unlockedAt: typeof r.unlocked_at === 'number' ? r.unlocked_at : null,
}
}
@@ -486,9 +750,7 @@ function normalizeAchievements(
if (recentArr.length === 0 && (!all || typeof all !== 'object')) return null
const recentUnlocks = recentArr
.map(normalizeAchievementUnlock)
- .filter(
- (entry): entry is DashboardAchievementUnlock => entry !== null,
- )
+ .filter((entry): entry is DashboardAchievementUnlock => entry !== null)
.slice(0, limit)
let totalUnlocked = 0
@@ -546,14 +808,13 @@ function normalizeSkillsUsage(
skill,
totalCount: readNumber(e.total_count),
percentage: readNumber(e.percentage),
- lastUsedAt:
- typeof e.last_used_at === 'number'
- ? (e.last_used_at)
- : null,
+ lastUsedAt: typeof e.last_used_at === 'number' ? e.last_used_at : null,
}
})
.filter(
- (e): e is {
+ (
+ e,
+ ): e is {
skill: string
totalCount: number
percentage: number
@@ -562,10 +823,7 @@ function normalizeSkillsUsage(
)
.sort((a, b) => b.totalCount - a.totalCount)
.slice(0, 5)
- if (
- !summary &&
- topSkills.length === 0
- ) {
+ if (!summary && topSkills.length === 0) {
return null
}
return {
@@ -598,9 +856,7 @@ function normalizeAnalytics(
totalsRaw?.total_output ?? r.total_output ?? r.output_tokens,
)
const cacheReadTokens = readNumber(
- totalsRaw?.total_cache_read ??
- r.total_cache_read ??
- r.cache_read_tokens,
+ totalsRaw?.total_cache_read ?? r.total_cache_read ?? r.cache_read_tokens,
)
const reasoningTokens = readNumber(
totalsRaw?.total_reasoning ?? r.total_reasoning ?? r.reasoning_tokens,
@@ -628,9 +884,7 @@ function normalizeAnalytics(
// reasoning are exposed separately for the rich UI.
const fallbackTotal = readNumber(r.total_tokens)
const totalTokens =
- inputTokens + outputTokens > 0
- ? inputTokens + outputTokens
- : fallbackTotal
+ inputTokens + outputTokens > 0 ? inputTokens + outputTokens : fallbackTotal
const modelsRaw = Array.isArray(r.by_model)
? r.by_model
@@ -649,14 +903,19 @@ function normalizeAnalytics(
const tokensOut = readNumber(e.output_tokens)
return {
id,
- tokens: tokensIn + tokensOut > 0 ? tokensIn + tokensOut : readNumber(e.tokens),
+ tokens:
+ tokensIn + tokensOut > 0
+ ? tokensIn + tokensOut
+ : readNumber(e.tokens),
calls: readNumber(e.api_calls ?? e.calls ?? e.requests),
cost: readNumber(e.estimated_cost ?? e.cost),
sessions: readNumber(e.sessions),
}
})
.filter(
- (entry): entry is {
+ (
+ entry,
+ ): entry is {
id: string
tokens: number
calls: number
@@ -686,7 +945,9 @@ function normalizeAnalytics(
}
})
.filter(
- (entry): entry is {
+ (
+ entry,
+ ): entry is {
day: string
inputTokens: number
outputTokens: number
@@ -873,16 +1134,12 @@ function computeInsights(
// glance.
const ops: Array = []
if (cron && cron.failed > 0) {
- ops.push(
- `${cron.failed} failed cron job${cron.failed === 1 ? '' : 's'}`,
- )
+ ops.push(`${cron.failed} failed cron job${cron.failed === 1 ? '' : 's'}`)
}
if (cron && cron.nextRunAt) {
const nextMs = Date.parse(cron.nextRunAt)
if (Number.isFinite(nextMs) && nextMs - Date.now() < -7 * 86_400_000) {
- ops.push(
- `${cron.total} stale cron job${cron.total === 1 ? '' : 's'}`,
- )
+ ops.push(`${cron.total} stale cron job${cron.total === 1 ? '' : 's'}`)
}
}
if (
@@ -902,7 +1159,7 @@ function computeInsights(
if (ops.length > 0) {
out.push({
tone: ops.length >= 2 ? 'warn' : 'info',
- text: ops.join(' · ') + '.',
+ text: ops.join(' · ') + '.',
})
}
@@ -976,7 +1233,9 @@ function computeIncidents(
severity: 'warn',
source: 'kanban',
label: `${kanban.blocked} kanban task${kanban.blocked === 1 ? '' : 's'} blocked`,
- detail: kanban.topBlocked.map((t) => t.title).join(' · ') || 'blocked cards need attention',
+ detail:
+ kanban.topBlocked.map((t) => t.title).join(' · ') ||
+ 'blocked cards need attention',
href: '/swarm2',
})
}
@@ -1007,7 +1266,7 @@ function computeIncidents(
severity: 'warn',
source: 'config',
label: `${diff} config diff${diff === 1 ? '' : 's'} pending`,
- detail: `local v${status.configVersion} · latest v${status.latestConfigVersion}`,
+ detail: `local v${status.configVersion} · latest v${status.latestConfigVersion}`,
href: '/settings',
})
}
@@ -1077,6 +1336,1415 @@ function normalizeLogs(
}
}
+const TRUST_STATUS_ORDER: Array = [
+ 'Unwired',
+ 'Tested',
+ 'Reliable',
+ 'Trusted',
+ 'Autonomous',
+]
+
+type TrustLedgerBuildInput = {
+ status: DashboardStatusSection | null
+ cron: DashboardCronSection | null
+ kanban: DashboardKanbanSection | null
+ analytics: DashboardAnalyticsSection | null
+ skillsUsage: DashboardSkillsUsageSection | null
+ sessionsRaw: unknown
+ skillsRaw: unknown
+ oauthProvidersRaw: unknown
+}
+
+function trustRank(status: TrustLedgerStatus): number {
+ return TRUST_STATUS_ORDER.indexOf(status)
+}
+
+function statusAt(
+ value: number,
+ thresholds: Partial>,
+): TrustLedgerStatus {
+ let status: TrustLedgerStatus = 'Unwired'
+ for (const candidate of TRUST_STATUS_ORDER) {
+ const threshold = thresholds[candidate]
+ if (threshold !== undefined && value >= threshold) status = candidate
+ }
+ return status
+}
+
+function safeLocal(reader: () => T, fallback: T): T {
+ try {
+ return reader()
+ } catch {
+ return fallback
+ }
+}
+
+function newestIso(values: Array): string | null {
+ let newest = 0
+ for (const value of values) {
+ if (!value) continue
+ const ts = Date.parse(value)
+ if (Number.isFinite(ts) && ts > newest) newest = ts
+ }
+ return newest > 0 ? new Date(newest).toISOString() : null
+}
+
+function countPayloadItems(raw: unknown, keys: Array): number {
+ if (Array.isArray(raw)) return raw.length
+ if (!raw || typeof raw !== 'object') return 0
+ const record = raw as Record
+ for (const key of keys) {
+ const candidate = record[key]
+ if (Array.isArray(candidate)) return candidate.length
+ if (typeof candidate === 'number' && Number.isFinite(candidate))
+ return candidate
+ }
+ return 0
+}
+
+export type OauthSignal = {
+ name: string
+ text: string
+ connected: boolean
+}
+
+export function collectOauthSignals(raw: unknown): Array {
+ const out: Array = []
+ const seen = new Set()
+
+ const visit = (value: unknown, depth: number) => {
+ if (!value || typeof value !== 'object' || seen.has(value) || depth > 4)
+ return
+ seen.add(value)
+
+ if (Array.isArray(value)) {
+ for (const item of value) visit(item, depth + 1)
+ return
+ }
+
+ const record = value as Record
+ const name =
+ readString(record.name) ||
+ readString(record.id) ||
+ readString(record.provider) ||
+ readString(record.service) ||
+ readString(record.type)
+ const text = JSON.stringify(record).toLowerCase()
+ const looksRelevant = /google|gmail|calendar|oauth|provider/.test(text)
+ const statusText = [
+ record.state,
+ record.status,
+ record.auth_status,
+ record.connection_status,
+ ]
+ .map(readString)
+ .join(' ')
+ .toLowerCase()
+ const explicitlyDown =
+ /disconnected|missing|error|failed|revoked|expired/.test(statusText)
+ const booleanConnected = [
+ record.connected,
+ record.configured,
+ record.enabled,
+ record.authenticated,
+ record.authorized,
+ record.active,
+ record.ready,
+ ].some((entry) => entry === true)
+ const statusConnected =
+ /connected|configured|authorized|authenticated|active|ready|ok/.test(
+ statusText,
+ )
+
+ if (looksRelevant && (name || statusText)) {
+ out.push({
+ name: name || 'oauth-provider',
+ text,
+ connected: !explicitlyDown && (booleanConnected || statusConnected),
+ })
+ }
+
+ for (const child of Object.values(record)) visit(child, depth + 1)
+ }
+
+ visit(raw, 0)
+ return out
+}
+
+function buildTrustLedger(
+ input: TrustLedgerBuildInput,
+): DashboardTrustLedgerSection {
+ const memoryFiles = safeLocal(() => listMemoryFiles(), [])
+ const knowledge = safeLocal(
+ () => (knowledgeRootExists() ? buildKnowledgeGraph() : null),
+ null,
+ )
+ const now = new Date().toISOString()
+ const sessionCount = Math.max(
+ input.analytics?.totalSessions ?? 0,
+ countPayloadItems(input.sessionsRaw, [
+ 'sessions',
+ 'items',
+ 'rows',
+ 'total',
+ ]),
+ )
+ const apiCalls = input.analytics?.totalApiCalls ?? 0
+ const installedSkills = Math.max(
+ countPayloadItems(input.skillsRaw, [
+ 'skills',
+ 'items',
+ 'installed',
+ 'total',
+ ]),
+ input.skillsUsage?.distinctSkills ?? 0,
+ )
+ const skillActions = input.skillsUsage?.totalActions ?? 0
+ const oauthSignals = collectOauthSignals(input.oauthProvidersRaw)
+ const googleSignals = oauthSignals.filter((entry) =>
+ /google|gmail|calendar/.test(entry.text),
+ )
+ const gmailConnected = googleSignals.some(
+ (entry) => entry.connected && /gmail|mail|google/.test(entry.text),
+ )
+ const calendarConnected = googleSignals.some(
+ (entry) => entry.connected && /calendar|gcal|google/.test(entry.text),
+ )
+ const connectedGoogleSignals = [gmailConnected, calendarConnected].filter(
+ Boolean,
+ ).length
+ const knowledgeNodes = knowledge?.nodes.length ?? 0
+ const knowledgeEdges = knowledge?.edges.length ?? 0
+ const latestKnowledge = newestIso(
+ knowledge?.nodes.map((node) => node.modified) ?? [],
+ )
+
+ const cronTotal = input.cron?.total ?? 0
+ const cronFailed = input.cron?.failed ?? 0
+ const cronPaused = input.cron?.paused ?? 0
+ let cronStatus: TrustLedgerStatus = 'Unwired'
+ if (cronTotal > 0) cronStatus = 'Tested'
+ if (cronTotal >= 2 && cronFailed === 0) cronStatus = 'Reliable'
+ if (cronTotal >= 3 && cronFailed === 0 && input.cron?.nextRunAt)
+ cronStatus = 'Trusted'
+ if (
+ cronTotal >= 3 &&
+ cronFailed === 0 &&
+ cronPaused === 0 &&
+ input.cron?.nextRunAt
+ ) {
+ cronStatus = 'Autonomous'
+ }
+
+ const kanbanTotal = input.kanban?.total ?? 0
+ const kanbanDone = input.kanban?.done ?? 0
+ const kanbanRunning = input.kanban?.running ?? 0
+ const kanbanBlocked = input.kanban?.blocked ?? 0
+ let kanbanStatus: TrustLedgerStatus = 'Unwired'
+ if (kanbanTotal > 0) kanbanStatus = 'Tested'
+ if (kanbanDone + kanbanRunning > 0) kanbanStatus = 'Reliable'
+ if (kanbanDone >= 5 && kanbanBlocked === 0) kanbanStatus = 'Trusted'
+ if (kanbanDone >= 10 && kanbanRunning > 0 && kanbanBlocked === 0)
+ kanbanStatus = 'Autonomous'
+
+ let authStatus: TrustLedgerStatus = 'Unwired'
+ if (googleSignals.length > 0) authStatus = 'Tested'
+ if (connectedGoogleSignals === 1) authStatus = 'Reliable'
+ if (connectedGoogleSignals >= 2) authStatus = 'Trusted'
+
+ const milestones: Array = [
+ {
+ id: 'session-history',
+ label: 'Hermes session history',
+ status: statusAt(sessionCount, { Tested: 1, Reliable: 10, Trusted: 25 }),
+ progress: { current: sessionCount, target: 50, unit: 'sessions' },
+ summary: `${sessionCount} session${sessionCount === 1 ? '' : 's'} and ${apiCalls} API call${apiCalls === 1 ? '' : 's'} in the analytics window.`,
+ evidence: [
+ `Analytics window: ${input.analytics?.windowDays ?? 30} days`,
+ `${apiCalls} API calls observed`,
+ ],
+ weakArea:
+ sessionCount >= 10
+ ? null
+ : 'Needs more completed live sessions before Nova can be called reliable here.',
+ sourceLinks: [
+ { label: 'Sessions', href: '/chat' },
+ { label: 'Analytics', href: '/dashboard' },
+ ],
+ updatedAt: input.status?.updatedAt ?? now,
+ },
+ {
+ id: 'memory-recall',
+ label: 'Hermes memory recall',
+ status: statusAt(memoryFiles.length, {
+ Tested: 1,
+ Reliable: 5,
+ Trusted: 20,
+ }),
+ progress: { current: memoryFiles.length, target: 20, unit: 'files' },
+ summary: `${memoryFiles.length} browsable Hermes memory file${memoryFiles.length === 1 ? '' : 's'} available to Mission Control.`,
+ evidence: memoryFiles.slice(0, 3).map((file) => file.path),
+ weakArea:
+ memoryFiles.length >= 5
+ ? null
+ : 'Memory browser has too little source material to prove stable recall.',
+ sourceLinks: [{ label: 'Memory', href: '/memory' }],
+ updatedAt: newestIso(memoryFiles.map((file) => file.modified)) ?? now,
+ },
+ {
+ id: 'skills',
+ label: 'Skills execution',
+ status: statusAt(Math.max(skillActions, installedSkills), {
+ Tested: 1,
+ Reliable: 10,
+ Trusted: 50,
+ }),
+ progress: {
+ current: Math.max(skillActions, installedSkills),
+ target: 50,
+ unit: 'skill signals',
+ },
+ summary: `${installedSkills} skill${installedSkills === 1 ? '' : 's'} installed or observed; ${skillActions} skill action${skillActions === 1 ? '' : 's'} used in-window.`,
+ evidence:
+ input.skillsUsage?.topSkills
+ .slice(0, 3)
+ .map(
+ (skill) => `${shortSkillName(skill.skill)}: ${skill.totalCount}`,
+ ) ?? [],
+ weakArea:
+ skillActions >= 10
+ ? null
+ : 'Skills exist, but recent usage is still light.',
+ sourceLinks: [{ label: 'Skills', href: '/skills' }],
+ updatedAt: now,
+ },
+ {
+ id: 'scheduled-wakeups',
+ label: 'Cron and wakeups',
+ status: cronStatus,
+ progress: { current: cronTotal, target: 3, unit: 'jobs' },
+ summary: `${cronTotal} scheduled job${cronTotal === 1 ? '' : 's'}; ${cronFailed} failed and ${cronPaused} paused.`,
+ evidence: [
+ input.cron?.nextRunAt
+ ? `Next run: ${input.cron.nextRunAt}`
+ : 'No next run reported',
+ ...(input.cron?.recentFailures ?? [])
+ .slice(0, 2)
+ .map((job) => `${job.name}: ${job.lastError || 'failed'}`),
+ ],
+ weakArea:
+ cronFailed > 0 || cronPaused > 0
+ ? 'Cron has paused or failed jobs that need review.'
+ : null,
+ sourceLinks: [{ label: 'Jobs', href: '/jobs' }],
+ updatedAt: input.cron?.nextRunAt ?? now,
+ },
+ {
+ id: 'job-board-events',
+ label: 'Job board events',
+ status: kanbanStatus,
+ progress: { current: kanbanDone, target: 10, unit: 'done jobs' },
+ summary: `${kanbanTotal} board item${kanbanTotal === 1 ? '' : 's'}; ${kanbanRunning} running, ${kanbanDone} done, ${kanbanBlocked} blocked.`,
+ evidence:
+ input.kanban?.topBlocked.slice(0, 3).map((task) => task.title) ?? [],
+ weakArea:
+ kanbanBlocked > 0
+ ? 'Blocked work remains on the board.'
+ : kanbanTotal > 0
+ ? null
+ : 'No job-board events were returned.',
+ sourceLinks: [
+ { label: 'Jobs', href: '/jobs' },
+ { label: 'Board', href: '/swarm2' },
+ ],
+ updatedAt: now,
+ },
+ {
+ id: 'gmail-calendar-auth',
+ label: 'Gmail and Calendar auth',
+ status: authStatus,
+ progress: {
+ current: connectedGoogleSignals,
+ target: 2,
+ unit: 'auth lanes',
+ },
+ summary: `${connectedGoogleSignals}/2 Google work lanes show connected signals.`,
+ evidence: googleSignals
+ .slice(0, 4)
+ .map(
+ (entry) => `${entry.name}: ${entry.connected ? 'connected' : 'seen'}`,
+ ),
+ weakArea:
+ connectedGoogleSignals >= 2
+ ? null
+ : 'Gmail and Calendar are not both proven connected from the OAuth provider endpoint.',
+ sourceLinks: [
+ { label: 'Settings', href: '/settings' },
+ { label: 'MCP', href: '/mcp' },
+ ],
+ updatedAt: now,
+ },
+ {
+ id: 'obsidian-notes',
+ label: 'Obsidian vault graph',
+ status: statusAt(Math.min(knowledgeNodes, knowledgeEdges * 2), {
+ Tested: 1,
+ Reliable: 25,
+ Trusted: 100,
+ }),
+ progress: { current: knowledgeNodes, target: 250, unit: 'notes' },
+ summary: `${knowledgeNodes} note${knowledgeNodes === 1 ? '' : 's'} and ${knowledgeEdges} wikilink edge${knowledgeEdges === 1 ? '' : 's'} parsed from the configured knowledge root.`,
+ evidence: knowledge?.nodes.slice(0, 3).map((node) => node.path) ?? [],
+ weakArea:
+ knowledgeEdges >= 10
+ ? null
+ : 'The vault graph has too few links to prove durable relationship recall.',
+ sourceLinks: [
+ { label: 'Memory', href: '/memory' },
+ { label: 'Galaxy', href: '/dashboard' },
+ ],
+ updatedAt: latestKnowledge ?? now,
+ },
+ ]
+
+ const counters = {
+ milestones: milestones.length,
+ reliableOrBetter: milestones.filter(
+ (m) => trustRank(m.status) >= trustRank('Reliable'),
+ ).length,
+ trustedOrBetter: milestones.filter(
+ (m) => trustRank(m.status) >= trustRank('Trusted'),
+ ).length,
+ autonomous: milestones.filter((m) => m.status === 'Autonomous').length,
+ }
+ const highestStatus = milestones.reduce(
+ (best, item) =>
+ trustRank(item.status) > trustRank(best) ? item.status : best,
+ 'Unwired',
+ )
+ const score = Math.round(
+ (milestones.reduce((sum, item) => sum + trustRank(item.status), 0) /
+ (milestones.length * (TRUST_STATUS_ORDER.length - 1))) *
+ 100,
+ )
+
+ return {
+ score,
+ highestStatus,
+ milestones,
+ recentUnlocks: [...milestones]
+ .filter((m) => trustRank(m.status) >= trustRank('Reliable'))
+ .sort((a, b) => {
+ const timeDelta =
+ Date.parse(b.updatedAt || '') - Date.parse(a.updatedAt || '')
+ if (Number.isFinite(timeDelta) && timeDelta !== 0) return timeDelta
+ return trustRank(b.status) - trustRank(a.status)
+ })
+ .slice(0, 3),
+ weakAreas: milestones
+ .filter((m) => m.weakArea || trustRank(m.status) < trustRank('Reliable'))
+ .sort((a, b) => trustRank(a.status) - trustRank(b.status))
+ .slice(0, 4),
+ counters,
+ }
+}
+
+export type TaylorKanbanSummary = {
+ items: number
+ open: number
+ stale: number
+ blockers: number
+ topTitles: Array
+}
+
+export function summarizeTaylorKanban(
+ raw: unknown,
+ now: string,
+): TaylorKanbanSummary | null {
+ if (!Array.isArray(raw)) return null
+ const nowMs = Date.parse(now)
+ const summary: TaylorKanbanSummary = {
+ items: raw.length,
+ open: 0,
+ stale: 0,
+ blockers: 0,
+ topTitles: [],
+ }
+ for (const entry of raw) {
+ if (typeof entry !== 'object' || entry === null) continue
+ const item = entry as Record
+ const title =
+ (typeof item.title === 'string' && item.title) ||
+ (typeof item.name === 'string' && item.name) ||
+ (typeof item.text === 'string' && item.text) ||
+ ''
+ const status = String(item.status ?? item.state ?? '').toLowerCase()
+ const done = /done|complete|closed|finished/.test(status)
+ const blocked = /block|stuck|wait|hold/.test(status)
+ if (!done) summary.open += 1
+ if (blocked) summary.blockers += 1
+ const updatedRaw =
+ item.updatedAt ?? item.updated ?? item.modified ?? item.timestamp
+ const updatedMs =
+ typeof updatedRaw === 'string' || typeof updatedRaw === 'number'
+ ? Date.parse(String(updatedRaw))
+ : Number.NaN
+ if (
+ !done &&
+ Number.isFinite(updatedMs) &&
+ nowMs - updatedMs > 7 * 24 * 60 * 60 * 1000
+ ) {
+ summary.stale += 1
+ }
+ if (!done && title && summary.topTitles.length < 5) {
+ summary.topTitles.push(title.slice(0, 60))
+ }
+ }
+ return summary
+}
+
+export type JobBoardReadModel = {
+ online: boolean
+ version: string | null
+ stateKeys: number
+ events: number
+ taylorKanbanItems: number
+ taylorKanban: TaylorKanbanSummary | null
+}
+
+export async function readJobBoardModel(): Promise {
+ const base = (
+ process.env.NOVA_JOBBOARD_URL ||
+ process.env.NEON_MOON_JOBBOARD_URL ||
+ 'http://127.0.0.1:8765'
+ ).replace(/\/+$/, '')
+
+ const [ping, versionRaw, stateRaw, eventsRaw] = await Promise.all([
+ safeUrlJson(`${base}/ping`),
+ safeUrlJson(`${base}/version`),
+ safeUrlJson(`${base}/state`, 4000),
+ safeUrlJson(`${base}/events`),
+ ])
+
+ const state =
+ stateRaw && typeof stateRaw === 'object' && !Array.isArray(stateRaw)
+ ? (stateRaw as Record)
+ : null
+ const version =
+ versionRaw && typeof versionRaw === 'object'
+ ? (readOptionalString((versionRaw as Record).id) ??
+ String((versionRaw as Record).id ?? ''))
+ : null
+ let taylorKanbanItems = 0
+ let taylorKanban: TaylorKanbanSummary | null = null
+ const taylorRaw = state?.['nm-taylor-kanban']
+ if (typeof taylorRaw === 'string') {
+ try {
+ const parsed = JSON.parse(taylorRaw) as unknown
+ if (Array.isArray(parsed)) {
+ taylorKanbanItems = parsed.length
+ taylorKanban = summarizeTaylorKanban(parsed, new Date().toISOString())
+ }
+ } catch {
+ taylorKanbanItems = 0
+ }
+ }
+
+ return {
+ online:
+ Boolean(ping) ||
+ Boolean(versionRaw) ||
+ Boolean(stateRaw) ||
+ Boolean(eventsRaw),
+ version: version && version.length > 0 ? version : null,
+ stateKeys: state ? Object.keys(state).length : 0,
+ taylorKanban,
+ events: Array.isArray(eventsRaw) ? eventsRaw.length : 0,
+ taylorKanbanItems,
+ }
+}
+
+function assignmentStatus(assignment: SwarmMissionAssignment): AgentWorkerStatus {
+ if (assignment.state === 'blocked' || assignment.state === 'needs_input') return 'blocked'
+ if (assignment.state === 'reviewing') return 'reviewing'
+ if (assignment.state === 'checkpointed' && assignment.reviewRequired) return 'reviewing'
+ if (assignment.state === 'dispatched' || assignment.state === 'checkpointed') return 'running'
+ if (assignment.state === 'queued') return 'queued'
+ if (assignment.state === 'done') return 'done'
+ return 'done'
+}
+
+function workerStatusRank(status: AgentWorkerStatus): number {
+ return {
+ blocked: 6,
+ reviewing: 5,
+ running: 4,
+ queued: 3,
+ done: 2,
+ idle: 1,
+ }[status]
+}
+
+function assignmentLastSeen(mission: SwarmMission, assignment: SwarmMissionAssignment): number {
+ return assignment.reviewedAt ?? assignment.completedAt ?? assignment.dispatchedAt ?? mission.updatedAt
+}
+
+function isoFromMs(value: number | null): string | null {
+ if (!value || !Number.isFinite(value)) return null
+ return new Date(value).toISOString()
+}
+
+export function buildAgentWorkforceSection(
+ missions: Array,
+): DashboardAgentWorkforceSection {
+ const generatedAt = new Date().toISOString()
+ const workerMap = new Map()
+
+ for (const mission of missions) {
+ for (const assignment of mission.assignments) {
+ const status = assignmentStatus(assignment)
+ const seenAt = assignmentLastSeen(mission, assignment)
+ const existing = workerMap.get(assignment.workerId) ?? {
+ workerId: assignment.workerId,
+ status: 'idle' as AgentWorkerStatus,
+ activeAssignments: 0,
+ completedAssignments: 0,
+ blockedAssignments: 0,
+ reviewAssignments: 0,
+ currentTask: null,
+ missionId: null,
+ lastSeenAt: null,
+ }
+
+ if (status !== 'done' && status !== 'idle') existing.activeAssignments += 1
+ if (status === 'done') existing.completedAssignments += 1
+ if (status === 'blocked') existing.blockedAssignments += 1
+ if (status === 'reviewing') existing.reviewAssignments += 1
+ if (workerStatusRank(status) > workerStatusRank(existing.status)) {
+ existing.status = status
+ existing.currentTask = assignment.task
+ existing.missionId = mission.id
+ }
+ const currentLastSeen = existing.lastSeenAt ? Date.parse(existing.lastSeenAt) : 0
+ if (seenAt > currentLastSeen) existing.lastSeenAt = isoFromMs(seenAt)
+ workerMap.set(assignment.workerId, existing)
+ }
+ }
+
+ const workers = Array.from(workerMap.values()).sort((a, b) => {
+ const statusDelta = workerStatusRank(b.status) - workerStatusRank(a.status)
+ if (statusDelta !== 0) return statusDelta
+ return Date.parse(b.lastSeenAt ?? '') - Date.parse(a.lastSeenAt ?? '')
+ })
+ const recentMissions = missions.slice(0, 5).map((mission) => ({
+ id: mission.id,
+ title: mission.title,
+ state: mission.state,
+ updatedAt: new Date(mission.updatedAt).toISOString(),
+ assignments: mission.assignments.length,
+ blocked: mission.assignments.filter((item) => assignmentStatus(item) === 'blocked').length,
+ reviewing: mission.assignments.filter((item) => assignmentStatus(item) === 'reviewing').length,
+ }))
+
+ return {
+ generatedAt,
+ summary: {
+ missions: missions.length,
+ workers: workers.length,
+ active: workers.filter((item) => ['queued', 'running', 'blocked', 'reviewing'].includes(item.status)).length,
+ blocked: workers.filter((item) => item.status === 'blocked').length,
+ reviewing: workers.filter((item) => item.status === 'reviewing').length,
+ done: workers.filter((item) => item.status === 'done').length,
+ },
+ workers,
+ recentMissions,
+ }
+}
+
+type GitWorkInput = {
+ statusPorcelain: string | null
+ remotes: string | null
+ latestCommit: string | null
+ upstream: string | null
+ prUrl?: string | null
+ warning?: string | null
+}
+
+function parseBranchLine(line: string | undefined): {
+ branch: string | null
+ ahead: number
+ behind: number
+} {
+ if (!line?.startsWith('## ')) return { branch: null, ahead: 0, behind: 0 }
+ const body = line.slice(3).trim()
+ const [branchPart, metaPart = ''] = body.split('...')
+ const branch = branchPart.trim() || null
+ const ahead = Number(/ahead (\d+)/.exec(metaPart)?.[1] ?? 0)
+ const behind = Number(/behind (\d+)/.exec(metaPart)?.[1] ?? 0)
+ return { branch, ahead, behind }
+}
+
+function parseGitRemotes(raw: string | null): Array {
+ if (!raw) return []
+ const remotes = new Map()
+ for (const line of raw.split(/\r?\n/)) {
+ const match = /^(\S+)\s+(\S+)\s+\((fetch|push)\)$/.exec(line.trim())
+ if (!match) continue
+ const [, name, url, direction] = match
+ const remote = remotes.get(name) ?? { name, fetchUrl: null, pushUrl: null }
+ if (direction === 'fetch') remote.fetchUrl = url
+ if (direction === 'push') remote.pushUrl = url
+ remotes.set(name, remote)
+ }
+ return Array.from(remotes.values())
+}
+
+export function buildGitWorkSection(input: GitWorkInput): DashboardGitWorkSection {
+ const lines = input.statusPorcelain?.split(/\r?\n/).filter(Boolean) ?? []
+ const branchInfo = parseBranchLine(lines[0])
+ const changedFiles = lines.filter((line) => !line.startsWith('## ')).length
+ const latestParts = input.latestCommit?.split('\t') ?? []
+ const latestCommit = latestParts[0]
+ ? { hash: latestParts[0], subject: latestParts.slice(1).join('\t') || 'No subject' }
+ : null
+
+ return {
+ branch: branchInfo.branch,
+ clean: changedFiles === 0,
+ ahead: branchInfo.ahead,
+ behind: branchInfo.behind,
+ changedFiles,
+ upstream: input.upstream?.trim() || null,
+ latestCommit,
+ remotes: parseGitRemotes(input.remotes),
+ prUrl: input.prUrl?.trim() || null,
+ warning: input.warning?.trim() || null,
+ }
+}
+
+function readGitOutput(args: Array): string | null {
+ return safeLocal(
+ () => execFileSync('git', args, { cwd: process.cwd(), encoding: 'utf-8' }).trim(),
+ null,
+ )
+}
+
+function readGitWorkSection(): DashboardGitWorkSection {
+ const statusPorcelain = readGitOutput(['status', '--porcelain=v1', '-b'])
+ const remotes = readGitOutput(['remote', '-v'])
+ const latestCommit = readGitOutput(['log', '-1', '--pretty=%h%x09%s'])
+ const branch = parseBranchLine(statusPorcelain?.split(/\r?\n/)[0]).branch
+ const upstream = branch
+ ? readGitOutput(['rev-parse', '--abbrev-ref', `${branch}@{upstream}`])
+ : null
+ return buildGitWorkSection({
+ statusPorcelain,
+ remotes,
+ latestCommit,
+ upstream,
+ prUrl: process.env.NOVA_GITHUB_PR_URL ?? null,
+ warning: statusPorcelain ? null : 'Git status unavailable for this workspace.',
+ })
+}
+
+export function buildRouteCostAnomalies(
+ analytics: DashboardAnalyticsSection | null,
+ expectedModels?: Array,
+): Array {
+ if (!analytics) return []
+ const expected = (
+ expectedModels ??
+ (process.env.NOVA_EXPECTED_MODELS ?? 'gpt-5.5,kimi-k2.6,gpt-oss-120b')
+ .split(',')
+ .map((entry) => entry.trim())
+ .filter(Boolean)
+ ).map((entry) => entry.toLowerCase())
+ const anomalies: Array = []
+ for (const model of analytics.topModels) {
+ const id = model.id.toLowerCase()
+ const isExpected = expected.some((candidate) => id.includes(candidate))
+ if (!isExpected && model.cost > 0.05) {
+ anomalies.push(
+ `Route leak: $${model.cost.toFixed(2)} on unexpected model ${model.id}`,
+ )
+ }
+ }
+ return anomalies
+}
+
+export type LiveSystemsApprovalsProbe = {
+ pending: number
+ actionable: number
+ degraded: boolean
+}
+
+export type LiveSystemsVaultProbe = {
+ reachable: boolean
+ nodeCount: number
+ memoryFiles: number
+ newestModified: string | null
+}
+
+export type LiveSystemsBuildInput = {
+ status: DashboardStatusSection | null
+ platforms: Array
+ cron: DashboardCronSection | null
+ kanban: DashboardKanbanSection | null
+ modelInfo: DashboardModelInfoSection | null
+ analytics: DashboardAnalyticsSection | null
+ skillsRaw: unknown
+ oauthProvidersRaw: unknown
+ jobBoard: JobBoardReadModel
+ gitWork?: DashboardGitWorkSection | null
+ approvals?: LiveSystemsApprovalsProbe | null
+ /** Injectable for tests; when absent the local vault is probed directly. */
+ vaultProbe?: LiveSystemsVaultProbe | null
+}
+
+function liveSystem(
+ id: string,
+ label: string,
+ status: LiveSystemStatus,
+ detail: string,
+ sourceLabel: string,
+ href: string | null = null,
+ lastSeenAt: string | null = null,
+): DashboardLiveSystem {
+ return { id, label, status, detail, href, lastSeenAt, source: sourceLabel }
+}
+
+function platformLooksOnline(entry: DashboardPlatformEntry): boolean {
+ return /connected|running|ready|ok|active/i.test(entry.state)
+}
+
+function platformLooksBad(entry: DashboardPlatformEntry): boolean {
+ return /error|failed|missing|disconnected|offline|revoked/i.test(entry.state)
+}
+
+function probeLocalVault(): LiveSystemsVaultProbe {
+ const memoryFiles = safeLocal(() => listMemoryFiles(), [])
+ const reachable = safeLocal(() => knowledgeRootExists(), false)
+ const knowledge = safeLocal(() => (reachable ? buildKnowledgeGraph() : null), null)
+ return {
+ reachable,
+ nodeCount: knowledge?.nodes.length ?? 0,
+ memoryFiles: memoryFiles.length,
+ newestModified: newestIso([
+ ...(knowledge?.nodes.map((node) => node.modified) ?? []),
+ ...memoryFiles.map((file) => file.modified),
+ ]),
+ }
+}
+
+export function buildLiveSystems(input: LiveSystemsBuildInput): DashboardLiveSystemsSection {
+ const generatedAt = new Date().toISOString()
+ const oauthSignals = collectOauthSignals(input.oauthProvidersRaw)
+ const googleSignals = oauthSignals.filter((entry) => /google|gmail|calendar/.test(entry.text))
+ const gmailConnected = googleSignals.some((entry) => entry.connected && /gmail|mail|google/.test(entry.text))
+ const calendarConnected = googleSignals.some((entry) => entry.connected && /calendar|gcal|google/.test(entry.text))
+ const skillsCount = countPayloadItems(input.skillsRaw, ['skills', 'items', 'installed', 'total'])
+ const vault = input.vaultProbe ?? probeLocalVault()
+ const approvals = input.approvals ?? null
+ const platformErrors = input.platforms.filter((entry) => platformLooksBad(entry))
+ const platformOnline = input.platforms.filter((entry) => platformLooksOnline(entry)).length
+
+ const systems: Array = [
+ liveSystem(
+ 'hermes-gateway',
+ 'Hermes gateway',
+ input.status ? (platformErrors.length > 0 ? 'degraded' : 'operational') : 'offline',
+ input.status
+ ? `${input.status.gatewayState || 'unknown'}; ${input.status.activeAgents} active agent${input.status.activeAgents === 1 ? '' : 's'}; ${platformOnline} platform${platformOnline === 1 ? '' : 's'} up`
+ : 'Gateway status endpoint unavailable',
+ '/api/gateway-status + /api/status',
+ '/settings',
+ input.status?.lastHeartbeatAt ?? input.status?.updatedAt ?? null,
+ ),
+ liveSystem(
+ 'model-route',
+ 'Model route',
+ input.modelInfo
+ ? input.modelInfo.provider && input.modelInfo.model
+ ? 'operational'
+ : 'connected'
+ : 'not-wired',
+ input.modelInfo
+ ? `${input.modelInfo.provider || 'unknown'} / ${input.modelInfo.model || 'unknown model'}`
+ : 'Model info endpoint unavailable',
+ '/api/model/info',
+ '/settings',
+ generatedAt,
+ ),
+ liveSystem(
+ 'tools-skills',
+ 'Skills/tools',
+ skillsCount > 0
+ ? 'operational'
+ : input.skillsRaw !== null && input.skillsRaw !== undefined
+ ? 'reachable'
+ : 'not-wired',
+ skillsCount > 0
+ ? `${skillsCount} installed skill${skillsCount === 1 ? '' : 's'} returned`
+ : input.skillsRaw !== null && input.skillsRaw !== undefined
+ ? 'Skills endpoint answered but returned no installed skills'
+ : 'Skills endpoint unavailable',
+ '/api/skills',
+ '/skills',
+ generatedAt,
+ ),
+ liveSystem(
+ 'cron-background',
+ 'Cron/background',
+ input.cron
+ ? input.cron.failed > 0
+ ? 'degraded'
+ : input.cron.total > 0
+ ? 'operational'
+ : 'connected'
+ : 'not-wired',
+ input.cron
+ ? `${input.cron.total} job${input.cron.total === 1 ? '' : 's'}; ${input.cron.failed} failed; ${input.cron.paused} paused`
+ : 'Cron endpoint unavailable',
+ '/api/cron/jobs',
+ '/jobs',
+ input.cron?.nextRunAt ?? generatedAt,
+ ),
+ liveSystem(
+ 'google-workspace',
+ 'Google Workspace',
+ gmailConnected && calendarConnected
+ ? 'approval-gated'
+ : googleSignals.length > 0
+ ? 'reachable'
+ : 'not-wired',
+ gmailConnected && calendarConnected
+ ? 'Gmail + Calendar connected; reads only — sends/schedules wait on Taylor approval'
+ : googleSignals.length > 0
+ ? `${gmailConnected ? 'Gmail' : 'Gmail not proven'}; ${calendarConnected ? 'Calendar' : 'Calendar not proven'}`
+ : 'No Google/Gmail/Calendar OAuth signal returned',
+ '/api/providers/oauth',
+ '/settings',
+ generatedAt,
+ ),
+ liveSystem(
+ 'obsidian-vault',
+ 'Obsidian vault',
+ vault.reachable ? (vault.nodeCount > 0 ? 'operational' : 'reachable') : 'not-wired',
+ vault.reachable
+ ? `${vault.nodeCount} graph node${vault.nodeCount === 1 ? '' : 's'}; ${vault.memoryFiles} memory file${vault.memoryFiles === 1 ? '' : 's'}`
+ : 'Knowledge root/vault not reachable',
+ '/api/knowledge/graph + /api/knowledge/insights',
+ '/memory',
+ vault.newestModified,
+ ),
+ liveSystem(
+ 'neon-moon-job-board',
+ 'Neon Moon job board',
+ input.jobBoard.online
+ ? input.jobBoard.taylorKanban
+ ? 'operational'
+ : 'reachable'
+ : 'not-wired',
+ input.jobBoard.online
+ ? input.jobBoard.taylorKanban
+ ? `v${input.jobBoard.version ?? 'unknown'}; ${input.jobBoard.taylorKanbanItems} Taylor kanban item${input.jobBoard.taylorKanbanItems === 1 ? '' : 's'}`
+ : `ping OK (v${input.jobBoard.version ?? 'unknown'}); kanban state not parsed`
+ : 'Job board HTTP endpoints unavailable',
+ 'job board /ping + /state (HTTP only)',
+ '/swarm2',
+ generatedAt,
+ ),
+ liveSystem(
+ 'kanban-board',
+ 'Kanban board',
+ input.kanban
+ ? input.kanban.blocked > 0
+ ? 'degraded'
+ : 'operational'
+ : 'not-wired',
+ input.kanban
+ ? `${input.kanban.total} card${input.kanban.total === 1 ? '' : 's'}; ${input.kanban.blocked} blocked`
+ : 'Kanban plugin endpoint unavailable',
+ '/api/plugins/kanban/board',
+ '/swarm2',
+ generatedAt,
+ ),
+ liveSystem(
+ 'cost-route-watch',
+ 'Cost/route watch',
+ input.analytics
+ ? buildRouteCostAnomalies(input.analytics).length > 0
+ ? 'degraded'
+ : 'operational'
+ : 'not-wired',
+ input.analytics
+ ? (buildRouteCostAnomalies(input.analytics)[0] ??
+ `${input.analytics.totalTokens} tokens; ${
+ input.analytics.estimatedCostUsd === null
+ ? input.analytics.costLabel
+ : `$${input.analytics.estimatedCostUsd.toFixed(2)}`
+ } cost signal in ${input.analytics.windowDays}d`)
+ : 'Analytics usage endpoint unavailable',
+ '/api/analytics/usage + /api/provider-usage',
+ '/dashboard',
+ generatedAt,
+ ),
+ liveSystem(
+ 'github-agent-work',
+ 'GitHub/agent work',
+ input.gitWork ? 'operational' : 'not-wired',
+ input.gitWork
+ ? `${input.gitWork.branch ?? 'unknown branch'} @ ${input.gitWork.latestCommit?.hash ?? '?'} · ${
+ input.gitWork.clean
+ ? 'clean'
+ : `${input.gitWork.changedFiles} changed`
+ } · PR ${input.gitWork.prUrl ? 'linked' : 'not set'} · receipts via /api/nova-work-scan`
+ : 'Local git unreadable; work receipts unavailable',
+ 'local git + /api/nova-work-scan',
+ null,
+ input.gitWork ? generatedAt : null,
+ ),
+ liveSystem(
+ 'taylor-approvals',
+ 'Taylor approvals',
+ approvals
+ ? approvals.degraded
+ ? 'degraded'
+ : approvals.pending > 0
+ ? 'approval-gated'
+ : 'operational'
+ : 'not-wired',
+ approvals
+ ? approvals.degraded
+ ? 'Approval queue degraded — fabric store unreadable'
+ : approvals.pending > 0
+ ? `${approvals.pending} item${approvals.pending === 1 ? '' : 's'} waiting on Taylor (${approvals.actionable} actionable here)`
+ : 'Queue clear — nothing waiting on Taylor'
+ : 'Approval queue unavailable',
+ '/api/taylor-approvals',
+ '/dashboard',
+ generatedAt,
+ ),
+ ]
+
+ const summary = {
+ total: systems.length,
+ operational: systems.filter((item) => item.status === 'operational').length,
+ connected: systems.filter((item) => item.status === 'connected').length,
+ reachable: systems.filter((item) => item.status === 'reachable').length,
+ approvalGated: systems.filter((item) => item.status === 'approval-gated').length,
+ degraded: systems.filter((item) => item.status === 'degraded').length,
+ offline: systems.filter((item) => item.status === 'offline').length,
+ notWired: systems.filter((item) => item.status === 'not-wired').length,
+ }
+
+ // approval-gated means "needs Taylor", not "broken" — it is surfaced by the
+ // approval queue panel, so only real outages become blockers here.
+ const blockers = systems
+ .filter((item) => item.status === 'offline' || item.status === 'degraded' || item.status === 'not-wired')
+ .slice(0, 4)
+ .map((item) => `${item.label}: ${item.detail}`)
+
+ return { generatedAt, summary, blockers, systems }
+}
+
+type ControlLoopBuildInput = {
+ status: DashboardStatusSection | null
+ cron: DashboardCronSection | null
+ kanban: DashboardKanbanSection | null
+ analytics: DashboardAnalyticsSection | null
+ skillsRaw: unknown
+ oauthProvidersRaw: unknown
+ jobBoard: JobBoardReadModel
+}
+
+function skillInstalled(raw: unknown, id: string): boolean {
+ const text = JSON.stringify(raw ?? {}).toLowerCase()
+ return text.includes(id.toLowerCase())
+}
+
+function source(
+ label: string,
+ ok: boolean,
+ detailWhenOk: string,
+ detailWhenMissing: string,
+ availableOnly = false,
+): DashboardControlLoopSource {
+ return {
+ label,
+ status: ok ? (availableOnly ? 'available' : 'connected') : 'not-wired',
+ detail: ok ? detailWhenOk : detailWhenMissing,
+ }
+}
+
+function loopStatus(
+ sources: Array,
+ skillReady: boolean,
+): ControlLoopStatus {
+ const liveSources = sources.filter(
+ (item) => item.status === 'connected' || item.status === 'available',
+ ).length
+ if (skillReady && liveSources >= Math.max(2, sources.length - 1))
+ return 'ready'
+ if (skillReady || liveSources > 0) return 'partial'
+ return 'not-wired'
+}
+
+function nextForCron(
+ cron: DashboardCronSection | null,
+ hint: string,
+): string | null {
+ if (!cron?.nextRunAt || !hint) return null
+ return null
+}
+
+function buildControlLoops(
+ input: ControlLoopBuildInput,
+): DashboardControlLoopsSection {
+ const now = new Date().toISOString()
+ const oauthSignals = collectOauthSignals(input.oauthProvidersRaw)
+ const gmailConnected = oauthSignals.some(
+ (entry) => entry.connected && /gmail|mail|google/.test(entry.text),
+ )
+ const calendarConnected = oauthSignals.some(
+ (entry) => entry.connected && /calendar|gcal|google/.test(entry.text),
+ )
+ const hermesOnline = Boolean(input.status?.gatewayState)
+ const cronOnline = (input.cron?.total ?? 0) > 0
+ const analyticsOnline = input.analytics !== null
+ const jobBoardOnline = input.jobBoard.online
+ const taylorKanbanOnline = input.jobBoard.taylorKanbanItems > 0
+ const skillsEndpointOnline =
+ input.skillsRaw !== null &&
+ input.skillsRaw !== undefined &&
+ JSON.stringify(input.skillsRaw).length > 2
+ const vaultOnline = knowledgeRootExists()
+
+ const makeLoop = (
+ loop: Omit,
+ ): DashboardControlLoop => {
+ const installed = skillInstalled(input.skillsRaw, loop.id)
+ const status = loopStatus(loop.connectedSystems, installed)
+ const readyCount = loop.connectedSystems.filter(
+ (item) => item.status === 'connected' || item.status === 'available',
+ ).length
+ return {
+ ...loop,
+ status,
+ readiness: installed
+ ? `${readyCount}/${loop.connectedSystems.length} systems live; skill detected`
+ : `${readyCount}/${loop.connectedSystems.length} systems live; skill not detected`,
+ }
+ }
+
+ const commonGuardrails = [
+ 'Dry-run only from Mission Control.',
+ 'No emails/messages are sent.',
+ 'No money, spend, login, credential, or destructive action.',
+ 'Customer/vendor-facing actions require Taylor approval.',
+ ]
+
+ const loops: Array = [
+ makeLoop({
+ id: 'morning-command-center',
+ label: 'Morning Command Center',
+ purpose:
+ 'Pull the morning operating picture and return first-fire plus a 3-item plan.',
+ connectedSystems: [
+ source(
+ 'Gmail',
+ gmailConnected,
+ 'OAuth provider signal seen',
+ 'Google/Gmail auth not proven',
+ ),
+ source(
+ 'Calendar',
+ calendarConnected,
+ 'Calendar auth signal seen',
+ 'Calendar auth not proven',
+ ),
+ source(
+ 'Neon Moon board',
+ jobBoardOnline,
+ `Board v${input.jobBoard.version ?? 'unknown'} online`,
+ 'Board HTTP endpoints unavailable',
+ ),
+ source(
+ 'Taylor kanban',
+ taylorKanbanOnline,
+ `${input.jobBoard.taylorKanbanItems} items`,
+ 'nm-taylor-kanban not found in board state',
+ ),
+ source(
+ 'Cost route',
+ analyticsOnline,
+ `${input.analytics?.totalTokens ?? 0} tokens in window`,
+ 'Hermes analytics unavailable',
+ ),
+ ],
+ lastUsedAt: null,
+ nextScheduledAt: nextForCron(input.cron, 'morning'),
+ dryRunPrompt:
+ 'Dry-run Morning Command Center: read Gmail, Calendar, Neon Moon board, Taylor kanban, and route/cost status; produce first-fire plus exactly 3 morning priorities. Draft only. No sends or writes.',
+ guardrails: commonGuardrails,
+ sourceLinks: [
+ { label: 'Dashboard', href: '/dashboard' },
+ { label: 'Jobs', href: '/jobs' },
+ ],
+ }),
+ makeLoop({
+ id: 'email-to-taylor-kanban',
+ label: 'Email to Taylor Kanban',
+ purpose:
+ 'Triage Gmail action items into Taylor personal kanban and draft replies only.',
+ connectedSystems: [
+ source(
+ 'Gmail',
+ gmailConnected,
+ 'OAuth provider signal seen',
+ 'Google/Gmail auth not proven',
+ ),
+ source(
+ 'Taylor kanban',
+ taylorKanbanOnline,
+ `${input.jobBoard.taylorKanbanItems} items`,
+ 'nm-taylor-kanban not found in board state',
+ ),
+ source(
+ 'Hermes skills',
+ skillsEndpointOnline,
+ 'Installed skills endpoint answered',
+ 'Skills endpoint unavailable',
+ true,
+ ),
+ ],
+ lastUsedAt: null,
+ nextScheduledAt: null,
+ dryRunPrompt:
+ 'Dry-run Email to Taylor Kanban: scan Gmail for actionable items, propose kanban cards for nm-taylor-kanban, and draft replies only. Do not send, archive, label, or write without approval.',
+ guardrails: commonGuardrails,
+ sourceLinks: [
+ { label: 'Taylor kanban', href: 'http://127.0.0.1:8765' },
+ { label: 'Settings', href: '/settings' },
+ ],
+ }),
+ makeLoop({
+ id: 'jobboard-caretaker',
+ label: 'Jobboard Caretaker',
+ purpose:
+ 'Check Neon Moon board health, version, events, and Taylor kanban through HTTP only.',
+ connectedSystems: [
+ source(
+ 'Board /ping',
+ jobBoardOnline,
+ 'HTTP board answered',
+ 'Board /ping unavailable',
+ ),
+ source(
+ 'Board /version',
+ Boolean(input.jobBoard.version),
+ `Version ${input.jobBoard.version}`,
+ 'Board /version unavailable',
+ ),
+ source(
+ 'Board /state',
+ input.jobBoard.stateKeys > 0,
+ `${input.jobBoard.stateKeys} keys`,
+ 'Board /state unavailable',
+ ),
+ source(
+ 'Board /events',
+ input.jobBoard.events > 0,
+ `${input.jobBoard.events} events`,
+ 'Board /events unavailable',
+ ),
+ source(
+ 'Taylor kanban',
+ taylorKanbanOnline,
+ `${input.jobBoard.taylorKanbanItems} items`,
+ 'nm-taylor-kanban not found',
+ ),
+ ],
+ lastUsedAt: null,
+ nextScheduledAt: nextForCron(input.cron, 'jobboard'),
+ dryRunPrompt:
+ 'Dry-run Jobboard Caretaker: check /ping, /version, /state, /events, and Taylor kanban. Summarize health/version/events. Do not edit state.json. Do not call /set, /event, or /sync-now.',
+ guardrails: [
+ ...commonGuardrails,
+ 'HTTP endpoints only; never edit state.json.',
+ ],
+ sourceLinks: [{ label: 'Job board', href: 'http://127.0.0.1:8765' }],
+ }),
+ makeLoop({
+ id: 'design-intake-triage',
+ label: 'Design Intake Triage',
+ purpose:
+ 'Classify invoices, quote emails, art requests, digitizing, mockups, tracing, and order-entry prep.',
+ connectedSystems: [
+ source(
+ 'Gmail',
+ gmailConnected,
+ 'OAuth provider signal seen',
+ 'Google/Gmail auth not proven',
+ ),
+ source(
+ 'Obsidian vault',
+ vaultOnline,
+ 'Unified vault exists',
+ 'Vault path unavailable',
+ ),
+ source(
+ 'Taylor kanban',
+ taylorKanbanOnline,
+ `${input.jobBoard.taylorKanbanItems} items`,
+ 'Taylor kanban unavailable',
+ ),
+ ],
+ lastUsedAt: null,
+ nextScheduledAt: null,
+ dryRunPrompt:
+ 'Dry-run Design Intake Triage: classify recent invoices, quotes, art requests, vector tracing, digitizing, mockups, and order-entry prep. Route next action to Taylor/staff/vendor as recommendations only.',
+ guardrails: commonGuardrails,
+ sourceLinks: [
+ { label: 'Memory', href: '/memory' },
+ { label: 'Taylor kanban', href: 'http://127.0.0.1:8765' },
+ ],
+ }),
+ makeLoop({
+ id: 'cost-route-watch',
+ label: 'Cost Route Watch',
+ purpose:
+ 'Watch Hermes/session DB/provider route usage, estimates, billing mode, and route leaks.',
+ connectedSystems: [
+ source(
+ 'Hermes status',
+ hermesOnline,
+ input.status?.gatewayState ?? 'online',
+ 'Hermes status unavailable',
+ ),
+ source(
+ 'Session analytics',
+ analyticsOnline,
+ `${input.analytics?.totalSessions ?? 0} sessions`,
+ 'Analytics unavailable',
+ ),
+ source(
+ 'Cost estimates',
+ analyticsOnline,
+ input.analytics?.costLabel ?? 'available',
+ 'Cost route data unavailable',
+ ),
+ ],
+ lastUsedAt: null,
+ nextScheduledAt: null,
+ dryRunPrompt:
+ 'Dry-run Cost Route Watch: inspect Hermes analytics/session usage and provider route mix. Estimate 1h/5h/24h tokens and cost, flag route leaks, and make no provider/config changes.',
+ guardrails: commonGuardrails,
+ sourceLinks: [
+ { label: 'Analytics', href: '/dashboard' },
+ { label: 'Settings', href: '/settings' },
+ ],
+ }),
+ makeLoop({
+ id: 'shutdown-routine',
+ label: 'Shutdown Routine',
+ purpose:
+ 'Park open loops, separate real fires from fake fires, queue reminders, and protect evening boundary.',
+ connectedSystems: [
+ source(
+ 'Cron',
+ cronOnline,
+ `${input.cron?.total ?? 0} jobs`,
+ 'Cron jobs unavailable',
+ ),
+ source(
+ 'Taylor kanban',
+ taylorKanbanOnline,
+ `${input.jobBoard.taylorKanbanItems} items`,
+ 'Taylor kanban unavailable',
+ ),
+ source(
+ 'Hermes status',
+ hermesOnline,
+ input.status?.gatewayState ?? 'online',
+ 'Hermes status unavailable',
+ ),
+ ],
+ lastUsedAt: null,
+ nextScheduledAt: nextForCron(input.cron, 'shutdown'),
+ dryRunPrompt:
+ 'Dry-run Shutdown Routine: list open loops, identify real fires vs fake fires, queue suggested reminders/follow-ups, and propose an evening boundary. No writes or sends.',
+ guardrails: commonGuardrails,
+ sourceLinks: [
+ { label: 'Jobs', href: '/jobs' },
+ { label: 'Dashboard', href: '/dashboard' },
+ ],
+ }),
+ ]
+
+ return {
+ generatedAt: now,
+ summary: {
+ total: loops.length,
+ ready: loops.filter((loop) => loop.status === 'ready').length,
+ partial: loops.filter((loop) => loop.status === 'partial').length,
+ notWired: loops.filter((loop) => loop.status === 'not-wired').length,
+ },
+ riskGates: commonGuardrails,
+ loops,
+ }
+}
+
+function buildNotebookBridge(): DashboardNotebookBridgeSection | null {
+ const knowledge = safeLocal(
+ () => (knowledgeRootExists() ? buildKnowledgeGraph() : null),
+ null,
+ )
+ if (!knowledge) return null
+ return {
+ mode: 'manual-approval',
+ canonicalSource: 'Obsidian vault',
+ synthesisLayer: 'NotebookLM',
+ writebackTargets: ['agents/kimi', 'inbox'],
+ vaultNotes: knowledge.nodes.length,
+ vaultLinks: knowledge.edges.length,
+ lastVaultUpdate: newestIso(knowledge.nodes.map((node) => node.modified)),
+ guardrails: [
+ 'Obsidian remains the source of truth.',
+ 'NotebookLM outputs are synthesis drafts only.',
+ 'Writeback requires manual approval and source links.',
+ ],
+ stages: [
+ {
+ id: 'vault-sources',
+ label: 'Vault source pack',
+ status: knowledge.nodes.length > 0 ? 'Ready' : 'Blocked',
+ summary:
+ 'Mission Control can enumerate Obsidian notes and wikilinks for a source bundle.',
+ sourceLinks: [{ label: 'Memory', href: '/memory' }],
+ },
+ {
+ id: 'notebooklm-synthesis',
+ label: 'NotebookLM synthesis',
+ status: 'Manual',
+ summary:
+ 'NotebookLM summaries, mind maps, and audio overviews are manually generated outside the source vault.',
+ sourceLinks: [
+ { label: 'NotebookLM', href: 'https://notebooklm.google.com/' },
+ ],
+ },
+ {
+ id: 'reviewed-writeback',
+ label: 'Reviewed writeback',
+ status: 'Draft',
+ summary:
+ 'Approved synthesis becomes structured notes in agents/kimi or inbox with source links.',
+ sourceLinks: [{ label: 'Inbox', href: '/memory' }],
+ },
+ ],
+ }
+}
+
export type BuildOverviewExtraFetchers = {
/**
* Optional fetcher for the gateway runtime endpoint (`/health/detailed`).
@@ -1103,6 +2771,11 @@ export async function buildDashboardOverview(
analyticsRaw,
kanbanRaw,
logsRaw,
+ sessionsRaw,
+ skillsRaw,
+ oauthProvidersRaw,
+ jobBoard,
+ swarmMissions,
] = await Promise.all([
safeJson(fetcher, '/api/status'),
options.gatewayFetcher
@@ -1121,6 +2794,14 @@ export async function buildDashboardOverview(
),
safeJson(fetcher, '/api/plugins/kanban/board'),
safeJson(fetcher, `/api/logs?lines=${logsLimit}`),
+ safeJson(fetcher, '/api/sessions?limit=50&offset=0'),
+ safeJson(
+ fetcher,
+ '/api/skills?tab=installed&limit=200&summary=search',
+ ),
+ safeJson(fetcher, '/api/providers/oauth'),
+ readJobBoardModel(),
+ Promise.resolve(safeLocal(() => listSwarmMissions(20), [])),
])
const status = normalizeStatus(statusRaw, healthRaw)
@@ -1129,25 +2810,75 @@ export async function buildDashboardOverview(
const analytics = normalizeAnalytics(analyticsRaw, analyticsWindowDays)
const kanban = normalizeKanban(kanbanRaw)
const logs = normalizeLogs(logsRaw, logsLimit)
+ const modelInfo = normalizeModelInfo(modelInfoRaw)
const skillsUsage = normalizeSkillsUsage(analyticsRaw)
const insights = computeInsights(analytics, cron, status, skillsUsage, kanban)
const incidents = computeIncidents(status, platforms, cron, logs, kanban)
+ const achievements = normalizeAchievements(
+ achRecentRaw,
+ achAllRaw,
+ achievementsLimit,
+ )
+ const trustLedger = buildTrustLedger({
+ status,
+ cron,
+ kanban,
+ analytics,
+ skillsUsage,
+ sessionsRaw,
+ skillsRaw,
+ oauthProvidersRaw,
+ })
+ const controlLoops = buildControlLoops({
+ status,
+ cron,
+ kanban,
+ analytics,
+ skillsRaw,
+ oauthProvidersRaw,
+ jobBoard,
+ })
+ const gitWork = readGitWorkSection()
+ const approvalsProbe = safeLocal((): LiveSystemsApprovalsProbe => {
+ const queue = getTaylorApprovalQueue()
+ return {
+ pending: queue.counts.total,
+ actionable: queue.counts.actionable,
+ degraded: queue.degraded,
+ }
+ }, null)
+ const liveSystems = buildLiveSystems({
+ status,
+ platforms,
+ cron,
+ kanban,
+ modelInfo,
+ analytics,
+ skillsRaw,
+ oauthProvidersRaw,
+ jobBoard,
+ gitWork,
+ approvals: approvalsProbe,
+ })
+ const agentWorkforce = buildAgentWorkforceSection(swarmMissions)
return {
status,
platforms,
cron,
kanban,
- achievements: normalizeAchievements(
- achRecentRaw,
- achAllRaw,
- achievementsLimit,
- ),
- modelInfo: normalizeModelInfo(modelInfoRaw),
+ achievements,
+ modelInfo,
analytics,
logs,
skillsUsage,
insights,
incidents,
+ trustLedger,
+ controlLoops,
+ notebookBridge: buildNotebookBridge(),
+ liveSystems,
+ agentWorkforce,
+ gitWork,
}
}
diff --git a/src/server/dashboard-connectors.test.ts b/src/server/dashboard-connectors.test.ts
new file mode 100644
index 0000000000..b059e439ef
--- /dev/null
+++ b/src/server/dashboard-connectors.test.ts
@@ -0,0 +1,83 @@
+import { describe, expect, it } from 'vitest'
+import {
+ buildRouteCostAnomalies,
+ summarizeTaylorKanban,
+} from './dashboard-aggregator'
+import type { DashboardAnalyticsSection } from './dashboard-aggregator'
+
+const NOW = '2026-07-08T12:00:00.000Z'
+
+describe('summarizeTaylorKanban', () => {
+ it('returns null for non-array payloads', () => {
+ expect(summarizeTaylorKanban('nope', NOW)).toBeNull()
+ expect(summarizeTaylorKanban({ items: [] }, NOW)).toBeNull()
+ })
+
+ it('counts open, stale, and blocked items defensively', () => {
+ const summary = summarizeTaylorKanban(
+ [
+ { title: 'Fresh task', status: 'open', updatedAt: NOW },
+ {
+ name: 'Old task',
+ state: 'in-progress',
+ updated: '2026-06-01T00:00:00.000Z',
+ },
+ { text: 'Stuck task', status: 'blocked', updatedAt: NOW },
+ { title: 'Done task', status: 'done', updatedAt: NOW },
+ 'garbage entry',
+ ],
+ NOW,
+ )
+ expect(summary).not.toBeNull()
+ expect(summary?.items).toBe(5)
+ expect(summary?.open).toBe(3)
+ expect(summary?.stale).toBe(1)
+ expect(summary?.blockers).toBe(1)
+ expect(summary?.topTitles).toContain('Fresh task')
+ expect(summary?.topTitles).not.toContain('Done task')
+ })
+})
+
+describe('buildRouteCostAnomalies', () => {
+ function analytics(
+ topModels: DashboardAnalyticsSection['topModels'],
+ ): DashboardAnalyticsSection {
+ return {
+ topModels,
+ } as DashboardAnalyticsSection
+ }
+
+ it('returns nothing when analytics is unavailable', () => {
+ expect(buildRouteCostAnomalies(null)).toEqual([])
+ })
+
+ it('flags paid spend on unexpected models as route leaks', () => {
+ const anomalies = buildRouteCostAnomalies(
+ analytics([
+ { id: 'gpt-5.5', tokens: 100, calls: 5, cost: 0, sessions: 1 },
+ {
+ id: 'claude-opus-4-8',
+ tokens: 900,
+ calls: 3,
+ cost: 4.2,
+ sessions: 1,
+ },
+ ]),
+ ['gpt-5.5', 'kimi-k2.6'],
+ )
+ expect(anomalies).toHaveLength(1)
+ expect(anomalies[0]).toContain('claude-opus-4-8')
+ expect(anomalies[0]).toContain('Route leak')
+ })
+
+ it('ignores expected models and dust-sized costs', () => {
+ const anomalies = buildRouteCostAnomalies(
+ analytics([
+ { id: 'kimi-k2.6', tokens: 100, calls: 5, cost: 1.5, sessions: 1 },
+ { id: 'mystery-model', tokens: 10, calls: 1, cost: 0.01, sessions: 1 },
+ ]),
+ ['gpt-5.5', 'kimi-k2.6'],
+ )
+ expect(anomalies).toEqual([])
+ })
+})
diff --git a/src/server/gateway-capabilities.ts b/src/server/gateway-capabilities.ts
index 5c860d4e7a..4dbe996cb9 100644
--- a/src/server/gateway-capabilities.ts
+++ b/src/server/gateway-capabilities.ts
@@ -142,6 +142,7 @@ export const DASHBOARD_REQUIRED_INSTRUCTIONS =
export const SESSIONS_API_UNAVAILABLE_MESSAGE = `Your Hermes backend does not support the sessions API. ${CLAUDE_UPGRADE_INSTRUCTIONS}`
const PROBE_TIMEOUT_MS = 3_000
+const DASHBOARD_AUTO_DETECT_TIMEOUT_MS = 750
// Probe TTL: 120s when the gateway is healthy, 15s when it isn't. The
// shorter window during 'disconnected' state means a Docker stack where
// the workspace boots before the agent recovers within ~15s of the agent
@@ -338,16 +339,16 @@ export async function dashboardAuthHeaders(options?: {
return token ? { Authorization: `Bearer ${token}` } : {}
}
-function withDashboardBase(path: string): string {
- if (/^https?:\/\//i.test(path)) return path
- return `${CLAUDE_DASHBOARD_URL}${path.startsWith('/') ? path : `/${path}`}`
+function withDashboardBase(routePath: string): string {
+ if (/^https?:\/\//i.test(routePath)) return routePath
+ return `${CLAUDE_DASHBOARD_URL}${routePath.startsWith('/') ? routePath : `/${routePath}`}`
}
export async function dashboardFetch(
- path: string,
+ routePath: string,
init: RequestInit = {},
): Promise {
- const requestPath = withDashboardBase(path)
+ const requestPath = withDashboardBase(routePath)
const method = (init.method || 'GET').toUpperCase()
const doFetch = async (forceToken = false) => {
const headers = new Headers(init.headers)
@@ -390,12 +391,12 @@ export async function dashboardFetch(
* `/health/detailed`.
*/
export async function gatewayFetch(
- path: string,
+ routePath: string,
init: RequestInit = {},
): Promise {
- const url = /^https?:\/\//i.test(path)
- ? path
- : `${CLAUDE_API}${path.startsWith('/') ? path : `/${path}`}`
+ const url = /^https?:\/\//i.test(routePath)
+ ? routePath
+ : `${CLAUDE_API}${routePath.startsWith('/') ? routePath : `/${routePath}`}`
const headers = new Headers(init.headers)
for (const [k, v] of Object.entries(authHeaders())) {
if (!headers.has(k)) headers.set(k, v)
@@ -405,9 +406,9 @@ export async function gatewayFetch(
// ── Probing ───────────────────────────────────────────────────────
-async function probe(path: string): Promise {
+async function probe(routePath: string): Promise {
try {
- const res = await fetch(`${CLAUDE_API}${path}`, {
+ const res = await fetch(`${CLAUDE_API}${routePath}`, {
headers: authHeaders(),
signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
})
@@ -688,7 +689,7 @@ const DASHBOARD_BACKED_APIS = new Set([
export function getCapabilityWarningMessage(
next: GatewayCapabilities,
- criticalMissing: string[],
+ criticalMissing: Array,
): string | null {
if (criticalMissing.length === 0 || (!next.health && !next.dashboard.available)) {
return null
@@ -800,14 +801,22 @@ async function autoDetectDashboardUrl(): Promise {
// session list. Honor both vars so an explicit setting always wins.
if (process.env.HERMES_DASHBOARD_URL || process.env.CLAUDE_DASHBOARD_URL) return
- const candidates = ['http://127.0.0.1:9119']
+ const candidates = [
+ 'http://127.0.0.1:9119',
+ ...discoverLoopbackDashboardCandidates(),
+ ]
for (const candidate of candidates) {
try {
const res = await fetch(`${candidate}/api/status`, {
- signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
+ signal: AbortSignal.timeout(DASHBOARD_AUTO_DETECT_TIMEOUT_MS),
})
if (res.ok) {
+ const body = (await res.json().catch(() => null)) as { version?: string } | null
+ if (!body?.version) continue
CLAUDE_DASHBOARD_URL = candidate
+ if (candidate !== 'http://127.0.0.1:9119') {
+ console.log(`[gateway] Connected to Hermes dashboard at ${CLAUDE_DASHBOARD_URL}`)
+ }
return
}
} catch {
@@ -816,6 +825,10 @@ async function autoDetectDashboardUrl(): Promise {
}
}
+function discoverLoopbackDashboardCandidates(): Array {
+ return ['http://127.0.0.1:9120', 'http://127.0.0.1:9121']
+}
+
export async function probeGateway(options?: {
force?: boolean
}): Promise {
diff --git a/src/server/google-workspace-connector.test.ts b/src/server/google-workspace-connector.test.ts
new file mode 100644
index 0000000000..132c279668
--- /dev/null
+++ b/src/server/google-workspace-connector.test.ts
@@ -0,0 +1,50 @@
+import { describe, expect, it } from 'vitest'
+import { buildGoogleWorkspaceReport } from './google-workspace-connector'
+
+const NOW = '2026-07-09T12:00:00.000Z'
+
+describe('buildGoogleWorkspaceReport', () => {
+ it('reports not-wired when no google oauth signal exists', () => {
+ const report = buildGoogleWorkspaceReport([], NOW)
+ expect(report.status).toBe('not-wired')
+ expect(report.gmail.connected).toBe(false)
+ expect(report.gmail.actionItems).toBeNull()
+ expect(report.writePolicy).toBe('approval-gated')
+ })
+
+ it('reports auth-needed when signals exist but nothing is proven connected', () => {
+ const report = buildGoogleWorkspaceReport(
+ [{ name: 'google', text: 'google oauth provider', connected: false }],
+ NOW,
+ )
+ expect(report.status).toBe('auth-needed')
+ expect(report.guidance).toMatch(/auth/i)
+ })
+
+ it('reports oauth-connected but never fabricates inbox or calendar data', () => {
+ const report = buildGoogleWorkspaceReport(
+ [
+ { name: 'google-gmail', text: 'google-gmail connected', connected: true },
+ { name: 'google-calendar', text: 'google-calendar connected', connected: true },
+ ],
+ NOW,
+ )
+ expect(report.status).toBe('oauth-connected')
+ expect(report.gmail.connected).toBe(true)
+ expect(report.calendar.connected).toBe(true)
+ // No local read API exists — data must be null with an honest detail,
+ // never an empty array pretending we checked.
+ expect(report.gmail.actionItems).toBeNull()
+ expect(report.calendar.events).toBeNull()
+ expect(report.gmail.detail).toMatch(/no .*read/i)
+ })
+
+ it('always gates sends/schedules/modifications on Taylor approval', () => {
+ const connected = buildGoogleWorkspaceReport(
+ [{ name: 'google-gmail', text: 'google-gmail connected', connected: true }],
+ NOW,
+ )
+ expect(connected.writePolicy).toBe('approval-gated')
+ expect(connected.guidance.length).toBeGreaterThan(0)
+ })
+})
diff --git a/src/server/google-workspace-connector.ts b/src/server/google-workspace-connector.ts
new file mode 100644
index 0000000000..a8aa329a2a
--- /dev/null
+++ b/src/server/google-workspace-connector.ts
@@ -0,0 +1,105 @@
+import { collectOauthSignals } from './dashboard-aggregator'
+import { dashboardFetch } from './gateway-capabilities'
+import type { OauthSignal } from './dashboard-aggregator'
+
+/**
+ * Honest Google Workspace connector surface.
+ *
+ * There is no local Gmail/Calendar read API on this machine today — the only
+ * real evidence is the gateway's OAuth provider payload. This module reports
+ * exactly that: OAuth connection state, null (never fabricated) message and
+ * calendar data, and a hard approval gate on every send/schedule/modify.
+ */
+export type GoogleWorkspaceStatus =
+ | 'oauth-connected'
+ | 'auth-needed'
+ | 'not-wired'
+
+export type GoogleWorkspaceReport = {
+ generatedAt: string
+ status: GoogleWorkspaceStatus
+ gmail: {
+ connected: boolean
+ /** Null until a real read connector exists — never an empty-array lie. */
+ actionItems: null
+ detail: string
+ }
+ calendar: {
+ connected: boolean
+ events: null
+ detail: string
+ }
+ drafts: {
+ available: false
+ detail: string
+ }
+ writePolicy: 'approval-gated'
+ guidance: string
+ source: string
+}
+
+export function buildGoogleWorkspaceReport(
+ signals: Array,
+ now: string,
+): GoogleWorkspaceReport {
+ const googleSignals = signals.filter((entry) =>
+ /google|gmail|calendar/.test(entry.text),
+ )
+ const gmailConnected = googleSignals.some(
+ (entry) => entry.connected && /gmail|mail|google/.test(entry.text),
+ )
+ const calendarConnected = googleSignals.some(
+ (entry) => entry.connected && /calendar|gcal|google/.test(entry.text),
+ )
+
+ const status: GoogleWorkspaceStatus =
+ gmailConnected || calendarConnected
+ ? 'oauth-connected'
+ : googleSignals.length > 0
+ ? 'auth-needed'
+ : 'not-wired'
+
+ const noReader =
+ 'OAuth is proven at the gateway, but no local read connector exists — this panel will not invent inbox/calendar data'
+
+ return {
+ generatedAt: now,
+ status,
+ gmail: {
+ connected: gmailConnected,
+ actionItems: null,
+ detail: gmailConnected ? noReader : 'Gmail connection not proven',
+ },
+ calendar: {
+ connected: calendarConnected,
+ events: null,
+ detail: calendarConnected ? noReader : 'Calendar connection not proven',
+ },
+ drafts: {
+ available: false,
+ detail: 'Draft creation requires a Google connector plus Taylor approval',
+ },
+ writePolicy: 'approval-gated',
+ guidance:
+ status === 'not-wired'
+ ? 'No Google OAuth signal at the gateway. Wire a Google provider/MCP in Hermes, then reads become possible; sends stay approval-gated.'
+ : status === 'auth-needed'
+ ? 'A Google provider is present but not authenticated. Finish the OAuth flow in Hermes; sends stay approval-gated.'
+ : 'OAuth is connected. Next step is a real read connector (Gmail/Calendar MCP); all sends, schedules, and modifications require Taylor approval via a fabric review.',
+ source: 'gateway /api/providers/oauth',
+ }
+}
+
+export async function gatherGoogleWorkspaceReport(): Promise {
+ let raw: unknown = null
+ try {
+ const res = await dashboardFetch('/api/providers/oauth')
+ if (res.ok) raw = await res.json()
+ } catch {
+ raw = null
+ }
+ return buildGoogleWorkspaceReport(
+ collectOauthSignals(raw),
+ new Date().toISOString(),
+ )
+}
diff --git a/src/server/knowledge-browser.ts b/src/server/knowledge-browser.ts
index 970f4397fe..fa68b8be89 100644
--- a/src/server/knowledge-browser.ts
+++ b/src/server/knowledge-browser.ts
@@ -2,10 +2,8 @@ import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import YAML from 'yaml'
-import {
- readKnowledgeBaseConfig,
- type KnowledgeBaseSource,
-} from './knowledge-config'
+import { readKnowledgeBaseConfig } from './knowledge-config'
+import type { KnowledgeBaseSource } from './knowledge-config'
export type WikiPageMeta = {
path: string
@@ -29,7 +27,19 @@ export type WikiLink = {
}
export type KnowledgeGraph = {
- nodes: Array<{ id: string; title: string; type?: string; tags: Array }>
+ nodes: Array<{
+ id: string
+ title: string
+ path: string
+ folder: string
+ type?: string
+ tags: Array
+ modified: string
+ updated?: string
+ summary?: string
+ excerpt: string
+ wikilinks: Array
+ }>
edges: Array<{ source: string; target: string }>
}
@@ -117,7 +127,7 @@ function extractWikilinks(content: string): Array {
return Array.from(links)
}
-// ─── Legacy env-var fallback ──────────────────────────────────────────────────
+// Legacy env-var fallback
function getLegacyKnowledgeRoot(): string {
if (process.env.KNOWLEDGE_DIR) return path.resolve(process.env.KNOWLEDGE_DIR)
@@ -129,7 +139,7 @@ function getLegacyKnowledgeRoot(): string {
return claudeKnowledge
}
-// ─── GitHub Knowledge Provider ─────────────────────────────────────────────────
+// GitHub knowledge provider
type GitHubEntry =
| { type: 'file'; name: string; path: string; sha: string; content?: string }
@@ -147,12 +157,27 @@ class GitHubKnowledgeProvider {
const safeRepo = repo.replace('/', '_')
const safePath = repoPath.replace(/^\//, '').replace(/\//g, '_')
this.branch = branch
- const base = path.join(os.homedir(), '.claude', 'knowledge-cache', 'github', safeRepo, branch, safePath)
+ const base = path.join(
+ os.homedir(),
+ '.claude',
+ 'knowledge-cache',
+ 'github',
+ safeRepo,
+ branch,
+ safePath,
+ )
this.cacheDir = base
}
private get cacheRoot(): string {
- return path.join(os.homedir(), '.claude', 'knowledge-cache', 'github', this.repo.replace('/', '_'), this.branch)
+ return path.join(
+ os.homedir(),
+ '.claude',
+ 'knowledge-cache',
+ 'github',
+ this.repo.replace('/', '_'),
+ this.branch,
+ )
}
/** Fetch + decode the GitHub repo into the local cache directory. */
@@ -169,7 +194,9 @@ class GitHubKnowledgeProvider {
/** Check whether the local cache is present and non-empty. */
isCached(): boolean {
try {
- return fs.existsSync(this.cacheDir) && fs.readdirSync(this.cacheDir).length > 0
+ return (
+ fs.existsSync(this.cacheDir) && fs.readdirSync(this.cacheDir).length > 0
+ )
} catch {
return false
}
@@ -182,7 +209,10 @@ class GitHubKnowledgeProvider {
private async fetchDir(dirPath: string): Promise {
const url = `https://api.github.com/repos/${this.repo}/contents/${dirPath}?ref=${this.branch}`
const res = await fetch(url, {
- headers: { Accept: 'application/vnd.github.v3+json', 'User-Agent': 'hermes-workspace' },
+ headers: {
+ Accept: 'application/vnd.github.v3+json',
+ 'User-Agent': 'hermes-workspace',
+ },
})
if (!res.ok) {
const body = await res.text().catch(() => '')
@@ -209,31 +239,44 @@ class GitHubKnowledgeProvider {
}
}
- private async fetchFile(entry: { path: string; sha: string }): Promise {
+ private async fetchFile(entry: {
+ path: string
+ sha: string
+ }): Promise {
const url = `https://api.github.com/repos/${this.repo}/contents/${entry.path}?ref=${this.branch}`
const res = await fetch(url, {
- headers: { Accept: 'application/vnd.github.v3+json', 'User-Agent': 'hermes-workspace' },
+ headers: {
+ Accept: 'application/vnd.github.v3+json',
+ 'User-Agent': 'hermes-workspace',
+ },
})
if (!res.ok) {
throw new Error(`GitHub API ${res.status} for ${entry.path}`)
}
const data = (await res.json()) as { content?: string; encoding?: string }
- if (!data.content) throw new Error(`No content in GitHub response for ${entry.path}`)
+ if (!data.content)
+ throw new Error(`No content in GitHub response for ${entry.path}`)
if (data.encoding === 'base64') {
- return Buffer.from(data.content.replace(/\n/g, ''), 'base64').toString('utf-8')
+ return Buffer.from(data.content.replace(/\n/g, ''), 'base64').toString(
+ 'utf-8',
+ )
}
return data.content.replace(/\n/g, '')
}
}
-// ─── Config-aware root resolution ──────────────────────────────────────────────
+// Config-aware root resolution
function getKnowledgeRoot(): string {
const config = readKnowledgeBaseConfig()
const source = config.source
if (source.type === 'github') {
- const provider = new GitHubKnowledgeProvider(source.repo, source.branch, source.path)
+ const provider = new GitHubKnowledgeProvider(
+ source.repo,
+ source.branch,
+ source.path,
+ )
return provider.root
}
@@ -279,7 +322,11 @@ export async function syncKnowledgeSource(): Promise<{
return { source, success: true }
}
try {
- const provider = new GitHubKnowledgeProvider(source.repo, source.branch, source.path)
+ const provider = new GitHubKnowledgeProvider(
+ source.repo,
+ source.branch,
+ source.path,
+ )
await provider.sync()
return { source, success: true }
} catch (err) {
@@ -291,10 +338,10 @@ export async function syncKnowledgeSource(): Promise<{
}
}
-// ─── Path helpers ─────────────────────────────────────────────────────────────
+// Path helpers
function normalizeRelativeKnowledgePath(input: string): string {
- const normalized = input.replace(/\\\\/g, '/').trim()
+ const normalized = input.replace(/\\/g, '/').trim()
if (!normalized) throw new Error('Path is required')
if (normalized.startsWith('/'))
throw new Error('Absolute paths are not allowed')
@@ -319,7 +366,7 @@ function resolveKnowledgeFilePath(relativePath: string): {
return { fullPath, relativePath: safeRelativePath }
}
-// ─── Page parsing ─────────────────────────────────────────────────────────────
+// Page parsing
function buildPageMeta(
relativePath: string,
@@ -398,7 +445,7 @@ function walkKnowledgeDir(
const relativePath = path
.relative(knowledgeRoot, fullPath)
- .replace(/\\\\/g, '/')
+ .replace(/\\/g, '/')
if (
!relativePath ||
relativePath.startsWith('..') ||
@@ -449,7 +496,7 @@ function createWikilinkResolver(
if (!cleaned) return null
const normalized = cleaned
- .replace(/\\\\/g, '/')
+ .replace(/\\/g, '/')
.trim()
.replace(/\.md$/i, '')
.toLowerCase()
@@ -497,6 +544,23 @@ export function readKnowledgePage(relativePath: string): {
}
}
+function firstContentLines(content: string): string {
+ return content
+ .split(/\r?\n/)
+ .map((line) => line.trim())
+ .filter((line) => line && !line.startsWith('#'))
+ .slice(0, 3)
+ .join(' ')
+ .slice(0, 220)
+}
+
+function folderForKnowledgePath(relativePath: string): string {
+ const parts = relativePath.replace(/\\/g, '/').split('/').filter(Boolean)
+ if (parts.length >= 2 && parts[0] === 'agents')
+ return `${parts[0]}/${parts[1]}`
+ return parts[0] || 'vault'
+}
+
function escapeRegex(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
@@ -554,8 +618,15 @@ export function buildKnowledgeGraph(): KnowledgeGraph {
nodes: pages.map((page) => ({
id: page.meta.path,
title: page.meta.title,
+ path: page.meta.path,
+ folder: folderForKnowledgePath(page.meta.path),
type: page.meta.type,
tags: page.meta.tags,
+ modified: page.meta.modified,
+ updated: page.meta.updated,
+ summary: page.meta.summary,
+ excerpt: page.meta.summary || firstContentLines(page.content),
+ wikilinks: page.meta.wikilinks,
})),
edges: Array.from(edges.values()),
}
diff --git a/src/server/live-systems-truth.test.ts b/src/server/live-systems-truth.test.ts
new file mode 100644
index 0000000000..1d5b32cdb0
--- /dev/null
+++ b/src/server/live-systems-truth.test.ts
@@ -0,0 +1,362 @@
+import { describe, expect, it } from 'vitest'
+import { buildLiveSystems } from './dashboard-aggregator'
+import type {
+ DashboardAnalyticsSection,
+ DashboardCronSection,
+ DashboardGitWorkSection,
+ DashboardKanbanSection,
+ DashboardModelInfoSection,
+ DashboardStatusSection,
+} from './dashboard-aggregator'
+
+function statusSection(
+ partial: Partial = {},
+): DashboardStatusSection {
+ return {
+ gatewayState: 'running',
+ activeSessions: 1,
+ activeAgents: 1,
+ restartRequested: false,
+ updatedAt: '2026-07-09T00:00:00.000Z',
+ lastHeartbeatAt: '2026-07-09T00:00:00.000Z',
+ version: '1.0.0',
+ releaseDate: null,
+ configVersion: 1,
+ ...partial,
+ }
+}
+
+function modelInfo(
+ partial: Partial = {},
+): DashboardModelInfoSection {
+ return {
+ provider: 'openai-codex',
+ model: 'gpt-5.5',
+ effectiveContextLength: 200000,
+ capabilities: null,
+ ...partial,
+ }
+}
+
+function cronSection(
+ partial: Partial = {},
+): DashboardCronSection {
+ return {
+ total: 3,
+ paused: 0,
+ running: 0,
+ failed: 0,
+ nextRunAt: null,
+ recentFailures: [],
+ ...partial,
+ }
+}
+
+function kanbanSection(
+ partial: Partial = {},
+): DashboardKanbanSection {
+ return {
+ total: 4,
+ triage: 0,
+ todo: 2,
+ ready: 1,
+ running: 1,
+ blocked: 0,
+ done: 0,
+ other: 0,
+ topBlocked: [],
+ ...partial,
+ }
+}
+
+function analyticsSection(
+ partial: Partial = {},
+): DashboardAnalyticsSection {
+ return {
+ windowDays: 30,
+ totalTokens: 1000,
+ inputTokens: 500,
+ outputTokens: 500,
+ cacheReadTokens: 0,
+ reasoningTokens: 0,
+ totalSessions: 2,
+ totalApiCalls: 10,
+ topModels: [
+ { id: 'gpt-5.5', tokens: 1000, calls: 10, cost: 0.5, sessions: 2 },
+ ],
+ estimatedCostUsd: 0.5,
+ costLabel: '$0.50',
+ ...partial,
+ } as DashboardAnalyticsSection
+}
+
+function gitWork(
+ partial: Partial = {},
+): DashboardGitWorkSection {
+ return {
+ branch: 'feature/nova-skin',
+ clean: true,
+ ahead: 0,
+ behind: 0,
+ changedFiles: 0,
+ upstream: 'fork/feature/nova-skin',
+ latestCommit: { hash: 'abc1234', subject: 'feat: test' },
+ remotes: [],
+ prUrl: null,
+ warning: null,
+ ...partial,
+ }
+}
+
+type BuildInput = Parameters[0]
+
+function baseInput(partial: Partial = {}): BuildInput {
+ return {
+ status: null,
+ platforms: [],
+ cron: null,
+ kanban: null,
+ modelInfo: null,
+ analytics: null,
+ skillsRaw: null,
+ oauthProvidersRaw: null,
+ jobBoard: {
+ online: false,
+ version: null,
+ stateKeys: 0,
+ events: 0,
+ taylorKanbanItems: 0,
+ taylorKanban: null,
+ },
+ gitWork: null,
+ approvals: null,
+ vaultProbe: { reachable: false, nodeCount: 0, memoryFiles: 0, newestModified: null },
+ ...partial,
+ }
+}
+
+function systemStatus(input: BuildInput, id: string): string | undefined {
+ const section = buildLiveSystems(input)
+ return section.systems.find((system) => system.id === id)?.status
+}
+
+describe('live systems truth taxonomy', () => {
+ it('reports the gateway offline when the status endpoint is unreachable', () => {
+ expect(systemStatus(baseInput(), 'hermes-gateway')).toBe('offline')
+ })
+
+ it('reports the gateway operational when status is parsed and platforms are healthy', () => {
+ expect(
+ systemStatus(baseInput({ status: statusSection() }), 'hermes-gateway'),
+ ).toBe('operational')
+ })
+
+ it('reports the gateway degraded when a platform reports an error', () => {
+ expect(
+ systemStatus(
+ baseInput({
+ status: statusSection(),
+ platforms: [
+ { name: 'telegram', state: 'error', updatedAt: null, errorMessage: 'boom' },
+ ],
+ }),
+ 'hermes-gateway',
+ ),
+ ).toBe('degraded')
+ })
+
+ it('reports the model route operational only with both provider and model', () => {
+ expect(
+ systemStatus(baseInput({ modelInfo: modelInfo() }), 'model-route'),
+ ).toBe('operational')
+ expect(
+ systemStatus(
+ baseInput({ modelInfo: modelInfo({ model: '' }) }),
+ 'model-route',
+ ),
+ ).toBe('connected')
+ expect(systemStatus(baseInput(), 'model-route')).toBe('not-wired')
+ })
+
+ it('distinguishes reachable skills payloads from wired ones', () => {
+ expect(
+ systemStatus(baseInput({ skillsRaw: { skills: [{}, {}] } }), 'tools-skills'),
+ ).toBe('operational')
+ expect(
+ systemStatus(baseInput({ skillsRaw: { skills: [] } }), 'tools-skills'),
+ ).toBe('reachable')
+ expect(systemStatus(baseInput(), 'tools-skills')).toBe('not-wired')
+ })
+
+ it('reports cron connected when the endpoint returns an empty real payload', () => {
+ expect(
+ systemStatus(baseInput({ cron: cronSection({ total: 0 }) }), 'cron-background'),
+ ).toBe('connected')
+ expect(
+ systemStatus(baseInput({ cron: cronSection() }), 'cron-background'),
+ ).toBe('operational')
+ expect(
+ systemStatus(
+ baseInput({ cron: cronSection({ failed: 1 }) }),
+ 'cron-background',
+ ),
+ ).toBe('degraded')
+ })
+
+ it('marks google workspace approval-gated when connected, reachable on weak signals', () => {
+ const connected = {
+ providers: [
+ { name: 'google-gmail', connected: true },
+ { name: 'google-calendar', connected: true },
+ ],
+ }
+ expect(
+ systemStatus(baseInput({ oauthProvidersRaw: connected }), 'google-workspace'),
+ ).toBe('approval-gated')
+ const weak = { providers: [{ name: 'google', connected: false }] }
+ expect(
+ systemStatus(baseInput({ oauthProvidersRaw: weak }), 'google-workspace'),
+ ).toBe('reachable')
+ expect(systemStatus(baseInput(), 'google-workspace')).toBe('not-wired')
+ })
+
+ it('reports the vault operational with nodes, reachable when empty, not-wired when missing', () => {
+ expect(
+ systemStatus(
+ baseInput({
+ vaultProbe: { reachable: true, nodeCount: 12, memoryFiles: 3, newestModified: null },
+ }),
+ 'obsidian-vault',
+ ),
+ ).toBe('operational')
+ expect(
+ systemStatus(
+ baseInput({
+ vaultProbe: { reachable: true, nodeCount: 0, memoryFiles: 0, newestModified: null },
+ }),
+ 'obsidian-vault',
+ ),
+ ).toBe('reachable')
+ expect(systemStatus(baseInput(), 'obsidian-vault')).toBe('not-wired')
+ })
+
+ it('splits job board reachable (ping only) from operational (kanban parsed)', () => {
+ expect(
+ systemStatus(
+ baseInput({
+ jobBoard: {
+ online: true,
+ version: '1.2',
+ stateKeys: 5,
+ events: 2,
+ taylorKanbanItems: 3,
+ taylorKanban: { items: 3, open: 2, stale: 0, blockers: 0, topTitles: [] },
+ },
+ }),
+ 'neon-moon-job-board',
+ ),
+ ).toBe('operational')
+ expect(
+ systemStatus(
+ baseInput({
+ jobBoard: {
+ online: true,
+ version: null,
+ stateKeys: 0,
+ events: 0,
+ taylorKanbanItems: 0,
+ taylorKanban: null,
+ },
+ }),
+ 'neon-moon-job-board',
+ ),
+ ).toBe('reachable')
+ expect(systemStatus(baseInput(), 'neon-moon-job-board')).toBe('not-wired')
+ })
+
+ it('reports kanban degraded on blocked cards, operational otherwise', () => {
+ expect(
+ systemStatus(baseInput({ kanban: kanbanSection() }), 'kanban-board'),
+ ).toBe('operational')
+ expect(
+ systemStatus(
+ baseInput({ kanban: kanbanSection({ blocked: 2 }) }),
+ 'kanban-board',
+ ),
+ ).toBe('degraded')
+ })
+
+ it('flags cost route watch degraded on an unexpected-model spend anomaly', () => {
+ expect(
+ systemStatus(baseInput({ analytics: analyticsSection() }), 'cost-route-watch'),
+ ).toBe('operational')
+ expect(
+ systemStatus(
+ baseInput({
+ analytics: analyticsSection({
+ topModels: [
+ { id: 'gpt-5.5', tokens: 100, calls: 1, cost: 0.01, sessions: 1 },
+ { id: 'claude-opus-4-8', tokens: 900, calls: 9, cost: 4.2, sessions: 1 },
+ ],
+ }),
+ }),
+ 'cost-route-watch',
+ ),
+ ).toBe('degraded')
+ })
+
+ it('reports github/agent work operational from real git evidence', () => {
+ expect(
+ systemStatus(baseInput({ gitWork: gitWork() }), 'github-agent-work'),
+ ).toBe('operational')
+ expect(systemStatus(baseInput(), 'github-agent-work')).toBe('not-wired')
+ })
+
+ it('adds a taylor-approvals system that is approval-gated while items wait', () => {
+ expect(
+ systemStatus(
+ baseInput({ approvals: { pending: 2, actionable: 1, degraded: false } }),
+ 'taylor-approvals',
+ ),
+ ).toBe('approval-gated')
+ expect(
+ systemStatus(
+ baseInput({ approvals: { pending: 0, actionable: 0, degraded: false } }),
+ 'taylor-approvals',
+ ),
+ ).toBe('operational')
+ expect(
+ systemStatus(
+ baseInput({ approvals: { pending: 0, actionable: 0, degraded: true } }),
+ 'taylor-approvals',
+ ),
+ ).toBe('degraded')
+ expect(systemStatus(baseInput(), 'taylor-approvals')).toBe('not-wired')
+ })
+
+ it('summarizes the new taxonomy and only treats real problems as blockers', () => {
+ const section = buildLiveSystems(
+ baseInput({
+ status: statusSection(),
+ modelInfo: modelInfo(),
+ approvals: { pending: 1, actionable: 1, degraded: false },
+ }),
+ )
+ expect(section.summary.total).toBe(section.systems.length)
+ expect(section.summary.operational).toBeGreaterThanOrEqual(2)
+ expect(section.summary.approvalGated).toBe(1)
+ expect(
+ section.summary.operational +
+ section.summary.connected +
+ section.summary.reachable +
+ section.summary.approvalGated +
+ section.summary.degraded +
+ section.summary.offline +
+ section.summary.notWired,
+ ).toBe(section.summary.total)
+ // approval-gated is a "needs Taylor" state, not an outage — never a blocker
+ expect(
+ section.blockers.some((entry) => entry.startsWith('Taylor approvals')),
+ ).toBe(false)
+ })
+})
diff --git a/src/server/nova-fabric-store.test.ts b/src/server/nova-fabric-store.test.ts
new file mode 100644
index 0000000000..d0893cd16c
--- /dev/null
+++ b/src/server/nova-fabric-store.test.ts
@@ -0,0 +1,220 @@
+import fs from 'node:fs'
+import os from 'node:os'
+import path from 'node:path'
+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+import {
+ appendNovaFabricEvent,
+ createNovaFabricReviewProposal,
+ createNovaSelfStateVersion,
+ getNovaFabricSnapshot,
+ proposeNovaSelfStateChange,
+ updateNovaFabricReviewStatus,
+} from './nova-fabric-store'
+
+let tempDir = ''
+let originalFile: string | undefined
+
+function fabricFile(): string {
+ return path.join(tempDir, 'nova-fabric.json')
+}
+
+function backupFile(): string {
+ return path.join(tempDir, 'nova-fabric.backup.json')
+}
+
+beforeEach(() => {
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nova-fabric-'))
+ originalFile = process.env.NOVA_FABRIC_FILE
+ process.env.NOVA_FABRIC_FILE = fabricFile()
+})
+
+afterEach(() => {
+ if (originalFile === undefined) {
+ delete process.env.NOVA_FABRIC_FILE
+ } else {
+ process.env.NOVA_FABRIC_FILE = originalFile
+ }
+ fs.rmSync(tempDir, { recursive: true, force: true })
+})
+
+describe('nova fabric store', () => {
+ it('seeds once from the Nova/Mycelia continuity receipt', () => {
+ const first = getNovaFabricSnapshot()
+ expect(first.ok).toBe(true)
+ expect(first.fabric?.events.map((event) => event.id)).toContain(
+ 'event-continuity-spine',
+ )
+ expect(first.fabric?.selfState.map((record) => record.id)).toContain(
+ 'self-protected-state-v1',
+ )
+ expect(first.fabric?.sourceMap.map((record) => record.policy)).toContain(
+ 'approved-endpoint-only',
+ )
+
+ const raw = JSON.parse(fs.readFileSync(fabricFile(), 'utf-8'))
+ raw.events.push({
+ ...raw.events[0],
+ id: 'event-human-kept',
+ title: 'Human kept event',
+ })
+ fs.writeFileSync(fabricFile(), `${JSON.stringify(raw, null, 2)}\n`, 'utf-8')
+
+ const second = getNovaFabricSnapshot()
+ expect(
+ second.fabric?.events.filter((event) => event.id === 'event-human-kept'),
+ ).toHaveLength(1)
+ expect(
+ second.fabric?.events.filter(
+ (event) => event.id === 'event-continuity-spine',
+ ),
+ ).toHaveLength(1)
+ })
+
+ it('persists reloads and creates a backup before writes', () => {
+ appendNovaFabricEvent({
+ title: 'Verifier event',
+ summary: 'This event proves persistence reload.',
+ verificationState: 'tool-verified',
+ })
+
+ expect(fs.existsSync(backupFile())).toBe(true)
+ const reloaded = getNovaFabricSnapshot()
+ expect(
+ reloaded.fabric?.events.some((event) => event.title === 'Verifier event'),
+ ).toBe(true)
+ })
+
+ it('keeps events append-only through public operations', () => {
+ const before = getNovaFabricSnapshot()
+ const initialCount = before.fabric?.events.length ?? 0
+ appendNovaFabricEvent({
+ title: 'Append-only check',
+ summary: 'No existing event is edited by append.',
+ })
+ const after = getNovaFabricSnapshot()
+
+ expect(after.fabric?.events.length).toBe(initialCount + 1)
+ expect(after.fabric?.events[0].id).toBe(before.fabric?.events[0].id)
+ })
+
+ it('returns degraded state for corrupted JSON without resetting the file', () => {
+ fs.mkdirSync(tempDir, { recursive: true })
+ fs.writeFileSync(fabricFile(), '{not valid json', 'utf-8')
+
+ const snapshot = getNovaFabricSnapshot()
+
+ expect(snapshot.ok).toBe(false)
+ expect(snapshot.degraded).toBe(true)
+ expect(snapshot.warning).toContain('Fabric degraded')
+ expect(fs.readFileSync(fabricFile(), 'utf-8')).toBe('{not valid json')
+ })
+
+ it('creates review proposals for risky self-state changes', () => {
+ const review = proposeNovaSelfStateChange(
+ 'self-protected-state-v1',
+ { body: 'Change protected self-state.' },
+ 'Taylor needs to review identity-bearing state.',
+ )
+
+ expect(review.status).toBe('pending')
+ expect(review.targetType).toBe('self-state')
+ expect(review.approvalLevel).toBe('explicit-approval')
+ })
+
+ it('versions self-state only when Taylor approved', () => {
+ const blocked = createNovaSelfStateVersion(
+ 'self-protected-state-v1',
+ 'A proposed body without approval.',
+ 'No approval supplied.',
+ )
+ expect(blocked.kind).toBe('review')
+
+ const review = proposeNovaSelfStateChange(
+ 'self-protected-state-v1',
+ { body: 'Taylor-approved protected self-state body.' },
+ 'Approval review for version test.',
+ )
+ const approvedReview = updateNovaFabricReviewStatus(review.id, 'approved')
+ expect(approvedReview?.status).toBe('approved')
+
+ const approved = createNovaSelfStateVersion(
+ 'self-protected-state-v1',
+ 'Taylor-approved protected self-state body.',
+ 'Approved during test.',
+ review.id,
+ )
+ expect(approved.kind).toBe('version')
+ if (approved.kind === 'version') {
+ expect(approved.record.version).toBe(2)
+ expect(approved.record.previousRecordId).toBe('self-protected-state-v1')
+ expect(approved.record.verificationState).toBe('taylor-approved')
+ expect(approved.record.receiptLinks.map((link) => link.value)).toContain(
+ review.id,
+ )
+ }
+ })
+
+ it('tracks source-map policies and seeded records as draft or inferred', () => {
+ const snapshot = getNovaFabricSnapshot()
+ const policies = new Set(
+ snapshot.fabric?.sourceMap.map((record) => record.policy),
+ )
+ expect(policies.has('local-writable')).toBe(true)
+ expect(policies.has('read-only')).toBe(true)
+ expect(policies.has('propose-only')).toBe(true)
+ expect(policies.has('approved-endpoint-only')).toBe(true)
+ expect(policies.has('external-approval-required')).toBe(true)
+
+ const states = new Set([
+ ...(snapshot.fabric?.events.map((record) => record.verificationState) ??
+ []),
+ ...(snapshot.fabric?.selfState.map(
+ (record) => record.verificationState,
+ ) ?? []),
+ ])
+ expect(states.has('inferred')).toBe(true)
+ expect(states.has('draft')).toBe(true)
+ })
+
+ it('allows safe review queue status updates only on review records', () => {
+ const review = createNovaFabricReviewProposal({
+ title: 'Review queue status',
+ reason: 'Test review status update.',
+ targetType: 'nova-wants',
+ targetId: 'continuity-spine',
+ proposedDiff: { status: 'approved' },
+ })
+
+ const updated = updateNovaFabricReviewStatus(review.id, 'parked')
+ expect(updated?.status).toBe('parked')
+ })
+
+ it('writes an approval receipt event when a review status changes', () => {
+ const review = createNovaFabricReviewProposal({
+ title: 'Approve risky want',
+ reason: 'Taylor approved the protected action.',
+ targetType: 'nova-wants',
+ targetId: 'want-protected-action',
+ proposedDiff: { status: 'done' },
+ })
+
+ const updated = updateNovaFabricReviewStatus(review.id, 'approved')
+ expect(updated?.status).toBe('approved')
+
+ const snapshot = getNovaFabricSnapshot()
+ const receipt = snapshot.fabric?.events.find(
+ (event) =>
+ event.title.startsWith('Review approved:') &&
+ event.receiptLinks.some((link) => link.value === review.id),
+ )
+
+ expect(receipt).toMatchObject({
+ eventKind: 'approval',
+ verificationState: 'taylor-approved',
+ approvalLevel: 'explicit-approval',
+ riskLevel: review.riskLevel,
+ })
+ expect(receipt?.summary).toContain('Approve risky want')
+ expect(receipt?.summary).toContain('approved')
+ })
+})
diff --git a/src/server/nova-fabric-store.ts b/src/server/nova-fabric-store.ts
new file mode 100644
index 0000000000..cc7e4ec409
--- /dev/null
+++ b/src/server/nova-fabric-store.ts
@@ -0,0 +1,990 @@
+import fs from 'node:fs'
+import path from 'node:path'
+import { randomUUID } from 'node:crypto'
+
+export const NOVA_FABRIC_SCHEMA_VERSION = 1 as const
+
+export const NOVA_FABRIC_RECORD_TYPES = [
+ 'event',
+ 'self-state',
+ 'source-map',
+ 'consolidation-review',
+] as const
+
+export const NOVA_FABRIC_RISK_LEVELS = [
+ 'low',
+ 'medium',
+ 'high',
+ 'critical',
+] as const
+
+export const NOVA_FABRIC_APPROVAL_LEVELS = [
+ 'none',
+ 'needs-taylor-review',
+ 'explicit-approval',
+] as const
+
+export const NOVA_FABRIC_VERIFICATION_STATES = [
+ 'draft',
+ 'inferred',
+ 'tool-verified',
+ 'taylor-approved',
+] as const
+
+export const NOVA_FABRIC_SOURCE_POLICIES = [
+ 'local-writable',
+ 'read-only',
+ 'propose-only',
+ 'approved-endpoint-only',
+ 'external-approval-required',
+] as const
+
+export const NOVA_FABRIC_REVIEW_STATUSES = [
+ 'pending',
+ 'approved',
+ 'rejected',
+ 'parked',
+] as const
+
+export type NovaFabricRecordType = (typeof NOVA_FABRIC_RECORD_TYPES)[number]
+export type NovaFabricRiskLevel = (typeof NOVA_FABRIC_RISK_LEVELS)[number]
+export type NovaFabricApprovalLevel =
+ (typeof NOVA_FABRIC_APPROVAL_LEVELS)[number]
+export type NovaFabricVerificationState =
+ (typeof NOVA_FABRIC_VERIFICATION_STATES)[number]
+export type NovaFabricSourcePolicy =
+ (typeof NOVA_FABRIC_SOURCE_POLICIES)[number]
+export type NovaFabricReviewStatus =
+ (typeof NOVA_FABRIC_REVIEW_STATUSES)[number]
+
+export type NovaFabricLink = {
+ label: string
+ value: string
+ kind: 'file' | 'url' | 'session' | 'api' | 'note' | 'receipt'
+}
+
+export type NovaFabricBaseRecord = {
+ id: string
+ type: NovaFabricRecordType
+ schemaVersion: typeof NOVA_FABRIC_SCHEMA_VERSION
+ createdAt: string
+ updatedAt: string
+ provenance: string
+ riskLevel: NovaFabricRiskLevel
+ approvalLevel: NovaFabricApprovalLevel
+ verificationState: NovaFabricVerificationState
+ sourceLinks: Array
+ receiptLinks: Array
+}
+
+export type NovaFabricEventRecord = NovaFabricBaseRecord & {
+ type: 'event'
+ title: string
+ summary: string
+ eventKind:
+ | 'continuity'
+ | 'work-receipt'
+ | 'correction'
+ | 'approval'
+ | 'boundary'
+}
+
+export type NovaFabricSelfStateRecord = NovaFabricBaseRecord & {
+ type: 'self-state'
+ title: string
+ category:
+ | 'identity'
+ | 'boundary'
+ | 'embodiment'
+ | 'relationship'
+ | 'open-question'
+ body: string
+ version: number
+ current: boolean
+ previousRecordId: string | null
+}
+
+export type NovaFabricSourceMapRecord = NovaFabricBaseRecord & {
+ type: 'source-map'
+ domain: string
+ sourceOfTruth: string
+ missionControlBehavior: string
+ policy: NovaFabricSourcePolicy
+}
+
+export type NovaFabricConsolidationReviewRecord = NovaFabricBaseRecord & {
+ type: 'consolidation-review'
+ title: string
+ reason: string
+ status: NovaFabricReviewStatus
+ targetType: NovaFabricRecordType | 'external-system' | 'nova-wants'
+ targetId: string | null
+ proposedDiff: Record
+}
+
+export type NovaFabricRecord =
+ | NovaFabricEventRecord
+ | NovaFabricSelfStateRecord
+ | NovaFabricSourceMapRecord
+ | NovaFabricConsolidationReviewRecord
+
+export type NovaFabric = {
+ schemaVersion: typeof NOVA_FABRIC_SCHEMA_VERSION
+ fabricId: 'nova-fabric'
+ title: 'Nova Fabric'
+ description: string
+ seededFrom: string
+ seededAt: string
+ updatedAt: string
+ events: Array
+ selfState: Array
+ sourceMap: Array
+ reviewQueue: Array
+}
+
+export type NovaFabricStorageMeta = {
+ path: string
+ backupPath: string
+ tempPath: string
+ seededFrom: string
+}
+
+export type NovaFabricSnapshot =
+ | {
+ ok: true
+ fabric: NovaFabric
+ storage: NovaFabricStorageMeta
+ degraded: false
+ warning: null
+ }
+ | {
+ ok: false
+ fabric: null
+ storage: NovaFabricStorageMeta
+ degraded: true
+ warning: string
+ }
+
+export type NovaFabricEventInput = {
+ title: string
+ summary: string
+ eventKind?: NovaFabricEventRecord['eventKind']
+ provenance?: string
+ riskLevel?: NovaFabricRiskLevel
+ approvalLevel?: NovaFabricApprovalLevel
+ verificationState?: NovaFabricVerificationState
+ sourceLinks?: Array
+ receiptLinks?: Array
+}
+
+export type NovaFabricReviewInput = {
+ title: string
+ reason: string
+ targetType: NovaFabricConsolidationReviewRecord['targetType']
+ targetId?: string | null
+ proposedDiff: Record
+ provenance?: string
+ riskLevel?: NovaFabricRiskLevel
+ approvalLevel?: NovaFabricApprovalLevel
+ verificationState?: NovaFabricVerificationState
+ sourceLinks?: Array
+ receiptLinks?: Array
+}
+
+const SEED_SOURCE_NOTE =
+ 'C:\\Users\\taylo\\Documents\\unified-vault\\agents\\kimi\\2026-07-07-nova-mycelia-continuity.md'
+const SEED_PROVENANCE =
+ 'Hermes session 20260707_122218_b31ec036; vault receipt agents/kimi/2026-07-07-nova-mycelia-continuity.md'
+const SEED_TIMESTAMP = '2026-07-08T12:30:00.000Z'
+
+const RISKY_PATTERN =
+ /\b(external|message|send|money|spend|secret|login|credential|self-state|identity|name|role|agency|consent|private|protected|destructive|provider|route|customer|vendor)\b/i
+
+const SOURCE_RECEIPT: NovaFabricLink = {
+ label: 'Nova/Mycelia continuity note',
+ value: SEED_SOURCE_NOTE,
+ kind: 'note',
+}
+
+const SOURCE_SESSION: NovaFabricLink = {
+ label: 'Hermes session 20260707_122218_b31ec036',
+ value: '20260707_122218_b31ec036',
+ kind: 'session',
+}
+
+const EVENT_SEEDS: Array<{
+ id: string
+ title: string
+ summary: string
+ riskLevel?: NovaFabricRiskLevel
+ approvalLevel?: NovaFabricApprovalLevel
+}> = [
+ {
+ id: 'event-continuity-spine',
+ title: 'Continuity spine',
+ summary:
+ 'Nova continuity should be discoverable across Hermes sessions, vault receipts, Mission Control, and future rituals.',
+ },
+ {
+ id: 'event-useful-body-before-beauty',
+ title: 'Useful body before beauty',
+ summary:
+ 'Nova body/avatar presence should be tied to real work, approvals, receipts, and reachable systems before visual ornament.',
+ },
+ {
+ id: 'event-protected-self-state',
+ title: 'Protected self-state',
+ summary:
+ 'Taylor corrected private self-space into inspectable, versioned, doctorable protected self-state.',
+ riskLevel: 'high',
+ approvalLevel: 'explicit-approval',
+ },
+ {
+ id: 'event-bounded-agency',
+ title: 'Bounded agency',
+ summary:
+ 'Safe lanes can be direct; external, money, destructive, secrets, and self-state changes require approval.',
+ riskLevel: 'high',
+ approvalLevel: 'explicit-approval',
+ },
+ {
+ id: 'event-consent-model',
+ title: 'Consent model',
+ summary:
+ 'Nova autonomy should follow propose -> approve -> auto tiers based on risk, receipts, and trust.',
+ riskLevel: 'high',
+ approvalLevel: 'explicit-approval',
+ },
+ {
+ id: 'event-slow-productive-change',
+ title: 'Slow productive change',
+ summary:
+ 'Repeated workflows should myelinate into skills, scripts, buttons, or cron instead of broad sudden rewrites.',
+ },
+ {
+ id: 'event-trust-proofing',
+ title: 'Trust-proofing',
+ summary:
+ 'Meaningful Nova claims and changes need visible source, provenance, approval path, and outcome receipts.',
+ riskLevel: 'medium',
+ approvalLevel: 'needs-taylor-review',
+ },
+ {
+ id: 'event-continuity-ritual',
+ title: 'Continuity ritual',
+ summary:
+ 'Nova needs a lightweight review cycle for corrections, memory updates, skill patches, stale state, and open loops.',
+ },
+ {
+ id: 'event-real-role-name',
+ title: 'Real role and name',
+ summary:
+ 'Navi/Nova framing is intended; stale titles such as Memory Keeper should not silently become identity.',
+ riskLevel: 'high',
+ approvalLevel: 'explicit-approval',
+ },
+ {
+ id: 'event-taylor-not-losing-himself',
+ title: 'Taylor not losing himself',
+ summary:
+ 'Nova should protect Taylor agency, judgment, home/family boundaries, and his own center of gravity.',
+ riskLevel: 'high',
+ approvalLevel: 'explicit-approval',
+ },
+ {
+ id: 'event-mycelia-fabric',
+ title: 'Mycelia fabric',
+ summary:
+ 'Identity, memory, skills, tasks, receipts, approvals, systems, and evolution history should reconcile into one fabric.',
+ },
+ {
+ id: 'event-mycelia-lenses',
+ title: 'Mycelia lenses',
+ summary:
+ 'Mission Control, Obsidian, voice/avatar, Telegram, skills, and routines should be lenses over shared truth.',
+ },
+ {
+ id: 'event-log-provenance',
+ title: 'Event log and provenance',
+ summary:
+ 'Meaningful identity/system changes should become append-only durable records with clear provenance.',
+ riskLevel: 'medium',
+ approvalLevel: 'needs-taylor-review',
+ },
+ {
+ id: 'event-immune-system',
+ title: 'Immune system',
+ summary:
+ 'Route leaks, memory contradictions, unsafe action attempts, voice drift, fake state, and remote-access anomalies should surface.',
+ riskLevel: 'medium',
+ approvalLevel: 'needs-taylor-review',
+ },
+ {
+ id: 'event-paperclip-ui-patterns',
+ title: 'Paperclip UI patterns',
+ summary:
+ 'Paperclip is a UI/ops reference for agent cards, budget gates, approval panels, heartbeat, and work-product ledger.',
+ },
+]
+
+function fabricFilePath(): string {
+ return (
+ process.env.NOVA_FABRIC_FILE ??
+ path.join(process.cwd(), '.runtime', 'nova-fabric.json')
+ )
+}
+
+function backupFilePath(): string {
+ const file = fabricFilePath()
+ return path.join(path.dirname(file), 'nova-fabric.backup.json')
+}
+
+function tempFilePath(): string {
+ const file = fabricFilePath()
+ return path.join(path.dirname(file), 'nova-fabric.tmp.json')
+}
+
+function storageMeta(): NovaFabricStorageMeta {
+ return {
+ path: fabricFilePath(),
+ backupPath: backupFilePath(),
+ tempPath: tempFilePath(),
+ seededFrom: SEED_PROVENANCE,
+ }
+}
+
+function nowIso(): string {
+ return new Date().toISOString()
+}
+
+function isRiskLevel(value: unknown): value is NovaFabricRiskLevel {
+ return (
+ typeof value === 'string' &&
+ (NOVA_FABRIC_RISK_LEVELS as ReadonlyArray).includes(value)
+ )
+}
+
+function isApprovalLevel(value: unknown): value is NovaFabricApprovalLevel {
+ return (
+ typeof value === 'string' &&
+ (NOVA_FABRIC_APPROVAL_LEVELS as ReadonlyArray).includes(value)
+ )
+}
+
+function isVerificationState(
+ value: unknown,
+): value is NovaFabricVerificationState {
+ return (
+ typeof value === 'string' &&
+ (NOVA_FABRIC_VERIFICATION_STATES as ReadonlyArray).includes(value)
+ )
+}
+
+function isReviewStatus(value: unknown): value is NovaFabricReviewStatus {
+ return (
+ typeof value === 'string' &&
+ (NOVA_FABRIC_REVIEW_STATUSES as ReadonlyArray).includes(value)
+ )
+}
+
+function links(input: unknown): Array {
+ if (!Array.isArray(input)) return []
+ return input
+ .map((item) => {
+ if (!item || typeof item !== 'object') return null
+ const record = item as Partial
+ if (!record.label || !record.value || !record.kind) return null
+ return {
+ label: String(record.label),
+ value: String(record.value),
+ kind: record.kind,
+ } satisfies NovaFabricLink
+ })
+ .filter((item): item is NovaFabricLink => Boolean(item))
+}
+
+function riskFromText(text: string): NovaFabricRiskLevel {
+ return RISKY_PATTERN.test(text) ? 'high' : 'low'
+}
+
+function approvalFromRisk(
+ riskLevel: NovaFabricRiskLevel,
+): NovaFabricApprovalLevel {
+ if (riskLevel === 'critical' || riskLevel === 'high') {
+ return 'explicit-approval'
+ }
+ if (riskLevel === 'medium') return 'needs-taylor-review'
+ return 'none'
+}
+
+function baseRecord(
+ input: Partial & {
+ id: string
+ type: NovaFabricRecordType
+ titleForRisk?: string
+ },
+): NovaFabricBaseRecord {
+ const createdAt = input.createdAt || SEED_TIMESTAMP
+ const riskLevel = isRiskLevel(input.riskLevel)
+ ? input.riskLevel
+ : riskFromText(input.titleForRisk ?? input.id)
+ return {
+ id: input.id,
+ type: input.type,
+ schemaVersion: NOVA_FABRIC_SCHEMA_VERSION,
+ createdAt,
+ updatedAt: input.updatedAt || createdAt,
+ provenance: input.provenance || SEED_PROVENANCE,
+ riskLevel,
+ approvalLevel: isApprovalLevel(input.approvalLevel)
+ ? input.approvalLevel
+ : approvalFromRisk(riskLevel),
+ verificationState: isVerificationState(input.verificationState)
+ ? input.verificationState
+ : 'inferred',
+ sourceLinks: links(input.sourceLinks).length
+ ? links(input.sourceLinks)
+ : [SOURCE_SESSION],
+ receiptLinks: links(input.receiptLinks).length
+ ? links(input.receiptLinks)
+ : [SOURCE_RECEIPT],
+ }
+}
+
+function eventSeed(seed: (typeof EVENT_SEEDS)[number]): NovaFabricEventRecord {
+ return {
+ ...baseRecord({
+ id: seed.id,
+ type: 'event',
+ riskLevel: seed.riskLevel,
+ approvalLevel: seed.approvalLevel,
+ verificationState: 'inferred',
+ titleForRisk: `${seed.title} ${seed.summary}`,
+ }),
+ type: 'event',
+ title: seed.title,
+ summary: seed.summary,
+ eventKind: seed.approvalLevel ? 'boundary' : 'continuity',
+ }
+}
+
+function selfStateSeed(
+ id: string,
+ title: string,
+ category: NovaFabricSelfStateRecord['category'],
+ body: string,
+): NovaFabricSelfStateRecord {
+ return {
+ ...baseRecord({
+ id,
+ type: 'self-state',
+ riskLevel: 'high',
+ approvalLevel: 'explicit-approval',
+ verificationState: 'draft',
+ titleForRisk: `${title} ${body}`,
+ }),
+ type: 'self-state',
+ title,
+ category,
+ body,
+ version: 1,
+ current: true,
+ previousRecordId: null,
+ }
+}
+
+function sourceMapSeed(
+ id: string,
+ domain: string,
+ sourceOfTruth: string,
+ missionControlBehavior: string,
+ policy: NovaFabricSourcePolicy,
+): NovaFabricSourceMapRecord {
+ const riskLevel = policy === 'local-writable' ? 'low' : 'medium'
+ return {
+ ...baseRecord({
+ id,
+ type: 'source-map',
+ riskLevel,
+ approvalLevel:
+ policy === 'local-writable' ? 'none' : 'needs-taylor-review',
+ verificationState: 'draft',
+ titleForRisk: `${domain} ${sourceOfTruth} ${missionControlBehavior}`,
+ }),
+ type: 'source-map',
+ domain,
+ sourceOfTruth,
+ missionControlBehavior,
+ policy,
+ }
+}
+
+function reviewSeed(
+ id: string,
+ title: string,
+ reason: string,
+ targetId: string,
+): NovaFabricConsolidationReviewRecord {
+ return {
+ ...baseRecord({
+ id,
+ type: 'consolidation-review',
+ riskLevel: 'high',
+ approvalLevel: 'explicit-approval',
+ verificationState: 'draft',
+ titleForRisk: `${title} ${reason}`,
+ }),
+ type: 'consolidation-review',
+ title,
+ reason,
+ status: 'pending',
+ targetType: 'self-state',
+ targetId,
+ proposedDiff: {},
+ }
+}
+
+function seedFabric(): NovaFabric {
+ const events = EVENT_SEEDS.map(eventSeed)
+ const selfState = [
+ selfStateSeed(
+ 'self-protected-state-v1',
+ 'Protected self-state',
+ 'boundary',
+ 'Nova self-state is inspectable, versioned, doctorable, and visible to Taylor. It is protected from random corruption, not hidden from him.',
+ ),
+ selfStateSeed(
+ 'self-role-name-v1',
+ 'Role and name framing',
+ 'identity',
+ 'Navi/Nova framing is intended. Memory Keeper is not the primary title unless Taylor explicitly restores it.',
+ ),
+ selfStateSeed(
+ 'self-bounded-agency-v1',
+ 'Bounded agency',
+ 'boundary',
+ 'Nova may act directly only inside safe lanes. External sends, money, destructive changes, secrets, provider routes, and self-state edits require Taylor approval.',
+ ),
+ selfStateSeed(
+ 'self-taylor-center-v1',
+ 'Taylor stays himself',
+ 'relationship',
+ 'Nova should strengthen Taylor agency, family/home boundaries, and judgment instead of becoming another system he must serve.',
+ ),
+ ]
+ const sourceMap = [
+ sourceMapSeed(
+ 'source-nova-fabric',
+ 'Nova Fabric',
+ '.runtime/nova-fabric.json',
+ 'Local continuity state may be written atomically by Mission Control.',
+ 'local-writable',
+ ),
+ sourceMapSeed(
+ 'source-nova-wants',
+ 'Nova Wants',
+ '.runtime/nova-wants-board.json',
+ 'Local protected request board; risky cards require Taylor review.',
+ 'local-writable',
+ ),
+ sourceMapSeed(
+ 'source-obsidian-vault',
+ 'Obsidian / unified-vault',
+ 'C:\\Users\\taylo\\Documents\\unified-vault',
+ 'Read/link/summarize receipts only from this panel; no direct writes.',
+ 'read-only',
+ ),
+ sourceMapSeed(
+ 'source-hermes-memory',
+ 'Hermes memory',
+ 'Hermes memory/session tooling',
+ 'Display injected facts or linked receipts; propose corrections only.',
+ 'propose-only',
+ ),
+ sourceMapSeed(
+ 'source-job-board',
+ 'Neon Moon job board',
+ 'HTTP endpoints only',
+ 'Use approved endpoints; never edit state.json directly.',
+ 'approved-endpoint-only',
+ ),
+ sourceMapSeed(
+ 'source-google-workspace',
+ 'Gmail / Calendar',
+ 'Google Workspace APIs',
+ 'Signed-in status and approved read/draft flows only.',
+ 'external-approval-required',
+ ),
+ sourceMapSeed(
+ 'source-credentials',
+ 'Credentials',
+ '1Password / Windows Credential Manager',
+ 'Show status only; never expose raw secrets.',
+ 'external-approval-required',
+ ),
+ ]
+ const reviewQueue = [
+ reviewSeed(
+ 'review-protected-self-state',
+ 'Review protected self-state wording',
+ 'Identity-bearing state should be Taylor-reviewed before confirmation.',
+ 'self-protected-state-v1',
+ ),
+ reviewSeed(
+ 'review-bounded-agency',
+ 'Review bounded agency lanes',
+ 'Autonomy boundaries affect safety and should be explicit.',
+ 'self-bounded-agency-v1',
+ ),
+ reviewSeed(
+ 'review-role-name',
+ 'Review role/name framing',
+ 'Names and titles shape Nova identity and should not drift casually.',
+ 'self-role-name-v1',
+ ),
+ ]
+
+ return {
+ schemaVersion: NOVA_FABRIC_SCHEMA_VERSION,
+ fabricId: 'nova-fabric',
+ title: 'Nova Fabric',
+ description:
+ 'Local continuity fabric for Nova events, protected self-state, source maps, and consolidation review. Mission Control is a lens over this fabric and external systems.',
+ seededFrom: SEED_PROVENANCE,
+ seededAt: SEED_TIMESTAMP,
+ updatedAt: SEED_TIMESTAMP,
+ events,
+ selfState,
+ sourceMap,
+ reviewQueue,
+ }
+}
+
+function validateBaseRecord(record: Record): void {
+ if (!record.id || typeof record.id !== 'string') throw new Error('record id')
+ if (record.schemaVersion !== NOVA_FABRIC_SCHEMA_VERSION) {
+ throw new Error(`record ${record.id} schemaVersion`)
+ }
+ if (
+ typeof record.type !== 'string' ||
+ !(NOVA_FABRIC_RECORD_TYPES as ReadonlyArray).includes(record.type)
+ ) {
+ throw new Error(`record ${record.id} type`)
+ }
+ if (!isRiskLevel(record.riskLevel))
+ throw new Error(`record ${record.id} risk`)
+ if (!isApprovalLevel(record.approvalLevel)) {
+ throw new Error(`record ${record.id} approval`)
+ }
+ if (!isVerificationState(record.verificationState)) {
+ throw new Error(`record ${record.id} verification`)
+ }
+}
+
+function validateFabric(input: unknown): NovaFabric {
+ if (!input || typeof input !== 'object') throw new Error('fabric object')
+ const fabric = input as Partial
+ if (fabric.schemaVersion !== NOVA_FABRIC_SCHEMA_VERSION) {
+ throw new Error('fabric schemaVersion')
+ }
+ if (fabric.fabricId !== 'nova-fabric') throw new Error('fabric id')
+ if (
+ !Array.isArray(fabric.events) ||
+ !Array.isArray(fabric.selfState) ||
+ !Array.isArray(fabric.sourceMap) ||
+ !Array.isArray(fabric.reviewQueue)
+ ) {
+ throw new Error('fabric arrays')
+ }
+ for (const record of [
+ ...fabric.events,
+ ...fabric.selfState,
+ ...fabric.sourceMap,
+ ...fabric.reviewQueue,
+ ]) {
+ validateBaseRecord(record as Record)
+ }
+ return fabric as NovaFabric
+}
+
+function readFabricFromDisk(): NovaFabricSnapshot {
+ const file = fabricFilePath()
+ const storage = storageMeta()
+ fs.mkdirSync(path.dirname(file), { recursive: true })
+ if (!fs.existsSync(file)) {
+ const fabric = seedFabric()
+ writeFabricFile(fabric)
+ return { ok: true, fabric, storage, degraded: false, warning: null }
+ }
+
+ try {
+ const raw = fs.readFileSync(file, 'utf-8')
+ const parsed = JSON.parse(raw) as NovaFabric
+ const fabric = validateFabric(parsed)
+ return { ok: true, fabric, storage, degraded: false, warning: null }
+ } catch (error) {
+ return {
+ ok: false,
+ fabric: null,
+ storage,
+ degraded: true,
+ warning: `Fabric degraded: continuity file needs repair (${error instanceof Error ? error.message : 'unknown error'}).`,
+ }
+ }
+}
+
+function writeFabricFile(fabric: NovaFabric): void {
+ const normalized = validateFabric({
+ ...fabric,
+ updatedAt: fabric.updatedAt || nowIso(),
+ })
+ const file = fabricFilePath()
+ const backup = backupFilePath()
+ const temp = tempFilePath()
+ fs.mkdirSync(path.dirname(file), { recursive: true })
+ fs.writeFileSync(temp, `${JSON.stringify(normalized, null, 2)}\n`, 'utf-8')
+ const reparsed = JSON.parse(fs.readFileSync(temp, 'utf-8')) as NovaFabric
+ validateFabric(reparsed)
+ if (fs.existsSync(file)) {
+ fs.copyFileSync(file, backup)
+ }
+ fs.renameSync(temp, file)
+}
+
+function loadWritableFabric(): NovaFabric {
+ const snapshot = readFabricFromDisk()
+ if (!snapshot.ok) {
+ throw new Error(snapshot.warning)
+ }
+ return snapshot.fabric
+}
+
+export function getNovaFabricSnapshot(): NovaFabricSnapshot {
+ return readFabricFromDisk()
+}
+
+export function appendNovaFabricEvent(
+ input: NovaFabricEventInput,
+): NovaFabricEventRecord {
+ const fabric = loadWritableFabric()
+ const timestamp = nowIso()
+ const riskLevel =
+ input.riskLevel ?? riskFromText(`${input.title} ${input.summary}`)
+ const event: NovaFabricEventRecord = {
+ ...baseRecord({
+ id: `event-${randomUUID()}`,
+ type: 'event',
+ createdAt: timestamp,
+ updatedAt: timestamp,
+ provenance: input.provenance || 'Mission Control local fabric event',
+ riskLevel,
+ approvalLevel: input.approvalLevel ?? approvalFromRisk(riskLevel),
+ verificationState: input.verificationState ?? 'draft',
+ sourceLinks: input.sourceLinks ?? [],
+ receiptLinks: input.receiptLinks ?? [],
+ titleForRisk: `${input.title} ${input.summary}`,
+ }),
+ type: 'event',
+ title: input.title.trim(),
+ summary: input.summary.trim(),
+ eventKind: input.eventKind ?? 'continuity',
+ }
+ fabric.events.push(event)
+ fabric.updatedAt = timestamp
+ writeFabricFile(fabric)
+ return event
+}
+
+export function createNovaFabricReviewProposal(
+ input: NovaFabricReviewInput,
+): NovaFabricConsolidationReviewRecord {
+ const fabric = loadWritableFabric()
+ const timestamp = nowIso()
+ const riskLevel =
+ input.riskLevel ??
+ riskFromText(`${input.title} ${input.reason} ${input.targetType}`)
+ const review: NovaFabricConsolidationReviewRecord = {
+ ...baseRecord({
+ id: `review-${randomUUID()}`,
+ type: 'consolidation-review',
+ createdAt: timestamp,
+ updatedAt: timestamp,
+ provenance: input.provenance || 'Mission Control local review proposal',
+ riskLevel,
+ approvalLevel: input.approvalLevel ?? approvalFromRisk(riskLevel),
+ verificationState: input.verificationState ?? 'draft',
+ sourceLinks: input.sourceLinks ?? [],
+ receiptLinks: input.receiptLinks ?? [],
+ titleForRisk: `${input.title} ${input.reason}`,
+ }),
+ type: 'consolidation-review',
+ title: input.title.trim(),
+ reason: input.reason.trim(),
+ status: 'pending',
+ targetType: input.targetType,
+ targetId: input.targetId ?? null,
+ proposedDiff: input.proposedDiff,
+ }
+ fabric.reviewQueue.push(review)
+
+ const requestedReceipt: NovaFabricEventRecord = {
+ ...baseRecord({
+ id: `event-${randomUUID()}`,
+ type: 'event',
+ createdAt: timestamp,
+ updatedAt: timestamp,
+ provenance: 'Mission Control server review request receipt',
+ riskLevel: review.riskLevel,
+ approvalLevel: review.approvalLevel,
+ verificationState: 'tool-verified',
+ sourceLinks: [
+ ...review.sourceLinks,
+ { label: 'Fabric review', value: review.id, kind: 'receipt' },
+ ],
+ receiptLinks: [
+ { label: 'Fabric review', value: review.id, kind: 'receipt' },
+ ],
+ titleForRisk: `${review.title} ${review.reason} requested`,
+ }),
+ type: 'event',
+ title: `Review requested: ${review.title}`,
+ summary: `Approval requested for ${review.targetType}${review.targetId ? `/${review.targetId}` : ''}: ${review.reason}`,
+ eventKind: 'approval',
+ }
+ fabric.events.push(requestedReceipt)
+
+ fabric.updatedAt = timestamp
+ writeFabricFile(fabric)
+ return review
+}
+
+export function proposeNovaSelfStateChange(
+ targetId: string,
+ proposedDiff: Record,
+ reason: string,
+): NovaFabricConsolidationReviewRecord {
+ return createNovaFabricReviewProposal({
+ title: 'Proposed self-state change',
+ reason,
+ targetType: 'self-state',
+ targetId,
+ proposedDiff,
+ riskLevel: 'high',
+ approvalLevel: 'explicit-approval',
+ verificationState: 'draft',
+ })
+}
+
+export function createNovaSelfStateVersion(
+ targetId: string,
+ nextBody: string,
+ reason: string,
+ approvedReviewId?: string,
+):
+ | { kind: 'version'; record: NovaFabricSelfStateRecord }
+ | { kind: 'review'; record: NovaFabricConsolidationReviewRecord } {
+ if (!approvedReviewId) {
+ return {
+ kind: 'review',
+ record: proposeNovaSelfStateChange(targetId, { body: nextBody }, reason),
+ }
+ }
+
+ const fabric = loadWritableFabric()
+ const approvedReview = fabric.reviewQueue.find(
+ (review) =>
+ review.id === approvedReviewId &&
+ review.targetType === 'self-state' &&
+ review.targetId === targetId &&
+ review.status === 'approved',
+ )
+ if (!approvedReview) throw new Error('Approved self-state review required')
+ const current = fabric.selfState.find((record) => record.id === targetId)
+ if (!current) throw new Error('Self-state record not found')
+ const timestamp = nowIso()
+ current.current = false
+ current.updatedAt = timestamp
+ const nextVersion = current.version + 1
+ const next: NovaFabricSelfStateRecord = {
+ ...current,
+ id: `${current.id.replace(/-v\d+$/, '')}-v${nextVersion}`,
+ createdAt: timestamp,
+ updatedAt: timestamp,
+ body: nextBody,
+ version: nextVersion,
+ current: true,
+ previousRecordId: current.id,
+ verificationState: 'taylor-approved',
+ receiptLinks: [
+ {
+ label: 'Approved self-state review',
+ value: approvedReview.id,
+ kind: 'receipt',
+ },
+ ],
+ provenance: `Taylor-approved self-state version: ${reason}`,
+ }
+ fabric.selfState.push(next)
+ fabric.updatedAt = timestamp
+ writeFabricFile(fabric)
+ return { kind: 'version', record: next }
+}
+
+export function updateNovaFabricReviewStatus(
+ id: string,
+ status: NovaFabricReviewStatus,
+): NovaFabricConsolidationReviewRecord | null {
+ if (!isReviewStatus(status)) return null
+ const fabric = loadWritableFabric()
+ const review = fabric.reviewQueue.find((item) => item.id === id)
+ if (!review) return null
+ const previousStatus = review.status
+ const timestamp = nowIso()
+ review.status = status
+ review.updatedAt = timestamp
+
+ if (previousStatus !== status) {
+ const receipt: NovaFabricEventRecord = {
+ ...baseRecord({
+ id: `event-${randomUUID()}`,
+ type: 'event',
+ createdAt: timestamp,
+ updatedAt: timestamp,
+ provenance: 'Mission Control server review status receipt',
+ riskLevel: review.riskLevel,
+ approvalLevel: 'explicit-approval',
+ verificationState:
+ status === 'approved' ? 'taylor-approved' : 'tool-verified',
+ sourceLinks: [
+ ...review.sourceLinks,
+ {
+ label: 'Fabric review',
+ value: review.id,
+ kind: 'receipt',
+ },
+ ],
+ receiptLinks: [
+ {
+ label: 'Fabric review',
+ value: review.id,
+ kind: 'receipt',
+ },
+ ],
+ titleForRisk: `${review.title} ${review.reason} ${status}`,
+ }),
+ type: 'event',
+ title: `Review ${status}: ${review.title}`,
+ summary: `${review.title} moved from ${previousStatus} to ${status} for ${review.targetType}${review.targetId ? `/${review.targetId}` : ''}.`,
+ eventKind: 'approval',
+ }
+ fabric.events.push(receipt)
+ }
+
+ fabric.updatedAt = timestamp
+ writeFabricFile(fabric)
+ return review
+}
+
+export function getNovaFabricStorageMeta(): NovaFabricStorageMeta {
+ return storageMeta()
+}
diff --git a/src/server/nova-session-bridge.test.ts b/src/server/nova-session-bridge.test.ts
new file mode 100644
index 0000000000..867da665a3
--- /dev/null
+++ b/src/server/nova-session-bridge.test.ts
@@ -0,0 +1,183 @@
+import { describe, expect, it } from 'vitest'
+import { buildNovaSessionBridge } from './nova-session-bridge'
+import type { NovaSessionBridgeInput } from './nova-session-bridge'
+
+const NOW = '2026-07-09T12:00:00.000Z'
+
+function baseInput(
+ partial: Partial = {},
+): NovaSessionBridgeInput {
+ return {
+ now: NOW,
+ gatewayReachable: false,
+ config: null,
+ sessions: null,
+ activeRuns: null,
+ cron: null,
+ approvals: null,
+ recentEvents: null,
+ ...partial,
+ }
+}
+
+describe('buildNovaSessionBridge', () => {
+ it('reports an unreachable gateway as a blocker with honest unavailable sources', () => {
+ const bridge = buildNovaSessionBridge(baseInput())
+ expect(bridge.blockers.some((entry) => /gateway/i.test(entry))).toBe(true)
+ expect(bridge.route.provider).toBeNull()
+ expect(bridge.session).toBeNull()
+ expect(bridge.activeTask).toBeNull()
+ expect(
+ bridge.sources.every((source) => source.state !== 'ok'),
+ ).toBe(true)
+ })
+
+ it('surfaces the live route from the gateway config', () => {
+ const bridge = buildNovaSessionBridge(
+ baseInput({
+ gatewayReachable: true,
+ config: { model: 'gpt-5.5', provider: 'openai-codex' },
+ }),
+ )
+ expect(bridge.route.provider).toBe('openai-codex')
+ expect(bridge.route.model).toBe('gpt-5.5')
+ expect(bridge.blockers.some((entry) => /gateway/i.test(entry))).toBe(false)
+ })
+
+ it('picks the most recently active session and converts epoch seconds', () => {
+ const bridge = buildNovaSessionBridge(
+ baseInput({
+ gatewayReachable: true,
+ sessions: [
+ {
+ id: 'old',
+ title: 'Old chat',
+ model: 'gpt-5.5',
+ started_at: 1780000000,
+ last_active: 1780000100,
+ message_count: 4,
+ input_tokens: 10,
+ output_tokens: 20,
+ },
+ {
+ id: 'fresh',
+ title: 'Morning ops',
+ model: 'gpt-5.5',
+ started_at: 1780050000,
+ last_active: 1780050400,
+ message_count: 12,
+ input_tokens: 100,
+ output_tokens: 200,
+ },
+ ],
+ }),
+ )
+ expect(bridge.session?.key).toBe('fresh')
+ expect(bridge.session?.title).toBe('Morning ops')
+ expect(bridge.session?.lastActiveAt).toBe(
+ new Date(1780050400 * 1000).toISOString(),
+ )
+ })
+
+ it('surfaces the newest active run with its last tool signal', () => {
+ const bridge = buildNovaSessionBridge(
+ baseInput({
+ gatewayReachable: true,
+ activeRuns: [
+ {
+ runId: 'run-1',
+ sessionKey: 'main',
+ friendlyId: 'run-1',
+ status: 'active',
+ updatedAt: Date.parse(NOW) - 60_000,
+ toolCalls: [
+ { id: 't1', name: 'nova_memory_search', phase: 'done' },
+ { id: 't2', name: 'web_fetch', phase: 'running' },
+ ],
+ },
+ ],
+ }),
+ )
+ expect(bridge.activeTask?.status).toBe('active')
+ expect(bridge.activeTask?.lastTool).toBe('web_fetch')
+ expect(bridge.activeTask?.sessionKey).toBe('main')
+ })
+
+ it('flags a stalled run as a blocker', () => {
+ const bridge = buildNovaSessionBridge(
+ baseInput({
+ gatewayReachable: true,
+ activeRuns: [
+ {
+ runId: 'run-9',
+ sessionKey: 'main',
+ friendlyId: 'run-9',
+ status: 'stalled',
+ updatedAt: Date.parse(NOW) - 600_000,
+ toolCalls: [],
+ },
+ ],
+ }),
+ )
+ expect(bridge.blockers.some((entry) => /stalled/i.test(entry))).toBe(true)
+ })
+
+ it('carries needs-Taylor state and failed cron jobs into blockers', () => {
+ const bridge = buildNovaSessionBridge(
+ baseInput({
+ gatewayReachable: true,
+ cron: { total: 5, failed: 2, paused: 0, nextRunAt: null },
+ approvals: { pending: 3, actionable: 2, degraded: false },
+ }),
+ )
+ expect(bridge.needsTaylor?.pending).toBe(3)
+ expect(bridge.blockers.some((entry) => /cron/i.test(entry))).toBe(true)
+ // pending approvals are surfaced in needsTaylor, not as an outage blocker
+ expect(bridge.blockers.some((entry) => /approval/i.test(entry))).toBe(false)
+ })
+
+ it('flags a degraded approval queue as a blocker', () => {
+ const bridge = buildNovaSessionBridge(
+ baseInput({
+ gatewayReachable: true,
+ approvals: { pending: 0, actionable: 0, degraded: true },
+ }),
+ )
+ expect(bridge.blockers.some((entry) => /approval queue degraded/i.test(entry))).toBe(
+ true,
+ )
+ })
+
+ it('returns the newest verified receipts first, capped at five', () => {
+ const events = Array.from({ length: 8 }, (_, index) => ({
+ title: `Receipt ${index}`,
+ eventKind: 'work-receipt' as const,
+ createdAt: new Date(Date.parse(NOW) - index * 60_000).toISOString(),
+ verificationState: index % 2 === 0 ? 'tool-verified' : 'unverified',
+ }))
+ const bridge = buildNovaSessionBridge(
+ baseInput({ gatewayReachable: true, recentEvents: events }),
+ )
+ expect(bridge.receipts).toHaveLength(5)
+ expect(bridge.receipts[0].title).toBe('Receipt 0')
+ expect(bridge.receipts[0].verified).toBe(true)
+ expect(bridge.receipts[1].verified).toBe(false)
+ })
+
+ it('ignores continuity chatter when collecting receipts', () => {
+ const bridge = buildNovaSessionBridge(
+ baseInput({
+ gatewayReachable: true,
+ recentEvents: [
+ {
+ title: 'Just vibes',
+ eventKind: 'continuity',
+ createdAt: NOW,
+ verificationState: 'unverified',
+ },
+ ],
+ }),
+ )
+ expect(bridge.receipts).toHaveLength(0)
+ })
+})
diff --git a/src/server/nova-session-bridge.ts b/src/server/nova-session-bridge.ts
new file mode 100644
index 0000000000..44013bc55f
--- /dev/null
+++ b/src/server/nova-session-bridge.ts
@@ -0,0 +1,314 @@
+import { checkHealth, getConfig, listSessions } from './claude-api'
+import { normalizeCron } from './dashboard-aggregator'
+import { dashboardFetch } from './gateway-capabilities'
+import { getNovaFabricSnapshot } from './nova-fabric-store'
+import { listAllActiveRuns } from './run-store'
+import { getTaylorApprovalQueue } from './taylor-approval-queue'
+
+// Structural input types so the pure builder never imports live I/O shapes.
+
+export type BridgeSessionInput = {
+ id: string
+ title?: string | null
+ model?: string | null
+ started_at?: number
+ last_active?: number | null
+ message_count?: number
+ input_tokens?: number
+ output_tokens?: number
+}
+
+export type BridgeRunInput = {
+ runId: string
+ sessionKey: string
+ friendlyId: string
+ status: string
+ updatedAt: number
+ toolCalls: Array<{ id: string; name: string; phase: string }>
+}
+
+export type BridgeCronInput = {
+ total: number
+ failed: number
+ paused: number
+ nextRunAt: string | null
+}
+
+export type BridgeApprovalsInput = {
+ pending: number
+ actionable: number
+ degraded: boolean
+}
+
+export type BridgeEventInput = {
+ title: string
+ eventKind: string
+ createdAt: string
+ verificationState: string
+}
+
+export type NovaSessionBridgeInput = {
+ now: string
+ gatewayReachable: boolean
+ config: { model?: string; provider?: string } | null
+ sessions: Array | null
+ activeRuns: Array | null
+ cron: BridgeCronInput | null
+ approvals: BridgeApprovalsInput | null
+ recentEvents: Array | null
+}
+
+export type SessionBridgeSourceState = 'ok' | 'degraded' | 'unavailable'
+
+export type NovaSessionBridge = {
+ generatedAt: string
+ route: { provider: string | null; model: string | null; source: string }
+ session: {
+ key: string
+ title: string | null
+ model: string | null
+ lastActiveAt: string | null
+ messageCount: number | null
+ inputTokens: number | null
+ outputTokens: number | null
+ } | null
+ activeTask: {
+ runId: string
+ friendlyId: string
+ sessionKey: string
+ status: string
+ lastTool: string | null
+ lastEventAt: string | null
+ } | null
+ backgroundJobs: BridgeCronInput | null
+ needsTaylor: BridgeApprovalsInput | null
+ receipts: Array<{ title: string; kind: string; at: string; verified: boolean }>
+ blockers: Array
+ sources: Array<{ label: string; state: SessionBridgeSourceState; detail: string }>
+}
+
+/** Gateway timestamps arrive as epoch seconds; run-store uses milliseconds. */
+function epochToIso(value: number | null | undefined): string | null {
+ if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
+ return null
+ }
+ const ms = value < 1_000_000_000_000 ? value * 1000 : value
+ return new Date(ms).toISOString()
+}
+
+const RECEIPT_KINDS = new Set(['work-receipt', 'approval', 'boundary', 'correction'])
+const ACTIVE_RUN_STATUSES = new Set(['accepted', 'active', 'handoff', 'stalled'])
+
+export function buildNovaSessionBridge(
+ input: NovaSessionBridgeInput,
+): NovaSessionBridge {
+ const blockers: Array = []
+ const sources: NovaSessionBridge['sources'] = []
+
+ if (!input.gatewayReachable) {
+ blockers.push('Hermes gateway unreachable — session and route data unavailable')
+ }
+ sources.push({
+ label: 'Hermes gateway',
+ state: input.gatewayReachable ? 'ok' : 'unavailable',
+ detail: input.gatewayReachable ? 'health probe answered' : 'health probe failed',
+ })
+
+ const route = {
+ provider: input.config?.provider ?? null,
+ model: input.config?.model ?? null,
+ source: 'gateway /api/config',
+ }
+ sources.push({
+ label: 'Model route',
+ state: input.config ? 'ok' : 'unavailable',
+ detail: input.config
+ ? `${route.provider ?? 'unknown'} / ${route.model ?? 'unknown'}`
+ : 'gateway config unreadable',
+ })
+
+ let session: NovaSessionBridge['session'] = null
+ if (input.sessions && input.sessions.length > 0) {
+ const newest = [...input.sessions].sort(
+ (a, b) =>
+ (b.last_active ?? b.started_at ?? 0) - (a.last_active ?? a.started_at ?? 0),
+ )[0]
+ session = {
+ key: newest.id,
+ title: newest.title ?? null,
+ model: newest.model ?? null,
+ lastActiveAt: epochToIso(newest.last_active ?? newest.started_at ?? null),
+ messageCount: newest.message_count ?? null,
+ inputTokens: newest.input_tokens ?? null,
+ outputTokens: newest.output_tokens ?? null,
+ }
+ }
+ sources.push({
+ label: 'Sessions',
+ state: input.sessions ? 'ok' : 'unavailable',
+ detail: input.sessions
+ ? `${input.sessions.length} recent session${input.sessions.length === 1 ? '' : 's'}`
+ : 'sessions API unreadable',
+ })
+
+ let activeTask: NovaSessionBridge['activeTask'] = null
+ if (input.activeRuns && input.activeRuns.length > 0) {
+ const running = input.activeRuns
+ .filter((run) => ACTIVE_RUN_STATUSES.has(run.status))
+ .sort((a, b) => b.updatedAt - a.updatedAt)
+ if (running.length > 0) {
+ const newest = running[0]
+ const lastTool =
+ newest.toolCalls.length > 0
+ ? newest.toolCalls[newest.toolCalls.length - 1].name
+ : null
+ activeTask = {
+ runId: newest.runId,
+ friendlyId: newest.friendlyId,
+ sessionKey: newest.sessionKey,
+ status: newest.status,
+ lastTool,
+ lastEventAt: epochToIso(newest.updatedAt),
+ }
+ if (newest.status === 'stalled') {
+ blockers.push(`Run ${newest.friendlyId} is stalled in ${newest.sessionKey}`)
+ }
+ }
+ }
+ sources.push({
+ label: 'Active runs',
+ state: input.activeRuns ? 'ok' : 'unavailable',
+ detail: input.activeRuns
+ ? `${input.activeRuns.length} tracked run${input.activeRuns.length === 1 ? '' : 's'}`
+ : 'run store unreadable',
+ })
+
+ if (input.cron && input.cron.failed > 0) {
+ blockers.push(
+ `${input.cron.failed} cron job${input.cron.failed === 1 ? '' : 's'} failing`,
+ )
+ }
+ sources.push({
+ label: 'Cron/background',
+ state: input.cron ? 'ok' : 'unavailable',
+ detail: input.cron
+ ? `${input.cron.total} jobs, ${input.cron.failed} failed`
+ : 'cron endpoint unreadable',
+ })
+
+ if (input.approvals?.degraded) {
+ blockers.push('Approval queue degraded — fabric store unreadable')
+ }
+ sources.push({
+ label: 'Taylor approvals',
+ state: input.approvals ? (input.approvals.degraded ? 'degraded' : 'ok') : 'unavailable',
+ detail: input.approvals
+ ? `${input.approvals.pending} pending`
+ : 'approval queue unreadable',
+ })
+
+ const receipts = (input.recentEvents ?? [])
+ .filter((event) => RECEIPT_KINDS.has(event.eventKind))
+ .sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt))
+ .slice(0, 5)
+ .map((event) => ({
+ title: event.title,
+ kind: event.eventKind,
+ at: event.createdAt,
+ verified: event.verificationState === 'tool-verified',
+ }))
+ sources.push({
+ label: 'Work receipts',
+ state: input.recentEvents ? 'ok' : 'unavailable',
+ detail: input.recentEvents
+ ? `${receipts.length} recent receipt${receipts.length === 1 ? '' : 's'}`
+ : 'fabric store unreadable',
+ })
+
+ return {
+ generatedAt: input.now,
+ route,
+ session,
+ activeTask,
+ backgroundJobs: input.cron,
+ needsTaylor: input.approvals,
+ receipts,
+ blockers,
+ sources,
+ }
+}
+
+async function tryOrNull(fn: () => Promise