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:
Julian Knutsen
2026-01-12 09:52:38 +00:00
committed by GitHub
parent 77e1199196
commit f6fd76172e
5 changed files with 244 additions and 50 deletions

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

View 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)
}
}
}