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.
This commit is contained in:
diesel
2026-01-22 22:25:34 -08:00
committed by John Ogle
parent 13303d4e83
commit 0d065921b6

View File

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