package cmd import ( "bytes" "encoding/json" "fmt" "os" "os/exec" "path/filepath" "sort" "strings" "time" "github.com/spf13/cobra" "github.com/steveyegge/gastown/internal/style" "github.com/steveyegge/gastown/internal/workspace" ) var focusJSON bool var focusAll bool var focusLimit int var focusCmd = &cobra.Command{ Use: "focus", GroupID: GroupWork, Short: "Show what needs attention (stalest high-priority goals)", Long: `Show what the overseer should focus on next. Analyzes active epics (goals) and sorts them by staleness × priority. Items that haven't moved in a while and have high priority appear first. Staleness indicators: 🔴 stuck - no movement for 4+ hours (high urgency) 🟡 stale - no movement for 1-4 hours (needs attention) 🟢 active - moved within the last hour (probably fine) Examples: gt focus # Top 5 suggestions gt focus --all # All active goals with staleness gt focus --limit=10 # Top 10 suggestions gt focus --json # Machine-readable output`, RunE: runFocus, } func init() { focusCmd.Flags().BoolVar(&focusJSON, "json", false, "Output as JSON") focusCmd.Flags().BoolVar(&focusAll, "all", false, "Show all active goals (not just top N)") focusCmd.Flags().IntVarP(&focusLimit, "limit", "n", 5, "Number of suggestions to show") rootCmd.AddCommand(focusCmd) } // FocusItem represents a goal that needs attention. type FocusItem struct { ID string `json:"id"` Title string `json:"title"` Priority int `json:"priority"` Status string `json:"status"` Staleness string `json:"staleness"` // "active", "stale", "stuck" StalenessHours float64 `json:"staleness_hours"` // Hours since last movement Score float64 `json:"score"` // priority × staleness_hours UpdatedAt string `json:"updated_at"` Assignee string `json:"assignee,omitempty"` DrillDown string `json:"drill_down"` // Suggested command } func runFocus(cmd *cobra.Command, args []string) error { // Find town root to query both town and rig beads townRoot, err := workspace.FindFromCwdOrError() if err != nil { return fmt.Errorf("not in a Gas Town workspace: %w", err) } // Collect epics from town beads and all rig beads items, err := collectFocusItems(townRoot) if err != nil { return err } if len(items) == 0 { fmt.Println("No active goals found.") fmt.Println("Goals are epics with open status. Create one with: bd create --type=epic \"Goal name\"") return nil } // Sort by score (highest first) sort.Slice(items, func(i, j int) bool { return items[i].Score > items[j].Score }) // Apply limit if !focusAll && len(items) > focusLimit { items = items[:focusLimit] } if focusJSON { enc := json.NewEncoder(os.Stdout) enc.SetIndent("", " ") return enc.Encode(items) } return outputFocusText(items) } // collectFocusItems gathers epics from all beads databases in the town. func collectFocusItems(townRoot string) ([]FocusItem, error) { var items []FocusItem seenIDs := make(map[string]bool) // Dedupe across databases // 1. Query town beads (hq-* prefix) townBeads := filepath.Join(townRoot, ".beads") if _, err := os.Stat(townBeads); err == nil { townItems := queryEpicsFromBeads(townBeads) for _, item := range townItems { if !seenIDs[item.ID] { items = append(items, item) seenIDs[item.ID] = true } } } // 2. Query each rig's beads (gt-*, bd-*, sc-* etc. prefixes) rigDirs, _ := filepath.Glob(filepath.Join(townRoot, "*", "mayor", "rig", ".beads")) for _, rigBeads := range rigDirs { rigItems := queryEpicsFromBeads(rigBeads) for _, item := range rigItems { if !seenIDs[item.ID] { items = append(items, item) seenIDs[item.ID] = true } } } return items, nil } // queryEpicsFromBeads queries a beads database for open epics. func queryEpicsFromBeads(beadsPath string) []FocusItem { var items []FocusItem // Use bd to query epics listCmd := exec.Command("bd", "list", "--type=epic", "--status=open", "--json") listCmd.Dir = beadsPath var stdout bytes.Buffer listCmd.Stdout = &stdout if err := listCmd.Run(); err != nil { // Also try in_progress and hooked statuses return items } var epics []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,omitempty"` Labels []string `json:"labels,omitempty"` Ephemeral bool `json:"ephemeral,omitempty"` } if err := json.Unmarshal(stdout.Bytes(), &epics); err != nil { return items } now := time.Now() for _, epic := range epics { // Skip ephemeral issues (molecules, wisps, etc.) - these aren't real goals if epic.Ephemeral { continue } // Also skip by ID pattern - wisps have "wisp" in the ID if strings.Contains(epic.ID, "wisp") || strings.Contains(epic.ID, "-mol-") { continue } item := FocusItem{ ID: epic.ID, Title: strings.TrimPrefix(epic.Title, "[EPIC] "), Priority: epic.Priority, Status: epic.Status, UpdatedAt: epic.UpdatedAt, Assignee: epic.Assignee, } // Calculate staleness if epic.UpdatedAt != "" { if updated, err := time.Parse(time.RFC3339, epic.UpdatedAt); err == nil { staleDuration := now.Sub(updated) item.StalenessHours = staleDuration.Hours() // Classify staleness switch { case staleDuration >= 4*time.Hour: item.Staleness = "stuck" case staleDuration >= 1*time.Hour: item.Staleness = "stale" default: item.Staleness = "active" } } } if item.Staleness == "" { item.Staleness = "active" } // Calculate score: priority × staleness_hours // P1 = 1, P2 = 2, etc. Lower priority number = higher importance // Invert so P1 has higher score priorityWeight := float64(5 - item.Priority) // P1=4, P2=3, P3=2, P4=1 if priorityWeight < 1 { priorityWeight = 1 } item.Score = priorityWeight * item.StalenessHours // Suggest drill-down command item.DrillDown = fmt.Sprintf("bd show %s", epic.ID) items = append(items, item) } // Also query in_progress and hooked epics for _, status := range []string{"in_progress", "hooked"} { extraCmd := exec.Command("bd", "list", "--type=epic", "--status="+status, "--json") extraCmd.Dir = beadsPath var extraStdout bytes.Buffer extraCmd.Stdout = &extraStdout if err := extraCmd.Run(); err != nil { continue } var extraEpics []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,omitempty"` Ephemeral bool `json:"ephemeral,omitempty"` } if err := json.Unmarshal(extraStdout.Bytes(), &extraEpics); err != nil { continue } for _, epic := range extraEpics { // Skip ephemeral issues if epic.Ephemeral { continue } if strings.Contains(epic.ID, "wisp") || strings.Contains(epic.ID, "-mol-") { continue } item := FocusItem{ ID: epic.ID, Title: strings.TrimPrefix(epic.Title, "[EPIC] "), Priority: epic.Priority, Status: epic.Status, UpdatedAt: epic.UpdatedAt, Assignee: epic.Assignee, } if epic.UpdatedAt != "" { if updated, err := time.Parse(time.RFC3339, epic.UpdatedAt); err == nil { staleDuration := now.Sub(updated) item.StalenessHours = staleDuration.Hours() switch { case staleDuration >= 4*time.Hour: item.Staleness = "stuck" case staleDuration >= 1*time.Hour: item.Staleness = "stale" default: item.Staleness = "active" } } } if item.Staleness == "" { item.Staleness = "active" } priorityWeight := float64(5 - item.Priority) if priorityWeight < 1 { priorityWeight = 1 } item.Score = priorityWeight * item.StalenessHours item.DrillDown = fmt.Sprintf("bd show %s", epic.ID) items = append(items, item) } } return items } func outputFocusText(items []FocusItem) error { fmt.Printf("%s\n\n", style.Bold.Render("Suggested focus (stalest high-priority first):")) for i, item := range items { // Staleness indicator var indicator string switch item.Staleness { case "stuck": indicator = style.Error.Render("🔴") case "stale": indicator = style.Warning.Render("🟡") default: indicator = style.Success.Render("🟢") } // Priority display priorityStr := fmt.Sprintf("P%d", item.Priority) // Format staleness duration stalenessStr := formatStaleness(item.StalenessHours) // Main line fmt.Printf("%d. %s [%s] %s: %s\n", i+1, indicator, priorityStr, item.ID, item.Title) // Details if item.Assignee != "" { // Extract short name from assignee path parts := strings.Split(item.Assignee, "/") shortAssignee := parts[len(parts)-1] fmt.Printf(" Last movement: %s Assignee: %s\n", stalenessStr, shortAssignee) } else { fmt.Printf(" Last movement: %s\n", stalenessStr) } // Drill-down hint fmt.Printf(" %s\n\n", style.Dim.Render("→ "+item.DrillDown)) } return nil } // formatStaleness formats staleness duration as human-readable string. func formatStaleness(hours float64) string { if hours < 1.0/60.0 { // Less than 1 minute return "just now" } if hours < 1 { return fmt.Sprintf("%dm ago", int(hours*60)) } if hours < 24 { return fmt.Sprintf("%.1fh ago", hours) } days := hours / 24 return fmt.Sprintf("%.1fd ago", days) }