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
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/steveyegge/gastown/internal/style"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Goal command flags
|
// 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
|
This command shows goals with staleness indicators to help identify
|
||||||
stale or neglected strategic initiatives.
|
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:
|
Examples:
|
||||||
gt goals # List all open goals
|
gt goals # List all open goals
|
||||||
gt goals --json # Output as JSON
|
gt goals --json # Output as JSON
|
||||||
gt goals --status=all # Show all goals including closed
|
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,
|
RunE: runGoals,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,12 +65,384 @@ func runGoals(cmd *cobra.Command, args []string) error {
|
|||||||
return listGoals()
|
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 {
|
func showGoal(goalID string) error {
|
||||||
// TODO: Implement goal details view
|
// Get goal details via bd show
|
||||||
return fmt.Errorf("goal details not yet implemented: %s", goalID)
|
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 {
|
func listGoals() error {
|
||||||
// TODO: Implement goals listing with staleness
|
// Build list args - bd has its own routing to find the right beads DB
|
||||||
return fmt.Errorf("goals listing not yet implemented")
|
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