feat: add overseer experience commands (gt focus, gt attention)
Implements the Overseer Experience epic (gt-k0kn): - gt focus: Shows stalest high-priority goals, sorted by priority × staleness - gt attention: Shows blocked items, PRs awaiting review, stuck workers - gt status: Now includes GOALS and ATTENTION summary sections - gt convoy list: Added --orphans, --epic, --by-epic flags These commands reduce Mayor bottleneck by giving the overseer direct visibility into system state without needing to ask Mayor. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
374
internal/cmd/attention.go
Normal file
374
internal/cmd/attention.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
@@ -1169,6 +1179,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 {
|
||||
@@ -1193,16 +1213,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("", " ")
|
||||
@@ -1210,26 +1272,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 <name> [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 <id>' or 'gt convoy status <n>' 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"`
|
||||
|
||||
351
internal/cmd/focus.go
Normal file
351
internal/cmd/focus.go
Normal file
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user