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>
352 lines
9.4 KiB
Go
352 lines
9.4 KiB
Go
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)
|
||
}
|