Files
gastown/internal/cmd/goals.go
diesel 05b716f4a3 perf(goals): optimize gt goals from 6s to <50ms via direct SQLite (gt-aps.3)
Replace bd subprocess spawns with direct SQLite queries:
- queryEpicsInDir: direct sqlite3 query vs bd list subprocess
- getLinkedConvoys: direct JOIN query vs bd dep list + getIssueDetails loop
- computeGoalLastMovement: reuse epic.UpdatedAt vs separate bd show call

Also includes mailbox optimization from earlier session:
- Consolidated multiple parallel queries into single bd list --all query
- Filters in Go instead of spawning O(identities × statuses) bd processes

177x improvement (6.2s → 35ms) by eliminating subprocess overhead.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 11:17:16 -08:00

652 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package cmd
import (
"bytes"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/beads"
"github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/workspace"
)
// Goal command flags
var (
goalsJSON bool
goalsStatus string
goalsPriority string
goalsIncludeWisp bool
)
var goalsCmd = &cobra.Command{
Use: "goals [goal-id]",
GroupID: GroupWork,
Short: "View strategic goals (epics) with staleness indicators",
Long: `View strategic goals (epics) across the workspace.
Goals are high-level objectives that organize related work items.
This command shows goals with staleness indicators to help identify
stale or neglected strategic initiatives.
Staleness indicators:
🟢 active: movement in last hour
🟡 stale: no movement for 1+ hours
🔴 stuck: no movement for 4+ hours
Goals are sorted by staleness × priority (highest attention needed first).
Examples:
gt goals # List all open goals
gt goals --json # Output as JSON
gt goals --status=all # Show all goals including closed
gt goals gt-abc # Show details for a specific goal`,
RunE: runGoals,
}
func init() {
goalsCmd.Flags().BoolVar(&goalsJSON, "json", false, "Output as JSON")
goalsCmd.Flags().StringVar(&goalsStatus, "status", "open", "Filter by status (open, closed, all)")
goalsCmd.Flags().StringVar(&goalsPriority, "priority", "", "Filter by priority (e.g., P0, P1, P2)")
goalsCmd.Flags().BoolVar(&goalsIncludeWisp, "include-wisp", false, "Include transient wisp molecules (normally hidden)")
rootCmd.AddCommand(goalsCmd)
}
func runGoals(cmd *cobra.Command, args []string) error {
// If arg provided, show specific goal
if len(args) > 0 {
goalID := args[0]
return showGoal(goalID)
}
// Otherwise list all goals
return listGoals()
}
// goalInfo holds computed goal data for display and sorting.
type goalInfo struct {
ID string `json:"id"`
Title string `json:"title"`
Status string `json:"status"`
Priority int `json:"priority"`
Assignee string `json:"assignee,omitempty"`
ConvoyCount int `json:"convoy_count"`
LastMovement time.Time `json:"last_movement,omitempty"`
StalenessHrs float64 `json:"staleness_hours"`
StalenessIcon string `json:"staleness_icon"`
Score float64 `json:"score"` // priority × staleness for sorting
}
func showGoal(goalID string) error {
// Get goal details via bd show
showCmd := exec.Command("bd", "show", goalID, "--json")
var stdout bytes.Buffer
showCmd.Stdout = &stdout
if err := showCmd.Run(); err != nil {
return fmt.Errorf("goal '%s' not found", goalID)
}
var goals []struct {
ID string `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
Status string `json:"status"`
Priority int `json:"priority"`
IssueType string `json:"issue_type"`
Assignee string `json:"assignee"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
if err := json.Unmarshal(stdout.Bytes(), &goals); err != nil {
return fmt.Errorf("parsing goal data: %w", err)
}
if len(goals) == 0 {
return fmt.Errorf("goal '%s' not found", goalID)
}
goal := goals[0]
// Verify it's an epic
if goal.IssueType != "epic" {
return fmt.Errorf("'%s' is not a goal/epic (type: %s)", goalID, goal.IssueType)
}
// Get linked convoys (no dbPath available for single goal lookup, use fallback)
convoys := getLinkedConvoys(goalID, "")
// Compute staleness
lastMovement := computeGoalLastMovement(goal.UpdatedAt, convoys)
stalenessHrs := time.Since(lastMovement).Hours()
icon := stalenessIcon(stalenessHrs)
if goalsJSON {
out := goalInfo{
ID: goal.ID,
Title: goal.Title,
Status: goal.Status,
Priority: goal.Priority,
Assignee: goal.Assignee,
ConvoyCount: len(convoys),
LastMovement: lastMovement,
StalenessHrs: stalenessHrs,
StalenessIcon: icon,
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(out)
}
// Human-readable output
fmt.Printf("%s P%d %s: %s\n\n", icon, goal.Priority, style.Bold.Render(goal.ID), goal.Title)
fmt.Printf(" Status: %s\n", goal.Status)
fmt.Printf(" Priority: P%d\n", goal.Priority)
if goal.Assignee != "" {
fmt.Printf(" Assignee: @%s\n", goal.Assignee)
}
fmt.Printf(" Convoys: %d\n", len(convoys))
fmt.Printf(" Last activity: %s\n", formatLastActivity(lastMovement))
if goal.Description != "" {
fmt.Printf("\n %s\n", style.Bold.Render("Description:"))
// Indent description
for _, line := range strings.Split(goal.Description, "\n") {
fmt.Printf(" %s\n", line)
}
}
if len(convoys) > 0 {
fmt.Printf("\n %s\n", style.Bold.Render("Linked Convoys:"))
for _, c := range convoys {
statusIcon := "○"
if c.Status == "closed" {
statusIcon = "✓"
}
fmt.Printf(" %s %s: %s\n", statusIcon, c.ID, c.Title)
}
}
return nil
}
func listGoals() error {
// Collect epics from all rigs (goals are cross-rig strategic objectives)
epics, err := collectEpicsFromAllRigs()
if err != nil {
return err
}
// Filter out wisp molecules by default (transient/operational, not strategic goals)
// These have IDs like "gt-wisp-*" and are molecule-tracking beads, not human goals
if !goalsIncludeWisp {
filtered := make([]epicRecord, 0)
for _, e := range epics {
if !isWispEpic(e.ID, e.Title) {
filtered = append(filtered, e)
}
}
epics = filtered
}
// Filter by priority if specified
if goalsPriority != "" {
targetPriority := parsePriority(goalsPriority)
filtered := make([]epicRecord, 0)
for _, e := range epics {
if e.Priority == targetPriority {
filtered = append(filtered, e)
}
}
epics = filtered
}
// Build goal info with staleness computation
var goals []goalInfo
for _, epic := range epics {
convoys := getLinkedConvoys(epic.ID, epic.dbPath)
lastMovement := computeGoalLastMovement(epic.UpdatedAt, convoys)
stalenessHrs := time.Since(lastMovement).Hours()
icon := stalenessIcon(stalenessHrs)
// Score = priority_value × staleness_hours
// Lower priority number = higher priority, so invert (4 - priority)
priorityWeight := float64(4 - epic.Priority)
if priorityWeight < 1 {
priorityWeight = 1
}
score := priorityWeight * stalenessHrs
goals = append(goals, goalInfo{
ID: epic.ID,
Title: epic.Title,
Status: epic.Status,
Priority: epic.Priority,
Assignee: epic.Assignee,
ConvoyCount: len(convoys),
LastMovement: lastMovement,
StalenessHrs: stalenessHrs,
StalenessIcon: icon,
Score: score,
})
}
// Sort by score (highest attention needed first)
sort.Slice(goals, func(i, j int) bool {
return goals[i].Score > goals[j].Score
})
if goalsJSON {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(goals)
}
if len(goals) == 0 {
fmt.Println("No goals found.")
fmt.Println("Create a goal with: bd create --type=epic --title=\"Goal name\"")
return nil
}
// Count active (non-closed) goals
activeCount := 0
for _, g := range goals {
if g.Status != "closed" {
activeCount++
}
}
fmt.Printf("%s\n\n", style.Bold.Render(fmt.Sprintf("Goals (%d active, sorted by staleness × priority)", activeCount)))
for _, g := range goals {
// Format: 🔴 P1 sc-xyz: Title
// 3 convoys | stale 6h
priorityStr := fmt.Sprintf("P%d", g.Priority)
fmt.Printf(" %s %s %s: %s\n", g.StalenessIcon, priorityStr, g.ID, g.Title)
// Second line with convoy count, staleness, and assignee (if any)
activityStr := formatActivityShort(g.StalenessHrs)
if g.Assignee != "" {
fmt.Printf(" %d convoy(s) | %s | @%s\n\n", g.ConvoyCount, activityStr, g.Assignee)
} else {
fmt.Printf(" %d convoy(s) | %s\n\n", g.ConvoyCount, activityStr)
}
}
return nil
}
// convoyInfo holds basic convoy info.
type convoyInfo struct {
ID string
Title string
Status string
}
// getLinkedConvoys finds convoys linked to a goal (via parent-child relation).
// dbPath is the path to beads.db containing the goal for direct SQLite queries.
func getLinkedConvoys(goalID, dbPath string) []convoyInfo {
var convoys []convoyInfo
// If no dbPath provided, fall back to bd subprocess (shouldn't happen normally)
if dbPath == "" {
return getLinkedConvoysFallback(goalID)
}
// Query dependencies directly from SQLite
// Children are stored as: depends_on_id = goalID (parent) with type 'blocks'
safeGoalID := strings.ReplaceAll(goalID, "'", "''")
query := fmt.Sprintf(`
SELECT i.id, i.title, i.status
FROM dependencies d
JOIN issues i ON d.issue_id = i.id
WHERE d.depends_on_id = '%s' AND d.type = 'blocks' AND i.issue_type = 'convoy'
`, safeGoalID)
queryCmd := exec.Command("sqlite3", "-json", dbPath, query)
var stdout bytes.Buffer
queryCmd.Stdout = &stdout
if err := queryCmd.Run(); err != nil {
return convoys
}
if stdout.Len() == 0 {
return convoys
}
var results []struct {
ID string `json:"id"`
Title string `json:"title"`
Status string `json:"status"`
}
if err := json.Unmarshal(stdout.Bytes(), &results); err != nil {
return convoys
}
for _, r := range results {
convoys = append(convoys, convoyInfo{
ID: r.ID,
Title: r.Title,
Status: r.Status,
})
}
return convoys
}
// getLinkedConvoysFallback uses bd subprocess (for when dbPath is unknown).
func getLinkedConvoysFallback(goalID string) []convoyInfo {
var convoys []convoyInfo
depArgs := []string{"dep", "list", goalID, "--json"}
depCmd := exec.Command("bd", depArgs...)
var stdout bytes.Buffer
depCmd.Stdout = &stdout
if err := depCmd.Run(); err != nil {
return convoys
}
var deps struct {
Children []struct {
ID string `json:"id"`
Type string `json:"type"`
} `json:"children"`
}
if err := json.Unmarshal(stdout.Bytes(), &deps); err != nil {
return convoys
}
for _, child := range deps.Children {
details := getIssueDetails(child.ID)
if details != nil && details.IssueType == "convoy" {
convoys = append(convoys, convoyInfo{
ID: details.ID,
Title: details.Title,
Status: details.Status,
})
}
}
return convoys
}
// computeGoalLastMovement computes when the goal last had activity.
// It looks at:
// 1. The goal's own updated_at (passed directly to avoid re-querying)
// 2. The last activity of any linked convoy's tracked issues
func computeGoalLastMovement(goalUpdatedAt string, convoys []convoyInfo) time.Time {
// Start with the goal's own updated_at
lastMovement := time.Now().Add(-24 * time.Hour) // Default to 24 hours ago
if goalUpdatedAt != "" {
if t, err := time.Parse(time.RFC3339, goalUpdatedAt); err == nil {
lastMovement = t
}
}
// If no convoys, return early (common case - avoids unnecessary work)
if len(convoys) == 0 {
return lastMovement
}
// Check convoy activity
townBeads, err := getTownBeadsDir()
if err != nil {
return lastMovement
}
for _, convoy := range convoys {
tracked := getTrackedIssues(townBeads, convoy.ID)
for _, t := range tracked {
// Get issue's updated_at
details := getIssueDetails(t.ID)
if details == nil {
continue
}
showCmd := exec.Command("bd", "show", t.ID, "--json")
var out bytes.Buffer
showCmd.Stdout = &out
showCmd.Run()
var issues []struct {
UpdatedAt string `json:"updated_at"`
}
json.Unmarshal(out.Bytes(), &issues)
if len(issues) > 0 && issues[0].UpdatedAt != "" {
if t, err := time.Parse(time.RFC3339, issues[0].UpdatedAt); err == nil {
if t.After(lastMovement) {
lastMovement = t
}
}
}
}
}
return lastMovement
}
// stalenessIcon returns the appropriate staleness indicator.
// 🟢 active: moved in last hour
// 🟡 stale: no movement for 1+ hours
// 🔴 stuck: no movement for 4+ hours
func stalenessIcon(hours float64) string {
if hours < 1 {
return "🟢"
}
if hours < 4 {
return "🟡"
}
return "🔴"
}
// formatLastActivity formats the last activity time for display.
func formatLastActivity(t time.Time) string {
if t.IsZero() {
return "unknown"
}
d := time.Since(t)
if d < time.Minute {
return "just now"
}
if d < time.Hour {
return fmt.Sprintf("%d minutes ago", int(d.Minutes()))
}
if d < 24*time.Hour {
return fmt.Sprintf("%d hours ago", int(d.Hours()))
}
return fmt.Sprintf("%d days ago", int(d.Hours()/24))
}
// formatActivityShort returns a short activity string for the list view.
func formatActivityShort(hours float64) string {
if hours < 1 {
mins := int(hours * 60)
if mins < 1 {
return "active just now"
}
return fmt.Sprintf("active %dm ago", mins)
}
if hours < 4 {
return fmt.Sprintf("stale %.0fh", hours)
}
return fmt.Sprintf("stuck %.0fh", hours)
}
// parsePriority converts a priority string (P0, P1, etc.) to an int.
func parsePriority(s string) int {
s = strings.TrimPrefix(strings.ToUpper(s), "P")
if p, err := strconv.Atoi(s); err == nil {
return p
}
return 2 // Default to P2
}
// isWispEpic returns true if the epic is a transient wisp molecule.
// These are operational/infrastructure beads, not strategic goals that need human attention.
// Detection criteria:
// - ID contains "-wisp-" (molecule tracking beads)
// - Title starts with "mol-" (molecule beads)
func isWispEpic(id, title string) bool {
// Check for wisp ID pattern (e.g., "gt-wisp-abc123")
if strings.Contains(id, "-wisp-") {
return true
}
// Check for molecule title pattern
if strings.HasPrefix(title, "mol-") {
return true
}
return false
}
// epicRecord represents an epic from bd list output.
type epicRecord 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"`
// dbPath is the path to beads.db containing this epic (for direct queries)
dbPath string
}
// collectEpicsFromAllRigs queries all rigs for epics and aggregates them.
// Goals are cross-rig strategic objectives, so we need to query each rig's beads.
func collectEpicsFromAllRigs() ([]epicRecord, error) {
var allEpics []epicRecord
seen := make(map[string]bool) // Deduplicate by ID
// Find the town root
townRoot, err := workspace.FindFromCwdOrError()
if err != nil {
// Not in a Gas Town workspace, fall back to single query
return queryEpicsInDir("")
}
// Also query town-level beads (for hq- prefixed epics)
townBeadsDir := filepath.Join(townRoot, ".beads")
if _, err := os.Stat(townBeadsDir); err == nil {
epics, err := queryEpicsInDir(townRoot)
if err == nil {
for _, e := range epics {
if !seen[e.ID] {
seen[e.ID] = true
allEpics = append(allEpics, e)
}
}
}
}
// Find all rig directories (they have .beads/ subdirectories)
entries, err := os.ReadDir(townRoot)
if err != nil {
return allEpics, nil // Return what we have
}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
// Skip hidden directories and known non-rig directories
name := entry.Name()
if strings.HasPrefix(name, ".") || name == "plugins" || name == "docs" {
continue
}
rigPath := filepath.Join(townRoot, name)
rigBeadsDir := filepath.Join(rigPath, ".beads")
// Check if this directory has a beads database
if _, err := os.Stat(rigBeadsDir); os.IsNotExist(err) {
continue
}
// Query this rig for epics
epics, err := queryEpicsInDir(rigPath)
if err != nil {
// Log but continue - one rig failing shouldn't stop the whole query
continue
}
for _, e := range epics {
if !seen[e.ID] {
seen[e.ID] = true
allEpics = append(allEpics, e)
}
}
}
return allEpics, nil
}
// queryEpicsInDir queries epics directly from SQLite in the specified directory.
// If dir is empty, uses current working directory.
func queryEpicsInDir(dir string) ([]epicRecord, error) {
beadsDir := dir
if beadsDir == "" {
var err error
beadsDir, err = os.Getwd()
if err != nil {
return nil, fmt.Errorf("getting working directory: %w", err)
}
}
// Resolve redirects to find actual beads.db
resolvedBeads := beads.ResolveBeadsDir(beadsDir)
dbPath := filepath.Join(resolvedBeads, "beads.db")
// Check if database exists
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
return nil, nil // No database, no epics
}
// Build SQL query for epics
query := `SELECT id, title, status, priority, updated_at, assignee
FROM issues
WHERE issue_type = 'epic'`
if goalsStatus == "" || goalsStatus == "open" {
query += ` AND status <> 'closed' AND status <> 'tombstone'`
} else if goalsStatus != "all" {
query += fmt.Sprintf(` AND status = '%s'`, strings.ReplaceAll(goalsStatus, "'", "''"))
} else {
// --all: exclude tombstones but include everything else
query += ` AND status <> 'tombstone'`
}
queryCmd := exec.Command("sqlite3", "-json", dbPath, query)
var stdout bytes.Buffer
queryCmd.Stdout = &stdout
if err := queryCmd.Run(); err != nil {
// Database might be empty or have no epics - not an error
return nil, nil
}
// Handle empty result (sqlite3 -json returns nothing for empty sets)
if stdout.Len() == 0 {
return nil, nil
}
var epics []epicRecord
if err := json.Unmarshal(stdout.Bytes(), &epics); err != nil {
return nil, fmt.Errorf("parsing epics: %w", err)
}
// Set dbPath on each epic for direct queries later
for i := range epics {
epics[i].dbPath = dbPath
}
return epics, nil
}