Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
a27e3a7
fix: improve light theme color contrast for WCAG AA compliance
justinjdev Mar 21, 2026
81b05fc
Merge pull request #81 from justinjdev/fix/light-theme-a11y
justinjdev Mar 21, 2026
1703d71
feat: add WebSocket infrastructure for real-time dashboard updates
justinjdev Mar 11, 2026
f61d284
feat: add command queue for dashboard-initiated actions
justinjdev Mar 11, 2026
d590205
feat: add REST endpoints for quest/scout spawn, kill, restart commands
justinjdev Mar 11, 2026
5d42e42
feat(dashboard): scaffold SvelteKit project with adapter-static
justinjdev Mar 11, 2026
582f4c9
feat: add REST endpoints for autopsies, tome, and config
justinjdev Mar 11, 2026
25f267c
feat(dashboard): add design system tokens and global styles
justinjdev Mar 11, 2026
ae4087d
feat: add SPA fallback routing for client-side navigation
justinjdev Mar 11, 2026
f4e9cb8
test: add unit tests for WebSocket Hub and command queue
justinjdev Mar 11, 2026
b1bf023
feat(dashboard): add shell layout with collapsible sidebar and routing
justinjdev Mar 11, 2026
eadeca3
feat(dashboard): add WebSocket store with exponential backoff reconnect
justinjdev Mar 11, 2026
6b2def6
feat(dashboard): add type definitions and quest state store with WS-d…
justinjdev Mar 11, 2026
1c1b4fd
feat(dashboard): add herald event store
justinjdev Mar 11, 2026
894c09b
feat(dashboard): add API helper with all REST endpoints
justinjdev Mar 11, 2026
c0ac590
feat(dashboard): add core UI components (stats, phase timeline, gate …
justinjdev Mar 11, 2026
197c51a
feat(dashboard): add QuestCard component with gate actions and health…
justinjdev Mar 11, 2026
89d5fca
feat(dashboard): implement Command view with stats, quest grid, and h…
justinjdev Mar 11, 2026
b43f7f2
feat(dashboard): implement Quest Detail view with errands, files, tom…
justinjdev Mar 11, 2026
d079aed
feat(dashboard): add command palette with fuzzy search and keyboard n…
justinjdev Mar 11, 2026
9bc6ade
feat(dashboard): implement Herald view with filtering
justinjdev Mar 11, 2026
64300d3
feat(dashboard): implement Autopsies view with search and drill-in
justinjdev Mar 11, 2026
7dc8ad6
feat(dashboard): implement Timeline view with Gantt chart
justinjdev Mar 11, 2026
4cff0df
feat(dashboard): implement Config view with editable settings
justinjdev Mar 11, 2026
d82f7ee
feat(dashboard): add build script and embed Svelte output in Go binary
justinjdev Mar 11, 2026
473d0b6
fix(dashboard): embed _app directory and fix SPA static file serving
justinjdev Mar 11, 2026
2f5da22
fix(dashboard): address PR review feedback — error handling, concurre…
justinjdev Mar 11, 2026
ef3162b
fix(dashboard): address PR review feedback round 2
justinjdev Mar 11, 2026
fcdeabb
fix(dashboard): populate Timestamp on all WSEvent broadcasts
justinjdev Mar 11, 2026
064d7aa
fix(dashboard): address PR review feedback round 3
justinjdev Mar 12, 2026
93d2e24
fix(dashboard): address local CodeRabbit review findings
justinjdev Mar 12, 2026
eb31fa0
chore: add local artifacts to .gitignore
justinjdev Mar 12, 2026
dcac78b
fix(dashboard): address PR review feedback round 6
justinjdev Mar 12, 2026
efb8647
fix: port dashboard data/commands to SQLite after rebase
justinjdev Mar 21, 2026
cdcf8f3
fix: remove accidentally committed local files
justinjdev Mar 21, 2026
2090894
fix(docs): use pinned CLI path in rekindle.md and fellowship SKILL.md
justinjdev Mar 21, 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
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
2 changes: 1 addition & 1 deletion cli/cmd/fellowship/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -913,7 +913,7 @@ 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 := dashboard.NewServer(d, gitRootOrCwd(), *poll)

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)
}
168 changes: 168 additions & 0 deletions cli/internal/dashboard/data.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
package dashboard

import (
"context"
"encoding/json"
"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
})

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
}
if err != nil {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode([]interface{}{})
return
}
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 {
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
}

var configPath string
switch req.Scope {
case "global":
home, _ := os.UserHomeDir()
configPath = filepath.Join(home, ".claude", "fellowship.json")
case "project":
configPath = filepath.Join(s.gitRoot, ".fellowship", "config.json")
default:
http.Error(w, "scope must be 'global' or 'project'", http.StatusBadRequest)
return
}

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
}
}

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 := configPath + ".tmp"
if err := os.WriteFile(tmp, data, 0644); err != nil {
http.Error(w, "failed to write config", http.StatusInternalServerError)
return
}
if err := os.Rename(tmp, configPath); err != nil {
os.Remove(tmp) // best-effort cleanup
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