Files
gastown/internal/cmd/synthesis.go
Ben Kraus 38adfa4d8b codex
2026-01-08 12:36:54 -05:00

678 lines
19 KiB
Go

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 <convoy-id>",
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 <convoy-id>",
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 <convoy-id>",
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: <address>" 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
}