fix(doctor): add role beads check with shared definitions (#378)
Role beads (hq-*-role) are templates that define role characteristics. They are created during gt install but creation may fail silently. Without role beads, agents fall back to defaults. Changes: - Add beads.AllRoleBeadDefs() as single source of truth for role bead definitions - Update gt install to use shared definitions - Add doctor check that detects missing role beads (warning, not error) - Doctor --fix creates missing role beads Fixes #371 Co-authored-by: julianknutsen <julianknutsen@users.noreply.github> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -92,3 +92,54 @@ func HasLabel(issue *Issue, label string) bool {
|
|||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RoleBeadDef defines a role bead's metadata.
|
||||||
|
// Used by gt install and gt doctor to create missing role beads.
|
||||||
|
type RoleBeadDef struct {
|
||||||
|
ID string // e.g., "hq-witness-role"
|
||||||
|
Title string // e.g., "Witness Role"
|
||||||
|
Desc string // Description of the role
|
||||||
|
}
|
||||||
|
|
||||||
|
// AllRoleBeadDefs returns all role bead definitions.
|
||||||
|
// This is the single source of truth for role beads used by both
|
||||||
|
// gt install (initial creation) and gt doctor --fix (repair).
|
||||||
|
func AllRoleBeadDefs() []RoleBeadDef {
|
||||||
|
return []RoleBeadDef{
|
||||||
|
{
|
||||||
|
ID: MayorRoleBeadIDTown(),
|
||||||
|
Title: "Mayor Role",
|
||||||
|
Desc: "Role definition for Mayor agents. Global coordinator for cross-rig work.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: DeaconRoleBeadIDTown(),
|
||||||
|
Title: "Deacon Role",
|
||||||
|
Desc: "Role definition for Deacon agents. Daemon beacon for heartbeats and monitoring.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: DogRoleBeadIDTown(),
|
||||||
|
Title: "Dog Role",
|
||||||
|
Desc: "Role definition for Dog agents. Town-level workers for cross-rig tasks.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: WitnessRoleBeadIDTown(),
|
||||||
|
Title: "Witness Role",
|
||||||
|
Desc: "Role definition for Witness agents. Per-rig worker monitor with progressive nudging.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: RefineryRoleBeadIDTown(),
|
||||||
|
Title: "Refinery Role",
|
||||||
|
Desc: "Role definition for Refinery agents. Merge queue processor with verification gates.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: PolecatRoleBeadIDTown(),
|
||||||
|
Title: "Polecat Role",
|
||||||
|
Desc: "Role definition for Polecat agents. Ephemeral workers for batch work dispatch.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: CrewRoleBeadIDTown(),
|
||||||
|
Title: "Crew Role",
|
||||||
|
Desc: "Role definition for Crew agents. Persistent user-managed workspaces.",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -153,6 +153,7 @@ func runDoctor(cmd *cobra.Command, args []string) error {
|
|||||||
d.Register(doctor.NewPatrolRolesHavePromptsCheck())
|
d.Register(doctor.NewPatrolRolesHavePromptsCheck())
|
||||||
d.Register(doctor.NewAgentBeadsCheck())
|
d.Register(doctor.NewAgentBeadsCheck())
|
||||||
d.Register(doctor.NewRigBeadsCheck())
|
d.Register(doctor.NewRigBeadsCheck())
|
||||||
|
d.Register(doctor.NewRoleBeadsCheck())
|
||||||
|
|
||||||
// NOTE: StaleAttachmentsCheck removed - staleness detection belongs in Deacon molecule
|
// NOTE: StaleAttachmentsCheck removed - staleness detection belongs in Deacon molecule
|
||||||
|
|
||||||
|
|||||||
@@ -454,70 +454,28 @@ func initTownAgentBeads(townPath string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Role beads (global templates)
|
// Role beads (global templates) - use shared definitions from beads package
|
||||||
roleDefs := []struct {
|
for _, role := range beads.AllRoleBeadDefs() {
|
||||||
id string
|
|
||||||
title string
|
|
||||||
desc string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
id: beads.MayorRoleBeadIDTown(),
|
|
||||||
title: "Mayor Role",
|
|
||||||
desc: "Role definition for Mayor agents. Global coordinator for cross-rig work.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: beads.DeaconRoleBeadIDTown(),
|
|
||||||
title: "Deacon Role",
|
|
||||||
desc: "Role definition for Deacon agents. Daemon beacon for heartbeats and monitoring.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: beads.DogRoleBeadIDTown(),
|
|
||||||
title: "Dog Role",
|
|
||||||
desc: "Role definition for Dog agents. Town-level workers for cross-rig tasks.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: beads.WitnessRoleBeadIDTown(),
|
|
||||||
title: "Witness Role",
|
|
||||||
desc: "Role definition for Witness agents. Per-rig worker monitor with progressive nudging.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: beads.RefineryRoleBeadIDTown(),
|
|
||||||
title: "Refinery Role",
|
|
||||||
desc: "Role definition for Refinery agents. Merge queue processor with verification gates.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: beads.PolecatRoleBeadIDTown(),
|
|
||||||
title: "Polecat Role",
|
|
||||||
desc: "Role definition for Polecat agents. Ephemeral workers for batch work dispatch.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: beads.CrewRoleBeadIDTown(),
|
|
||||||
title: "Crew Role",
|
|
||||||
desc: "Role definition for Crew agents. Persistent user-managed workspaces.",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, role := range roleDefs {
|
|
||||||
// Check if already exists
|
// Check if already exists
|
||||||
if _, err := bd.Show(role.id); err == nil {
|
if _, err := bd.Show(role.ID); err == nil {
|
||||||
continue // Already exists
|
continue // Already exists
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create role bead using the beads API
|
// Create role bead using the beads API
|
||||||
// CreateWithID with Type: "role" automatically adds gt:role label
|
// CreateWithID with Type: "role" automatically adds gt:role label
|
||||||
_, err := bd.CreateWithID(role.id, beads.CreateOptions{
|
_, err := bd.CreateWithID(role.ID, beads.CreateOptions{
|
||||||
Title: role.title,
|
Title: role.Title,
|
||||||
Type: "role",
|
Type: "role",
|
||||||
Description: role.desc,
|
Description: role.Desc,
|
||||||
Priority: -1, // No priority
|
Priority: -1, // No priority
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Log but continue - role beads are optional
|
// Log but continue - role beads are optional
|
||||||
fmt.Printf(" %s Could not create role bead %s: %v\n",
|
fmt.Printf(" %s Could not create role bead %s: %v\n",
|
||||||
style.Dim.Render("⚠"), role.id, err)
|
style.Dim.Render("⚠"), role.ID, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
fmt.Printf(" ✓ Created role bead: %s\n", role.id)
|
fmt.Printf(" ✓ Created role bead: %s\n", role.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Town-level agent beads
|
// Town-level agent beads
|
||||||
|
|||||||
116
internal/doctor/role_beads_check.go
Normal file
116
internal/doctor/role_beads_check.go
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
package doctor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/steveyegge/gastown/internal/beads"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RoleBeadsCheck verifies that role definition beads exist.
|
||||||
|
// Role beads are templates that define role characteristics and lifecycle hooks.
|
||||||
|
// They are stored in town beads (~/.beads/) with hq- prefix:
|
||||||
|
// - hq-mayor-role, hq-deacon-role, hq-dog-role
|
||||||
|
// - hq-witness-role, hq-refinery-role, hq-polecat-role, hq-crew-role
|
||||||
|
//
|
||||||
|
// Role beads are created by gt install, but creation may fail silently.
|
||||||
|
// Without role beads, agents fall back to defaults which may differ from
|
||||||
|
// user expectations.
|
||||||
|
type RoleBeadsCheck struct {
|
||||||
|
FixableCheck
|
||||||
|
missing []string // Track missing role beads for fix
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRoleBeadsCheck creates a new role beads check.
|
||||||
|
func NewRoleBeadsCheck() *RoleBeadsCheck {
|
||||||
|
return &RoleBeadsCheck{
|
||||||
|
FixableCheck: FixableCheck{
|
||||||
|
BaseCheck: BaseCheck{
|
||||||
|
CheckName: "role-beads-exist",
|
||||||
|
CheckDescription: "Verify role definition beads exist",
|
||||||
|
CheckCategory: CategoryConfig,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run checks if role beads exist.
|
||||||
|
func (c *RoleBeadsCheck) Run(ctx *CheckContext) *CheckResult {
|
||||||
|
c.missing = nil // Reset
|
||||||
|
|
||||||
|
townBeadsPath := beads.GetTownBeadsPath(ctx.TownRoot)
|
||||||
|
bd := beads.New(townBeadsPath)
|
||||||
|
|
||||||
|
var missing []string
|
||||||
|
roleDefs := beads.AllRoleBeadDefs()
|
||||||
|
|
||||||
|
for _, role := range roleDefs {
|
||||||
|
if _, err := bd.Show(role.ID); err != nil {
|
||||||
|
missing = append(missing, role.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.missing = missing
|
||||||
|
|
||||||
|
if len(missing) == 0 {
|
||||||
|
return &CheckResult{
|
||||||
|
Name: c.Name(),
|
||||||
|
Status: StatusOK,
|
||||||
|
Message: fmt.Sprintf("All %d role beads exist", len(roleDefs)),
|
||||||
|
Category: c.Category(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &CheckResult{
|
||||||
|
Name: c.Name(),
|
||||||
|
Status: StatusWarning, // Warning, not error - agents work without role beads
|
||||||
|
Message: fmt.Sprintf("%d role bead(s) missing (agents will use defaults)", len(missing)),
|
||||||
|
Details: missing,
|
||||||
|
FixHint: "Run 'gt doctor --fix' to create missing role beads",
|
||||||
|
Category: c.Category(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fix creates missing role beads.
|
||||||
|
func (c *RoleBeadsCheck) Fix(ctx *CheckContext) error {
|
||||||
|
// Re-run check to populate missing if needed
|
||||||
|
if c.missing == nil {
|
||||||
|
result := c.Run(ctx)
|
||||||
|
if result.Status == StatusOK {
|
||||||
|
return nil // Nothing to fix
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(c.missing) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build lookup map for role definitions
|
||||||
|
roleDefMap := make(map[string]beads.RoleBeadDef)
|
||||||
|
for _, role := range beads.AllRoleBeadDefs() {
|
||||||
|
roleDefMap[role.ID] = role
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create missing role beads
|
||||||
|
for _, id := range c.missing {
|
||||||
|
role, ok := roleDefMap[id]
|
||||||
|
if !ok {
|
||||||
|
continue // Shouldn't happen
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create role bead using bd create --type=role
|
||||||
|
cmd := exec.Command("bd", "create",
|
||||||
|
"--type=role",
|
||||||
|
"--id="+role.ID,
|
||||||
|
"--title="+role.Title,
|
||||||
|
"--description="+role.Desc,
|
||||||
|
)
|
||||||
|
cmd.Dir = ctx.TownRoot
|
||||||
|
if output, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
return fmt.Errorf("creating %s: %s", role.ID, strings.TrimSpace(string(output)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
68
internal/doctor/role_beads_check_test.go
Normal file
68
internal/doctor/role_beads_check_test.go
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
package doctor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/steveyegge/gastown/internal/beads"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRoleBeadsCheck_Run(t *testing.T) {
|
||||||
|
t.Run("no town beads returns warning", func(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
// Create minimal town structure without .beads
|
||||||
|
if err := os.MkdirAll(filepath.Join(tmpDir, "mayor"), 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
check := NewRoleBeadsCheck()
|
||||||
|
ctx := &CheckContext{TownRoot: tmpDir}
|
||||||
|
result := check.Run(ctx)
|
||||||
|
|
||||||
|
// Without .beads directory, all role beads are "missing"
|
||||||
|
expectedCount := len(beads.AllRoleBeadDefs())
|
||||||
|
if result.Status != StatusWarning {
|
||||||
|
t.Errorf("expected StatusWarning, got %v: %s", result.Status, result.Message)
|
||||||
|
}
|
||||||
|
if len(result.Details) != expectedCount {
|
||||||
|
t.Errorf("expected %d missing role beads, got %d: %v", expectedCount, len(result.Details), result.Details)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("check is fixable", func(t *testing.T) {
|
||||||
|
check := NewRoleBeadsCheck()
|
||||||
|
if !check.CanFix() {
|
||||||
|
t.Error("RoleBeadsCheck should be fixable")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoleBeadsCheck_usesSharedDefs(t *testing.T) {
|
||||||
|
// Verify the check uses beads.AllRoleBeadDefs()
|
||||||
|
roleDefs := beads.AllRoleBeadDefs()
|
||||||
|
|
||||||
|
if len(roleDefs) < 7 {
|
||||||
|
t.Errorf("expected at least 7 role beads, got %d", len(roleDefs))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify key roles are present
|
||||||
|
expectedIDs := map[string]bool{
|
||||||
|
"hq-mayor-role": false,
|
||||||
|
"hq-deacon-role": false,
|
||||||
|
"hq-witness-role": false,
|
||||||
|
"hq-refinery-role": false,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, role := range roleDefs {
|
||||||
|
if _, exists := expectedIDs[role.ID]; exists {
|
||||||
|
expectedIDs[role.ID] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for id, found := range expectedIDs {
|
||||||
|
if !found {
|
||||||
|
t.Errorf("expected role %s not found in AllRoleBeadDefs()", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user