diff --git a/internal/cmd/molecule.go b/internal/cmd/molecule.go index bb8a3260..2fd00026 100644 --- a/internal/cmd/molecule.go +++ b/internal/cmd/molecule.go @@ -23,8 +23,9 @@ var ( ) var moleculeCmd = &cobra.Command{ - Use: "molecule", - Short: "Molecule workflow commands", + Use: "molecule", + Aliases: []string{"mol"}, + Short: "Molecule workflow commands", Long: `Manage molecule workflow templates. Molecules are composable workflow patterns stored as beads issues. @@ -169,6 +170,28 @@ Example: RunE: runMoleculeAttachment, } +var moleculeStatusCmd = &cobra.Command{ + Use: "status [target]", + Short: "Show what's on an agent's hook", + Long: `Show what's slung on an agent's hook. + +If no target is specified, shows the current agent's status based on +the working directory (polecat, crew member, witness, etc.). + +Output includes: +- What's slung (molecule name, associated issue) +- Current phase and progress +- Whether it's a wisp +- Next action hint + +Examples: + gt mol status # Show current agent's hook + gt mol status gastown/nux # Show specific polecat's hook + gt mol status gastown/witness # Show witness's hook`, + Args: cobra.MaximumNArgs(1), + RunE: runMoleculeStatus, +} + func init() { // List flags moleculeListCmd.Flags().BoolVar(&moleculeJSON, "json", false, "Output as JSON") @@ -195,7 +218,11 @@ func init() { // Attachment flags moleculeAttachmentCmd.Flags().BoolVar(&moleculeJSON, "json", false, "Output as JSON") + // Status flags + moleculeStatusCmd.Flags().BoolVar(&moleculeJSON, "json", false, "Output as JSON") + // Add subcommands + moleculeCmd.AddCommand(moleculeStatusCmd) moleculeCmd.AddCommand(moleculeListCmd) moleculeCmd.AddCommand(moleculeShowCmd) moleculeCmd.AddCommand(moleculeParseCmd) @@ -979,3 +1006,307 @@ func runMoleculeAttachment(cmd *cobra.Command, args []string) error { return nil } + +// MoleculeStatusInfo contains status information for an agent's hook. +type MoleculeStatusInfo struct { + Target string `json:"target"` + Role string `json:"role"` + HasWork bool `json:"has_work"` + PinnedBead *beads.Issue `json:"pinned_bead,omitempty"` + AttachedMolecule string `json:"attached_molecule,omitempty"` + AttachedAt string `json:"attached_at,omitempty"` + IsWisp bool `json:"is_wisp"` + Progress *MoleculeProgressInfo `json:"progress,omitempty"` + NextAction string `json:"next_action,omitempty"` +} + +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 { + // Auto-detect from current directory + roleCtx = detectRole(cwd, townRoot) + target = buildAgentIdentity(roleCtx) + if target == "" { + return fmt.Errorf("cannot determine agent identity from current directory (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) + + // Find pinned beads for this agent + pinnedBeads, err := b.List(beads.ListOptions{ + Status: beads.StatusPinned, + Assignee: target, + Priority: -1, + }) + if err != nil { + return fmt.Errorf("listing pinned beads: %w", err) + } + + // Build status info + status := MoleculeStatusInfo{ + Target: target, + Role: string(roleCtx.Role), + HasWork: len(pinnedBeads) > 0, + } + + if len(pinnedBeads) > 0 { + // Take the first pinned bead (agents typically have one pinned bead) + status.PinnedBead = pinnedBeads[0] + + // Check for attached molecule + attachment := beads.ParseAttachmentFields(pinnedBeads[0]) + if attachment != nil { + status.AttachedMolecule = attachment.AttachedMolecule + status.AttachedAt = attachment.AttachedAt + + // Check if it's a wisp (look for wisp indicator in description) + status.IsWisp = strings.Contains(pinnedBeads[0].Description, "wisp: true") || + strings.Contains(pinnedBeads[0].Description, "is_wisp: true") + + // Get progress if there's an attached molecule + if attachment.AttachedMolecule != "" { + progress, _ := getMoleculeProgressInfo(b, attachment.AttachedMolecule) + status.Progress = progress + + // Determine next action + 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. +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 + "/" + 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 pinned bead info + fmt.Printf("%s %s: %s\n", style.Bold.Render("📌 Pinned:"), 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) + } + } else { + fmt.Printf("%s\n", style.Dim.Render("No molecule attached")) + } + + // 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 +}