Files
gastown/internal/doctor/misclassified_wisp_check.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

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
}