From 9cf012c0d5828f673903e8f02a0915c30875969f Mon Sep 17 00:00:00 2001 From: nux Date: Thu, 22 Jan 2026 17:45:34 -0800 Subject: [PATCH] feat(goals): implement goals list with staleness computation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements gt goals command to show epics sorted by staleness × priority. Features: - List all open epics with staleness indicators (🟢/🟡/🔴) - Sort by attention score (priority × staleness hours) - Show specific goal details with description and linked convoys - JSON output support - Priority and status filtering Staleness thresholds: - 🟢 active: moved in last hour - 🟡 stale: no movement for 1+ hours - 🔴 stuck: no movement for 4+ hours Closes: gt-vix Co-Authored-By: Claude Opus 4.5 --- internal/cmd/goals.go | 398 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 393 insertions(+), 5 deletions(-) diff --git a/internal/cmd/goals.go b/internal/cmd/goals.go index 762405cf..f34790f5 100644 --- a/internal/cmd/goals.go +++ b/internal/cmd/goals.go @@ -1,9 +1,18 @@ package cmd import ( + "bytes" + "encoding/json" "fmt" + "os" + "os/exec" + "sort" + "strconv" + "strings" + "time" "github.com/spf13/cobra" + "github.com/steveyegge/gastown/internal/style" ) // Goal command flags @@ -23,11 +32,18 @@ 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 hq-abc # Show details for a specific goal`, + gt goals gt-abc # Show details for a specific goal`, RunE: runGoals, } @@ -49,12 +65,384 @@ func runGoals(cmd *cobra.Command, args []string) error { 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"` + 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 { - // TODO: Implement goal details view - return fmt.Errorf("goal details not yet implemented: %s", goalID) + // 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"` + 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 + convoys := getLinkedConvoys(goalID) + + // Compute staleness + lastMovement := computeGoalLastMovement(goalID, 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, + 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) + 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 { - // TODO: Implement goals listing with staleness - return fmt.Errorf("goals listing not yet implemented") + // 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) + } + + // 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) + 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) + lastMovement := computeGoalLastMovement(epic.ID, 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, + 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 and staleness + activityStr := formatActivityShort(g.StalenessHrs) + 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). +func getLinkedConvoys(goalID string) []convoyInfo { + var convoys []convoyInfo + + // Query dependencies where this goal is the parent + // The child issues (convoys) will have depends_on_id = goalID with type = 'parent-child' + 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 + } + + // Get details for each child that is a convoy + 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 +// 2. The last activity of any linked convoy's tracked issues +func computeGoalLastMovement(goalID string, convoys []convoyInfo) time.Time { + // Start with the goal's own updated_at + showCmd := exec.Command("bd", "show", goalID, "--json") + var stdout bytes.Buffer + showCmd.Stdout = &stdout + showCmd.Run() + + var goals []struct { + UpdatedAt string `json:"updated_at"` + } + json.Unmarshal(stdout.Bytes(), &goals) + + lastMovement := time.Now().Add(-24 * time.Hour) // Default to 24 hours ago + if len(goals) > 0 && goals[0].UpdatedAt != "" { + if t, err := time.Parse(time.RFC3339, goals[0].UpdatedAt); err == nil { + lastMovement = t + } + } + + // 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 }