Files
gastown/internal/cmd/goals.go
furiosa beb863897f feat(goals): show assignee for each bead in gt goals output
Add assignee display to both list and single-goal views. In list view,
assignee appears on the second line when present. In single-goal view,
it appears as a dedicated field after priority. JSON output also includes
the assignee field.

Closes: gt-libj

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 10:32:51 -08:00

574 lines
15 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"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/workspace"
)
// 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"`
Assignee string `json:"assignee,omitempty"`
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"`
Assignee string `json:"assignee"`
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,
Assignee: goal.Assignee,
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)
if goal.Assignee != "" {
fmt.Printf(" Assignee: @%s\n", goal.Assignee)
}
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 {
// Collect epics from all rigs (goals are cross-rig strategic objectives)
epics, err := collectEpicsFromAllRigs()
if err != nil {
return 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([]epicRecord, 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([]epicRecord, 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,
Assignee: epic.Assignee,
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, staleness, and assignee (if any)
activityStr := formatActivityShort(g.StalenessHrs)
if g.Assignee != "" {
fmt.Printf(" %d convoy(s) | %s | @%s\n\n", g.ConvoyCount, activityStr, g.Assignee)
} else {
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
}
// epicRecord represents an epic from bd list output.
type epicRecord struct {
ID string `json:"id"`
Title string `json:"title"`
Status string `json:"status"`
Priority int `json:"priority"`
UpdatedAt string `json:"updated_at"`
Assignee string `json:"assignee"`
}
// collectEpicsFromAllRigs queries all rigs for epics and aggregates them.
// Goals are cross-rig strategic objectives, so we need to query each rig's beads.
func collectEpicsFromAllRigs() ([]epicRecord, error) {
var allEpics []epicRecord
seen := make(map[string]bool) // Deduplicate by ID
// Find the town root
townRoot, err := workspace.FindFromCwdOrError()
if err != nil {
// Not in a Gas Town workspace, fall back to single query
return queryEpicsInDir("")
}
// Also query town-level beads (for hq- prefixed epics)
townBeadsDir := filepath.Join(townRoot, ".beads")
if _, err := os.Stat(townBeadsDir); err == nil {
epics, err := queryEpicsInDir(townRoot)
if err == nil {
for _, e := range epics {
if !seen[e.ID] {
seen[e.ID] = true
allEpics = append(allEpics, e)
}
}
}
}
// Find all rig directories (they have .beads/ subdirectories)
entries, err := os.ReadDir(townRoot)
if err != nil {
return allEpics, nil // Return what we have
}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
// Skip hidden directories and known non-rig directories
name := entry.Name()
if strings.HasPrefix(name, ".") || name == "plugins" || name == "docs" {
continue
}
rigPath := filepath.Join(townRoot, name)
rigBeadsDir := filepath.Join(rigPath, ".beads")
// Check if this directory has a beads database
if _, err := os.Stat(rigBeadsDir); os.IsNotExist(err) {
continue
}
// Query this rig for epics
epics, err := queryEpicsInDir(rigPath)
if err != nil {
// Log but continue - one rig failing shouldn't stop the whole query
continue
}
for _, e := range epics {
if !seen[e.ID] {
seen[e.ID] = true
allEpics = append(allEpics, e)
}
}
}
return allEpics, nil
}
// queryEpicsInDir runs bd list --type=epic in the specified directory.
// If dir is empty, uses current working directory.
func queryEpicsInDir(dir string) ([]epicRecord, error) {
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...)
if dir != "" {
listCmd.Dir = dir
}
var stdout bytes.Buffer
listCmd.Stdout = &stdout
if err := listCmd.Run(); err != nil {
return nil, fmt.Errorf("listing epics: %w", err)
}
var epics []epicRecord
if err := json.Unmarshal(stdout.Bytes(), &epics); err != nil {
return nil, fmt.Errorf("parsing epics: %w", err)
}
return epics, nil
}