package cmd import ( "bytes" "encoding/json" "fmt" "os" "os/exec" "path/filepath" "strings" "github.com/spf13/cobra" "github.com/steveyegge/gastown/internal/formula" "github.com/steveyegge/gastown/internal/runtime" "github.com/steveyegge/gastown/internal/style" "github.com/steveyegge/gastown/internal/workspace" ) // Synthesis command flags var ( synthesisRig string synthesisDryRun bool synthesisForce bool synthesisReviewID string ) var synthesisCmd = &cobra.Command{ Use: "synthesis", Aliases: []string{"synth"}, GroupID: GroupWork, Short: "Manage convoy synthesis steps", RunE: requireSubcommand, Long: `Manage synthesis steps for convoy formulas. Synthesis is the final step in a convoy workflow that combines outputs from all parallel legs into a unified deliverable. Commands: start Start synthesis for a convoy (checks all legs complete) status Show synthesis readiness and leg outputs close Close convoy after synthesis complete Examples: gt synthesis status hq-cv-abc # Check if ready for synthesis gt synthesis start hq-cv-abc # Start synthesis step gt synthesis close hq-cv-abc # Close convoy after synthesis`, } var synthesisStartCmd = &cobra.Command{ Use: "start ", Short: "Start synthesis for a convoy", Long: `Start the synthesis step for a convoy. This command: 1. Verifies all legs are complete 2. Collects outputs from all legs 3. Creates a synthesis bead with combined context 4. Slings the synthesis to a polecat Options: --rig=NAME Target rig for synthesis polecat (default: current) --review-id=ID Override review ID for output paths --force Start synthesis even if some legs incomplete --dry-run Show what would happen without executing`, Args: cobra.ExactArgs(1), RunE: runSynthesisStart, } var synthesisStatusCmd = &cobra.Command{ Use: "status ", Short: "Show synthesis readiness", Long: `Show whether a convoy is ready for synthesis. Displays: - Convoy metadata - Leg completion status - Available leg outputs - Formula synthesis configuration`, Args: cobra.ExactArgs(1), RunE: runSynthesisStatus, } var synthesisCloseCmd = &cobra.Command{ Use: "close ", Short: "Close convoy after synthesis", Long: `Close a convoy after synthesis is complete. This marks the convoy as complete and triggers any configured notifications.`, Args: cobra.ExactArgs(1), RunE: runSynthesisClose, } func init() { // Start flags synthesisStartCmd.Flags().StringVar(&synthesisRig, "rig", "", "Target rig for synthesis polecat") synthesisStartCmd.Flags().BoolVar(&synthesisDryRun, "dry-run", false, "Preview execution") synthesisStartCmd.Flags().BoolVar(&synthesisForce, "force", false, "Start even if legs incomplete") synthesisStartCmd.Flags().StringVar(&synthesisReviewID, "review-id", "", "Override review ID") // Add subcommands synthesisCmd.AddCommand(synthesisStartCmd) synthesisCmd.AddCommand(synthesisStatusCmd) synthesisCmd.AddCommand(synthesisCloseCmd) rootCmd.AddCommand(synthesisCmd) } // LegOutput represents collected output from a convoy leg. type LegOutput struct { LegID string `json:"leg_id"` Title string `json:"title"` Status string `json:"status"` FilePath string `json:"file_path,omitempty"` Content string `json:"content,omitempty"` HasFile bool `json:"has_file"` } // ConvoyMeta holds metadata about a convoy including its formula. type ConvoyMeta struct { ID string `json:"id"` Title string `json:"title"` Status string `json:"status"` Formula string `json:"formula,omitempty"` // Formula name FormulaPath string `json:"formula_path,omitempty"` // Path to formula file ReviewID string `json:"review_id,omitempty"` // Review ID for output paths LegIssues []string `json:"leg_issues,omitempty"` // Tracked leg issue IDs } // runSynthesisStart implements gt synthesis start. func runSynthesisStart(cmd *cobra.Command, args []string) error { convoyID := args[0] // Get convoy metadata meta, err := getConvoyMeta(convoyID) if err != nil { return fmt.Errorf("getting convoy metadata: %w", err) } fmt.Printf("%s Checking synthesis readiness for %s...\n", style.Bold.Render("🔬"), convoyID) // Load formula if specified var f *formula.Formula if meta.FormulaPath != "" { f, err = formula.ParseFile(meta.FormulaPath) if err != nil { return fmt.Errorf("loading formula: %w", err) } } else if meta.Formula != "" { // Try to find formula by name formulaPath, findErr := findFormula(meta.Formula) if findErr == nil { f, err = formula.ParseFile(formulaPath) if err != nil { return fmt.Errorf("loading formula: %w", err) } } } // Check leg completion status legOutputs, allComplete, err := collectLegOutputs(meta, f) if err != nil { return fmt.Errorf("collecting leg outputs: %w", err) } // Report status completedCount := 0 for _, leg := range legOutputs { if leg.Status == "closed" { completedCount++ } } fmt.Printf(" Legs: %d/%d complete\n", completedCount, len(legOutputs)) if !allComplete && !synthesisForce { fmt.Printf("\n%s Not all legs complete. Use --force to proceed anyway.\n", style.Warning.Render("⚠")) fmt.Printf("\nIncomplete legs:\n") for _, leg := range legOutputs { if leg.Status != "closed" { fmt.Printf(" ○ %s: %s [%s]\n", leg.LegID, leg.Title, leg.Status) } } return nil } // Determine review ID reviewID := synthesisReviewID if reviewID == "" { reviewID = meta.ReviewID } if reviewID == "" { // Extract from convoy ID reviewID = strings.TrimPrefix(convoyID, "hq-cv-") } // Determine target rig targetRig := synthesisRig if targetRig == "" { townRoot, err := workspace.FindFromCwdOrError() if err == nil { rigName, _, rigErr := findCurrentRig(townRoot) if rigErr == nil && rigName != "" { targetRig = rigName } } if targetRig == "" { targetRig = "gastown" } } if synthesisDryRun { fmt.Printf("\n%s Would start synthesis:\n", style.Dim.Render("[dry-run]")) fmt.Printf(" Convoy: %s\n", convoyID) fmt.Printf(" Review ID: %s\n", reviewID) fmt.Printf(" Target: %s\n", targetRig) fmt.Printf(" Legs: %d outputs collected\n", len(legOutputs)) if f != nil && f.Synthesis != nil { fmt.Printf(" Synthesis: %s\n", f.Synthesis.Title) } return nil } // Create synthesis bead synthesisID, err := createSynthesisBead(convoyID, meta, f, legOutputs, reviewID) if err != nil { return fmt.Errorf("creating synthesis bead: %w", err) } fmt.Printf("%s Created synthesis bead: %s\n", style.Bold.Render("✓"), synthesisID) // Sling to target rig fmt.Printf(" Slinging to %s...\n", targetRig) if err := slingSynthesis(synthesisID, targetRig); err != nil { return fmt.Errorf("slinging synthesis: %w", err) } fmt.Printf("%s Synthesis started\n", style.Bold.Render("✓")) fmt.Printf(" Monitor: gt convoy status %s\n", convoyID) return nil } // runSynthesisStatus implements gt synthesis status. func runSynthesisStatus(cmd *cobra.Command, args []string) error { convoyID := args[0] meta, err := getConvoyMeta(convoyID) if err != nil { return fmt.Errorf("getting convoy metadata: %w", err) } // Load formula if available var f *formula.Formula if meta.FormulaPath != "" { f, _ = formula.ParseFile(meta.FormulaPath) } else if meta.Formula != "" { if path, err := findFormula(meta.Formula); err == nil { f, _ = formula.ParseFile(path) } } // Collect leg outputs legOutputs, allComplete, err := collectLegOutputs(meta, f) if err != nil { return fmt.Errorf("collecting leg outputs: %w", err) } // Display status fmt.Printf("🚚 %s %s\n\n", style.Bold.Render(convoyID+":"), meta.Title) fmt.Printf(" Status: %s\n", formatConvoyStatus(meta.Status)) if meta.Formula != "" { fmt.Printf(" Formula: %s\n", meta.Formula) } fmt.Printf("\n %s\n", style.Bold.Render("Legs:")) for _, leg := range legOutputs { status := "○" if leg.Status == "closed" { status = "✓" } fileStatus := "" if leg.HasFile { fileStatus = style.Dim.Render(" (output: ✓)") } fmt.Printf(" %s %s: %s [%s]%s\n", status, leg.LegID, leg.Title, leg.Status, fileStatus) } // Synthesis readiness fmt.Printf("\n %s\n", style.Bold.Render("Synthesis:")) if allComplete { fmt.Printf(" %s Ready - all legs complete\n", style.Success.Render("✓")) fmt.Printf(" Run: gt synthesis start %s\n", convoyID) } else { completedCount := 0 for _, leg := range legOutputs { if leg.Status == "closed" { completedCount++ } } fmt.Printf(" %s Waiting - %d/%d legs complete\n", style.Warning.Render("○"), completedCount, len(legOutputs)) } if f != nil && f.Synthesis != nil { fmt.Printf("\n %s\n", style.Bold.Render("Synthesis Config:")) fmt.Printf(" Title: %s\n", f.Synthesis.Title) if f.Output != nil && f.Output.Synthesis != "" { fmt.Printf(" Output: %s\n", f.Output.Synthesis) } } return nil } // runSynthesisClose implements gt synthesis close. func runSynthesisClose(cmd *cobra.Command, args []string) error { convoyID := args[0] townBeads, err := getTownBeadsDir() if err != nil { return err } // Close the convoy closeArgs := []string{"close", convoyID, "--reason=synthesis complete"} if sessionID := runtime.SessionIDFromEnv(); sessionID != "" { closeArgs = append(closeArgs, "--session="+sessionID) } closeCmd := exec.Command("bd", closeArgs...) closeCmd.Dir = townBeads closeCmd.Stderr = os.Stderr if err := closeCmd.Run(); err != nil { return fmt.Errorf("closing convoy: %w", err) } fmt.Printf("%s Convoy closed: %s\n", style.Bold.Render("✓"), convoyID) // TODO: Trigger notification if configured // Parse description for "Notify:
" and send mail return nil } // getConvoyMeta retrieves convoy metadata from beads. func getConvoyMeta(convoyID string) (*ConvoyMeta, error) { townBeads, err := getTownBeadsDir() if err != nil { return nil, err } showCmd := exec.Command("bd", "show", convoyID, "--json") showCmd.Dir = townBeads var stdout bytes.Buffer showCmd.Stdout = &stdout if err := showCmd.Run(); err != nil { return nil, fmt.Errorf("convoy '%s' not found", convoyID) } var convoys []struct { ID string `json:"id"` Title string `json:"title"` Status string `json:"status"` Description string `json:"description"` Type string `json:"issue_type"` } if err := json.Unmarshal(stdout.Bytes(), &convoys); err != nil { return nil, fmt.Errorf("parsing convoy data: %w", err) } if len(convoys) == 0 || convoys[0].Type != "convoy" { return nil, fmt.Errorf("'%s' is not a convoy", convoyID) } convoy := convoys[0] // Parse formula and review ID from description meta := &ConvoyMeta{ ID: convoy.ID, Title: convoy.Title, Status: convoy.Status, } // Look for structured fields in description for _, line := range strings.Split(convoy.Description, "\n") { line = strings.TrimSpace(line) if colonIdx := strings.Index(line, ":"); colonIdx != -1 { key := strings.ToLower(strings.TrimSpace(line[:colonIdx])) value := strings.TrimSpace(line[colonIdx+1:]) switch key { case "formula": meta.Formula = value case "formula_path", "formula-path": meta.FormulaPath = value case "review_id", "review-id": meta.ReviewID = value } } } // Get tracked leg issues tracked := getTrackedIssues(townBeads, convoyID) for _, t := range tracked { meta.LegIssues = append(meta.LegIssues, t.ID) } return meta, nil } // collectLegOutputs gathers outputs from all convoy legs. func collectLegOutputs(meta *ConvoyMeta, f *formula.Formula) ([]LegOutput, bool, error) { //nolint:unparam // error return kept for future use var outputs []LegOutput allComplete := true // If we have tracked issues, use those as legs if len(meta.LegIssues) > 0 { for _, issueID := range meta.LegIssues { details := getIssueDetails(issueID) output := LegOutput{ LegID: issueID, Title: "(unknown)", } if details != nil { output.Title = details.Title output.Status = details.Status } if output.Status != "closed" { allComplete = false } outputs = append(outputs, output) } } // If we have a formula, also try to find output files if f != nil && f.Output != nil && meta.ReviewID != "" { for _, leg := range f.Legs { // Expand output path template outputPath := expandOutputPath(f.Output.Directory, f.Output.LegPattern, meta.ReviewID, leg.ID) // Check if file exists and read content if content, err := os.ReadFile(outputPath); err == nil { // Find or create leg output entry found := false for i := range outputs { if outputs[i].LegID == leg.ID { outputs[i].FilePath = outputPath outputs[i].Content = string(content) outputs[i].HasFile = true found = true break } } if !found { outputs = append(outputs, LegOutput{ LegID: leg.ID, Title: leg.Title, Status: "closed", // If file exists, assume complete FilePath: outputPath, Content: string(content), HasFile: true, }) } } } } return outputs, allComplete, nil } // expandOutputPath expands template variables in output paths. // Supports: {{review_id}}, {{leg.id}} func expandOutputPath(directory, pattern, reviewID, legID string) string { // Expand directory dir := strings.ReplaceAll(directory, "{{review_id}}", reviewID) // Expand pattern file := strings.ReplaceAll(pattern, "{{leg.id}}", legID) return filepath.Join(dir, file) } // createSynthesisBead creates a bead for the synthesis step. func createSynthesisBead(convoyID string, meta *ConvoyMeta, f *formula.Formula, legOutputs []LegOutput, reviewID string) (string, error) { // Build synthesis title title := "Synthesis: " + meta.Title if f != nil && f.Synthesis != nil && f.Synthesis.Title != "" { title = f.Synthesis.Title + ": " + meta.Title } // Build synthesis description with leg outputs var desc strings.Builder desc.WriteString(fmt.Sprintf("convoy: %s\n", convoyID)) desc.WriteString(fmt.Sprintf("review_id: %s\n", reviewID)) desc.WriteString("\n") // Add synthesis instructions from formula if f != nil && f.Synthesis != nil && f.Synthesis.Description != "" { desc.WriteString("## Instructions\n\n") desc.WriteString(f.Synthesis.Description) desc.WriteString("\n\n") } // Add collected leg outputs desc.WriteString("## Leg Outputs\n\n") for _, leg := range legOutputs { desc.WriteString(fmt.Sprintf("### %s: %s\n\n", leg.LegID, leg.Title)) if leg.Content != "" { desc.WriteString(leg.Content) desc.WriteString("\n\n") } else if leg.FilePath != "" { desc.WriteString(fmt.Sprintf("Output file: %s\n\n", leg.FilePath)) } else { desc.WriteString("(no output available)\n\n") } } // Add output path if configured if f != nil && f.Output != nil && f.Output.Synthesis != "" { outputPath := strings.ReplaceAll(f.Output.Directory, "{{review_id}}", reviewID) outputPath = filepath.Join(outputPath, f.Output.Synthesis) desc.WriteString(fmt.Sprintf("\n## Output\n\nWrite synthesis to: %s\n", outputPath)) } // Create the bead createArgs := []string{ "create", "--type=task", "--title=" + title, "--description=" + desc.String(), "--json", } townBeads, err := getTownBeadsDir() if err != nil { return "", err } createCmd := exec.Command("bd", createArgs...) createCmd.Dir = townBeads var stdout bytes.Buffer createCmd.Stdout = &stdout createCmd.Stderr = os.Stderr if err := createCmd.Run(); err != nil { return "", fmt.Errorf("creating synthesis bead: %w", err) } // Parse created bead ID var result struct { ID string `json:"id"` } if err := json.Unmarshal(stdout.Bytes(), &result); err != nil { // Try to extract ID from non-JSON output out := strings.TrimSpace(stdout.String()) if strings.HasPrefix(out, "hq-") || strings.HasPrefix(out, "gt-") { return out, nil } return "", fmt.Errorf("parsing created bead: %w", err) } // Add tracking relation: convoy tracks synthesis depArgs := []string{"dep", "add", convoyID, result.ID, "--type=tracks"} depCmd := exec.Command("bd", depArgs...) depCmd.Dir = townBeads _ = depCmd.Run() // Non-fatal if this fails return result.ID, nil } // slingSynthesis slings the synthesis bead to a rig. func slingSynthesis(beadID, targetRig string) error { slingArgs := []string{"sling", beadID, targetRig} slingCmd := exec.Command("gt", slingArgs...) slingCmd.Stdout = os.Stdout slingCmd.Stderr = os.Stderr return slingCmd.Run() } // findFormula searches for a formula file by name. func findFormula(name string) (string, error) { // Search paths searchPaths := []string{ ".beads/formulas", } // Add home directory formulas if home, err := os.UserHomeDir(); err == nil { searchPaths = append(searchPaths, filepath.Join(home, ".beads", "formulas")) } // Add GT_ROOT formulas if set if gtRoot := os.Getenv("GT_ROOT"); gtRoot != "" { searchPaths = append(searchPaths, filepath.Join(gtRoot, ".beads", "formulas")) } // Try each search path for _, searchPath := range searchPaths { // Try with .formula.toml extension path := filepath.Join(searchPath, name+".formula.toml") if _, err := os.Stat(path); err == nil { return path, nil } // Try with .formula.json extension path = filepath.Join(searchPath, name+".formula.json") if _, err := os.Stat(path); err == nil { return path, nil } } return "", fmt.Errorf("formula '%s' not found", name) } // CheckSynthesisReady checks if a convoy is ready for synthesis. // Returns true if all tracked legs are complete. func CheckSynthesisReady(convoyID string) (bool, error) { meta, err := getConvoyMeta(convoyID) if err != nil { return false, err } _, allComplete, err := collectLegOutputs(meta, nil) return allComplete, err } // TriggerSynthesisIfReady checks convoy status and starts synthesis if ready. // This can be called by the witness when a leg completes. func TriggerSynthesisIfReady(convoyID, targetRig string) error { ready, err := CheckSynthesisReady(convoyID) if err != nil { return err } if !ready { return nil // Not ready yet } // Synthesis is ready - start it fmt.Printf("%s All legs complete, starting synthesis...\n", style.Bold.Render("🔬")) meta, err := getConvoyMeta(convoyID) if err != nil { return err } // Load formula if available var f *formula.Formula if meta.FormulaPath != "" { f, _ = formula.ParseFile(meta.FormulaPath) } else if meta.Formula != "" { if path, err := findFormula(meta.Formula); err == nil { f, _ = formula.ParseFile(path) } } legOutputs, _, _ := collectLegOutputs(meta, f) reviewID := meta.ReviewID if reviewID == "" { reviewID = strings.TrimPrefix(convoyID, "hq-cv-") } synthesisID, err := createSynthesisBead(convoyID, meta, f, legOutputs, reviewID) if err != nil { return fmt.Errorf("creating synthesis bead: %w", err) } if err := slingSynthesis(synthesisID, targetRig); err != nil { return fmt.Errorf("slinging synthesis: %w", err) } return nil }