feat: Add stale molecules check to bd doctor (bd-6a5z)

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 <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-12-25 12:42:41 -08:00
parent df3a6bff43
commit 29501c7aeb
2 changed files with 128 additions and 0 deletions

View File

@@ -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)

View File

@@ -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 <id>' 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
}