Files
gastown/internal/cmd/focus.go
kerosene 996cf4a670 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>
2026-01-26 10:31:16 -08:00

352 lines
9.4 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}