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>
409 lines
11 KiB
Go
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
|
|
}
|