Files
gastown/internal/cmd/ready.go
furiosa f376d07e12 fix(ready): use rig root for beads resolution, not mayor/rig
The ready command was using constants.RigMayorPath(r.Path) which returns
<rig>/mayor/rig, but this fails for rigs where the source repo doesn't
have tracked beads. In those cases, rig-level beads are stored at
<rig>/.beads directly.

Using r.Path (rig root) allows ResolveBeadsDir to properly handle both:
- Tracked beads: follows <rig>/.beads/redirect to mayor/rig/.beads
- Local beads: uses <rig>/.beads directly

Fixes "no beads database found" errors for google_cookie_retrieval and
home_assistant_blueprints rigs.

Closes: hq-c90jd

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

411 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 - ResolveBeadsDir follows redirects to find actual beads.
// For tracked beads: <rig>/.beads/redirect -> mayor/rig/.beads
// For rig-local beads: <rig>/.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 "<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
}