From 78507ff326e50964442d621e6981da7ce69c1552 Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Tue, 23 Dec 2025 01:38:34 -0800 Subject: [PATCH] refactor(cmd): split molecule.go into focused files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split the 1929-line molecule.go into 5 focused files: - molecule.go (376): command definitions, init(), loadMoleculeCatalog - molecule_status.go (673): status, progress, current commands - molecule_list.go (432): list, show, export, parse, instances - molecule_lifecycle.go (359): instantiate, catalog, burn, squash - molecule_attach.go (128): attach, detach, attachment No functional changes - pure refactoring for maintainability. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/cmd/molecule.go | 1563 +--------------------------- internal/cmd/molecule_attach.go | 128 +++ internal/cmd/molecule_lifecycle.go | 359 +++++++ internal/cmd/molecule_list.go | 432 ++++++++ internal/cmd/molecule_status.go | 673 ++++++++++++ 5 files changed, 1597 insertions(+), 1558 deletions(-) create mode 100644 internal/cmd/molecule_attach.go create mode 100644 internal/cmd/molecule_lifecycle.go create mode 100644 internal/cmd/molecule_list.go create mode 100644 internal/cmd/molecule_status.go diff --git a/internal/cmd/molecule.go b/internal/cmd/molecule.go index 982bc7cf..d2ad0e1d 100644 --- a/internal/cmd/molecule.go +++ b/internal/cmd/molecule.go @@ -1,26 +1,20 @@ package cmd import ( - "encoding/json" - "fmt" - "os" "path/filepath" - "strings" - "time" "github.com/spf13/cobra" "github.com/steveyegge/gastown/internal/beads" - "github.com/steveyegge/gastown/internal/style" "github.com/steveyegge/gastown/internal/workspace" ) // Molecule command flags var ( - moleculeJSON bool - moleculeInstParent string - moleculeInstContext []string - moleculeCatalogOnly bool // List only catalog templates - moleculeDBOnly bool // List only database molecules + moleculeJSON bool + moleculeInstParent string + moleculeInstContext []string + moleculeCatalogOnly bool // List only catalog templates + moleculeDBOnly bool // List only database molecules ) var moleculeCmd = &cobra.Command{ @@ -360,125 +354,6 @@ func init() { rootCmd.AddCommand(moleculeCmd) } -func runMoleculeList(cmd *cobra.Command, args []string) error { - workDir, err := findLocalBeadsDir() - if err != nil { - return fmt.Errorf("not in a beads workspace: %w", err) - } - - // Collect molecules from requested sources - type moleculeEntry struct { - ID string `json:"id"` - Title string `json:"title"` - Source string `json:"source"` - StepCount int `json:"step_count,omitempty"` - Status string `json:"status,omitempty"` - Description string `json:"description,omitempty"` - } - - var entries []moleculeEntry - - // Load from catalog (unless --db only) - if !moleculeDBOnly { - catalog, err := loadMoleculeCatalog(workDir) - if err != nil { - return fmt.Errorf("loading catalog: %w", err) - } - - for _, mol := range catalog.List() { - steps, _ := beads.ParseMoleculeSteps(mol.Description) - entries = append(entries, moleculeEntry{ - ID: mol.ID, - Title: mol.Title, - Source: mol.Source, - StepCount: len(steps), - Description: mol.Description, - }) - } - } - - // Load from database (unless --catalog only) - if !moleculeCatalogOnly { - b := beads.New(workDir) - issues, err := b.List(beads.ListOptions{ - Type: "molecule", - Status: "all", - Priority: -1, - }) - if err != nil { - return fmt.Errorf("listing molecules: %w", err) - } - - // Track catalog IDs to avoid duplicates - catalogIDs := make(map[string]bool) - for _, e := range entries { - catalogIDs[e.ID] = true - } - - for _, mol := range issues { - // Skip if already in catalog (catalog takes precedence) - if catalogIDs[mol.ID] { - continue - } - - steps, _ := beads.ParseMoleculeSteps(mol.Description) - entries = append(entries, moleculeEntry{ - ID: mol.ID, - Title: mol.Title, - Source: "database", - StepCount: len(steps), - Status: mol.Status, - Description: mol.Description, - }) - } - } - - if moleculeJSON { - enc := json.NewEncoder(os.Stdout) - enc.SetIndent("", " ") - return enc.Encode(entries) - } - - // Human-readable output - fmt.Printf("%s Molecules (%d)\n\n", style.Bold.Render("🧬"), len(entries)) - - if len(entries) == 0 { - fmt.Printf(" %s\n", style.Dim.Render("(no molecules defined)")) - return nil - } - - // Create styled table - table := style.NewTable( - style.Column{Name: "ID", Width: 20}, - style.Column{Name: "TITLE", Width: 35}, - style.Column{Name: "STEPS", Width: 5, Align: style.AlignRight}, - style.Column{Name: "SOURCE", Width: 10}, - ) - - for _, mol := range entries { - // Format steps count - stepStr := "" - if mol.StepCount > 0 { - stepStr = fmt.Sprintf("%d", mol.StepCount) - } - - // Format title with status - title := mol.Title - if mol.Status == "closed" { - title = style.Dim.Render(mol.Title + " [closed]") - } - - // Format source - source := style.Dim.Render(mol.Source) - - table.AddRow(mol.ID, title, stepStr, source) - } - - fmt.Print(table.Render()) - - return nil -} - // loadMoleculeCatalog loads the molecule catalog with hierarchical sources. func loadMoleculeCatalog(workDir string) (*beads.MoleculeCatalog, error) { var townRoot, rigPath, projectPath string @@ -499,1431 +374,3 @@ func loadMoleculeCatalog(workDir string) (*beads.MoleculeCatalog, error) { return beads.LoadCatalog(townRoot, rigPath, projectPath) } - -func runMoleculeExport(cmd *cobra.Command, args []string) error { - path := args[0] - - if err := beads.ExportBuiltinMolecules(path); err != nil { - return fmt.Errorf("exporting molecules: %w", err) - } - - fmt.Printf("%s Exported %d built-in molecules to %s\n", - style.Bold.Render("✓"), len(beads.BuiltinMolecules()), path) - - return nil -} - -func runMoleculeShow(cmd *cobra.Command, args []string) error { - molID := args[0] - - workDir, err := findLocalBeadsDir() - if err != nil { - return fmt.Errorf("not in a beads workspace: %w", err) - } - - // Try catalog first - catalog, err := loadMoleculeCatalog(workDir) - if err != nil { - return fmt.Errorf("loading catalog: %w", err) - } - - var mol *beads.Issue - var source string - - if catalogMol := catalog.Get(molID); catalogMol != nil { - mol = catalogMol.ToIssue() - source = catalogMol.Source - } else { - // Fall back to database - b := beads.New(workDir) - mol, err = b.Show(molID) - if err != nil { - return fmt.Errorf("getting molecule: %w", err) - } - source = "database" - } - - if mol.Type != "molecule" { - return fmt.Errorf("%s is not a molecule (type: %s)", molID, mol.Type) - } - - // Parse steps - steps, parseErr := beads.ParseMoleculeSteps(mol.Description) - _ = source // Used below in output - - // For JSON, include parsed steps - if moleculeJSON { - type moleculeOutput struct { - *beads.Issue - Source string `json:"source"` - Steps []beads.MoleculeStep `json:"steps,omitempty"` - ParseError string `json:"parse_error,omitempty"` - } - out := moleculeOutput{Issue: mol, Source: source, Steps: steps} - if parseErr != nil { - out.ParseError = parseErr.Error() - } - enc := json.NewEncoder(os.Stdout) - enc.SetIndent("", " ") - return enc.Encode(out) - } - - // Human-readable output - fmt.Printf("\n%s: %s %s\n", style.Bold.Render(mol.ID), mol.Title, style.Dim.Render(fmt.Sprintf("[%s]", source))) - fmt.Printf("Type: %s\n", mol.Type) - - if parseErr != nil { - fmt.Printf("\n%s Parse error: %s\n", style.Bold.Render("⚠"), parseErr) - } - - // Show steps - fmt.Printf("\nSteps (%d):\n", len(steps)) - if len(steps) == 0 { - fmt.Printf(" %s\n", style.Dim.Render("(no steps defined)")) - } else { - // Find which steps are ready (no dependencies) - for _, step := range steps { - needsStr := "" - if len(step.Needs) == 0 { - needsStr = style.Dim.Render("(ready first)") - } else { - needsStr = fmt.Sprintf("Needs: %s", strings.Join(step.Needs, ", ")) - } - - tierStr := "" - if step.Tier != "" { - tierStr = fmt.Sprintf(" [%s]", step.Tier) - } - - fmt.Printf(" %-12s → %s%s\n", step.Ref, needsStr, tierStr) - } - } - - // Count instances (need beads client for this) - b := beads.New(workDir) - instances, _ := findMoleculeInstances(b, molID) - fmt.Printf("\nInstances: %d\n", len(instances)) - - return nil -} - -func runMoleculeParse(cmd *cobra.Command, args []string) error { - molID := args[0] - - workDir, err := findLocalBeadsDir() - if err != nil { - return fmt.Errorf("not in a beads workspace: %w", err) - } - - b := beads.New(workDir) - mol, err := b.Show(molID) - if err != nil { - return fmt.Errorf("getting molecule: %w", err) - } - - // Validate the molecule - validationErr := beads.ValidateMolecule(mol) - - // Parse steps regardless of validation - steps, parseErr := beads.ParseMoleculeSteps(mol.Description) - - if moleculeJSON { - type parseOutput struct { - Valid bool `json:"valid"` - ValidationError string `json:"validation_error,omitempty"` - ParseError string `json:"parse_error,omitempty"` - Steps []beads.MoleculeStep `json:"steps"` - } - out := parseOutput{ - Valid: validationErr == nil, - Steps: steps, - } - if validationErr != nil { - out.ValidationError = validationErr.Error() - } - if parseErr != nil { - out.ParseError = parseErr.Error() - } - enc := json.NewEncoder(os.Stdout) - enc.SetIndent("", " ") - return enc.Encode(out) - } - - // Human-readable output - fmt.Printf("\n%s: %s\n\n", style.Bold.Render(mol.ID), mol.Title) - - if validationErr != nil { - fmt.Printf("%s Validation failed: %s\n\n", style.Bold.Render("✗"), validationErr) - } else { - fmt.Printf("%s Valid molecule\n\n", style.Bold.Render("✓")) - } - - if parseErr != nil { - fmt.Printf("Parse error: %s\n\n", parseErr) - } - - fmt.Printf("Parsed Steps (%d):\n", len(steps)) - for i, step := range steps { - fmt.Printf("\n [%d] %s\n", i+1, style.Bold.Render(step.Ref)) - if step.Title != step.Ref { - fmt.Printf(" Title: %s\n", step.Title) - } - if len(step.Needs) > 0 { - fmt.Printf(" Needs: %s\n", strings.Join(step.Needs, ", ")) - } - if step.Tier != "" { - fmt.Printf(" Tier: %s\n", step.Tier) - } - if step.Instructions != "" { - // Show first line of instructions - firstLine := strings.SplitN(step.Instructions, "\n", 2)[0] - if len(firstLine) > 60 { - firstLine = firstLine[:57] + "..." - } - fmt.Printf(" Instructions: %s\n", style.Dim.Render(firstLine)) - } - } - - return nil -} - -func runMoleculeInstantiate(cmd *cobra.Command, args []string) error { - molID := args[0] - - workDir, err := findLocalBeadsDir() - if err != nil { - return fmt.Errorf("not in a beads workspace: %w", err) - } - - b := beads.New(workDir) - - // Try catalog first - catalog, err := loadMoleculeCatalog(workDir) - if err != nil { - return fmt.Errorf("loading catalog: %w", err) - } - - var mol *beads.Issue - - if catalogMol := catalog.Get(molID); catalogMol != nil { - mol = catalogMol.ToIssue() - } else { - // Fall back to database - mol, err = b.Show(molID) - if err != nil { - return fmt.Errorf("getting molecule: %w", err) - } - } - - if mol.Type != "molecule" { - return fmt.Errorf("%s is not a molecule (type: %s)", molID, mol.Type) - } - - // Validate molecule - if err := beads.ValidateMolecule(mol); err != nil { - return fmt.Errorf("invalid molecule: %w", err) - } - - // Get the parent issue - parent, err := b.Show(moleculeInstParent) - if err != nil { - return fmt.Errorf("getting parent issue: %w", err) - } - - // Parse context variables - ctx := make(map[string]string) - for _, kv := range moleculeInstContext { - parts := strings.SplitN(kv, "=", 2) - if len(parts) != 2 { - return fmt.Errorf("invalid context format %q (expected key=value)", kv) - } - ctx[parts[0]] = parts[1] - } - - // Instantiate the molecule - opts := beads.InstantiateOptions{Context: ctx} - steps, err := b.InstantiateMolecule(mol, parent, opts) - if err != nil { - return fmt.Errorf("instantiating molecule: %w", err) - } - - fmt.Printf("%s Created %d steps from %s on %s\n\n", - style.Bold.Render("✓"), len(steps), molID, moleculeInstParent) - - for _, step := range steps { - fmt.Printf(" %s: %s\n", style.Dim.Render(step.ID), step.Title) - } - - return nil -} - -func runMoleculeInstances(cmd *cobra.Command, args []string) error { - molID := args[0] - - workDir, err := findLocalBeadsDir() - if err != nil { - return fmt.Errorf("not in a beads workspace: %w", err) - } - - b := beads.New(workDir) - - // Verify the molecule exists - mol, err := b.Show(molID) - if err != nil { - return fmt.Errorf("getting molecule: %w", err) - } - - if mol.Type != "molecule" { - return fmt.Errorf("%s is not a molecule (type: %s)", molID, mol.Type) - } - - // Find all instances - instances, err := findMoleculeInstances(b, molID) - if err != nil { - return fmt.Errorf("finding instances: %w", err) - } - - if moleculeJSON { - enc := json.NewEncoder(os.Stdout) - enc.SetIndent("", " ") - return enc.Encode(instances) - } - - // Human-readable output - fmt.Printf("\n%s Instances of %s (%d)\n\n", - style.Bold.Render("📋"), molID, len(instances)) - - if len(instances) == 0 { - fmt.Printf(" %s\n", style.Dim.Render("(no instantiations found)")) - return nil - } - - fmt.Printf("%-16s %-12s %s\n", - style.Bold.Render("Parent"), - style.Bold.Render("Status"), - style.Bold.Render("Created")) - fmt.Println(strings.Repeat("-", 50)) - - for _, inst := range instances { - // Calculate progress from children - progress := "" - if len(inst.Children) > 0 { - closed := 0 - for _, childID := range inst.Children { - child, err := b.Show(childID) - if err == nil && child.Status == "closed" { - closed++ - } - } - progress = fmt.Sprintf(" (%d/%d complete)", closed, len(inst.Children)) - } - - statusStr := inst.Status - if inst.Status == "closed" { - statusStr = style.Dim.Render("done") - } else if inst.Status == "in_progress" { - statusStr = "active" - } - - created := "" - if inst.CreatedAt != "" { - // Parse and format date - created = inst.CreatedAt[:10] // Just the date portion - } - - fmt.Printf("%-16s %-12s %s%s\n", inst.ID, statusStr, created, progress) - } - - return nil -} - -// moleculeInstance represents an instantiation of a molecule. -type moleculeInstance struct { - *beads.Issue -} - -// findMoleculeInstances finds all parent issues that have steps instantiated from the given molecule. -func findMoleculeInstances(b *beads.Beads, molID string) ([]*beads.Issue, error) { - // Get all issues and look for ones with children that have instantiated_from metadata - // This is a brute-force approach - could be optimized with better queries - - // Strategy: search for issues whose descriptions contain "instantiated_from: " - allIssues, err := b.List(beads.ListOptions{Status: "all", Priority: -1}) - if err != nil { - return nil, err - } - - // Find issues that reference this molecule - parentIDs := make(map[string]bool) - for _, issue := range allIssues { - if strings.Contains(issue.Description, fmt.Sprintf("instantiated_from: %s", molID)) { - // This is a step - find its parent - if issue.Parent != "" { - parentIDs[issue.Parent] = true - } - } - } - - // Fetch the parent issues - var parents []*beads.Issue - for parentID := range parentIDs { - parent, err := b.Show(parentID) - if err == nil { - parents = append(parents, parent) - } - } - - return parents, nil -} - -// 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"` -} - -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 runMoleculeAttach(cmd *cobra.Command, args []string) error { - pinnedBeadID := args[0] - moleculeID := args[1] - - workDir, err := findLocalBeadsDir() - if err != nil { - return fmt.Errorf("not in a beads workspace: %w", err) - } - - b := beads.New(workDir) - - // Attach the molecule - issue, err := b.AttachMolecule(pinnedBeadID, moleculeID) - if err != nil { - return fmt.Errorf("attaching molecule: %w", err) - } - - attachment := beads.ParseAttachmentFields(issue) - fmt.Printf("%s Attached %s to %s\n", style.Bold.Render("✓"), moleculeID, pinnedBeadID) - if attachment != nil && attachment.AttachedAt != "" { - fmt.Printf(" attached_at: %s\n", attachment.AttachedAt) - } - - return nil -} - -func runMoleculeDetach(cmd *cobra.Command, args []string) error { - pinnedBeadID := args[0] - - workDir, err := findLocalBeadsDir() - if err != nil { - return fmt.Errorf("not in a beads workspace: %w", err) - } - - b := beads.New(workDir) - - // Check current attachment first - attachment, err := b.GetAttachment(pinnedBeadID) - if err != nil { - return fmt.Errorf("checking attachment: %w", err) - } - - if attachment == nil { - fmt.Printf("%s No molecule attached to %s\n", style.Dim.Render("ℹ"), pinnedBeadID) - return nil - } - - previousMolecule := attachment.AttachedMolecule - - // Detach the molecule - _, err = b.DetachMolecule(pinnedBeadID) - if err != nil { - return fmt.Errorf("detaching molecule: %w", err) - } - - fmt.Printf("%s Detached %s from %s\n", style.Bold.Render("✓"), previousMolecule, pinnedBeadID) - - return nil -} - -func runMoleculeAttachment(cmd *cobra.Command, args []string) error { - pinnedBeadID := args[0] - - workDir, err := findLocalBeadsDir() - if err != nil { - return fmt.Errorf("not in a beads workspace: %w", err) - } - - b := beads.New(workDir) - - // Get the issue - issue, err := b.Show(pinnedBeadID) - if err != nil { - return fmt.Errorf("getting issue: %w", err) - } - - attachment := beads.ParseAttachmentFields(issue) - - if moleculeJSON { - type attachmentOutput struct { - IssueID string `json:"issue_id"` - IssueTitle string `json:"issue_title"` - Status string `json:"status"` - AttachedMolecule string `json:"attached_molecule,omitempty"` - AttachedAt string `json:"attached_at,omitempty"` - } - out := attachmentOutput{ - IssueID: issue.ID, - IssueTitle: issue.Title, - Status: issue.Status, - } - if attachment != nil { - out.AttachedMolecule = attachment.AttachedMolecule - out.AttachedAt = attachment.AttachedAt - } - enc := json.NewEncoder(os.Stdout) - enc.SetIndent("", " ") - return enc.Encode(out) - } - - // Human-readable output - fmt.Printf("\n%s: %s\n", style.Bold.Render(issue.ID), issue.Title) - fmt.Printf("Status: %s\n", issue.Status) - - if attachment == nil || attachment.AttachedMolecule == "" { - fmt.Printf("\n%s\n", style.Dim.Render("No molecule attached")) - } else { - fmt.Printf("\n%s\n", style.Bold.Render("Attached Molecule:")) - fmt.Printf(" ID: %s\n", attachment.AttachedMolecule) - if attachment.AttachedAt != "" { - fmt.Printf(" Attached at: %s\n", attachment.AttachedAt) - } - } - - 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 -} - -// 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 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 { - // 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) - - // 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 -} - -// runMoleculeCatalog lists available molecule protos. -func runMoleculeCatalog(cmd *cobra.Command, args []string) error { - workDir, err := findLocalBeadsDir() - if err != nil { - return fmt.Errorf("not in a beads workspace: %w", err) - } - - // Load catalog - catalog, err := loadMoleculeCatalog(workDir) - if err != nil { - return fmt.Errorf("loading catalog: %w", err) - } - - molecules := catalog.List() - - if moleculeJSON { - type catalogEntry struct { - ID string `json:"id"` - Title string `json:"title"` - Source string `json:"source"` - StepCount int `json:"step_count"` - } - - var entries []catalogEntry - for _, mol := range molecules { - steps, _ := beads.ParseMoleculeSteps(mol.Description) - entries = append(entries, catalogEntry{ - ID: mol.ID, - Title: mol.Title, - Source: mol.Source, - StepCount: len(steps), - }) - } - - enc := json.NewEncoder(os.Stdout) - enc.SetIndent("", " ") - return enc.Encode(entries) - } - - // Human-readable output - fmt.Printf("%s Molecule Catalog (%d protos)\n\n", style.Bold.Render("🧬"), len(molecules)) - - if len(molecules) == 0 { - fmt.Printf(" %s\n", style.Dim.Render("(no protos available)")) - return nil - } - - for _, mol := range molecules { - steps, _ := beads.ParseMoleculeSteps(mol.Description) - stepCount := len(steps) - - sourceMarker := style.Dim.Render(fmt.Sprintf("[%s]", mol.Source)) - fmt.Printf(" %s: %s (%d steps) %s\n", - style.Bold.Render(mol.ID), mol.Title, stepCount, sourceMarker) - } - - return nil -} - -// runMoleculeBurn burns (destroys) the current molecule attachment. -func runMoleculeBurn(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 - if len(args) > 0 { - 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") - } - } - - // Find beads directory - workDir, err := findLocalBeadsDir() - if err != nil { - return fmt.Errorf("not in a beads workspace: %w", err) - } - - b := beads.New(workDir) - - // Find agent's pinned bead (handoff bead) - parts := strings.Split(target, "/") - role := parts[len(parts)-1] - - handoff, err := b.FindHandoffBead(role) - if err != nil { - return fmt.Errorf("finding handoff bead: %w", err) - } - if handoff == nil { - return fmt.Errorf("no handoff bead found for %s", target) - } - - // Check for attached molecule - attachment := beads.ParseAttachmentFields(handoff) - if attachment == nil || attachment.AttachedMolecule == "" { - fmt.Printf("%s No molecule attached to %s - nothing to burn\n", - style.Dim.Render("ℹ"), target) - return nil - } - - moleculeID := attachment.AttachedMolecule - - // Detach the molecule (this "burns" it by removing the attachment) - _, err = b.DetachMolecule(handoff.ID) - if err != nil { - return fmt.Errorf("detaching molecule: %w", err) - } - - if moleculeJSON { - result := map[string]interface{}{ - "burned": moleculeID, - "from": target, - "handoff_id": handoff.ID, - } - enc := json.NewEncoder(os.Stdout) - enc.SetIndent("", " ") - return enc.Encode(result) - } - - fmt.Printf("%s Burned molecule %s from %s\n", - style.Bold.Render("🔥"), moleculeID, target) - - return nil -} - -// runMoleculeSquash squashes the current molecule into a digest. -func runMoleculeSquash(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 - if len(args) > 0 { - 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") - } - } - - // Find beads directory - workDir, err := findLocalBeadsDir() - if err != nil { - return fmt.Errorf("not in a beads workspace: %w", err) - } - - b := beads.New(workDir) - - // Find agent's pinned bead (handoff bead) - parts := strings.Split(target, "/") - role := parts[len(parts)-1] - - handoff, err := b.FindHandoffBead(role) - if err != nil { - return fmt.Errorf("finding handoff bead: %w", err) - } - if handoff == nil { - return fmt.Errorf("no handoff bead found for %s", target) - } - - // Check for attached molecule - attachment := beads.ParseAttachmentFields(handoff) - if attachment == nil || attachment.AttachedMolecule == "" { - fmt.Printf("%s No molecule attached to %s - nothing to squash\n", - style.Dim.Render("ℹ"), target) - return nil - } - - moleculeID := attachment.AttachedMolecule - - // Get progress info for the digest - progress, _ := getMoleculeProgressInfo(b, moleculeID) - - // Create a digest issue - digestTitle := fmt.Sprintf("Digest: %s", moleculeID) - digestDesc := fmt.Sprintf(`Squashed molecule execution. - -molecule: %s -agent: %s -squashed_at: %s -`, moleculeID, target, time.Now().UTC().Format(time.RFC3339)) - - if progress != nil { - digestDesc += fmt.Sprintf(` -## Execution Summary -- Steps: %d/%d completed -- Status: %s -`, progress.DoneSteps, progress.TotalSteps, func() string { - if progress.Complete { - return "complete" - } - return "partial" - }()) - } - - // Create the digest bead - digestIssue, err := b.Create(beads.CreateOptions{ - Title: digestTitle, - Description: digestDesc, - Type: "task", - Priority: 4, // P4 - backlog priority for digests - }) - if err != nil { - return fmt.Errorf("creating digest: %w", err) - } - - // Add the digest label - _ = b.Update(digestIssue.ID, beads.UpdateOptions{ - AddLabels: []string{"digest"}, - }) - - // Close the digest immediately - closedStatus := "closed" - err = b.Update(digestIssue.ID, beads.UpdateOptions{ - Status: &closedStatus, - }) - if err != nil { - fmt.Printf("%s Created digest but couldn't close it: %v\n", - style.Dim.Render("Warning:"), err) - } - - // Detach the molecule from the handoff bead - _, err = b.DetachMolecule(handoff.ID) - if err != nil { - return fmt.Errorf("detaching molecule: %w", err) - } - - if moleculeJSON { - result := map[string]interface{}{ - "squashed": moleculeID, - "digest_id": digestIssue.ID, - "from": target, - "handoff_id": handoff.ID, - } - enc := json.NewEncoder(os.Stdout) - enc.SetIndent("", " ") - return enc.Encode(result) - } - - fmt.Printf("%s Squashed molecule %s → digest %s\n", - style.Bold.Render("📦"), moleculeID, digestIssue.ID) - - return nil -} diff --git a/internal/cmd/molecule_attach.go b/internal/cmd/molecule_attach.go new file mode 100644 index 00000000..982ad3d6 --- /dev/null +++ b/internal/cmd/molecule_attach.go @@ -0,0 +1,128 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/spf13/cobra" + "github.com/steveyegge/gastown/internal/beads" + "github.com/steveyegge/gastown/internal/style" +) + +func runMoleculeAttach(cmd *cobra.Command, args []string) error { + pinnedBeadID := args[0] + moleculeID := args[1] + + workDir, err := findLocalBeadsDir() + if err != nil { + return fmt.Errorf("not in a beads workspace: %w", err) + } + + b := beads.New(workDir) + + // Attach the molecule + issue, err := b.AttachMolecule(pinnedBeadID, moleculeID) + if err != nil { + return fmt.Errorf("attaching molecule: %w", err) + } + + attachment := beads.ParseAttachmentFields(issue) + fmt.Printf("%s Attached %s to %s\n", style.Bold.Render("✓"), moleculeID, pinnedBeadID) + if attachment != nil && attachment.AttachedAt != "" { + fmt.Printf(" attached_at: %s\n", attachment.AttachedAt) + } + + return nil +} + +func runMoleculeDetach(cmd *cobra.Command, args []string) error { + pinnedBeadID := args[0] + + workDir, err := findLocalBeadsDir() + if err != nil { + return fmt.Errorf("not in a beads workspace: %w", err) + } + + b := beads.New(workDir) + + // Check current attachment first + attachment, err := b.GetAttachment(pinnedBeadID) + if err != nil { + return fmt.Errorf("checking attachment: %w", err) + } + + if attachment == nil { + fmt.Printf("%s No molecule attached to %s\n", style.Dim.Render("ℹ"), pinnedBeadID) + return nil + } + + previousMolecule := attachment.AttachedMolecule + + // Detach the molecule + _, err = b.DetachMolecule(pinnedBeadID) + if err != nil { + return fmt.Errorf("detaching molecule: %w", err) + } + + fmt.Printf("%s Detached %s from %s\n", style.Bold.Render("✓"), previousMolecule, pinnedBeadID) + + return nil +} + +func runMoleculeAttachment(cmd *cobra.Command, args []string) error { + pinnedBeadID := args[0] + + workDir, err := findLocalBeadsDir() + if err != nil { + return fmt.Errorf("not in a beads workspace: %w", err) + } + + b := beads.New(workDir) + + // Get the issue + issue, err := b.Show(pinnedBeadID) + if err != nil { + return fmt.Errorf("getting issue: %w", err) + } + + attachment := beads.ParseAttachmentFields(issue) + + if moleculeJSON { + type attachmentOutput struct { + IssueID string `json:"issue_id"` + IssueTitle string `json:"issue_title"` + Status string `json:"status"` + AttachedMolecule string `json:"attached_molecule,omitempty"` + AttachedAt string `json:"attached_at,omitempty"` + } + out := attachmentOutput{ + IssueID: issue.ID, + IssueTitle: issue.Title, + Status: issue.Status, + } + if attachment != nil { + out.AttachedMolecule = attachment.AttachedMolecule + out.AttachedAt = attachment.AttachedAt + } + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(out) + } + + // Human-readable output + fmt.Printf("\n%s: %s\n", style.Bold.Render(issue.ID), issue.Title) + fmt.Printf("Status: %s\n", issue.Status) + + if attachment == nil || attachment.AttachedMolecule == "" { + fmt.Printf("\n%s\n", style.Dim.Render("No molecule attached")) + } else { + fmt.Printf("\n%s\n", style.Bold.Render("Attached Molecule:")) + fmt.Printf(" ID: %s\n", attachment.AttachedMolecule) + if attachment.AttachedAt != "" { + fmt.Printf(" Attached at: %s\n", attachment.AttachedAt) + } + } + + return nil +} diff --git a/internal/cmd/molecule_lifecycle.go b/internal/cmd/molecule_lifecycle.go new file mode 100644 index 00000000..5a9b5676 --- /dev/null +++ b/internal/cmd/molecule_lifecycle.go @@ -0,0 +1,359 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "os" + "strings" + "time" + + "github.com/spf13/cobra" + "github.com/steveyegge/gastown/internal/beads" + "github.com/steveyegge/gastown/internal/style" + "github.com/steveyegge/gastown/internal/workspace" +) + +func runMoleculeInstantiate(cmd *cobra.Command, args []string) error { + molID := args[0] + + workDir, err := findLocalBeadsDir() + if err != nil { + return fmt.Errorf("not in a beads workspace: %w", err) + } + + b := beads.New(workDir) + + // Try catalog first + catalog, err := loadMoleculeCatalog(workDir) + if err != nil { + return fmt.Errorf("loading catalog: %w", err) + } + + var mol *beads.Issue + + if catalogMol := catalog.Get(molID); catalogMol != nil { + mol = catalogMol.ToIssue() + } else { + // Fall back to database + mol, err = b.Show(molID) + if err != nil { + return fmt.Errorf("getting molecule: %w", err) + } + } + + if mol.Type != "molecule" { + return fmt.Errorf("%s is not a molecule (type: %s)", molID, mol.Type) + } + + // Validate molecule + if err := beads.ValidateMolecule(mol); err != nil { + return fmt.Errorf("invalid molecule: %w", err) + } + + // Get the parent issue + parent, err := b.Show(moleculeInstParent) + if err != nil { + return fmt.Errorf("getting parent issue: %w", err) + } + + // Parse context variables + ctx := make(map[string]string) + for _, kv := range moleculeInstContext { + parts := strings.SplitN(kv, "=", 2) + if len(parts) != 2 { + return fmt.Errorf("invalid context format %q (expected key=value)", kv) + } + ctx[parts[0]] = parts[1] + } + + // Instantiate the molecule + opts := beads.InstantiateOptions{Context: ctx} + steps, err := b.InstantiateMolecule(mol, parent, opts) + if err != nil { + return fmt.Errorf("instantiating molecule: %w", err) + } + + fmt.Printf("%s Created %d steps from %s on %s\n\n", + style.Bold.Render("✓"), len(steps), molID, moleculeInstParent) + + for _, step := range steps { + fmt.Printf(" %s: %s\n", style.Dim.Render(step.ID), step.Title) + } + + return nil +} + +// runMoleculeCatalog lists available molecule protos. +func runMoleculeCatalog(cmd *cobra.Command, args []string) error { + workDir, err := findLocalBeadsDir() + if err != nil { + return fmt.Errorf("not in a beads workspace: %w", err) + } + + // Load catalog + catalog, err := loadMoleculeCatalog(workDir) + if err != nil { + return fmt.Errorf("loading catalog: %w", err) + } + + molecules := catalog.List() + + if moleculeJSON { + type catalogEntry struct { + ID string `json:"id"` + Title string `json:"title"` + Source string `json:"source"` + StepCount int `json:"step_count"` + } + + var entries []catalogEntry + for _, mol := range molecules { + steps, _ := beads.ParseMoleculeSteps(mol.Description) + entries = append(entries, catalogEntry{ + ID: mol.ID, + Title: mol.Title, + Source: mol.Source, + StepCount: len(steps), + }) + } + + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(entries) + } + + // Human-readable output + fmt.Printf("%s Molecule Catalog (%d protos)\n\n", style.Bold.Render("🧬"), len(molecules)) + + if len(molecules) == 0 { + fmt.Printf(" %s\n", style.Dim.Render("(no protos available)")) + return nil + } + + for _, mol := range molecules { + steps, _ := beads.ParseMoleculeSteps(mol.Description) + stepCount := len(steps) + + sourceMarker := style.Dim.Render(fmt.Sprintf("[%s]", mol.Source)) + fmt.Printf(" %s: %s (%d steps) %s\n", + style.Bold.Render(mol.ID), mol.Title, stepCount, sourceMarker) + } + + return nil +} + +// runMoleculeBurn burns (destroys) the current molecule attachment. +func runMoleculeBurn(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 + if len(args) > 0 { + 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") + } + } + + // Find beads directory + workDir, err := findLocalBeadsDir() + if err != nil { + return fmt.Errorf("not in a beads workspace: %w", err) + } + + b := beads.New(workDir) + + // Find agent's pinned bead (handoff bead) + parts := strings.Split(target, "/") + role := parts[len(parts)-1] + + handoff, err := b.FindHandoffBead(role) + if err != nil { + return fmt.Errorf("finding handoff bead: %w", err) + } + if handoff == nil { + return fmt.Errorf("no handoff bead found for %s", target) + } + + // Check for attached molecule + attachment := beads.ParseAttachmentFields(handoff) + if attachment == nil || attachment.AttachedMolecule == "" { + fmt.Printf("%s No molecule attached to %s - nothing to burn\n", + style.Dim.Render("ℹ"), target) + return nil + } + + moleculeID := attachment.AttachedMolecule + + // Detach the molecule (this "burns" it by removing the attachment) + _, err = b.DetachMolecule(handoff.ID) + if err != nil { + return fmt.Errorf("detaching molecule: %w", err) + } + + if moleculeJSON { + result := map[string]interface{}{ + "burned": moleculeID, + "from": target, + "handoff_id": handoff.ID, + } + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(result) + } + + fmt.Printf("%s Burned molecule %s from %s\n", + style.Bold.Render("🔥"), moleculeID, target) + + return nil +} + +// runMoleculeSquash squashes the current molecule into a digest. +func runMoleculeSquash(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 + if len(args) > 0 { + 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") + } + } + + // Find beads directory + workDir, err := findLocalBeadsDir() + if err != nil { + return fmt.Errorf("not in a beads workspace: %w", err) + } + + b := beads.New(workDir) + + // Find agent's pinned bead (handoff bead) + parts := strings.Split(target, "/") + role := parts[len(parts)-1] + + handoff, err := b.FindHandoffBead(role) + if err != nil { + return fmt.Errorf("finding handoff bead: %w", err) + } + if handoff == nil { + return fmt.Errorf("no handoff bead found for %s", target) + } + + // Check for attached molecule + attachment := beads.ParseAttachmentFields(handoff) + if attachment == nil || attachment.AttachedMolecule == "" { + fmt.Printf("%s No molecule attached to %s - nothing to squash\n", + style.Dim.Render("ℹ"), target) + return nil + } + + moleculeID := attachment.AttachedMolecule + + // Get progress info for the digest + progress, _ := getMoleculeProgressInfo(b, moleculeID) + + // Create a digest issue + digestTitle := fmt.Sprintf("Digest: %s", moleculeID) + digestDesc := fmt.Sprintf(`Squashed molecule execution. + +molecule: %s +agent: %s +squashed_at: %s +`, moleculeID, target, time.Now().UTC().Format(time.RFC3339)) + + if progress != nil { + digestDesc += fmt.Sprintf(` +## Execution Summary +- Steps: %d/%d completed +- Status: %s +`, progress.DoneSteps, progress.TotalSteps, func() string { + if progress.Complete { + return "complete" + } + return "partial" + }()) + } + + // Create the digest bead + digestIssue, err := b.Create(beads.CreateOptions{ + Title: digestTitle, + Description: digestDesc, + Type: "task", + Priority: 4, // P4 - backlog priority for digests + }) + if err != nil { + return fmt.Errorf("creating digest: %w", err) + } + + // Add the digest label + _ = b.Update(digestIssue.ID, beads.UpdateOptions{ + AddLabels: []string{"digest"}, + }) + + // Close the digest immediately + closedStatus := "closed" + err = b.Update(digestIssue.ID, beads.UpdateOptions{ + Status: &closedStatus, + }) + if err != nil { + fmt.Printf("%s Created digest but couldn't close it: %v\n", + style.Dim.Render("Warning:"), err) + } + + // Detach the molecule from the handoff bead + _, err = b.DetachMolecule(handoff.ID) + if err != nil { + return fmt.Errorf("detaching molecule: %w", err) + } + + if moleculeJSON { + result := map[string]interface{}{ + "squashed": moleculeID, + "digest_id": digestIssue.ID, + "from": target, + "handoff_id": handoff.ID, + } + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(result) + } + + fmt.Printf("%s Squashed molecule %s → digest %s\n", + style.Bold.Render("📦"), moleculeID, digestIssue.ID) + + return nil +} diff --git a/internal/cmd/molecule_list.go b/internal/cmd/molecule_list.go new file mode 100644 index 00000000..dbd1151f --- /dev/null +++ b/internal/cmd/molecule_list.go @@ -0,0 +1,432 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + "github.com/steveyegge/gastown/internal/beads" + "github.com/steveyegge/gastown/internal/style" +) + +func runMoleculeList(cmd *cobra.Command, args []string) error { + workDir, err := findLocalBeadsDir() + if err != nil { + return fmt.Errorf("not in a beads workspace: %w", err) + } + + // Collect molecules from requested sources + type moleculeEntry struct { + ID string `json:"id"` + Title string `json:"title"` + Source string `json:"source"` + StepCount int `json:"step_count,omitempty"` + Status string `json:"status,omitempty"` + Description string `json:"description,omitempty"` + } + + var entries []moleculeEntry + + // Load from catalog (unless --db only) + if !moleculeDBOnly { + catalog, err := loadMoleculeCatalog(workDir) + if err != nil { + return fmt.Errorf("loading catalog: %w", err) + } + + for _, mol := range catalog.List() { + steps, _ := beads.ParseMoleculeSteps(mol.Description) + entries = append(entries, moleculeEntry{ + ID: mol.ID, + Title: mol.Title, + Source: mol.Source, + StepCount: len(steps), + Description: mol.Description, + }) + } + } + + // Load from database (unless --catalog only) + if !moleculeCatalogOnly { + b := beads.New(workDir) + issues, err := b.List(beads.ListOptions{ + Type: "molecule", + Status: "all", + Priority: -1, + }) + if err != nil { + return fmt.Errorf("listing molecules: %w", err) + } + + // Track catalog IDs to avoid duplicates + catalogIDs := make(map[string]bool) + for _, e := range entries { + catalogIDs[e.ID] = true + } + + for _, mol := range issues { + // Skip if already in catalog (catalog takes precedence) + if catalogIDs[mol.ID] { + continue + } + + steps, _ := beads.ParseMoleculeSteps(mol.Description) + entries = append(entries, moleculeEntry{ + ID: mol.ID, + Title: mol.Title, + Source: "database", + StepCount: len(steps), + Status: mol.Status, + Description: mol.Description, + }) + } + } + + if moleculeJSON { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(entries) + } + + // Human-readable output + fmt.Printf("%s Molecules (%d)\n\n", style.Bold.Render("🧬"), len(entries)) + + if len(entries) == 0 { + fmt.Printf(" %s\n", style.Dim.Render("(no molecules defined)")) + return nil + } + + // Create styled table + table := style.NewTable( + style.Column{Name: "ID", Width: 20}, + style.Column{Name: "TITLE", Width: 35}, + style.Column{Name: "STEPS", Width: 5, Align: style.AlignRight}, + style.Column{Name: "SOURCE", Width: 10}, + ) + + for _, mol := range entries { + // Format steps count + stepStr := "" + if mol.StepCount > 0 { + stepStr = fmt.Sprintf("%d", mol.StepCount) + } + + // Format title with status + title := mol.Title + if mol.Status == "closed" { + title = style.Dim.Render(mol.Title + " [closed]") + } + + // Format source + source := style.Dim.Render(mol.Source) + + table.AddRow(mol.ID, title, stepStr, source) + } + + fmt.Print(table.Render()) + + return nil +} + +func runMoleculeExport(cmd *cobra.Command, args []string) error { + path := args[0] + + if err := beads.ExportBuiltinMolecules(path); err != nil { + return fmt.Errorf("exporting molecules: %w", err) + } + + fmt.Printf("%s Exported %d built-in molecules to %s\n", + style.Bold.Render("✓"), len(beads.BuiltinMolecules()), path) + + return nil +} + +func runMoleculeShow(cmd *cobra.Command, args []string) error { + molID := args[0] + + workDir, err := findLocalBeadsDir() + if err != nil { + return fmt.Errorf("not in a beads workspace: %w", err) + } + + // Try catalog first + catalog, err := loadMoleculeCatalog(workDir) + if err != nil { + return fmt.Errorf("loading catalog: %w", err) + } + + var mol *beads.Issue + var source string + + if catalogMol := catalog.Get(molID); catalogMol != nil { + mol = catalogMol.ToIssue() + source = catalogMol.Source + } else { + // Fall back to database + b := beads.New(workDir) + mol, err = b.Show(molID) + if err != nil { + return fmt.Errorf("getting molecule: %w", err) + } + source = "database" + } + + if mol.Type != "molecule" { + return fmt.Errorf("%s is not a molecule (type: %s)", molID, mol.Type) + } + + // Parse steps + steps, parseErr := beads.ParseMoleculeSteps(mol.Description) + _ = source // Used below in output + + // For JSON, include parsed steps + if moleculeJSON { + type moleculeOutput struct { + *beads.Issue + Source string `json:"source"` + Steps []beads.MoleculeStep `json:"steps,omitempty"` + ParseError string `json:"parse_error,omitempty"` + } + out := moleculeOutput{Issue: mol, Source: source, Steps: steps} + if parseErr != nil { + out.ParseError = parseErr.Error() + } + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(out) + } + + // Human-readable output + fmt.Printf("\n%s: %s %s\n", style.Bold.Render(mol.ID), mol.Title, style.Dim.Render(fmt.Sprintf("[%s]", source))) + fmt.Printf("Type: %s\n", mol.Type) + + if parseErr != nil { + fmt.Printf("\n%s Parse error: %s\n", style.Bold.Render("⚠"), parseErr) + } + + // Show steps + fmt.Printf("\nSteps (%d):\n", len(steps)) + if len(steps) == 0 { + fmt.Printf(" %s\n", style.Dim.Render("(no steps defined)")) + } else { + // Find which steps are ready (no dependencies) + for _, step := range steps { + needsStr := "" + if len(step.Needs) == 0 { + needsStr = style.Dim.Render("(ready first)") + } else { + needsStr = fmt.Sprintf("Needs: %s", strings.Join(step.Needs, ", ")) + } + + tierStr := "" + if step.Tier != "" { + tierStr = fmt.Sprintf(" [%s]", step.Tier) + } + + fmt.Printf(" %-12s → %s%s\n", step.Ref, needsStr, tierStr) + } + } + + // Count instances (need beads client for this) + b := beads.New(workDir) + instances, _ := findMoleculeInstances(b, molID) + fmt.Printf("\nInstances: %d\n", len(instances)) + + return nil +} + +func runMoleculeParse(cmd *cobra.Command, args []string) error { + molID := args[0] + + workDir, err := findLocalBeadsDir() + if err != nil { + return fmt.Errorf("not in a beads workspace: %w", err) + } + + b := beads.New(workDir) + mol, err := b.Show(molID) + if err != nil { + return fmt.Errorf("getting molecule: %w", err) + } + + // Validate the molecule + validationErr := beads.ValidateMolecule(mol) + + // Parse steps regardless of validation + steps, parseErr := beads.ParseMoleculeSteps(mol.Description) + + if moleculeJSON { + type parseOutput struct { + Valid bool `json:"valid"` + ValidationError string `json:"validation_error,omitempty"` + ParseError string `json:"parse_error,omitempty"` + Steps []beads.MoleculeStep `json:"steps"` + } + out := parseOutput{ + Valid: validationErr == nil, + Steps: steps, + } + if validationErr != nil { + out.ValidationError = validationErr.Error() + } + if parseErr != nil { + out.ParseError = parseErr.Error() + } + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(out) + } + + // Human-readable output + fmt.Printf("\n%s: %s\n\n", style.Bold.Render(mol.ID), mol.Title) + + if validationErr != nil { + fmt.Printf("%s Validation failed: %s\n\n", style.Bold.Render("✗"), validationErr) + } else { + fmt.Printf("%s Valid molecule\n\n", style.Bold.Render("✓")) + } + + if parseErr != nil { + fmt.Printf("Parse error: %s\n\n", parseErr) + } + + fmt.Printf("Parsed Steps (%d):\n", len(steps)) + for i, step := range steps { + fmt.Printf("\n [%d] %s\n", i+1, style.Bold.Render(step.Ref)) + if step.Title != step.Ref { + fmt.Printf(" Title: %s\n", step.Title) + } + if len(step.Needs) > 0 { + fmt.Printf(" Needs: %s\n", strings.Join(step.Needs, ", ")) + } + if step.Tier != "" { + fmt.Printf(" Tier: %s\n", step.Tier) + } + if step.Instructions != "" { + // Show first line of instructions + firstLine := strings.SplitN(step.Instructions, "\n", 2)[0] + if len(firstLine) > 60 { + firstLine = firstLine[:57] + "..." + } + fmt.Printf(" Instructions: %s\n", style.Dim.Render(firstLine)) + } + } + + return nil +} + +func runMoleculeInstances(cmd *cobra.Command, args []string) error { + molID := args[0] + + workDir, err := findLocalBeadsDir() + if err != nil { + return fmt.Errorf("not in a beads workspace: %w", err) + } + + b := beads.New(workDir) + + // Verify the molecule exists + mol, err := b.Show(molID) + if err != nil { + return fmt.Errorf("getting molecule: %w", err) + } + + if mol.Type != "molecule" { + return fmt.Errorf("%s is not a molecule (type: %s)", molID, mol.Type) + } + + // Find all instances + instances, err := findMoleculeInstances(b, molID) + if err != nil { + return fmt.Errorf("finding instances: %w", err) + } + + if moleculeJSON { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(instances) + } + + // Human-readable output + fmt.Printf("\n%s Instances of %s (%d)\n\n", + style.Bold.Render("📋"), molID, len(instances)) + + if len(instances) == 0 { + fmt.Printf(" %s\n", style.Dim.Render("(no instantiations found)")) + return nil + } + + fmt.Printf("%-16s %-12s %s\n", + style.Bold.Render("Parent"), + style.Bold.Render("Status"), + style.Bold.Render("Created")) + fmt.Println(strings.Repeat("-", 50)) + + for _, inst := range instances { + // Calculate progress from children + progress := "" + if len(inst.Children) > 0 { + closed := 0 + for _, childID := range inst.Children { + child, err := b.Show(childID) + if err == nil && child.Status == "closed" { + closed++ + } + } + progress = fmt.Sprintf(" (%d/%d complete)", closed, len(inst.Children)) + } + + statusStr := inst.Status + if inst.Status == "closed" { + statusStr = style.Dim.Render("done") + } else if inst.Status == "in_progress" { + statusStr = "active" + } + + created := "" + if inst.CreatedAt != "" { + // Parse and format date + created = inst.CreatedAt[:10] // Just the date portion + } + + fmt.Printf("%-16s %-12s %s%s\n", inst.ID, statusStr, created, progress) + } + + return nil +} + +// findMoleculeInstances finds all parent issues that have steps instantiated from the given molecule. +func findMoleculeInstances(b *beads.Beads, molID string) ([]*beads.Issue, error) { + // Get all issues and look for ones with children that have instantiated_from metadata + // This is a brute-force approach - could be optimized with better queries + + // Strategy: search for issues whose descriptions contain "instantiated_from: " + allIssues, err := b.List(beads.ListOptions{Status: "all", Priority: -1}) + if err != nil { + return nil, err + } + + // Find issues that reference this molecule + parentIDs := make(map[string]bool) + for _, issue := range allIssues { + if strings.Contains(issue.Description, fmt.Sprintf("instantiated_from: %s", molID)) { + // This is a step - find its parent + if issue.Parent != "" { + parentIDs[issue.Parent] = true + } + } + } + + // Fetch the parent issues + var parents []*beads.Issue + for parentID := range parentIDs { + parent, err := b.Show(parentID) + if err == nil { + parents = append(parents, parent) + } + } + + return parents, nil +} diff --git a/internal/cmd/molecule_status.go b/internal/cmd/molecule_status.go new file mode 100644 index 00000000..1d8e3a2f --- /dev/null +++ b/internal/cmd/molecule_status.go @@ -0,0 +1,673 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + "github.com/steveyegge/gastown/internal/beads" + "github.com/steveyegge/gastown/internal/style" + "github.com/steveyegge/gastown/internal/workspace" +) + +// 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 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"` +} + +// 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 { + // 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 +} + +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 { + // 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) + + // 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 +}