Files
gastown/internal/cmd/ready.go
Steve Yegge 7c2f9687ec feat(wisp): add misclassified wisp detection and defense-in-depth filtering (#833)
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>
2026-01-20 20:20:49 -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 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
}