From 29501c7aeb21e1879176133fc76092f0cfc63cd6 Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Thu, 25 Dec 2025 12:42:41 -0800 Subject: [PATCH] feat: Add stale molecules check to bd doctor (bd-6a5z) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends bd doctor to detect complete-but-unclosed molecules (epics where all children are closed but root is still open). - Added CheckStaleMolecules() to doctor/maintenance.go - Added resolveBeadsDir() helper to follow Gas Town redirect files - Check appears in Maintenance category with warning severity - Shows example IDs and suggests 'bd mol stale' for review 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- cmd/bd/doctor.go | 5 ++ cmd/bd/doctor/maintenance.go | 123 +++++++++++++++++++++++++++++++++++ 2 files changed, 128 insertions(+) diff --git a/cmd/bd/doctor.go b/cmd/bd/doctor.go index aff7160c..cc25bfd2 100644 --- a/cmd/bd/doctor.go +++ b/cmd/bd/doctor.go @@ -814,6 +814,11 @@ func runDiagnostics(path string) doctorResult { result.Checks = append(result.Checks, staleClosedCheck) // Don't fail overall check for stale issues, just warn + // Check 26a: Stale molecules (complete but unclosed, bd-6a5z) + staleMoleculesCheck := convertDoctorCheck(doctor.CheckStaleMolecules(path)) + result.Checks = append(result.Checks, staleMoleculesCheck) + // Don't fail overall check for stale molecules, just warn + // Check 27: Expired tombstones (maintenance, bd-bqcc) tombstonesExpiredCheck := convertDoctorCheck(doctor.CheckExpiredTombstones(path)) result.Checks = append(result.Checks, tombstonesExpiredCheck) diff --git a/cmd/bd/doctor/maintenance.go b/cmd/bd/doctor/maintenance.go index 837a52fb..1d6d0229 100644 --- a/cmd/bd/doctor/maintenance.go +++ b/cmd/bd/doctor/maintenance.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "time" "github.com/steveyegge/beads/internal/beads" @@ -157,6 +158,87 @@ func CheckExpiredTombstones(path string) DoctorCheck { } } +// CheckStaleMolecules detects complete-but-unclosed molecules (bd-6a5z). +// A molecule is stale if all children are closed but the root is still open. +func CheckStaleMolecules(path string) DoctorCheck { + beadsDir := resolveBeadsDir(filepath.Join(path, ".beads")) + + // Check metadata.json first for custom database name + var dbPath string + if cfg, err := configfile.Load(beadsDir); err == nil && cfg != nil && cfg.Database != "" { + dbPath = cfg.DatabasePath(beadsDir) + } else { + dbPath = filepath.Join(beadsDir, beads.CanonicalDatabaseName) + } + + if _, err := os.Stat(dbPath); os.IsNotExist(err) { + return DoctorCheck{ + Name: "Stale Molecules", + Status: StatusOK, + Message: "N/A (no database)", + Category: CategoryMaintenance, + } + } + + ctx := context.Background() + store, err := sqlite.New(ctx, dbPath) + if err != nil { + return DoctorCheck{ + Name: "Stale Molecules", + Status: StatusOK, + Message: "N/A (unable to open database)", + Category: CategoryMaintenance, + } + } + defer func() { _ = store.Close() }() + + // Get all epics eligible for closure (complete but unclosed) + epicStatuses, err := store.GetEpicsEligibleForClosure(ctx) + if err != nil { + return DoctorCheck{ + Name: "Stale Molecules", + Status: StatusOK, + Message: "N/A (query failed)", + Category: CategoryMaintenance, + } + } + + // Count stale molecules (eligible for close with at least 1 child) + var staleCount int + var staleIDs []string + for _, es := range epicStatuses { + if es.EligibleForClose && es.TotalChildren > 0 { + staleCount++ + if len(staleIDs) < 3 { + staleIDs = append(staleIDs, es.Epic.ID) + } + } + } + + if staleCount == 0 { + return DoctorCheck{ + Name: "Stale Molecules", + Status: StatusOK, + Message: "No stale molecules", + Category: CategoryMaintenance, + } + } + + detail := fmt.Sprintf("Example: %v", staleIDs) + if staleCount > 3 { + detail += fmt.Sprintf(" (+%d more)", staleCount-3) + } + + return DoctorCheck{ + Name: "Stale Molecules", + Status: StatusWarning, + Message: fmt.Sprintf("%d complete-but-unclosed molecule(s)", staleCount), + Detail: detail, + Fix: "Run 'bd mol stale' to review, then 'bd close ' for each", + Category: CategoryMaintenance, + } +} + // CheckCompactionCandidates detects issues eligible for compaction. func CheckCompactionCandidates(path string) DoctorCheck { beadsDir := filepath.Join(path, ".beads") @@ -224,3 +306,44 @@ func CheckCompactionCandidates(path string) DoctorCheck { Category: CategoryMaintenance, } } + +// resolveBeadsDir follows a redirect file if present in the beads directory. +// This handles Gas Town's redirect mechanism where .beads/redirect points to +// the actual beads directory location. +func resolveBeadsDir(beadsDir string) string { + redirectFile := filepath.Join(beadsDir, "redirect") + data, err := os.ReadFile(redirectFile) + if err != nil { + // No redirect file - use original path + return beadsDir + } + + // Parse the redirect target + target := strings.TrimSpace(string(data)) + if target == "" { + return beadsDir + } + + // Skip comments + lines := strings.Split(target, "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line != "" && !strings.HasPrefix(line, "#") { + target = line + break + } + } + + // Resolve relative paths from the parent of the .beads directory + if !filepath.IsAbs(target) { + projectRoot := filepath.Dir(beadsDir) + target = filepath.Join(projectRoot, target) + } + + // Verify the target exists + if info, err := os.Stat(target); err != nil || !info.IsDir() { + return beadsDir + } + + return target +}