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:
slit
2026-01-01 16:00:14 -08:00
committed by Steve Yegge
parent 98d68827f0
commit 1e4cd86b56
3 changed files with 847 additions and 0 deletions

View File

@@ -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,