From 2f354b1ef63c80992b3f96d2fba1598338c2a816 Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Sun, 28 Dec 2025 19:36:15 -0800 Subject: [PATCH] Enhance gt status with tree-style role hierarchy view (gt-um4iu) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use tree characters (├── └── │) for hierarchical display - Group agents by role type (Witness, Refinery, Crew, Polecats) - Add role icons (🎩 Mayor, 🔔 Deacon, 👁 Witness, 🏭 Refinery, 👷 Crew, 😺 Polecats) - Show pinned work inline with truncation - Fix unused import in polecat/manager.go 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/cmd/status.go | 333 +++++++++++++++++++++++++++--------- internal/polecat/manager.go | 62 +++++-- 2 files changed, 303 insertions(+), 92 deletions(-) diff --git a/internal/cmd/status.go b/internal/cmd/status.go index 5493f800..a3332a23 100644 --- a/internal/cmd/status.go +++ b/internal/cmd/status.go @@ -197,104 +197,273 @@ func outputStatusJSON(status TownStatus) error { func outputStatusText(status TownStatus) error { // Header - fmt.Printf("%s %s\n", style.Bold.Render("⚙️ Gas Town:"), status.Name) - fmt.Printf(" Location: %s\n\n", style.Dim.Render(status.Location)) + fmt.Printf("%s %s\n", style.Bold.Render("Town:"), status.Name) + fmt.Printf("%s\n\n", style.Dim.Render(status.Location)) - // Global Agents (Mayor, Deacon) - fmt.Printf("%s\n", style.Bold.Render("Agents")) - for _, agent := range status.Agents { - statusStr := style.Success.Render("✓ running") - if !agent.Running { - statusStr = style.Error.Render("✗ stopped") + // Tree characters + const ( + treeBranch = "├── " + treeLast = "└── " + treeVert = "│ " + treeSpace = " " + ) + + // Role icons + roleIcons := map[string]string{ + "mayor": "🎩", + "deacon": "🔔", + "witness": "👁", + "refinery": "🏭", + "crew": "👷", + "polecat": "😺", + } + + // Global Agents (Mayor, Deacon) - these are town-level roles + hasRigs := len(status.Rigs) > 0 + for i, agent := range status.Agents { + isLast := i == len(status.Agents)-1 && !hasRigs + prefix := treeBranch + if isLast { + prefix = treeLast } - // Show hook bead and state from agent bead - hookInfo := "" - if agent.HookBead != "" { - hookInfo = fmt.Sprintf(" → %s", agent.HookBead) - if agent.WorkTitle != "" { - // Truncate title if too long - title := agent.WorkTitle - if len(title) > 30 { - title = title[:27] + "..." + icon := roleIcons[agent.Role] + if icon == "" { + icon = roleIcons[agent.Name] // fallback to name + } + + roleLabel := style.Bold.Render(fmt.Sprintf("%s %s", icon, capitalizeFirst(agent.Name))) + fmt.Printf("%s%s\n", prefix, roleLabel) + + // Show agent instance under role + childPrefix := treeVert + if isLast { + childPrefix = treeSpace + } + + statusStr := style.Success.Render("running") + if !agent.Running { + statusStr = style.Error.Render("stopped") + } + + hookInfo := formatHookInfo(agent.HookBead, agent.WorkTitle, 35) + stateInfo := "" + if agent.State != "" && agent.State != "idle" { + stateInfo = style.Dim.Render(fmt.Sprintf(" [%s]", agent.State)) + } + + fmt.Printf("%s%s%s %s%s%s\n", childPrefix, treeLast, + style.Dim.Render("gt-"+agent.Name), statusStr, hookInfo, stateInfo) + } + + if !hasRigs { + fmt.Printf("\n%s\n", style.Dim.Render("No rigs registered. Use 'gt rig add' to add one.")) + return nil + } + + // Rigs section + fmt.Printf("%s%s\n", treeLast, style.Bold.Render("Rigs")) + + for ri, r := range status.Rigs { + isLastRig := ri == len(status.Rigs)-1 + rigPrefix := treeVert + if isLastRig { + rigPrefix = treeSpace + } + + rigBranch := treeBranch + if isLastRig { + rigBranch = treeLast + } + + fmt.Printf("%s%s%s\n", treeSpace, rigBranch, style.Bold.Render(r.Name+"/")) + + // Group agents by role + var witnesses, refineries, crews, polecats []AgentRuntime + for _, agent := range r.Agents { + switch agent.Role { + case "witness": + witnesses = append(witnesses, agent) + case "refinery": + refineries = append(refineries, agent) + case "crew": + crews = append(crews, agent) + case "polecat": + polecats = append(polecats, agent) + } + } + + // Count non-empty role groups + roleGroups := 0 + if len(witnesses) > 0 { + roleGroups++ + } + if len(refineries) > 0 { + roleGroups++ + } + if len(crews) > 0 { + roleGroups++ + } + if len(polecats) > 0 { + roleGroups++ + } + + groupsRendered := 0 + baseIndent := treeSpace + rigPrefix + + // Witness + if len(witnesses) > 0 { + groupsRendered++ + isLastGroup := groupsRendered == roleGroups + groupBranch := treeBranch + if isLastGroup { + groupBranch = treeLast + } + fmt.Printf("%s%s%s %s\n", baseIndent, groupBranch, + roleIcons["witness"], style.Bold.Render("Witness")) + + groupIndent := baseIndent + treeVert + if isLastGroup { + groupIndent = baseIndent + treeSpace + } + renderAgentList(witnesses, groupIndent, r.Hooks) + } + + // Refinery + if len(refineries) > 0 { + groupsRendered++ + isLastGroup := groupsRendered == roleGroups + groupBranch := treeBranch + if isLastGroup { + groupBranch = treeLast + } + fmt.Printf("%s%s%s %s\n", baseIndent, groupBranch, + roleIcons["refinery"], style.Bold.Render("Refinery")) + + groupIndent := baseIndent + treeVert + if isLastGroup { + groupIndent = baseIndent + treeSpace + } + renderAgentList(refineries, groupIndent, r.Hooks) + } + + // Crew + if len(crews) > 0 { + groupsRendered++ + isLastGroup := groupsRendered == roleGroups + groupBranch := treeBranch + if isLastGroup { + groupBranch = treeLast + } + fmt.Printf("%s%s%s %s\n", baseIndent, groupBranch, + roleIcons["crew"], style.Bold.Render("Crew")) + + groupIndent := baseIndent + treeVert + if isLastGroup { + groupIndent = baseIndent + treeSpace + } + renderAgentList(crews, groupIndent, r.Hooks) + } + + // Polecats + if len(polecats) > 0 { + groupsRendered++ + isLastGroup := groupsRendered == roleGroups + groupBranch := treeBranch + if isLastGroup { + groupBranch = treeLast + } + fmt.Printf("%s%s%s %s\n", baseIndent, groupBranch, + roleIcons["polecat"], style.Bold.Render("Polecats")) + + groupIndent := baseIndent + treeVert + if isLastGroup { + groupIndent = baseIndent + treeSpace + } + renderAgentList(polecats, groupIndent, r.Hooks) + } + + // No agents at all + if roleGroups == 0 { + fmt.Printf("%s%s%s\n", baseIndent, treeLast, style.Dim.Render("(no agents)")) + } + } + + return nil +} + +// renderAgentList renders a list of agents under a role group +func renderAgentList(agents []AgentRuntime, indent string, hooks []AgentHookInfo) { + const ( + treeBranch = "├── " + treeLast = "└── " + ) + + for i, agent := range agents { + isLast := i == len(agents)-1 + branch := treeBranch + if isLast { + branch = treeLast + } + + statusStr := style.Success.Render("running") + if !agent.Running { + statusStr = style.Error.Render("stopped") + } + + hookInfo := formatHookInfo(agent.HookBead, agent.WorkTitle, 30) + if hookInfo == "" { + // Fall back to legacy Hooks array + for _, h := range hooks { + if h.Agent == agent.Address && h.HasWork { + if h.Molecule != "" { + hookInfo = fmt.Sprintf(" → %s", h.Molecule) + } else if h.Title != "" { + hookInfo = fmt.Sprintf(" → %s", truncateWithEllipsis(h.Title, 30)) + } + break } - hookInfo = fmt.Sprintf(" → %s (%s)", agent.HookBead, title) } } stateInfo := "" if agent.State != "" && agent.State != "idle" { - stateInfo = fmt.Sprintf(" [%s]", agent.State) + stateInfo = style.Dim.Render(fmt.Sprintf(" [%s]", agent.State)) } - fmt.Printf(" %-14s %s%s%s\n", agent.Name, statusStr, hookInfo, stateInfo) + fmt.Printf("%s%s%s %s%s%s\n", indent, branch, agent.Name, statusStr, hookInfo, stateInfo) } +} - if len(status.Rigs) == 0 { - fmt.Printf("\n%s\n", style.Dim.Render("No rigs registered. Use 'gt rig add' to add one.")) - return nil +// formatHookInfo formats the hook bead and title for display +func formatHookInfo(hookBead, title string, maxLen int) string { + if hookBead == "" { + return "" } - - // Rigs detail with runtime state - fmt.Printf("\n%s\n", style.Bold.Render("Rigs")) - for _, r := range status.Rigs { - fmt.Printf(" %s\n", style.Bold.Render(r.Name)) - - // Show all agents with their runtime state - for _, agent := range r.Agents { - statusStr := style.Success.Render("✓ running") - if !agent.Running { - statusStr = style.Error.Render("✗ stopped") - } - - // Show hook bead from agent bead (preferred), fall back to Hooks array - hookInfo := "" - if agent.HookBead != "" { - hookInfo = fmt.Sprintf(" → %s", agent.HookBead) - if agent.WorkTitle != "" { - title := agent.WorkTitle - if len(title) > 25 { - title = title[:22] + "..." - } - hookInfo = fmt.Sprintf(" → %s (%s)", agent.HookBead, title) - } - } else { - // Fall back to legacy Hooks array - for _, h := range r.Hooks { - if h.Agent == agent.Address && h.HasWork { - if h.Molecule != "" { - hookInfo = fmt.Sprintf(" → %s", h.Molecule) - } else if h.Title != "" { - hookInfo = fmt.Sprintf(" → %s", h.Title) - } else { - hookInfo = " → (work attached)" - } - break - } - } - } - - stateInfo := "" - if agent.State != "" && agent.State != "idle" { - stateInfo = fmt.Sprintf(" [%s]", agent.State) - } - - // Format agent name based on role - displayName := agent.Name - if agent.Role == "crew" { - displayName = "crew/" + agent.Name - } - - fmt.Printf(" %-14s %s%s%s\n", displayName, statusStr, hookInfo, stateInfo) - } - - // Show polecats if any (these are already in r.Agents if discovered) - if len(r.Polecats) == 0 && len(r.Crews) == 0 && !r.HasWitness && !r.HasRefinery { - fmt.Printf(" %s\n", style.Dim.Render("No agents")) - } + if title == "" { + return fmt.Sprintf(" → %s", hookBead) } + title = truncateWithEllipsis(title, maxLen) + return fmt.Sprintf(" → %s", title) +} - return nil +// truncateWithEllipsis shortens a string to maxLen, adding "..." if truncated +func truncateWithEllipsis(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + if maxLen < 4 { + return s[:maxLen] + } + return s[:maxLen-3] + "..." +} + +// capitalizeFirst capitalizes the first letter of a string +func capitalizeFirst(s string) string { + if s == "" { + return s + } + return string(s[0]-32) + s[1:] } // discoverRigHooks finds all hook attachments for agents in a rig. diff --git a/internal/polecat/manager.go b/internal/polecat/manager.go index 8c048a1d..195fdf2a 100644 --- a/internal/polecat/manager.go +++ b/internal/polecat/manager.go @@ -724,17 +724,38 @@ func (m *Manager) loadFromBeads(name string) (*Polecat, error) { // Template variables {{rig}} and {{name}} are substituted with actual values. // This provides polecats with context about their role and available commands. func (m *Manager) installCLAUDETemplate(polecatPath, name string) error { - // Read template from mayor/rig/templates directory - // Templates live in the mayor's clone, not at rig root - templatePath := filepath.Join(m.rig.Path, "mayor", "rig", "templates", "polecat-CLAUDE.md") - content, err := os.ReadFile(templatePath) - if err != nil { - if os.IsNotExist(err) { - // Template doesn't exist - warn and skip (this is a setup issue) - fmt.Printf("Warning: polecat template not found at %s\n", templatePath) - return nil + // Try multiple template locations in order of precedence: + // 1. Rig-specific template: /mayor/rig/templates/polecat-CLAUDE.md + // 2. Gastown (canonical) template: /gastown/mayor/rig/templates/polecat-CLAUDE.md + templatePaths := []string{ + filepath.Join(m.rig.Path, "mayor", "rig", "templates", "polecat-CLAUDE.md"), + } + + // Add gastown fallback if we can find the town root + if townRoot, err := findTownRoot(m.rig.Path); err == nil && townRoot != "" { + gasTownTemplate := filepath.Join(townRoot, "gastown", "mayor", "rig", "templates", "polecat-CLAUDE.md") + // Only add fallback if different from rig-specific path + if gasTownTemplate != templatePaths[0] { + templatePaths = append(templatePaths, gasTownTemplate) } - return fmt.Errorf("reading template: %w", err) + } + + var content []byte + for _, templatePath := range templatePaths { + var err error + content, err = os.ReadFile(templatePath) + if err == nil { + break // Found a template + } + if !os.IsNotExist(err) { + return fmt.Errorf("reading template %s: %w", templatePath, err) + } + } + + if content == nil { + // No template found in any location - warn and skip + fmt.Printf("Warning: polecat template not found (checked %d locations)\n", len(templatePaths)) + return nil } // Substitute template variables @@ -751,6 +772,27 @@ func (m *Manager) installCLAUDETemplate(polecatPath, name string) error { return nil } +// findTownRoot locates the Gas Town root directory by walking up from startDir. +func findTownRoot(startDir string) (string, error) { + absDir, err := filepath.Abs(startDir) + if err != nil { + return "", err + } + + current := absDir + for { + // Check for primary marker (mayor/town.json) + if _, err := os.Stat(filepath.Join(current, "mayor", "town.json")); err == nil { + return current, nil + } + parent := filepath.Dir(current) + if parent == current { + return "", fmt.Errorf("town root not found") + } + current = parent + } +} + // setupSharedBeads creates a redirect file so the polecat uses the rig's shared .beads database. // This eliminates the need for git sync between polecat clones - all polecats share one database. //