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