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 }