Add CheckMisclassifiedWisps doctor check to detect issues that should be marked as wisps but aren't. This catches merge-requests, patrol molecules, and operational work that lacks the wisp:true flag. Add defense-in-depth wisp filtering to gt ready command. While bd ready should already filter wisps, this provides an additional layer to ensure ephemeral operational work doesn't leak into the ready work display. Changes: - New doctor check: misclassified-wisps (fixable, CategoryCleanup) - gt ready now filters wisps from issues.jsonl in addition to scaffolds - Detects wisp patterns: merge-request type, patrol labels, mol-* IDs Co-authored-by: Claude Opus 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 mayor/rig path where rig-level beads are stored
|
|
rigBeadsPath := constants.RigMayorPath(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
|
|
}
|