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

374
internal/cmd/attention.go Normal file
View 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
}

View File

@@ -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
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)
}

View File

@@ -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
}