feat(synthesis): Add synthesis step for convoy formulas (gt-v5s0j)
Implements the synthesis step that combines leg outputs into a final deliverable for convoy workflows: - `gt synthesis start <convoy-id>` - Start synthesis after verifying all legs are complete, collecting outputs, and slinging to polecat - `gt synthesis status <convoy-id>` - Show synthesis readiness and leg completion status - `gt synthesis close <convoy-id>` - Close convoy after synthesis Key features: - Collects leg outputs from formula-defined paths (e.g., findings.md) - Creates synthesis bead with combined context from all leg outputs - Integrates with formula.toml synthesis configuration - Provides TriggerSynthesisIfReady() for automated synthesis trigger - Adds SynthesisFields struct for tracking synthesis state in beads 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
672
internal/cmd/synthesis.go
Normal file
672
internal/cmd/synthesis.go
Normal file
@@ -0,0 +1,672 @@
|
||||
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/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
|
||||
closeCmd := exec.Command("bd", "close", convoyID, "--reason=synthesis complete")
|
||||
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) {
|
||||
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
|
||||
}
|
||||
94
internal/cmd/synthesis_test.go
Normal file
94
internal/cmd/synthesis_test.go
Normal file
@@ -0,0 +1,94 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExpandOutputPath(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
directory string
|
||||
pattern string
|
||||
reviewID string
|
||||
legID string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "basic expansion",
|
||||
directory: ".reviews/{{review_id}}",
|
||||
pattern: "{{leg.id}}-findings.md",
|
||||
reviewID: "abc123",
|
||||
legID: "security",
|
||||
want: ".reviews/abc123/security-findings.md",
|
||||
},
|
||||
{
|
||||
name: "no templates",
|
||||
directory: ".output",
|
||||
pattern: "results.md",
|
||||
reviewID: "xyz",
|
||||
legID: "test",
|
||||
want: ".output/results.md",
|
||||
},
|
||||
{
|
||||
name: "complex path",
|
||||
directory: "reviews/{{review_id}}/findings",
|
||||
pattern: "leg-{{leg.id}}-analysis.md",
|
||||
reviewID: "pr-123",
|
||||
legID: "performance",
|
||||
want: "reviews/pr-123/findings/leg-performance-analysis.md",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := expandOutputPath(tt.directory, tt.pattern, tt.reviewID, tt.legID)
|
||||
if got != tt.want {
|
||||
t.Errorf("expandOutputPath() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegOutput(t *testing.T) {
|
||||
// Test LegOutput struct
|
||||
output := LegOutput{
|
||||
LegID: "correctness",
|
||||
Title: "Correctness Review",
|
||||
Status: "closed",
|
||||
FilePath: "/tmp/findings.md",
|
||||
Content: "## Findings\n\nNo issues found.",
|
||||
HasFile: true,
|
||||
}
|
||||
|
||||
if output.LegID != "correctness" {
|
||||
t.Errorf("LegID = %q, want %q", output.LegID, "correctness")
|
||||
}
|
||||
|
||||
if output.Status != "closed" {
|
||||
t.Errorf("Status = %q, want %q", output.Status, "closed")
|
||||
}
|
||||
|
||||
if !output.HasFile {
|
||||
t.Error("HasFile should be true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvoyMeta(t *testing.T) {
|
||||
// Test ConvoyMeta struct
|
||||
meta := ConvoyMeta{
|
||||
ID: "hq-cv-abc",
|
||||
Title: "Code Review: PR #123",
|
||||
Status: "open",
|
||||
Formula: "code-review",
|
||||
ReviewID: "pr123",
|
||||
LegIssues: []string{"gt-leg1", "gt-leg2", "gt-leg3"},
|
||||
}
|
||||
|
||||
if meta.ID != "hq-cv-abc" {
|
||||
t.Errorf("ID = %q, want %q", meta.ID, "hq-cv-abc")
|
||||
}
|
||||
|
||||
if len(meta.LegIssues) != 3 {
|
||||
t.Errorf("len(LegIssues) = %d, want 3", len(meta.LegIssues))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user