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
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:
374
internal/cmd/attention.go
Normal file
374
internal/cmd/attention.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user