diff --git a/internal/beads/fields.go b/internal/beads/fields.go index 12009280..b574955f 100644 --- a/internal/beads/fields.go +++ b/internal/beads/fields.go @@ -344,6 +344,87 @@ func SetMRFields(issue *Issue, fields *MRFields) string { return formatted + "\n\n" + strings.Join(otherLines, "\n") } +// SynthesisFields holds structured fields for synthesis beads. +// These fields track the synthesis step in a convoy workflow. +type SynthesisFields struct { + ConvoyID string `json:"convoy_id"` // Parent convoy ID + ReviewID string `json:"review_id"` // Review ID for output paths + OutputPath string `json:"output_path"` // Path to synthesis output file + Formula string `json:"formula"` // Formula name (if from formula) +} + +// ParseSynthesisFields extracts synthesis fields from an issue's description. +// Fields are expected as "key: value" lines. Returns nil if no fields found. +func ParseSynthesisFields(issue *Issue) *SynthesisFields { + if issue == nil || issue.Description == "" { + return nil + } + + fields := &SynthesisFields{} + hasFields := false + + for _, line := range strings.Split(issue.Description, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + + colonIdx := strings.Index(line, ":") + if colonIdx == -1 { + continue + } + + key := strings.TrimSpace(line[:colonIdx]) + value := strings.TrimSpace(line[colonIdx+1:]) + if value == "" { + continue + } + + switch strings.ToLower(key) { + case "convoy", "convoy_id", "convoy-id": + fields.ConvoyID = value + hasFields = true + case "review_id", "review-id", "reviewid": + fields.ReviewID = value + hasFields = true + case "output_path", "output-path", "outputpath": + fields.OutputPath = value + hasFields = true + case "formula": + fields.Formula = value + hasFields = true + } + } + + if !hasFields { + return nil + } + return fields +} + +// FormatSynthesisFields formats SynthesisFields as a string for issue description. +func FormatSynthesisFields(fields *SynthesisFields) string { + if fields == nil { + return "" + } + + var lines []string + if fields.ConvoyID != "" { + lines = append(lines, "convoy: "+fields.ConvoyID) + } + if fields.ReviewID != "" { + lines = append(lines, "review_id: "+fields.ReviewID) + } + if fields.OutputPath != "" { + lines = append(lines, "output_path: "+fields.OutputPath) + } + if fields.Formula != "" { + lines = append(lines, "formula: "+fields.Formula) + } + + return strings.Join(lines, "\n") +} + // RoleConfig holds structured lifecycle configuration for role beads. // These fields are stored as "key: value" lines in the role bead description. // This enables agents to self-register their lifecycle configuration, diff --git a/internal/cmd/synthesis.go b/internal/cmd/synthesis.go new file mode 100644 index 00000000..655a0624 --- /dev/null +++ b/internal/cmd/synthesis.go @@ -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 ", + 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 + 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:
" 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 +} diff --git a/internal/cmd/synthesis_test.go b/internal/cmd/synthesis_test.go new file mode 100644 index 00000000..ff699a60 --- /dev/null +++ b/internal/cmd/synthesis_test.go @@ -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)) + } +}