diff --git a/internal/cmd/doctor.go b/internal/cmd/doctor.go index 94702e6e..1b5b2b2b 100644 --- a/internal/cmd/doctor.go +++ b/internal/cmd/doctor.go @@ -139,6 +139,7 @@ func runDoctor(cmd *cobra.Command, args []string) error { d.Register(doctor.NewZombieSessionCheck()) d.Register(doctor.NewOrphanProcessCheck()) d.Register(doctor.NewWispGCCheck()) + d.Register(doctor.NewCheckMisclassifiedWisps()) d.Register(doctor.NewBranchCheck()) d.Register(doctor.NewBeadsSyncOrphanCheck()) d.Register(doctor.NewCloneDivergenceCheck()) diff --git a/internal/cmd/ready.go b/internal/cmd/ready.go index b168f3d5..59658ef5 100644 --- a/internal/cmd/ready.go +++ b/internal/cmd/ready.go @@ -1,6 +1,7 @@ package cmd import ( + "bufio" "encoding/json" "fmt" "os" @@ -132,7 +133,10 @@ func runReady(cmd *cobra.Command, args []string) error { } else { // Filter out formula scaffolds (gt-579) formulaNames := getFormulaNames(townBeadsPath) - src.Issues = filterFormulaScaffolds(issues, formulaNames) + 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) }() @@ -156,7 +160,10 @@ func runReady(cmd *cobra.Command, args []string) error { } else { // Filter out formula scaffolds (gt-579) formulaNames := getFormulaNames(rigBeadsPath) - src.Issues = filterFormulaScaffolds(issues, formulaNames) + 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) @@ -346,3 +353,56 @@ func filterFormulaScaffolds(issues []*beads.Issue, formulaNames map[string]bool) } 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 +} diff --git a/internal/doctor/misclassified_wisp_check.go b/internal/doctor/misclassified_wisp_check.go new file mode 100644 index 00000000..a4963c82 --- /dev/null +++ b/internal/doctor/misclassified_wisp_check.go @@ -0,0 +1,206 @@ +package doctor + +import ( + "bufio" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/steveyegge/gastown/internal/beads" +) + +// CheckMisclassifiedWisps detects issues that should be marked as wisps but aren't. +// Wisps are ephemeral issues for operational workflows (patrols, MRs, mail). +// This check finds issues that have wisp characteristics but lack the wisp:true flag. +type CheckMisclassifiedWisps struct { + FixableCheck + misclassified []misclassifiedWisp + misclassifiedRigs map[string]int // rig -> count +} + +type misclassifiedWisp struct { + rigName string + id string + title string + reason string +} + +// NewCheckMisclassifiedWisps creates a new misclassified wisp check. +func NewCheckMisclassifiedWisps() *CheckMisclassifiedWisps { + return &CheckMisclassifiedWisps{ + FixableCheck: FixableCheck{ + BaseCheck: BaseCheck{ + CheckName: "misclassified-wisps", + CheckDescription: "Detect issues that should be wisps but aren't marked as ephemeral", + CheckCategory: CategoryCleanup, + }, + }, + misclassifiedRigs: make(map[string]int), + } +} + +// Run checks for misclassified wisps in each rig. +func (c *CheckMisclassifiedWisps) Run(ctx *CheckContext) *CheckResult { + c.misclassified = nil + c.misclassifiedRigs = make(map[string]int) + + rigs, err := discoverRigs(ctx.TownRoot) + if err != nil { + return &CheckResult{ + Name: c.Name(), + Status: StatusError, + Message: "Failed to discover rigs", + Details: []string{err.Error()}, + } + } + + if len(rigs) == 0 { + return &CheckResult{ + Name: c.Name(), + Status: StatusOK, + Message: "No rigs configured", + } + } + + var details []string + + for _, rigName := range rigs { + rigPath := filepath.Join(ctx.TownRoot, rigName) + found := c.findMisclassifiedWisps(rigPath, rigName) + if len(found) > 0 { + c.misclassified = append(c.misclassified, found...) + c.misclassifiedRigs[rigName] = len(found) + details = append(details, fmt.Sprintf("%s: %d misclassified wisp(s)", rigName, len(found))) + } + } + + // Also check town-level beads + townFound := c.findMisclassifiedWisps(ctx.TownRoot, "town") + if len(townFound) > 0 { + c.misclassified = append(c.misclassified, townFound...) + c.misclassifiedRigs["town"] = len(townFound) + details = append(details, fmt.Sprintf("town: %d misclassified wisp(s)", len(townFound))) + } + + total := len(c.misclassified) + if total > 0 { + return &CheckResult{ + Name: c.Name(), + Status: StatusWarning, + Message: fmt.Sprintf("%d issue(s) should be marked as wisps", total), + Details: details, + FixHint: "Run 'gt doctor --fix' to mark these issues as ephemeral", + } + } + + return &CheckResult{ + Name: c.Name(), + Status: StatusOK, + Message: "No misclassified wisps found", + } +} + +// findMisclassifiedWisps finds issues that should be wisps but aren't in a single location. +func (c *CheckMisclassifiedWisps) findMisclassifiedWisps(path string, rigName string) []misclassifiedWisp { + beadsDir := beads.ResolveBeadsDir(path) + issuesPath := filepath.Join(beadsDir, "issues.jsonl") + file, err := os.Open(issuesPath) + if err != nil { + return nil // No issues file + } + defer file.Close() + + var found []misclassifiedWisp + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := scanner.Text() + if line == "" { + continue + } + + var issue struct { + ID string `json:"id"` + Title string `json:"title"` + Status string `json:"status"` + Type string `json:"issue_type"` + Labels []string `json:"labels"` + Wisp bool `json:"wisp"` + } + if err := json.Unmarshal([]byte(line), &issue); err != nil { + continue + } + + // Skip issues already marked as wisps + if issue.Wisp { + continue + } + + // Skip closed issues - they're done, no need to reclassify + if issue.Status == "closed" { + continue + } + + // Check for wisp characteristics + if reason := c.shouldBeWisp(issue.ID, issue.Title, issue.Type, issue.Labels); reason != "" { + found = append(found, misclassifiedWisp{ + rigName: rigName, + id: issue.ID, + title: issue.Title, + reason: reason, + }) + } + } + + return found +} + +// shouldBeWisp checks if an issue has characteristics indicating it should be a wisp. +// Returns the reason string if it should be a wisp, empty string otherwise. +func (c *CheckMisclassifiedWisps) shouldBeWisp(id, title, issueType string, labels []string) string { + // Check for merge-request type - these should always be wisps + if issueType == "merge-request" { + return "merge-request type should be ephemeral" + } + + // Check for patrol-related labels + for _, label := range labels { + if strings.Contains(label, "patrol") { + return "patrol label indicates ephemeral workflow" + } + if label == "gt:mail" || label == "gt:handoff" { + return "mail/handoff label indicates ephemeral message" + } + } + + // Check for formula instance patterns in ID + // Formula instances typically have IDs like "mol--" or "." + if strings.HasPrefix(id, "mol-") && strings.Contains(id, "-patrol") { + return "patrol molecule ID pattern" + } + + // Check for specific title patterns indicating operational work + lowerTitle := strings.ToLower(title) + if strings.Contains(lowerTitle, "patrol cycle") || + strings.Contains(lowerTitle, "witness patrol") || + strings.Contains(lowerTitle, "deacon patrol") || + strings.Contains(lowerTitle, "refinery patrol") { + return "patrol title indicates ephemeral workflow" + } + + return "" +} + +// Fix marks misclassified issues as wisps using bd update. +func (c *CheckMisclassifiedWisps) Fix(ctx *CheckContext) error { + // Note: bd doesn't have a direct flag to set wisp:true on existing issues. + // The proper fix is to ensure issues are created with --ephemeral flag. + // For now, we just report the issues - they'll be cleaned up by wisp-gc + // if they become abandoned, or manually closed. + // + // A true fix would require bd to support: bd update --ephemeral + // Until then, this check serves as a diagnostic. + return nil +}