package cmd import ( "bufio" "encoding/json" "fmt" "os" "path/filepath" "sort" "strings" "sync" "github.com/spf13/cobra" "github.com/steveyegge/gastown/internal/beads" "github.com/steveyegge/gastown/internal/config" "github.com/steveyegge/gastown/internal/constants" "github.com/steveyegge/gastown/internal/git" "github.com/steveyegge/gastown/internal/rig" "github.com/steveyegge/gastown/internal/style" "github.com/steveyegge/gastown/internal/workspace" ) var readyJSON bool var readyRig string var readyCmd = &cobra.Command{ Use: "ready", GroupID: GroupWork, Short: "Show work ready across town", Long: `Display all ready work items across the town and all rigs. Aggregates ready issues from: - Town beads (hq-* items: convoys, cross-rig coordination) - Each rig's beads (project-level issues, MRs) Ready items have no blockers and can be worked immediately. Results are sorted by priority (highest first) then by source. Examples: gt ready # Show all ready work gt ready --json # Output as JSON gt ready --rig=gastown # Show only one rig`, RunE: runReady, } func init() { readyCmd.Flags().BoolVar(&readyJSON, "json", false, "Output as JSON") readyCmd.Flags().StringVar(&readyRig, "rig", "", "Filter to a specific rig") rootCmd.AddCommand(readyCmd) } // ReadySource represents ready items from a single source (town or rig). type ReadySource struct { Name string `json:"name"` // "town" or rig name Issues []*beads.Issue `json:"issues"` // Ready issues from this source Error string `json:"error,omitempty"` } // ReadyResult is the aggregated result of gt ready. type ReadyResult struct { Sources []ReadySource `json:"sources"` Summary ReadySummary `json:"summary"` TownRoot string `json:"town_root,omitempty"` } // ReadySummary provides counts for the ready report. type ReadySummary struct { Total int `json:"total"` BySource map[string]int `json:"by_source"` P0Count int `json:"p0_count"` P1Count int `json:"p1_count"` P2Count int `json:"p2_count"` P3Count int `json:"p3_count"` P4Count int `json:"p4_count"` } func runReady(cmd *cobra.Command, args []string) error { // Find town root townRoot, err := workspace.FindFromCwdOrError() if err != nil { return fmt.Errorf("not in a Gas Town workspace: %w", err) } // Load rigs config rigsConfigPath := constants.MayorRigsPath(townRoot) rigsConfig, err := config.LoadRigsConfig(rigsConfigPath) if err != nil { rigsConfig = &config.RigsConfig{Rigs: make(map[string]config.RigEntry)} } // Create rig manager and discover rigs g := git.NewGit(townRoot) mgr := rig.NewManager(townRoot, rigsConfig, g) rigs, err := mgr.DiscoverRigs() if err != nil { return fmt.Errorf("discovering rigs: %w", err) } // Filter rigs if --rig flag provided if readyRig != "" { var filtered []*rig.Rig for _, r := range rigs { if r.Name == readyRig { filtered = append(filtered, r) break } } if len(filtered) == 0 { return fmt.Errorf("rig not found: %s", readyRig) } rigs = filtered } // Collect results from all sources in parallel var wg sync.WaitGroup var mu sync.Mutex sources := make([]ReadySource, 0, len(rigs)+1) // Fetch town beads (only if not filtering to a specific rig) if readyRig == "" { wg.Add(1) go func() { defer wg.Done() townBeadsPath := beads.GetTownBeadsPath(townRoot) townBeads := beads.New(townBeadsPath) issues, err := townBeads.Ready() mu.Lock() defer mu.Unlock() src := ReadySource{Name: "town"} if err != nil { src.Error = err.Error() } else { // Filter out formula scaffolds (gt-579) formulaNames := getFormulaNames(townBeadsPath) filtered := filterFormulaScaffolds(issues, formulaNames) // Defense-in-depth: also filter wisps that shouldn't appear in ready work wispIDs := getWispIDs(townBeadsPath) src.Issues = filterWisps(filtered, wispIDs) } sources = append(sources, src) }() } // Fetch from each rig in parallel for _, r := range rigs { wg.Add(1) go func(r *rig.Rig) { defer wg.Done() // Use rig root path - ResolveBeadsDir follows redirects to find actual beads. // For tracked beads: /.beads/redirect -> mayor/rig/.beads // For rig-local beads: /.beads directly rigBeadsPath := r.Path rigBeads := beads.New(rigBeadsPath) issues, err := rigBeads.Ready() mu.Lock() defer mu.Unlock() src := ReadySource{Name: r.Name} if err != nil { src.Error = err.Error() } else { // Filter out formula scaffolds (gt-579) formulaNames := getFormulaNames(rigBeadsPath) filtered := filterFormulaScaffolds(issues, formulaNames) // Defense-in-depth: also filter wisps that shouldn't appear in ready work wispIDs := getWispIDs(rigBeadsPath) src.Issues = filterWisps(filtered, wispIDs) } sources = append(sources, src) }(r) } wg.Wait() // Sort sources: town first, then rigs alphabetically sort.Slice(sources, func(i, j int) bool { if sources[i].Name == "town" { return true } if sources[j].Name == "town" { return false } return sources[i].Name < sources[j].Name }) // Sort issues within each source by priority (lower number = higher priority) for i := range sources { sort.Slice(sources[i].Issues, func(a, b int) bool { return sources[i].Issues[a].Priority < sources[i].Issues[b].Priority }) } // Build summary summary := ReadySummary{ BySource: make(map[string]int), } for _, src := range sources { count := len(src.Issues) summary.Total += count summary.BySource[src.Name] = count for _, issue := range src.Issues { switch issue.Priority { case 0: summary.P0Count++ case 1: summary.P1Count++ case 2: summary.P2Count++ case 3: summary.P3Count++ case 4: summary.P4Count++ } } } result := ReadyResult{ Sources: sources, Summary: summary, TownRoot: townRoot, } // Output if readyJSON { enc := json.NewEncoder(os.Stdout) enc.SetIndent("", " ") return enc.Encode(result) } return printReadyHuman(result) } func printReadyHuman(result ReadyResult) error { if result.Summary.Total == 0 { fmt.Println("No ready work across town.") return nil } fmt.Printf("%s Ready work across town:\n\n", style.Bold.Render("📋")) for _, src := range result.Sources { if src.Error != "" { fmt.Printf("%s %s\n", style.Dim.Render(src.Name+"/"), style.Warning.Render("(error: "+src.Error+")")) continue } count := len(src.Issues) if count == 0 { fmt.Printf("%s %s\n", style.Dim.Render(src.Name+"/"), style.Dim.Render("(none)")) continue } fmt.Printf("%s (%d items)\n", style.Bold.Render(src.Name+"/"), count) for _, issue := range src.Issues { priorityStr := fmt.Sprintf("P%d", issue.Priority) var priorityStyled string switch issue.Priority { case 0: priorityStyled = style.Error.Render(priorityStr) // P0 is critical case 1: priorityStyled = style.Error.Render(priorityStr) case 2: priorityStyled = style.Warning.Render(priorityStr) default: priorityStyled = style.Dim.Render(priorityStr) } // Truncate title if too long title := issue.Title if len(title) > 60 { title = title[:57] + "..." } fmt.Printf(" [%s] %s %s\n", priorityStyled, style.Dim.Render(issue.ID), title) } fmt.Println() } // Summary line parts := []string{} if result.Summary.P0Count > 0 { parts = append(parts, fmt.Sprintf("%d P0", result.Summary.P0Count)) } if result.Summary.P1Count > 0 { parts = append(parts, fmt.Sprintf("%d P1", result.Summary.P1Count)) } if result.Summary.P2Count > 0 { parts = append(parts, fmt.Sprintf("%d P2", result.Summary.P2Count)) } if result.Summary.P3Count > 0 { parts = append(parts, fmt.Sprintf("%d P3", result.Summary.P3Count)) } if result.Summary.P4Count > 0 { parts = append(parts, fmt.Sprintf("%d P4", result.Summary.P4Count)) } if len(parts) > 0 { fmt.Printf("Total: %d items ready (%s)\n", result.Summary.Total, strings.Join(parts, ", ")) } else { fmt.Printf("Total: %d items ready\n", result.Summary.Total) } return nil } // getFormulaNames reads the formulas directory and returns a set of formula names. // Formula names are derived from filenames by removing the ".formula.toml" suffix. func getFormulaNames(beadsPath string) map[string]bool { formulasDir := filepath.Join(beadsPath, "formulas") entries, err := os.ReadDir(formulasDir) if err != nil { return nil } names := make(map[string]bool) for _, entry := range entries { if entry.IsDir() { continue } name := entry.Name() if strings.HasSuffix(name, ".formula.toml") { // Remove suffix to get formula name formulaName := strings.TrimSuffix(name, ".formula.toml") names[formulaName] = true } } return names } // filterFormulaScaffolds removes formula scaffold issues from the list. // Formula scaffolds are issues whose ID matches a formula name exactly // or starts with "." (step scaffolds). func filterFormulaScaffolds(issues []*beads.Issue, formulaNames map[string]bool) []*beads.Issue { if formulaNames == nil || len(formulaNames) == 0 { return issues } filtered := make([]*beads.Issue, 0, len(issues)) for _, issue := range issues { // Check if this is a formula scaffold (exact match) if formulaNames[issue.ID] { continue } // Check if this is a step scaffold (formula-name.step-id) if idx := strings.Index(issue.ID, "."); idx > 0 { prefix := issue.ID[:idx] if formulaNames[prefix] { continue } } filtered = append(filtered, issue) } return filtered } // getWispIDs reads the issues.jsonl and returns a set of IDs that are wisps. // Wisps are ephemeral issues (wisp: true flag) that shouldn't appear in ready work. // This is a defense-in-depth exclusion - bd ready should already filter wisps, // but we double-check at the display layer to ensure operational work doesn't leak. func getWispIDs(beadsPath string) map[string]bool { beadsDir := beads.ResolveBeadsDir(beadsPath) issuesPath := filepath.Join(beadsDir, "issues.jsonl") file, err := os.Open(issuesPath) if err != nil { return nil // No issues file } defer file.Close() wispIDs := make(map[string]bool) scanner := bufio.NewScanner(file) for scanner.Scan() { line := scanner.Text() if line == "" { continue } var issue struct { ID string `json:"id"` Wisp bool `json:"wisp"` } if err := json.Unmarshal([]byte(line), &issue); err != nil { continue } if issue.Wisp { wispIDs[issue.ID] = true } } return wispIDs } // filterWisps removes wisp issues from the list. // Wisps are ephemeral operational work that shouldn't appear in ready work. func filterWisps(issues []*beads.Issue, wispIDs map[string]bool) []*beads.Issue { if wispIDs == nil || len(wispIDs) == 0 { return issues } filtered := make([]*beads.Issue, 0, len(issues)) for _, issue := range issues { if !wispIDs[issue.ID] { filtered = append(filtered, issue) } } return filtered }