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>
207 lines
5.8 KiB
Go
207 lines
5.8 KiB
Go
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-<formula>-<hash>" or "<formula>.<step>"
|
|
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 <id> --ephemeral
|
|
// Until then, this check serves as a diagnostic.
|
|
return nil
|
|
}
|