feat: add overseer experience commands (gt focus, gt attention)
Some checks failed
CI / Check for .beads changes (push) Has been skipped
CI / Check embedded formulas (push) Failing after 15s
CI / Test (push) Failing after 1m29s
CI / Lint (push) Failing after 19s
CI / Integration Tests (push) Successful in 1m14s
CI / Coverage Report (push) Has been skipped
Windows CI / Windows Build and Unit Tests (push) Has been cancelled

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:
kerosene
2026-01-22 18:27:41 -08:00
committed by John Ogle
parent 41760bf464
commit f65c4ecfc8
4 changed files with 986 additions and 9 deletions

351
internal/cmd/focus.go Normal file
View 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)
}