Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
d91012d
feat: add WebSocket infrastructure for real-time dashboard updates
justinjdev Mar 11, 2026
f179fd6
feat: add command queue for dashboard-initiated actions
justinjdev Mar 11, 2026
3d1c66f
feat: add REST endpoints for quest/scout spawn, kill, restart commands
justinjdev Mar 11, 2026
959b970
feat(dashboard): scaffold SvelteKit project with adapter-static
justinjdev Mar 11, 2026
af59732
feat: add REST endpoints for autopsies, tome, and config
justinjdev Mar 11, 2026
6826df5
feat(dashboard): add design system tokens and global styles
justinjdev Mar 11, 2026
8d73d53
feat: add SPA fallback routing for client-side navigation
justinjdev Mar 11, 2026
3d8d88d
test: add unit tests for WebSocket Hub and command queue
justinjdev Mar 11, 2026
78f1afb
feat(dashboard): add shell layout with collapsible sidebar and routing
justinjdev Mar 11, 2026
88298a7
feat(dashboard): add WebSocket store with exponential backoff reconnect
justinjdev Mar 11, 2026
c051e3e
feat(dashboard): add type definitions and quest state store with WS-d…
justinjdev Mar 11, 2026
d35d129
feat(dashboard): add herald event store
justinjdev Mar 11, 2026
ee09bc2
feat(dashboard): add API helper with all REST endpoints
justinjdev Mar 11, 2026
76d8eda
feat(dashboard): add core UI components (stats, phase timeline, gate …
justinjdev Mar 11, 2026
d55914c
feat(dashboard): add QuestCard component with gate actions and health…
justinjdev Mar 11, 2026
4366974
feat(dashboard): implement Command view with stats, quest grid, and h…
justinjdev Mar 11, 2026
8e50d44
feat(dashboard): implement Quest Detail view with errands, files, tom…
justinjdev Mar 11, 2026
8db1431
feat(dashboard): add command palette with fuzzy search and keyboard n…
justinjdev Mar 11, 2026
b8d919b
feat(dashboard): implement Herald view with filtering
justinjdev Mar 11, 2026
0efa51e
feat(dashboard): implement Autopsies view with search and drill-in
justinjdev Mar 11, 2026
e717988
feat(dashboard): implement Timeline view with Gantt chart
justinjdev Mar 11, 2026
25ddcca
feat(dashboard): implement Config view with editable settings
justinjdev Mar 11, 2026
fbe3587
feat(dashboard): add build script and embed Svelte output in Go binary
justinjdev Mar 11, 2026
22d69b2
fix(dashboard): embed _app directory and fix SPA static file serving
justinjdev Mar 11, 2026
ca75c15
fix(dashboard): address PR review feedback — error handling, concurre…
justinjdev Mar 11, 2026
f07b823
fix(dashboard): address PR review feedback round 2
justinjdev Mar 11, 2026
e1245a9
fix(dashboard): populate Timestamp on all WSEvent broadcasts
justinjdev Mar 11, 2026
3ce18d3
fix(dashboard): address PR review feedback round 3
justinjdev Mar 12, 2026
25f4871
fix(dashboard): address local CodeRabbit review findings
justinjdev Mar 12, 2026
7bc5960
chore: add local artifacts to .gitignore
justinjdev Mar 12, 2026
5d07e72
fix(dashboard): address PR review feedback round 6
justinjdev Mar 12, 2026
30e9673
fix: port dashboard data/commands to SQLite after rebase
justinjdev Mar 21, 2026
62d5cc1
fix: remove accidentally committed local files
justinjdev Mar 21, 2026
8bfd4d8
fix(docs): use pinned CLI path in rekindle.md and fellowship SKILL.md…
justinjdev Mar 21, 2026
ef69faa
fix(dashboard): frontend error handling from CodeRabbit review (#83)
justinjdev Mar 21, 2026
964c5c3
fix(dashboard): complete stale socket guards, cross-platform shortcut…
justinjdev Mar 21, 2026
9fd0a73
fix(dashboard): address Go backend CodeRabbit review comments (#85)
justinjdev Mar 21, 2026
4c59fcd
feat(dashboard): add error logging to SQLite database (#87)
justinjdev Mar 21, 2026
7019cba
fix: review round 1 - correctness
justinjdev Mar 21, 2026
942726a
fix: review round 2 - robustness
justinjdev Mar 21, 2026
cb7e33b
fix: review round 3 - architecture
justinjdev Mar 21, 2026
4b794b6
fix: review round 4 - coherence
justinjdev Mar 21, 2026
278095a
fix(dashboard): handle config fetch failure on config page
justinjdev Mar 21, 2026
19b5798
fix(dashboard): address latest CodeRabbit review comments
justinjdev Mar 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .claude/scheduled_tasks.lock
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"sessionId":"0bcd5a26-d987-422f-baaa-4fab1b70762e","pid":58700,"acquiredAt":1773024431187}
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,9 @@ docs/
.worktrees/
.fellowship/
cli/fellowship
.DS_Store
.fastembed_cache/
.shire/
.superpowers/
scripts/
shire.toml
10 changes: 10 additions & 0 deletions .mcp.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"mcpServers": {
"shire": {
"args": [
"serve"
],
"command": "shire"
}
}
}
6 changes: 5 additions & 1 deletion cli/cmd/fellowship/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -913,7 +913,11 @@ func runDashboard(d *db.DB, args []string) int {
poll := fs.Int("poll", 5, "Poll interval in seconds")
fs.Parse(args)

srv := dashboard.NewServer(d, *poll)
srv, err := dashboard.NewServer(d, gitRootOrCwd(), *poll)
if err != nil {
fmt.Fprintf(os.Stderr, "dashboard: %v\n", err)
return 1
}

addr := fmt.Sprintf("localhost:%d", *port)
url := fmt.Sprintf("http://%s", addr)
Expand Down
5 changes: 4 additions & 1 deletion cli/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ module github.com/justinjdev/fellowship/cli

go 1.25.5

require zombiezen.com/go/sqlite v1.4.2
require (
github.com/gorilla/websocket v1.5.3
zombiezen.com/go/sqlite v1.4.2
)

require (
github.com/dustin/go-humanize v1.0.1 // indirect
Expand Down
2 changes: 2 additions & 0 deletions cli/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
Expand Down
120 changes: 120 additions & 0 deletions cli/internal/dashboard/commands.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
package dashboard

import (
"encoding/json"
"net/http"
"time"
)

type SpawnQuestRequest struct {
Task string `json:"task"`
Branch string `json:"branch,omitempty"`
Company string `json:"company,omitempty"`
}

type SpawnScoutRequest struct {
Question string `json:"question"`
}

type QuestIDRequest struct {
QuestID string `json:"quest_id"`
}

type QueuedResponse struct {
Queued bool `json:"queued"`
CommandID string `json:"command_id"`
}

func (s *Server) handleSpawnQuest(w http.ResponseWriter, r *http.Request) {
var req SpawnQuestRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
if req.Task == "" {
http.Error(w, "task is required", http.StatusBadRequest)
return
}
params, _ := json.Marshal(req)
cmd, err := EnqueueCommand(s.gitRoot, ActionSpawnQuest, params)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
s.hub.Broadcast(WSEvent{Type: "command-queued", CommandID: cmd.ID, Timestamp: time.Now().Unix()})
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(QueuedResponse{Queued: true, CommandID: cmd.ID})
}

func (s *Server) handleSpawnScout(w http.ResponseWriter, r *http.Request) {
var req SpawnScoutRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
if req.Question == "" {
http.Error(w, "question is required", http.StatusBadRequest)
return
}
params, _ := json.Marshal(req)
cmd, err := EnqueueCommand(s.gitRoot, ActionSpawnScout, params)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
s.hub.Broadcast(WSEvent{Type: "command-queued", CommandID: cmd.ID, Timestamp: time.Now().Unix()})
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(QueuedResponse{Queued: true, CommandID: cmd.ID})
}

func (s *Server) handleKillQuest(w http.ResponseWriter, r *http.Request) {
var req QuestIDRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
if req.QuestID == "" {
http.Error(w, "quest_id is required", http.StatusBadRequest)
return
}
params, _ := json.Marshal(req)
cmd, err := EnqueueCommand(s.gitRoot, ActionKillQuest, params)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
s.hub.Broadcast(WSEvent{Type: "command-queued", CommandID: cmd.ID, Timestamp: time.Now().Unix()})
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(QueuedResponse{Queued: true, CommandID: cmd.ID})
}

func (s *Server) handleRestartQuest(w http.ResponseWriter, r *http.Request) {
var req QuestIDRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
if req.QuestID == "" {
http.Error(w, "quest_id is required", http.StatusBadRequest)
return
}
params, _ := json.Marshal(req)
cmd, err := EnqueueCommand(s.gitRoot, ActionRestartQuest, params)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
s.hub.Broadcast(WSEvent{Type: "command-queued", CommandID: cmd.ID, Timestamp: time.Now().Unix()})
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(QueuedResponse{Queued: true, CommandID: cmd.ID})
}

func (s *Server) handleCommands(w http.ResponseWriter, r *http.Request) {
q, err := LoadCommandQueue(s.gitRoot)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(q.Commands)
}
186 changes: 186 additions & 0 deletions cli/internal/dashboard/data.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
package dashboard

import (
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/justinjdev/fellowship/cli/internal/autopsy"
"github.com/justinjdev/fellowship/cli/internal/db"
"github.com/justinjdev/fellowship/cli/internal/tome"
)

// GET /api/autopsies — list all autopsy records
// GET /api/autopsies/<filename> — single autopsy detail
func (s *Server) handleAutopsies(w http.ResponseWriter, r *http.Request) {
suffix := strings.TrimPrefix(r.URL.Path, "/api/autopsies")
suffix = strings.TrimPrefix(suffix, "/")

// 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
})
Comment on lines +22 to +28
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.


if err != nil {
s.logError("api", "handleAutopsies", err.Error())
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode([]interface{}{})
return
}
Comment on lines +30 to +35
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

if suffix != "" {
// Filter to matching record by quest name
for _, r := range records {
if r.Quest == suffix {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(r)
return
}
}
http.Error(w, "autopsy not found", http.StatusNotFound)
return
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if records == nil {
records = []autopsy.Autopsy{}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(records)
}

// GET /api/tome/<questName> — quest tome (CV)
func (s *Server) handleTome(w http.ResponseWriter, r *http.Request) {
questName := strings.TrimPrefix(r.URL.Path, "/api/tome/")
if questName == "" {
http.Error(w, "quest name required", http.StatusBadRequest)
return
}

var t *tome.QuestTome
err := s.db.WithConn(context.Background(), func(conn *db.Conn) error {
var loadErr error
t, loadErr = tome.Load(conn, questName)
return loadErr
})
if err != nil || t == nil {
if err != nil {
s.logError("api", "handleTome", err.Error())
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"quest_name": questName,
"phases_completed": []interface{}{},
"gate_history": []interface{}{},
"files_touched": []string{},
})
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(t)
}

// GET /api/config — read fellowship config
func (s *Server) handleConfigRead(w http.ResponseWriter, r *http.Request) {
result := map[string]interface{}{
"global": nil,
"project": nil,
}

if home, err := os.UserHomeDir(); err == nil {
globalPath := filepath.Join(home, ".claude", "fellowship.json")
if data, err := os.ReadFile(globalPath); err == nil {
var global interface{}
if json.Unmarshal(data, &global) == nil {
result["global"] = global
}
}
} // silently skip global config if home directory unavailable

projectPath := filepath.Join(s.gitRoot, ".fellowship", "config.json")
if data, err := os.ReadFile(projectPath); err == nil {
var project interface{}
if json.Unmarshal(data, &project) == nil {
result["project"] = project
}
}

w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(result)
}

type ConfigWriteRequest struct {
Key string `json:"key"`
Value interface{} `json:"value"`
Scope string `json:"scope"`
}

func (s *Server) handleConfigWrite(w http.ResponseWriter, r *http.Request) {
var req ConfigWriteRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
if req.Key == "" {
http.Error(w, "key is required", http.StatusBadRequest)
return
}

s.configMu.Lock()
defer s.configMu.Unlock()

var configPath string
switch req.Scope {
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")
Comment on lines +137 to +143
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

case "project":
configPath = filepath.Join(s.gitRoot, ".fellowship", "config.json")
default:
http.Error(w, "scope must be 'global' or 'project'", http.StatusBadRequest)
return
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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
}

existing[req.Key] = req.Value

if err := os.MkdirAll(filepath.Dir(configPath), 0755); err != nil {
http.Error(w, "failed to create config directory", http.StatusInternalServerError)
return
}
data, err := json.MarshalIndent(existing, "", " ")
if err != nil {
http.Error(w, "failed to marshal config", http.StatusInternalServerError)
return
}
tmp := fmt.Sprintf("%s.tmp.%d", configPath, os.Getpid())
if err := os.WriteFile(tmp, data, 0644); err != nil {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
http.Error(w, "failed to write config", http.StatusInternalServerError)
return
}
if err := os.Rename(tmp, configPath); err != nil {
os.Remove(tmp) // best-effort cleanup
Comment thread
coderabbitai[bot] marked this conversation as resolved.
http.Error(w, "failed to save config", http.StatusInternalServerError)
return
}

w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]bool{"ok": true})
}
2 changes: 1 addition & 1 deletion cli/internal/dashboard/embed.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@ package dashboard

import "embed"

//go:embed static
//go:embed all:static
var staticFiles embed.FS
Loading
Loading