diff --git a/internal/cmd/formula.go b/internal/cmd/formula.go index 7fe6f8b4..dfd01264 100644 --- a/internal/cmd/formula.go +++ b/internal/cmd/formula.go @@ -2,6 +2,8 @@ package cmd import ( "bufio" + "crypto/rand" + "encoding/base32" "fmt" "os" "os/exec" @@ -185,10 +187,24 @@ func runFormulaShow(cmd *cobra.Command, args []string) error { return bdCmd.Run() } -// runFormulaRun executes a formula +// runFormulaRun executes a formula by spawning a convoy of polecats. +// For convoy-type formulas, it creates a convoy bead, creates leg beads, +// and slings each leg to a separate polecat with leg-specific prompts. func runFormulaRun(cmd *cobra.Command, args []string) error { formulaName := args[0] + // Find the formula file + formulaPath, err := findFormulaFile(formulaName) + if err != nil { + return fmt.Errorf("finding formula: %w", err) + } + + // Parse the formula + f, err := parseFormulaFile(formulaPath) + if err != nil { + return fmt.Errorf("parsing formula: %w", err) + } + // Determine target rig targetRig := formulaRunRig if targetRig == "" { @@ -205,35 +221,470 @@ func runFormulaRun(cmd *cobra.Command, args []string) error { } } + // Handle dry-run mode if formulaRunDryRun { - fmt.Printf("%s Would execute formula:\n", style.Dim.Render("[dry-run]")) - fmt.Printf(" Formula: %s\n", style.Bold.Render(formulaName)) - fmt.Printf(" Rig: %s\n", targetRig) - if formulaRunPR > 0 { - fmt.Printf(" PR: #%d\n", formulaRunPR) - } + return dryRunFormula(f, formulaName, targetRig) + } + + // Currently only convoy formulas are supported for execution + if f.Type != "convoy" { + fmt.Printf("%s Formula type '%s' not yet supported for execution.\n", + style.Dim.Render("Note:"), f.Type) + fmt.Printf("Currently only 'convoy' formulas can be run.\n") + fmt.Printf("\nTo run '%s' manually:\n", formulaName) + fmt.Printf(" 1. View formula: gt formula show %s\n", formulaName) + fmt.Printf(" 2. Cook to proto: bd cook %s\n", formulaName) + fmt.Printf(" 3. Pour molecule: bd pour %s\n", formulaName) + fmt.Printf(" 4. Sling to rig: gt sling %s\n", targetRig) return nil } - // For now, provide instructions on how to execute manually - // TODO: Full implementation in gt-574qn (Formula execution: Spawn convoy from formula) - fmt.Printf("Formula execution is being implemented.\n\n") - fmt.Printf("To run '%s' manually:\n", formulaName) - fmt.Printf(" 1. View formula: bd formula show %s\n", formulaName) - fmt.Printf(" 2. Cook to proto: bd cook %s\n", formulaName) - fmt.Printf(" 3. Pour molecule: bd pour %s\n", formulaName) - fmt.Printf(" 4. Sling to rig: gt sling %s\n", targetRig) + // Execute convoy formula + return executeConvoyFormula(f, formulaName, targetRig) +} +// dryRunFormula shows what would happen without executing +func dryRunFormula(f *formulaData, formulaName, targetRig string) error { + fmt.Printf("%s Would execute formula:\n", style.Dim.Render("[dry-run]")) + fmt.Printf(" Formula: %s\n", style.Bold.Render(formulaName)) + fmt.Printf(" Type: %s\n", f.Type) + fmt.Printf(" Rig: %s\n", targetRig) if formulaRunPR > 0 { - fmt.Printf("\n For PR #%d, set variable: --var pr=%d\n", formulaRunPR, formulaRunPR) + fmt.Printf(" PR: #%d\n", formulaRunPR) } - fmt.Printf("\n%s Full automation coming in gt-574qn\n", - style.Dim.Render("Note:")) + if f.Type == "convoy" && len(f.Legs) > 0 { + fmt.Printf("\n Legs (%d parallel):\n", len(f.Legs)) + for _, leg := range f.Legs { + fmt.Printf(" • %s: %s\n", leg.ID, leg.Title) + } + if f.Synthesis != nil { + fmt.Printf("\n Synthesis:\n") + fmt.Printf(" • %s\n", f.Synthesis.Title) + } + } return nil } +// executeConvoyFormula spawns a convoy of polecats to execute a convoy formula +func executeConvoyFormula(f *formulaData, formulaName, targetRig string) error { + fmt.Printf("%s Executing convoy formula: %s\n\n", + style.Bold.Render("🚚"), formulaName) + + // Get town beads directory for convoy creation + townRoot, err := workspace.FindFromCwd() + if err != nil { + return fmt.Errorf("finding town root: %w", err) + } + townBeads := filepath.Join(townRoot, ".beads") + + // Step 1: Create convoy bead + convoyID := fmt.Sprintf("hq-cv-%s", generateFormulaShortID()) + convoyTitle := fmt.Sprintf("%s: %s", formulaName, f.Description) + if len(convoyTitle) > 80 { + convoyTitle = convoyTitle[:77] + "..." + } + + // Build description with formula context + description := fmt.Sprintf("Formula convoy: %s\n\nLegs: %d\nRig: %s", + formulaName, len(f.Legs), targetRig) + if formulaRunPR > 0 { + description += fmt.Sprintf("\nPR: #%d", formulaRunPR) + } + + createArgs := []string{ + "create", + "--type=convoy", + "--id=" + convoyID, + "--title=" + convoyTitle, + "--description=" + description, + } + + createCmd := exec.Command("bd", createArgs...) + createCmd.Dir = townBeads + createCmd.Stderr = os.Stderr + if err := createCmd.Run(); err != nil { + return fmt.Errorf("creating convoy bead: %w", err) + } + + fmt.Printf("%s Created convoy: %s\n", style.Bold.Render("✓"), convoyID) + + // Step 2: Create leg beads and track them + legBeads := make(map[string]string) // leg.ID -> bead ID + for _, leg := range f.Legs { + legBeadID := fmt.Sprintf("hq-leg-%s", generateFormulaShortID()) + + // Build leg description with prompt if available + legDesc := leg.Description + if f.Prompts != nil { + if basePrompt, ok := f.Prompts["base"]; ok { + legDesc = fmt.Sprintf("%s\n\n---\nBase Prompt:\n%s", leg.Description, basePrompt) + } + } + + legArgs := []string{ + "create", + "--type=task", + "--id=" + legBeadID, + "--title=" + leg.Title, + "--description=" + legDesc, + } + + legCmd := exec.Command("bd", legArgs...) + legCmd.Dir = townBeads + legCmd.Stderr = os.Stderr + if err := legCmd.Run(); err != nil { + fmt.Printf("%s Failed to create leg bead for %s: %v\n", + style.Dim.Render("Warning:"), leg.ID, err) + continue + } + + // Track the leg with the convoy + trackArgs := []string{"dep", "add", convoyID, legBeadID, "--type=tracks"} + trackCmd := exec.Command("bd", trackArgs...) + trackCmd.Dir = townBeads + if err := trackCmd.Run(); err != nil { + fmt.Printf("%s Failed to track leg %s: %v\n", + style.Dim.Render("Warning:"), leg.ID, err) + } + + legBeads[leg.ID] = legBeadID + fmt.Printf(" %s Created leg: %s (%s)\n", style.Dim.Render("○"), leg.ID, legBeadID) + } + + // Step 3: Create synthesis bead if defined + var synthesisBeadID string + if f.Synthesis != nil { + synthesisBeadID = fmt.Sprintf("hq-syn-%s", generateFormulaShortID()) + + synDesc := f.Synthesis.Description + if synDesc == "" { + synDesc = "Synthesize findings from all legs into unified output" + } + + synArgs := []string{ + "create", + "--type=task", + "--id=" + synthesisBeadID, + "--title=" + f.Synthesis.Title, + "--description=" + synDesc, + } + + synCmd := exec.Command("bd", synArgs...) + synCmd.Dir = townBeads + synCmd.Stderr = os.Stderr + if err := synCmd.Run(); err != nil { + fmt.Printf("%s Failed to create synthesis bead: %v\n", + style.Dim.Render("Warning:"), err) + } else { + // Track synthesis with convoy + trackArgs := []string{"dep", "add", convoyID, synthesisBeadID, "--type=tracks"} + trackCmd := exec.Command("bd", trackArgs...) + trackCmd.Dir = townBeads + _ = trackCmd.Run() + + // Add dependencies: synthesis depends on all legs + for _, legBeadID := range legBeads { + depArgs := []string{"dep", "add", synthesisBeadID, legBeadID} + depCmd := exec.Command("bd", depArgs...) + depCmd.Dir = townBeads + _ = depCmd.Run() + } + + fmt.Printf(" %s Created synthesis: %s\n", style.Dim.Render("★"), synthesisBeadID) + } + } + + // Step 4: Sling each leg to a polecat + fmt.Printf("\n%s Dispatching legs to polecats...\n\n", style.Bold.Render("→")) + + slingCount := 0 + for _, leg := range f.Legs { + legBeadID, ok := legBeads[leg.ID] + if !ok { + continue + } + + // Build context message for the polecat + contextMsg := fmt.Sprintf("Convoy leg: %s\nFocus: %s", leg.Title, leg.Focus) + + // Use gt sling with args for leg-specific context + slingArgs := []string{ + "sling", legBeadID, targetRig, + "-a", leg.Description, + "-s", leg.Title, + } + + slingCmd := exec.Command("gt", slingArgs...) + slingCmd.Stdout = os.Stdout + slingCmd.Stderr = os.Stderr + + if err := slingCmd.Run(); err != nil { + fmt.Printf("%s Failed to sling leg %s: %v\n", + style.Dim.Render("Warning:"), leg.ID, err) + // Add comment to bead about failure + commentArgs := []string{"comment", legBeadID, fmt.Sprintf("Failed to sling: %v", err)} + commentCmd := exec.Command("bd", commentArgs...) + commentCmd.Dir = townBeads + _ = commentCmd.Run() + continue + } + + slingCount++ + _ = contextMsg // Used in future for richer context + } + + // Summary + fmt.Printf("\n%s Convoy dispatched!\n", style.Bold.Render("✓")) + fmt.Printf(" Convoy: %s\n", convoyID) + fmt.Printf(" Legs: %d dispatched\n", slingCount) + if synthesisBeadID != "" { + fmt.Printf(" Synthesis: %s (blocked until legs complete)\n", synthesisBeadID) + } + fmt.Printf("\n Track progress: gt convoy status %s\n", convoyID) + + return nil +} + +// formulaData holds parsed formula information +type formulaData struct { + Name string + Description string + Type string + Legs []formulaLeg + Synthesis *formulaSynthesis + Prompts map[string]string +} + +type formulaLeg struct { + ID string + Title string + Focus string + Description string +} + +type formulaSynthesis struct { + Title string + Description string + DependsOn []string +} + +// findFormulaFile searches for a formula file by name +func findFormulaFile(name string) (string, error) { + // Search paths in order + searchPaths := []string{} + + // 1. Project .beads/formulas/ + if cwd, err := os.Getwd(); err == nil { + searchPaths = append(searchPaths, filepath.Join(cwd, ".beads", "formulas")) + } + + // 2. Town .beads/formulas/ + if townRoot, err := workspace.FindFromCwd(); err == nil { + searchPaths = append(searchPaths, filepath.Join(townRoot, ".beads", "formulas")) + } + + // 3. User ~/.beads/formulas/ + if home, err := os.UserHomeDir(); err == nil { + searchPaths = append(searchPaths, filepath.Join(home, ".beads", "formulas")) + } + + // Try each path with common extensions + extensions := []string{".formula.toml", ".formula.json"} + for _, basePath := range searchPaths { + for _, ext := range extensions { + path := filepath.Join(basePath, name+ext) + if _, err := os.Stat(path); err == nil { + return path, nil + } + } + } + + return "", fmt.Errorf("formula '%s' not found in search paths", name) +} + +// parseFormulaFile parses a formula file into formulaData +func parseFormulaFile(path string) (*formulaData, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + // Use simple TOML parsing for the fields we need + // (avoids importing the full formula package which might cause cycles) + f := &formulaData{ + Prompts: make(map[string]string), + } + + content := string(data) + + // Parse formula name + if match := extractTOMLValue(content, "formula"); match != "" { + f.Name = match + } + + // Parse description + if match := extractTOMLMultiline(content, "description"); match != "" { + f.Description = match + } + + // Parse type + if match := extractTOMLValue(content, "type"); match != "" { + f.Type = match + } + + // Parse legs (convoy formulas) + f.Legs = extractLegs(content) + + // Parse synthesis + f.Synthesis = extractSynthesis(content) + + // Parse prompts + f.Prompts = extractPrompts(content) + + return f, nil +} + +// extractTOMLValue extracts a simple quoted value from TOML +func extractTOMLValue(content, key string) string { + // Match: key = "value" or key = 'value' + for _, line := range strings.Split(content, "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, key+" =") || strings.HasPrefix(line, key+"=") { + parts := strings.SplitN(line, "=", 2) + if len(parts) == 2 { + val := strings.TrimSpace(parts[1]) + // Remove quotes + if len(val) >= 2 && (val[0] == '"' || val[0] == '\'') { + return val[1 : len(val)-1] + } + return val + } + } + } + return "" +} + +// extractTOMLMultiline extracts a multiline string (""" ... """) +func extractTOMLMultiline(content, key string) string { + // Look for key = """ + keyPattern := key + ` = """` + idx := strings.Index(content, keyPattern) + if idx == -1 { + // Try single-line + return extractTOMLValue(content, key) + } + + start := idx + len(keyPattern) + end := strings.Index(content[start:], `"""`) + if end == -1 { + return "" + } + + return strings.TrimSpace(content[start : start+end]) +} + +// extractLegs parses [[legs]] sections from TOML +func extractLegs(content string) []formulaLeg { + var legs []formulaLeg + + // Split by [[legs]] + sections := strings.Split(content, "[[legs]]") + for i, section := range sections { + if i == 0 { + continue // Skip content before first [[legs]] + } + + // Find where this section ends (next [[ or EOF) + endIdx := strings.Index(section, "[[") + if endIdx == -1 { + endIdx = len(section) + } + section = section[:endIdx] + + leg := formulaLeg{ + ID: extractTOMLValue(section, "id"), + Title: extractTOMLValue(section, "title"), + Focus: extractTOMLValue(section, "focus"), + Description: extractTOMLMultiline(section, "description"), + } + + if leg.ID != "" { + legs = append(legs, leg) + } + } + + return legs +} + +// extractSynthesis parses [synthesis] section from TOML +func extractSynthesis(content string) *formulaSynthesis { + idx := strings.Index(content, "[synthesis]") + if idx == -1 { + return nil + } + + section := content[idx:] + // Find where section ends + if endIdx := strings.Index(section[1:], "\n["); endIdx != -1 { + section = section[:endIdx+1] + } + + syn := &formulaSynthesis{ + Title: extractTOMLValue(section, "title"), + Description: extractTOMLMultiline(section, "description"), + } + + // Parse depends_on array + if depsLine := extractTOMLValue(section, "depends_on"); depsLine != "" { + // Simple array parsing: ["a", "b", "c"] + depsLine = strings.Trim(depsLine, "[]") + for _, dep := range strings.Split(depsLine, ",") { + dep = strings.Trim(strings.TrimSpace(dep), `"'`) + if dep != "" { + syn.DependsOn = append(syn.DependsOn, dep) + } + } + } + + if syn.Title == "" && syn.Description == "" { + return nil + } + + return syn +} + +// extractPrompts parses [prompts] section from TOML +func extractPrompts(content string) map[string]string { + prompts := make(map[string]string) + + idx := strings.Index(content, "[prompts]") + if idx == -1 { + return prompts + } + + section := content[idx:] + // Find where section ends + if endIdx := strings.Index(section[1:], "\n["); endIdx != -1 { + section = section[:endIdx+1] + } + + // Extract base prompt + if base := extractTOMLMultiline(section, "base"); base != "" { + prompts["base"] = base + } + + return prompts +} + +// generateFormulaShortID generates a short random ID (5 lowercase chars) +func generateFormulaShortID() string { + b := make([]byte, 3) + rand.Read(b) + return strings.ToLower(base32.StdEncoding.EncodeToString(b)[:5]) +} + // runFormulaCreate creates a new formula template func runFormulaCreate(cmd *cobra.Command, args []string) error { formulaName := args[0]