When `gt formula run` fell back to the default "gastown" rig (because no rig could be detected), it didn't set rigPath, which meant the default formula lookup would fail. Now rigPath is properly constructed when we have townRoot but can't detect a current rig. Also adds tests for GetDefaultFormula helper. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
955 lines
25 KiB
Go
955 lines
25 KiB
Go
package cmd
|
|
|
|
import (
|
|
"bufio"
|
|
"crypto/rand"
|
|
"encoding/base32"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/spf13/cobra"
|
|
"github.com/steveyegge/gastown/internal/config"
|
|
"github.com/steveyegge/gastown/internal/style"
|
|
"github.com/steveyegge/gastown/internal/workspace"
|
|
"golang.org/x/text/cases"
|
|
"golang.org/x/text/language"
|
|
)
|
|
|
|
// Formula command flags
|
|
var (
|
|
formulaListJSON bool
|
|
formulaShowJSON bool
|
|
formulaRunPR int
|
|
formulaRunRig string
|
|
formulaRunDryRun bool
|
|
formulaCreateType string
|
|
)
|
|
|
|
var formulaCmd = &cobra.Command{
|
|
Use: "formula",
|
|
Aliases: []string{"formulas"},
|
|
GroupID: GroupWork,
|
|
Short: "Manage workflow formulas",
|
|
RunE: requireSubcommand,
|
|
Long: `Manage workflow formulas - reusable molecule templates.
|
|
|
|
Formulas are TOML/JSON files that define workflows with steps, variables,
|
|
and composition rules. They can be "poured" to create molecules or "wisped"
|
|
for ephemeral patrol cycles.
|
|
|
|
Commands:
|
|
list List available formulas from all search paths
|
|
show Display formula details (steps, variables, composition)
|
|
run Execute a formula (pour and dispatch)
|
|
create Create a new formula template
|
|
|
|
Search paths (in order):
|
|
1. .beads/formulas/ (project)
|
|
2. ~/.beads/formulas/ (user)
|
|
3. $GT_ROOT/.beads/formulas/ (orchestrator)
|
|
|
|
Examples:
|
|
gt formula list # List all formulas
|
|
gt formula show shiny # Show formula details
|
|
gt formula run shiny --pr=123 # Run formula on PR #123
|
|
gt formula create my-workflow # Create new formula template`,
|
|
}
|
|
|
|
var formulaListCmd = &cobra.Command{
|
|
Use: "list",
|
|
Short: "List available formulas",
|
|
Long: `List available formulas from all search paths.
|
|
|
|
Searches for formula files (.formula.toml, .formula.json) in:
|
|
1. .beads/formulas/ (project)
|
|
2. ~/.beads/formulas/ (user)
|
|
3. $GT_ROOT/.beads/formulas/ (orchestrator)
|
|
|
|
Examples:
|
|
gt formula list # List all formulas
|
|
gt formula list --json # JSON output`,
|
|
RunE: runFormulaList,
|
|
}
|
|
|
|
var formulaShowCmd = &cobra.Command{
|
|
Use: "show <name>",
|
|
Short: "Display formula details",
|
|
Long: `Display detailed information about a formula.
|
|
|
|
Shows:
|
|
- Formula metadata (name, type, description)
|
|
- Variables with defaults and constraints
|
|
- Steps with dependencies
|
|
- Composition rules (extends, aspects)
|
|
|
|
Examples:
|
|
gt formula show shiny
|
|
gt formula show rule-of-five --json`,
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: runFormulaShow,
|
|
}
|
|
|
|
var formulaRunCmd = &cobra.Command{
|
|
Use: "run [name]",
|
|
Short: "Execute a formula",
|
|
Long: `Execute a formula by pouring it and dispatching work.
|
|
|
|
This command:
|
|
1. Looks up the formula by name (or uses default from rig config)
|
|
2. Pours it to create a molecule (or uses existing proto)
|
|
3. Dispatches the molecule to available workers
|
|
|
|
For PR-based workflows, use --pr to specify the GitHub PR number.
|
|
|
|
If no formula name is provided, uses the default formula configured in
|
|
the rig's settings/config.json under workflow.default_formula.
|
|
|
|
Options:
|
|
--pr=N Run formula on GitHub PR #N
|
|
--rig=NAME Target specific rig (default: current or gastown)
|
|
--dry-run Show what would happen without executing
|
|
|
|
Examples:
|
|
gt formula run shiny # Run formula in current rig
|
|
gt formula run # Run default formula from rig config
|
|
gt formula run shiny --pr=123 # Run on PR #123
|
|
gt formula run security-audit --rig=beads # Run in specific rig
|
|
gt formula run release --dry-run # Preview execution`,
|
|
Args: cobra.MaximumNArgs(1),
|
|
RunE: runFormulaRun,
|
|
}
|
|
|
|
var formulaCreateCmd = &cobra.Command{
|
|
Use: "create <name>",
|
|
Short: "Create a new formula template",
|
|
Long: `Create a new formula template file.
|
|
|
|
Creates a starter formula file in .beads/formulas/ with the given name.
|
|
The template includes common sections that you can customize.
|
|
|
|
Formula types:
|
|
task Single-step task formula (default)
|
|
workflow Multi-step workflow with dependencies
|
|
patrol Repeating patrol cycle (for wisps)
|
|
|
|
Examples:
|
|
gt formula create my-task # Create task formula
|
|
gt formula create my-workflow --type=workflow
|
|
gt formula create nightly-check --type=patrol`,
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: runFormulaCreate,
|
|
}
|
|
|
|
func init() {
|
|
// List flags
|
|
formulaListCmd.Flags().BoolVar(&formulaListJSON, "json", false, "Output as JSON")
|
|
|
|
// Show flags
|
|
formulaShowCmd.Flags().BoolVar(&formulaShowJSON, "json", false, "Output as JSON")
|
|
|
|
// Run flags
|
|
formulaRunCmd.Flags().IntVar(&formulaRunPR, "pr", 0, "GitHub PR number to run formula on")
|
|
formulaRunCmd.Flags().StringVar(&formulaRunRig, "rig", "", "Target rig (default: current or gastown)")
|
|
formulaRunCmd.Flags().BoolVar(&formulaRunDryRun, "dry-run", false, "Preview execution without running")
|
|
|
|
// Create flags
|
|
formulaCreateCmd.Flags().StringVar(&formulaCreateType, "type", "task", "Formula type: task, workflow, or patrol")
|
|
|
|
// Add subcommands
|
|
formulaCmd.AddCommand(formulaListCmd)
|
|
formulaCmd.AddCommand(formulaShowCmd)
|
|
formulaCmd.AddCommand(formulaRunCmd)
|
|
formulaCmd.AddCommand(formulaCreateCmd)
|
|
|
|
rootCmd.AddCommand(formulaCmd)
|
|
}
|
|
|
|
// runFormulaList delegates to bd formula list
|
|
func runFormulaList(cmd *cobra.Command, args []string) error {
|
|
bdArgs := []string{"formula", "list"}
|
|
if formulaListJSON {
|
|
bdArgs = append(bdArgs, "--json")
|
|
}
|
|
|
|
bdCmd := exec.Command("bd", bdArgs...)
|
|
bdCmd.Stdout = os.Stdout
|
|
bdCmd.Stderr = os.Stderr
|
|
return bdCmd.Run()
|
|
}
|
|
|
|
// runFormulaShow delegates to bd formula show
|
|
func runFormulaShow(cmd *cobra.Command, args []string) error {
|
|
formulaName := args[0]
|
|
bdArgs := []string{"formula", "show", formulaName}
|
|
if formulaShowJSON {
|
|
bdArgs = append(bdArgs, "--json")
|
|
}
|
|
|
|
bdCmd := exec.Command("bd", bdArgs...)
|
|
bdCmd.Stdout = os.Stdout
|
|
bdCmd.Stderr = os.Stderr
|
|
return bdCmd.Run()
|
|
}
|
|
|
|
// 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 {
|
|
// Determine target rig first (needed for default formula lookup)
|
|
targetRig := formulaRunRig
|
|
var rigPath string
|
|
if targetRig == "" {
|
|
// Try to detect from current directory
|
|
townRoot, err := workspace.FindFromCwd()
|
|
if err == nil && townRoot != "" {
|
|
rigName, r, rigErr := findCurrentRig(townRoot)
|
|
if rigErr == nil && rigName != "" {
|
|
targetRig = rigName
|
|
if r != nil {
|
|
rigPath = r.Path
|
|
}
|
|
}
|
|
// If we still don't have a target rig but have townRoot, use gastown
|
|
if targetRig == "" {
|
|
targetRig = "gastown"
|
|
rigPath = filepath.Join(townRoot, "gastown")
|
|
}
|
|
} else {
|
|
// No town root found, fall back to gastown without rigPath
|
|
targetRig = "gastown"
|
|
}
|
|
} else {
|
|
// If rig specified, construct path
|
|
townRoot, err := workspace.FindFromCwd()
|
|
if err == nil && townRoot != "" {
|
|
rigPath = filepath.Join(townRoot, targetRig)
|
|
}
|
|
}
|
|
|
|
// Get formula name from args or default
|
|
var formulaName string
|
|
if len(args) > 0 {
|
|
formulaName = args[0]
|
|
} else {
|
|
// Try to get default formula from rig config
|
|
if rigPath != "" {
|
|
formulaName = config.GetDefaultFormula(rigPath)
|
|
}
|
|
if formulaName == "" {
|
|
return fmt.Errorf("no formula specified and no default formula configured\n\nTo set a default formula, add to your rig's settings/config.json:\n \"workflow\": {\n \"default_formula\": \"<formula-name>\"\n }")
|
|
}
|
|
fmt.Printf("%s Using default formula: %s\n", style.Dim.Render("Note:"), formulaName)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// Handle dry-run mode
|
|
if formulaRunDryRun {
|
|
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 <mol-id> %s\n", targetRig)
|
|
return nil
|
|
}
|
|
|
|
// 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(" PR: #%d\n", formulaRunPR)
|
|
}
|
|
|
|
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]
|
|
|
|
// Find or create formulas directory
|
|
formulasDir := ".beads/formulas"
|
|
|
|
// Check if we're in a beads-enabled directory
|
|
if _, err := os.Stat(".beads"); os.IsNotExist(err) {
|
|
// Try user formulas directory
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return fmt.Errorf("cannot find home directory: %w", err)
|
|
}
|
|
formulasDir = filepath.Join(home, ".beads", "formulas")
|
|
}
|
|
|
|
// Ensure directory exists
|
|
if err := os.MkdirAll(formulasDir, 0755); err != nil {
|
|
return fmt.Errorf("creating formulas directory: %w", err)
|
|
}
|
|
|
|
// Generate filename
|
|
filename := filepath.Join(formulasDir, formulaName+".formula.toml")
|
|
|
|
// Check if file already exists
|
|
if _, err := os.Stat(filename); err == nil {
|
|
return fmt.Errorf("formula already exists: %s", filename)
|
|
}
|
|
|
|
// Generate template based on type
|
|
var template string
|
|
switch formulaCreateType {
|
|
case "task":
|
|
template = generateTaskTemplate(formulaName)
|
|
case "workflow":
|
|
template = generateWorkflowTemplate(formulaName)
|
|
case "patrol":
|
|
template = generatePatrolTemplate(formulaName)
|
|
default:
|
|
return fmt.Errorf("unknown formula type: %s (use: task, workflow, or patrol)", formulaCreateType)
|
|
}
|
|
|
|
// Write the file
|
|
if err := os.WriteFile(filename, []byte(template), 0644); err != nil {
|
|
return fmt.Errorf("writing formula file: %w", err)
|
|
}
|
|
|
|
fmt.Printf("%s Created formula: %s\n", style.Bold.Render("✓"), filename)
|
|
fmt.Printf("\nNext steps:\n")
|
|
fmt.Printf(" 1. Edit the formula: %s\n", filename)
|
|
fmt.Printf(" 2. View it: gt formula show %s\n", formulaName)
|
|
fmt.Printf(" 3. Run it: gt formula run %s\n", formulaName)
|
|
|
|
return nil
|
|
}
|
|
|
|
func generateTaskTemplate(name string) string {
|
|
// Sanitize name for use in template
|
|
title := strings.ReplaceAll(name, "-", " ")
|
|
title = cases.Title(language.English).String(title)
|
|
|
|
return fmt.Sprintf(`# Formula: %s
|
|
# Type: task
|
|
# Created by: gt formula create
|
|
|
|
description = """%s task.
|
|
|
|
Add a detailed description here."""
|
|
formula = "%s"
|
|
version = 1
|
|
|
|
# Single step task
|
|
[[steps]]
|
|
id = "do-task"
|
|
title = "Execute task"
|
|
description = """
|
|
Perform the main task work.
|
|
|
|
**Steps:**
|
|
1. Understand the requirements
|
|
2. Implement the changes
|
|
3. Verify the work
|
|
"""
|
|
|
|
# Variables that can be passed when running the formula
|
|
# [vars]
|
|
# [vars.issue]
|
|
# description = "Issue ID to work on"
|
|
# required = true
|
|
#
|
|
# [vars.target]
|
|
# description = "Target branch"
|
|
# default = "main"
|
|
`, name, title, name)
|
|
}
|
|
|
|
func generateWorkflowTemplate(name string) string {
|
|
title := strings.ReplaceAll(name, "-", " ")
|
|
title = cases.Title(language.English).String(title)
|
|
|
|
return fmt.Sprintf(`# Formula: %s
|
|
# Type: workflow
|
|
# Created by: gt formula create
|
|
|
|
description = """%s workflow.
|
|
|
|
A multi-step workflow with dependencies between steps."""
|
|
formula = "%s"
|
|
version = 1
|
|
|
|
# Step 1: Setup
|
|
[[steps]]
|
|
id = "setup"
|
|
title = "Setup environment"
|
|
description = """
|
|
Prepare the environment for the workflow.
|
|
|
|
**Steps:**
|
|
1. Check prerequisites
|
|
2. Set up working environment
|
|
"""
|
|
|
|
# Step 2: Implementation (depends on setup)
|
|
[[steps]]
|
|
id = "implement"
|
|
title = "Implement changes"
|
|
needs = ["setup"]
|
|
description = """
|
|
Make the necessary code changes.
|
|
|
|
**Steps:**
|
|
1. Understand requirements
|
|
2. Write code
|
|
3. Test locally
|
|
"""
|
|
|
|
# Step 3: Test (depends on implementation)
|
|
[[steps]]
|
|
id = "test"
|
|
title = "Run tests"
|
|
needs = ["implement"]
|
|
description = """
|
|
Verify the changes work correctly.
|
|
|
|
**Steps:**
|
|
1. Run unit tests
|
|
2. Run integration tests
|
|
3. Check for regressions
|
|
"""
|
|
|
|
# Step 4: Complete (depends on tests)
|
|
[[steps]]
|
|
id = "complete"
|
|
title = "Complete workflow"
|
|
needs = ["test"]
|
|
description = """
|
|
Finalize and clean up.
|
|
|
|
**Steps:**
|
|
1. Commit final changes
|
|
2. Clean up temporary files
|
|
"""
|
|
|
|
# Variables
|
|
[vars]
|
|
[vars.issue]
|
|
description = "Issue ID to work on"
|
|
required = true
|
|
`, name, title, name)
|
|
}
|
|
|
|
func generatePatrolTemplate(name string) string {
|
|
title := strings.ReplaceAll(name, "-", " ")
|
|
title = cases.Title(language.English).String(title)
|
|
|
|
return fmt.Sprintf(`# Formula: %s
|
|
# Type: patrol
|
|
# Created by: gt formula create
|
|
#
|
|
# Patrol formulas are for repeating cycles (wisps).
|
|
# They run continuously and are NOT synced to git.
|
|
|
|
description = """%s patrol.
|
|
|
|
A patrol formula for periodic checks. Patrol formulas create wisps
|
|
(ephemeral molecules) that are NOT synced to git."""
|
|
formula = "%s"
|
|
version = 1
|
|
|
|
# The patrol step(s)
|
|
[[steps]]
|
|
id = "check"
|
|
title = "Run patrol check"
|
|
description = """
|
|
Perform the patrol inspection.
|
|
|
|
**Check for:**
|
|
1. Health indicators
|
|
2. Warning signs
|
|
3. Items needing attention
|
|
|
|
**On findings:**
|
|
- Log the issue
|
|
- Escalate if critical
|
|
"""
|
|
|
|
# Optional: remediation step
|
|
# [[steps]]
|
|
# id = "remediate"
|
|
# title = "Fix issues"
|
|
# needs = ["check"]
|
|
# description = """
|
|
# Fix any issues found during the check.
|
|
# """
|
|
|
|
# Variables (optional)
|
|
# [vars]
|
|
# [vars.verbose]
|
|
# description = "Enable verbose output"
|
|
# default = "false"
|
|
`, name, title, name)
|
|
}
|
|
|
|
// promptYesNo asks the user a yes/no question
|
|
func promptYesNo(question string) bool {
|
|
fmt.Printf("%s [y/N]: ", question)
|
|
reader := bufio.NewReader(os.Stdin)
|
|
answer, _ := reader.ReadString('\n')
|
|
answer = strings.TrimSpace(strings.ToLower(answer))
|
|
return answer == "y" || answer == "yes"
|
|
}
|