Files
gastown/internal/cmd/ready.go
baldvin-kovacs 8a8603e6df Fix gt ready using wrong beads path for rigs (#963)
The ready command was incorrectly using RigMayorPath() which resolves to
<rig>/mayor/rig, causing BEADS_DIR to be set to a non-existent path like
/home/baldvin/gt/jbrs/mayor/rig/.beads instead of the actual database at
/home/baldvin/gt/jbrs/.beads.

This caused \"bd ready --json\" to fail with \"no beads database found\" when
called by gt ready, even though the database existed at the rig root.

Fix by using r.BeadsPath() which returns the rig root path. The beads
redirect system at <rig>/.beads/redirect already handles routing to
mayor/rig/.beads when appropriate.

Also updated getFormulaNames() and getWispIDs() calls to use the correct
path consistently.

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-25 18:01:27 -08:00

409 lines
11 KiB
Go

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 where rig-level beads are stored
// BeadsPath returns rig root; redirect system handles mayor/rig routing
rigBeads := beads.New(r.BeadsPath())
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(r.BeadsPath())
filtered := filterFormulaScaffolds(issues, formulaNames)
// Defense-in-depth: also filter wisps that shouldn't appear in ready work
wispIDs := getWispIDs(r.BeadsPath())
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 "<formula-name>." (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
}