Skip to content

feat: Svelte 5 command dashboard with real-time WebSocket updates#76

Open
justinjdev wants to merge 44 commits intomainfrom
feat/command-dashboard
Open

feat: Svelte 5 command dashboard with real-time WebSocket updates#76
justinjdev wants to merge 44 commits intomainfrom
feat/command-dashboard

Conversation

@justinjdev
Copy link
Copy Markdown
Owner

@justinjdev justinjdev commented Mar 11, 2026

Summary

  • Full Svelte 5 + SvelteKit dashboard replacing the vanilla JS dashboard, with 6 views: Command (quest grid + herald feed), Quest Detail (errands/files/tome/herald tabs), Herald (filterable activity log), Autopsies (failure memory browser), Timeline (Gantt chart), and Config (editable settings)
  • Go backend extensions: WebSocket hub for real-time event broadcast, command queue for signal-based dispatch to Gandalf (spawn/kill/restart quests and scouts), REST endpoints for autopsies, tome, and config
  • Command palette (Cmd+K) with fuzzy search, keyboard navigation, and quest/scout spawn actions
  • Build pipeline: Svelte output embedded in Go binary via embed.FS, SPA fallback routing for client-side navigation

Architecture

Dashboard communicates with Go server via REST (state fetching, commands) + WebSocket (real-time notifications). Commands dispatch through a file-backed queue (command-queue.json) that Gandalf polls to execute agent actions. Dark "War Table" design system with Cinzel/Outfit/DM Sans/JetBrains Mono typography and gold accent palette.

Test plan

  • Run cd cli && go test ./internal/dashboard/ -v — unit tests for WebSocket hub and command queue
  • Run cd dashboard && npm run build — verify Svelte compilation
  • Run dashboard/build.sh — verify build pipeline copies assets to Go embed dir
  • Run cd cli && go build ./... — verify Go compiles with embedded dashboard
  • Start dashboard server and verify all 6 views render
  • Verify WebSocket connection and real-time updates
  • Test command palette (Cmd+K) navigation and actions
  • Test gate approve/reject from quest cards

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Rebuilt dashboard with modern, responsive UI powered by SvelteKit with TypeScript.
    • Added real-time WebSocket updates replacing periodic polling for live quest/scout status.
    • Added command palette (Cmd/Ctrl+K) for quick navigation and task execution.
    • Added quest detail pages showing phases, errands, files, gate history, and events.
    • Added timeline view with Gantt chart visualization of quest progress.
    • Added autopsies browser for viewing past quest failures.
    • Added configuration UI for managing global and project settings.
    • Added error logging with in-app error banner and error history.
    • Added ability to spawn quests, scouts, and manage quest lifecycle from UI.
  • Bug Fixes

    • Improved error handling for server-side failures with persistent error logs.
  • Chores

    • Updated CLI routing and error context handling.
    • Added command queue persistence system.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Mar 11, 2026

📝 Walkthrough

Walkthrough

This PR comprehensively replaces the dashboard frontend with a SvelteKit-based application and extends the backend with WebSocket communication, command queuing, error logging, configuration management, and multiple new API endpoints. The static HTML/JavaScript dashboard is replaced by a modular, component-driven architecture.

Changes

Cohort / File(s) Summary
CLI Main & Dependencies
cli/cmd/fellowship/main.go, cli/go.mod, cli/internal/db/schema.go
Updated runDashboard to pass gitRoot() to NewServer and handle error returns; added gorilla/websocket dependency; added dashboard_errors table with timestamp index.
Dashboard Server Core
cli/internal/dashboard/server.go, cli/internal/dashboard/server_test.go
Updated NewServer signature to accept gitRoot and return (*Server, error), added hub, configMu fields; added centralized error logging via logError; reworked HTTP routing and static asset serving; added test helpers and error logging tests.
Dashboard Command Queue
cli/internal/dashboard/queue.go, cli/internal/dashboard/queue_test.go
Implemented persisted JSON command queue with concurrent access control via file locking; EnqueueCommand generates IDs and prunes queue to 200 items; LoadCommandQueue/SaveCommandQueue handle persistence.
Dashboard API Handlers
cli/internal/dashboard/commands.go, cli/internal/dashboard/data.go, cli/internal/dashboard/ws.go, cli/internal/dashboard/errlog.go
Added HTTP handlers for quest/scout spawn/kill/restart, commands list, autopsies search, tome loading, and config read/write; implemented WebSocket hub for real-time broadcasts; added error logging with SQLite backend.
Dashboard Static Assets
cli/internal/dashboard/embed.go, cli/internal/dashboard/static/...
Changed //go:embed directive to recursively embed all static files; added compiled Svelte assets (immutable chunks, CSS, JS), font definitions, and version stamp.
SvelteKit Dashboard Frontend
dashboard/src/..., dashboard/package.json, dashboard/svelte.config.js, dashboard/tsconfig.json, dashboard/vite.config.ts, dashboard/.gitignore
Complete new SvelteKit-based dashboard with npm build setup, six route pages (+page.svelte redirects to /command), shared components (Shell, Sidebar, CommandPalette, ErrorBanner, etc.), Svelte stores for quests/herald/websocket/errors, global styles with design tokens, and TypeScript types.
Dashboard Components
dashboard/src/lib/components/*
Eight new Svelte components: Shell (layout wrapper), Sidebar (navigation), CommandPalette (search/action overlay), ConnectionBanner (offline notice), ErrandList, GanttChart, GateActions, HeraldFeed, PhaseTimeline, QuestCard, StatCounter, ErrorBanner.
Dashboard Pages
dashboard/src/routes/command/+page.svelte, dashboard/src/routes/autopsies/+page.svelte, dashboard/src/routes/config/+page.svelte, dashboard/src/routes/herald/+page.svelte, dashboard/src/routes/quest/[id]/+page.svelte, dashboard/src/routes/timeline/+page.svelte
Six dashboard pages: command overview with stats/quest grid, autopsies search/details, config editor with global/project scopes, herald event feed with filters, quest detail with errands/files/gate history, and timeline gantt chart.
Dashboard Stores & API
dashboard/src/lib/stores/..., dashboard/src/lib/api.ts, dashboard/src/lib/types.ts
Svelte stores for quests, herald tidings, WebSocket state, and errors; typed API client wrapping backend endpoints; TypeScript type definitions for dashboard entities (QuestStatus, Tiding, Problem, Command, etc.).
Dashboard Styles
dashboard/src/lib/styles/...
Global design tokens (colors, fonts, spacing, transitions), baseline reset and typography, form control styling, and utility classes.
Build & Configuration
dashboard/build.sh
Shell script to build SvelteKit app and copy artifacts into Go embed directory.
Documentation & Config
plugin/commands/rekindle.md, plugin/skills/fellowship/SKILL.md, site/src/lib/styles/theme.css, .gitignore, .mcp.json
Updated autopsy/init command paths to use fully-qualified binary path; adjusted quest classification (added "zombie"); refreshed site theme accent and visualization colors; added ignore patterns for caches; added MCP server config.

Sequence Diagram(s)

sequenceDiagram
    participant Client as Browser Client
    participant WS as WebSocket
    participant Handler as HTTP Handler
    participant Queue as Command Queue
    participant DB as Database
    participant Effect as Effect Processor

    Client->>Handler: POST /api/spawn-quest<br/>(task, branch, company)
    Handler->>Handler: Validate request body
    Handler->>Handler: Marshal params to JSON
    Handler->>Queue: EnqueueCommand(gitRoot,<br/>ActionSpawnQuest, params)
    Queue->>Queue: Lock + Load queue
    Queue->>Queue: Generate random ID
    Queue->>Queue: Append command<br/>(status: pending)
    Queue->>Queue: Prune to 200 items
    Queue->>Queue: Save to .json file
    Queue-->>Handler: Return Command
    Handler->>WS: Broadcast WSEvent<br/>(type: "command-queued")
    WS-->>Client: Send event
    Handler-->>Client: 200 OK<br/>(Queued: true, CommandID)
    
    Effect->>DB: Poll command queue
    Effect->>Effect: Execute command
    Effect->>DB: Update command status
    Effect->>WS: Broadcast status change
    WS-->>Client: Update in real-time
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

  • PR #27: Introduces tome package and Load API that the dashboard directly uses in handleTome and client-side fetchTome.
  • PR #78: Refactors dashboard.NewServer signature (from accepting *db.DB, int to accepting *db.DB, string, int) and alters server initialization—directly overlaps with this PR's changes to the same function.
  • PR #75: Adds autopsy subsystem; this PR's dashboard handlers call autopsy.Scan and integrate autopsy endpoints that depend on the retrieved PR's infrastructure.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/command-dashboard

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 19

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

🟡 Minor comments (9)
cli/go.mod-5-5 (1)

5-5: ⚠️ Potential issue | 🟡 Minor

Remove // indirect comment from gorilla/websocket dependency.

The module is imported directly in cli/internal/dashboard/ws.go at line 10, but marked as indirect in go.mod line 5. Remove the // indirect comment and run go mod tidy to correct the dependency metadata.

cli/internal/dashboard/static/_app/immutable/chunks/DmxyyQfE.js-1-1 (1)

1-1: ⚠️ Potential issue | 🟡 Minor

Add error handling to loading state in gate actions.

The approve() and reject() functions lack error handling. If either approveGate(worktree) or rejectGate(worktree) throws, the loading state is never reset to false, leaving buttons permanently disabled.

Use a try/finally block (consistent with CommandPalette.svelte in the same codebase):

async function approve() {
  loading = true;
  try {
    await approveGate(worktree);
  } finally {
    loading = false;
  }
}
cli/internal/dashboard/ws.go-25-27 (1)

25-27: ⚠️ Potential issue | 🟡 Minor

Misleading comment: CheckOrigin allows all origins, not just localhost.

The comment says "localhost only" but the implementation returns true unconditionally, accepting WebSocket connections from any origin. If this is intentional for development, consider adding a TODO or making it configurable. For production, validate the origin against expected hosts.

🛡️ Proposed fix for localhost-only validation
 var upgrader = websocket.Upgrader{
-	CheckOrigin: func(r *http.Request) bool { return true }, // localhost only
+	CheckOrigin: func(r *http.Request) bool {
+		origin := r.Header.Get("Origin")
+		// Allow localhost origins for development
+		return origin == "" ||
+			strings.HasPrefix(origin, "http://localhost") ||
+			strings.HasPrefix(origin, "http://127.0.0.1")
+	},
 }

Add "strings" to the imports if using this approach.

cli/internal/autopsy/autopsy_test.go-417-423 (1)

417-423: ⚠️ Potential issue | 🟡 Minor

Make this assertion fail instead of only logging.

If inferModules regresses here, the test still passes because this branch only calls t.Logf. That turns the first half of TestInferModules into a no-op.

Proposed fix
 	modules := inferModules([]string{"src/auth/jwt.go", "src/auth/session.go", "src/billing/charge.go"})
-	if len(modules) != 1 {
-		// Both start with "src"
-		t.Logf("modules = %v", modules)
-	}
+	if len(modules) != 1 || modules[0] != "src" {
+		t.Fatalf("modules = %v, want [src]", modules)
+	}
dashboard/src/lib/components/HeraldFeed.svelte-17-25 (1)

17-25: ⚠️ Potential issue | 🟡 Minor

Handle invalid timestamps gracefully.

If ts is invalid or malformed, new Date(ts).getTime() returns NaN, which propagates through the calculation and displays "NaNd". Consider adding a guard.

🛡️ Proposed defensive fix
 function formatTime(ts: string): string {
-	const diff = Date.now() - new Date(ts).getTime();
+	const date = new Date(ts);
+	if (isNaN(date.getTime())) return '?';
+	const diff = Date.now() - date.getTime();
 	const mins = Math.floor(diff / 60000);
dashboard/src/routes/timeline/+page.svelte-17-18 (1)

17-18: ⚠️ Potential issue | 🟡 Minor

Add error handling for fetchTome failures.

If any fetchTome call rejects, Promise.all will reject, leaving loading as true indefinitely. Consider wrapping individual fetches or the entire block in try/catch.

🛡️ Proposed fix using Promise.allSettled
-const results = await Promise.all(names.map((n) => fetchTome(n)));
-tomes = results.filter((t): t is QuestTome => t != null) as QuestTome[];
+const results = await Promise.allSettled(names.map((n) => fetchTome(n)));
+tomes = results
+  .filter((r): r is PromiseFulfilledResult<QuestTome | null> => r.status === 'fulfilled')
+  .map((r) => r.value)
+  .filter((t): t is QuestTome => t != null);
dashboard/src/routes/autopsies/+page.svelte-38-41 (1)

38-41: ⚠️ Potential issue | 🟡 Minor

Add error handling for fetch failure.

If fetchAutopsies() throws, the loading state remains true and the user sees "Loading autopsies..." indefinitely with no feedback.

🛡️ Proposed fix
+let error = $state<string | null>(null);
+
 onMount(async () => {
-  autopsies = (await fetchAutopsies()) as Autopsy[];
-  loading = false;
+  try {
+    autopsies = (await fetchAutopsies()) as Autopsy[];
+  } catch (e) {
+    error = 'Failed to load autopsies';
+  } finally {
+    loading = false;
+  }
 });
dashboard/src/lib/components/CommandPalette.svelte-166-173 (1)

166-173: ⚠️ Potential issue | 🟡 Minor

Guard arrow-key math when there are no matches.

If filtered is empty, items.length is 0, so Lines 169 and 173 assign NaN to selectedIndex. A simple empty-list guard keeps keyboard navigation stable.

💡 Proposed fix
 		} else if (e.key === 'ArrowDown') {
 			e.preventDefault();
 			const items = flatItems();
+			if (!items.length) return;
 			selectedIndex = (selectedIndex + 1) % items.length;
 		} else if (e.key === 'ArrowUp') {
 			e.preventDefault();
 			const items = flatItems();
+			if (!items.length) return;
 			selectedIndex = (selectedIndex - 1 + items.length) % items.length;
dashboard/src/routes/herald/+page.svelte-33-45 (1)

33-45: ⚠️ Potential issue | 🟡 Minor

All type filters off currently means “show everything.”

When every checkbox is unchecked, anyTypeEnabled becomes false and the type filter stops applying. That makes it impossible to hide all event types.

💡 Proposed fix
 			if (selectedQuest && t.quest !== selectedQuest) return false;
 			const anyTypeEnabled = Object.values(enabledTypes).some(Boolean);
-			if (anyTypeEnabled && !enabledTypes[t.type]) return false;
+			if (!anyTypeEnabled) return false;
+			if (!enabledTypes[t.type]) return false;
 			if (searchText) {
🧹 Nitpick comments (22)
cli/internal/dashboard/static/_app/immutable/chunks/sifeoocM.js (1)

1-1: Committed build artifact — ensure reproducibility and consider documentation.

This is minified SvelteKit compiler output. Committing build artifacts to version control can cause merge conflicts and bloat repository history, but is acceptable when embedding assets in a Go binary via embed.FS as noted in the PR objectives.

Recommendations:

  1. Add a README.md or comment in cli/internal/dashboard/static/ explaining that these files are generated and should not be manually edited.
  2. Document the build command (e.g., npm run build in the Svelte project) in the repo's contributing guide.
  3. Consider adding a CI step that verifies the committed build matches a fresh build to catch drift.
cli/internal/dashboard/static/_app/version.json (1)

1-1: Build artifact committed as part of embedding strategy.

This SvelteKit-generated version file uses a timestamp for cache-busting and is being committed as part of the embed.FS strategy described in the PR. The approach is valid and serves its purpose.

If reproducible builds become a priority, consider using a git commit hash or semantic version instead of the build timestamp. This would make builds deterministic across different build times.

cli/internal/datadir/datadir_test.go (1)

264-280: Add coverage for non-positive expiryDays overrides.

AutopsyExpiryDays has a dedicated fallback for <= 0, but these tests only cover the unset and positive paths. A bad config like 0 or -1 can regress silently without its own assertion.

Suggested test
+func TestAutopsyExpiryDays_NonPositiveFallsBackToDefault(t *testing.T) {
+	home := t.TempDir()
+	t.Setenv("HOME", home)
+	if err := os.MkdirAll(filepath.Join(home, ".claude"), 0o755); err != nil {
+		t.Fatalf("creating claude dir: %v", err)
+	}
+	if err := os.WriteFile(
+		filepath.Join(home, ".claude", "fellowship.json"),
+		[]byte(`{"autopsy":{"expiryDays":0}}`),
+		0o644,
+	); err != nil {
+		t.Fatalf("writing config: %v", err)
+	}
+
+	if got := AutopsyExpiryDays(90); got != 90 {
+		t.Errorf("AutopsyExpiryDays = %d, want 90", got)
+	}
+}
cli/internal/datadir/datadir.go (1)

105-113: Consider aligning config merge strategy with Name().

The AutopsyExpiryDays function only reads from user config (~/.claude/fellowship.json), while Name() merges project config (.fellowship/config.json) with user config where user wins. If autopsy expiry should also be configurable per-project, consider applying the same merge pattern.

If this is intentional (user-level setting only), the docstring is accurate and no change is needed.

♻️ Optional: Apply project+user merge pattern
 func AutopsyExpiryDays(defaultDays int) int {
-	c := readUserConfig()
-	if c.Autopsy.ExpiryDays <= 0 {
+	days := 0
+	if p := readProjectConfig(); p.Autopsy.ExpiryDays > 0 {
+		days = p.Autopsy.ExpiryDays
+	}
+	if u := readUserConfig(); u.Autopsy.ExpiryDays > 0 {
+		days = u.Autopsy.ExpiryDays
+	}
+	if days <= 0 {
 		return defaultDays
 	}
-	return c.Autopsy.ExpiryDays
+	return days
 }
cli/internal/dashboard/static/index.html (1)

32-37: Consider adding error handling to the bootstrap Promise.

If module loading fails (e.g., network error, corrupted bundle), the Promise rejection is unhandled and the app silently fails to start. Adding a .catch() handler would provide user feedback on load failure.

🛡️ Suggested improvement
 				Promise.all([
 					import("/_app/immutable/entry/start.2wbYeCGw.js"),
 					import("/_app/immutable/entry/app.D9gBBLez.js")
 				]).then(([kit, app]) => {
 					kit.start(app, element);
+				}).catch((err) => {
+					console.error('Failed to load dashboard:', err);
+					element.innerHTML = '<p style="color:red;padding:1rem;">Failed to load dashboard. Please refresh.</p>';
 				});
cli/internal/dashboard/ws.go (1)

80-88: Read pump lacks read deadline for connection health detection.

Without a read deadline, the read pump relies solely on TCP-level detection which can take minutes to notice a dead peer. Setting a read deadline with periodic pong handling ensures faster detection of unresponsive clients.

♻️ Proposed improvement with read deadline and pong handler
 func (h *Hub) HandleWS(w http.ResponseWriter, r *http.Request) {
 	conn, err := upgrader.Upgrade(w, r, nil)
 	if err != nil {
 		log.Printf("ws: upgrade error: %v", err)
 		return
 	}
 	h.Add(conn)

+	const (
+		pongWait   = 60 * time.Second
+		pingPeriod = (pongWait * 9) / 10
+	)
+	conn.SetReadDeadline(time.Now().Add(pongWait))
+	conn.SetPongHandler(func(string) error {
+		conn.SetReadDeadline(time.Now().Add(pongWait))
+		return nil
+	})

 	// Read pump — just drain pings/pongs, we don't expect client messages
 	go func() {
 		defer h.Remove(conn)
 		for {
 			if _, _, err := conn.ReadMessage(); err != nil {
 				break
 			}
 		}
 	}()
 }

Note: This requires a separate ping ticker goroutine to send pings periodically if the server needs to initiate keepalive probes.

cli/internal/dashboard/ws_test.go (1)

8-11: Test lacks assertions and meaningful verification.

This test broadcasts to an empty hub (no connections), which is a no-op. It doesn't verify that messages are actually received by connected clients, nor does it test connection add/remove behavior.

♻️ Proposed test with actual WebSocket connection
 func TestHubBroadcast(t *testing.T) {
 	hub := NewHub()
-	hub.Broadcast(WSEvent{Type: "test", Timestamp: time.Now().Unix()})
+
+	// Start test server
+	server := httptest.NewServer(http.HandlerFunc(hub.HandleWS))
+	defer server.Close()
+
+	// Connect WebSocket client
+	wsURL := "ws" + strings.TrimPrefix(server.URL, "http")
+	conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
+	if err != nil {
+		t.Fatalf("dial: %v", err)
+	}
+	defer conn.Close()
+
+	// Give connection time to register
+	time.Sleep(10 * time.Millisecond)
+
+	// Broadcast event
+	event := WSEvent{Type: "test", Timestamp: time.Now().Unix()}
+	hub.Broadcast(event)
+
+	// Verify receipt
+	conn.SetReadDeadline(time.Now().Add(time.Second))
+	_, msg, err := conn.ReadMessage()
+	if err != nil {
+		t.Fatalf("read: %v", err)
+	}
+
+	var received WSEvent
+	if err := json.Unmarshal(msg, &received); err != nil {
+		t.Fatalf("unmarshal: %v", err)
+	}
+	if received.Type != "test" {
+		t.Errorf("got type %q, want %q", received.Type, "test")
+	}
 }

Add imports: "encoding/json", "net/http", "net/http/httptest", "strings", "github.com/gorilla/websocket".

Would you like me to generate additional test cases for connection removal and concurrent broadcast scenarios?

dashboard/src/routes/+page.svelte (1)

1-7: Prefer a route redirect over onMount navigation.

With ssr = false, this page stays empty until the client boots and onMount runs. Moving the redirect into +page.js avoids rendering a throwaway page at all.

♻️ Proposed change
-<script>
-	import { goto } from '$app/navigation';
-	import { onMount } from 'svelte';
-
-	onMount(() => {
-		goto('/command', { replaceState: true });
-	});
-</script>
+// dashboard/src/routes/+page.js
+import { redirect } from '@sveltejs/kit';
+
+export function load() {
+	throw redirect(307, '/command');
+}
dashboard/src/app.html (1)

6-8: Self-host these fonts instead of fetching them at runtime.

This makes the embedded dashboard depend on outbound access to Google, which is a poor fit for a local/embedded admin UI and leaks requests from localhost sessions. If this dashboard is expected to work in offline or locked-down environments, keep the font assets under the app’s static bundle instead.

cli/internal/dashboard/queue_test.go (1)

26-32: Assert the persisted command payload, not just the count.

Right now this still passes if EnqueueCommand writes the wrong Action or Params as long as one row lands in the file. Since the queue is the integration point for Gandalf, it’s worth checking the full round-trip here.

Suggested assertions
 	q, err := LoadCommandQueue(dir)
 	if err != nil {
 		t.Fatalf("LoadCommandQueue failed: %v", err)
 	}
 	if len(q.Commands) != 1 {
 		t.Errorf("expected 1 command, got %d", len(q.Commands))
 	}
+	if q.Commands[0].ID != cmd.ID {
+		t.Errorf("expected persisted ID %q, got %q", cmd.ID, q.Commands[0].ID)
+	}
+	if q.Commands[0].Action != ActionSpawnQuest {
+		t.Errorf("expected action %q, got %q", ActionSpawnQuest, q.Commands[0].Action)
+	}
+	if string(q.Commands[0].Params) != string(params) {
+		t.Errorf("expected params %s, got %s", string(params), string(q.Commands[0].Params))
+	}
dashboard/src/lib/components/ConnectionBanner.svelte (1)

5-9: Consider adding accessibility attributes for screen reader users.

The connection status banner would benefit from role="alert" or aria-live="polite" to announce connectivity changes to screen reader users.

♿ Proposed accessibility improvement
 {`#if` !$connected}
-	<div class="banner">
+	<div class="banner" role="alert" aria-live="polite">
 		Disconnected — reconnecting...
 	</div>
 {/if}
dashboard/src/routes/command/+page.svelte (1)

4-4: Unused import: refreshAll.

The refreshAll function is imported but never used in this component. Consider removing it to keep imports clean.

🧹 Remove unused import
-	import { dashboardStatus, questHealths, problems, activeQuests, pendingGates, activeScouts, refreshAll, startPolling, stopPolling } from '$lib/stores/quests';
+	import { dashboardStatus, questHealths, problems, activeQuests, pendingGates, activeScouts, startPolling, stopPolling } from '$lib/stores/quests';
dashboard/src/lib/components/HeraldFeed.svelte (1)

39-46: Add a key to the {#each} block for stable updates.

Without a key, Svelte may inefficiently re-render items when the tidings list changes. If tidings have a unique identifier, use it as the key.

♻️ Add keyed iteration
-{`#each` tidings.slice(0, limit) as tiding}
+{`#each` tidings.slice(0, limit) as tiding (tiding.timestamp + tiding.quest)}

If tidings have a unique id field, prefer using that instead.

dashboard/src/lib/components/Sidebar.svelte (2)

43-45: Add accessibility attributes to the toggle button.

The toggle button lacks an accessible label for screen readers. Consider adding aria-label and optionally aria-expanded on the sidebar.

♿ Proposed accessibility improvement
-<button class="sidebar-toggle" onclick={() => collapsed = !collapsed}>
+<button 
+	class="sidebar-toggle" 
+	onclick={() => collapsed = !collapsed}
+	aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
+>
 	{collapsed ? '›' : '‹'}
 </button>

49-58: Consider using a CSS variable instead of hardcoded color.

The sidebar background uses #0a0c10 directly. For consistency with the design system, consider using a CSS variable (e.g., define --bg-sidebar in tokens).

🎨 Use design token
 .sidebar {
 	width: var(--sidebar-width-expanded);
-	background: `#0a0c10`;
+	background: var(--bg-sidebar, `#0a0c10`);

Then define --bg-sidebar: #0a0c10; in your tokens file.

dashboard/src/routes/timeline/+page.svelte (2)

12-20: Consider debouncing or guarding against concurrent loadTomes calls.

Every dashboardStatus update triggers loadTomes(). If updates arrive rapidly, multiple concurrent fetches could race, potentially causing stale data to overwrite fresh data. Consider adding a guard:

♻️ Proposed guard
+let loadingTomes = false;
+
 async function loadTomes() {
+  if (loadingTomes) return;
+  loadingTomes = true;
   const status = $dashboardStatus;
   if (!status) return;

   const names = status.quests.map((q) => q.name);
   const results = await Promise.all(names.map((n) => fetchTome(n)));
   tomes = results.filter((t): t is QuestTome => t != null) as QuestTome[];
   loading = false;
+  loadingTomes = false;
 }

18-18: Redundant type assertion.

The type guard (t): t is QuestTome => t != null already narrows the type, making as QuestTome[] unnecessary.

-tomes = results.filter((t): t is QuestTome => t != null) as QuestTome[];
+tomes = results.filter((t): t is QuestTome => t != null);
dashboard/src/routes/quest/[id]/+page.svelte (1)

30-31: Type assertions may mask null returns.

If fetchErrands or fetchTome return null, the assertions as QuestErrandList and as QuestTome will incorrectly type the null values. The template already handles null checks (errandList && ...), so removing the assertions maintains correctness:

-errandList = e as QuestErrandList;
-tomeData = t as QuestTome;
+errandList = e;
+tomeData = t;

This requires updating the type of the state variables to include null explicitly if not already.

dashboard/src/lib/components/GanttChart.svelte (1)

115-125: Consider adding keyboard accessibility for bar segments.

The svelte-ignore a11y_no_static_element_interactions comment suppresses the warning, but the bars lack keyboard focus. For accessibility, consider making bars focusable with tabindex="0" and handling focus/blur for tooltip display:

 <div
   class="gantt-bar"
+  tabindex="0"
+  role="button"
+  aria-label="{seg.phase}: {seg.duration}"
   style:left="{seg.startPct}%"
   ...
   onmouseenter={(e) => onSegmentEnter(e, row.name, seg)}
   onmouseleave={onSegmentLeave}
+  onfocus={(e) => onSegmentEnter(e, row.name, seg)}
+  onblur={onSegmentLeave}
 ></div>
dashboard/src/lib/stores/herald.ts (1)

16-28: Make startHeraldPolling() idempotent.

A second start overwrites unsubscribe without disposing the previous subscription, so each matching websocket event can fan out into duplicate /api/herald fetches.

💡 Proposed fix
 export function startHeraldPolling() {
+	unsubscribe?.();
 	fetchTidings();
 	unsubscribe = lastEvent.subscribe((event) => {
 		if (!event) return;
 		if (event.type === 'herald-event' || event.type === 'gate-resolved' || event.type === 'quest-changed') {
 			fetchTidings();
 		}
 	});
 }

 export function stopHeraldPolling() {
 	unsubscribe?.();
+	unsubscribe = null;
 }
dashboard/src/lib/stores/quests.ts (1)

48-70: Make startPolling() idempotent.

A second start leaks the previous lastEvent subscription because unsubscribe is overwritten without being called first. That can multiply refreshAll() calls for every websocket event.

💡 Proposed fix
 export function startPolling() {
+	unsubscribe?.();
 	refreshAll();
 	unsubscribe = lastEvent.subscribe((event) => {
 		if (!event) return;
 		switch (event.type) {
@@
 export function stopPolling() {
 	unsubscribe?.();
+	unsubscribe = null;
 }
cli/internal/dashboard/commands.go (1)

27-45: Consider setting Content-Type header and handling the Encode error.

The handler logic is correct - validation, enqueue, broadcast, respond. Two minor improvements:

  1. No Content-Type: application/json header is set before encoding the response.
  2. The error from json.NewEncoder(w).Encode() is discarded. While typically benign (client disconnect), logging could aid debugging.
♻️ Suggested improvement
 	s.hub.Broadcast(WSEvent{Type: "command-queued", CommandID: cmd.ID})
+	w.Header().Set("Content-Type", "application/json")
-	json.NewEncoder(w).Encode(QueuedResponse{Queued: true, CommandID: cmd.ID})
+	_ = json.NewEncoder(w).Encode(QueuedResponse{Queued: true, CommandID: cmd.ID})

The explicit _ = documents the intentional discard. Alternatively, consider a helper function to reduce repetition across all handlers.


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 5d937fa1-c12e-4131-8bfd-34f97e9effe1

📥 Commits

Reviewing files that changed from the base of the PR and between 9f1de07 and 3f90d72.

⛔ Files ignored due to path filters (2)
  • cli/go.sum is excluded by !**/*.sum
  • dashboard/package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (98)
  • cli/cmd/fellowship/main.go
  • cli/go.mod
  • cli/internal/autopsy/autopsy.go
  • cli/internal/autopsy/autopsy_test.go
  • cli/internal/dashboard/commands.go
  • cli/internal/dashboard/data.go
  • cli/internal/dashboard/queue.go
  • cli/internal/dashboard/queue_test.go
  • cli/internal/dashboard/server.go
  • cli/internal/dashboard/static/_app/env.js
  • cli/internal/dashboard/static/_app/immutable/assets/0.B4Tu4Ui9.css
  • cli/internal/dashboard/static/_app/immutable/assets/3.BNs1lsiP.css
  • cli/internal/dashboard/static/_app/immutable/assets/4.BQjEdRqL.css
  • cli/internal/dashboard/static/_app/immutable/assets/5.BgjiOm-S.css
  • cli/internal/dashboard/static/_app/immutable/assets/6.BoWh0gEj.css
  • cli/internal/dashboard/static/_app/immutable/assets/7.Ctb9NTgd.css
  • cli/internal/dashboard/static/_app/immutable/assets/8.CA8t3wJx.css
  • cli/internal/dashboard/static/_app/immutable/assets/GateActions.DvbP5nIs.css
  • cli/internal/dashboard/static/_app/immutable/assets/HeraldFeed.D75iUDfw.css
  • cli/internal/dashboard/static/_app/immutable/chunks/0bHCZF5Z.js
  • cli/internal/dashboard/static/_app/immutable/chunks/Brf73Enf.js
  • cli/internal/dashboard/static/_app/immutable/chunks/C6n-RmJK.js
  • cli/internal/dashboard/static/_app/immutable/chunks/CX7xRtj3.js
  • cli/internal/dashboard/static/_app/immutable/chunks/CfZZe3Sc.js
  • cli/internal/dashboard/static/_app/immutable/chunks/CnZUc5Q9.js
  • cli/internal/dashboard/static/_app/immutable/chunks/D36wQwZD.js
  • cli/internal/dashboard/static/_app/immutable/chunks/D4lNzUeh.js
  • cli/internal/dashboard/static/_app/immutable/chunks/D9Ae_xmU.js
  • cli/internal/dashboard/static/_app/immutable/chunks/DDGGbvTe.js
  • cli/internal/dashboard/static/_app/immutable/chunks/DH37wy21.js
  • cli/internal/dashboard/static/_app/immutable/chunks/DQBMclug.js
  • cli/internal/dashboard/static/_app/immutable/chunks/DTR50Ff_.js
  • cli/internal/dashboard/static/_app/immutable/chunks/DWdIdLRT.js
  • cli/internal/dashboard/static/_app/immutable/chunks/DfVq0qY5.js
  • cli/internal/dashboard/static/_app/immutable/chunks/DmxyyQfE.js
  • cli/internal/dashboard/static/_app/immutable/chunks/DsnmJJEf.js
  • cli/internal/dashboard/static/_app/immutable/chunks/gDmQICWX.js
  • cli/internal/dashboard/static/_app/immutable/chunks/sifeoocM.js
  • cli/internal/dashboard/static/_app/immutable/chunks/uxT17ePy.js
  • cli/internal/dashboard/static/_app/immutable/entry/app.D9gBBLez.js
  • cli/internal/dashboard/static/_app/immutable/entry/start.2wbYeCGw.js
  • cli/internal/dashboard/static/_app/immutable/nodes/0.UJ1fGXlT.js
  • cli/internal/dashboard/static/_app/immutable/nodes/1.CjKBPYHY.js
  • cli/internal/dashboard/static/_app/immutable/nodes/2.fl-D6mRW.js
  • cli/internal/dashboard/static/_app/immutable/nodes/3.B9fCFfLL.js
  • cli/internal/dashboard/static/_app/immutable/nodes/4.Cn9Er3ny.js
  • cli/internal/dashboard/static/_app/immutable/nodes/5.VELijUFU.js
  • cli/internal/dashboard/static/_app/immutable/nodes/6.DInTcMZr.js
  • cli/internal/dashboard/static/_app/immutable/nodes/7.BZGO45mE.js
  • cli/internal/dashboard/static/_app/immutable/nodes/8.XpIxEabI.js
  • cli/internal/dashboard/static/_app/version.json
  • cli/internal/dashboard/static/app.js
  • cli/internal/dashboard/static/index.html
  • cli/internal/dashboard/static/style.css
  • cli/internal/dashboard/ws.go
  • cli/internal/dashboard/ws_test.go
  • cli/internal/datadir/datadir.go
  • cli/internal/datadir/datadir_test.go
  • cli/internal/hooks/guard.go
  • cli/internal/hooks/guard_test.go
  • dashboard/.gitignore
  • dashboard/build.sh
  • dashboard/package.json
  • dashboard/src/app.html
  • dashboard/src/lib/api.ts
  • dashboard/src/lib/components/CommandPalette.svelte
  • dashboard/src/lib/components/ConnectionBanner.svelte
  • dashboard/src/lib/components/ErrandList.svelte
  • dashboard/src/lib/components/GanttChart.svelte
  • dashboard/src/lib/components/GateActions.svelte
  • dashboard/src/lib/components/HeraldFeed.svelte
  • dashboard/src/lib/components/PhaseTimeline.svelte
  • dashboard/src/lib/components/QuestCard.svelte
  • dashboard/src/lib/components/Shell.svelte
  • dashboard/src/lib/components/Sidebar.svelte
  • dashboard/src/lib/components/StatCounter.svelte
  • dashboard/src/lib/stores/herald.ts
  • dashboard/src/lib/stores/quests.ts
  • dashboard/src/lib/stores/websocket.ts
  • dashboard/src/lib/styles/global.css
  • dashboard/src/lib/styles/tokens.css
  • dashboard/src/lib/types.ts
  • dashboard/src/routes/+layout.js
  • dashboard/src/routes/+layout.svelte
  • dashboard/src/routes/+page.svelte
  • dashboard/src/routes/autopsies/+page.svelte
  • dashboard/src/routes/command/+page.svelte
  • dashboard/src/routes/config/+page.svelte
  • dashboard/src/routes/herald/+page.svelte
  • dashboard/src/routes/quest/[id]/+page.svelte
  • dashboard/src/routes/timeline/+page.svelte
  • dashboard/svelte.config.js
  • dashboard/tsconfig.json
  • dashboard/vite.config.ts
  • plugin/commands/rekindle.md
  • plugin/skills/fellowship/SKILL.md
  • plugin/skills/quest/SKILL.md
  • plugin/skills/retro/SKILL.md
💤 Files with no reviewable changes (2)
  • cli/internal/dashboard/static/app.js
  • cli/internal/dashboard/static/style.css

Comment thread cli/internal/autopsy/autopsy.go Outdated
Comment thread cli/internal/dashboard/data.go Outdated
Comment thread cli/internal/dashboard/data.go
Comment thread cli/internal/dashboard/queue.go
Comment thread cli/internal/dashboard/queue.go
Comment thread dashboard/src/lib/components/Shell.svelte
Comment thread dashboard/src/lib/stores/websocket.ts
Comment thread dashboard/src/routes/quest/[id]/+page.svelte Outdated
Comment thread plugin/commands/rekindle.md Outdated
Comment thread plugin/skills/fellowship/SKILL.md
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: c3937b41-006d-45d2-9da5-68fa3bf65eeb

📥 Commits

Reviewing files that changed from the base of the PR and between 3f90d72 and c8d8296.

📒 Files selected for processing (2)
  • cli/internal/dashboard/embed.go
  • cli/internal/dashboard/server.go

Comment thread cli/internal/dashboard/server.go Outdated
Comment thread cli/internal/dashboard/server.go Outdated
Comment thread cli/internal/dashboard/server.go Outdated
Comment thread cli/internal/dashboard/server.go Outdated
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 13

🧹 Nitpick comments (5)
cli/internal/dashboard/static/_app/immutable/chunks/CaohEQ9u.js (1)

1-1: Review the source module, not this generated chunk.

This is compiled/minified Svelte output, so any manual fix here will be overwritten on the next build. Please keep behavioral changes in the originating source file and treat this artifact as generated output to keep future reviews actionable.

cli/internal/dashboard/static/_app/immutable/assets/6.DrszQgHF.css (1)

1-1: Verify the Herald feed can actually scroll inside the flex layout.

.feed-scroll is a flex child with overflow-y:auto, but this rule set never gives the scroll region a min-height:0 guard. In nested flex layouts, that can make the feed expand the page instead of becoming the scroll container. Please verify the Herald view in Firefox/Safari, and if the inner pane does not scroll correctly, add min-height:0 in the source stylesheet/component before rebuilding the asset.

cli/internal/autopsy/autopsy.go (1)

155-158: Consider logging failed autopsy removals.

Expired autopsy deletion failures are silently ignored. While not critical, this could lead to disk bloat if permissions or filesystem issues prevent cleanup.

🔧 Optional: log removal failures
 		if ts.Before(cutoff) {
-			os.Remove(path)
+			if err := os.Remove(path); err != nil {
+				fmt.Fprintf(os.Stderr, "fellowship: failed to prune expired autopsy %s: %v\n", entry.Name(), err)
+			}
 			continue
 		}
dashboard/src/lib/components/GateActions.svelte (1)

4-23: Consider adding error feedback to the user.

When approveGate or rejectGate throws, the buttons re-enable but the user receives no indication that the operation failed. Consider adding an error state to display feedback.

dashboard/src/routes/quest/[id]/+page.svelte (1)

31-34: Consider adding a loading indicator.

While fetching errands and tome data, users see "No data available" rather than a loading state. A loading indicator would improve the UX during the async fetch.


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: c16a5383-e385-4e83-95c2-299e61353fd1

📥 Commits

Reviewing files that changed from the base of the PR and between c8d8296 and 3ad1f62.

📒 Files selected for processing (59)
  • cli/go.mod
  • cli/internal/autopsy/autopsy.go
  • cli/internal/autopsy/autopsy_test.go
  • cli/internal/dashboard/data.go
  • cli/internal/dashboard/queue.go
  • cli/internal/dashboard/server.go
  • cli/internal/dashboard/static/_app/immutable/assets/4.DUdybRxg.css
  • cli/internal/dashboard/static/_app/immutable/assets/6.DrszQgHF.css
  • cli/internal/dashboard/static/_app/immutable/chunks/537vdszY.js
  • cli/internal/dashboard/static/_app/immutable/chunks/69_IOA4Y.js
  • cli/internal/dashboard/static/_app/immutable/chunks/BTNO_X98.js
  • cli/internal/dashboard/static/_app/immutable/chunks/BUApaBEI.js
  • cli/internal/dashboard/static/_app/immutable/chunks/BVGfD6Eb.js
  • cli/internal/dashboard/static/_app/immutable/chunks/Ba0bfBr4.js
  • cli/internal/dashboard/static/_app/immutable/chunks/BjyTiIPo.js
  • cli/internal/dashboard/static/_app/immutable/chunks/ByVGy8qs.js
  • cli/internal/dashboard/static/_app/immutable/chunks/C67DyfnJ.js
  • cli/internal/dashboard/static/_app/immutable/chunks/CFIQ5hjj.js
  • cli/internal/dashboard/static/_app/immutable/chunks/CQvy7xpd.js
  • cli/internal/dashboard/static/_app/immutable/chunks/CSXl7R3t.js
  • cli/internal/dashboard/static/_app/immutable/chunks/CaohEQ9u.js
  • cli/internal/dashboard/static/_app/immutable/chunks/CgqK3UK9.js
  • cli/internal/dashboard/static/_app/immutable/chunks/DIeogL5L.js
  • cli/internal/dashboard/static/_app/immutable/chunks/DLS4WeON.js
  • cli/internal/dashboard/static/_app/immutable/chunks/DamZUyog.js
  • cli/internal/dashboard/static/_app/immutable/chunks/DdAmTr_Q.js
  • cli/internal/dashboard/static/_app/immutable/chunks/Dv0Hwark.js
  • cli/internal/dashboard/static/_app/immutable/chunks/Nm4YapQn.js
  • cli/internal/dashboard/static/_app/immutable/chunks/jKnW4BRh.js
  • cli/internal/dashboard/static/_app/immutable/chunks/zKsXIgWY.js
  • cli/internal/dashboard/static/_app/immutable/entry/app.BKAubqOz.js
  • cli/internal/dashboard/static/_app/immutable/entry/start.4GtjckjD.js
  • cli/internal/dashboard/static/_app/immutable/nodes/0.DX6K4gMq.js
  • cli/internal/dashboard/static/_app/immutable/nodes/1.BNOz1B6M.js
  • cli/internal/dashboard/static/_app/immutable/nodes/2.EjT9vOq6.js
  • cli/internal/dashboard/static/_app/immutable/nodes/3.Dy9zUKhR.js
  • cli/internal/dashboard/static/_app/immutable/nodes/4.JGirkQFV.js
  • cli/internal/dashboard/static/_app/immutable/nodes/5.DjFqZ3VZ.js
  • cli/internal/dashboard/static/_app/immutable/nodes/6.YDCVnqWQ.js
  • cli/internal/dashboard/static/_app/immutable/nodes/7.CXeBpwE0.js
  • cli/internal/dashboard/static/_app/immutable/nodes/8.D_pQ694c.js
  • cli/internal/dashboard/static/_app/version.json
  • cli/internal/dashboard/static/index.html
  • cli/internal/dashboard/ws.go
  • dashboard/src/lib/api.ts
  • dashboard/src/lib/components/CommandPalette.svelte
  • dashboard/src/lib/components/GateActions.svelte
  • dashboard/src/lib/components/HeraldFeed.svelte
  • dashboard/src/lib/components/QuestCard.svelte
  • dashboard/src/lib/components/Shell.svelte
  • dashboard/src/lib/stores/websocket.ts
  • dashboard/src/routes/+page.js
  • dashboard/src/routes/+page.svelte
  • dashboard/src/routes/autopsies/+page.svelte
  • dashboard/src/routes/herald/+page.svelte
  • dashboard/src/routes/quest/[id]/+page.svelte
  • dashboard/src/routes/timeline/+page.svelte
  • plugin/commands/rekindle.md
  • plugin/skills/fellowship/SKILL.md
✅ Files skipped from review due to trivial changes (1)
  • cli/internal/dashboard/static/_app/immutable/chunks/BUApaBEI.js
🚧 Files skipped from review as they are similar to previous changes (11)
  • dashboard/src/lib/components/Shell.svelte
  • dashboard/src/lib/stores/websocket.ts
  • dashboard/src/routes/autopsies/+page.svelte
  • dashboard/src/lib/components/QuestCard.svelte
  • cli/go.mod
  • dashboard/src/routes/timeline/+page.svelte
  • cli/internal/dashboard/static/_app/version.json
  • cli/internal/autopsy/autopsy_test.go
  • cli/internal/dashboard/ws.go
  • dashboard/src/routes/herald/+page.svelte
  • plugin/commands/rekindle.md

Comment thread cli/internal/autopsy/autopsy.go Outdated
Comment thread cli/internal/dashboard/data.go Outdated
Comment thread cli/internal/dashboard/data.go
Comment thread cli/internal/dashboard/queue.go Outdated
@@ -0,0 +1 @@
import"./DsnmJJEf.js";import{p as m,d as n,g as i,s as g,t as x,l as s,b as h,_ as y,F as r}from"./CFIQ5hjj.js";import{d as _,s as c,b as f,a as k,f as j}from"./ByVGy8qs.js";import{b as w,r as G}from"./C67DyfnJ.js";var A=j('<div class="gate-actions svelte-1fnxfh2"><button class="gate-btn approve svelte-1fnxfh2"> </button> <button class="gate-btn reject svelte-1fnxfh2"> </button></div>');function B(p,o){m(o,!0);let t=y(!1);async function v(){r(t,!0);try{await w(o.worktree)}finally{r(t,!1)}}async function d(){r(t,!0);try{await G(o.worktree)}finally{r(t,!1)}}var l=A(),e=n(l),u=n(e,!0);i(e);var a=g(e,2),b=n(a,!0);i(a),i(l),x(()=>{e.disabled=s(t),c(u,s(t)?"...":"Approve"),a.disabled=s(t),c(b,s(t)?"...":"Reject")}),f("click",e,v),f("click",a,d),k(p,l),h()}_(["click"]);export{B as G};
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add a synchronous guard to prevent duplicate approve/reject submissions.

These handlers only depend on the reactive disabled update. A quick double-activation can still invoke w(o.worktree) / G(o.worktree) twice before the buttons become disabled, which is risky for gate mutations. Please add an early if (loading) return; guard in the source component before setting the loading state.

@@ -0,0 +1 @@
import"../chunks/DsnmJJEf.js";import{p as U,E as V,$ as X,a1 as Y,t as $,l as t,b as Z,d as l,s as i,F as P,_ as S,g as a,a0 as k}from"../chunks/CFIQ5hjj.js";import{a as u,f as _,s as T}from"../chunks/ByVGy8qs.js";import{s as ee,a as te}from"../chunks/DLS4WeON.js";import{i as se}from"../chunks/537vdszY.js";import{e as E,i as G}from"../chunks/zKsXIgWY.js";import{r as L}from"../chunks/DdAmTr_Q.js";import{b as ae,a as re}from"../chunks/jKnW4BRh.js";import{b as le}from"../chunks/CaohEQ9u.js";import{s as ie,a as oe,b as pe,c as ne}from"../chunks/BjyTiIPo.js";import{s as ve,a as de,t as ce,H as ue}from"../chunks/BTNO_X98.js";var _e=_('<option class="svelte-11xttp7"> </option>'),fe=_('<label class="type-checkbox svelte-11xttp7"><input type="checkbox" class="svelte-11xttp7"/> <span class="type-label svelte-11xttp7"> </span></label>'),me=_('<div class="empty-state svelte-11xttp7"><p>No tidings match the current filters.</p></div>'),ge=_('<div class="herald-view svelte-11xttp7"><div class="view-header svelte-11xttp7"><h1 class="svelte-11xttp7">Herald</h1> <span class="tiding-count svelte-11xttp7"> </span></div> <div class="filters svelte-11xttp7"><div class="filter-row svelte-11xttp7"><input type="text" class="search-input svelte-11xttp7" placeholder="Search tidings..."/> <select class="quest-select svelte-11xttp7"><option class="svelte-11xttp7">All Quests</option><!></select></div> <div class="type-filters svelte-11xttp7"></div></div> <div class="feed-scroll svelte-11xttp7"><!></div></div>');function Pe(A,B){U(B,!0);const C=()=>te(ce,"$tidings",F),[F,N]=ee();let n=S(""),v=S(""),d=V({gate_submitted:!0,gate_approved:!0,gate_rejected:!0,phase_transition:!0});const Q=["gate_submitted","gate_approved","gate_rejected","phase_transition"],W={gate_submitted:"Gate Submitted",gate_approved:"Gate Approved",gate_rejected:"Gate Rejected",phase_transition:"Phase Transition"};let q=k(C),D=k(()=>[...new Set(t(q).map(e=>e.quest))].filter(Boolean).sort()),c=k(()=>t(q).filter(e=>{if(t(v)&&e.quest!==t(v)||Object.values(d).some(Boolean)&&!d[e.type])return!1;if(t(n)){const s=t(n).toLowerCase();if(!`${e.quest} ${e.type} ${e.phase} ${e.detail}`.toLowerCase().includes(s))return!1}return!0}));X(()=>{ie(),oe(),ve()}),Y(()=>{pe(),ne(),de()});var f=ge(),m=l(f),w=i(l(m),2),M=l(w);a(w),a(m);var g=i(m,2),h=l(g),x=l(h);L(x);var b=i(x,2),y=l(b);y.value=y.__value="";var O=i(y);E(O,17,()=>t(D),G,(e,r)=>{var s=_e(),o=l(s,!0);a(s);var p={};$(()=>{T(o,t(r)),p!==(p=t(r))&&(s.value=(s.__value=t(r))??"")}),u(e,s)}),a(b),a(h);var j=i(h,2);E(j,21,()=>Q,G,(e,r)=>{var s=fe(),o=l(s);L(o);var p=i(o,2),J=l(p,!0);a(p),a(s),$(()=>T(J,W[t(r)])),re(o,()=>d[t(r)],K=>d[t(r)]=K),u(e,s)}),a(j),a(g);var H=i(g,2),R=l(H);{var z=e=>{ue(e,{get tidings(){return t(c)},get limit(){return t(c).length}})},I=e=>{var r=me();u(e,r)};se(R,e=>{t(c).length>0?e(z):e(I,-1)})}a(H),a(f),$(()=>T(M,`${t(c).length??""} tidings`)),ae(x,()=>t(n),e=>P(n,e)),le(b,()=>t(v),e=>P(v,e)),u(A,f),Z(),N()}export{Pe as component};
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don't hardcode a partial event-type allowlist here.

d/Q only cover four tiding types, but cli/internal/dashboard/static/_app/immutable/chunks/BTNO_X98.js defines additional ones (lembas_completed, metadata_updated, quest_held, quest_unheld). Because the filter does !d[e.type], those events are hidden even before the user changes any filters. Please derive this state from the shared event-type map instead of a local subset.


⚠️ Potential issue | 🟠 Major

Label the Herald filter controls.

The search <input> and quest <select> are rendered without an associated <label> or aria-label, so assistive tech will announce them as unlabeled controls. Please add accessible names in the source route component.

@@ -0,0 +1 @@
import"../chunks/DsnmJJEf.js";import{p as G,f as Y,d as u,g as v,s as w,l as r,t as M,b as N,a0 as z,_ as C,F as j,E as A,$ as B,a1 as H}from"../chunks/CFIQ5hjj.js";import{s as J,a as K}from"../chunks/DLS4WeON.js";import{e as E,a as p,s as k,f}from"../chunks/ByVGy8qs.js";import{i as X}from"../chunks/537vdszY.js";import{d as L,s as Q,a as U,e as O,b as V,c as Z}from"../chunks/BjyTiIPo.js";import{d as tt}from"../chunks/C67DyfnJ.js";import{e as W,i as F}from"../chunks/zKsXIgWY.js";var et=f('<div class="gantt-bar svelte-t05lje"></div>'),at=f('<div class="gantt-row svelte-t05lje"><div class="gantt-label svelte-t05lje"> </div> <div class="gantt-track svelte-t05lje"></div></div>'),st=f('<div class="gantt-tooltip svelte-t05lje"><span class="tooltip-phase svelte-t05lje"> </span> <span class="tooltip-dur svelte-t05lje"> </span></div>'),rt=f('<div class="gantt-chart svelte-t05lje"></div> <!>',1);function nt(q,h){G(h,!0);const y={Onboard:"var(--accent-blue-dim)",Research:"var(--accent-purple-dim)",Plan:"var(--accent-gold-dim)",Implement:"var(--accent-green-dim)",Review:"var(--accent-gold)",Complete:"var(--accent-green)"},T={Onboard:"var(--accent-blue)",Research:"var(--accent-purple)",Plan:"var(--accent-gold)",Implement:"var(--accent-green-text)",Review:"var(--accent-gold)",Complete:"var(--accent-green-text)"};function S(s){const a=Math.floor(s/6e4);if(a<60)return`${a}m`;const t=Math.floor(a/60);return t<24?`${t}h ${a%60}m`:`${Math.floor(t/24)}d ${t%24}h`}let b=z(()=>{if(h.quests.length===0)return[];let s=1/0,a=-1/0;for(const l of h.quests){for(const d of l.phases_completed){const i=new Date(d.timestamp).getTime();i<s&&(s=i),i>a&&(a=i)}const e=new Date(l.created_at).getTime();e<s&&(s=e)}a<=s&&(a=s+1);const t=a-s,n=[];for(const l of h.quests){const e=[...l.phases_completed].sort((o,m)=>new Date(o.timestamp).getTime()-new Date(m.timestamp).getTime());if(e.length===0)continue;const d=[],i=new Date(l.created_at).getTime();for(let o=0;o<e.length;o++){const m=o===0?i:new Date(e[o-1].timestamp).getTime(),R=new Date(e[o].timestamp).getTime()-m;d.push({phase:e[o].phase,startPct:(m-s)/t*100,widthPct:Math.max(R/t*100,.5),duration:S(R)})}n.push({name:l.quest_name,segments:d})}return n}),c=C(null);function D(s,a,t){j(c,{quest:a,phase:t.phase,duration:t.duration,x:s.clientX,y:s.clientY},!0)}function x(){j(c,null)}var g=rt(),_=Y(g);W(_,21,()=>r(b),F,(s,a)=>{var t=at(),n=u(t),l=u(n,!0);v(n);var e=w(n,2);W(e,21,()=>r(a).segments,F,(d,i)=>{var o=et();let m;M(()=>m=L(o,"",m,{left:`${r(i).startPct??""}%`,width:`${r(i).widthPct??""}%`,background:y[r(i).phase]??"var(--bg-raised)",color:T[r(i).phase]??"var(--text-muted)"})),E("mouseenter",o,I=>D(I,r(a).name,r(i))),E("mouseleave",o,x),p(d,o)}),v(e),v(t),M(()=>k(l,r(a).name)),p(s,t)}),v(_);var P=w(_,2);{var $=s=>{var a=st();let t;var n=u(a),l=u(n,!0);v(n);var e=w(n,2),d=u(e,!0);v(e),v(a),M(()=>{t=L(a,"",t,{left:`${r(c).x+12}px`,top:`${r(c).y-8}px`}),k(l,r(c).phase),k(d,r(c).duration)}),p(s,a)};X(P,s=>{r(c)&&s($)})}p(q,g),N()}var ot=f('<div class="empty-state svelte-1fjec68">Loading quest timeline data...</div>'),lt=f('<div class="empty-state svelte-1fjec68">No quest timeline data available.</div>'),it=f('<div class="timeline-view svelte-1fjec68"><div class="view-header svelte-1fjec68"><h1 class="svelte-1fjec68">Timeline</h1></div> <div class="chart-area svelte-1fjec68"><!></div></div>');function gt(q,h){G(h,!0);const y=()=>K(O,"$dashboardStatus",T),[T,S]=J();let b=C(A([])),c=C(!0);async function D(){const t=y();if(!t)return;const n=t.quests.map(e=>e.name),l=await Promise.allSettled(n.map(e=>tt(e)));j(b,l.filter(e=>e.status==="fulfilled").map(e=>e.value).filter(e=>e!=null),!0),j(c,!1)}let x=null;B(()=>{Q(),U(),x=O.subscribe(t=>{t&&D()})}),H(()=>{V(),Z(),x?.()});var g=it(),_=w(u(g),2),P=u(_);{var $=t=>{var n=ot();p(t,n)},s=t=>{var n=lt();p(t,n)},a=t=>{nt(t,{get quests(){return r(b)}})};X(P,t=>{r(c)?t($):r(b).length===0?t(s,1):t(a,-1)})}v(_),v(g),p(q,g),N(),S()}export{gt as component};
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if any NaN guards exist in the dashboard source files
rg -n "isNaN|Number\.isNaN|isFinite" --type ts --type svelte 2>/dev/null || \
rg -n "isNaN|Number\.isNaN|isFinite" -g "*.svelte" -g "*.ts"

# Look for the timeline source component to verify NaN handling
fd -i timeline -e svelte --type f --exec cat {}

Repository: justinjdev/fellowship

Length of output: 1129


🏁 Script executed:

# Find the timeline/gantt chart source component
fd -i gantt -e svelte --type f
fd -i timeline -e ts --type f
fd -i timeline -e svelte --type f

# Look for the dashboard source files
find . -path "*/dashboard/src*" -type f \( -name "*.svelte" -o -name "*.ts" \) | head -20

Repository: justinjdev/fellowship

Length of output: 1040


🏁 Script executed:

cat -n dashboard/src/lib/components/GanttChart.svelte

Repository: justinjdev/fellowship

Length of output: 6589


🏁 Script executed:

cat -n dashboard/src/lib/types.ts | head -100
cat -n dashboard/src/lib/api.ts | head -100

Repository: justinjdev/fellowship

Length of output: 6388


Add NaN validation for Gantt chart percentage calculations.

When timestamps in phases_completed are invalid date strings, new Date().getTime() returns NaN, which propagates through the timeline calculations. Even with the globalMax <= globalMin guard (line 60), if all timestamps are invalid, range becomes NaN, producing invalid CSS values like left: NaN% and width: NaN%. While the API layer should ideally validate timestamps, adding a check like isNaN(range) before rendering segments would prevent silent rendering failures:

if (isNaN(range) || range <= 0) {
  // Skip rendering or return empty
  return [];
}

Comment on lines +100 to +104
let filtered = $derived(
query
? actions.filter((a) => a.label.toLowerCase().includes(query.toLowerCase()))
: actions
);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Use a real fuzzy matcher here.

This is still contiguous substring matching, so queries like sq / rq will never find Spawn Quest / Restart Quest. That misses the fuzzy-search behavior this PR claims to add.

Suggested direction
+	function fuzzyMatch(query: string, label: string): boolean {
+		const needle = query.toLowerCase().replace(/\s+/g, '');
+		const haystack = label.toLowerCase();
+		let i = 0;
+		for (const ch of haystack) {
+			if (ch === needle[i]) i++;
+			if (i === needle.length) return true;
+		}
+		return needle.length === 0;
+	}
+
 	let filtered = $derived(
-		query
-			? actions.filter((a) => a.label.toLowerCase().includes(query.toLowerCase()))
-			: actions
+		query ? actions.filter((a) => fuzzyMatch(query, a.label)) : actions
 	);

Comment thread dashboard/src/lib/components/CommandPalette.svelte Outdated
Comment thread plugin/skills/fellowship/SKILL.md
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

♻️ Duplicate comments (1)
cli/internal/dashboard/queue.go (1)

83-87: ⚠️ Potential issue | 🟡 Minor

Consider adding file-level synchronization for queue writes to prevent multi-process races.

The mutex currently protects only in-process concurrency. While the dashboard runs as a single server instance today, if additional CLI processes or multiple dashboard instances ever access the queue file simultaneously, the predictable .tmp filename creates a race condition: both processes could write to the same temporary file, and rename ordering could lose data.

Since the codebase already uses filelock elsewhere (e.g., state.go, fellowship.go), consider either:

  1. Adding filelock around queue reads/writes:
+ lockFile := path + ".lock"
+ f, _ := os.Create(lockFile)
+ filelock.Lock(f.Fd())
+ defer filelock.Unlock(f.Fd())
  // existing write logic
  1. Or use a unique tmp name to avoid collisions:
- tmp := path + ".tmp"
+ tmp := fmt.Sprintf("%s.tmp.%d", path, os.Getpid())

This is defensive programming—currently only the single dashboard instance writes to the queue, but explicit locking prevents issues if the architecture changes.

🧹 Nitpick comments (8)
cli/internal/autopsy/autopsy.go (3)

157-162: Consider documenting the pruning side effect in the function signature.

The Scan function performs filesystem writes (deleting expired autopsies) during what appears to be a read operation. While this lazy cleanup is efficient, callers may not expect a "scan" to mutate state. Consider adding a comment to the function doc or renaming to ScanAndPrune.


264-278: Path separator handling may be inconsistent across platforms.

inferModules (line 247) normalizes paths with filepath.ToSlash, but matchesFilters uses filepath.Dir which is OS-dependent. If autopsy files are stored with forward slashes, filepath.Dir on Windows will not correctly extract directory components.

♻️ Proposed fix for consistent path handling
 func matchesFilters(a *Autopsy, opts ScanOptions) bool {
 	// File path prefix match
 	for _, queryFile := range opts.Files {
 		for _, autopsyFile := range a.Files {
-			if strings.HasPrefix(autopsyFile, queryFile) || strings.HasPrefix(queryFile, autopsyFile) {
+			normalizedQuery := filepath.ToSlash(queryFile)
+			normalizedAutopsy := filepath.ToSlash(autopsyFile)
+			if strings.HasPrefix(normalizedAutopsy, normalizedQuery) || strings.HasPrefix(normalizedQuery, normalizedAutopsy) {
 				return true
 			}
 			// Also match if they share a directory prefix (skip root-level files)
-			queryDir := filepath.Dir(queryFile)
-			aDir := filepath.Dir(autopsyFile)
+			queryDir := filepath.Dir(normalizedQuery)
+			aDir := filepath.Dir(normalizedAutopsy)
 			if queryDir != "." && aDir != "." && queryDir == aDir {
 				return true
 			}

313-320: Consider sanitizing additional characters for cross-platform filename safety.

The sanitize function is used to create filenames (line 99: sanitize(input.Quest) becomes part of the .json filename). While the current quest names in the codebase don't contain problematic characters, Windows filenames prohibit :, *, ?, ", <, >, and |. Adding defensive sanitization for these characters would improve robustness across platforms.

cli/internal/dashboard/data.go (1)

53-57: Missing Content-Type header for list response.

The single-autopsy response sets Content-Type: application/json (line 28), but the list response does not. While most clients will handle this, it's inconsistent and could cause issues with strict content-type checking.

♻️ Proposed fix
 	sort.Slice(records, func(i, j int) bool {
 		return records[i].Timestamp > records[j].Timestamp
 	})
+	w.Header().Set("Content-Type", "application/json")
 	json.NewEncoder(w).Encode(records)
 }
dashboard/src/routes/quest/[id]/+page.svelte (3)

5-5: Unused import: PhaseTimeline.

PhaseTimeline is imported but never used in the template. Remove it to avoid dead code.

-	import PhaseTimeline from '$lib/components/PhaseTimeline.svelte';

82-93: Missing ARIA attributes for tab accessibility.

The tabs lack role, aria-selected, and aria-controls attributes needed for screen reader users to understand the tab interface.

♿ Suggested accessibility improvement
-<div class="tabs">
-	<button class="tab" class:active={activeTab === 'errands'} onclick={() => activeTab = 'errands'}>
+<div class="tabs" role="tablist">
+	<button class="tab" class:active={activeTab === 'errands'} onclick={() => activeTab = 'errands'}
+		role="tab" aria-selected={activeTab === 'errands'} aria-controls="panel-errands">
 		Errands
 		...
 	</button>
-	<button class="tab" class:active={activeTab === 'files'} onclick={() => activeTab = 'files'}>Files</button>
+	<button class="tab" class:active={activeTab === 'files'} onclick={() => activeTab = 'files'}
+		role="tab" aria-selected={activeTab === 'files'} aria-controls="panel-files">Files</button>
 	<!-- Similar for other tabs -->

And add role="tabpanel" with matching id attributes to content sections.


97-124: Consider adding a loading state and guarding against missing nested properties.

  1. The "No data available" fallback shows during initial load before data arrives, which may confuse users.
  2. tomeData.files_touched and tomeData.gate_history could be undefined if the API response shape differs, causing silent failures in {#each}.
🛠️ Suggested improvements
+{`#if` !dataLoaded && quest}
+	<p class="empty">Loading...</p>
+{:else if activeTab === 'errands' && errandList}
-{`#if` activeTab === 'errands' && errandList}
 	<ErrandList errands={errandList.items} />
 {:else if activeTab === 'files' && tomeData}
 	<div class="files-list">
-		{`#each` tomeData.files_touched as file}
+		{`#each` tomeData.files_touched ?? [] as file}
 			<div class="file-item">{file}</div>
 		{/each}
 	</div>
 {:else if activeTab === 'tome' && tomeData}
 	<div class="tome-content">
 		<h3>Gate History</h3>
-		{`#each` tomeData.gate_history as gate}
+		{`#each` tomeData.gate_history ?? [] as gate}
cli/internal/dashboard/queue.go (1)

42-44: Consider adding a pruning mechanism for completed/failed commands.

The queue will grow unbounded over time since there's no removal of completed or failed commands. For long-running systems, consider:

  1. Pruning old completed/failed commands during EnqueueCommand (e.g., keep only last N or commands newer than X days).
  2. Adding a separate PruneQueue function called periodically.

This prevents the queue file from growing indefinitely and keeps load/save operations fast.


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 9e4fce6d-faac-4aa9-b553-3e3ebbd192bf

📥 Commits

Reviewing files that changed from the base of the PR and between 3ad1f62 and 18028c2.

📒 Files selected for processing (5)
  • cli/internal/autopsy/autopsy.go
  • cli/internal/dashboard/data.go
  • cli/internal/dashboard/queue.go
  • dashboard/src/routes/config/+page.svelte
  • dashboard/src/routes/quest/[id]/+page.svelte
🚧 Files skipped from review as they are similar to previous changes (1)
  • dashboard/src/routes/config/+page.svelte

Comment thread cli/internal/dashboard/data.go
Comment thread cli/internal/dashboard/data.go Outdated
Comment thread dashboard/src/routes/quest/[id]/+page.svelte
Comment thread dashboard/src/routes/quest/[id]/+page.svelte
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 10

🧹 Nitpick comments (3)
cli/internal/dashboard/static/_app/immutable/nodes/3.DZm9JPwg.js (1)

1-1: This is compiled/minified build output — review the Svelte source files instead.

This file is SvelteKit's bundled output (committed to enable Go's embed.FS). Standard code review isn't applicable to minified artifacts. Please ensure the original .svelte source files (likely under a src/ or frontend/ directory) are included in the review scope for meaningful feedback on logic, error handling, and UI behavior.

If the source files aren't part of this PR, consider adding them or confirming they were reviewed separately.

cli/internal/dashboard/static/index.html (1)

34-39: Handle bootstrap import failures explicitly.

If Line 35 or Line 36 fails to load, the page stays blank with no user-visible error. Adding a small .catch(...) here would make stale asset/version mismatches much easier to diagnose.

Suggested change
 					Promise.all([
 						import("/_app/immutable/entry/start.LZjJK6rN.js"),
 						import("/_app/immutable/entry/app.B4sUXTrz.js")
 					]).then(([kit, app]) => {
 						kit.start(app, element);
+					}).catch((error) => {
+						console.error("Failed to bootstrap dashboard", error);
+						element.innerHTML =
+							'<div role="alert">Failed to load dashboard. Refresh the page or restart the server.</div>';
 					});
cli/internal/dashboard/static/_app/immutable/entry/app.B4sUXTrz.js (1)

1-2: Keep the emitted bundle reviewable.

This hashed, minified asset turns small dashboard changes into opaque one-line diffs. If committed assets are required for embed.FS, consider pointing reviewers to the originating SvelteKit source files or generating this output in CI so behavior changes stay auditable.


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 61569ed9-268e-4e8f-b643-f45778414672

📥 Commits

Reviewing files that changed from the base of the PR and between 18028c2 and e737edf.

📒 Files selected for processing (16)
  • cli/internal/dashboard/static/_app/immutable/assets/3.CVgNbBqn.css
  • cli/internal/dashboard/static/_app/immutable/chunks/CMrUtYuQ.js
  • cli/internal/dashboard/static/_app/immutable/chunks/OlN70tzR.js
  • cli/internal/dashboard/static/_app/immutable/chunks/loYStGme.js
  • cli/internal/dashboard/static/_app/immutable/entry/app.B4sUXTrz.js
  • cli/internal/dashboard/static/_app/immutable/entry/start.LZjJK6rN.js
  • cli/internal/dashboard/static/_app/immutable/nodes/0.DGxOyW2-.js
  • cli/internal/dashboard/static/_app/immutable/nodes/1.CTb2d7ZU.js
  • cli/internal/dashboard/static/_app/immutable/nodes/3.DZm9JPwg.js
  • cli/internal/dashboard/static/_app/immutable/nodes/4.DEgfp_0c.js
  • cli/internal/dashboard/static/_app/immutable/nodes/5.VZsWOulv.js
  • cli/internal/dashboard/static/_app/immutable/nodes/7.R1TVsdkV.js
  • cli/internal/dashboard/static/_app/version.json
  • cli/internal/dashboard/static/index.html
  • dashboard/src/lib/components/GateActions.svelte
  • dashboard/src/routes/autopsies/+page.svelte
✅ Files skipped from review due to trivial changes (1)
  • cli/internal/dashboard/static/_app/immutable/assets/3.CVgNbBqn.css
🚧 Files skipped from review as they are similar to previous changes (3)
  • dashboard/src/lib/components/GateActions.svelte
  • dashboard/src/routes/autopsies/+page.svelte
  • cli/internal/dashboard/static/_app/version.json

@@ -0,0 +1 @@
import"../chunks/DsnmJJEf.js";import{a4 as ue,az as ye,a3 as we,b6 as be,aS as ke,w as B,ae as Ce,ay as ae,ac as se,a6 as le,ad as Ee,a7 as xe,p as Y,t as T,b as Z,d as p,s as w,g as v,l as e,i as oe,F as r,$ as ve,_ as q,f as O,a0 as U,a1 as Se,e as De,b7 as qe}from"../chunks/CFIQ5hjj.js";import{d as de,b as M,a as m,f as _,s as I,e as Qe,c as Pe}from"../chunks/ByVGy8qs.js";import{B as Fe,i as R}from"../chunks/537vdszY.js";import{e as V,i as W}from"../chunks/zKsXIgWY.js";import{a as J,r as ne}from"../chunks/DdAmTr_Q.js";import{s as X}from"../chunks/CQvy7xpd.js";import{p as Ne}from"../chunks/BVGfD6Eb.js";import{p as re}from"../chunks/OlN70tzR.js";import{b as ie}from"../chunks/jKnW4BRh.js";import{b as ce}from"../chunks/CSXl7R3t.js";import{g as K}from"../chunks/loYStGme.js";import{e as Te,g as Ie,k as Ae,h as Ke}from"../chunks/C67DyfnJ.js";function pe(h,o,...l){var u=new Fe(h);ue(()=>{const s=o()??null;u.ensure(s,s&&(t=>s(t,...l)))},ye)}function Me(h,o){let l=null,u=B;var s;if(B){l=Ee;for(var t=xe(document.head);t!==null&&(t.nodeType!==Ce||t.data!==h);)t=ae(t);if(t===null)se(!1);else{var i=ae(t);t.remove(),le(i)}}B||(s=document.head.appendChild(we()));try{ue(()=>o(s),be|ke)}finally{u&&(se(!0),le(l))}}const Oe=!1,Re=!1,pt=Object.freeze(Object.defineProperty({__proto__:null,prerender:Oe,ssr:Re},Symbol.toStringTag,{value:"Module"}));var Ge=_('<span class="logo-text svelte-129hoe0">Fellowship</span>'),Le=_('<span class="nav-label"> </span>'),He=_('<a><span class="nav-icon svelte-129hoe0"> </span> <!></a>'),je=_('<aside><div class="sidebar-header svelte-129hoe0"><div class="logo-mark svelte-129hoe0">F</div> <!></div> <nav class="sidebar-nav svelte-129hoe0"></nav> <button class="sidebar-toggle svelte-129hoe0"> </button></aside>');function ze(h,o){Y(o,!0);let l=Ne(o,"collapsed",15,!1);const u=[{path:"/command",label:"Command",icon:"⚔"},{path:"/herald",label:"Herald",icon:"📜"},{path:"/autopsies",label:"Autopsies",icon:"◆"},{path:"/timeline",label:"Timeline",icon:"▰"},{path:"/config",label:"Config",icon:"⚙"}];function s(y){return re.url.pathname===y||re.url.pathname.startsWith(y+"/")}var t=je();let i;var g=p(t),b=w(p(g),2);{var S=y=>{var C=Ge();m(y,C)};R(b,y=>{l()||y(S)})}v(g);var Q=w(g,2);V(Q,21,()=>u,W,(y,C)=>{var f=He();let F;var A=p(f),L=p(A,!0);v(A);var H=w(A,2);{var a=n=>{var c=Le(),D=p(c,!0);v(c),T(()=>I(D,e(C).label)),m(n,c)};R(H,n=>{l()||n(a)})}v(f),T(n=>{J(f,"href",e(C).path),F=X(f,1,"nav-item svelte-129hoe0",null,F,n),J(f,"title",l()?e(C).label:void 0),I(L,e(C).icon)},[()=>({active:s(e(C).path)})]),m(y,f)}),v(Q);var k=w(Q,2),d=p(k,!0);v(k),v(t),T(()=>{i=X(t,1,"sidebar svelte-129hoe0",null,i,{collapsed:l()}),I(d,l()?"›":"‹")}),M("click",k,()=>l(!l())),m(h,t),Z()}de(["click"]);var Be=_('<div class="input-mode-header svelte-wh9uu8"> </div> <input class="search-input svelte-wh9uu8" type="text"/>',1),Ue=_("<button> </button>"),Ve=_('<div class="category-header svelte-wh9uu8"> </div> <!>',1),We=_('<div class="no-results svelte-wh9uu8">No matching commands</div>'),Je=_('<input class="search-input svelte-wh9uu8" placeholder="Search commands..." type="text"/> <div class="action-list svelte-wh9uu8"><!> <!></div>',1),Xe=_('<div class="overlay svelte-wh9uu8"><div class="panel svelte-wh9uu8"><!></div></div>');function Ye(h,o){Y(o,!0);const l=[{label:"Spawn Quest",category:"Quest Control",action:()=>{r(t,"spawn-quest")},inputPlaceholder:"Enter task description..."},{label:"Spawn Scout",category:"Quest Control",action:()=>{r(t,"spawn-scout")},inputPlaceholder:"Enter question..."},{label:"Kill Quest",category:"Quest Control",action:()=>{r(t,"kill-quest")},inputPlaceholder:"Enter quest ID..."},{label:"Restart Quest",category:"Quest Control",action:()=>{r(t,"restart-quest")},inputPlaceholder:"Enter quest ID..."},{label:"Go to Command",category:"Navigation",action:()=>{K("/command"),o.onClose()}},{label:"Go to Herald",category:"Navigation",action:()=>{K("/herald"),o.onClose()}},{label:"Go to Autopsies",category:"Navigation",action:()=>{K("/autopsies"),o.onClose()}},{label:"Go to Timeline",category:"Navigation",action:()=>{K("/timeline"),o.onClose()}},{label:"Go to Config",category:"Navigation",action:()=>{K("/config"),o.onClose()}}];let u=q(""),s=q(0),t=q(null),i=q(""),g=q(void 0),b=q(void 0),S=U(()=>e(u)?l.filter(a=>a.label.toLowerCase().includes(e(u).toLowerCase())):l),Q=U(()=>()=>{const a=[],n=new Set;for(const c of e(S))n.has(c.category)||(n.add(c.category),a.push({category:c.category,items:[]})),a.find(D=>D.category===c.category).items.push(c);return a});function k(){return e(S)}function d(){const a=k();a[e(s)]&&a[e(s)].action()}async function y(){if(e(i).trim())try{e(t)==="spawn-quest"?await Te(e(i).trim()):e(t)==="spawn-scout"?await Ie(e(i).trim()):e(t)==="kill-quest"?await Ae(e(i).trim()):e(t)==="restart-quest"&&await Ke(e(i).trim()),r(t,null),r(i,""),o.onClose()}catch{}}function C(a){if(e(t)){a.key==="Escape"?(a.preventDefault(),r(t,null),r(i,""),e(g)?.focus()):a.key==="Enter"&&(a.preventDefault(),y());return}if(a.key==="Escape")a.preventDefault(),o.onClose();else if(a.key==="ArrowDown"){a.preventDefault();const n=k();r(s,(e(s)+1)%n.length)}else if(a.key==="ArrowUp"){a.preventDefault();const n=k();r(s,(e(s)-1+n.length)%n.length)}else a.key==="Enter"&&(a.preventDefault(),d())}oe(()=>{e(u),r(s,0)}),oe(()=>{e(t)&&e(b)&&e(b).focus()}),ve(()=>{e(g)?.focus()});var f=Xe(),F=p(f),A=p(F);{var L=a=>{var n=Be(),c=O(n),D=p(c,!0);v(c);var P=w(c,2);ne(P),ce(P,E=>r(b,E),()=>e(b)),T(E=>{I(D,e(t)==="spawn-quest"?"Spawn Quest":e(t)==="spawn-scout"?"Spawn Scout":e(t)==="kill-quest"?"Kill Quest":"Restart Quest"),J(P,"placeholder",E)},[()=>l.find(E=>E.label.toLowerCase().replace(/\s+/g,"-")===e(t)?.replace("spawn-","spawn-").replace("kill-","kill-").replace("restart-","restart-"))?.inputPlaceholder??"Enter value..."]),ie(P,()=>e(i),E=>r(i,E)),m(a,n)},H=a=>{var n=Je(),c=O(n);ne(c),ce(c,x=>r(g,x),()=>e(g));var D=w(c,2),P=p(D);V(P,17,()=>e(Q)(),W,(x,G)=>{var $=Ve(),j=O($),me=p(j,!0);v(j);var he=w(j,2);V(he,17,()=>e(G).items,W,(ge,z)=>{const ee=U(()=>e(S).indexOf(e(z)));var N=Ue();let te;var _e=p(N,!0);v(N),T(()=>{te=X(N,1,"action-item svelte-wh9uu8",null,te,{selected:e(ee)===e(s)}),I(_e,e(z).label)}),Qe("mouseenter",N,()=>r(s,e(ee),!0)),M("click",N,()=>e(z).action()),m(ge,N)}),T(()=>I(me,e(G).category)),m(x,$)});var E=w(P,2);{var fe=x=>{var G=We();m(x,G)};R(E,x=>{e(S).length===0&&x(fe)})}v(D),ie(c,()=>e(u),x=>r(u,x)),m(a,n)};R(A,a=>{e(t)?a(L):a(H,-1)})}v(F),v(f),M("keydown",f,C),M("click",f,function(...a){o.onClose?.apply(this,a)}),M("click",F,a=>a.stopPropagation()),m(h,f),Z()}de(["keydown","click"]);var Ze=_('<div class="shell svelte-w96i92"><!> <main class="main-content svelte-w96i92"><!></main></div> <!>',1);function $e(h,o){Y(o,!0);let l=q(!1),u=q(!1);function s(d){d.key==="k"&&(d.metaKey||d.ctrlKey)&&(d.preventDefault(),r(u,!e(u)))}ve(()=>{window.addEventListener("keydown",s)}),Se(()=>{window.removeEventListener("keydown",s)});var t=Ze(),i=O(t),g=p(i);ze(g,{get collapsed(){return e(l)},set collapsed(d){r(l,d,!0)}});var b=w(g,2),S=p(b);pe(S,()=>o.children),v(b),v(i);var Q=w(i,2);{var k=d=>{Ye(d,{onClose:()=>r(u,!1)})};R(Q,d=>{e(u)&&d(k)})}m(h,t),Z()}function ft(h,o){Me("12qhfyh",l=>{De(()=>{qe.title="Fellowship Dashboard"})}),$e(h,{children:(l,u)=>{var s=Pe(),t=O(s);pe(t,()=>o.children),m(l,s)},$$slots:{default:!0}})}export{ft as component,pt as universal};
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Guard command submissions and surface failures.

Line 1 routes every Enter press to the async dispatcher, clears the active command only after the awaited API returns, and then swallows any exception with catch {}. A fast double-press can enqueue duplicate spawn/kill/restart commands, and backend failures disappear silently. Please add a synchronous in-flight guard plus a visible error state in the Svelte source, then regenerate this bundle.


⚠️ Potential issue | 🟠 Major

The command matcher is substring-only, not fuzzy.

Line 1 filters with label.toLowerCase().includes(query), so queries like sq for “Spawn Quest” or cfg for “Go to Config” will never match. That falls short of the PR’s advertised fuzzy-search behavior.


⚠️ Potential issue | 🟡 Minor

Avoid modulo-by-zero when there are no matches.

Line 1 computes (selected ± 1) % results.length on ArrowUp/ArrowDown without checking results.length. When the filtered list is empty, this writes NaN into the selection state and leaves keyboard navigation in a broken state until the query changes.

@@ -0,0 +1 @@
import"../chunks/DsnmJJEf.js";import"../chunks/69_IOA4Y.js";import{f as F,t as y,d as a,g as t,s as r,l as h,a0 as ne,p as K,b as N,$ as oe,a1 as ve,L as J}from"../chunks/CFIQ5hjj.js";import{s as V,a as q}from"../chunks/DLS4WeON.js";import{c as ie,a as p,f as m,s as k,d as le,b as E}from"../chunks/ByVGy8qs.js";import{i as A}from"../chunks/537vdszY.js";import{e as L,i as de}from"../chunks/zKsXIgWY.js";import{i as ce}from"../chunks/Nm4YapQn.js";import{f as ue,d as pe,s as me,a as _e,g as ge,p as fe,h as he,i as xe,e as be,b as qe,c as ke,q as we}from"../chunks/BjyTiIPo.js";import{H as ye,s as Se,t as He,a as Pe}from"../chunks/BTNO_X98.js";import{p as X}from"../chunks/BVGfD6Eb.js";import{s as T}from"../chunks/CQvy7xpd.js";import{g as Ce}from"../chunks/loYStGme.js";import{a as Ge}from"../chunks/DdAmTr_Q.js";import{G as Qe}from"../chunks/CMrUtYuQ.js";var $e=m('<div class="banner svelte-1etvnwu">Disconnected — reconnecting...</div>');function Ae(f){const e=()=>q(ue,"$connected",_),[_,s]=V();var l=ie(),v=F(l);{var c=n=>{var u=$e();p(n,u)};A(v,n=>{e()||n(c)})}p(f,l),s()}var Ie=m('<div class="stat svelte-1em4u7y"><div class="stat-value svelte-1em4u7y"> </div> <div class="stat-label svelte-1em4u7y"> </div></div>');function D(f,e){let _=X(e,"color",3,"var(--text-primary)");var s=Ie(),l=a(s);let v;var c=a(l,!0);t(l);var n=r(l,2),u=a(n,!0);t(n),t(s),y(()=>{v=pe(l,"",v,{color:_()}),k(c,e.value),k(u,e.label)}),p(f,s)}var Oe=m("<div></div>"),Re=m("<div></div>");function We(f,e){let _=X(e,"compact",3,!1);const s=["Onboard","Research","Plan","Implement","Review","Complete"];function l(n){const u=s.indexOf(e.phase),d=s.indexOf(n);return d<u?"done":d===u?"current":"pending"}var v=Re();let c;L(v,21,()=>s,de,(n,u)=>{const d=ne(()=>l(h(u)));var x=Oe();y(()=>{T(x,1,`phase-bar ${h(d)??""}`,"svelte-16oavfg"),Ge(x,"title",h(u))}),p(n,x)}),t(v),y(()=>c=T(v,1,"timeline svelte-16oavfg",null,c,{compact:_()})),p(f,v)}var De=m("<span> </span>"),Te=m('<span class="errand-count"> </span>'),Be=m('<div class="gate-row svelte-1cd3txo"><!></div>'),Ee=m('<div role="link" tabindex="0"><div class="card-header svelte-1cd3txo"><span class="quest-name svelte-1cd3txo"> </span> <!></div> <!> <div class="card-meta svelte-1cd3txo"><span class="phase-label svelte-1cd3txo"> </span> <!></div> <!></div>');function Fe(f,e){K(e,!0);function _(){Ce(`/quest/${encodeURIComponent(e.quest.name)}`)}var s=Ee();let l;var v=a(s),c=a(v),n=a(c,!0);t(c);var u=r(c,2);{var d=o=>{var i=De(),b=a(i,!0);t(i),y(()=>{T(i,1,`health-badge ${e.health.health??""}`,"svelte-1cd3txo"),k(b,e.health.health)}),p(o,i)};A(u,o=>{e.health&&o(d)})}t(v);var x=r(v,2);We(x,{get phase(){return e.quest.phase},compact:!0});var P=r(x,2),S=a(P),I=a(S,!0);t(S);var O=r(S,2);{var C=o=>{var i=Te(),b=a(i);t(i),y(()=>k(b,`${e.quest.errands_done??""}/${e.quest.errands_total??""} errands`)),p(o,i)};A(O,o=>{e.quest.errands_total>0&&o(C)})}t(P);var R=r(P,2);{var W=o=>{var i=Be(),b=a(i);Qe(b,{get worktree(){return e.quest.worktree}}),t(i),E("click",i,G=>G.stopPropagation()),p(o,i)};A(R,o=>{e.quest.gate_pending&&o(W)})}t(s),y(()=>{l=T(s,1,"quest-card svelte-1cd3txo",null,l,{"gate-pending":e.quest.gate_pending}),k(n,e.quest.name),k(I,e.quest.phase)}),E("click",s,_),E("keydown",s,o=>o.key==="Enter"&&_()),p(f,s),N()}le(["click","keydown"]);var Le=m('<div class="scout-card svelte-1x14rpa"><div class="scout-header svelte-1x14rpa"><span class="scout-icon">📡</span> <span class="scout-name svelte-1x14rpa"> </span></div> <p class="scout-question svelte-1x14rpa"> </p></div>'),Me=m("<!> <!>",1),Ue=m('<div class="empty-state svelte-1x14rpa"><p>Waiting for fellowship data...</p></div>'),je=m('<!> <div class="command-view svelte-1x14rpa"><div class="view-header svelte-1x14rpa"><h1 class="svelte-1x14rpa">Command</h1></div> <div class="stats-row svelte-1x14rpa"><!> <!> <!> <!></div> <div class="content-split svelte-1x14rpa"><div class="quest-grid svelte-1x14rpa"><!></div> <div class="herald-panel svelte-1x14rpa"><div class="panel-header svelte-1x14rpa">Herald</div> <!></div></div></div>',1);function vt(f,e){K(e,!1);const _=()=>q(we,"$questHealths",d),s=()=>q(ge,"$activeQuests",d),l=()=>q(fe,"$pendingGates",d),v=()=>q(he,"$activeScouts",d),c=()=>q(xe,"$problems",d),n=()=>q(be,"$dashboardStatus",d),u=()=>q(He,"$tidings",d),[d,x]=V();oe(()=>{me(),_e(),Se()}),ve(()=>{qe(),ke(),Pe()});function P(g){return _().find(H=>H.name===g)}ce();var S=je(),I=F(S);Ae(I);var O=r(I,2),C=r(a(O),2),R=a(C);D(R,{get value(){return s().length},label:"Active Quests",color:"var(--accent-green-text)"});var W=r(R,2);D(W,{get value(){return l().length},label:"Gates Pending",color:"var(--accent-gold)"});var o=r(W,2);D(o,{get value(){return v().length},label:"Scouts Active",color:"var(--accent-blue)"});var i=r(o,2);{let g=J(()=>c().length>0?"var(--accent-red)":"var(--accent-purple)");D(i,{get value(){return c().length},label:"Alerts",get color(){return h(g)}})}t(C);var b=r(C,2),G=a(b),Y=a(G);{var Z=g=>{var H=Me(),U=F(H);L(U,1,()=>n().quests,w=>w.name,(w,Q)=>{{let $=J(()=>P(h(Q).name));Fe(w,{get quest(){return h(Q)},get health(){return h($)}})}});var ae=r(U,2);L(ae,1,()=>n().scouts,w=>w.name,(w,Q)=>{var $=Le(),B=a($),j=r(a(B),2),se=a(j,!0);t(j),t(B);var z=r(B,2),re=a(z,!0);t(z),t($),y(()=>{k(se,h(Q).name),k(re,h(Q).question)}),p(w,$)}),p(g,H)},ee=g=>{var H=Ue();p(g,H)};A(Y,g=>{n()?g(Z):g(ee,-1)})}t(G);var M=r(G,2),te=r(a(M),2);ye(te,{get tidings(){return u()}}),t(M),t(b),t(O),p(f,S),N(),x()}export{vt as component};
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if build artifacts are gitignored or if there's a build script

echo "=== Checking .gitignore for static assets ==="
fd -t f '.gitignore' --exec cat {} \; 2>/dev/null | grep -E '(_app|static|dist|build)' || echo "No relevant gitignore patterns found"

echo ""
echo "=== Looking for build scripts or Makefile ==="
fd -t f -e 'Makefile' -e 'makefile' --exec cat {} \; 2>/dev/null | head -50
fd -t f 'build.sh' --exec cat {} \; 2>/dev/null | head -50

echo ""
echo "=== Checking for CI workflow files ==="
fd -t f -e 'yml' -e 'yaml' -p '.github/workflows' --exec cat {} \; 2>/dev/null | head -100

Repository: justinjdev/fellowship

Length of output: 506


Add .gitignore rule and enforce the build step in CI.

A build script already exists (npm run build + copy to cli/internal/dashboard/static/), which is good. However, the current setup relies on developers remembering to rebuild before committing:

  • Build artifacts are committed despite being auto-generated (no .gitignore rule prevents this)
  • No CI workflow enforces running the build step
  • This creates risk of source/artifact mismatch if the rebuild is skipped

Strengthen the setup by:

  1. Add cli/internal/dashboard/static/_app/ to .gitignore
  2. Call the build script in CI before the Go build step (ensures the binary is always in sync)

This keeps the source of truth clear while preventing merge conflicts and stale artifacts.

Comment thread cli/internal/dashboard/static/_app/immutable/nodes/5.VZsWOulv.js Outdated
@@ -0,0 +1 @@
import"../chunks/DsnmJJEf.js";import{d as a,l as s,g as t,s as r,t as g,p as Ts,i as Cs,F as w,b as Fs,a0 as M,_ as Q,f as us}from"../chunks/CFIQ5hjj.js";import{s as Gs,a as ls}from"../chunks/DLS4WeON.js";import{s as p,a as v,t as ms,f as c,d as Os,b as D}from"../chunks/ByVGy8qs.js";import{i as H}from"../chunks/537vdszY.js";import{e as U,i as rs}from"../chunks/zKsXIgWY.js";import{a as Is}from"../chunks/DdAmTr_Q.js";import{s as y}from"../chunks/CQvy7xpd.js";import{p as Ns}from"../chunks/OlN70tzR.js";import{e as Ps,q as As}from"../chunks/BjyTiIPo.js";import{c as Bs,d as Ds}from"../chunks/C67DyfnJ.js";import{G as Ms}from"../chunks/CMrUtYuQ.js";import{H as Qs,t as Us}from"../chunks/BTNO_X98.js";var Ws=c('<div class="errand-item svelte-6y5tqw"><div><!></div> <span> </span></div>'),js=c('<div class="errand-list svelte-6y5tqw"></div>');function zs(W,j){var N=js();U(N,21,()=>j.errands,P=>P.id,(P,S)=>{var E=Ws(),O=a(E),L=a(O);{var n=m=>{var i=ms("✓");v(m,i)},k=m=>{var i=ms("●");v(m,i)};H(L,m=>{s(S).status==="done"?m(n):s(S).status==="active"&&m(k,1)})}t(O);var A=r(O,2),q=a(A,!0);t(A),t(E),g(()=>{y(O,1,`errand-check ${s(S).status??""}`,"svelte-6y5tqw"),y(A,1,`errand-text ${s(S).status??""}`,"svelte-6y5tqw"),p(q,s(S).description)}),v(P,E)}),t(N),v(W,N)}var Js=c('<span></span> <span class="health-label svelte-1ysin3l"> </span>',1),Ks=c('<div><span class="phase-step-label"> </span></div>'),Vs=c('<div class="title-row svelte-1ysin3l"><h1 class="svelte-1ysin3l"> </h1> <span class="phase-badge svelte-1ysin3l"> </span> <!> <!></div> <div class="phase-steps svelte-1ysin3l"></div>',1),Xs=c('<span class="tab-badge svelte-1ysin3l"> </span>'),Ys=c('<div class="file-item svelte-1ysin3l"> </div>'),Zs=c('<div class="files-list svelte-1ysin3l"></div>'),se=c('<div class="tome-entry svelte-1ysin3l"><span> </span> <span class="tome-phase svelte-1ysin3l"> </span> <span class="tome-time svelte-1ysin3l"> </span></div>'),ee=c('<div class="tome-content svelte-1ysin3l"><h3 class="svelte-1ysin3l">Gate History</h3> <!></div>'),te=c('<div class="logs-placeholder"><p class="empty svelte-1ysin3l">Quest logs not yet available. Raw output logging is a future enhancement.</p></div>'),ae=c('<p class="empty svelte-1ysin3l">No data available</p>'),le=c('<div class="meta-row svelte-1ysin3l"><span>Eagles</span> <span> </span></div>'),re=c('<div class="detail-meta svelte-1ysin3l"><div class="meta-card svelte-1ysin3l"><div class="meta-title svelte-1ysin3l">Metadata</div> <div class="meta-row svelte-1ysin3l"><span>Branch</span> <span class="meta-value mono svelte-1ysin3l"> </span></div> <div class="meta-row svelte-1ysin3l"><span>Worktree</span> <span class="meta-value mono truncate svelte-1ysin3l"> </span></div> <div class="meta-row svelte-1ysin3l"><span>Status</span> <span class="meta-value svelte-1ysin3l"> </span></div> <!></div></div>'),ie=c('<div class="detail-view svelte-1ysin3l"><div class="detail-header svelte-1ysin3l"><div class="breadcrumb svelte-1ysin3l"><a href="/command" class="svelte-1ysin3l">Command</a> <span class="sep svelte-1ysin3l">/</span> <span> </span></div> <!></div> <div class="tabs svelte-1ysin3l"><button>Errands <!></button> <button>Files</button> <button>Tome</button> <button>Herald</button> <button>Logs</button></div> <div class="detail-body svelte-1ysin3l"><div class="detail-content svelte-1ysin3l"><!></div> <!></div></div>');function ge(W,j){Ts(j,!0);const N=()=>ls(Ps,"$dashboardStatus",E),P=()=>ls(As,"$questHealths",E),S=()=>ls(Us,"$tidings",E),[E,O]=Gs();let L=M(()=>decodeURIComponent(Ns.params.id)),n=M(()=>N()?.quests.find(e=>e.name===s(L))),k=M(()=>P().find(e=>e.name===s(L))),A=M(()=>S().filter(e=>e.quest===s(L))),q=Q(null),m=Q(null),i=Q("errands");const z=["Onboard","Research","Plan","Implement","Review","Complete"];let J=Q(!1);Cs(()=>{s(n)&&!s(J)&&(w(J,!0),Promise.all([Bs(s(n).worktree),Ds(s(L))]).then(([e,l])=>{w(q,e,!0),w(m,l,!0)}).catch(()=>{w(J,!1)}))});function fs(e){if(!s(n))return"pending";const l=z.indexOf(s(n).phase),o=z.indexOf(e);return o<l?"done":o===l?"current":"pending"}var K=ie(),V=a(K),X=a(V),is=r(a(X),4),hs=a(is,!0);t(is),t(X);var ys=r(X,2);{var bs=e=>{var l=Vs(),o=us(l),d=a(o),_=a(d,!0);t(d);var f=r(d,2),b=a(f,!0);t(f);var $=r(f,2);{var R=h=>{Ms(h,{get worktree(){return s(n).worktree}})};H($,h=>{s(n).gate_pending&&h(R)})}var T=r($,2);{var C=h=>{var F=Js(),u=us(F),x=r(u,2),G=a(x,!0);t(x),g(()=>{y(u,1,`health-dot ${s(k).health??""}`,"svelte-1ysin3l"),p(G,s(k).health)}),v(h,F)};H(T,h=>{s(k)&&h(C)})}t(o);var I=r(o,2);U(I,21,()=>z,rs,(h,F)=>{var u=Ks(),x=a(u),G=a(x,!0);t(x),t(u),g(as=>{y(u,1,`phase-step ${as??""}`,"svelte-1ysin3l"),p(G,s(F))},[()=>fs(s(F))]),v(h,u)}),t(I),g(()=>{p(_,s(n).name),p(b,s(n).phase)}),v(e,l)};H(ys,e=>{s(n)&&e(bs)})}t(V);var Y=r(V,2),B=a(Y);let vs;var gs=r(a(B));{var xs=e=>{var l=Xs(),o=a(l);t(l),g(d=>p(o,`${d??""}/${s(q).items.length??""}`),[()=>s(q).items.filter(d=>d.status==="done").length]),v(e,l)};H(gs,e=>{s(q)&&e(xs)})}t(B);var Z=r(B,2);let ns;var ss=r(Z,2);let os;var es=r(ss,2);let ds;var ps=r(es,2);let cs;t(Y);var _s=r(Y,2),ts=a(_s),ws=a(ts);{var ks=e=>{zs(e,{get errands(){return s(q).items}})},qs=e=>{var l=Zs();U(l,21,()=>s(m).files_touched,rs,(o,d)=>{var _=Ys(),f=a(_,!0);t(_),g(()=>p(f,s(d))),v(o,_)}),t(l),v(e,l)},$s=e=>{var l=ee(),o=r(a(l),2);U(o,17,()=>s(m).gate_history,rs,(d,_)=>{var f=se(),b=a(f),$=a(b,!0);t(b);var R=r(b,2),T=a(R,!0);t(R);var C=r(R,2),I=a(C,!0);t(C),t(f),g(()=>{y(b,1,`tome-action ${s(_).action??""}`,"svelte-1ysin3l"),p($,s(_).action),p(T,s(_).phase),p(I,s(_).timestamp)}),v(d,f)}),t(l),v(e,l)},Hs=e=>{Qs(e,{get tidings(){return s(A)},limit:50})},Ss=e=>{var l=te();v(e,l)},Es=e=>{var l=ae();v(e,l)};H(ws,e=>{s(i)==="errands"&&s(q)?e(ks):s(i)==="files"&&s(m)?e(qs,1):s(i)==="tome"&&s(m)?e($s,2):s(i)==="herald"?e(Hs,3):s(i)==="logs"?e(Ss,4):e(Es,-1)})}t(ts);var Ls=r(ts,2);{var Rs=e=>{var l=re(),o=a(l),d=r(a(o),2),_=r(a(d),2),f=a(_,!0);t(_),t(d);var b=r(d,2),$=r(a(b),2),R=a($,!0);t($),t(b);var T=r(b,2),C=r(a(T),2),I=a(C,!0);t(C),t(T);var h=r(T,2);{var F=u=>{var x=le(),G=r(a(x),2),as=a(G,!0);t(G),t(x),g(()=>{y(G,1,`meta-value ${s(k).health??""}`,"svelte-1ysin3l"),p(as,s(k).health)}),v(u,x)};H(h,u=>{s(k)&&u(F)})}t(o),t(l),g(u=>{p(f,u),Is($,"title",s(n).worktree),p(R,s(n).worktree),p(I,s(n).status)},[()=>s(n).worktree.split("/").pop()]),v(e,l)};H(Ls,e=>{s(n)&&e(Rs)})}t(_s),t(K),g(()=>{p(hs,s(L)),vs=y(B,1,"tab svelte-1ysin3l",null,vs,{active:s(i)==="errands"}),ns=y(Z,1,"tab svelte-1ysin3l",null,ns,{active:s(i)==="files"}),os=y(ss,1,"tab svelte-1ysin3l",null,os,{active:s(i)==="tome"}),ds=y(es,1,"tab svelte-1ysin3l",null,ds,{active:s(i)==="herald"}),cs=y(ps,1,"tab svelte-1ysin3l",null,cs,{active:s(i)==="logs"})}),D("click",B,()=>w(i,"errands")),D("click",Z,()=>w(i,"files")),D("click",ss,()=>w(i,"tome")),D("click",es,()=>w(i,"herald")),D("click",ps,()=>w(i,"logs")),v(W,K),Fs(),O()}Os(["click"]);export{ge as component};
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Don’t derive the Branch label with split('/') only.

Line 1 uses worktree.split("/").pop() for the metadata branch name. If the Go backend returns a Windows path, this renders the full C:\... string instead of the basename. Normalize both separators here, or better return the branch name explicitly from the API.


⚠️ Potential issue | 🔴 Critical

Replace the one-shot fetch flag with request-keyed state.

Line 1’s Cs(...) effect sets J before Promise.all, never re-keys/clears it after a successful load, and only flips it back on error. That makes the detail payload stale after a quest/worktree change and can hammer the backend with immediate retries when either request fails. Please track the active {id, worktree} and keep separate loading/error state in the Svelte source that generates this bundle.

🔧 Source-level fix sketch
-let loaded = false;
-$effect(() => {
-  if (quest && !loaded) {
-    loaded = true;
-    Promise.all([loadErrands(quest.worktree), loadDetails(id)])
-      .then(([nextErrands, nextDetails]) => {
-        errands = nextErrands;
-        details = nextDetails;
-      })
-      .catch(() => {
-        loaded = false;
-      });
-  }
-});
+let activeKey = '';
+let loading = false;
+let error = null;
+
+$effect(() => {
+  const quest = $dashboardStatus?.quests.find((q) => q.name === id);
+  const key = quest ? `${id}:${quest.worktree}` : '';
+  if (!key || key === activeKey || loading) return;
+
+  activeKey = key;
+  errands = null;
+  details = null;
+  error = null;
+  loading = true;
+
+  Promise.all([loadErrands(quest.worktree), loadDetails(id)])
+    .then(([nextErrands, nextDetails]) => {
+      if (activeKey !== key) return;
+      errands = nextErrands;
+      details = nextDetails;
+    })
+    .catch((e) => {
+      if (activeKey === key) error = e;
+    })
+    .finally(() => {
+      if (activeKey === key) loading = false;
+    });
+});

Comment thread cli/internal/dashboard/static/index.html Outdated
@justinjdev justinjdev force-pushed the feat/command-dashboard branch from 88abd8d to 4f8303b Compare March 12, 2026 02:07
justinjdev and others added 23 commits March 20, 2026 22:33
Add gorilla/websocket-based Hub that manages connections and broadcasts
events. Wire into existing gate approve/reject handlers so dashboard
clients receive live notifications on quest state changes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
File-backed command queue supporting spawn-quest, spawn-scout,
kill-quest, and restart-quest actions with pending/completed/failed
status tracking.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
New endpoints enqueue commands to the file-backed queue and broadcast
WebSocket events: POST /api/quest/{spawn,kill,restart},
POST /api/scout/spawn, GET /api/commands.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
New endpoints: GET /api/autopsies (list/detail), GET /api/tome/<quest>
for quest history, GET/POST /api/config for reading and writing
fellowship configuration at global or project scope.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace root handler with SPA-aware routing that serves static files
when they exist and falls back to index.html for unmatched paths,
enabling client-side routing in the dashboard frontend.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Tests cover Hub broadcast with no connections, command enqueue with
persistence verification, and empty queue loading.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…riven refresh

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…actions, herald, connection banner)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
… badge

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…avigation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
justinjdev and others added 7 commits March 20, 2026 22:34
Previously, WSEvent broadcasts from gate approve/reject handlers and
command enqueue handlers omitted the Timestamp field, resulting in
zero values. Clients relying on event timestamps for ordering would
see incorrect data.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Sanitize autopsy filename to prevent path traversal (reject non-base filenames)
- Handle json.MarshalIndent error instead of discarding
- Guard arrow-key navigation in CommandPalette when filtered list is empty
- Prevent infinite retry loop on quest detail page fetch failure
- Handle unknown phase in phaseStatus (return pending instead of marking all done)
- Normalize backslashes in branch label path splitting
- Self-host Google Fonts to eliminate CDN dependency (embedded binary model)
- Rebuild dashboard static assets

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Fix CommandPalette submitInput logic bug: success-path code was outside
  try block, running even after failures
- Fix $derived(() => ...) to $derived.by() in CommandPalette grouped
- Add Content-Type: application/json headers to all JSON responses
- Clean up orphaned tmp file on config rename failure
- Sanitize internal error details in HTTP responses
- Add double-call guards for startPolling/startHeraldPolling to prevent
  subscription leaks
- Add dataLoaded guard to timeline to prevent redundant tome fetching
- Guard against null/undefined tags in autopsies search filter
- Fix quest detail page race condition: set dataLoaded after fetch
  completes, add loading guard to prevent concurrent fetches
- Rebuild dashboard static assets

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Define zombie classification in rekindle recovery flow
- Fix <main_repo> → <repo_root> placeholder in SKILL.md
- Handle UserHomeDir failure gracefully in config read
- Add all 8 herald tiding types to filter UI
- Guard NaN dates in GanttChart timeline
- Guard arrow-key navigation when command palette is empty
- Fallback for branch label when path split yields empty

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add gitRoot field back to Server for file-based operations
  (command queue, config read/write) not yet migrated to SQLite
- Port autopsy handler to use autopsy.Scan instead of removed
  file-based LoadByID/List functions
- Port tome handler to use tome.Load with SQLite connection
- Add Timestamp field to WSEvent broadcasts (from branch)
- Update NewServer callers with gitRoot parameter

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@justinjdev justinjdev force-pushed the feat/command-dashboard branch from 5c92fcd to 62d5cc1 Compare March 21, 2026 03:37
@justinjdev
Copy link
Copy Markdown
Owner Author

@coderabbitai resume

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Mar 21, 2026

✅ Actions performed

Reviews resumed.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (3)
cli/internal/dashboard/data.go (1)

126-128: ⚠️ Potential issue | 🟡 Minor

Handle UserHomeDir error for global scope writes.

The error from os.UserHomeDir() is still ignored on line 127. If $HOME is unset (e.g., containerized environments), home is empty and configPath becomes "/.claude/fellowship.json", attempting a write to filesystem root. The read path (line 89) correctly handles this, but the write path does not.

🛡️ Proposed fix
 	case "global":
-		home, _ := os.UserHomeDir()
+		home, err := os.UserHomeDir()
+		if err != nil {
+			http.Error(w, "cannot determine home directory for global config", http.StatusInternalServerError)
+			return
+		}
 		configPath = filepath.Join(home, ".claude", "fellowship.json")
dashboard/src/lib/components/CommandPalette.svelte (1)

100-104: ⚠️ Potential issue | 🟠 Major

Use a real fuzzy matcher here.

This is still contiguous substring matching, so queries like sq / rq will never find Spawn Quest / Restart Quest. That misses the fuzzy-search behavior this PR claims to add.

Suggested direction
+	function fuzzyMatch(query: string, label: string): boolean {
+		const needle = query.toLowerCase().replace(/\s+/g, '');
+		const haystack = label.toLowerCase();
+		let i = 0;
+		for (const ch of haystack) {
+			if (ch === needle[i]) i++;
+			if (i === needle.length) return true;
+		}
+		return needle.length === 0;
+	}
+
 	let filtered = $derived(
-		query
-			? actions.filter((a) => a.label.toLowerCase().includes(query.toLowerCase()))
-			: actions
+		query ? actions.filter((a) => fuzzyMatch(query, a.label)) : actions
 	);
cli/internal/dashboard/queue.go (1)

50-54: ⚠️ Potential issue | 🟡 Minor

Handle error from rand.Read.

The code ignores the error return value from rand.Read(). While rare, this call can fail due to entropy depletion or system errors, potentially resulting in predictable or incomplete command IDs.

Proposed fix
 func generateID() string {
 	b := make([]byte, 8)
-	rand.Read(b)
+	if _, err := rand.Read(b); err != nil {
+		panic(fmt.Sprintf("crypto/rand.Read failed: %v", err))
+	}
 	return hex.EncodeToString(b)
 }
🧹 Nitpick comments (9)
cli/internal/dashboard/static/_app/immutable/chunks/C67DyfnJ.js (1)

1-1: Consider adding fetch timeouts for resilience.

The API client functions use fetch without timeouts. If the backend becomes unresponsive, these calls will hang indefinitely, potentially degrading user experience.

For critical paths, consider using AbortController with a timeout:

Example pattern
async function fetchWithTimeout(url, options, timeout = 10000) {
  const controller = new AbortController();
  const id = setTimeout(() => controller.abort(), timeout);
  try {
    return await fetch(url, { ...options, signal: controller.signal });
  } finally {
    clearTimeout(id);
  }
}
dashboard/src/routes/timeline/+page.svelte (2)

13-25: dataLoaded flag set before fetch completes could mask status updates.

Setting dataLoaded = true on Line 16 before the async Promise.allSettled completes means if dashboardStatus emits again during the fetch, the new status is ignored. If the intent is one-time load, this is fine. If quests can be added dynamically and should trigger re-fetch, consider setting dataLoaded = true after the fetch or using a different approach.

Also, the as QuestTome[] cast on Line 23 is redundant since the preceding type guard already narrows the type.

Suggested simplification
 		tomes = results
 			.filter((r): r is PromiseFulfilledResult<unknown> => r.status === 'fulfilled')
 			.map((r) => r.value)
-			.filter((t): t is QuestTome => t != null) as QuestTome[];
+			.filter((t): t is QuestTome => t != null);
 		loading = false;
 	}

38-42: Consider stopping WebSocket/polling only when navigating away from real-time views.

The onDestroy stops WebSocket and polling when leaving the timeline page. If other pages (like /command or /herald) also need these connections, each page redundantly starts/stops them. This works but causes unnecessary reconnection overhead when navigating between real-time views.

dashboard/src/routes/herald/+page.svelte (1)

40-52: Filter logic: unchecking all type checkboxes shows all tidings.

The anyTypeEnabled check on Line 43-44 means when no checkboxes are checked, anyTypeEnabled is false, so the type filter is bypassed and all types are shown. This may be intentional (showing everything when nothing selected) but could surprise users expecting an empty result. Consider whether this UX matches user expectations.

dashboard/src/lib/stores/herald.ts (1)

7-12: Silent error handling hides potential issues.

The empty catch block on Line 11 swallows all errors, including JSON parse failures or unexpected response formats. While this is acceptable for network failures when offline, it could mask server-side bugs or API contract violations during development.

Suggested improvement
 export async function fetchTidings() {
 	try {
 		const res = await fetch('/api/herald');
-		if (res.ok) tidings.set(await res.json());
-	} catch { /* offline */ }
+		if (res.ok) {
+			tidings.set(await res.json());
+		}
+	} catch (err) {
+		// Silently ignore fetch failures (e.g., offline)
+		if (import.meta.env.DEV) {
+			console.warn('[herald] fetch failed:', err);
+		}
+	}
 }
dashboard/src/lib/stores/websocket.ts (1)

36-46: Guard against duplicate connection attempts during CONNECTING state.

The early return only checks for WebSocket.OPEN, but if connect() is called while a socket is still in CONNECTING state, a new socket will be created, potentially causing duplicate connections.

🛠️ Proposed fix
 function connect() {
-	if (ws?.readyState === WebSocket.OPEN) return;
+	if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return;

 	let socket: WebSocket;
cli/internal/dashboard/ws.go (1)

81-89: Consider adding a read deadline to detect half-open connections.

The read pump runs indefinitely without a read deadline. If a client becomes unresponsive without properly closing the TCP connection (half-open state), this goroutine may hang indefinitely. Setting a read deadline with periodic pings would allow detection of such connections.

🛠️ Proposed enhancement
 	// Read pump — just drain pings/pongs, we don't expect client messages
 	go func() {
 		defer h.Remove(conn)
+		conn.SetReadDeadline(time.Now().Add(60 * time.Second))
+		conn.SetPongHandler(func(string) error {
+			conn.SetReadDeadline(time.Now().Add(60 * time.Second))
+			return nil
+		})
 		for {
 			if _, _, err := conn.ReadMessage(); err != nil {
 				break
 			}
 		}
 	}()

Note: This would require a corresponding ping sender, which may be overkill for a localhost dashboard.

dashboard/src/lib/stores/quests.ts (1)

48-67: Consider debouncing rapid WebSocket events to avoid redundant API calls.

When multiple events arrive in quick succession (e.g., quest-changed followed immediately by gate-resolved), each triggers a separate refreshAll() call. This could result in redundant concurrent API requests.

🛠️ Proposed debounce approach
+let refreshTimeout: ReturnType<typeof setTimeout> | null = null;
+
+function debouncedRefreshAll() {
+	if (refreshTimeout) clearTimeout(refreshTimeout);
+	refreshTimeout = setTimeout(() => {
+		refreshAll();
+		refreshTimeout = null;
+	}, 100);
+}
+
 export function startPolling() {
 	if (unsubscribe) return;
 	refreshAll();
 	unsubscribe = lastEvent.subscribe((event) => {
 		if (!event) return;
 		switch (event.type) {
 			case 'quest-changed':
 			case 'gate-submitted':
 			case 'gate-resolved':
 			case 'command-completed':
-				refreshAll();
+				debouncedRefreshAll();
 				break;
cli/internal/dashboard/queue.go (1)

88-108: LGTM on concurrency, but consider queue size management.

The mutex-protected enqueue logic is correct. However, the queue will grow indefinitely as commands are only appended. For long-running deployments, consider periodically pruning completed/failed commands older than a threshold.


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 21e048dd-c1db-4616-87cd-e8257eef0f18

📥 Commits

Reviewing files that changed from the base of the PR and between 18028c2 and 62d5cc1.

⛔ Files ignored due to path filters (32)
  • cli/go.sum is excluded by !**/*.sum
  • cli/internal/dashboard/static/fonts/cinzel-400.ttf is excluded by !**/*.ttf
  • cli/internal/dashboard/static/fonts/cinzel-600.ttf is excluded by !**/*.ttf
  • cli/internal/dashboard/static/fonts/cinzel-700.ttf is excluded by !**/*.ttf
  • cli/internal/dashboard/static/fonts/dm-sans-300.ttf is excluded by !**/*.ttf
  • cli/internal/dashboard/static/fonts/dm-sans-400.ttf is excluded by !**/*.ttf
  • cli/internal/dashboard/static/fonts/dm-sans-500.ttf is excluded by !**/*.ttf
  • cli/internal/dashboard/static/fonts/dm-sans-600.ttf is excluded by !**/*.ttf
  • cli/internal/dashboard/static/fonts/jetbrains-mono-300.ttf is excluded by !**/*.ttf
  • cli/internal/dashboard/static/fonts/jetbrains-mono-400.ttf is excluded by !**/*.ttf
  • cli/internal/dashboard/static/fonts/jetbrains-mono-500.ttf is excluded by !**/*.ttf
  • cli/internal/dashboard/static/fonts/outfit-300.ttf is excluded by !**/*.ttf
  • cli/internal/dashboard/static/fonts/outfit-400.ttf is excluded by !**/*.ttf
  • cli/internal/dashboard/static/fonts/outfit-500.ttf is excluded by !**/*.ttf
  • cli/internal/dashboard/static/fonts/outfit-600.ttf is excluded by !**/*.ttf
  • cli/internal/dashboard/static/fonts/outfit-700.ttf is excluded by !**/*.ttf
  • dashboard/package-lock.json is excluded by !**/package-lock.json
  • dashboard/static/fonts/cinzel-400.ttf is excluded by !**/*.ttf
  • dashboard/static/fonts/cinzel-600.ttf is excluded by !**/*.ttf
  • dashboard/static/fonts/cinzel-700.ttf is excluded by !**/*.ttf
  • dashboard/static/fonts/dm-sans-300.ttf is excluded by !**/*.ttf
  • dashboard/static/fonts/dm-sans-400.ttf is excluded by !**/*.ttf
  • dashboard/static/fonts/dm-sans-500.ttf is excluded by !**/*.ttf
  • dashboard/static/fonts/dm-sans-600.ttf is excluded by !**/*.ttf
  • dashboard/static/fonts/jetbrains-mono-300.ttf is excluded by !**/*.ttf
  • dashboard/static/fonts/jetbrains-mono-400.ttf is excluded by !**/*.ttf
  • dashboard/static/fonts/jetbrains-mono-500.ttf is excluded by !**/*.ttf
  • dashboard/static/fonts/outfit-300.ttf is excluded by !**/*.ttf
  • dashboard/static/fonts/outfit-400.ttf is excluded by !**/*.ttf
  • dashboard/static/fonts/outfit-500.ttf is excluded by !**/*.ttf
  • dashboard/static/fonts/outfit-600.ttf is excluded by !**/*.ttf
  • dashboard/static/fonts/outfit-700.ttf is excluded by !**/*.ttf
📒 Files selected for processing (99)
  • .gitignore
  • cli/cmd/fellowship/main.go
  • cli/go.mod
  • cli/internal/dashboard/commands.go
  • cli/internal/dashboard/data.go
  • cli/internal/dashboard/embed.go
  • cli/internal/dashboard/queue.go
  • cli/internal/dashboard/queue_test.go
  • cli/internal/dashboard/server.go
  • cli/internal/dashboard/server_test.go
  • cli/internal/dashboard/static/_app/env.js
  • cli/internal/dashboard/static/_app/immutable/assets/0.B4Tu4Ui9.css
  • cli/internal/dashboard/static/_app/immutable/assets/3.CVgNbBqn.css
  • cli/internal/dashboard/static/_app/immutable/assets/4.DUdybRxg.css
  • cli/internal/dashboard/static/_app/immutable/assets/5.BgjiOm-S.css
  • cli/internal/dashboard/static/_app/immutable/assets/6.DrszQgHF.css
  • cli/internal/dashboard/static/_app/immutable/assets/7.Ctb9NTgd.css
  • cli/internal/dashboard/static/_app/immutable/assets/8.CA8t3wJx.css
  • cli/internal/dashboard/static/_app/immutable/assets/GateActions.DvbP5nIs.css
  • cli/internal/dashboard/static/_app/immutable/assets/HeraldFeed.D75iUDfw.css
  • cli/internal/dashboard/static/_app/immutable/chunks/537vdszY.js
  • cli/internal/dashboard/static/_app/immutable/chunks/69_IOA4Y.js
  • cli/internal/dashboard/static/_app/immutable/chunks/BUApaBEI.js
  • cli/internal/dashboard/static/_app/immutable/chunks/BVGfD6Eb.js
  • cli/internal/dashboard/static/_app/immutable/chunks/ByVGy8qs.js
  • cli/internal/dashboard/static/_app/immutable/chunks/C67DyfnJ.js
  • cli/internal/dashboard/static/_app/immutable/chunks/CFIQ5hjj.js
  • cli/internal/dashboard/static/_app/immutable/chunks/CJ8mMi92.js
  • cli/internal/dashboard/static/_app/immutable/chunks/CMrUtYuQ.js
  • cli/internal/dashboard/static/_app/immutable/chunks/CQvy7xpd.js
  • cli/internal/dashboard/static/_app/immutable/chunks/CSXl7R3t.js
  • cli/internal/dashboard/static/_app/immutable/chunks/CaohEQ9u.js
  • cli/internal/dashboard/static/_app/immutable/chunks/DIeogL5L.js
  • cli/internal/dashboard/static/_app/immutable/chunks/DLS4WeON.js
  • cli/internal/dashboard/static/_app/immutable/chunks/DdAmTr_Q.js
  • cli/internal/dashboard/static/_app/immutable/chunks/DeY8KYDG.js
  • cli/internal/dashboard/static/_app/immutable/chunks/DgDK1e17.js
  • cli/internal/dashboard/static/_app/immutable/chunks/DmL0_esv.js
  • cli/internal/dashboard/static/_app/immutable/chunks/DsnmJJEf.js
  • cli/internal/dashboard/static/_app/immutable/chunks/Dv0Hwark.js
  • cli/internal/dashboard/static/_app/immutable/chunks/Nm4YapQn.js
  • cli/internal/dashboard/static/_app/immutable/chunks/jKnW4BRh.js
  • cli/internal/dashboard/static/_app/immutable/chunks/zKsXIgWY.js
  • cli/internal/dashboard/static/_app/immutable/entry/app.BdcOekXH.js
  • cli/internal/dashboard/static/_app/immutable/entry/start.B5-0ZWIv.js
  • cli/internal/dashboard/static/_app/immutable/nodes/0.DBOxo5Wb.js
  • cli/internal/dashboard/static/_app/immutable/nodes/1.ByZ8u1CK.js
  • cli/internal/dashboard/static/_app/immutable/nodes/2.EjT9vOq6.js
  • cli/internal/dashboard/static/_app/immutable/nodes/3.Dot0CBpK.js
  • cli/internal/dashboard/static/_app/immutable/nodes/4.4T6CzQcj.js
  • cli/internal/dashboard/static/_app/immutable/nodes/5.VZsWOulv.js
  • cli/internal/dashboard/static/_app/immutable/nodes/6.BzB38D4G.js
  • cli/internal/dashboard/static/_app/immutable/nodes/7.C5EwJyeT.js
  • cli/internal/dashboard/static/_app/immutable/nodes/8.BFyONnpl.js
  • cli/internal/dashboard/static/_app/version.json
  • cli/internal/dashboard/static/app.js
  • cli/internal/dashboard/static/fonts/fonts.css
  • cli/internal/dashboard/static/index.html
  • cli/internal/dashboard/static/style.css
  • cli/internal/dashboard/ws.go
  • cli/internal/dashboard/ws_test.go
  • dashboard/.gitignore
  • dashboard/build.sh
  • dashboard/package.json
  • dashboard/src/app.html
  • dashboard/src/lib/api.ts
  • dashboard/src/lib/components/CommandPalette.svelte
  • dashboard/src/lib/components/ConnectionBanner.svelte
  • dashboard/src/lib/components/ErrandList.svelte
  • dashboard/src/lib/components/GanttChart.svelte
  • dashboard/src/lib/components/GateActions.svelte
  • dashboard/src/lib/components/HeraldFeed.svelte
  • dashboard/src/lib/components/PhaseTimeline.svelte
  • dashboard/src/lib/components/QuestCard.svelte
  • dashboard/src/lib/components/Shell.svelte
  • dashboard/src/lib/components/Sidebar.svelte
  • dashboard/src/lib/components/StatCounter.svelte
  • dashboard/src/lib/stores/herald.ts
  • dashboard/src/lib/stores/quests.ts
  • dashboard/src/lib/stores/websocket.ts
  • dashboard/src/lib/styles/global.css
  • dashboard/src/lib/styles/tokens.css
  • dashboard/src/lib/types.ts
  • dashboard/src/routes/+layout.js
  • dashboard/src/routes/+layout.svelte
  • dashboard/src/routes/+page.js
  • dashboard/src/routes/+page.svelte
  • dashboard/src/routes/autopsies/+page.svelte
  • dashboard/src/routes/command/+page.svelte
  • dashboard/src/routes/config/+page.svelte
  • dashboard/src/routes/herald/+page.svelte
  • dashboard/src/routes/quest/[id]/+page.svelte
  • dashboard/src/routes/timeline/+page.svelte
  • dashboard/static/fonts/fonts.css
  • dashboard/svelte.config.js
  • dashboard/tsconfig.json
  • dashboard/vite.config.ts
  • plugin/commands/rekindle.md
  • plugin/skills/fellowship/SKILL.md
💤 Files with no reviewable changes (2)
  • cli/internal/dashboard/static/style.css
  • cli/internal/dashboard/static/app.js
✅ Files skipped from review due to trivial changes (42)
  • cli/internal/dashboard/static/_app/immutable/chunks/DsnmJJEf.js
  • .gitignore
  • cli/internal/dashboard/static/_app/env.js
  • dashboard/build.sh
  • cli/internal/dashboard/static/_app/immutable/assets/4.DUdybRxg.css
  • dashboard/.gitignore
  • cli/internal/dashboard/static/_app/immutable/chunks/69_IOA4Y.js
  • dashboard/src/routes/+page.js
  • cli/internal/dashboard/static/_app/immutable/assets/3.CVgNbBqn.css
  • cli/internal/dashboard/static/_app/immutable/entry/start.B5-0ZWIv.js
  • dashboard/package.json
  • cli/internal/dashboard/static/_app/immutable/chunks/DIeogL5L.js
  • dashboard/tsconfig.json
  • cli/internal/dashboard/static/_app/immutable/assets/GateActions.DvbP5nIs.css
  • cli/internal/dashboard/static/_app/immutable/assets/HeraldFeed.D75iUDfw.css
  • dashboard/src/routes/+layout.js
  • dashboard/vite.config.ts
  • cli/internal/dashboard/static/_app/immutable/chunks/DgDK1e17.js
  • cli/internal/dashboard/queue_test.go
  • dashboard/src/lib/styles/tokens.css
  • dashboard/static/fonts/fonts.css
  • cli/go.mod
  • dashboard/src/app.html
  • cli/internal/dashboard/static/_app/immutable/assets/6.DrszQgHF.css
  • dashboard/src/routes/+layout.svelte
  • dashboard/src/lib/styles/global.css
  • cli/internal/dashboard/static/_app/immutable/assets/0.B4Tu4Ui9.css
  • dashboard/src/lib/components/GateActions.svelte
  • cli/internal/dashboard/static/_app/immutable/assets/5.BgjiOm-S.css
  • cli/internal/dashboard/static/fonts/fonts.css
  • cli/internal/dashboard/static/_app/immutable/assets/8.CA8t3wJx.css
  • cli/internal/dashboard/static/_app/immutable/nodes/2.EjT9vOq6.js
  • dashboard/svelte.config.js
  • dashboard/src/routes/config/+page.svelte
  • cli/internal/dashboard/static/_app/immutable/chunks/BUApaBEI.js
  • cli/internal/dashboard/static/_app/immutable/entry/app.BdcOekXH.js
  • cli/internal/dashboard/static/_app/immutable/assets/7.Ctb9NTgd.css
  • dashboard/src/routes/autopsies/+page.svelte
  • dashboard/src/routes/quest/[id]/+page.svelte
  • cli/internal/dashboard/static/_app/immutable/chunks/Nm4YapQn.js
  • cli/internal/dashboard/commands.go
  • dashboard/src/lib/types.ts
🚧 Files skipped from review as they are similar to previous changes (21)
  • cli/internal/dashboard/embed.go
  • cli/internal/dashboard/static/_app/version.json
  • cli/internal/dashboard/ws_test.go
  • plugin/skills/fellowship/SKILL.md
  • cli/internal/dashboard/static/index.html
  • plugin/commands/rekindle.md
  • dashboard/src/routes/command/+page.svelte
  • cli/internal/dashboard/static/_app/immutable/chunks/CQvy7xpd.js
  • dashboard/src/lib/components/PhaseTimeline.svelte
  • dashboard/src/lib/components/ConnectionBanner.svelte
  • dashboard/src/lib/components/GanttChart.svelte
  • dashboard/src/lib/components/QuestCard.svelte
  • cli/internal/dashboard/static/_app/immutable/chunks/CSXl7R3t.js
  • cli/internal/dashboard/static/_app/immutable/chunks/jKnW4BRh.js
  • cli/internal/dashboard/static/_app/immutable/chunks/CMrUtYuQ.js
  • cli/internal/dashboard/static/_app/immutable/chunks/DdAmTr_Q.js
  • dashboard/src/lib/components/ErrandList.svelte
  • cli/internal/dashboard/static/_app/immutable/chunks/Dv0Hwark.js
  • cli/internal/dashboard/static/_app/immutable/chunks/zKsXIgWY.js
  • cli/internal/dashboard/server.go
  • cli/internal/dashboard/static/_app/immutable/chunks/DLS4WeON.js

Comment on lines +215 to +219
placeholder={actions.find(
(a) =>
a.label.toLowerCase().replace(/\s+/g, '-') ===
inputMode?.replace('spawn-', 'spawn-').replace('kill-', 'kill-').replace('restart-', 'restart-')
)?.inputPlaceholder ?? 'Enter value...'}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

No-op replace calls in placeholder lookup logic.

The .replace() calls here replace strings with themselves and have no effect:

inputMode?.replace('spawn-', 'spawn-').replace('kill-', 'kill-').replace('restart-', 'restart-')

This appears to be dead code or a bug. The entire lookup is also unnecessarily complex when the action objects already have inputPlaceholder defined.

Simplified approach
 <input
   bind:this={taskInputEl}
   bind:value={taskInput}
   class="search-input"
-  placeholder={actions.find(
-    (a) =>
-      a.label.toLowerCase().replace(/\s+/g, '-') ===
-      inputMode?.replace('spawn-', 'spawn-').replace('kill-', 'kill-').replace('restart-', 'restart-')
-  )?.inputPlaceholder ?? 'Enter value...'}
+  placeholder={inputMode === 'spawn-quest'
+    ? 'Enter task description...'
+    : inputMode === 'spawn-scout'
+      ? 'Enter question...'
+      : inputMode === 'kill-quest'
+        ? 'Enter quest ID...'
+        : inputMode === 'restart-quest'
+          ? 'Enter quest ID...'
+          : 'Enter value...'}
   type="text"
 />

Or store placeholders in a map keyed by inputMode values.

Comment on lines +45 to +49
if (searchText) {
const q = searchText.toLowerCase();
const haystack = `${t.quest} ${t.type} ${t.phase} ${t.detail}`.toLowerCase();
if (!haystack.includes(q)) return false;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Search haystack may include literal "undefined" string.

If t.phase or t.detail is undefined, the template literal on Line 47 will include the string "undefined", causing false positive matches when users search for that term.

Proposed fix
 			if (searchText) {
 				const q = searchText.toLowerCase();
-				const haystack = `${t.quest} ${t.type} ${t.phase} ${t.detail}`.toLowerCase();
+				const haystack = `${t.quest ?? ''} ${t.type ?? ''} ${t.phase ?? ''} ${t.detail ?? ''}`.toLowerCase();
 				if (!haystack.includes(q)) return false;
 			}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (searchText) {
const q = searchText.toLowerCase();
const haystack = `${t.quest} ${t.type} ${t.phase} ${t.detail}`.toLowerCase();
if (!haystack.includes(q)) return false;
}
if (searchText) {
const q = searchText.toLowerCase();
const haystack = `${t.quest ?? ''} ${t.type ?? ''} ${t.phase ?? ''} ${t.detail ?? ''}`.toLowerCase();
if (!haystack.includes(q)) return false;
}

justinjdev added a commit that referenced this pull request Mar 21, 2026
Replace bare `fellowship` CLI references with the pinned path
`~/.claude/fellowship/bin/fellowship` per the documented convention.
Addresses CodeRabbit review comments from PR #76.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
justinjdev added a commit that referenced this pull request Mar 21, 2026
…ard shortcut

- Add `ws !== socket` guard to onmessage and onerror handlers in
  websocket.ts to prevent stale socket instances from mutating shared
  state during reconnection races
- Add CONNECTING state check to prevent duplicate connection attempts
- Normalize keyboard shortcut key check with toLowerCase() for
  cross-platform robustness (Caps Lock edge case)

Addresses CodeRabbit review comments from PR #76.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
justinjdev and others added 2 commits March 21, 2026 17:47
…#82)

* fix: improve light theme color contrast for WCAG AA compliance

- Darken accent from #8b6914 to #7a5a0e (4.1:1 → 5.1:1)
- Darken scout from #4a8a9e to #377080 (3.3:1 → 4.7:1)
- Darken monitor from #7b6ea4 to #635599 (3.4:1 → 4.8:1)
- Update hover/focus colors to match new accent

All light theme text/background pairs now pass WCAG AA (4.5:1+).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add WebSocket infrastructure for real-time dashboard updates

Add gorilla/websocket-based Hub that manages connections and broadcasts
events. Wire into existing gate approve/reject handlers so dashboard
clients receive live notifications on quest state changes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add command queue for dashboard-initiated actions

File-backed command queue supporting spawn-quest, spawn-scout,
kill-quest, and restart-quest actions with pending/completed/failed
status tracking.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add REST endpoints for quest/scout spawn, kill, restart commands

New endpoints enqueue commands to the file-backed queue and broadcast
WebSocket events: POST /api/quest/{spawn,kill,restart},
POST /api/scout/spawn, GET /api/commands.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(dashboard): scaffold SvelteKit project with adapter-static

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add REST endpoints for autopsies, tome, and config

New endpoints: GET /api/autopsies (list/detail), GET /api/tome/<quest>
for quest history, GET/POST /api/config for reading and writing
fellowship configuration at global or project scope.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(dashboard): add design system tokens and global styles

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add SPA fallback routing for client-side navigation

Replace root handler with SPA-aware routing that serves static files
when they exist and falls back to index.html for unmatched paths,
enabling client-side routing in the dashboard frontend.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* test: add unit tests for WebSocket Hub and command queue

Tests cover Hub broadcast with no connections, command enqueue with
persistence verification, and empty queue loading.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(dashboard): add shell layout with collapsible sidebar and routing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(dashboard): add WebSocket store with exponential backoff reconnect

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(dashboard): add type definitions and quest state store with WS-driven refresh

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(dashboard): add herald event store

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(dashboard): add API helper with all REST endpoints

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(dashboard): add core UI components (stats, phase timeline, gate actions, herald, connection banner)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(dashboard): add QuestCard component with gate actions and health badge

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(dashboard): implement Command view with stats, quest grid, and herald feed

* feat(dashboard): implement Quest Detail view with errands, files, tome, and herald tabs

* feat(dashboard): add command palette with fuzzy search and keyboard navigation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(dashboard): implement Herald view with filtering

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(dashboard): implement Autopsies view with search and drill-in

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(dashboard): implement Timeline view with Gantt chart

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(dashboard): implement Config view with editable settings

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(dashboard): add build script and embed Svelte output in Go binary

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(dashboard): embed _app directory and fix SPA static file serving

- Use `all:static` in embed directive to include `_app/` directory
  (Go embed skips underscore-prefixed paths by default)
- Fix SPA fallback to properly serve static files before falling back
  to index.html, reusing a single http.FileServer instance

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(dashboard): address PR review feedback — error handling, concurrency, accessibility

- Add mutex locking to EnqueueCommand for concurrent safety
- Return error on corrupt command queue instead of silent empty fallback
- Add collision-resistant random bytes to autopsy filenames
- Use datadir.Name() instead of hardcoded .fellowship for autopsy paths
- Reject malformed config JSON instead of silently overwriting
- Add WebSocket write deadline to prevent blocking on unresponsive clients
- Guard against stale WebSocket lifecycle events
- Handle iofs.Sub error in static file serving
- Fix gorilla/websocket marked as indirect in go.mod
- Add res.ok checks to all API mutation functions
- Fix btoa() Unicode safety for non-ASCII worktree paths
- Wrap read API helpers in try/catch for network failures
- Keep command palette open on failed commands
- Reset GateActions loading state on API failure (try/finally)
- Support Ctrl+K alongside Cmd+K for command palette
- Convert QuestCard from <a> to <div> to fix nested button a11y violation
- Use $effect instead of onMount for reactive quest data loading
- Add NaN guard for invalid timestamps in HeraldFeed
- Use Promise.allSettled for timeline tome fetching
- Add error handling for autopsies fetch
- Replace onMount redirect with SvelteKit redirect() in +page.js
- Add :focus-visible outline for quest filter select
- Use pinned CLI path in SKILL.md and rekindle.md
- Replace undefined zombie classification with stale in rekindle.md
- Rebuild dashboard static assets

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(dashboard): address PR review feedback round 2

- Add synchronous double-submit guard in GateActions approve/reject
- Show error state instead of empty result on autopsies page load failure

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(dashboard): populate Timestamp on all WSEvent broadcasts

Previously, WSEvent broadcasts from gate approve/reject handlers and
command enqueue handlers omitted the Timestamp field, resulting in
zero values. Clients relying on event timestamps for ordering would
see incorrect data.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(dashboard): address PR review feedback round 3

- Sanitize autopsy filename to prevent path traversal (reject non-base filenames)
- Handle json.MarshalIndent error instead of discarding
- Guard arrow-key navigation in CommandPalette when filtered list is empty
- Prevent infinite retry loop on quest detail page fetch failure
- Handle unknown phase in phaseStatus (return pending instead of marking all done)
- Normalize backslashes in branch label path splitting
- Self-host Google Fonts to eliminate CDN dependency (embedded binary model)
- Rebuild dashboard static assets

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(dashboard): address local CodeRabbit review findings

- Fix CommandPalette submitInput logic bug: success-path code was outside
  try block, running even after failures
- Fix $derived(() => ...) to $derived.by() in CommandPalette grouped
- Add Content-Type: application/json headers to all JSON responses
- Clean up orphaned tmp file on config rename failure
- Sanitize internal error details in HTTP responses
- Add double-call guards for startPolling/startHeraldPolling to prevent
  subscription leaks
- Add dataLoaded guard to timeline to prevent redundant tome fetching
- Guard against null/undefined tags in autopsies search filter
- Fix quest detail page race condition: set dataLoaded after fetch
  completes, add loading guard to prevent concurrent fetches
- Rebuild dashboard static assets

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore: add local artifacts to .gitignore

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(dashboard): address PR review feedback round 6

- Define zombie classification in rekindle recovery flow
- Fix <main_repo> → <repo_root> placeholder in SKILL.md
- Handle UserHomeDir failure gracefully in config read
- Add all 8 herald tiding types to filter UI
- Guard NaN dates in GanttChart timeline
- Guard arrow-key navigation when command palette is empty
- Fallback for branch label when path split yields empty

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: port dashboard data/commands to SQLite after rebase

- Add gitRoot field back to Server for file-based operations
  (command queue, config read/write) not yet migrated to SQLite
- Port autopsy handler to use autopsy.Scan instead of removed
  file-based LoadByID/List functions
- Port tome handler to use tome.Load with SQLite connection
- Add Timestamp field to WSEvent broadcasts (from branch)
- Update NewServer callers with gitRoot parameter

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: remove accidentally committed local files

* fix(docs): use pinned CLI path in rekindle.md and fellowship SKILL.md

Replace bare `fellowship` CLI references with the pinned path
`~/.claude/fellowship/bin/fellowship` per the documented convention.
Addresses CodeRabbit review comments from PR #76.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(dashboard): add Adversarial to phase timeline and fix eagles API parsing

- Add Adversarial phase to PhaseTimeline.svelte phases array so progress
  bar renders correctly during adversarial review
- Fix quests.ts to extract .quests array from eagles API response object
  instead of treating the whole response as the array

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(dashboard): address frontend error handling CodeRabbit review comments

- Await res.json() in all read helpers so JSON parse errors are caught
  by surrounding try/catch instead of escaping as unhandled rejections
- Add synchronous (non-reactive) submitting guard in GateActions to
  prevent duplicate approve/reject submissions on rapid double-click
- Restructure QuestCard to render gate action buttons as siblings of the
  clickable card link rather than nested inside it, fixing interactive
  element nesting. Also guard against empty worktree prop.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
justinjdev added a commit that referenced this pull request Mar 21, 2026
…ard shortcut

- Add `ws !== socket` guard to onmessage and onerror handlers in
  websocket.ts to prevent stale socket instances from mutating shared
  state during reconnection races
- Add CONNECTING state check to prevent duplicate connection attempts
- Normalize keyboard shortcut key check with toLowerCase() for
  cross-platform robustness (Caps Lock edge case)

Addresses CodeRabbit review comments from PR #76.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
justinjdev and others added 4 commits March 21, 2026 17:49
…, quest navigation (#84)

* fix(dashboard): complete stale socket guards and cross-platform keyboard shortcut

- Add `ws !== socket` guard to onmessage and onerror handlers in
  websocket.ts to prevent stale socket instances from mutating shared
  state during reconnection races
- Add CONNECTING state check to prevent duplicate connection attempts
- Normalize keyboard shortcut key check with toLowerCase() for
  cross-platform robustness (Caps Lock edge case)

Addresses CodeRabbit review comments from PR #76.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(dashboard): address balrog findings — quest navigation reset and onerror cleanup

- Reset dataLoaded/loadFailed state when questName changes to prevent
  stale data when navigating between quest detail pages
- Revert onerror stale guard — errored sockets should always be closed
  for cleanup; the onclose handler already guards shared state

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* fix(dashboard): address CodeRabbit review — robustness and concurrency

- queue.go: handle rand.Read error, use unique tmp path per PID,
  add file locking (flock) for cross-process safety
- data.go: handle UserHomeDir error on config write path
- server.go: NewServer returns error instead of log.Fatalf
- ws.go: snapshot conns under RLock before writing to avoid holding
  mutex during network I/O; fix misleading CheckOrigin comment

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(dashboard): address balrog findings — per-conn write mutex, idempotent close

- ws.go: introduce wsConn wrapper with per-connection write mutex
  (gorilla/websocket requires serialized writes) and sync.Once Close
  to prevent double-close on concurrent Remove + Broadcast failure
- data.go: move error check before suffix filter in handleAutopsies
  to avoid masking DB errors as 404
- data.go: use PID-unique tmp path in handleConfigWrite, consistent
  with queue.go SaveCommandQueue

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* feat(dashboard): add error logging to SQLite database

Add a dedicated dashboard_errors table to record server-level errors
(API handler failures, WebSocket errors) to the SQLite database instead
of only writing to stderr. This enables the dashboard frontend to surface
errors to the user.

Backend changes:
- Add dashboard_errors table to schema with timestamp, source, handler,
  message, and detail columns
- Add LogError/ReadErrors/ClearErrors functions in errlog.go
- Add GET /api/errors and DELETE /api/errors endpoints
- Add Server.logError() best-effort helper that also broadcasts
  "error-logged" WS events
- Instrument all 500-error paths in server.go, ws.go, and data.go
- Add logFunc callback to Hub for WebSocket error logging

Frontend changes:
- Add DashboardError type to types.ts
- Add errors store with WS-triggered refetch on "error-logged" events
- Add ErrorBanner component with expandable error list and clear button
- Integrate ErrorBanner into Shell component
- Add "error-logged" to WSEventType union
- Rebuild static assets

Tests:
- TestLogErrorAndReadErrors: verify insert and read ordering
- TestAPIErrors: verify GET /api/errors returns logged errors
- TestAPIClearErrors: verify DELETE /api/errors clears all errors

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(dashboard): address adversarial review findings

- Prevent infinite recursion in Broadcast by skipping logFunc for
  "error-logged" events (logError -> Broadcast -> marshal fail -> logFunc
  -> logError cycle)
- Add auto-pruning to LogError: keep only the most recent 500 entries
  to prevent unbounded table growth
- Add comment explaining why handleErrors/handleClearErrors don't self-log

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
- Remove dead error check in handleAutopsies and add logError to the reachable error path

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 11

♻️ Duplicate comments (2)
cli/internal/dashboard/static/_app/immutable/nodes/5.DjFqZ3VZ.js (1)

1-1: ⚠️ Potential issue | 🟠 Major

The first config read can still trap the page in “Loading configuration...”.

The initial fetchConfig path only clears the loading flag on success. If that request rejects, the source page never leaves its loading placeholder. Please handle the failure in dashboard/src/routes/config/+page.svelte and regenerate this bundle.

cli/internal/dashboard/data.go (1)

141-147: ⚠️ Potential issue | 🟠 Major

Abort on non-ENOENT config read errors.

If os.ReadFile(configPath) fails for permissions or I/O, existing stays empty and the next save can replace the file with only the new key. Treat any read error other than os.IsNotExist(err) as a hard failure.

🛡️ Suggested fix
 	existing := make(map[string]interface{})
 	if data, err := os.ReadFile(configPath); err == nil {
 		if err := json.Unmarshal(data, &existing); err != nil {
 			http.Error(w, "existing config file contains invalid JSON", http.StatusInternalServerError)
 			return
 		}
+	} else if !os.IsNotExist(err) {
+		http.Error(w, "failed to read existing config", http.StatusInternalServerError)
+		return
 	}
🧹 Nitpick comments (2)
dashboard/src/lib/stores/quests.ts (1)

51-69: Coalesce refreshes triggered by the same websocket burst.

One gate approval already emits both gate-resolved and quest-changed, so this subscriber can kick off two identical refreshAll() runs for one user action. Because each run fans out into three fetches, that turns into six requests and interleaved store updates.

♻️ One way to collapse duplicate refreshes
 let unsubscribe: (() => void) | null = null;
+let refreshInFlight: Promise<void> | null = null;
+
+function scheduleRefresh() {
+	if (refreshInFlight) return refreshInFlight;
+	refreshInFlight = refreshAll().finally(() => {
+		refreshInFlight = null;
+	});
+	return refreshInFlight;
+}
 
 export function startPolling() {
 	if (unsubscribe) return;
-	refreshAll();
+	void scheduleRefresh();
 	unsubscribe = lastEvent.subscribe((event) => {
 		if (!event) return;
 		switch (event.type) {
 			case 'quest-changed':
 			case 'gate-submitted':
 			case 'gate-resolved':
 			case 'command-completed':
-				refreshAll();
+				void scheduleRefresh();
 				break;
 			case 'alert':
 				fetchProblems();
dashboard/src/lib/components/QuestCard.svelte (1)

15-16: Prefer a real anchor over div role="link".

Now that GateActions lives outside the clickable area, using a plain <a href> restores open-in-new-tab/copy-link behavior and lets you drop the accessibility ignore plus the manual key handling.

🔗 Minimal cleanup
-	import { goto } from '$app/navigation';
 	import PhaseTimeline from './PhaseTimeline.svelte';
 	import GateActions from './GateActions.svelte';
 	import type { QuestStatus, QuestHealth } from '$lib/types';
 
 	let { quest, health }: { quest: QuestStatus; health?: QuestHealth } = $props();
-
-	function navigateToQuest() {
-		goto(`/quest/${encodeURIComponent(quest.name)}`);
-	}
 </script>
 
 <div class="quest-card-wrapper" class:gate-pending={quest.gate_pending}>
-	<!-- svelte-ignore a11y_no_static_element_interactions -->
-	<div class="quest-card" onclick={navigateToQuest} onkeydown={(e) => e.key === 'Enter' && navigateToQuest()} role="link" tabindex="0">
+	<a class="quest-card" href={`/quest/${encodeURIComponent(quest.name)}`}>
@@
-	</div>
+	</a>

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 428fab33-e30c-4835-b782-01d49f585997

📥 Commits

Reviewing files that changed from the base of the PR and between 62d5cc1 and 9fd0a73.

📒 Files selected for processing (32)
  • cli/cmd/fellowship/main.go
  • cli/internal/dashboard/data.go
  • cli/internal/dashboard/queue.go
  • cli/internal/dashboard/server.go
  • cli/internal/dashboard/server_test.go
  • cli/internal/dashboard/static/_app/immutable/chunks/BEMN3nqi.js
  • cli/internal/dashboard/static/_app/immutable/chunks/BX8RXYyp.js
  • cli/internal/dashboard/static/_app/immutable/chunks/CXSl1Cmv.js
  • cli/internal/dashboard/static/_app/immutable/chunks/DfmEkXBB.js
  • cli/internal/dashboard/static/_app/immutable/entry/app.COsyL2J0.js
  • cli/internal/dashboard/static/_app/immutable/entry/start.Bhmut7i0.js
  • cli/internal/dashboard/static/_app/immutable/nodes/0.Bo2aciyg.js
  • cli/internal/dashboard/static/_app/immutable/nodes/1.CkYkQUGd.js
  • cli/internal/dashboard/static/_app/immutable/nodes/4.BpuSHdkg.js
  • cli/internal/dashboard/static/_app/immutable/nodes/5.DjFqZ3VZ.js
  • cli/internal/dashboard/static/_app/immutable/nodes/6.GopVlA_i.js
  • cli/internal/dashboard/static/_app/immutable/nodes/7.CfJplPK4.js
  • cli/internal/dashboard/static/_app/immutable/nodes/8.DH6zydf3.js
  • cli/internal/dashboard/static/_app/version.json
  • cli/internal/dashboard/static/index.html
  • cli/internal/dashboard/ws.go
  • dashboard/src/lib/api.ts
  • dashboard/src/lib/components/GateActions.svelte
  • dashboard/src/lib/components/PhaseTimeline.svelte
  • dashboard/src/lib/components/QuestCard.svelte
  • dashboard/src/lib/components/Shell.svelte
  • dashboard/src/lib/stores/quests.ts
  • dashboard/src/lib/stores/websocket.ts
  • dashboard/src/routes/quest/[id]/+page.svelte
  • plugin/commands/rekindle.md
  • plugin/skills/fellowship/SKILL.md
  • site/src/lib/styles/theme.css
✅ Files skipped from review due to trivial changes (6)
  • cli/internal/dashboard/static/_app/immutable/entry/start.Bhmut7i0.js
  • cli/internal/dashboard/static/_app/immutable/chunks/CXSl1Cmv.js
  • cli/internal/dashboard/static/index.html
  • plugin/commands/rekindle.md
  • dashboard/src/routes/quest/[id]/+page.svelte
  • dashboard/src/lib/api.ts
🚧 Files skipped from review as they are similar to previous changes (7)
  • cli/internal/dashboard/static/_app/version.json
  • plugin/skills/fellowship/SKILL.md
  • cli/internal/dashboard/server_test.go
  • dashboard/src/lib/components/PhaseTimeline.svelte
  • dashboard/src/lib/components/Shell.svelte
  • dashboard/src/lib/stores/websocket.ts
  • cli/internal/dashboard/queue.go

Comment on lines +31 to +35
if err != nil {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode([]interface{}{})
return
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don't collapse database failures into empty success responses.

These branches turn read failures into 200 responses with empty/default JSON, so the dashboard can't tell “no data yet” from “storage is broken.” Return 500 when the DB call fails, and only synthesize empty payloads for true not-found cases.

Also applies to: 69-77

Comment on lines +127 to +133
case "global":
home, err := os.UserHomeDir()
if err != nil {
http.Error(w, "unable to determine home directory", http.StatusInternalServerError)
return
}
configPath = filepath.Join(home, ".claude", "fellowship.json")
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Keep the global config private on first write.

For global scope this creates ~/.claude with 0755 and the temp file with 0644. On a fresh setup, the first save makes the directory listable and the config world-readable. Use tighter defaults for user-scoped config, or preserve existing modes.

Also applies to: 151-161

Comment thread cli/internal/dashboard/data.go
Comment on lines +73 to 94
// Try to serve static file first
path := strings.TrimPrefix(r.URL.Path, "/")
if path == "" {
path = "index.html"
}
if f, err := staticFS.Open(path); err == nil {
stat, statErr := f.Stat()
f.Close()
if statErr == nil && !stat.IsDir() {
fileServer.ServeHTTP(w, r)
return
}
}

// SPA fallback: serve index.html for client-side routes
data, _ := staticFiles.ReadFile("static/index.html")
w.Header().Set("Content-Type", "text/html")
if data == nil {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write(data)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Missing assets should 404, not fall back to the SPA shell.

If a hashed JS/CSS/image request misses here, the server returns index.html with 200 OK, and the browser ends up parsing HTML as an asset. Restrict the fallback to route navigations, and return 404 for missing static files.

Comment on lines +191 to 205
now := time.Now().UTC()
ts := now.Unix()
s.hub.Broadcast(WSEvent{Type: "gate-resolved", QuestID: result.Name, Action: "approved", Timestamp: ts})
s.hub.Broadcast(WSEvent{Type: "quest-changed", QuestID: result.Name, Timestamp: ts})

// Best-effort herald announcements after tx commits.
s.db.WithConn(context.Background(), func(conn *db.Conn) error {
now := time.Now().UTC().Format(time.RFC3339)
nowStr := now.Format(time.RFC3339)
herald.Announce(conn, herald.Tiding{
Timestamp: now, Quest: result.Name, Type: herald.GateApproved,
Timestamp: nowStr, Quest: result.Name, Type: herald.GateApproved,
Phase: prevPhase, Detail: fmt.Sprintf("Gate approved for %s", prevPhase),
})
herald.Announce(conn, herald.Tiding{
Timestamp: now, Quest: result.Name, Type: herald.PhaseTransition,
Timestamp: nowStr, Quest: result.Name, Type: herald.PhaseTransition,
Phase: result.Phase, Detail: fmt.Sprintf("Phase advanced from %s to %s", prevPhase, result.Phase),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Keep herald writes in the same transaction as gate state changes.

These handlers commit the quest mutation first and then append herald tidings on a separate connection. If that second step fails, the state change is durable but the activity log/feed misses the corresponding event. batchApproveCompany already keeps this atomic; approve/reject should do the same.

Based on learnings, herald.Announce calls are intentionally kept inside the same SQLite transaction as the state mutation because the herald log participates in the same atomic DB change.

Also applies to: 272-280

@@ -0,0 +1 @@
import"../chunks/DsnmJJEf.js";import{p as me,_ as C,E as H,$ as ye,b as he,F as _,s as g,d as r,l as e,g as o,t as b,a0 as F}from"../chunks/CFIQ5hjj.js";import{d as we,a as p,f as d,s as w,b as I,e as ke}from"../chunks/ByVGy8qs.js";import{i as E}from"../chunks/537vdszY.js";import{e as Q,i as U}from"../chunks/zKsXIgWY.js";import{r as xe,s as je}from"../chunks/DdAmTr_Q.js";import{s as X}from"../chunks/CQvy7xpd.js";import{i as Se,s as Oe}from"../chunks/CaohEQ9u.js";import{a as Ce,s as Ee}from"../chunks/C67DyfnJ.js";var Pe=d('<div class="empty-state svelte-1gp6n77">Loading configuration...</div>'),Fe=d("<button> </button>"),Ge=d('<input type="text" class="setting-input svelte-1gp6n77"/>'),Ke=d('<option class="svelte-1gp6n77"> </option>'),Ne=d('<select class="setting-select svelte-1gp6n77"></select>'),Te=d("<span> </span>"),Ae=d('<div class="setting-row svelte-1gp6n77"><div class="setting-info svelte-1gp6n77"><span class="setting-label svelte-1gp6n77"> </span> <span class="setting-scope svelte-1gp6n77"> </span></div> <div class="setting-control svelte-1gp6n77"><!> <!></div></div>'),Je=d('<div class="config-section svelte-1gp6n77"><div class="section-header svelte-1gp6n77">Global Config (raw)</div> <pre class="raw-json svelte-1gp6n77"> </pre></div>'),Me=d('<div class="config-section svelte-1gp6n77"><div class="section-header svelte-1gp6n77">Project Config (raw)</div> <pre class="raw-json svelte-1gp6n77"> </pre></div>'),Be=d('<div class="config-sections svelte-1gp6n77"><div class="config-section svelte-1gp6n77"><div class="section-header svelte-1gp6n77">Settings</div> <div class="settings-list svelte-1gp6n77"></div></div> <!> <!></div>'),De=d('<div class="config-view svelte-1gp6n77"><div class="view-header svelte-1gp6n77"><h1 class="svelte-1gp6n77">Config</h1></div> <!></div>');function Ue(Y,Z){me(Z,!0);let k=C(H({})),m=C(H({})),M=C(!0),j=C(null),y=C(null);const G=[{key:"gate_auto_approve",label:"Gate Auto-Approve",type:"boolean"},{key:"branch_prefix",label:"Branch Prefix",type:"text"},{key:"pr_draft",label:"PR Draft Mode",type:"boolean"},{key:"palantir_enabled",label:"Palantir Enabled",type:"boolean"},{key:"worktree_strategy",label:"Worktree Strategy",type:"select",options:["auto","manual"]}];function B(t){if(t in e(m))return e(m)[t];if(t in e(k))return e(k)[t]}function P(t){return t in e(m)?"project":"global"}function D(){const t=new Set(G.map(n=>n.key));return Object.keys(e(k)).filter(n=>!t.has(n))}function L(){const t=new Set(G.map(n=>n.key));return Object.keys(e(m)).filter(n=>!t.has(n))}async function K(t,n,v){_(j,t,!0);try{await Ee(t,n,v),v==="project"?e(m)[t]=n:e(k)[t]=n,_(y,{key:t,ok:!0},!0)}catch{_(y,{key:t,ok:!1},!0)}_(j,null),setTimeout(()=>{e(y)?.key===t&&_(y,null)},2e3)}async function $(t){const n=B(t),v=P(t);await K(t,!n,v)}async function ee(t,n){const v=n.target,x=P(t);await K(t,v.value,x)}async function te(t,n){const v=n.target,x=P(t);await K(t,v.value,x)}ye(async()=>{const t=await Ce();_(k,t.global??{},!0),_(m,t.project??{},!0),_(M,!1)});var N=De(),ae=g(r(N),2);{var se=t=>{var n=Pe();p(t,n)},ne=t=>{var n=Be(),v=r(n),x=g(r(v),2);Q(x,21,()=>G,U,(f,s)=>{const c=F(()=>B(e(s).key)),S=F(()=>P(e(s).key));var l=Ae(),T=r(l),A=r(T),ce=r(A,!0);o(A);var V=g(A,2),pe=r(V,!0);o(V),o(T);var W=g(T,2),q=r(W);{var de=i=>{var a=Fe();let u;var h=r(a,!0);o(a),b(()=>{u=X(a,1,"toggle-btn svelte-1gp6n77",null,u,{active:!!e(c)}),a.disabled=e(j)===e(s).key,w(h,e(c)?"ON":"OFF")}),I("click",a,()=>$(e(s).key)),p(i,a)},ue=i=>{var a=Ge();xe(a),b(()=>{je(a,e(c)??""),a.disabled=e(j)===e(s).key}),ke("blur",a,u=>ee(e(s).key,u)),p(i,a)},fe=i=>{var a=Ne();Q(a,21,()=>e(s).options??[],U,(h,J)=>{var O=Ke(),be=r(O,!0);o(O);var z={};b(()=>{w(be,e(J)),z!==(z=e(J))&&(O.value=(O.__value=e(J))??"")}),p(h,O)}),o(a);var u;Se(a),b(()=>{a.disabled=e(j)===e(s).key,u!==(u=e(c)??e(s).options?.[0])&&(a.value=(a.__value=e(c)??e(s).options?.[0])??"",Oe(a,e(c)??e(s).options?.[0]))}),I("change",a,h=>te(e(s).key,h)),p(i,a)};E(q,i=>{e(s).type==="boolean"?i(de):e(s).type==="text"?i(ue,1):e(s).type==="select"&&i(fe,2)})}var ge=g(q,2);{var _e=i=>{var a=Te();let u;var h=r(a,!0);o(a),b(()=>{u=X(a,1,"save-indicator svelte-1gp6n77",null,u,{error:!e(y).ok}),w(h,e(y).ok?"Saved":"Error")}),p(i,a)};E(ge,i=>{e(y)?.key===e(s).key&&i(_e)})}o(W),o(l),b(()=>{w(ce,e(s).label),w(pe,e(S))}),p(f,l)}),o(x),o(v);var R=g(v,2);{var oe=f=>{var s=Je(),c=g(r(s),2),S=r(c,!0);o(c),o(s),b(l=>w(S,l),[()=>JSON.stringify(Object.fromEntries(D().map(l=>[l,e(k)[l]])),null,2)]),p(f,s)},re=F(()=>D().length>0);E(R,f=>{e(re)&&f(oe)})}var le=g(R,2);{var ie=f=>{var s=Me(),c=g(r(s),2),S=r(c,!0);o(c),o(s),b(l=>w(S,l),[()=>JSON.stringify(Object.fromEntries(L().map(l=>[l,e(m)[l]])),null,2)]),p(f,s)},ve=F(()=>L().length>0);E(le,f=>{e(ve)&&f(ie)})}o(n),p(t,n)};E(ae,t=>{e(M)?t(se):t(ne,-1)})}o(N),p(Y,N),he()}we(["click","change"]);export{Ue as component};
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

The settings controls still have no programmatic labels.

Each toggle, text input, and select is rendered next to label text, but none of the controls are actually associated with that text. Screen readers will not announce which setting is being edited, and the boolean toggle also lacks pressed-state semantics. Please add label/for or aria-labelledby, plus aria-pressed for the toggle, in dashboard/src/routes/config/+page.svelte.

@@ -0,0 +1 @@
import"../chunks/DsnmJJEf.js";import{d as a,l as e,g as t,s as r,t as x,p as Ce,i as Ge,F as y,b as Oe,a0 as M,_ as N,f as fe}from"../chunks/CFIQ5hjj.js";import{s as Ie,a as le}from"../chunks/DLS4WeON.js";import{s as p,a as o,t as he,f as c,d as Ne,b as D}from"../chunks/ByVGy8qs.js";import{i as H}from"../chunks/537vdszY.js";import{e as Q,i as re}from"../chunks/zKsXIgWY.js";import{a as Pe}from"../chunks/DdAmTr_Q.js";import{s as b}from"../chunks/CQvy7xpd.js";import{p as We}from"../chunks/CXSl1Cmv.js";import{e as Ae,q as Be}from"../chunks/BX8RXYyp.js";import{c as De,d as Me}from"../chunks/C67DyfnJ.js";import{G as Qe}from"../chunks/CMrUtYuQ.js";import{H as Ue,t as je}from"../chunks/BEMN3nqi.js";var ze=c('<div class="errand-item svelte-6y5tqw"><div><!></div> <span> </span></div>'),Je=c('<div class="errand-list svelte-6y5tqw"></div>');function Ke(U,j){var P=Je();Q(P,21,()=>j.errands,W=>W.id,(W,S)=>{var E=ze(),O=a(E),F=a(O);{var v=m=>{var i=he("✓");o(m,i)},k=m=>{var i=he("●");o(m,i)};H(F,m=>{e(S).status==="done"?m(v):e(S).status==="active"&&m(k,1)})}t(O);var A=r(O,2),q=a(A,!0);t(A),t(E),x(()=>{b(O,1,`errand-check ${e(S).status??""}`,"svelte-6y5tqw"),b(A,1,`errand-text ${e(S).status??""}`,"svelte-6y5tqw"),p(q,e(S).description)}),o(W,E)}),t(P),o(U,P)}var Ve=c('<span></span> <span class="health-label svelte-1ysin3l"> </span>',1),Xe=c('<div><span class="phase-step-label"> </span></div>'),Ye=c('<div class="title-row svelte-1ysin3l"><h1 class="svelte-1ysin3l"> </h1> <span class="phase-badge svelte-1ysin3l"> </span> <!> <!></div> <div class="phase-steps svelte-1ysin3l"></div>',1),Ze=c('<span class="tab-badge svelte-1ysin3l"> </span>'),es=c('<div class="file-item svelte-1ysin3l"> </div>'),ss=c('<div class="files-list svelte-1ysin3l"></div>'),ts=c('<div class="tome-entry svelte-1ysin3l"><span> </span> <span class="tome-phase svelte-1ysin3l"> </span> <span class="tome-time svelte-1ysin3l"> </span></div>'),as=c('<div class="tome-content svelte-1ysin3l"><h3 class="svelte-1ysin3l">Gate History</h3> <!></div>'),ls=c('<div class="logs-placeholder"><p class="empty svelte-1ysin3l">Quest logs not yet available. Raw output logging is a future enhancement.</p></div>'),rs=c('<p class="empty svelte-1ysin3l">No data available</p>'),is=c('<div class="meta-row svelte-1ysin3l"><span>Eagles</span> <span> </span></div>'),vs=c('<div class="detail-meta svelte-1ysin3l"><div class="meta-card svelte-1ysin3l"><div class="meta-title svelte-1ysin3l">Metadata</div> <div class="meta-row svelte-1ysin3l"><span>Branch</span> <span class="meta-value mono svelte-1ysin3l"> </span></div> <div class="meta-row svelte-1ysin3l"><span>Worktree</span> <span class="meta-value mono truncate svelte-1ysin3l"> </span></div> <div class="meta-row svelte-1ysin3l"><span>Status</span> <span class="meta-value svelte-1ysin3l"> </span></div> <!></div></div>'),ns=c('<div class="detail-view svelte-1ysin3l"><div class="detail-header svelte-1ysin3l"><div class="breadcrumb svelte-1ysin3l"><a href="/command" class="svelte-1ysin3l">Command</a> <span class="sep svelte-1ysin3l">/</span> <span> </span></div> <!></div> <div class="tabs svelte-1ysin3l"><button>Errands <!></button> <button>Files</button> <button>Tome</button> <button>Herald</button> <button>Logs</button></div> <div class="detail-body svelte-1ysin3l"><div class="detail-content svelte-1ysin3l"><!></div> <!></div></div>');function ws(U,j){Ce(j,!0);const P=()=>le(Ae,"$dashboardStatus",E),W=()=>le(Be,"$questHealths",E),S=()=>le(je,"$tidings",E),[E,O]=Ie();let F=M(()=>decodeURIComponent(We.params.id)),v=M(()=>P()?.quests.find(s=>s.name===e(F))),k=M(()=>W().find(s=>s.name===e(F))),A=M(()=>S().filter(s=>s.quest===e(F))),q=N(null),m=N(null),i=N("errands");const z=["Onboard","Research","Plan","Implement","Review","Complete"];let ie=N(!1),ve=N(!1),J=N(!1);Ge(()=>{if(e(v)&&!e(ie)&&!e(ve)&&!e(J)){y(J,!0);const s=e(v).worktree;Promise.all([De(s),Me(e(F))]).then(([l,n])=>{y(q,l,!0),y(m,n,!0),y(ie,!0)}).catch(()=>{y(ve,!0)}).finally(()=>{y(J,!1)})}});function ye(s){if(!e(v))return"pending";const l=z.indexOf(e(v).phase);if(l===-1)return"pending";const n=z.indexOf(s);return n<l?"done":n===l?"current":"pending"}var K=ns(),V=a(K),X=a(V),ne=r(a(X),4),be=a(ne,!0);t(ne),t(X);var ge=r(X,2);{var xe=s=>{var l=Ye(),n=fe(l),d=a(n),_=a(d,!0);t(d);var f=r(d,2),g=a(f,!0);t(f);var $=r(f,2);{var L=h=>{Qe(h,{get worktree(){return e(v).worktree}})};H($,h=>{e(v).gate_pending&&h(L)})}var R=r($,2);{var T=h=>{var C=Ve(),u=fe(C),w=r(u,2),G=a(w,!0);t(w),x(()=>{b(u,1,`health-dot ${e(k).health??""}`,"svelte-1ysin3l"),p(G,e(k).health)}),o(h,C)};H(R,h=>{e(k)&&h(T)})}t(n);var I=r(n,2);Q(I,21,()=>z,re,(h,C)=>{var u=Xe(),w=a(u),G=a(w,!0);t(w),t(u),x(ae=>{b(u,1,`phase-step ${ae??""}`,"svelte-1ysin3l"),p(G,e(C))},[()=>ye(e(C))]),o(h,u)}),t(I),x(()=>{p(_,e(v).name),p(g,e(v).phase)}),o(s,l)};H(ge,s=>{e(v)&&s(xe)})}t(V);var Y=r(V,2),B=a(Y);let oe;var we=r(a(B));{var ke=s=>{var l=Ze(),n=a(l);t(l),x(d=>p(n,`${d??""}/${e(q).items.length??""}`),[()=>e(q).items.filter(d=>d.status==="done").length]),o(s,l)};H(we,s=>{e(q)&&s(ke)})}t(B);var Z=r(B,2);let de;var ee=r(Z,2);let pe;var se=r(ee,2);let ce;var _e=r(se,2);let ue;t(Y);var me=r(Y,2),te=a(me),qe=a(te);{var $e=s=>{Ke(s,{get errands(){return e(q).items}})},He=s=>{var l=ss();Q(l,21,()=>e(m).files_touched,re,(n,d)=>{var _=es(),f=a(_,!0);t(_),x(()=>p(f,e(d))),o(n,_)}),t(l),o(s,l)},Se=s=>{var l=as(),n=r(a(l),2);Q(n,17,()=>e(m).gate_history,re,(d,_)=>{var f=ts(),g=a(f),$=a(g,!0);t(g);var L=r(g,2),R=a(L,!0);t(L);var T=r(L,2),I=a(T,!0);t(T),t(f),x(()=>{b(g,1,`tome-action ${e(_).action??""}`,"svelte-1ysin3l"),p($,e(_).action),p(R,e(_).phase),p(I,e(_).timestamp)}),o(d,f)}),t(l),o(s,l)},Ee=s=>{Ue(s,{get tidings(){return e(A)},limit:50})},Fe=s=>{var l=ls();o(s,l)},Le=s=>{var l=rs();o(s,l)};H(qe,s=>{e(i)==="errands"&&e(q)?s($e):e(i)==="files"&&e(m)?s(He,1):e(i)==="tome"&&e(m)?s(Se,2):e(i)==="herald"?s(Ee,3):e(i)==="logs"?s(Fe,4):s(Le,-1)})}t(te);var Re=r(te,2);{var Te=s=>{var l=vs(),n=a(l),d=r(a(n),2),_=r(a(d),2),f=a(_,!0);t(_),t(d);var g=r(d,2),$=r(a(g),2),L=a($,!0);t($),t(g);var R=r(g,2),T=r(a(R),2),I=a(T,!0);t(T),t(R);var h=r(R,2);{var C=u=>{var w=is(),G=r(a(w),2),ae=a(G,!0);t(G),t(w),x(()=>{b(G,1,`meta-value ${e(k).health??""}`,"svelte-1ysin3l"),p(ae,e(k).health)}),o(u,w)};H(h,u=>{e(k)&&u(C)})}t(n),t(l),x(u=>{p(f,u),Pe($,"title",e(v).worktree),p(L,e(v).worktree),p(I,e(v).status)},[()=>e(v).worktree.replace(/\\/g,"/").split("/").pop()||e(v).worktree]),o(s,l)};H(Re,s=>{e(v)&&s(Te)})}t(me),t(K),x(()=>{p(be,e(F)),oe=b(B,1,"tab svelte-1ysin3l",null,oe,{active:e(i)==="errands"}),de=b(Z,1,"tab svelte-1ysin3l",null,de,{active:e(i)==="files"}),pe=b(ee,1,"tab svelte-1ysin3l",null,pe,{active:e(i)==="tome"}),ce=b(se,1,"tab svelte-1ysin3l",null,ce,{active:e(i)==="herald"}),ue=b(_e,1,"tab svelte-1ysin3l",null,ue,{active:e(i)==="logs"})}),D("click",B,()=>y(i,"errands")),D("click",Z,()=>y(i,"files")),D("click",ee,()=>y(i,"tome")),D("click",se,()=>y(i,"herald")),D("click",_e,()=>y(i,"logs")),o(U,K),Oe(),O()}Ne(["click"]);export{ws as component};
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Re-fetch quest data when the quest id changes.

This page latches its local loaded/error guards after the first request and never resets them on same-route navigation. Moving from one /quest/[id] page to another can keep the previous quest’s errands/files/tome on screen, and an initial failure also blocks later retries until a hard refresh. Reset the local request state when the id changes, or key dashboard/src/routes/quest/[id]/+page.svelte by quest id before rebuilding.


⚠️ Potential issue | 🟡 Minor

The phase model has drifted between views.

This bundle’s stepper omits "Adversarial", but cli/internal/dashboard/static/_app/immutable/nodes/4.BpuSHdkg.js, Line 1 still includes it. When a quest enters that phase, the detail stepper falls back to its unknown-phase branch and marks every step as pending. Please source the phase order from one shared constant before rebuilding the compiled assets.

@@ -0,0 +1 @@
import"../chunks/DsnmJJEf.js";import{p as F,f as Y,d as p,g as d,s as q,l as n,t as k,b as G,a0 as z,_ as y,F as x,E as A,$ as B,a1 as H}from"../chunks/CFIQ5hjj.js";import{s as J,a as K}from"../chunks/DLS4WeON.js";import{e as R,a as f,s as C,f as h}from"../chunks/ByVGy8qs.js";import{i as X}from"../chunks/537vdszY.js";import{d as L,s as Q,a as U,e as E,b as V,c as Z}from"../chunks/BX8RXYyp.js";import{d as tt}from"../chunks/C67DyfnJ.js";import{e as O,i as W}from"../chunks/zKsXIgWY.js";var et=h('<div class="gantt-bar svelte-t05lje"></div>'),at=h('<div class="gantt-row svelte-t05lje"><div class="gantt-label svelte-t05lje"> </div> <div class="gantt-track svelte-t05lje"></div></div>'),st=h('<div class="gantt-tooltip svelte-t05lje"><span class="tooltip-phase svelte-t05lje"> </span> <span class="tooltip-dur svelte-t05lje"> </span></div>'),rt=h('<div class="gantt-chart svelte-t05lje"></div> <!>',1);function nt(T,g){F(g,!0);const S={Onboard:"var(--accent-blue-dim)",Research:"var(--accent-purple-dim)",Plan:"var(--accent-gold-dim)",Implement:"var(--accent-green-dim)",Review:"var(--accent-gold)",Complete:"var(--accent-green)"},D={Onboard:"var(--accent-blue)",Research:"var(--accent-purple)",Plan:"var(--accent-gold)",Implement:"var(--accent-green-text)",Review:"var(--accent-gold)",Complete:"var(--accent-green-text)"};function P(a){const e=Math.floor(a/6e4);if(e<60)return`${e}m`;const s=Math.floor(e/60);return s<24?`${s}h ${e%60}m`:`${Math.floor(s/24)}d ${s%24}h`}let _=z(()=>{if(g.quests.length===0)return[];let a=1/0,e=-1/0;for(const i of g.quests){for(const r of i.phases_completed){const l=new Date(r.timestamp).getTime();isNaN(l)||(l<a&&(a=l),l>e&&(e=l))}const o=new Date(i.created_at).getTime();!isNaN(o)&&o<a&&(a=o)}e<=a&&(e=a+1);const s=e-a,t=[];for(const i of g.quests){const o=[...i.phases_completed].sort((c,u)=>new Date(c.timestamp).getTime()-new Date(u.timestamp).getTime());if(o.length===0)continue;const r=[],l=new Date(i.created_at).getTime();for(let c=0;c<o.length;c++){const u=c===0?l:new Date(o[c-1].timestamp).getTime(),I=new Date(o[c].timestamp).getTime()-u;r.push({phase:o[c].phase,startPct:(u-a)/s*100,widthPct:Math.max(I/s*100,.5),duration:P(I)})}t.push({name:i.quest_name,segments:r})}return t}),v=y(null);function w(a,e,s){x(v,{quest:e,phase:s.phase,duration:s.duration,x:a.clientX,y:a.clientY},!0)}function $(){x(v,null)}var b=rt(),m=Y(b);O(m,21,()=>n(_),W,(a,e)=>{var s=at(),t=p(s),i=p(t,!0);d(t);var o=q(t,2);O(o,21,()=>n(e).segments,W,(r,l)=>{var c=et();let u;k(()=>u=L(c,"",u,{left:`${n(l).startPct??""}%`,width:`${n(l).widthPct??""}%`,background:S[n(l).phase]??"var(--bg-raised)",color:D[n(l).phase]??"var(--text-muted)"})),R("mouseenter",c,N=>w(N,n(e).name,n(l))),R("mouseleave",c,$),f(r,c)}),d(o),d(s),k(()=>C(i,n(e).name)),f(a,s)}),d(m);var j=q(m,2);{var M=a=>{var e=st();let s;var t=p(e),i=p(t,!0);d(t);var o=q(t,2),r=p(o,!0);d(o),d(e),k(()=>{s=L(e,"",s,{left:`${n(v).x+12}px`,top:`${n(v).y-8}px`}),C(i,n(v).phase),C(r,n(v).duration)}),f(a,e)};X(j,a=>{n(v)&&a(M)})}f(T,b),G()}var ot=h('<div class="empty-state svelte-1fjec68">Loading quest timeline data...</div>'),it=h('<div class="empty-state svelte-1fjec68">No quest timeline data available.</div>'),lt=h('<div class="timeline-view svelte-1fjec68"><div class="view-header svelte-1fjec68"><h1 class="svelte-1fjec68">Timeline</h1></div> <div class="chart-area svelte-1fjec68"><!></div></div>');function gt(T,g){F(g,!0);const S=()=>K(E,"$dashboardStatus",D),[D,P]=J();let _=y(A([])),v=y(!0),w=y(!1);async function $(){const t=S();if(!t)return;x(w,!0);const i=t.quests.map(r=>r.name),o=await Promise.allSettled(i.map(r=>tt(r)));x(_,o.filter(r=>r.status==="fulfilled").map(r=>r.value).filter(r=>r!=null),!0),x(v,!1)}let b=null;B(()=>{Q(),U(),b=E.subscribe(t=>{t&&!n(w)&&$()})}),H(()=>{V(),Z(),b?.()});var m=lt(),j=q(p(m),2),M=p(j);{var a=t=>{var i=ot();f(t,i)},e=t=>{var i=it();f(t,i)},s=t=>{nt(t,{get quests(){return n(_)}})};X(M,t=>{n(v)?t(a):n(_).length===0?t(e,1):t(s,-1)})}d(j),d(m),f(T,m),G(),P()}export{gt as component};
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Release the in-flight guard after each timeline refresh.

The Timeline load path sets its reload guard before fetching tome data, but never clears it afterward. After the first batch resolves, later dashboard-status updates can no longer trigger another refresh, so this view goes stale until a full navigation. Please wrap the source load in try/finally in dashboard/src/routes/timeline/+page.svelte and regenerate this bundle.

Comment on lines +25 to +28
var upgrader = websocket.Upgrader{
// Allow all origins — the dashboard binds to localhost but may be accessed
// from different ports or via forwarded connections during development.
CheckOrigin: func(r *http.Request) bool { return true },
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Restrict WebSocket origins instead of accepting every browser origin.

CheckOrigin: return true lets any site connect to the local dashboard socket and read event traffic from the user's machine. Allow loopback/same-host origins, and make any forwarded hosts explicit rather than blanket-allowing all origins.

🔒 Suggested restriction
 import (
 	"encoding/json"
 	"log"
+	"net/url"
 	"net/http"
 	"sync"
 	"time"
@@
 var upgrader = websocket.Upgrader{
 	// Allow all origins — the dashboard binds to localhost but may be accessed
 	// from different ports or via forwarded connections during development.
-	CheckOrigin: func(r *http.Request) bool { return true },
+	CheckOrigin: func(r *http.Request) bool {
+		origin := r.Header.Get("Origin")
+		if origin == "" {
+			return true
+		}
+		u, err := url.Parse(origin)
+		if err != nil {
+			return false
+		}
+		switch u.Hostname() {
+		case "localhost", "127.0.0.1", "::1":
+			return true
+		default:
+			return false
+		}
+	},
 }

Comment thread cli/internal/dashboard/ws.go
justinjdev and others added 5 commits March 21, 2026 18:02
- Add command queue pruning (max 200) to prevent unbounded growth
- Add mutex to handleConfigWrite to prevent concurrent write races
- Set WebSocket read limit (4096) to prevent unbounded memory allocation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Move configMu from package-level var to Server struct field for consistency

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add lifecycle hooks to quest detail page for direct navigation support
- Add loading state for quest detail when data not yet available
- Validate config write key is non-empty

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Wrap fetchConfig in try/catch so loading state clears even on network
failure, preventing the page from being stuck on "Loading configuration..."

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Abort config write on non-ENOENT read errors to prevent data loss
- Coalesce WS-triggered refreshes to avoid duplicate API calls
- Use semantic <a> element for QuestCard navigation instead of div role="link"

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 14

♻️ Duplicate comments (2)
cli/internal/dashboard/ws.go (1)

25-28: ⚠️ Potential issue | 🟠 Major

Restrict browser origins for the dashboard socket.

CheckOrigin: return true lets any site open this localhost socket and consume dashboard events from the user's machine. Allow loopback/same-host origins by default, and make any forwarded hosts explicit instead of blanket-allowing all origins.

🔒 Suggested restriction
 import (
 	"encoding/json"
 	"log"
+	"net/url"
 	"net/http"
 	"sync"
 	"time"
@@
 var upgrader = websocket.Upgrader{
 	// Allow all origins — the dashboard binds to localhost but may be accessed
 	// from different ports or via forwarded connections during development.
-	CheckOrigin: func(r *http.Request) bool { return true },
+	CheckOrigin: func(r *http.Request) bool {
+		origin := r.Header.Get("Origin")
+		if origin == "" {
+			return true
+		}
+		u, err := url.Parse(origin)
+		if err != nil {
+			return false
+		}
+		switch u.Hostname() {
+		case "localhost", "127.0.0.1", "::1":
+			return true
+		default:
+			return false
+		}
+	},
 }
For Gorilla WebSocket v1.5.3, what does the `Upgrader.CheckOrigin` documentation recommend, and what are the security implications of always returning `true`?
cli/internal/dashboard/data.go (1)

135-175: ⚠️ Potential issue | 🟠 Major

Keep the first global config write private.

For req.Scope == "global", this path creates ~/.claude with 0755 and writes fellowship.json with 0644. Any future secret-bearing keys become world-readable/listable on a fresh setup. Use tighter modes for the global path, or preserve the existing ones.

🔐 Suggested tightening
-	if err := os.MkdirAll(filepath.Dir(configPath), 0755); err != nil {
+	dirMode := os.FileMode(0755)
+	fileMode := os.FileMode(0644)
+	if req.Scope == "global" {
+		dirMode = 0700
+		fileMode = 0600
+	}
+	if err := os.MkdirAll(filepath.Dir(configPath), dirMode); err != nil {
 		http.Error(w, "failed to create config directory", http.StatusInternalServerError)
 		return
 	}
@@
-	if err := os.WriteFile(tmp, data, 0644); err != nil {
+	if err := os.WriteFile(tmp, data, fileMode); err != nil {
 		http.Error(w, "failed to write config", http.StatusInternalServerError)
 		return
 	}
🧹 Nitpick comments (3)
cli/internal/dashboard/static/_app/immutable/chunks/DM7OFrYY.js (1)

1-1: Don’t silently swallow /api/herald fetch failures.

On Line 1, catch {} drops all failures with no signal. This makes production diagnosis harder when herald data stops updating.

🛠️ Proposed fix (source-level)
-} catch {}
+} catch (err) {
+  console.error('Failed to fetch /api/herald', err);
+}
cli/internal/dashboard/static/_app/immutable/chunks/4mhR39rs.js (1)

1-1: Coalesce websocket-triggered refreshes.

Every matching event calls p() immediately, so bursts of quest-changed/command-completed messages can spin up overlapping /api/status, /api/eagles, and /api/herald/problems fetches. Because each response writes straight into the stores, an older slower refresh can still land last and repaint stale state.

dashboard/src/lib/components/ErrorBanner.svelte (1)

33-40: Expose the disclosure state on the summary button.

This control expands and collapses content, but it never announces whether the list is open or closed. Adding aria-expanded and aria-controls here makes the banner understandable to screen-reader users.

♿ Suggested markup tweak
-		<button class="error-summary" onclick={() => (expanded = !expanded)}>
+		<button
+			class="error-summary"
+			type="button"
+			aria-expanded={expanded}
+			aria-controls="dashboard-error-list"
+			onclick={() => (expanded = !expanded)}
+		>
 			<span class="error-icon">!</span>
 			<span class="error-count">{errors.length} server error{errors.length !== 1 ? 's' : ''}</span>
 			<span class="error-toggle">{expanded ? '▾' : '▸'}</span>
 		</button>
 
 		{`#if` expanded}
-			<div class="error-list">
+			<div id="dashboard-error-list" class="error-list">

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 882f51fb-6eec-4682-866d-5d10c74a7b2c

📥 Commits

Reviewing files that changed from the base of the PR and between 9fd0a73 and 19b5798.

⛔ Files ignored due to path filters (1)
  • .claude/scheduled_tasks.lock is excluded by !**/*.lock
📒 Files selected for processing (38)
  • .mcp.json
  • cli/internal/dashboard/data.go
  • cli/internal/dashboard/errlog.go
  • cli/internal/dashboard/queue.go
  • cli/internal/dashboard/server.go
  • cli/internal/dashboard/server_test.go
  • cli/internal/dashboard/static/_app/immutable/assets/0.DzBZsd8E.css
  • cli/internal/dashboard/static/_app/immutable/assets/4.DQ8-Y4U1.css
  • cli/internal/dashboard/static/_app/immutable/chunks/4mhR39rs.js
  • cli/internal/dashboard/static/_app/immutable/chunks/C19yGuqu.js
  • cli/internal/dashboard/static/_app/immutable/chunks/DDgnzpUj.js
  • cli/internal/dashboard/static/_app/immutable/chunks/DJzzQicO.js
  • cli/internal/dashboard/static/_app/immutable/chunks/DM7OFrYY.js
  • cli/internal/dashboard/static/_app/immutable/chunks/c59pmuMM.js
  • cli/internal/dashboard/static/_app/immutable/chunks/eQSYeF-0.js
  • cli/internal/dashboard/static/_app/immutable/entry/app.CbPicme5.js
  • cli/internal/dashboard/static/_app/immutable/entry/start.COYkiJU8.js
  • cli/internal/dashboard/static/_app/immutable/nodes/0.C133qmEE.js
  • cli/internal/dashboard/static/_app/immutable/nodes/1.BwivCJmQ.js
  • cli/internal/dashboard/static/_app/immutable/nodes/3.DUsoW_8B.js
  • cli/internal/dashboard/static/_app/immutable/nodes/4.P1MrWDtB.js
  • cli/internal/dashboard/static/_app/immutable/nodes/5.DCaVDi0b.js
  • cli/internal/dashboard/static/_app/immutable/nodes/6.5lNWWaDI.js
  • cli/internal/dashboard/static/_app/immutable/nodes/7.BQnCdFNo.js
  • cli/internal/dashboard/static/_app/immutable/nodes/8.DqkmqCzQ.js
  • cli/internal/dashboard/static/_app/version.json
  • cli/internal/dashboard/static/index.html
  • cli/internal/dashboard/ws.go
  • cli/internal/db/schema.go
  • dashboard/src/lib/components/ErrorBanner.svelte
  • dashboard/src/lib/components/QuestCard.svelte
  • dashboard/src/lib/components/Shell.svelte
  • dashboard/src/lib/stores/errors.ts
  • dashboard/src/lib/stores/quests.ts
  • dashboard/src/lib/stores/websocket.ts
  • dashboard/src/lib/types.ts
  • dashboard/src/routes/config/+page.svelte
  • dashboard/src/routes/quest/[id]/+page.svelte
✅ Files skipped from review due to trivial changes (6)
  • cli/internal/dashboard/static/_app/immutable/assets/4.DQ8-Y4U1.css
  • .mcp.json
  • cli/internal/dashboard/static/_app/immutable/entry/start.COYkiJU8.js
  • cli/internal/dashboard/static/_app/immutable/nodes/1.BwivCJmQ.js
  • cli/internal/dashboard/static/_app/immutable/assets/0.DzBZsd8E.css
  • cli/internal/dashboard/static/_app/immutable/chunks/eQSYeF-0.js
🚧 Files skipped from review as they are similar to previous changes (8)
  • cli/internal/dashboard/static/_app/version.json
  • dashboard/src/lib/components/QuestCard.svelte
  • dashboard/src/lib/components/Shell.svelte
  • dashboard/src/lib/stores/websocket.ts
  • cli/internal/dashboard/static/index.html
  • dashboard/src/routes/quest/[id]/+page.svelte
  • cli/internal/dashboard/server.go
  • dashboard/src/lib/types.ts

Comment on lines +22 to +28
// Individual autopsy lookup not supported via SQLite API; scan all and filter.
var records []autopsy.Autopsy
err := s.db.WithConn(context.Background(), func(conn *db.Conn) error {
var loadErr error
records, loadErr = autopsy.Scan(conn, autopsy.ScanOptions{}, autopsy.DefaultExpiryDays)
return loadErr
})
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

handleAutopsies calls a filter-only API with no filters.

cli/internal/autopsy/autopsy.go:134-136 rejects autopsy.ScanOptions{} when files/modules/tags are all empty, but this handler always passes an empty options struct. That means /api/autopsies falls straight into the error branch and the new Autopsies view cannot load real data.

@@ -0,0 +1 @@
import"./DsnmJJEf.js";import{p as g,d as o,g as f,s as x,t as h,l as r,b as y,_,F as l}from"./CFIQ5hjj.js";import{d as k,s as c,b as u,a as j,f as w}from"./ByVGy8qs.js";import{b as G,r as A}from"./c59pmuMM.js";var F=w('<div class="gate-actions svelte-1fnxfh2"><button class="gate-btn approve svelte-1fnxfh2"> </button> <button class="gate-btn reject svelte-1fnxfh2"> </button></div>');function C(p,i){g(i,!0);let t=_(!1),e=!1;async function v(){if(!e){e=!0,l(t,!0);try{await G(i.worktree)}finally{e=!1,l(t,!1)}}}async function b(){if(!e){e=!0,l(t,!0);try{await A(i.worktree)}finally{e=!1,l(t,!1)}}}var n=F(),a=o(n),d=o(a,!0);f(a);var s=x(a,2),m=o(s,!0);f(s),f(n),h(()=>{a.disabled=r(t),c(d,r(t)?"...":"Approve"),s.disabled=r(t),c(m,r(t)?"...":"Reject")}),u("click",a,v),u("click",s,b),j(p,n),y()}k(["click"]);export{C as G};
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== GateActions handlers (check for try/finally without catch) =="
sed -n '1,140p' dashboard/src/lib/components/GateActions.svelte

echo
echo "== API methods (check thrown errors on non-OK responses) =="
sed -n '1,120p' dashboard/src/lib/api.ts

echo
echo "== Focused matches =="
rg -n 'async function (approve|reject)|await (approveGate|rejectGate)\(|catch\s*\(' dashboard/src/lib/components/GateActions.svelte
rg -n 'if \(!res\.ok\) throw new Error' dashboard/src/lib/api.ts

Repository: justinjdev/fellowship

Length of output: 5768


Add error handling to gate action handlers to prevent unhandled promise rejections.

The approve() and reject() functions in dashboard/src/lib/components/GateActions.svelte await approveGate() and rejectGate() calls within try/finally blocks, but lack catch handlers. Since both API functions throw errors on non-OK responses, failed requests will reject without user-facing feedback or error logging.

Suggested fix
 async function approve() {
 	if (submitting) return;
 	submitting = true;
 	loading = true;
+	actionError = null;
 	try {
 		await approveGate(worktree);
+	} catch (err) {
+		actionError = err instanceof Error ? err.message : 'Gate approve failed';
 	} finally {
 		submitting = false;
 		loading = false;
 	}
 }

 async function reject() {
 	if (submitting) return;
 	submitting = true;
 	loading = true;
+	actionError = null;
 	try {
 		await rejectGate(worktree);
+	} catch (err) {
+		actionError = err instanceof Error ? err.message : 'Gate reject failed';
 	} finally {
 		submitting = false;
 		loading = false;
 	}
 }

Add let actionError = $state<string | null>(null); to component state and display in UI.

@@ -0,0 +1 @@
import{w as r}from"./Dv0Hwark.js";const c=r(!1),i=r(null);let e=null,o=1e3;const f=3e4;let n=!0;function w(){return`${window.location.protocol==="https:"?"wss:":"ws:"}//${window.location.host}/ws`}function a(){if(e&&(e.readyState===WebSocket.OPEN||e.readyState===WebSocket.CONNECTING))return;let t;try{t=new WebSocket(w())}catch{s();return}e=t,t.onopen=()=>{e===t&&(c.set(!0),o=1e3)},t.onclose=()=>{e===t&&(c.set(!1),e=null,n&&s())},t.onerror=()=>{t.close()},t.onmessage=l=>{if(e===t)try{const u=JSON.parse(l.data);i.set(u)}catch{}}}function s(){setTimeout(()=>{n&&a()},o),o=Math.min(o*2,f)}function p(){n=!0,a()}function S(){n=!1,e?.close()}export{S as a,c,i as l,p as s};
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Track connecting separately from disconnected.

connected starts as false, and the Command view reads that directly to show “Disconnected — reconnecting...”. On a fresh load the banner therefore appears before the first socket attempt has even finished, which looks like a real outage.

🔧 Suggested state shape
-const connected = writable(false);
+const connectionState = writable<'idle' | 'connecting' | 'connected' | 'disconnected'>('idle');

Gate the banner on 'disconnected', and set 'connecting' just before opening the socket.

@@ -0,0 +1 @@
import{w as M}from"./Dv0Hwark.js";import{l as N}from"./DJzzQicO.js";import"./DsnmJJEf.js";import{p as P,b as k,d as c,g as o,s as g,t as E,l as n,a0 as F}from"./CFIQ5hjj.js";import{a as h,f as b,s as m}from"./ByVGy8qs.js";import{e as I,i as z}from"./zKsXIgWY.js";import{b as A}from"./4mhR39rs.js";import{p as B}from"./BVGfD6Eb.js";const C=M([]);async function _(){try{const t=await fetch("/api/herald");t.ok&&C.set(await t.json())}catch{}}let l=null;function W(){l||(_(),l=N.subscribe(t=>{t&&(t.type==="herald-event"||t.type==="gate-resolved"||t.type==="quest-changed")&&_()}))}function X(){l?.(),l=null}var G=b('<div class="herald-item svelte-1lw2g4q"><span class="herald-icon svelte-1lw2g4q"> </span> <span class="herald-text svelte-1lw2g4q"> </span> <span class="herald-time svelte-1lw2g4q"> </span></div>'),J=b('<div class="herald-feed svelte-1lw2g4q"></div>');function Y(t,d){P(d,!0);let q=B(d,"limit",3,20);const w={gate_submitted:{icon:"◆",color:"var(--accent-gold)"},gate_approved:{icon:"✓",color:"var(--accent-green-text)"},gate_rejected:{icon:"✗",color:"var(--accent-red)"},phase_transition:{icon:"→",color:"var(--accent-green-text)"},lembas_completed:{icon:"◎",color:"var(--accent-blue)"},metadata_updated:{icon:"◎",color:"var(--text-muted)"},quest_held:{icon:"⏸",color:"var(--accent-gold)"},quest_unheld:{icon:"▶",color:"var(--accent-green-text)"}};function x(e){const s=new Date(e);if(isNaN(s.getTime()))return"?";const i=Date.now()-s.getTime(),a=Math.floor(i/6e4);if(a<1)return"now";if(a<60)return`${a}m`;const r=Math.floor(a/60);return r<24?`${r}h`:`${Math.floor(r/24)}d`}function y(e){switch(e.type){case"gate_submitted":return`${e.quest} submitted ${e.phase} gate`;case"gate_approved":return`${e.quest} ${e.phase} gate approved`;case"gate_rejected":return`${e.quest} ${e.phase} gate rejected`;case"phase_transition":return`${e.quest} entered ${e.phase}`;default:return e.detail||`${e.quest} ${e.type}`}}var p=J();I(p,21,()=>d.tidings.slice(0,q()),z,(e,s)=>{const i=F(()=>w[n(s).type]??{icon:"·",color:"var(--text-muted)"});var a=G(),r=c(a);let f;var $=c(r,!0);o(r);var u=g(r,2),j=c(u,!0);o(u);var v=g(u,2),H=c(v,!0);o(v),o(a),E((T,D)=>{f=A(r,"",f,{color:n(i).color}),m($,n(i).icon),m(j,T),m(H,D)},[()=>y(n(s)),()=>x(n(s).timestamp)]),h(e,a)}),o(p),h(t,p),k()}export{Y as H,X as a,W as s,C as t};
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Coalesce WS-triggered herald refreshes to avoid stale overwrites.

On Line 1, each matching websocket event immediately calls fetch("/api/herald") with no in-flight dedupe or response-order guard. Under bursty events, concurrent requests can resolve out of order and apply stale data last.

♻️ Proposed fix (apply in dashboard/src/lib/stores/herald.ts, then rebuild static assets)
 let unsubscribe: (() => void) | null = null;
+let inFlight = false;
+let queued = false;
+let reqSeq = 0;
+let appliedSeq = 0;
 
 export async function fetchTidings() {
+  if (inFlight) {
+    queued = true;
+    return;
+  }
+  inFlight = true;
+  const seq = ++reqSeq;
   try {
     const res = await fetch('/api/herald');
-    if (res.ok) tidings.set(await res.json());
+    if (res.ok) {
+      const data = await res.json();
+      if (seq >= appliedSeq) {
+        appliedSeq = seq;
+        tidings.set(data);
+      }
+    }
   } catch {
-    // ignored
+    // handled elsewhere
+  } finally {
+    inFlight = false;
+    if (queued) {
+      queued = false;
+      void fetchTidings();
+    }
   }
 }

@@ -0,0 +1 @@
import"../chunks/DsnmJJEf.js";import{a4 as _e,az as Ee,a3 as Ce,b6 as xe,aS as De,w as te,ae as Se,ay as ce,ac as ue,a6 as ve,ad as Pe,a7 as Te,p as J,t as L,b as X,d as i,s as h,g as o,l as e,i as de,F as f,$ as be,_ as A,f as R,a0 as ae,a1 as qe,e as Qe,b7 as Ne}from"../chunks/CFIQ5hjj.js";import{d as oe,b as O,a as _,f as E,s as P,e as Fe,c as we}from"../chunks/ByVGy8qs.js";import{B as Ie,i as K}from"../chunks/537vdszY.js";import{s as Ae,a as Me}from"../chunks/DLS4WeON.js";import{e as W,i as U}from"../chunks/zKsXIgWY.js";import{a as re,r as fe}from"../chunks/DdAmTr_Q.js";import{s as se}from"../chunks/CQvy7xpd.js";import{p as Le}from"../chunks/BVGfD6Eb.js";import{p as pe}from"../chunks/eQSYeF-0.js";import{b as me}from"../chunks/jKnW4BRh.js";import{b as he}from"../chunks/CSXl7R3t.js";import{g as H}from"../chunks/DDgnzpUj.js";import{e as Ke,g as Oe,k as Re,h as Ge}from"../chunks/c59pmuMM.js";import{w as je}from"../chunks/Dv0Hwark.js";import{l as Be}from"../chunks/DJzzQicO.js";function ye(d,r,...s){var b=new Ie(d);_e(()=>{const n=r()??null;b.ensure(n,n&&(t=>n(t,...s)))},Ee)}function He(d,r){let s=null,b=te;var n;if(te){s=Pe;for(var t=Te(document.head);t!==null&&(t.nodeType!==Se||t.data!==d);)t=ce(t);if(t===null)ue(!1);else{var v=ce(t);t.remove(),ve(v)}}te||(n=document.head.appendChild(Ce()));try{_e(()=>r(n),xe|De)}finally{b&&(ue(!0),ve(s))}}const ze=!1,We=!1,Qt=Object.freeze(Object.defineProperty({__proto__:null,prerender:ze,ssr:We},Symbol.toStringTag,{value:"Module"}));var Ue=E('<span class="logo-text svelte-129hoe0">Fellowship</span>'),Ve=E('<span class="nav-label"> </span>'),Je=E('<a><span class="nav-icon svelte-129hoe0"> </span> <!></a>'),Xe=E('<aside><div class="sidebar-header svelte-129hoe0"><div class="logo-mark svelte-129hoe0">F</div> <!></div> <nav class="sidebar-nav svelte-129hoe0"></nav> <button class="sidebar-toggle svelte-129hoe0"> </button></aside>');function Ye(d,r){J(r,!0);let s=Le(r,"collapsed",15,!1);const b=[{path:"/command",label:"Command",icon:"⚔"},{path:"/herald",label:"Herald",icon:"📜"},{path:"/autopsies",label:"Autopsies",icon:"◆"},{path:"/timeline",label:"Timeline",icon:"▰"},{path:"/config",label:"Config",icon:"⚙"}];function n(k){return pe.url.pathname===k||pe.url.pathname.startsWith(k+"/")}var t=Xe();let v;var C=i(t),p=h(i(C),2);{var u=k=>{var x=Ue();_(k,x)};K(p,k=>{s()||k(u)})}o(C);var y=h(C,2);W(y,21,()=>b,U,(k,x)=>{var w=Je();let N;var l=i(w),F=i(l,!0);o(l);var I=h(l,2);{var a=m=>{var c=Ve(),S=i(c,!0);o(c),L(()=>P(S,e(x).label)),_(m,c)};K(I,m=>{s()||m(a)})}o(w),L(m=>{re(w,"href",e(x).path),N=se(w,1,"nav-item svelte-129hoe0",null,N,m),re(w,"title",s()?e(x).label:void 0),P(F,e(x).icon)},[()=>({active:n(e(x).path)})]),_(k,w)}),o(y);var g=h(y,2),D=i(g,!0);o(g),o(t),L(()=>{v=se(t,1,"sidebar svelte-129hoe0",null,v,{collapsed:s()}),P(D,s()?"›":"‹")}),O("click",g,()=>s(!s())),_(d,t),X()}oe(["click"]);var Ze=E('<div class="input-mode-header svelte-wh9uu8"> </div> <input class="search-input svelte-wh9uu8" type="text"/>',1),$e=E("<button> </button>"),et=E('<div class="category-header svelte-wh9uu8"> </div> <!>',1),tt=E('<div class="no-results svelte-wh9uu8">No matching commands</div>'),at=E('<input class="search-input svelte-wh9uu8" placeholder="Search commands..." type="text"/> <div class="action-list svelte-wh9uu8"><!> <!></div>',1),rt=E('<div class="overlay svelte-wh9uu8"><div class="panel svelte-wh9uu8"><!></div></div>');function st(d,r){J(r,!0);const s=[{label:"Spawn Quest",category:"Quest Control",action:()=>{f(t,"spawn-quest")},inputPlaceholder:"Enter task description..."},{label:"Spawn Scout",category:"Quest Control",action:()=>{f(t,"spawn-scout")},inputPlaceholder:"Enter question..."},{label:"Kill Quest",category:"Quest Control",action:()=>{f(t,"kill-quest")},inputPlaceholder:"Enter quest ID..."},{label:"Restart Quest",category:"Quest Control",action:()=>{f(t,"restart-quest")},inputPlaceholder:"Enter quest ID..."},{label:"Go to Command",category:"Navigation",action:()=>{H("/command"),r.onClose()}},{label:"Go to Herald",category:"Navigation",action:()=>{H("/herald"),r.onClose()}},{label:"Go to Autopsies",category:"Navigation",action:()=>{H("/autopsies"),r.onClose()}},{label:"Go to Timeline",category:"Navigation",action:()=>{H("/timeline"),r.onClose()}},{label:"Go to Config",category:"Navigation",action:()=>{H("/config"),r.onClose()}}];let b=A(""),n=A(0),t=A(null),v=A(""),C=A(void 0),p=A(void 0),u=ae(()=>e(b)?s.filter(a=>a.label.toLowerCase().includes(e(b).toLowerCase())):s),y=ae(()=>{const a=[],m=new Set;for(const c of e(u))m.has(c.category)||(m.add(c.category),a.push({category:c.category,items:[]})),a.find(S=>S.category===c.category).items.push(c);return a});function g(){return e(u)}function D(){const a=g();a[e(n)]&&a[e(n)].action()}async function k(){if(e(v).trim())try{e(t)==="spawn-quest"?await Ke(e(v).trim()):e(t)==="spawn-scout"?await Oe(e(v).trim()):e(t)==="kill-quest"?await Re(e(v).trim()):e(t)==="restart-quest"&&await Ge(e(v).trim()),f(t,null),f(v,""),r.onClose()}catch{}}function x(a){if(e(t)){a.key==="Escape"?(a.preventDefault(),f(t,null),f(v,""),e(C)?.focus()):a.key==="Enter"&&(a.preventDefault(),k());return}a.key==="Escape"?(a.preventDefault(),r.onClose()):a.key==="ArrowDown"?(a.preventDefault(),e(u).length>0&&f(n,(e(n)+1)%e(u).length)):a.key==="ArrowUp"?(a.preventDefault(),e(u).length>0&&f(n,(e(n)-1+e(u).length)%e(u).length)):a.key==="Enter"&&(a.preventDefault(),D())}de(()=>{e(b),f(n,0)}),de(()=>{e(t)&&e(p)&&e(p).focus()}),be(()=>{e(C)?.focus()});var w=rt(),N=i(w),l=i(N);{var F=a=>{var m=Ze(),c=R(m),S=i(c,!0);o(c);var T=h(c,2);fe(T),he(T,q=>f(p,q),()=>e(p)),L(q=>{P(S,e(t)==="spawn-quest"?"Spawn Quest":e(t)==="spawn-scout"?"Spawn Scout":e(t)==="kill-quest"?"Kill Quest":"Restart Quest"),re(T,"placeholder",q)},[()=>s.find(q=>q.label.toLowerCase().replace(/\s+/g,"-")===e(t)?.replace("spawn-","spawn-").replace("kill-","kill-").replace("restart-","restart-"))?.inputPlaceholder??"Enter value..."]),me(T,()=>e(v),q=>f(v,q)),_(a,m)},I=a=>{var m=at(),c=R(m);fe(c),he(c,Q=>f(C,Q),()=>e(C));var S=h(c,2),T=i(S);W(T,17,()=>e(y),U,(Q,M)=>{var z=et(),G=R(z),Y=i(G,!0);o(G);var Z=h(G,2);W(Z,17,()=>e(M).items,U,($,ee)=>{const le=ae(()=>e(u).indexOf(e(ee)));var j=$e();let ie;var ke=i(j,!0);o(j),L(()=>{ie=se(j,1,"action-item svelte-wh9uu8",null,ie,{selected:e(le)===e(n)}),P(ke,e(ee).label)}),Fe("mouseenter",j,()=>f(n,e(le),!0)),O("click",j,()=>e(ee).action()),_($,j)}),L(()=>P(Y,e(M).category)),_(Q,z)});var q=h(T,2);{var B=Q=>{var M=tt();_(Q,M)};K(q,Q=>{e(u).length===0&&Q(B)})}o(S),me(c,()=>e(b),Q=>f(b,Q)),_(a,m)};K(l,a=>{e(t)?a(F):a(I,-1)})}o(N),o(w),O("keydown",w,x),O("click",w,function(...a){r.onClose?.apply(this,a)}),O("click",N,a=>a.stopPropagation()),_(d,w),X()}oe(["keydown","click"]);const ne=je([]);async function ge(){try{const d=await fetch("/api/errors");d.ok&&ne.set(await d.json())}catch{}}async function ot(){try{(await fetch("/api/errors",{method:"DELETE"})).ok&&ne.set([])}catch{}}let V=null;function nt(){V||(ge(),V=Be.subscribe(d=>{d&&d.type==="error-logged"&&ge()}))}function lt(){V?.(),V=null}var it=E('<div class="error-item svelte-vde8u4"><span class="error-source svelte-vde8u4"> </span> <span class="error-handler svelte-vde8u4"> </span> <span class="error-message svelte-vde8u4"> </span> <span class="error-time svelte-vde8u4"> </span></div>'),ct=E('<div class="error-list svelte-vde8u4"></div> <button class="error-clear svelte-vde8u4">Clear all</button>',1),ut=E('<div class="error-banner svelte-vde8u4"><button class="error-summary svelte-vde8u4"><span class="error-icon svelte-vde8u4">!</span> <span class="error-count svelte-vde8u4"> </span> <span class="error-toggle svelte-vde8u4"> </span></button> <!></div>');function vt(d,r){J(r,!0);let s=A(!1);function b(p){const u=new Date(p);if(isNaN(u.getTime()))return"?";const y=Date.now()-u.getTime(),g=Math.floor(y/6e4);if(g<1)return"now";if(g<60)return`${g}m ago`;const D=Math.floor(g/60);return D<24?`${D}h ago`:`${Math.floor(D/24)}d ago`}function n(p){switch(p){case"api":return"API";case"websocket":return"WS";case"polling":return"Poll";default:return p}}var t=we(),v=R(t);{var C=p=>{var u=ut(),y=i(u),g=h(i(y),2),D=i(g);o(g);var k=h(g,2),x=i(k,!0);o(k),o(y);var w=h(y,2);{var N=l=>{var F=ct(),I=R(F);W(I,21,()=>r.errors,U,(m,c)=>{var S=it(),T=i(S),q=i(T,!0);o(T);var B=h(T,2),Q=i(B,!0);o(B);var M=h(B,2),z=i(M,!0);o(M);var G=h(M,2),Y=i(G,!0);o(G),o(S),L((Z,$)=>{P(q,Z),P(Q,e(c).handler),P(z,e(c).message),P(Y,$)},[()=>n(e(c).source),()=>b(e(c).timestamp)]),_(m,S)}),o(I);var a=h(I,2);O("click",a,function(...m){ot?.apply(this,m)}),_(l,F)};K(w,l=>{e(s)&&l(N)})}o(u),L(()=>{P(D,`${r.errors.length??""} server error${r.errors.length!==1?"s":""}`),P(x,e(s)?"▾":"▸")}),O("click",y,()=>f(s,!e(s))),_(p,u)};K(v,p=>{r.errors.length>0&&p(C)})}_(d,t),X()}oe(["click"]);var dt=E('<div class="error-banner-wrapper svelte-w96i92"><!></div>'),ft=E('<div class="shell svelte-w96i92"><!> <main class="main-content svelte-w96i92"><!> <!></main></div> <!>',1);function pt(d,r){J(r,!0);const s=()=>Me(ne,"$errors",b),[b,n]=Ae();let t=A(!1),v=A(!1);function C(l){l.key.toLowerCase()==="k"&&(l.metaKey||l.ctrlKey)&&(l.preventDefault(),f(v,!e(v)))}be(()=>{window.addEventListener("keydown",C),nt()}),qe(()=>{window.removeEventListener("keydown",C),lt()});var p=ft(),u=R(p),y=i(u);Ye(y,{get collapsed(){return e(t)},set collapsed(l){f(t,l,!0)}});var g=h(y,2),D=i(g);{var k=l=>{var F=dt(),I=i(F);vt(I,{get errors(){return s()}}),o(F),_(l,F)};K(D,l=>{s().length>0&&l(k)})}var x=h(D,2);ye(x,()=>r.children),o(g),o(u);var w=h(u,2);{var N=l=>{st(l,{onClose:()=>f(v,!1)})};K(w,l=>{e(v)&&l(N)})}_(d,p),X(),n()}function Nt(d,r){He("12qhfyh",s=>{Qe(()=>{Ne.title="Fellowship Dashboard"})}),pt(d,{children:(s,b)=>{var n=we(),t=R(n);ye(t,()=>r.children),_(s,n)},$$slots:{default:!0}})}export{Nt as component,Qt as universal};
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Ignore Cmd/Ctrl+K while focus is inside an editable control.

The window-level shortcut toggles the palette even when the active element is an input, textarea, select, or contenteditable node. That interrupts typing on the Config page and can close the palette while the user is entering command arguments. Please fix this in the source layout and rebuild the bundle.


⚠️ Potential issue | 🟡 Minor

The current matcher is substring-only, not fuzzy.

The palette filter uses includes(...), so queries like sq will not find “Spawn Quest”. If fuzzy search is part of the acceptance criteria for this PR, the source command-palette matcher needs a scorer or at least ordered-subsequence matching before release.

@@ -0,0 +1 @@
import"../chunks/DsnmJJEf.js";import{d as a,l as e,g as t,s as r,t as x,p as Ge,i as fe,F as p,b as Oe,a0 as M,_ as N,f as he}from"../chunks/CFIQ5hjj.js";import{s as Ie,a as ie}from"../chunks/DLS4WeON.js";import{s as c,a as o,t as ye,f as u,d as Ne,b as D}from"../chunks/ByVGy8qs.js";import{i as S}from"../chunks/537vdszY.js";import{e as Q,i as ve}from"../chunks/zKsXIgWY.js";import{a as Pe}from"../chunks/DdAmTr_Q.js";import{s as b}from"../chunks/CQvy7xpd.js";import{p as We}from"../chunks/eQSYeF-0.js";import{d as Ae,q as Be}from"../chunks/4mhR39rs.js";import{c as De,d as Me}from"../chunks/c59pmuMM.js";import{G as Qe}from"../chunks/C19yGuqu.js";import{H as Ue,t as je}from"../chunks/DM7OFrYY.js";var ze=u('<div class="errand-item svelte-6y5tqw"><div><!></div> <span> </span></div>'),Je=u('<div class="errand-list svelte-6y5tqw"></div>');function Ke(U,j){var P=Je();Q(P,21,()=>j.errands,W=>W.id,(W,E)=>{var F=ze(),O=a(F),q=a(O);{var v=_=>{var i=ye("✓");o(_,i)},$=_=>{var i=ye("●");o(_,i)};S(q,_=>{e(E).status==="done"?_(v):e(E).status==="active"&&_($,1)})}t(O);var A=r(O,2),w=a(A,!0);t(A),t(F),x(()=>{b(O,1,`errand-check ${e(E).status??""}`,"svelte-6y5tqw"),b(A,1,`errand-text ${e(E).status??""}`,"svelte-6y5tqw"),c(w,e(E).description)}),o(W,F)}),t(P),o(U,P)}var Ve=u('<span></span> <span class="health-label svelte-1ysin3l"> </span>',1),Xe=u('<div><span class="phase-step-label"> </span></div>'),Ye=u('<div class="title-row svelte-1ysin3l"><h1 class="svelte-1ysin3l"> </h1> <span class="phase-badge svelte-1ysin3l"> </span> <!> <!></div> <div class="phase-steps svelte-1ysin3l"></div>',1),Ze=u('<span class="tab-badge svelte-1ysin3l"> </span>'),es=u('<div class="file-item svelte-1ysin3l"> </div>'),ss=u('<div class="files-list svelte-1ysin3l"></div>'),ts=u('<div class="tome-entry svelte-1ysin3l"><span> </span> <span class="tome-phase svelte-1ysin3l"> </span> <span class="tome-time svelte-1ysin3l"> </span></div>'),as=u('<div class="tome-content svelte-1ysin3l"><h3 class="svelte-1ysin3l">Gate History</h3> <!></div>'),ls=u('<div class="logs-placeholder"><p class="empty svelte-1ysin3l">Quest logs not yet available. Raw output logging is a future enhancement.</p></div>'),rs=u('<p class="empty svelte-1ysin3l">No data available</p>'),is=u('<div class="meta-row svelte-1ysin3l"><span>Eagles</span> <span> </span></div>'),vs=u('<div class="detail-meta svelte-1ysin3l"><div class="meta-card svelte-1ysin3l"><div class="meta-title svelte-1ysin3l">Metadata</div> <div class="meta-row svelte-1ysin3l"><span>Branch</span> <span class="meta-value mono svelte-1ysin3l"> </span></div> <div class="meta-row svelte-1ysin3l"><span>Worktree</span> <span class="meta-value mono truncate svelte-1ysin3l"> </span></div> <div class="meta-row svelte-1ysin3l"><span>Status</span> <span class="meta-value svelte-1ysin3l"> </span></div> <!></div></div>'),ns=u('<div class="detail-view svelte-1ysin3l"><div class="detail-header svelte-1ysin3l"><div class="breadcrumb svelte-1ysin3l"><a href="/command" class="svelte-1ysin3l">Command</a> <span class="sep svelte-1ysin3l">/</span> <span> </span></div> <!></div> <div class="tabs svelte-1ysin3l"><button>Errands <!></button> <button>Files</button> <button>Tome</button> <button>Herald</button> <button>Logs</button></div> <div class="detail-body svelte-1ysin3l"><div class="detail-content svelte-1ysin3l"><!></div> <!></div></div>');function ws(U,j){Ge(j,!0);const P=()=>ie(Ae,"$dashboardStatus",F),W=()=>ie(Be,"$questHealths",F),E=()=>ie(je,"$tidings",F),[F,O]=Ie();let q=M(()=>decodeURIComponent(We.params.id)),v=M(()=>P()?.quests.find(s=>s.name===e(q))),$=M(()=>W().find(s=>s.name===e(q))),A=M(()=>E().filter(s=>s.quest===e(q))),w=N(null),_=N(null),i=N("errands");const z=["Onboard","Research","Plan","Implement","Review","Complete"];let J=N(!1),K=N(!1),V=N(!1);fe(()=>{e(q),p(J,!1),p(K,!1),p(w,null),p(_,null)}),fe(()=>{if(e(v)&&!e(J)&&!e(K)&&!e(V)){p(V,!0);const s=e(v).worktree;Promise.all([De(s),Me(e(q))]).then(([l,n])=>{p(w,l,!0),p(_,n,!0),p(J,!0)}).catch(()=>{p(K,!0)}).finally(()=>{p(V,!1)})}});function be(s){if(!e(v))return"pending";const l=z.indexOf(e(v).phase);if(l===-1)return"pending";const n=z.indexOf(s);return n<l?"done":n===l?"current":"pending"}var X=ns(),Y=a(X),Z=a(Y),ne=r(a(Z),4),ge=a(ne,!0);t(ne),t(Z);var xe=r(Z,2);{var we=s=>{var l=Ye(),n=he(l),d=a(n),m=a(d,!0);t(d);var h=r(d,2),g=a(h,!0);t(h);var H=r(h,2);{var L=y=>{Qe(y,{get worktree(){return e(v).worktree}})};S(H,y=>{e(v).gate_pending&&y(L)})}var R=r(H,2);{var T=y=>{var C=Ve(),f=he(C),k=r(f,2),G=a(k,!0);t(k),x(()=>{b(f,1,`health-dot ${e($).health??""}`,"svelte-1ysin3l"),c(G,e($).health)}),o(y,C)};S(R,y=>{e($)&&y(T)})}t(n);var I=r(n,2);Q(I,21,()=>z,ve,(y,C)=>{var f=Xe(),k=a(f),G=a(k,!0);t(k),t(f),x(re=>{b(f,1,`phase-step ${re??""}`,"svelte-1ysin3l"),c(G,e(C))},[()=>be(e(C))]),o(y,f)}),t(I),x(()=>{c(m,e(v).name),c(g,e(v).phase)}),o(s,l)};S(xe,s=>{e(v)&&s(we)})}t(Y);var ee=r(Y,2),B=a(ee);let oe;var ke=r(a(B));{var qe=s=>{var l=Ze(),n=a(l);t(l),x(d=>c(n,`${d??""}/${e(w).items.length??""}`),[()=>e(w).items.filter(d=>d.status==="done").length]),o(s,l)};S(ke,s=>{e(w)&&s(qe)})}t(B);var se=r(B,2);let de;var te=r(se,2);let pe;var ae=r(te,2);let ce;var ue=r(ae,2);let _e;t(ee);var me=r(ee,2),le=a(me),$e=a(le);{var He=s=>{Ke(s,{get errands(){return e(w).items}})},Se=s=>{var l=ss();Q(l,21,()=>e(_).files_touched,ve,(n,d)=>{var m=es(),h=a(m,!0);t(m),x(()=>c(h,e(d))),o(n,m)}),t(l),o(s,l)},Ee=s=>{var l=as(),n=r(a(l),2);Q(n,17,()=>e(_).gate_history,ve,(d,m)=>{var h=ts(),g=a(h),H=a(g,!0);t(g);var L=r(g,2),R=a(L,!0);t(L);var T=r(L,2),I=a(T,!0);t(T),t(h),x(()=>{b(g,1,`tome-action ${e(m).action??""}`,"svelte-1ysin3l"),c(H,e(m).action),c(R,e(m).phase),c(I,e(m).timestamp)}),o(d,h)}),t(l),o(s,l)},Fe=s=>{Ue(s,{get tidings(){return e(A)},limit:50})},Le=s=>{var l=ls();o(s,l)},Re=s=>{var l=rs();o(s,l)};S($e,s=>{e(i)==="errands"&&e(w)?s(He):e(i)==="files"&&e(_)?s(Se,1):e(i)==="tome"&&e(_)?s(Ee,2):e(i)==="herald"?s(Fe,3):e(i)==="logs"?s(Le,4):s(Re,-1)})}t(le);var Te=r(le,2);{var Ce=s=>{var l=vs(),n=a(l),d=r(a(n),2),m=r(a(d),2),h=a(m,!0);t(m),t(d);var g=r(d,2),H=r(a(g),2),L=a(H,!0);t(H),t(g);var R=r(g,2),T=r(a(R),2),I=a(T,!0);t(T),t(R);var y=r(R,2);{var C=f=>{var k=is(),G=r(a(k),2),re=a(G,!0);t(G),t(k),x(()=>{b(G,1,`meta-value ${e($).health??""}`,"svelte-1ysin3l"),c(re,e($).health)}),o(f,k)};S(y,f=>{e($)&&f(C)})}t(n),t(l),x(f=>{c(h,f),Pe(H,"title",e(v).worktree),c(L,e(v).worktree),c(I,e(v).status)},[()=>e(v).worktree.replace(/\\/g,"/").split("/").pop()||e(v).worktree]),o(s,l)};S(Te,s=>{e(v)&&s(Ce)})}t(me),t(X),x(()=>{c(ge,e(q)),oe=b(B,1,"tab svelte-1ysin3l",null,oe,{active:e(i)==="errands"}),de=b(se,1,"tab svelte-1ysin3l",null,de,{active:e(i)==="files"}),pe=b(te,1,"tab svelte-1ysin3l",null,pe,{active:e(i)==="tome"}),ce=b(ae,1,"tab svelte-1ysin3l",null,ce,{active:e(i)==="herald"}),_e=b(ue,1,"tab svelte-1ysin3l",null,_e,{active:e(i)==="logs"})}),D("click",B,()=>p(i,"errands")),D("click",se,()=>p(i,"files")),D("click",te,()=>p(i,"tome")),D("click",ae,()=>p(i,"herald")),D("click",ue,()=>p(i,"logs")),o(U,X),Oe(),O()}Ne(["click"]);export{ws as component};
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Drop stale quest-detail responses before writing local state.

The Promise.all([De(s), Me(e(q))]) result is committed into w/_ unconditionally. If the user switches quests before it resolves, the older response can overwrite the new quest's detail pane, and the J guard then suppresses any corrective refetch until the id changes.


⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

python - <<'PY'
from pathlib import Path

checks = [
    (
        "runtime param decoding",
        Path("cli/internal/dashboard/static/_app/immutable/chunks/DDgnzpUj.js"),
        ["decodeURIComponent(e[t])", "params:wt("],
    ),
    (
        "quest route extra decode",
        Path("cli/internal/dashboard/static/_app/immutable/nodes/7.BQnCdFNo.js"),
        ["decodeURIComponent(We.params.id)"],
    ),
]

for label, path, needles in checks:
    text = path.read_text()
    print(f"== {label}: {path} ==")
    for needle in needles:
        idx = text.find(needle)
        print(f"-- {needle} --")
        if idx == -1:
            print("NOT FOUND\n")
            continue
        start = max(0, idx - 120)
        end = min(len(text), idx + len(needle) + 120)
        print(text[start:end])
        print()
PY

Repository: justinjdev/fellowship

Length of output: 1134


Remove decodeURIComponent() call; params.id is already decoded by the runtime.

The SvelteKit runtime in cli/internal/dashboard/static/_app/immutable/chunks/DDgnzpUj.js runs decodeURIComponent() on all route params via the wt() function before the route receives them. Calling decodeURIComponent(We.params.id) again in the quest route causes double-decoding, which will throw for ids containing % and corrupt any id with percent-escaped bytes. Remove the extra decode and rebuild the bundle.

Comment on lines +7 to +31
export async function fetchErrors() {
try {
const res = await fetch('/api/errors');
if (res.ok) errors.set(await res.json());
} catch { /* offline */ }
}

export async function clearErrors() {
try {
const res = await fetch('/api/errors', { method: 'DELETE' });
if (res.ok) errors.set([]);
} catch { /* offline */ }
}

let unsubscribe: (() => void) | null = null;

export function startErrorPolling() {
if (unsubscribe) return;
fetchErrors();
unsubscribe = lastEvent.subscribe((event) => {
if (!event) return;
if (event.type === 'error-logged') {
fetchErrors();
}
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Serialize /api/errors refreshes before mutating the store.

The GET on Line 9 can run from the initial load on Line 25 and again from Line 29, so a slower response can land after the DELETE on Line 16 and make cleared errors reappear. Please gate errors.set(...) behind a request token or abort stale GETs.

🛠️ One way to make the updates monotonic
 export const errors = writable<DashboardError[]>([]);
+let refreshToken = 0;
 
 export async function fetchErrors() {
+	const token = ++refreshToken;
 	try {
 		const res = await fetch('/api/errors');
-		if (res.ok) errors.set(await res.json());
+		if (!res.ok) return;
+		const next = await res.json();
+		if (token === refreshToken) errors.set(next);
 	} catch { /* offline */ }
 }
 
 export async function clearErrors() {
+	refreshToken++;
 	try {
 		const res = await fetch('/api/errors', { method: 'DELETE' });
 		if (res.ok) errors.set([]);
 	} catch { /* offline */ }
 }

Comment on lines +49 to +58
let unsubscribe: (() => void) | null = null;
let refreshInFlight: Promise<void> | null = null;

function scheduleRefresh() {
if (refreshInFlight) return refreshInFlight;
refreshInFlight = refreshAll().finally(() => {
refreshInFlight = null;
});
return refreshInFlight;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Queue one trailing refresh instead of dropping mid-flight WS events.

While refreshInFlight is set, scheduleRefresh() just returns the current promise. If another event lands after the active fetches have already read old state, no follow-up refresh runs and these stores can stay stale until a later event.

♻️ Suggested fix
 let unsubscribe: (() => void) | null = null;
 let refreshInFlight: Promise<void> | null = null;
+let refreshQueued = false;
 
 function scheduleRefresh() {
-	if (refreshInFlight) return refreshInFlight;
-	refreshInFlight = refreshAll().finally(() => {
+	if (refreshInFlight) {
+		refreshQueued = true;
+		return refreshInFlight;
+	}
+	refreshInFlight = (async () => {
+		do {
+			refreshQueued = false;
+			await refreshAll();
+		} while (refreshQueued);
+	})().finally(() => {
 		refreshInFlight = null;
 	});
 	return refreshInFlight;
 }

Comment on lines +30 to +32
function getScope(key: string): 'project' | 'global' {
return key in projectConfig ? 'project' : 'global';
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

This scope heuristic cannot create the first project override.

Line 31 returns 'global' for every known key that is not already present in projectConfig, so a fresh repo can never create the first project-scoped value from this UI. Since saveConfig(...) and the backend already support both scopes, this needs an explicit scope chooser instead of inferring scope purely from current presence.

Comment on lines +105 to +140
{#each knownKeys as setting}
{@const val = getEffectiveValue(setting.key)}
{@const scope = getScope(setting.key)}
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">{setting.label}</span>
<span class="setting-scope">{scope}</span>
</div>
<div class="setting-control">
{#if setting.type === 'boolean'}
<button
class="toggle-btn"
class:active={!!val}
disabled={saving === setting.key}
onclick={() => handleToggle(setting.key)}
>
{val ? 'ON' : 'OFF'}
</button>
{:else if setting.type === 'text'}
<input
type="text"
class="setting-input"
value={val ?? ''}
disabled={saving === setting.key}
onblur={(e) => handleText(setting.key, e)}
/>
{:else if setting.type === 'select'}
<select
class="setting-select"
value={val ?? setting.options?.[0]}
disabled={saving === setting.key}
onchange={(e) => handleSelect(setting.key, e)}
>
{#each setting.options ?? [] as opt}
<option value={opt}>{opt}</option>
{/each}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Give each setting control an accessible name.

The visible setting text is not associated with the button/input/select controls, so screen readers will announce the text/select fields as unlabeled and the toggle as just “ON/OFF”. That makes the Config view much harder to operate with assistive tech.

♿ Minimal accessibility fix
 					{`#each` knownKeys as setting}
 						{`@const` val = getEffectiveValue(setting.key)}
 						{`@const` scope = getScope(setting.key)}
+						{`@const` labelId = `setting-${setting.key}-label`}
 						<div class="setting-row">
 							<div class="setting-info">
-								<span class="setting-label">{setting.label}</span>
+								<span class="setting-label" id={labelId}>{setting.label}</span>
 								<span class="setting-scope">{scope}</span>
 							</div>
 							<div class="setting-control">
 								{`#if` setting.type === 'boolean'}
 									<button
 										class="toggle-btn"
 										class:active={!!val}
+										aria-labelledby={labelId}
+										aria-pressed={!!val}
 										disabled={saving === setting.key}
 										onclick={() => handleToggle(setting.key)}
 									>
 										{val ? 'ON' : 'OFF'}
 									</button>
 								{:else if setting.type === 'text'}
 									<input
 										type="text"
 										class="setting-input"
+										aria-labelledby={labelId}
 										value={val ?? ''}
 										disabled={saving === setting.key}
 										onblur={(e) => handleText(setting.key, e)}
 									/>
 								{:else if setting.type === 'select'}
 									<select
 										class="setting-select"
+										aria-labelledby={labelId}
 										value={val ?? setting.options?.[0]}
 										disabled={saving === setting.key}
 										onchange={(e) => handleSelect(setting.key, e)}
 									>

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant