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 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"` 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"` 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 { // 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 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) 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([]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 } // 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 }