Files
gastown/internal/cmd/goals.go
nux a7a2c7fc58 feat(goals): implement goals list with staleness computation
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 <noreply@anthropic.com>
2026-01-22 19:47:12 -08:00

449 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
)
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)")
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 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
}