From 0d065921b60a820bb9c64f6d47bc2fe2a12d6859 Mon Sep 17 00:00:00 2001 From: diesel Date: Thu, 22 Jan 2026 22:25:34 -0800 Subject: [PATCH] fix(goals): query epics from all rigs, not just default gt goals was only querying the default beads location (town-level with hq- prefix), missing epics from rig-level beads (j-, sc-, etc.). Now iterates over all rig directories with .beads/ subdirectories and aggregates epics, deduplicating by ID. --- internal/cmd/goals.go | 158 +++++++++++++++++++++++++++++++----------- 1 file changed, 117 insertions(+), 41 deletions(-) diff --git a/internal/cmd/goals.go b/internal/cmd/goals.go index a1a50881..726afbda 100644 --- a/internal/cmd/goals.go +++ b/internal/cmd/goals.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "os/exec" + "path/filepath" "sort" "strconv" "strings" @@ -13,6 +14,7 @@ import ( "github.com/spf13/cobra" "github.com/steveyegge/gastown/internal/style" + "github.com/steveyegge/gastown/internal/workspace" ) // Goal command flags @@ -169,45 +171,16 @@ func showGoal(goalID string) error { } func listGoals() error { - // Build list args - bd has its own routing to find the right beads DB - listArgs := []string{"list", "--type=epic", "--json"} - if goalsStatus != "" && goalsStatus != "open" { - if goalsStatus == "all" { - listArgs = append(listArgs, "--all") - } else { - listArgs = append(listArgs, "--status="+goalsStatus) - } - } - - listCmd := exec.Command("bd", listArgs...) - var stdout bytes.Buffer - listCmd.Stdout = &stdout - - if err := listCmd.Run(); err != nil { - return fmt.Errorf("listing goals: %w", err) - } - - var epics []struct { - ID string `json:"id"` - Title string `json:"title"` - Status string `json:"status"` - Priority int `json:"priority"` - UpdatedAt string `json:"updated_at"` - } - if err := json.Unmarshal(stdout.Bytes(), &epics); err != nil { - return fmt.Errorf("parsing goals list: %w", err) + // Collect epics from all rigs (goals are cross-rig strategic objectives) + epics, err := collectEpicsFromAllRigs() + if err != nil { + return err } // Filter out wisp molecules by default (transient/operational, not strategic goals) // These have IDs like "gt-wisp-*" and are molecule-tracking beads, not human goals if !goalsIncludeWisp { - filtered := make([]struct { - ID string `json:"id"` - Title string `json:"title"` - Status string `json:"status"` - Priority int `json:"priority"` - UpdatedAt string `json:"updated_at"` - }, 0) + filtered := make([]epicRecord, 0) for _, e := range epics { if !isWispEpic(e.ID, e.Title) { filtered = append(filtered, e) @@ -219,13 +192,7 @@ func listGoals() error { // Filter by priority if specified if goalsPriority != "" { targetPriority := parsePriority(goalsPriority) - filtered := make([]struct { - ID string `json:"id"` - Title string `json:"title"` - Status string `json:"status"` - Priority int `json:"priority"` - UpdatedAt string `json:"updated_at"` - }, 0) + filtered := make([]epicRecord, 0) for _, e := range epics { if e.Priority == targetPriority { filtered = append(filtered, e) @@ -483,3 +450,112 @@ func isWispEpic(id, title string) bool { } return false } + +// epicRecord represents an epic from bd list output. +type epicRecord struct { + ID string `json:"id"` + Title string `json:"title"` + Status string `json:"status"` + Priority int `json:"priority"` + UpdatedAt string `json:"updated_at"` +} + +// collectEpicsFromAllRigs queries all rigs for epics and aggregates them. +// Goals are cross-rig strategic objectives, so we need to query each rig's beads. +func collectEpicsFromAllRigs() ([]epicRecord, error) { + var allEpics []epicRecord + seen := make(map[string]bool) // Deduplicate by ID + + // Find the town root + townRoot, err := workspace.FindFromCwdOrError() + if err != nil { + // Not in a Gas Town workspace, fall back to single query + return queryEpicsInDir("") + } + + // Also query town-level beads (for hq- prefixed epics) + townBeadsDir := filepath.Join(townRoot, ".beads") + if _, err := os.Stat(townBeadsDir); err == nil { + epics, err := queryEpicsInDir(townRoot) + if err == nil { + for _, e := range epics { + if !seen[e.ID] { + seen[e.ID] = true + allEpics = append(allEpics, e) + } + } + } + } + + // Find all rig directories (they have .beads/ subdirectories) + entries, err := os.ReadDir(townRoot) + if err != nil { + return allEpics, nil // Return what we have + } + + for _, entry := range entries { + if !entry.IsDir() { + continue + } + // Skip hidden directories and known non-rig directories + name := entry.Name() + if strings.HasPrefix(name, ".") || name == "plugins" || name == "docs" { + continue + } + + rigPath := filepath.Join(townRoot, name) + rigBeadsDir := filepath.Join(rigPath, ".beads") + + // Check if this directory has a beads database + if _, err := os.Stat(rigBeadsDir); os.IsNotExist(err) { + continue + } + + // Query this rig for epics + epics, err := queryEpicsInDir(rigPath) + if err != nil { + // Log but continue - one rig failing shouldn't stop the whole query + continue + } + + for _, e := range epics { + if !seen[e.ID] { + seen[e.ID] = true + allEpics = append(allEpics, e) + } + } + } + + return allEpics, nil +} + +// queryEpicsInDir runs bd list --type=epic in the specified directory. +// If dir is empty, uses current working directory. +func queryEpicsInDir(dir string) ([]epicRecord, error) { + listArgs := []string{"list", "--type=epic", "--json"} + if goalsStatus != "" && goalsStatus != "open" { + if goalsStatus == "all" { + listArgs = append(listArgs, "--all") + } else { + listArgs = append(listArgs, "--status="+goalsStatus) + } + } + + listCmd := exec.Command("bd", listArgs...) + if dir != "" { + listCmd.Dir = dir + } + var stdout bytes.Buffer + listCmd.Stdout = &stdout + + if err := listCmd.Run(); err != nil { + return nil, fmt.Errorf("listing epics: %w", err) + } + + var epics []epicRecord + if err := json.Unmarshal(stdout.Bytes(), &epics); err != nil { + return nil, fmt.Errorf("parsing epics: %w", err) + } + + return epics, nil +}