package cmd import ( "bytes" "encoding/json" "fmt" "os" "os/exec" "path/filepath" "sort" "strconv" "strings" "time" "github.com/spf13/cobra" "github.com/steveyegge/gastown/internal/beads" "github.com/steveyegge/gastown/internal/style" "github.com/steveyegge/gastown/internal/workspace" ) // Goal command flags var ( goalsJSON bool goalsStatus string goalsPriority string goalsIncludeWisp bool ) var goalsCmd = &cobra.Command{ Use: "goals [goal-id]", GroupID: GroupWork, Short: "View strategic goals (epics) with staleness indicators", Long: `View strategic goals (epics) across the workspace. Goals are high-level objectives that organize related work items. This command shows goals with staleness indicators to help identify stale or neglected strategic initiatives. Staleness indicators: 🟢 active: movement in last hour 🟡 stale: no movement for 1+ hours 🔴 stuck: no movement for 4+ hours Goals are sorted by staleness × priority (highest attention needed first). Examples: gt goals # List all open goals gt goals --json # Output as JSON gt goals --status=all # Show all goals including closed gt goals gt-abc # Show details for a specific goal`, RunE: runGoals, } func init() { goalsCmd.Flags().BoolVar(&goalsJSON, "json", false, "Output as JSON") goalsCmd.Flags().StringVar(&goalsStatus, "status", "open", "Filter by status (open, closed, all)") goalsCmd.Flags().StringVar(&goalsPriority, "priority", "", "Filter by priority (e.g., P0, P1, P2)") goalsCmd.Flags().BoolVar(&goalsIncludeWisp, "include-wisp", false, "Include transient wisp molecules (normally hidden)") rootCmd.AddCommand(goalsCmd) } func runGoals(cmd *cobra.Command, args []string) error { // If arg provided, show specific goal if len(args) > 0 { goalID := args[0] return showGoal(goalID) } // Otherwise list all goals return listGoals() } // goalInfo holds computed goal data for display and sorting. type goalInfo struct { ID string `json:"id"` Title string `json:"title"` Status string `json:"status"` Priority int `json:"priority"` Assignee string `json:"assignee,omitempty"` ConvoyCount int `json:"convoy_count"` LastMovement time.Time `json:"last_movement,omitempty"` StalenessHrs float64 `json:"staleness_hours"` StalenessIcon string `json:"staleness_icon"` Score float64 `json:"score"` // priority × staleness for sorting } func showGoal(goalID string) error { // Get goal details via bd show showCmd := exec.Command("bd", "show", goalID, "--json") var stdout bytes.Buffer showCmd.Stdout = &stdout if err := showCmd.Run(); err != nil { return fmt.Errorf("goal '%s' not found", goalID) } var goals []struct { ID string `json:"id"` Title string `json:"title"` Description string `json:"description"` Status string `json:"status"` Priority int `json:"priority"` IssueType string `json:"issue_type"` Assignee string `json:"assignee"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` } if err := json.Unmarshal(stdout.Bytes(), &goals); err != nil { return fmt.Errorf("parsing goal data: %w", err) } if len(goals) == 0 { return fmt.Errorf("goal '%s' not found", goalID) } goal := goals[0] // Verify it's an epic if goal.IssueType != "epic" { return fmt.Errorf("'%s' is not a goal/epic (type: %s)", goalID, goal.IssueType) } // Get linked convoys (no dbPath available for single goal lookup, use fallback) convoys := getLinkedConvoys(goalID, "") // Compute staleness lastMovement := computeGoalLastMovement(goal.UpdatedAt, convoys) stalenessHrs := time.Since(lastMovement).Hours() icon := stalenessIcon(stalenessHrs) if goalsJSON { out := goalInfo{ ID: goal.ID, Title: goal.Title, Status: goal.Status, Priority: goal.Priority, Assignee: goal.Assignee, ConvoyCount: len(convoys), LastMovement: lastMovement, StalenessHrs: stalenessHrs, StalenessIcon: icon, } enc := json.NewEncoder(os.Stdout) enc.SetIndent("", " ") return enc.Encode(out) } // Human-readable output fmt.Printf("%s P%d %s: %s\n\n", icon, goal.Priority, style.Bold.Render(goal.ID), goal.Title) fmt.Printf(" Status: %s\n", goal.Status) fmt.Printf(" Priority: P%d\n", goal.Priority) if goal.Assignee != "" { fmt.Printf(" Assignee: @%s\n", goal.Assignee) } fmt.Printf(" Convoys: %d\n", len(convoys)) fmt.Printf(" Last activity: %s\n", formatLastActivity(lastMovement)) if goal.Description != "" { fmt.Printf("\n %s\n", style.Bold.Render("Description:")) // Indent description for _, line := range strings.Split(goal.Description, "\n") { fmt.Printf(" %s\n", line) } } if len(convoys) > 0 { fmt.Printf("\n %s\n", style.Bold.Render("Linked Convoys:")) for _, c := range convoys { statusIcon := "○" if c.Status == "closed" { statusIcon = "✓" } fmt.Printf(" %s %s: %s\n", statusIcon, c.ID, c.Title) } } return nil } func listGoals() error { // 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([]epicRecord, 0) for _, e := range epics { if !isWispEpic(e.ID, e.Title) { filtered = append(filtered, e) } } epics = filtered } // Filter by priority if specified if goalsPriority != "" { targetPriority := parsePriority(goalsPriority) filtered := make([]epicRecord, 0) for _, e := range epics { if e.Priority == targetPriority { filtered = append(filtered, e) } } epics = filtered } // Build goal info with staleness computation var goals []goalInfo for _, epic := range epics { convoys := getLinkedConvoys(epic.ID, epic.dbPath) lastMovement := computeGoalLastMovement(epic.UpdatedAt, convoys) stalenessHrs := time.Since(lastMovement).Hours() icon := stalenessIcon(stalenessHrs) // Score = priority_value × staleness_hours // Lower priority number = higher priority, so invert (4 - priority) priorityWeight := float64(4 - epic.Priority) if priorityWeight < 1 { priorityWeight = 1 } score := priorityWeight * stalenessHrs goals = append(goals, goalInfo{ ID: epic.ID, Title: epic.Title, Status: epic.Status, Priority: epic.Priority, Assignee: epic.Assignee, ConvoyCount: len(convoys), LastMovement: lastMovement, StalenessHrs: stalenessHrs, StalenessIcon: icon, Score: score, }) } // Sort by score (highest attention needed first) sort.Slice(goals, func(i, j int) bool { return goals[i].Score > goals[j].Score }) if goalsJSON { enc := json.NewEncoder(os.Stdout) enc.SetIndent("", " ") return enc.Encode(goals) } if len(goals) == 0 { fmt.Println("No goals found.") fmt.Println("Create a goal with: bd create --type=epic --title=\"Goal name\"") return nil } // Count active (non-closed) goals activeCount := 0 for _, g := range goals { if g.Status != "closed" { activeCount++ } } fmt.Printf("%s\n\n", style.Bold.Render(fmt.Sprintf("Goals (%d active, sorted by staleness × priority)", activeCount))) for _, g := range goals { // Format: 🔴 P1 sc-xyz: Title // 3 convoys | stale 6h priorityStr := fmt.Sprintf("P%d", g.Priority) fmt.Printf(" %s %s %s: %s\n", g.StalenessIcon, priorityStr, g.ID, g.Title) // Second line with convoy count, staleness, and assignee (if any) activityStr := formatActivityShort(g.StalenessHrs) if g.Assignee != "" { fmt.Printf(" %d convoy(s) | %s | @%s\n\n", g.ConvoyCount, activityStr, g.Assignee) } else { fmt.Printf(" %d convoy(s) | %s\n\n", g.ConvoyCount, activityStr) } } return nil } // convoyInfo holds basic convoy info. type convoyInfo struct { ID string Title string Status string } // getLinkedConvoys finds convoys linked to a goal (via parent-child relation). // dbPath is the path to beads.db containing the goal for direct SQLite queries. func getLinkedConvoys(goalID, dbPath string) []convoyInfo { var convoys []convoyInfo // If no dbPath provided, fall back to bd subprocess (shouldn't happen normally) if dbPath == "" { return getLinkedConvoysFallback(goalID) } // Query dependencies directly from SQLite // Children are stored as: depends_on_id = goalID (parent) with type 'blocks' safeGoalID := strings.ReplaceAll(goalID, "'", "''") query := fmt.Sprintf(` SELECT i.id, i.title, i.status FROM dependencies d JOIN issues i ON d.issue_id = i.id WHERE d.depends_on_id = '%s' AND d.type = 'blocks' AND i.issue_type = 'convoy' `, safeGoalID) queryCmd := exec.Command("sqlite3", "-json", dbPath, query) var stdout bytes.Buffer queryCmd.Stdout = &stdout if err := queryCmd.Run(); err != nil { return convoys } if stdout.Len() == 0 { return convoys } var results []struct { ID string `json:"id"` Title string `json:"title"` Status string `json:"status"` } if err := json.Unmarshal(stdout.Bytes(), &results); err != nil { return convoys } for _, r := range results { convoys = append(convoys, convoyInfo{ ID: r.ID, Title: r.Title, Status: r.Status, }) } return convoys } // getLinkedConvoysFallback uses bd subprocess (for when dbPath is unknown). func getLinkedConvoysFallback(goalID string) []convoyInfo { var convoys []convoyInfo depArgs := []string{"dep", "list", goalID, "--json"} depCmd := exec.Command("bd", depArgs...) var stdout bytes.Buffer depCmd.Stdout = &stdout if err := depCmd.Run(); err != nil { return convoys } var deps struct { Children []struct { ID string `json:"id"` Type string `json:"type"` } `json:"children"` } if err := json.Unmarshal(stdout.Bytes(), &deps); err != nil { return convoys } for _, child := range deps.Children { details := getIssueDetails(child.ID) if details != nil && details.IssueType == "convoy" { convoys = append(convoys, convoyInfo{ ID: details.ID, Title: details.Title, Status: details.Status, }) } } return convoys } // computeGoalLastMovement computes when the goal last had activity. // It looks at: // 1. The goal's own updated_at (passed directly to avoid re-querying) // 2. The last activity of any linked convoy's tracked issues func computeGoalLastMovement(goalUpdatedAt string, convoys []convoyInfo) time.Time { // Start with the goal's own updated_at lastMovement := time.Now().Add(-24 * time.Hour) // Default to 24 hours ago if goalUpdatedAt != "" { if t, err := time.Parse(time.RFC3339, goalUpdatedAt); err == nil { lastMovement = t } } // If no convoys, return early (common case - avoids unnecessary work) if len(convoys) == 0 { return lastMovement } // Check convoy activity townBeads, err := getTownBeadsDir() if err != nil { return lastMovement } for _, convoy := range convoys { tracked := getTrackedIssues(townBeads, convoy.ID) for _, t := range tracked { // Get issue's updated_at details := getIssueDetails(t.ID) if details == nil { continue } showCmd := exec.Command("bd", "show", t.ID, "--json") var out bytes.Buffer showCmd.Stdout = &out showCmd.Run() var issues []struct { UpdatedAt string `json:"updated_at"` } json.Unmarshal(out.Bytes(), &issues) if len(issues) > 0 && issues[0].UpdatedAt != "" { if t, err := time.Parse(time.RFC3339, issues[0].UpdatedAt); err == nil { if t.After(lastMovement) { lastMovement = t } } } } } return lastMovement } // stalenessIcon returns the appropriate staleness indicator. // 🟢 active: moved in last hour // 🟡 stale: no movement for 1+ hours // 🔴 stuck: no movement for 4+ hours func stalenessIcon(hours float64) string { if hours < 1 { return "🟢" } if hours < 4 { return "🟡" } return "🔴" } // formatLastActivity formats the last activity time for display. func formatLastActivity(t time.Time) string { if t.IsZero() { return "unknown" } d := time.Since(t) if d < time.Minute { return "just now" } if d < time.Hour { return fmt.Sprintf("%d minutes ago", int(d.Minutes())) } if d < 24*time.Hour { return fmt.Sprintf("%d hours ago", int(d.Hours())) } return fmt.Sprintf("%d days ago", int(d.Hours()/24)) } // formatActivityShort returns a short activity string for the list view. func formatActivityShort(hours float64) string { if hours < 1 { mins := int(hours * 60) if mins < 1 { return "active just now" } return fmt.Sprintf("active %dm ago", mins) } if hours < 4 { return fmt.Sprintf("stale %.0fh", hours) } return fmt.Sprintf("stuck %.0fh", hours) } // parsePriority converts a priority string (P0, P1, etc.) to an int. func parsePriority(s string) int { s = strings.TrimPrefix(strings.ToUpper(s), "P") if p, err := strconv.Atoi(s); err == nil { return p } return 2 // Default to P2 } // isWispEpic returns true if the epic is a transient wisp molecule. // These are operational/infrastructure beads, not strategic goals that need human attention. // Detection criteria: // - ID contains "-wisp-" (molecule tracking beads) // - Title starts with "mol-" (molecule beads) func isWispEpic(id, title string) bool { // Check for wisp ID pattern (e.g., "gt-wisp-abc123") if strings.Contains(id, "-wisp-") { return true } // Check for molecule title pattern if strings.HasPrefix(title, "mol-") { return true } 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"` Assignee string `json:"assignee"` // dbPath is the path to beads.db containing this epic (for direct queries) dbPath string } // 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 queries epics directly from SQLite in the specified directory. // If dir is empty, uses current working directory. func queryEpicsInDir(dir string) ([]epicRecord, error) { beadsDir := dir if beadsDir == "" { var err error beadsDir, err = os.Getwd() if err != nil { return nil, fmt.Errorf("getting working directory: %w", err) } } // Resolve redirects to find actual beads.db resolvedBeads := beads.ResolveBeadsDir(beadsDir) dbPath := filepath.Join(resolvedBeads, "beads.db") // Check if database exists if _, err := os.Stat(dbPath); os.IsNotExist(err) { return nil, nil // No database, no epics } // Build SQL query for epics query := `SELECT id, title, status, priority, updated_at, assignee FROM issues WHERE issue_type = 'epic'` if goalsStatus == "" || goalsStatus == "open" { query += ` AND status <> 'closed' AND status <> 'tombstone'` } else if goalsStatus != "all" { query += fmt.Sprintf(` AND status = '%s'`, strings.ReplaceAll(goalsStatus, "'", "''")) } else { // --all: exclude tombstones but include everything else query += ` AND status <> 'tombstone'` } queryCmd := exec.Command("sqlite3", "-json", dbPath, query) var stdout bytes.Buffer queryCmd.Stdout = &stdout if err := queryCmd.Run(); err != nil { // Database might be empty or have no epics - not an error return nil, nil } // Handle empty result (sqlite3 -json returns nothing for empty sets) if stdout.Len() == 0 { return nil, nil } var epics []epicRecord if err := json.Unmarshal(stdout.Bytes(), &epics); err != nil { return nil, fmt.Errorf("parsing epics: %w", err) } // Set dbPath on each epic for direct queries later for i := range epics { epics[i].dbPath = dbPath } return epics, nil }