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
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -69,6 +70,9 @@ var (
|
|||||||
convoyListStatus string
|
convoyListStatus string
|
||||||
convoyListAll bool
|
convoyListAll bool
|
||||||
convoyListTree bool
|
convoyListTree bool
|
||||||
|
convoyListOrphans bool
|
||||||
|
convoyListEpic string
|
||||||
|
convoyListByEpic bool
|
||||||
convoyInteractive bool
|
convoyInteractive bool
|
||||||
convoyStrandedJSON bool
|
convoyStrandedJSON bool
|
||||||
convoyCloseReason string
|
convoyCloseReason string
|
||||||
@@ -159,6 +163,9 @@ Examples:
|
|||||||
gt convoy list --all # All convoys (open + closed)
|
gt convoy list --all # All convoys (open + closed)
|
||||||
gt convoy list --status=closed # Recently landed
|
gt convoy list --status=closed # Recently landed
|
||||||
gt convoy list --tree # Show convoy + child status tree
|
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`,
|
gt convoy list --json`,
|
||||||
RunE: runConvoyList,
|
RunE: runConvoyList,
|
||||||
}
|
}
|
||||||
@@ -253,6 +260,9 @@ func init() {
|
|||||||
convoyListCmd.Flags().StringVar(&convoyListStatus, "status", "", "Filter by status (open, closed)")
|
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(&convoyListAll, "all", false, "Show all convoys (open and closed)")
|
||||||
convoyListCmd.Flags().BoolVar(&convoyListTree, "tree", false, "Show convoy + child status tree")
|
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)
|
// Interactive TUI flag (on parent command)
|
||||||
convoyCmd.Flags().BoolVarP(&convoyInteractive, "interactive", "i", false, "Interactive tree view")
|
convoyCmd.Flags().BoolVarP(&convoyInteractive, "interactive", "i", false, "Interactive tree view")
|
||||||
@@ -1163,6 +1173,16 @@ func showAllConvoyStatus(townBeads string) error {
|
|||||||
return nil
|
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 {
|
func runConvoyList(cmd *cobra.Command, args []string) error {
|
||||||
townBeads, err := getTownBeadsDir()
|
townBeads, err := getTownBeadsDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1187,16 +1207,58 @@ func runConvoyList(cmd *cobra.Command, args []string) error {
|
|||||||
return fmt.Errorf("listing convoys: %w", err)
|
return fmt.Errorf("listing convoys: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var convoys []struct {
|
var rawConvoys []struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
CreatedAt string `json:"created_at"`
|
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)
|
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 {
|
if convoyListJSON {
|
||||||
enc := json.NewEncoder(os.Stdout)
|
enc := json.NewEncoder(os.Stdout)
|
||||||
enc.SetIndent("", " ")
|
enc.SetIndent("", " ")
|
||||||
@@ -1204,26 +1266,133 @@ func runConvoyList(cmd *cobra.Command, args []string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if len(convoys) == 0 {
|
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...]")
|
fmt.Println("Create a convoy with: gt convoy create <name> [issues...]")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Group by epic view
|
||||||
|
if convoyListByEpic {
|
||||||
|
return printConvoysByEpic(townBeads, convoys)
|
||||||
|
}
|
||||||
|
|
||||||
// Tree view: show convoys with their child issues
|
// Tree view: show convoys with their child issues
|
||||||
if convoyListTree {
|
if convoyListTree {
|
||||||
return printConvoyTree(townBeads, convoys)
|
return printConvoyTreeFromItems(townBeads, convoys)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("%s\n\n", style.Bold.Render("Convoys"))
|
fmt.Printf("%s\n\n", style.Bold.Render("Convoys"))
|
||||||
for i, c := range convoys {
|
for i, c := range convoys {
|
||||||
status := formatConvoyStatus(c.Status)
|
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")
|
fmt.Printf("\nUse 'gt convoy status <id>' or 'gt convoy status <n>' for detailed view.\n")
|
||||||
|
|
||||||
return nil
|
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.
|
// printConvoyTree displays convoys with their child issues in a tree format.
|
||||||
func printConvoyTree(townBeads string, convoys []struct {
|
func printConvoyTree(townBeads string, convoys []struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
|
|||||||
351
internal/cmd/focus.go
Normal file
351
internal/cmd/focus.go
Normal 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)
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
@@ -439,6 +440,55 @@ func outputStatusText(status TownStatus) error {
|
|||||||
fmt.Println()
|
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
|
// Role icons - uses centralized emojis from constants package
|
||||||
roleIcons := map[string]string{
|
roleIcons := map[string]string{
|
||||||
constants.RoleMayor: constants.EmojiMayor,
|
constants.RoleMayor: constants.EmojiMayor,
|
||||||
@@ -1232,3 +1282,36 @@ func getAgentHook(b *beads.Beads, role, agentAddress, roleType string) AgentHook
|
|||||||
|
|
||||||
return hook
|
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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user