feat(doctor): add hook-singleton check (gt-h6eq.1)
Add doctor check to ensure 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.
- Check: hook-singleton
- Error if: Multiple pinned beads with same '{role} Handoff' title
- Fix: Automatically closes duplicates (keeps oldest) when run with --fix
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 <id>' 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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user