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>
This commit is contained in:
nux
2026-01-22 17:45:34 -08:00
committed by John Ogle
parent 36b3c3b00a
commit 9cf012c0d5

View File

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