Import beads' UX design system into gastown: - Add internal/ui/ package with Ayu theme colors and semantic styling - styles.go: AdaptiveColor definitions for light/dark mode - terminal.go: TTY detection, NO_COLOR/CLICOLOR support - markdown.go: Glamour rendering with agent mode bypass - pager.go: Smart paging with GT_PAGER support - Add colorized help output (internal/cmd/help.go) - Group headers in accent color - Command names styled for scannability - Flag types and defaults muted - Add gt thanks command (internal/cmd/thanks.go) - Contributor display with same logic as bd thanks - Styled with Ayu theme colors - Update gt doctor to match bd doctor UX - Category grouping (Core, Infrastructure, Rig, Patrol, etc.) - Semantic icons (✓ ⚠ ✖) with Ayu colors - Tree connectors for detail lines - Summary line with pass/warn/fail counts - Warnings section at end with numbered issues - Migrate existing styles to use ui package - internal/style/style.go uses ui.ColorPass etc. - internal/tui/feed/styles.go uses ui package colors Co-Authored-By: SageOx <ox@sageox.ai>
294 lines
8.7 KiB
Go
294 lines
8.7 KiB
Go
package doctor
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/steveyegge/gastown/internal/beads"
|
|
)
|
|
|
|
// AgentBeadsCheck verifies that agent beads exist for all agents.
|
|
// This includes:
|
|
// - Global agents (deacon, mayor) - stored in town beads with hq- prefix
|
|
// - Per-rig agents (witness, refinery) - stored in each rig's beads
|
|
// - Crew workers - stored in each rig's beads
|
|
//
|
|
// Agent beads are created by gt rig add (see gt-h3hak, gt-pinkq) and gt crew add.
|
|
// Each rig uses its configured prefix (e.g., "gt-" for gastown, "bd-" for beads).
|
|
type AgentBeadsCheck struct {
|
|
FixableCheck
|
|
}
|
|
|
|
// NewAgentBeadsCheck creates a new agent beads check.
|
|
func NewAgentBeadsCheck() *AgentBeadsCheck {
|
|
return &AgentBeadsCheck{
|
|
FixableCheck: FixableCheck{
|
|
BaseCheck: BaseCheck{
|
|
CheckName: "agent-beads-exist",
|
|
CheckDescription: "Verify agent beads exist for all agents",
|
|
CheckCategory: CategoryRig,
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
// rigInfo holds the rig name and its beads path from routes.
|
|
type rigInfo struct {
|
|
name string // rig name (first component of path)
|
|
beadsPath string // full path to beads directory relative to town root
|
|
}
|
|
|
|
// Run checks if agent beads exist for all expected agents.
|
|
func (c *AgentBeadsCheck) Run(ctx *CheckContext) *CheckResult {
|
|
// Load routes to get prefixes (routes.jsonl is source of truth for prefixes)
|
|
beadsDir := filepath.Join(ctx.TownRoot, ".beads")
|
|
routes, err := beads.LoadRoutes(beadsDir)
|
|
if err != nil {
|
|
return &CheckResult{
|
|
Name: c.Name(),
|
|
Status: StatusWarning,
|
|
Message: "Could not load routes.jsonl",
|
|
}
|
|
}
|
|
|
|
// Build prefix -> rigInfo map from routes
|
|
// Routes have format: prefix "gt-" -> path "gastown/mayor/rig" or "my-saas"
|
|
prefixToRig := make(map[string]rigInfo) // prefix (without hyphen) -> rigInfo
|
|
for _, r := range routes {
|
|
// Extract rig name from path (first component)
|
|
parts := strings.Split(r.Path, "/")
|
|
if len(parts) >= 1 && parts[0] != "." {
|
|
rigName := parts[0]
|
|
prefix := strings.TrimSuffix(r.Prefix, "-")
|
|
prefixToRig[prefix] = rigInfo{
|
|
name: rigName,
|
|
beadsPath: r.Path, // Use the full route path
|
|
}
|
|
}
|
|
}
|
|
|
|
var missing []string
|
|
var checked int
|
|
|
|
// Check global agents (Mayor, Deacon) in town beads
|
|
// These use hq- prefix and are stored in ~/gt/.beads/
|
|
townBeadsPath := beads.GetTownBeadsPath(ctx.TownRoot)
|
|
townBd := beads.New(townBeadsPath)
|
|
|
|
deaconID := beads.DeaconBeadIDTown()
|
|
mayorID := beads.MayorBeadIDTown()
|
|
|
|
if _, err := townBd.Show(deaconID); err != nil {
|
|
missing = append(missing, deaconID)
|
|
}
|
|
checked++
|
|
|
|
if _, err := townBd.Show(mayorID); err != nil {
|
|
missing = append(missing, mayorID)
|
|
}
|
|
checked++
|
|
|
|
if len(prefixToRig) == 0 {
|
|
// No rigs to check, but we still checked global agents
|
|
if len(missing) == 0 {
|
|
return &CheckResult{
|
|
Name: c.Name(),
|
|
Status: StatusOK,
|
|
Message: fmt.Sprintf("All %d agent beads exist", checked),
|
|
}
|
|
}
|
|
return &CheckResult{
|
|
Name: c.Name(),
|
|
Status: StatusError,
|
|
Message: fmt.Sprintf("%d agent bead(s) missing", len(missing)),
|
|
Details: missing,
|
|
FixHint: "Run 'gt doctor --fix' to create missing agent beads",
|
|
}
|
|
}
|
|
|
|
// Check each rig for its agents
|
|
for prefix, info := range prefixToRig {
|
|
// Get beads client for this rig using the route path directly
|
|
rigBeadsPath := filepath.Join(ctx.TownRoot, info.beadsPath)
|
|
bd := beads.New(rigBeadsPath)
|
|
rigName := info.name
|
|
|
|
// Check rig-specific agents (using canonical naming: prefix-rig-role-name)
|
|
witnessID := beads.WitnessBeadIDWithPrefix(prefix, rigName)
|
|
refineryID := beads.RefineryBeadIDWithPrefix(prefix, rigName)
|
|
|
|
if _, err := bd.Show(witnessID); err != nil {
|
|
missing = append(missing, witnessID)
|
|
}
|
|
checked++
|
|
|
|
if _, err := bd.Show(refineryID); err != nil {
|
|
missing = append(missing, refineryID)
|
|
}
|
|
checked++
|
|
|
|
// Check crew worker agents
|
|
crewWorkers := listCrewWorkers(ctx.TownRoot, rigName)
|
|
for _, workerName := range crewWorkers {
|
|
crewID := beads.CrewBeadIDWithPrefix(prefix, rigName, workerName)
|
|
if _, err := bd.Show(crewID); err != nil {
|
|
missing = append(missing, crewID)
|
|
}
|
|
checked++
|
|
}
|
|
}
|
|
|
|
if len(missing) == 0 {
|
|
return &CheckResult{
|
|
Name: c.Name(),
|
|
Status: StatusOK,
|
|
Message: fmt.Sprintf("All %d agent beads exist", checked),
|
|
}
|
|
}
|
|
|
|
return &CheckResult{
|
|
Name: c.Name(),
|
|
Status: StatusError,
|
|
Message: fmt.Sprintf("%d agent bead(s) missing", len(missing)),
|
|
Details: missing,
|
|
FixHint: "Run 'gt doctor --fix' to create missing agent beads",
|
|
}
|
|
}
|
|
|
|
// Fix creates missing agent beads.
|
|
func (c *AgentBeadsCheck) Fix(ctx *CheckContext) error {
|
|
// Create global agents (Mayor, Deacon) in town beads
|
|
// These use hq- prefix and are stored in ~/gt/.beads/
|
|
townBeadsPath := beads.GetTownBeadsPath(ctx.TownRoot)
|
|
townBd := beads.New(townBeadsPath)
|
|
|
|
deaconID := beads.DeaconBeadIDTown()
|
|
if _, err := townBd.Show(deaconID); err != nil {
|
|
fields := &beads.AgentFields{
|
|
RoleType: "deacon",
|
|
Rig: "",
|
|
AgentState: "idle",
|
|
RoleBead: beads.DeaconRoleBeadIDTown(),
|
|
}
|
|
desc := "Deacon (daemon beacon) - receives mechanical heartbeats, runs town plugins and monitoring."
|
|
if _, err := townBd.CreateAgentBead(deaconID, desc, fields); err != nil {
|
|
return fmt.Errorf("creating %s: %w", deaconID, err)
|
|
}
|
|
}
|
|
|
|
mayorID := beads.MayorBeadIDTown()
|
|
if _, err := townBd.Show(mayorID); err != nil {
|
|
fields := &beads.AgentFields{
|
|
RoleType: "mayor",
|
|
Rig: "",
|
|
AgentState: "idle",
|
|
RoleBead: beads.MayorRoleBeadIDTown(),
|
|
}
|
|
desc := "Mayor - global coordinator, handles cross-rig communication and escalations."
|
|
if _, err := townBd.CreateAgentBead(mayorID, desc, fields); err != nil {
|
|
return fmt.Errorf("creating %s: %w", mayorID, err)
|
|
}
|
|
}
|
|
|
|
// Load routes to get prefixes for rig-level agents
|
|
beadsDir := filepath.Join(ctx.TownRoot, ".beads")
|
|
routes, err := beads.LoadRoutes(beadsDir)
|
|
if err != nil {
|
|
return fmt.Errorf("loading routes.jsonl: %w", err)
|
|
}
|
|
|
|
// Build prefix -> rigInfo map from routes
|
|
prefixToRig := make(map[string]rigInfo)
|
|
for _, r := range routes {
|
|
parts := strings.Split(r.Path, "/")
|
|
if len(parts) >= 1 && parts[0] != "." {
|
|
rigName := parts[0]
|
|
prefix := strings.TrimSuffix(r.Prefix, "-")
|
|
prefixToRig[prefix] = rigInfo{
|
|
name: rigName,
|
|
beadsPath: r.Path, // Use the full route path
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(prefixToRig) == 0 {
|
|
return nil // No rigs to process
|
|
}
|
|
|
|
// Create missing agents for each rig
|
|
for prefix, info := range prefixToRig {
|
|
// Use the route path directly instead of hardcoding /mayor/rig
|
|
rigBeadsPath := filepath.Join(ctx.TownRoot, info.beadsPath)
|
|
bd := beads.New(rigBeadsPath)
|
|
rigName := info.name
|
|
|
|
// Create rig-specific agents if missing (using canonical naming: prefix-rig-role-name)
|
|
witnessID := beads.WitnessBeadIDWithPrefix(prefix, rigName)
|
|
if _, err := bd.Show(witnessID); err != nil {
|
|
fields := &beads.AgentFields{
|
|
RoleType: "witness",
|
|
Rig: rigName,
|
|
AgentState: "idle",
|
|
RoleBead: beads.RoleBeadIDTown("witness"),
|
|
}
|
|
desc := fmt.Sprintf("Witness for %s - monitors polecat health and progress.", rigName)
|
|
if _, err := bd.CreateAgentBead(witnessID, desc, fields); err != nil {
|
|
return fmt.Errorf("creating %s: %w", witnessID, err)
|
|
}
|
|
}
|
|
|
|
refineryID := beads.RefineryBeadIDWithPrefix(prefix, rigName)
|
|
if _, err := bd.Show(refineryID); err != nil {
|
|
fields := &beads.AgentFields{
|
|
RoleType: "refinery",
|
|
Rig: rigName,
|
|
AgentState: "idle",
|
|
RoleBead: beads.RoleBeadIDTown("refinery"),
|
|
}
|
|
desc := fmt.Sprintf("Refinery for %s - processes merge queue.", rigName)
|
|
if _, err := bd.CreateAgentBead(refineryID, desc, fields); err != nil {
|
|
return fmt.Errorf("creating %s: %w", refineryID, err)
|
|
}
|
|
}
|
|
|
|
// Create crew worker agents if missing
|
|
crewWorkers := listCrewWorkers(ctx.TownRoot, rigName)
|
|
for _, workerName := range crewWorkers {
|
|
crewID := beads.CrewBeadIDWithPrefix(prefix, rigName, workerName)
|
|
if _, err := bd.Show(crewID); err != nil {
|
|
fields := &beads.AgentFields{
|
|
RoleType: "crew",
|
|
Rig: rigName,
|
|
AgentState: "idle",
|
|
RoleBead: beads.RoleBeadIDTown("crew"),
|
|
}
|
|
desc := fmt.Sprintf("Crew worker %s in %s - human-managed persistent workspace.", workerName, rigName)
|
|
if _, err := bd.CreateAgentBead(crewID, desc, fields); err != nil {
|
|
return fmt.Errorf("creating %s: %w", crewID, err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// listCrewWorkers returns the names of all crew workers in a rig.
|
|
func listCrewWorkers(townRoot, rigName string) []string {
|
|
crewDir := filepath.Join(townRoot, rigName, "crew")
|
|
entries, err := os.ReadDir(crewDir)
|
|
if err != nil {
|
|
return nil // No crew directory or can't read it
|
|
}
|
|
|
|
var workers []string
|
|
for _, entry := range entries {
|
|
if entry.IsDir() && !strings.HasPrefix(entry.Name(), ".") {
|
|
workers = append(workers, entry.Name())
|
|
}
|
|
}
|
|
return workers
|
|
}
|