package cmd import ( "encoding/json" "fmt" "os" "os/exec" "path/filepath" "strings" "github.com/spf13/cobra" "github.com/steveyegge/gastown/internal/beads" "github.com/steveyegge/gastown/internal/config" "github.com/steveyegge/gastown/internal/style" "github.com/steveyegge/gastown/internal/workspace" ) // Note: Agent field parsing is now in internal/beads/fields.go (AgentFields, ParseAgentFieldsFromDescription) // buildAgentBeadID constructs the agent bead ID from an agent identity. // Uses canonical naming: prefix-rig-role-name // Town-level agents use hq- prefix; rig-level agents use rig's prefix. // Examples: // - "mayor" -> "hq-mayor" // - "deacon" -> "hq-deacon" // - "gastown/witness" -> "gt-gastown-witness" // - "gastown/refinery" -> "gt-gastown-refinery" // - "gastown/nux" (polecat) -> "gt-gastown-polecat-nux" // - "gastown/crew/max" -> "gt-gastown-crew-max" // // If role is unknown, it tries to infer from the identity string. // townRoot is needed to look up the rig's configured prefix. func buildAgentBeadID(identity string, role Role, townRoot string) string { parts := strings.Split(identity, "/") // Helper to get prefix for a rig getPrefix := func(rig string) string { return config.GetRigPrefix(townRoot, rig) } // If role is unknown or empty, try to infer from identity if role == RoleUnknown || role == Role("") { switch { case identity == "mayor": return beads.MayorBeadIDTown() case identity == "deacon": return beads.DeaconBeadIDTown() case len(parts) == 2 && parts[1] == "witness": return beads.WitnessBeadIDWithPrefix(getPrefix(parts[0]), parts[0]) case len(parts) == 2 && parts[1] == "refinery": return beads.RefineryBeadIDWithPrefix(getPrefix(parts[0]), parts[0]) case len(parts) == 2: // Assume rig/name is a polecat return beads.PolecatBeadIDWithPrefix(getPrefix(parts[0]), parts[0], parts[1]) case len(parts) == 3 && parts[1] == "crew": // rig/crew/name - crew member return beads.CrewBeadIDWithPrefix(getPrefix(parts[0]), parts[0], parts[2]) case len(parts) == 3 && parts[1] == "polecats": // rig/polecats/name - explicit polecat return beads.PolecatBeadIDWithPrefix(getPrefix(parts[0]), parts[0], parts[2]) default: return "" } } switch role { case RoleMayor: return beads.MayorBeadIDTown() case RoleDeacon: return beads.DeaconBeadIDTown() case RoleWitness: if len(parts) >= 1 { return beads.WitnessBeadIDWithPrefix(getPrefix(parts[0]), parts[0]) } return "" case RoleRefinery: if len(parts) >= 1 { return beads.RefineryBeadIDWithPrefix(getPrefix(parts[0]), parts[0]) } return "" case RolePolecat: // Handle both 2-part (rig/name) and 3-part (rig/polecats/name) formats if len(parts) == 3 && parts[1] == "polecats" { return beads.PolecatBeadIDWithPrefix(getPrefix(parts[0]), parts[0], parts[2]) } if len(parts) >= 2 { return beads.PolecatBeadIDWithPrefix(getPrefix(parts[0]), parts[0], parts[1]) } return "" case RoleCrew: if len(parts) >= 3 && parts[1] == "crew" { return beads.CrewBeadIDWithPrefix(getPrefix(parts[0]), parts[0], parts[2]) } return "" default: return "" } } // MoleculeProgressInfo contains progress information for a molecule instance. type MoleculeProgressInfo struct { RootID string `json:"root_id"` RootTitle string `json:"root_title"` MoleculeID string `json:"molecule_id,omitempty"` TotalSteps int `json:"total_steps"` DoneSteps int `json:"done_steps"` InProgress int `json:"in_progress_steps"` ReadySteps []string `json:"ready_steps"` BlockedSteps []string `json:"blocked_steps"` Percent int `json:"percent_complete"` Complete bool `json:"complete"` } // MoleculeStatusInfo contains status information for an agent's work. type MoleculeStatusInfo struct { Target string `json:"target"` Role string `json:"role"` AgentBeadID string `json:"agent_bead_id,omitempty"` // The agent bead if found HasWork bool `json:"has_work"` PinnedBead *beads.Issue `json:"pinned_bead,omitempty"` AttachedMolecule string `json:"attached_molecule,omitempty"` AttachedAt string `json:"attached_at,omitempty"` AttachedArgs string `json:"attached_args,omitempty"` IsWisp bool `json:"is_wisp"` Progress *MoleculeProgressInfo `json:"progress,omitempty"` NextAction string `json:"next_action,omitempty"` } // MoleculeCurrentInfo contains info about what an agent should be working on. type MoleculeCurrentInfo struct { Identity string `json:"identity"` HandoffID string `json:"handoff_id,omitempty"` HandoffTitle string `json:"handoff_title,omitempty"` MoleculeID string `json:"molecule_id,omitempty"` MoleculeTitle string `json:"molecule_title,omitempty"` StepsComplete int `json:"steps_complete"` StepsTotal int `json:"steps_total"` CurrentStepID string `json:"current_step_id,omitempty"` CurrentStep string `json:"current_step,omitempty"` Status string `json:"status"` // "working", "naked", "complete", "blocked" } func runMoleculeProgress(cmd *cobra.Command, args []string) error { rootID := args[0] workDir, err := findLocalBeadsDir() if err != nil { return fmt.Errorf("not in a beads workspace: %w", err) } b := beads.New(workDir) // Get the root issue root, err := b.Show(rootID) if err != nil { return fmt.Errorf("getting root issue: %w", err) } // Find all children of the root issue children, err := b.List(beads.ListOptions{ Parent: rootID, Status: "all", Priority: -1, }) if err != nil { return fmt.Errorf("listing children: %w", err) } if len(children) == 0 { return fmt.Errorf("no steps found for %s (not a molecule root?)", rootID) } // Build progress info progress := MoleculeProgressInfo{ RootID: rootID, RootTitle: root.Title, } // Try to find molecule ID from first child's description for _, child := range children { if molID := extractMoleculeID(child.Description); molID != "" { progress.MoleculeID = molID break } } // Build set of closed issue IDs for dependency checking closedIDs := make(map[string]bool) for _, child := range children { if child.Status == "closed" { closedIDs[child.ID] = true } } // Categorize steps for _, child := range children { progress.TotalSteps++ switch child.Status { case "closed": progress.DoneSteps++ case "in_progress": progress.InProgress++ case "open": // Check if all dependencies are closed allDepsClosed := true for _, depID := range child.DependsOn { if !closedIDs[depID] { allDepsClosed = false break } } if len(child.DependsOn) == 0 || allDepsClosed { progress.ReadySteps = append(progress.ReadySteps, child.ID) } else { progress.BlockedSteps = append(progress.BlockedSteps, child.ID) } } } // Calculate completion percentage if progress.TotalSteps > 0 { progress.Percent = (progress.DoneSteps * 100) / progress.TotalSteps } progress.Complete = progress.DoneSteps == progress.TotalSteps // JSON output if moleculeJSON { enc := json.NewEncoder(os.Stdout) enc.SetIndent("", " ") return enc.Encode(progress) } // Human-readable output fmt.Printf("\n%s %s\n\n", style.Bold.Render("🧬 Molecule Progress:"), root.Title) fmt.Printf(" Root: %s\n", rootID) if progress.MoleculeID != "" { fmt.Printf(" Molecule: %s\n", progress.MoleculeID) } fmt.Println() // Progress bar barWidth := 20 filled := (progress.Percent * barWidth) / 100 bar := strings.Repeat("█", filled) + strings.Repeat("░", barWidth-filled) fmt.Printf(" [%s] %d%% (%d/%d)\n\n", bar, progress.Percent, progress.DoneSteps, progress.TotalSteps) // Step status fmt.Printf(" Done: %d\n", progress.DoneSteps) fmt.Printf(" In Progress: %d\n", progress.InProgress) fmt.Printf(" Ready: %d", len(progress.ReadySteps)) if len(progress.ReadySteps) > 0 { fmt.Printf(" (%s)", strings.Join(progress.ReadySteps, ", ")) } fmt.Println() fmt.Printf(" Blocked: %d\n", len(progress.BlockedSteps)) if progress.Complete { fmt.Printf("\n %s\n", style.Bold.Render("✓ Molecule complete!")) } return nil } // extractMoleculeID extracts the molecule ID from an issue's description. func extractMoleculeID(description string) string { lines := strings.Split(description, "\n") for _, line := range lines { line = strings.TrimSpace(line) if strings.HasPrefix(line, "instantiated_from:") { return strings.TrimSpace(strings.TrimPrefix(line, "instantiated_from:")) } } return "" } func runMoleculeStatus(cmd *cobra.Command, args []string) error { cwd, err := os.Getwd() if err != nil { return fmt.Errorf("getting current directory: %w", err) } // Find town root townRoot, err := workspace.FindFromCwd() if err != nil { return fmt.Errorf("finding workspace: %w", err) } if townRoot == "" { return fmt.Errorf("not in a Gas Town workspace") } // Determine target agent var target string var roleCtx RoleContext if len(args) > 0 { // Explicit target provided target = args[0] } else { // Use cwd-based detection for status display // This ensures we show the hook for the agent whose directory we're in, // not the agent from the GT_ROLE env var (which might be different if // we cd'd into another rig's crew/polecat directory) roleCtx = detectRole(cwd, townRoot) target = buildAgentIdentity(roleCtx) if target == "" { return fmt.Errorf("cannot determine agent identity (role: %s)", roleCtx.Role) } } // Find beads directory workDir, err := findLocalBeadsDir() if err != nil { return fmt.Errorf("not in a beads workspace: %w", err) } b := beads.New(workDir) // Build status info status := MoleculeStatusInfo{ Target: target, Role: string(roleCtx.Role), } // Try to find agent bead and read hook slot // This is the preferred method - agent beads have a hook_bead field agentBeadID := buildAgentBeadID(target, roleCtx.Role, townRoot) var hookBead *beads.Issue if agentBeadID != "" { // Try to fetch the agent bead agentBead, err := b.Show(agentBeadID) if err == nil && agentBead != nil && agentBead.Type == "agent" { status.AgentBeadID = agentBeadID // Read hook_bead from the agent bead's database field (not description!) // The hook_bead column is updated by `bd slot set` in UpdateAgentState. // IMPORTANT: Don't use ParseAgentFieldsFromDescription - the description // field may contain stale data, causing the wrong issue to be hooked. if agentBead.HookBead != "" { // Fetch the bead on the hook hookBead, err = b.Show(agentBead.HookBead) if err != nil { // Hook bead referenced but not found - report error but continue hookBead = nil } } } // If agent bead not found or not an agent type, fall through to legacy approach } // If we found a hook bead via agent bead, use it if hookBead != nil { status.HasWork = true status.PinnedBead = hookBead // Check for attached molecule attachment := beads.ParseAttachmentFields(hookBead) if attachment != nil { status.AttachedMolecule = attachment.AttachedMolecule status.AttachedAt = attachment.AttachedAt status.AttachedArgs = attachment.AttachedArgs // Check if it's a wisp status.IsWisp = strings.Contains(hookBead.Description, "wisp: true") || strings.Contains(hookBead.Description, "is_wisp: true") // Get progress if there's an attached molecule if attachment.AttachedMolecule != "" { progress, _ := getMoleculeProgressInfo(b, attachment.AttachedMolecule) status.Progress = progress status.NextAction = determineNextAction(status) } } } else { // FALLBACK: Query for hooked beads (work on agent's hook) // First try status=hooked (work that's been slung but not yet claimed) hookedBeads, err := b.List(beads.ListOptions{ Status: beads.StatusHooked, Assignee: target, Priority: -1, }) if err != nil { return fmt.Errorf("listing hooked beads: %w", err) } // If no hooked beads found, also check in_progress beads assigned to this agent. // This handles the case where work was claimed (status changed to in_progress) // but the session was interrupted before completion. The hook should persist. if len(hookedBeads) == 0 { inProgressBeads, err := b.List(beads.ListOptions{ Status: "in_progress", Assignee: target, Priority: -1, }) if err == nil && len(inProgressBeads) > 0 { // Use the first in_progress bead (should typically be only one) hookedBeads = inProgressBeads } } // For town-level roles (mayor, deacon), scan all rigs if nothing found locally if len(hookedBeads) == 0 && isTownLevelRole(target) { hookedBeads = scanAllRigsForHookedBeads(townRoot, target) } status.HasWork = len(hookedBeads) > 0 if len(hookedBeads) > 0 { // Take the first hooked bead status.PinnedBead = hookedBeads[0] // Check for attached molecule attachment := beads.ParseAttachmentFields(hookedBeads[0]) if attachment != nil { status.AttachedMolecule = attachment.AttachedMolecule status.AttachedAt = attachment.AttachedAt status.AttachedArgs = attachment.AttachedArgs // Check if it's a wisp status.IsWisp = strings.Contains(hookedBeads[0].Description, "wisp: true") || strings.Contains(hookedBeads[0].Description, "is_wisp: true") // Get progress if there's an attached molecule if attachment.AttachedMolecule != "" { progress, _ := getMoleculeProgressInfo(b, attachment.AttachedMolecule) status.Progress = progress status.NextAction = determineNextAction(status) } } } } // Determine next action if no work is slung if !status.HasWork { status.NextAction = "Check inbox for work assignments: gt mail inbox" } else if status.AttachedMolecule == "" { status.NextAction = "Attach a molecule to start work: gt mol attach " } // JSON output if moleculeJSON { enc := json.NewEncoder(os.Stdout) enc.SetIndent("", " ") return enc.Encode(status) } // Human-readable output return outputMoleculeStatus(status) } // buildAgentIdentity constructs the agent identity string from role context. // Town-level agents (mayor, deacon) use trailing slash to match the format // used when setting assignee on hooked beads (see resolveSelfTarget in sling.go). func buildAgentIdentity(ctx RoleContext) string { switch ctx.Role { case RoleMayor: return "mayor/" case RoleDeacon: return "deacon/" case RoleWitness: return ctx.Rig + "/witness" case RoleRefinery: return ctx.Rig + "/refinery" case RolePolecat: return ctx.Rig + "/polecats/" + ctx.Polecat case RoleCrew: return ctx.Rig + "/crew/" + ctx.Polecat default: return "" } } // getMoleculeProgressInfo gets progress info for a molecule instance. func getMoleculeProgressInfo(b *beads.Beads, moleculeRootID string) (*MoleculeProgressInfo, error) { // Get the molecule root issue root, err := b.Show(moleculeRootID) if err != nil { return nil, fmt.Errorf("getting molecule root: %w", err) } // Find all children of the root issue children, err := b.List(beads.ListOptions{ Parent: moleculeRootID, Status: "all", Priority: -1, }) if err != nil { return nil, fmt.Errorf("listing children: %w", err) } if len(children) == 0 { // No children - might be a simple issue, not a molecule return nil, nil } // Build progress info progress := &MoleculeProgressInfo{ RootID: moleculeRootID, RootTitle: root.Title, } // Try to find molecule ID from first child's description for _, child := range children { if molID := extractMoleculeID(child.Description); molID != "" { progress.MoleculeID = molID break } } // Build set of closed issue IDs for dependency checking closedIDs := make(map[string]bool) for _, child := range children { if child.Status == "closed" { closedIDs[child.ID] = true } } // Categorize steps for _, child := range children { progress.TotalSteps++ switch child.Status { case "closed": progress.DoneSteps++ case "in_progress": progress.InProgress++ case "open": // Check if all dependencies are closed allDepsClosed := true for _, depID := range child.DependsOn { if !closedIDs[depID] { allDepsClosed = false break } } if len(child.DependsOn) == 0 || allDepsClosed { progress.ReadySteps = append(progress.ReadySteps, child.ID) } else { progress.BlockedSteps = append(progress.BlockedSteps, child.ID) } } } // Calculate completion percentage if progress.TotalSteps > 0 { progress.Percent = (progress.DoneSteps * 100) / progress.TotalSteps } progress.Complete = progress.DoneSteps == progress.TotalSteps return progress, nil } // determineNextAction suggests the next action based on status. func determineNextAction(status MoleculeStatusInfo) string { if status.Progress == nil { return "" } if status.Progress.Complete { return "Molecule complete! Close the bead: bd close " + status.PinnedBead.ID } if status.Progress.InProgress > 0 { return "Continue working on in-progress steps" } if len(status.Progress.ReadySteps) > 0 { return fmt.Sprintf("Start next ready step: bd update %s --status=in_progress", status.Progress.ReadySteps[0]) } if len(status.Progress.BlockedSteps) > 0 { return "All remaining steps are blocked - waiting on dependencies" } return "" } // outputMoleculeStatus outputs human-readable status. func outputMoleculeStatus(status MoleculeStatusInfo) error { // Header with hook icon fmt.Printf("\n%s Hook Status: %s\n", style.Bold.Render("🪝"), status.Target) if status.Role != "" && status.Role != "unknown" { fmt.Printf("Role: %s\n", status.Role) } fmt.Println() if !status.HasWork { fmt.Printf("%s\n", style.Dim.Render("Nothing on hook - no work slung")) fmt.Printf("\n%s %s\n", style.Bold.Render("Next:"), status.NextAction) return nil } // Show hooked bead info if status.PinnedBead == nil { fmt.Printf("%s\n", style.Dim.Render("Work indicated but no bead found")) return nil } // AUTONOMOUS MODE banner - hooked work triggers autonomous execution fmt.Println(style.Bold.Render("🚀 AUTONOMOUS MODE - Work on hook triggers immediate execution")) fmt.Println() // Check if the hooked bead is already closed (someone closed it externally) if status.PinnedBead.Status == "closed" { fmt.Printf("%s Hooked bead %s is already closed!\n", style.Bold.Render("⚠"), status.PinnedBead.ID) fmt.Printf(" Title: %s\n", status.PinnedBead.Title) fmt.Printf(" This work was completed elsewhere. Clear your hook with: gt unsling\n") return nil } // Check if this is a mail bead - display mail-specific format if status.PinnedBead.Type == "message" { sender := extractMailSender(status.PinnedBead.Labels) fmt.Printf("%s %s (mail)\n", style.Bold.Render("🪝 Hook:"), status.PinnedBead.ID) if sender != "" { fmt.Printf(" From: %s\n", sender) } fmt.Printf(" Subject: %s\n", status.PinnedBead.Title) fmt.Printf(" Run: gt mail read %s\n", status.PinnedBead.ID) return nil } fmt.Printf("%s %s: %s\n", style.Bold.Render("🪝 Hooked:"), status.PinnedBead.ID, status.PinnedBead.Title) // Show attached molecule if status.AttachedMolecule != "" { molType := "Molecule" if status.IsWisp { molType = "Wisp" } fmt.Printf("%s %s: %s\n", style.Bold.Render("🧬 "+molType+":"), status.AttachedMolecule, "") if status.AttachedAt != "" { fmt.Printf(" Attached: %s\n", status.AttachedAt) } if status.AttachedArgs != "" { fmt.Printf(" %s %s\n", style.Bold.Render("Args:"), status.AttachedArgs) } } else { fmt.Printf("%s\n", style.Dim.Render("No molecule attached (hooked bead still triggers autonomous work)")) } // Show progress if available if status.Progress != nil { fmt.Println() // Progress bar barWidth := 20 filled := (status.Progress.Percent * barWidth) / 100 bar := strings.Repeat("█", filled) + strings.Repeat("░", barWidth-filled) fmt.Printf("Progress: [%s] %d%% (%d/%d steps)\n", bar, status.Progress.Percent, status.Progress.DoneSteps, status.Progress.TotalSteps) // Step breakdown fmt.Printf(" Done: %d\n", status.Progress.DoneSteps) fmt.Printf(" In Progress: %d\n", status.Progress.InProgress) fmt.Printf(" Ready: %d", len(status.Progress.ReadySteps)) if len(status.Progress.ReadySteps) > 0 && len(status.Progress.ReadySteps) <= 3 { fmt.Printf(" (%s)", strings.Join(status.Progress.ReadySteps, ", ")) } fmt.Println() fmt.Printf(" Blocked: %d\n", len(status.Progress.BlockedSteps)) if status.Progress.Complete { fmt.Printf("\n%s\n", style.Bold.Render("✓ Molecule complete!")) } } // Next action hint if status.NextAction != "" { fmt.Printf("\n%s %s\n", style.Bold.Render("Next:"), status.NextAction) } return nil } func runMoleculeCurrent(cmd *cobra.Command, args []string) error { cwd, err := os.Getwd() if err != nil { return fmt.Errorf("getting current directory: %w", err) } // Find town root townRoot, err := workspace.FindFromCwd() if err != nil { return fmt.Errorf("finding workspace: %w", err) } if townRoot == "" { return fmt.Errorf("not in a Gas Town workspace") } // Determine target agent identity var target string var roleCtx RoleContext if len(args) > 0 { // Explicit target provided target = args[0] } else { // Use cwd-based detection for status display // This ensures we show the hook for the agent whose directory we're in, // not the agent from the GT_ROLE env var (which might be different if // we cd'd into another rig's crew/polecat directory) roleCtx = detectRole(cwd, townRoot) target = buildAgentIdentity(roleCtx) if target == "" { return fmt.Errorf("cannot determine agent identity (role: %s)", roleCtx.Role) } } // Find beads directory workDir, err := findLocalBeadsDir() if err != nil { return fmt.Errorf("not in a beads workspace: %w", err) } b := beads.New(workDir) // Extract role from target for handoff bead lookup parts := strings.Split(target, "/") role := parts[len(parts)-1] // Find handoff bead for this identity handoff, err := b.FindHandoffBead(role) if err != nil { return fmt.Errorf("finding handoff bead: %w", err) } // Build current info info := MoleculeCurrentInfo{ Identity: target, } if handoff == nil { info.Status = "naked" return outputMoleculeCurrent(info) } info.HandoffID = handoff.ID info.HandoffTitle = handoff.Title // Check for attached molecule attachment := beads.ParseAttachmentFields(handoff) if attachment == nil || attachment.AttachedMolecule == "" { info.Status = "naked" return outputMoleculeCurrent(info) } info.MoleculeID = attachment.AttachedMolecule // Get the molecule root to find its title and children molRoot, err := b.Show(attachment.AttachedMolecule) if err != nil { // Molecule not found - might be a template ID, still report what we have info.Status = "working" return outputMoleculeCurrent(info) } info.MoleculeTitle = molRoot.Title // Find all children (steps) of the molecule root children, err := b.List(beads.ListOptions{ Parent: attachment.AttachedMolecule, Status: "all", Priority: -1, }) if err != nil { // No steps - just an issue, not a molecule instance info.Status = "working" return outputMoleculeCurrent(info) } info.StepsTotal = len(children) // Build set of closed issue IDs for dependency checking closedIDs := make(map[string]bool) var inProgressSteps []*beads.Issue var readySteps []*beads.Issue for _, child := range children { switch child.Status { case "closed": info.StepsComplete++ closedIDs[child.ID] = true case "in_progress": inProgressSteps = append(inProgressSteps, child) } } // Find ready steps (open with all deps closed) for _, child := range children { if child.Status == "open" { allDepsClosed := true for _, depID := range child.DependsOn { if !closedIDs[depID] { allDepsClosed = false break } } if len(child.DependsOn) == 0 || allDepsClosed { readySteps = append(readySteps, child) } } } // Determine current step and status if info.StepsComplete == info.StepsTotal && info.StepsTotal > 0 { info.Status = "complete" } else if len(inProgressSteps) > 0 { // First in-progress step is the current one info.Status = "working" info.CurrentStepID = inProgressSteps[0].ID info.CurrentStep = inProgressSteps[0].Title } else if len(readySteps) > 0 { // First ready step is the next to work on info.Status = "working" info.CurrentStepID = readySteps[0].ID info.CurrentStep = readySteps[0].Title } else if info.StepsTotal > 0 { // Has steps but none ready or in-progress -> blocked info.Status = "blocked" } else { info.Status = "working" } return outputMoleculeCurrent(info) } // outputMoleculeCurrent outputs the current info in the appropriate format. func outputMoleculeCurrent(info MoleculeCurrentInfo) error { if moleculeJSON { enc := json.NewEncoder(os.Stdout) enc.SetIndent("", " ") return enc.Encode(info) } // Human-readable output matching spec format fmt.Printf("Identity: %s\n", info.Identity) if info.HandoffID != "" { fmt.Printf("Handoff: %s (%s)\n", info.HandoffID, info.HandoffTitle) } else { fmt.Printf("Handoff: %s\n", style.Dim.Render("(none)")) } if info.MoleculeID != "" { if info.MoleculeTitle != "" { fmt.Printf("Molecule: %s (%s)\n", info.MoleculeID, info.MoleculeTitle) } else { fmt.Printf("Molecule: %s\n", info.MoleculeID) } } else { fmt.Printf("Molecule: %s\n", style.Dim.Render("(none attached)")) } if info.StepsTotal > 0 { fmt.Printf("Progress: %d/%d steps complete\n", info.StepsComplete, info.StepsTotal) } if info.CurrentStepID != "" { fmt.Printf("Current: %s - %s\n", info.CurrentStepID, info.CurrentStep) } else if info.Status == "naked" { fmt.Printf("Status: %s\n", style.Dim.Render("naked - awaiting work assignment")) } else if info.Status == "complete" { fmt.Printf("Status: %s\n", style.Bold.Render("complete - molecule finished")) } else if info.Status == "blocked" { fmt.Printf("Status: %s\n", style.Dim.Render("blocked - waiting on dependencies")) } return nil } // getGitRootForMolStatus returns the git root for hook file lookup. func getGitRootForMolStatus() (string, error) { cmd := exec.Command("git", "rev-parse", "--show-toplevel") out, err := cmd.Output() if err != nil { return "", err } return strings.TrimSpace(string(out)), nil } // isTownLevelRole returns true if the agent ID is a town-level role. // Town-level roles (Mayor, Deacon) operate from the town root and may have // pinned beads in any rig's beads directory. // Accepts both "mayor" and "mayor/" formats for compatibility. func isTownLevelRole(agentID string) bool { return agentID == "mayor" || agentID == "mayor/" || agentID == "deacon" || agentID == "deacon/" } // extractMailSender extracts the sender from mail bead labels. // Mail beads have a "from:X" label containing the sender address. func extractMailSender(labels []string) string { for _, label := range labels { if strings.HasPrefix(label, "from:") { return strings.TrimPrefix(label, "from:") } } return "" } // scanAllRigsForHookedBeads scans all registered rigs for hooked beads // assigned to the target agent. Used for town-level roles that may have // work hooked in any rig. func scanAllRigsForHookedBeads(townRoot, target string) []*beads.Issue { // Load routes from town beads townBeadsDir := filepath.Join(townRoot, ".beads") routes, err := beads.LoadRoutes(townBeadsDir) if err != nil { return nil } // Scan each rig's beads directory for _, route := range routes { rigBeadsDir := filepath.Join(townRoot, route.Path) if _, err := os.Stat(rigBeadsDir); os.IsNotExist(err) { continue } b := beads.New(rigBeadsDir) // First check for hooked beads hookedBeads, err := b.List(beads.ListOptions{ Status: beads.StatusHooked, Assignee: target, Priority: -1, }) if err != nil { continue } if len(hookedBeads) > 0 { return hookedBeads } // Also check for in_progress beads (work that was claimed but session interrupted) inProgressBeads, err := b.List(beads.ListOptions{ Status: "in_progress", Assignee: target, Priority: -1, }) if err != nil { continue } if len(inProgressBeads) > 0 { return inProgressBeads } } return nil }