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>
This commit is contained in:
@@ -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())
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
206
internal/doctor/misclassified_wisp_check.go
Normal file
206
internal/doctor/misclassified_wisp_check.go
Normal file
@@ -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-<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
|
||||
}
|
||||
Reference in New Issue
Block a user