Add explicit guidance on the Mayor → Crew → Polecats delegation model: - Crew are coordinators for epics/goals needing decomposition - Polecats are executors for well-defined tasks - Include decision framework table for work type routing Closes: gt-9jd
191 lines
5.6 KiB
Go
191 lines
5.6 KiB
Go
package cmd
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/base32"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/steveyegge/gastown/internal/beads"
|
|
"github.com/steveyegge/gastown/internal/style"
|
|
"github.com/steveyegge/gastown/internal/workspace"
|
|
)
|
|
|
|
// slingGenerateShortID generates a short random ID (5 lowercase chars).
|
|
func slingGenerateShortID() string {
|
|
b := make([]byte, 3)
|
|
_, _ = rand.Read(b)
|
|
return strings.ToLower(base32.StdEncoding.EncodeToString(b)[:5])
|
|
}
|
|
|
|
// isTrackedByConvoy checks if an issue is already being tracked by a convoy.
|
|
// Returns the convoy ID if tracked, empty string otherwise.
|
|
func isTrackedByConvoy(beadID string) string {
|
|
townRoot, err := workspace.FindFromCwd()
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
|
|
// Use bd dep list to find what tracks this issue (direction=up)
|
|
// Filter for open convoys in the results
|
|
depCmd := exec.Command("bd", "--no-daemon", "dep", "list", beadID, "--direction=up", "--type=tracks", "--json")
|
|
depCmd.Dir = townRoot
|
|
|
|
out, err := depCmd.Output()
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
|
|
// Parse results and find an open convoy
|
|
var trackers []struct {
|
|
ID string `json:"id"`
|
|
IssueType string `json:"issue_type"`
|
|
Status string `json:"status"`
|
|
}
|
|
if err := json.Unmarshal(out, &trackers); err != nil {
|
|
return ""
|
|
}
|
|
|
|
// Return the first open convoy that tracks this issue
|
|
for _, tracker := range trackers {
|
|
if tracker.IssueType == "convoy" && tracker.Status == "open" {
|
|
return tracker.ID
|
|
}
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
// createAutoConvoy creates an auto-convoy for a single issue and tracks it.
|
|
// If epicID is provided, links the convoy to the parent epic.
|
|
// Returns the created convoy ID.
|
|
func createAutoConvoy(beadID, beadTitle string, epicID string) (string, error) {
|
|
townRoot, err := workspace.FindFromCwd()
|
|
if err != nil {
|
|
return "", fmt.Errorf("finding town root: %w", err)
|
|
}
|
|
|
|
townBeads := filepath.Join(townRoot, ".beads")
|
|
|
|
// Generate convoy ID with hq-cv- prefix for visual distinction
|
|
// The hq-cv- prefix is registered in routes during gt install
|
|
convoyID := fmt.Sprintf("hq-cv-%s", slingGenerateShortID())
|
|
|
|
// Create convoy with title "Work: <issue-title>"
|
|
convoyTitle := fmt.Sprintf("Work: %s", beadTitle)
|
|
description := fmt.Sprintf("Auto-created convoy tracking %s", beadID)
|
|
if epicID != "" {
|
|
description += fmt.Sprintf("\nParent-Epic: %s", epicID)
|
|
}
|
|
|
|
createArgs := []string{
|
|
"create",
|
|
"--type=convoy",
|
|
"--id=" + convoyID,
|
|
"--title=" + convoyTitle,
|
|
"--description=" + description,
|
|
}
|
|
if beads.NeedsForceForID(convoyID) {
|
|
createArgs = append(createArgs, "--force")
|
|
}
|
|
|
|
createCmd := exec.Command("bd", append([]string{"--no-daemon"}, createArgs...)...)
|
|
createCmd.Dir = townBeads
|
|
createCmd.Stderr = os.Stderr
|
|
|
|
if err := createCmd.Run(); err != nil {
|
|
return "", fmt.Errorf("creating convoy: %w", err)
|
|
}
|
|
|
|
// Add tracking relation: convoy tracks the issue
|
|
trackBeadID := formatTrackBeadID(beadID)
|
|
depArgs := []string{"--no-daemon", "dep", "add", convoyID, trackBeadID, "--type=tracks"}
|
|
depCmd := exec.Command("bd", depArgs...)
|
|
depCmd.Dir = townBeads
|
|
depCmd.Stderr = os.Stderr
|
|
|
|
if err := depCmd.Run(); err != nil {
|
|
// Convoy was created but tracking failed - log warning but continue
|
|
fmt.Printf("%s Could not add tracking relation: %v\n", style.Dim.Render("Warning:"), err)
|
|
}
|
|
|
|
// Link convoy to parent epic if specified (Goals layer)
|
|
if epicID != "" {
|
|
epicDepArgs := []string{"--no-daemon", "dep", "add", convoyID, epicID, "--type=child_of"}
|
|
epicDepCmd := exec.Command("bd", epicDepArgs...)
|
|
epicDepCmd.Dir = townBeads
|
|
epicDepCmd.Stderr = os.Stderr
|
|
|
|
if err := epicDepCmd.Run(); err != nil {
|
|
// Epic link failed - log warning but continue
|
|
fmt.Printf("%s Could not link convoy to epic: %v\n", style.Dim.Render("Warning:"), err)
|
|
}
|
|
}
|
|
|
|
return convoyID, nil
|
|
}
|
|
|
|
// addToExistingConvoy adds a bead to an existing convoy by creating a tracking relation.
|
|
// Returns an error if the convoy doesn't exist or the tracking relation fails.
|
|
func addToExistingConvoy(convoyID, beadID string) error {
|
|
townRoot, err := workspace.FindFromCwd()
|
|
if err != nil {
|
|
return fmt.Errorf("finding town root: %w", err)
|
|
}
|
|
|
|
townBeads := filepath.Join(townRoot, ".beads")
|
|
dbPath := filepath.Join(townBeads, "beads.db")
|
|
|
|
// Verify convoy exists and is open
|
|
query := fmt.Sprintf(`
|
|
SELECT id FROM issues
|
|
WHERE id = '%s'
|
|
AND issue_type = 'convoy'
|
|
AND status = 'open'
|
|
`, convoyID)
|
|
|
|
queryCmd := exec.Command("sqlite3", dbPath, query)
|
|
out, err := queryCmd.Output()
|
|
if err != nil || strings.TrimSpace(string(out)) == "" {
|
|
return fmt.Errorf("convoy %s not found or not open", convoyID)
|
|
}
|
|
|
|
// Add tracking relation: convoy tracks the issue
|
|
trackBeadID := formatTrackBeadID(beadID)
|
|
depArgs := []string{"--no-daemon", "dep", "add", convoyID, trackBeadID, "--type=tracks"}
|
|
depCmd := exec.Command("bd", depArgs...)
|
|
depCmd.Dir = townBeads
|
|
depCmd.Stderr = os.Stderr
|
|
|
|
if err := depCmd.Run(); err != nil {
|
|
return fmt.Errorf("adding tracking relation: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// formatTrackBeadID formats a bead ID for use in convoy tracking dependencies.
|
|
// Cross-rig beads (non-hq- prefixed) are formatted as external references
|
|
// so the bd tool can resolve them when running from HQ context.
|
|
//
|
|
// Examples:
|
|
// - "hq-abc123" -> "hq-abc123" (HQ beads unchanged)
|
|
// - "gt-mol-xyz" -> "external:gt-mol:gt-mol-xyz"
|
|
// - "beads-task-123" -> "external:beads-task:beads-task-123"
|
|
func formatTrackBeadID(beadID string) string {
|
|
if strings.HasPrefix(beadID, "hq-") {
|
|
return beadID
|
|
}
|
|
parts := strings.SplitN(beadID, "-", 3)
|
|
if len(parts) >= 2 {
|
|
rigPrefix := parts[0] + "-" + parts[1]
|
|
return fmt.Sprintf("external:%s:%s", rigPrefix, beadID)
|
|
}
|
|
// Fallback for malformed IDs (single segment)
|
|
return beadID
|
|
}
|