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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user