diff --git a/internal/cmd/doctor.go b/internal/cmd/doctor.go index a7d3e561..ed6af9f4 100644 --- a/internal/cmd/doctor.go +++ b/internal/cmd/doctor.go @@ -101,6 +101,7 @@ func runDoctor(cmd *cobra.Command, args []string) error { // Hook attachment checks d.Register(doctor.NewHookAttachmentValidCheck()) + d.Register(doctor.NewHookSingletonCheck()) // Run checks var report *doctor.Report diff --git a/internal/doctor/hook_check.go b/internal/doctor/hook_check.go index 4b6b2385..c712dd3c 100644 --- a/internal/doctor/hook_check.go +++ b/internal/doctor/hook_check.go @@ -184,3 +184,142 @@ func (c *HookAttachmentValidCheck) Fix(ctx *CheckContext) error { } return nil } + +// HookSingletonCheck ensures each agent has at most one handoff bead. +// Detects when multiple pinned beads exist with the same "{role} Handoff" title, +// which can cause confusion about which handoff is authoritative. +type HookSingletonCheck struct { + FixableCheck + duplicates []duplicateHandoff +} + +type duplicateHandoff struct { + title string + beadsDir string + beadIDs []string // All IDs with this title (first one is kept, rest are duplicates) +} + +// NewHookSingletonCheck creates a new hook singleton check. +func NewHookSingletonCheck() *HookSingletonCheck { + return &HookSingletonCheck{ + FixableCheck: FixableCheck{ + BaseCheck: BaseCheck{ + CheckName: "hook-singleton", + CheckDescription: "Ensure each agent has at most one handoff bead", + }, + }, + } +} + +// Run checks all pinned beads for duplicate handoff titles. +func (c *HookSingletonCheck) Run(ctx *CheckContext) *CheckResult { + c.duplicates = nil + + var details []string + + // Check town-level beads + townBeadsDir := filepath.Join(ctx.TownRoot, ".beads") + townDups := c.checkBeadsDir(townBeadsDir) + for _, dup := range townDups { + details = append(details, c.formatDuplicate(dup)) + } + c.duplicates = append(c.duplicates, townDups...) + + // Check rig-level beads using the shared helper + attachCheck := &HookAttachmentValidCheck{} + rigDirs := attachCheck.findRigBeadsDirs(ctx.TownRoot) + for _, rigDir := range rigDirs { + rigDups := c.checkBeadsDir(rigDir) + for _, dup := range rigDups { + details = append(details, c.formatDuplicate(dup)) + } + c.duplicates = append(c.duplicates, rigDups...) + } + + if len(c.duplicates) == 0 { + return &CheckResult{ + Name: c.Name(), + Status: StatusOK, + Message: "All handoff beads are unique", + } + } + + totalDups := 0 + for _, dup := range c.duplicates { + totalDups += len(dup.beadIDs) - 1 // Count extras beyond the first + } + + return &CheckResult{ + Name: c.Name(), + Status: StatusError, + Message: fmt.Sprintf("Found %d duplicate handoff bead(s)", totalDups), + Details: details, + FixHint: "Run 'gt doctor --fix' to close duplicates, or 'bd close ' manually", + } +} + +// checkBeadsDir checks for duplicate handoff beads in a directory. +func (c *HookSingletonCheck) checkBeadsDir(beadsDir string) []duplicateHandoff { + var duplicates []duplicateHandoff + + b := beads.New(filepath.Dir(beadsDir)) + + // List all pinned beads + pinnedBeads, err := b.List(beads.ListOptions{ + Status: beads.StatusPinned, + Priority: -1, + }) + if err != nil { + return nil + } + + // Group pinned beads by title (only those matching "{role} Handoff" pattern) + titleToIDs := make(map[string][]string) + for _, bead := range pinnedBeads { + // Check if title matches handoff pattern (ends with " Handoff") + if strings.HasSuffix(bead.Title, " Handoff") { + titleToIDs[bead.Title] = append(titleToIDs[bead.Title], bead.ID) + } + } + + // Find duplicates (titles with more than one bead) + for title, ids := range titleToIDs { + if len(ids) > 1 { + duplicates = append(duplicates, duplicateHandoff{ + title: title, + beadsDir: beadsDir, + beadIDs: ids, + }) + } + } + + return duplicates +} + +// formatDuplicate formats a duplicate handoff for display. +func (c *HookSingletonCheck) formatDuplicate(dup duplicateHandoff) string { + return fmt.Sprintf("%q has %d beads: %s", dup.title, len(dup.beadIDs), strings.Join(dup.beadIDs, ", ")) +} + +// Fix closes duplicate handoff beads, keeping the first one. +func (c *HookSingletonCheck) Fix(ctx *CheckContext) error { + var errors []string + + for _, dup := range c.duplicates { + b := beads.New(filepath.Dir(dup.beadsDir)) + + // Close all but the first bead (keep the oldest/first one) + toClose := dup.beadIDs[1:] + if len(toClose) > 0 { + err := b.CloseWithReason("duplicate handoff bead", toClose...) + if err != nil { + errors = append(errors, fmt.Sprintf("failed to close duplicates for %q: %v", dup.title, err)) + } + } + } + + if len(errors) > 0 { + return fmt.Errorf("%s", strings.Join(errors, "; ")) + } + return nil +} diff --git a/internal/doctor/hook_check_test.go b/internal/doctor/hook_check_test.go index e2a8e770..56745582 100644 --- a/internal/doctor/hook_check_test.go +++ b/internal/doctor/hook_check_test.go @@ -121,3 +121,84 @@ func TestHookAttachmentValidCheck_FindRigBeadsDirs(t *testing.T) { t.Logf("Found dirs: %v", dirs) } } + +// Tests for HookSingletonCheck + +func TestNewHookSingletonCheck(t *testing.T) { + check := NewHookSingletonCheck() + + if check.Name() != "hook-singleton" { + t.Errorf("expected name 'hook-singleton', got %q", check.Name()) + } + + if check.Description() != "Ensure each agent has at most one handoff bead" { + t.Errorf("unexpected description: %q", check.Description()) + } + + if !check.CanFix() { + t.Error("expected CanFix to return true") + } +} + +func TestHookSingletonCheck_NoBeadsDir(t *testing.T) { + tmpDir := t.TempDir() + + check := NewHookSingletonCheck() + ctx := &CheckContext{TownRoot: tmpDir} + + result := check.Run(ctx) + + // No beads dir means nothing to check, should be OK + if result.Status != StatusOK { + t.Errorf("expected StatusOK when no beads dir, got %v", result.Status) + } +} + +func TestHookSingletonCheck_EmptyBeadsDir(t *testing.T) { + tmpDir := t.TempDir() + beadsDir := filepath.Join(tmpDir, ".beads") + if err := os.MkdirAll(beadsDir, 0755); err != nil { + t.Fatal(err) + } + + check := NewHookSingletonCheck() + ctx := &CheckContext{TownRoot: tmpDir} + + result := check.Run(ctx) + + // Empty beads dir means no pinned beads, should be OK + if result.Status != StatusOK { + t.Errorf("expected StatusOK when empty beads dir, got %v", result.Status) + } +} + +func TestHookSingletonCheck_FormatDuplicate(t *testing.T) { + check := NewHookSingletonCheck() + + tests := []struct { + dup duplicateHandoff + expected string + }{ + { + dup: duplicateHandoff{ + title: "Mayor Handoff", + beadIDs: []string{"hq-123", "hq-456"}, + }, + expected: `"Mayor Handoff" has 2 beads: hq-123, hq-456`, + }, + { + dup: duplicateHandoff{ + title: "Witness Handoff", + beadIDs: []string{"gt-1", "gt-2", "gt-3"}, + }, + expected: `"Witness Handoff" has 3 beads: gt-1, gt-2, gt-3`, + }, + } + + for _, tt := range tests { + result := check.formatDuplicate(tt.dup) + if result != tt.expected { + t.Errorf("formatDuplicate() = %q, want %q", result, tt.expected) + } + } +}