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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user