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

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")
@@ -1163,6 +1173,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 {
@@ -1187,16 +1207,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("", " ")
@@ -1204,26 +1266,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"`