diff --git a/internal/cmd/attention.go b/internal/cmd/attention.go new file mode 100644 index 00000000..ea97662d --- /dev/null +++ b/internal/cmd/attention.go @@ -0,0 +1,374 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + + "github.com/spf13/cobra" + "github.com/steveyegge/gastown/internal/style" + "github.com/steveyegge/gastown/internal/workspace" +) + +var attentionJSON bool +var attentionAll bool + +var attentionCmd = &cobra.Command{ + Use: "attention", + GroupID: GroupWork, + Short: "Show items requiring overseer attention", + Long: `Show what specifically needs the overseer's attention. + +Groups items into categories: + REQUIRES DECISION - Issues needing architectural/design choices + REQUIRES REVIEW - PRs and design docs awaiting approval + BLOCKED - Items stuck on unresolved dependencies + +Examples: + gt attention # Show all attention items + gt attention --json # Machine-readable output`, + RunE: runAttention, +} + +func init() { + attentionCmd.Flags().BoolVar(&attentionJSON, "json", false, "Output as JSON") + attentionCmd.Flags().BoolVar(&attentionAll, "all", false, "Include lower-priority items") + rootCmd.AddCommand(attentionCmd) +} + +// AttentionCategory represents a group of items needing attention. +type AttentionCategory string + +const ( + CategoryDecision AttentionCategory = "REQUIRES_DECISION" + CategoryReview AttentionCategory = "REQUIRES_REVIEW" + CategoryBlocked AttentionCategory = "BLOCKED" + CategoryStuck AttentionCategory = "STUCK_WORKERS" +) + +// AttentionItem represents something needing overseer attention. +type AttentionItem struct { + Category AttentionCategory `json:"category"` + Priority int `json:"priority"` + ID string `json:"id"` + Title string `json:"title"` + Context string `json:"context,omitempty"` + DrillDown string `json:"drill_down"` + Source string `json:"source,omitempty"` // "beads", "github", "agent" + Details string `json:"details,omitempty"` +} + +// AttentionOutput is the full attention report. +type AttentionOutput struct { + Decisions []AttentionItem `json:"decisions,omitempty"` + Reviews []AttentionItem `json:"reviews,omitempty"` + Blocked []AttentionItem `json:"blocked,omitempty"` + StuckWorkers []AttentionItem `json:"stuck_workers,omitempty"` +} + +func runAttention(cmd *cobra.Command, args []string) error { + townRoot, err := workspace.FindFromCwdOrError() + if err != nil { + return fmt.Errorf("not in a Gas Town workspace: %w", err) + } + + output := AttentionOutput{} + + // Collect items from various sources in parallel + // 1. Blocked beads + output.Blocked = collectBlockedItems(townRoot) + + // 2. Items needing decision (issues with needs-decision label) + output.Decisions = collectDecisionItems(townRoot) + + // 3. PRs awaiting review + output.Reviews = collectReviewItems(townRoot) + + // 4. Stuck workers (agents marked as stuck) + output.StuckWorkers = collectStuckWorkers(townRoot) + + // Sort each category by priority + sortByPriority := func(items []AttentionItem) { + sort.Slice(items, func(i, j int) bool { + return items[i].Priority < items[j].Priority // Lower priority number = higher importance + }) + } + sortByPriority(output.Decisions) + sortByPriority(output.Reviews) + sortByPriority(output.Blocked) + sortByPriority(output.StuckWorkers) + + if attentionJSON { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(output) + } + + return outputAttentionText(output) +} + +func collectBlockedItems(townRoot string) []AttentionItem { + var items []AttentionItem + + // Query blocked issues from beads + blockedCmd := exec.Command("bd", "blocked", "--json") + var stdout bytes.Buffer + blockedCmd.Stdout = &stdout + + if err := blockedCmd.Run(); err != nil { + return items + } + + var blocked []struct { + ID string `json:"id"` + Title string `json:"title"` + Priority int `json:"priority"` + BlockedBy []string `json:"blocked_by,omitempty"` + } + if err := json.Unmarshal(stdout.Bytes(), &blocked); err != nil { + return items + } + + for _, b := range blocked { + // Skip ephemeral/internal issues + if strings.Contains(b.ID, "wisp") || strings.Contains(b.ID, "-mol-") { + continue + } + if strings.Contains(b.ID, "-agent-") { + continue + } + + context := "" + if len(b.BlockedBy) > 0 { + context = fmt.Sprintf("Blocked by: %s", strings.Join(b.BlockedBy, ", ")) + } + + items = append(items, AttentionItem{ + Category: CategoryBlocked, + Priority: b.Priority, + ID: b.ID, + Title: b.Title, + Context: context, + DrillDown: fmt.Sprintf("bd show %s", b.ID), + Source: "beads", + }) + } + + return items +} + +func collectDecisionItems(townRoot string) []AttentionItem { + var items []AttentionItem + + // Query issues with needs-decision label + listCmd := exec.Command("bd", "list", "--label=needs-decision", "--status=open", "--json") + var stdout bytes.Buffer + listCmd.Stdout = &stdout + + if err := listCmd.Run(); err != nil { + return items + } + + var issues []struct { + ID string `json:"id"` + Title string `json:"title"` + Priority int `json:"priority"` + } + if err := json.Unmarshal(stdout.Bytes(), &issues); err != nil { + return items + } + + for _, issue := range issues { + items = append(items, AttentionItem{ + Category: CategoryDecision, + Priority: issue.Priority, + ID: issue.ID, + Title: issue.Title, + Context: "Needs architectural/design decision", + DrillDown: fmt.Sprintf("bd show %s", issue.ID), + Source: "beads", + }) + } + + return items +} + +func collectReviewItems(townRoot string) []AttentionItem { + var items []AttentionItem + + // Query open PRs from GitHub + prCmd := exec.Command("gh", "pr", "list", "--json", "number,title,headRefName,reviewDecision,additions,deletions") + var stdout bytes.Buffer + prCmd.Stdout = &stdout + + if err := prCmd.Run(); err != nil { + // gh not available or not in a git repo - skip + return items + } + + var prs []struct { + Number int `json:"number"` + Title string `json:"title"` + HeadRefName string `json:"headRefName"` + ReviewDecision string `json:"reviewDecision"` + Additions int `json:"additions"` + Deletions int `json:"deletions"` + } + if err := json.Unmarshal(stdout.Bytes(), &prs); err != nil { + return items + } + + for _, pr := range prs { + // Skip PRs that are already approved + if pr.ReviewDecision == "APPROVED" { + continue + } + + details := fmt.Sprintf("+%d/-%d lines", pr.Additions, pr.Deletions) + + items = append(items, AttentionItem{ + Category: CategoryReview, + Priority: 2, // Default P2 for PRs + ID: fmt.Sprintf("PR #%d", pr.Number), + Title: pr.Title, + Context: fmt.Sprintf("Branch: %s", pr.HeadRefName), + DrillDown: fmt.Sprintf("gh pr view %d", pr.Number), + Source: "github", + Details: details, + }) + } + + return items +} + +func collectStuckWorkers(townRoot string) []AttentionItem { + var items []AttentionItem + + // Query agent beads with stuck state + // Check each rig's beads for stuck agents + rigDirs, _ := filepath.Glob(filepath.Join(townRoot, "*", "mayor", "rig", ".beads")) + for _, rigBeads := range rigDirs { + rigItems := queryStuckAgents(rigBeads) + items = append(items, rigItems...) + } + + return items +} + +func queryStuckAgents(beadsPath string) []AttentionItem { + var items []AttentionItem + + // Query agents with stuck state + dbPath := filepath.Join(beadsPath, "beads.db") + if _, err := os.Stat(dbPath); err != nil { + return items + } + + // Query for agent beads with agent_state = 'stuck' + query := `SELECT id, title, agent_state FROM issues WHERE issue_type = 'agent' AND agent_state = 'stuck'` + queryCmd := exec.Command("sqlite3", "-json", dbPath, query) + var stdout bytes.Buffer + queryCmd.Stdout = &stdout + + if err := queryCmd.Run(); err != nil { + return items + } + + var agents []struct { + ID string `json:"id"` + Title string `json:"title"` + AgentState string `json:"agent_state"` + } + if err := json.Unmarshal(stdout.Bytes(), &agents); err != nil { + return items + } + + for _, agent := range agents { + // Extract agent name from ID (e.g., "gt-gastown-polecat-goose" -> "goose") + parts := strings.Split(agent.ID, "-") + name := parts[len(parts)-1] + + items = append(items, AttentionItem{ + Category: CategoryStuck, + Priority: 1, // Stuck workers are high priority + ID: agent.ID, + Title: fmt.Sprintf("Worker %s is stuck", name), + Context: "Agent escalated - needs help", + DrillDown: fmt.Sprintf("bd show %s", agent.ID), + Source: "agent", + }) + } + + return items +} + +func outputAttentionText(output AttentionOutput) error { + hasContent := false + + // Decisions + if len(output.Decisions) > 0 { + hasContent = true + fmt.Printf("%s (%d items)\n", style.Bold.Render("REQUIRES DECISION"), len(output.Decisions)) + for i, item := range output.Decisions { + fmt.Printf("%d. [P%d] %s: %s\n", i+1, item.Priority, item.ID, item.Title) + if item.Context != "" { + fmt.Printf(" %s\n", style.Dim.Render(item.Context)) + } + fmt.Printf(" %s\n\n", style.Dim.Render("→ "+item.DrillDown)) + } + } + + // Reviews + if len(output.Reviews) > 0 { + hasContent = true + fmt.Printf("%s (%d items)\n", style.Bold.Render("REQUIRES REVIEW"), len(output.Reviews)) + for i, item := range output.Reviews { + fmt.Printf("%d. [P%d] %s: %s\n", i+1, item.Priority, item.ID, item.Title) + if item.Details != "" { + fmt.Printf(" %s\n", style.Dim.Render(item.Details)) + } + if item.Context != "" { + fmt.Printf(" %s\n", style.Dim.Render(item.Context)) + } + fmt.Printf(" %s\n\n", style.Dim.Render("→ "+item.DrillDown)) + } + } + + // Stuck Workers + if len(output.StuckWorkers) > 0 { + hasContent = true + fmt.Printf("%s (%d items)\n", style.Bold.Render("STUCK WORKERS"), len(output.StuckWorkers)) + for i, item := range output.StuckWorkers { + fmt.Printf("%d. %s\n", i+1, item.Title) + if item.Context != "" { + fmt.Printf(" %s\n", style.Dim.Render(item.Context)) + } + fmt.Printf(" %s\n\n", style.Dim.Render("→ "+item.DrillDown)) + } + } + + // Blocked + if len(output.Blocked) > 0 { + hasContent = true + fmt.Printf("%s (%d items)\n", style.Bold.Render("BLOCKED"), len(output.Blocked)) + for i, item := range output.Blocked { + fmt.Printf("%d. [P%d] %s: %s\n", i+1, item.Priority, item.ID, item.Title) + if item.Context != "" { + fmt.Printf(" %s\n", style.Dim.Render(item.Context)) + } + fmt.Printf(" %s\n\n", style.Dim.Render("→ "+item.DrillDown)) + } + } + + if !hasContent { + fmt.Println("No items requiring attention.") + fmt.Println(style.Dim.Render("All clear - nothing blocked, no pending reviews.")) + } + + return nil +} diff --git a/internal/cmd/convoy.go b/internal/cmd/convoy.go index d2aa4a57..5c6ec984 100644 --- a/internal/cmd/convoy.go +++ b/internal/cmd/convoy.go @@ -9,6 +9,7 @@ import ( "os" "os/exec" "path/filepath" + "sort" "strconv" "strings" "sync" @@ -69,6 +70,9 @@ var ( convoyListStatus string convoyListAll bool convoyListTree bool + convoyListOrphans bool + convoyListEpic string + convoyListByEpic bool convoyInteractive bool convoyStrandedJSON bool convoyCloseReason string @@ -159,6 +163,9 @@ Examples: gt convoy list --all # All convoys (open + closed) gt convoy list --status=closed # Recently landed gt convoy list --tree # Show convoy + child status tree + gt convoy list --orphans # Convoys with no parent epic + gt convoy list --epic gt-abc # Convoys linked to specific epic + gt convoy list --by-epic # Group convoys by parent epic gt convoy list --json`, RunE: runConvoyList, } @@ -253,6 +260,9 @@ func init() { convoyListCmd.Flags().StringVar(&convoyListStatus, "status", "", "Filter by status (open, closed)") convoyListCmd.Flags().BoolVar(&convoyListAll, "all", false, "Show all convoys (open and closed)") convoyListCmd.Flags().BoolVar(&convoyListTree, "tree", false, "Show convoy + child status tree") + convoyListCmd.Flags().BoolVar(&convoyListOrphans, "orphans", false, "Show only orphan convoys (no parent epic)") + convoyListCmd.Flags().StringVar(&convoyListEpic, "epic", "", "Show convoys for a specific epic") + convoyListCmd.Flags().BoolVar(&convoyListByEpic, "by-epic", false, "Group convoys by parent epic") // Interactive TUI flag (on parent command) convoyCmd.Flags().BoolVarP(&convoyInteractive, "interactive", "i", false, "Interactive tree view") @@ -1163,6 +1173,16 @@ func showAllConvoyStatus(townBeads string) error { return nil } +// convoyListItem holds convoy info for list display. +type convoyListItem struct { + ID string `json:"id"` + Title string `json:"title"` + Status string `json:"status"` + CreatedAt string `json:"created_at"` + ParentEpic string `json:"parent_epic,omitempty"` + Description string `json:"description,omitempty"` +} + func runConvoyList(cmd *cobra.Command, args []string) error { townBeads, err := getTownBeadsDir() if err != nil { @@ -1187,16 +1207,58 @@ func runConvoyList(cmd *cobra.Command, args []string) error { return fmt.Errorf("listing convoys: %w", err) } - var convoys []struct { - ID string `json:"id"` - Title string `json:"title"` - Status string `json:"status"` - CreatedAt string `json:"created_at"` + var rawConvoys []struct { + ID string `json:"id"` + Title string `json:"title"` + Status string `json:"status"` + CreatedAt string `json:"created_at"` + Description string `json:"description"` } - if err := json.Unmarshal(stdout.Bytes(), &convoys); err != nil { + if err := json.Unmarshal(stdout.Bytes(), &rawConvoys); err != nil { return fmt.Errorf("parsing convoy list: %w", err) } + // Convert to convoyListItem and extract parent_epic from description + convoys := make([]convoyListItem, 0, len(rawConvoys)) + for _, rc := range rawConvoys { + item := convoyListItem{ + ID: rc.ID, + Title: rc.Title, + Status: rc.Status, + CreatedAt: rc.CreatedAt, + Description: rc.Description, + } + // Extract parent_epic from description (format: "Parent-Epic: xxx") + for _, line := range strings.Split(rc.Description, "\n") { + if strings.HasPrefix(line, "Parent-Epic: ") { + item.ParentEpic = strings.TrimPrefix(line, "Parent-Epic: ") + break + } + } + convoys = append(convoys, item) + } + + // Apply filtering based on new flags + if convoyListOrphans { + // Filter to only orphan convoys (no parent epic) + filtered := make([]convoyListItem, 0) + for _, c := range convoys { + if c.ParentEpic == "" { + filtered = append(filtered, c) + } + } + convoys = filtered + } else if convoyListEpic != "" { + // Filter to convoys linked to specific epic + filtered := make([]convoyListItem, 0) + for _, c := range convoys { + if c.ParentEpic == convoyListEpic { + filtered = append(filtered, c) + } + } + convoys = filtered + } + if convoyListJSON { enc := json.NewEncoder(os.Stdout) enc.SetIndent("", " ") @@ -1204,26 +1266,133 @@ func runConvoyList(cmd *cobra.Command, args []string) error { } if len(convoys) == 0 { - fmt.Println("No convoys found.") + if convoyListOrphans { + fmt.Println("No orphan convoys found.") + } else if convoyListEpic != "" { + fmt.Printf("No convoys found for epic %s.\n", convoyListEpic) + } else { + fmt.Println("No convoys found.") + } fmt.Println("Create a convoy with: gt convoy create [issues...]") return nil } + // Group by epic view + if convoyListByEpic { + return printConvoysByEpic(townBeads, convoys) + } + // Tree view: show convoys with their child issues if convoyListTree { - return printConvoyTree(townBeads, convoys) + return printConvoyTreeFromItems(townBeads, convoys) } fmt.Printf("%s\n\n", style.Bold.Render("Convoys")) for i, c := range convoys { status := formatConvoyStatus(c.Status) - fmt.Printf(" %d. 🚚 %s: %s %s\n", i+1, c.ID, c.Title, status) + epicSuffix := "" + if c.ParentEpic != "" { + epicSuffix = style.Dim.Render(fmt.Sprintf(" [%s]", c.ParentEpic)) + } + fmt.Printf(" %d. 🚚 %s: %s %s%s\n", i+1, c.ID, c.Title, status, epicSuffix) } fmt.Printf("\nUse 'gt convoy status ' or 'gt convoy status ' for detailed view.\n") return nil } +// printConvoysByEpic groups and displays convoys by their parent epic. +func printConvoysByEpic(townBeads string, convoys []convoyListItem) error { + // Group convoys by parent epic + byEpic := make(map[string][]convoyListItem) + for _, c := range convoys { + epic := c.ParentEpic + if epic == "" { + epic = "(No Epic)" + } + byEpic[epic] = append(byEpic[epic], c) + } + + // Get sorted epic keys (No Epic last) + var epics []string + for epic := range byEpic { + if epic != "(No Epic)" { + epics = append(epics, epic) + } + } + sort.Strings(epics) + if _, ok := byEpic["(No Epic)"]; ok { + epics = append(epics, "(No Epic)") + } + + // Print grouped output + for _, epic := range epics { + convoys := byEpic[epic] + fmt.Printf("%s (%d convoys)\n", style.Bold.Render(epic), len(convoys)) + for _, c := range convoys { + status := formatConvoyStatus(c.Status) + fmt.Printf(" 🚚 %s: %s %s\n", c.ID, c.Title, status) + } + fmt.Println() + } + + return nil +} + +// printConvoyTreeFromItems displays convoys with their child issues in a tree format. +func printConvoyTreeFromItems(townBeads string, convoys []convoyListItem) error { + for _, c := range convoys { + // Get tracked issues for this convoy + tracked := getTrackedIssues(townBeads, c.ID) + + // Count completed + completed := 0 + for _, t := range tracked { + if t.Status == "closed" { + completed++ + } + } + + // Print convoy header with progress + total := len(tracked) + progress := "" + if total > 0 { + progress = fmt.Sprintf(" (%d/%d)", completed, total) + } + epicSuffix := "" + if c.ParentEpic != "" { + epicSuffix = style.Dim.Render(fmt.Sprintf(" [%s]", c.ParentEpic)) + } + fmt.Printf("🚚 %s: %s%s%s\n", c.ID, c.Title, progress, epicSuffix) + + // Print tracked issues as tree children + for i, t := range tracked { + // Determine tree connector + isLast := i == len(tracked)-1 + connector := "├──" + if isLast { + connector = "└──" + } + + // Status symbol: ✓ closed, ▶ in_progress/hooked, ○ other + status := "○" + switch t.Status { + case "closed": + status = "✓" + case "in_progress", "hooked": + status = "▶" + } + + fmt.Printf("%s %s %s: %s\n", connector, status, t.ID, t.Title) + } + + // Add blank line between convoys + fmt.Println() + } + + return nil +} + // printConvoyTree displays convoys with their child issues in a tree format. func printConvoyTree(townBeads string, convoys []struct { ID string `json:"id"` diff --git a/internal/cmd/focus.go b/internal/cmd/focus.go new file mode 100644 index 00000000..c9286914 --- /dev/null +++ b/internal/cmd/focus.go @@ -0,0 +1,351 @@ +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) +} diff --git a/internal/cmd/status.go b/internal/cmd/status.go index 4721b3ea..ca0ed17c 100644 --- a/internal/cmd/status.go +++ b/internal/cmd/status.go @@ -6,6 +6,7 @@ import ( "os" "os/signal" "path/filepath" + "sort" "strings" "sync" "syscall" @@ -439,6 +440,55 @@ func outputStatusText(status TownStatus) error { fmt.Println() } + // Goals summary (top 3 stalest high-priority) + goals, _ := collectFocusItems(status.Location) + // Sort by score (highest first) + sort.Slice(goals, func(i, j int) bool { + return goals[i].Score > goals[j].Score + }) + if len(goals) > 0 { + fmt.Printf("%s (%d active)\n", style.Bold.Render("GOALS"), len(goals)) + // Show top 3 + showCount := 3 + if len(goals) < showCount { + showCount = len(goals) + } + for i := 0; i < showCount; i++ { + g := goals[i] + var indicator string + switch g.Staleness { + case "stuck": + indicator = style.Error.Render("🔴") + case "stale": + indicator = style.Warning.Render("🟡") + default: + indicator = style.Success.Render("🟢") + } + fmt.Printf(" %s P%d %s: %s\n", indicator, g.Priority, g.ID, truncateWithEllipsis(g.Title, 40)) + } + if len(goals) > showCount { + fmt.Printf(" %s\n", style.Dim.Render(fmt.Sprintf("... and %d more (gt focus)", len(goals)-showCount))) + } + fmt.Println() + } + + // Attention summary (blocked items, reviews) + attention := collectAttentionSummary(status.Location) + if attention.Total > 0 { + fmt.Printf("%s (%d items)\n", style.Bold.Render("ATTENTION"), attention.Total) + if attention.Blocked > 0 { + fmt.Printf(" • %d blocked issue(s)\n", attention.Blocked) + } + if attention.Reviews > 0 { + fmt.Printf(" • %d PR(s) awaiting review\n", attention.Reviews) + } + if attention.Stuck > 0 { + fmt.Printf(" • %d stuck worker(s)\n", attention.Stuck) + } + fmt.Printf(" %s\n", style.Dim.Render("→ gt attention for details")) + fmt.Println() + } + // Role icons - uses centralized emojis from constants package roleIcons := map[string]string{ constants.RoleMayor: constants.EmojiMayor, @@ -1232,3 +1282,36 @@ func getAgentHook(b *beads.Beads, role, agentAddress, roleType string) AgentHook return hook } + +// AttentionSummary holds counts of items needing attention for status display. +type AttentionSummary struct { + Blocked int + Reviews int + Stuck int + Decisions int + Total int +} + +// collectAttentionSummary gathers counts of items needing attention. +func collectAttentionSummary(townRoot string) AttentionSummary { + summary := AttentionSummary{} + + // Count blocked items (reuse logic from attention.go) + blocked := collectBlockedItems(townRoot) + summary.Blocked = len(blocked) + + // Count reviews + reviews := collectReviewItems(townRoot) + summary.Reviews = len(reviews) + + // Count stuck workers + stuck := collectStuckWorkers(townRoot) + summary.Stuck = len(stuck) + + // Count decisions + decisions := collectDecisionItems(townRoot) + summary.Decisions = len(decisions) + + summary.Total = summary.Blocked + summary.Reviews + summary.Stuck + summary.Decisions + return summary +}