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 ) var moleculeCmd = &cobra.Command{ Use: "molecule", Aliases: []string{"mol"}, Short: "Molecule workflow commands", Long: `Manage molecule workflow templates. Molecules are composable workflow patterns stored as beads issues. When instantiated on a parent issue, they create child beads forming a DAG. LIFECYCLE: Proto (template) │ ▼ instantiate/bond ┌─────────────────┐ │ Mol (durable) │ ← tracked in .beads/ │ Wisp (ephemeral)│ ← tracked in .beads-wisp/ └────────┬────────┘ │ ┌──────┴──────┐ ▼ ▼ burn squash (no record) (→ digest) PHASE TRANSITIONS (for pluggable molecules): ┌─────────────┬─────────────┬─────────────┬─────────────────────┐ │ Phase │ Parallelism │ Blocks │ Purpose │ ├─────────────┼─────────────┼─────────────┼─────────────────────┤ │ discovery │ full │ (nothing) │ Inventory, gather │ │ structural │ sequential │ discovery │ Big-picture review │ │ tactical │ parallel │ structural │ Detailed work │ │ synthesis │ single │ tactical │ Aggregate results │ └─────────────┴─────────────┴─────────────┴─────────────────────┘ COMMANDS: catalog List available molecule protos instantiate Create steps from a molecule template progress Show execution progress of an instantiated molecule status Show what's on an agent's hook burn Discard molecule without creating a digest squash Complete molecule and create a digest`, } var moleculeListCmd = &cobra.Command{ Use: "list", Short: "List molecules", Long: `List all molecule definitions. By default, lists molecules from all sources: - Built-in molecules (shipped with gt) - Town-level: /.beads/molecules.jsonl - Rig-level: /.beads/molecules.jsonl - Project-level: .beads/molecules.jsonl - Database: molecules stored as issues Use --catalog to show only template molecules (not instantiated). Use --db to show only database molecules.`, RunE: runMoleculeList, } var moleculeExportCmd = &cobra.Command{ Use: "export ", Short: "Export built-in molecules to JSONL", Long: `Export built-in molecule templates to a JSONL file. This creates a molecules.jsonl file containing all built-in molecules. You can place this in: - /.beads/molecules.jsonl (town-level) - /.beads/molecules.jsonl (rig-level) - .beads/molecules.jsonl (project-level) The file can be edited to customize or add new molecules.`, Args: cobra.ExactArgs(1), RunE: runMoleculeExport, } var moleculeShowCmd = &cobra.Command{ Use: "show ", Short: "Show molecule with parsed steps", Long: `Show a molecule definition with its parsed steps. Displays the molecule's title, description structure, and all defined steps with their dependencies.`, Args: cobra.ExactArgs(1), RunE: runMoleculeShow, } var moleculeParseCmd = &cobra.Command{ Use: "parse ", Short: "Validate and show parsed structure", Long: `Parse and validate a molecule definition. This command parses the molecule's step definitions and reports any errors. Useful for debugging molecule definitions before instantiation.`, Args: cobra.ExactArgs(1), RunE: runMoleculeParse, } var moleculeInstantiateCmd = &cobra.Command{ Use: "instantiate ", Short: "Create steps from molecule template", Long: `Instantiate a molecule on a parent issue. Creates child issues for each step defined in the molecule, wiring up dependencies according to the Needs: declarations. Template variables ({{variable}}) can be substituted using --context flags. Examples: gt molecule instantiate mol-xyz --parent=gt-abc gt molecule instantiate mol-xyz --parent=gt-abc --context feature=auth --context file=login.go`, Args: cobra.ExactArgs(1), RunE: runMoleculeInstantiate, } var moleculeInstancesCmd = &cobra.Command{ Use: "instances ", Short: "Show all instantiations of a molecule", Long: `Show all parent issues that have instantiated this molecule. Lists each instantiation with its status and progress.`, Args: cobra.ExactArgs(1), RunE: runMoleculeInstances, } var moleculeProgressCmd = &cobra.Command{ Use: "progress ", Short: "Show progress through a molecule's steps", Long: `Show the execution progress of an instantiated molecule. Given a root issue (the parent of molecule steps), displays: - Total steps and completion status - Which steps are done, in-progress, ready, or blocked - Overall progress percentage This is useful for the Witness to monitor molecule execution. Example: gt molecule progress gt-abc`, Args: cobra.ExactArgs(1), RunE: runMoleculeProgress, } var moleculeAttachCmd = &cobra.Command{ Use: "attach ", Short: "Attach a molecule to a pinned bead", Long: `Attach a molecule to a pinned/handoff bead. This records which molecule an agent is currently working on. The attachment is stored in the pinned bead's description and visible via 'bd show'. Example: gt molecule attach gt-abc mol-xyz`, Args: cobra.ExactArgs(2), RunE: runMoleculeAttach, } var moleculeDetachCmd = &cobra.Command{ Use: "detach ", Short: "Detach molecule from a pinned bead", Long: `Remove molecule attachment from a pinned/handoff bead. This clears the attached_molecule and attached_at fields from the bead. Example: gt molecule detach gt-abc`, Args: cobra.ExactArgs(1), RunE: runMoleculeDetach, } var moleculeAttachmentCmd = &cobra.Command{ Use: "attachment ", Short: "Show attachment status of a pinned bead", Long: `Show which molecule is attached to a pinned bead. Example: gt molecule attachment gt-abc`, Args: cobra.ExactArgs(1), 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, } var moleculeCatalogCmd = &cobra.Command{ Use: "catalog", Short: "List available molecule protos", Long: `List molecule protos available for slinging. This is a convenience alias for 'gt mol list --catalog' that shows only reusable templates, not instantiated molecules. Protos come from: - Built-in molecules (shipped with gt) - Town-level: /.beads/molecules.jsonl - Rig-level: /.beads/molecules.jsonl - Project-level: .beads/molecules.jsonl`, RunE: runMoleculeCatalog, } var moleculeBurnCmd = &cobra.Command{ Use: "burn [target]", Short: "Burn current molecule without creating a digest", Long: `Burn (destroy) the current molecule attachment. This discards the molecule without creating a permanent record. Use this when abandoning work or when a molecule doesn't need an audit trail. If no target is specified, burns the current agent's attached molecule. For wisps, burning is the default completion action. For regular molecules, consider using 'squash' instead to preserve an audit trail.`, Args: cobra.MaximumNArgs(1), RunE: runMoleculeBurn, } var moleculeSquashCmd = &cobra.Command{ Use: "squash [target]", Short: "Compress molecule into a digest", Long: `Squash the current molecule into a permanent digest. This condenses a completed molecule's execution into a compact record. The digest preserves: - What molecule was executed - When it ran - Summary of results Use this for patrol cycles and other operational work that should have a permanent (but compact) record.`, Args: cobra.MaximumNArgs(1), RunE: runMoleculeSquash, } func init() { // List flags moleculeListCmd.Flags().BoolVar(&moleculeJSON, "json", false, "Output as JSON") moleculeListCmd.Flags().BoolVar(&moleculeCatalogOnly, "catalog", false, "Show only catalog templates") moleculeListCmd.Flags().BoolVar(&moleculeDBOnly, "db", false, "Show only database molecules") // Show flags moleculeShowCmd.Flags().BoolVar(&moleculeJSON, "json", false, "Output as JSON") // Parse flags moleculeParseCmd.Flags().BoolVar(&moleculeJSON, "json", false, "Output as JSON") // Instantiate flags moleculeInstantiateCmd.Flags().StringVar(&moleculeInstParent, "parent", "", "Parent issue ID (required)") moleculeInstantiateCmd.Flags().StringArrayVar(&moleculeInstContext, "context", nil, "Context variable (key=value)") moleculeInstantiateCmd.MarkFlagRequired("parent") // Instances flags moleculeInstancesCmd.Flags().BoolVar(&moleculeJSON, "json", false, "Output as JSON") // Progress flags moleculeProgressCmd.Flags().BoolVar(&moleculeJSON, "json", false, "Output as JSON") // Attachment flags moleculeAttachmentCmd.Flags().BoolVar(&moleculeJSON, "json", false, "Output as JSON") // Status flags moleculeStatusCmd.Flags().BoolVar(&moleculeJSON, "json", false, "Output as JSON") // Catalog flags moleculeCatalogCmd.Flags().BoolVar(&moleculeJSON, "json", false, "Output as JSON") // Burn flags moleculeBurnCmd.Flags().BoolVar(&moleculeJSON, "json", false, "Output as JSON") // Squash flags moleculeSquashCmd.Flags().BoolVar(&moleculeJSON, "json", false, "Output as JSON") // Add subcommands moleculeCmd.AddCommand(moleculeStatusCmd) moleculeCmd.AddCommand(moleculeCatalogCmd) moleculeCmd.AddCommand(moleculeBurnCmd) moleculeCmd.AddCommand(moleculeSquashCmd) moleculeCmd.AddCommand(moleculeListCmd) moleculeCmd.AddCommand(moleculeShowCmd) moleculeCmd.AddCommand(moleculeParseCmd) moleculeCmd.AddCommand(moleculeInstantiateCmd) moleculeCmd.AddCommand(moleculeInstancesCmd) moleculeCmd.AddCommand(moleculeExportCmd) moleculeCmd.AddCommand(moleculeProgressCmd) moleculeCmd.AddCommand(moleculeAttachCmd) moleculeCmd.AddCommand(moleculeDetachCmd) moleculeCmd.AddCommand(moleculeAttachmentCmd) 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 // Try to find town root townRoot, _ = workspace.FindFromCwd() // Try to find rig path if townRoot != "" { rigName, _, err := findCurrentRig(townRoot) if err == nil && rigName != "" { rigPath = filepath.Join(townRoot, rigName) } } // Project path is the work directory projectPath = workDir 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 } // 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 }