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:
Steve Yegge
2025-12-23 11:42:22 -08:00
parent d0616870dd
commit 26a7e54205
3 changed files with 221 additions and 0 deletions

View File

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