From 46cdf075d4266cba0d3262376b057ff50eb12fe2 Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Sun, 28 Dec 2025 00:07:04 -0800 Subject: [PATCH] feat: add agent ID pattern validation for bd create --type=agent (gt-hlaaf) - Update ParseIssueType to use canonical types.IsValid() (includes agent, role, gate, message) - Add ValidateAgentID function to validate gt-[-[-]] pattern - Wire up validation in create.go when --type=agent with explicit ID - Update --type flag help text to include agent and role types - Add comprehensive test coverage for agent ID validation --- cmd/bd/create.go | 9 ++- internal/validation/bead.go | 111 +++++++++++++++++++++++++++---- internal/validation/bead_test.go | 80 +++++++++++++++++++++- 3 files changed, 184 insertions(+), 16 deletions(-) diff --git a/cmd/bd/create.go b/cmd/bd/create.go index 8fb401b5..c1596711 100644 --- a/cmd/bd/create.go +++ b/cmd/bd/create.go @@ -215,6 +215,13 @@ var createCmd = &cobra.Command{ if err := validation.ValidatePrefix(requestedPrefix, dbPrefix, forceCreate); err != nil { FatalError("%v", err) } + + // Validate agent ID pattern if type is agent (gt-hlaaf) + if issueType == "agent" { + if err := validation.ValidateAgentID(explicitID); err != nil { + FatalError("invalid agent ID: %v", err) + } + } } var externalRefPtr *string @@ -453,7 +460,7 @@ func init() { createCmd.Flags().String("title", "", "Issue title (alternative to positional argument)") createCmd.Flags().Bool("silent", false, "Output only the issue ID (for scripting)") registerPriorityFlag(createCmd, "2") - createCmd.Flags().StringP("type", "t", "task", "Issue type (bug|feature|task|epic|chore|merge-request|molecule|gate)") + createCmd.Flags().StringP("type", "t", "task", "Issue type (bug|feature|task|epic|chore|merge-request|molecule|gate|agent|role)") registerCommonIssueFlags(createCmd) createCmd.Flags().StringSliceP("labels", "l", []string{}, "Labels (comma-separated)") createCmd.Flags().StringSlice("label", []string{}, "Alias for --labels") diff --git a/internal/validation/bead.go b/internal/validation/bead.go index 487371bf..5cf205f5 100644 --- a/internal/validation/bead.go +++ b/internal/validation/bead.go @@ -30,18 +30,8 @@ func ParsePriority(content string) int { func ParseIssueType(content string) (types.IssueType, error) { issueType := types.IssueType(strings.TrimSpace(content)) - // Validate issue type - validTypes := map[types.IssueType]bool{ - types.TypeBug: true, - types.TypeFeature: true, - types.TypeTask: true, - types.TypeEpic: true, - types.TypeChore: true, - types.TypeMergeRequest: true, - types.TypeMolecule: true, - } - - if !validTypes[issueType] { + // Use the canonical IsValid() from types package + if !issueType.IsValid() { return types.TypeTask, fmt.Errorf("invalid issue type: %s", content) } @@ -88,3 +78,100 @@ func ValidatePrefix(requestedPrefix, dbPrefix string, force bool) error { return fmt.Errorf("prefix mismatch: database uses '%s' but you specified '%s' (use --force to override)", dbPrefix, requestedPrefix) } + +// ValidAgentRoles are the known agent role types for ID pattern validation +var ValidAgentRoles = []string{ + "mayor", // Town-level: gt-mayor + "deacon", // Town-level: gt-deacon + "witness", // Per-rig: gt-witness- + "refinery", // Per-rig: gt-refinery- + "crew", // Per-rig with name: gt-crew-- + "polecat", // Per-rig with name: gt-polecat-- +} + +// TownLevelRoles are agent roles that don't have a rig +var TownLevelRoles = []string{"mayor", "deacon"} + +// NamedRoles are agent roles that include a worker name +var NamedRoles = []string{"crew", "polecat"} + +// ValidateAgentID validates that an agent ID follows the expected pattern. +// Patterns: +// - Town-level: gt- (e.g., gt-mayor, gt-deacon) +// - Per-rig: gt-- (e.g., gt-witness-gastown) +// - Named: gt--- (e.g., gt-polecat-gastown-nux) +// +// Returns nil if the ID is valid, or an error describing the issue. +func ValidateAgentID(id string) error { + if id == "" { + return fmt.Errorf("agent ID is required") + } + + // Must start with gt- + if !strings.HasPrefix(id, "gt-") { + return fmt.Errorf("agent ID must start with 'gt-' (got %q)", id) + } + + // Split into parts after the prefix + rest := id[3:] // Skip "gt-" + parts := strings.Split(rest, "-") + if len(parts) < 1 || parts[0] == "" { + return fmt.Errorf("agent ID must include role type: gt-[-[-]] (got %q)", id) + } + + role := parts[0] + + // Check if role is valid + validRole := false + for _, r := range ValidAgentRoles { + if role == r { + validRole = true + break + } + } + if !validRole { + return fmt.Errorf("invalid agent role %q (valid: %s)", role, strings.Join(ValidAgentRoles, ", ")) + } + + // Check town-level roles (no rig allowed) + for _, r := range TownLevelRoles { + if role == r { + if len(parts) > 1 { + return fmt.Errorf("town-level agent %q cannot have rig suffix (expected gt-%s, got %q)", role, role, id) + } + return nil // Valid town-level agent + } + } + + // Per-rig agents require at least a rig + if len(parts) < 2 { + return fmt.Errorf("per-rig agent %q requires rig: gt-%s- (got %q)", role, role, id) + } + + rig := parts[1] + if rig == "" { + return fmt.Errorf("rig name cannot be empty in %q", id) + } + + // Check named roles (require name) + for _, r := range NamedRoles { + if role == r { + if len(parts) < 3 { + return fmt.Errorf("agent %q requires name: gt-%s-- (got %q)", role, role, id) + } + name := parts[2] + if name == "" { + return fmt.Errorf("agent name cannot be empty in %q", id) + } + // Extra parts after name are allowed (e.g., for complex identifiers) + return nil // Valid named agent + } + } + + // Regular per-rig agents (witness, refinery) - should have exactly 2 parts + if len(parts) > 2 { + return fmt.Errorf("agent %q takes only rig: gt-%s- (got %q)", role, role, id) + } + + return nil // Valid per-rig agent +} diff --git a/internal/validation/bead_test.go b/internal/validation/bead_test.go index fe3d6026..f11cee42 100644 --- a/internal/validation/bead_test.go +++ b/internal/validation/bead_test.go @@ -125,15 +125,19 @@ func TestParseIssueType(t *testing.T) { {"chore type", "chore", types.TypeChore, false, ""}, {"merge-request type", "merge-request", types.TypeMergeRequest, false, ""}, {"molecule type", "molecule", types.TypeMolecule, false, ""}, - + {"gate type", "gate", types.TypeGate, false, ""}, + {"agent type", "agent", types.TypeAgent, false, ""}, + {"role type", "role", types.TypeRole, false, ""}, + {"message type", "message", types.TypeMessage, false, ""}, + // Case sensitivity (function is case-sensitive) {"uppercase bug", "BUG", types.TypeTask, true, "invalid issue type"}, {"mixed case feature", "FeAtUrE", types.TypeTask, true, "invalid issue type"}, - + // With whitespace {"bug with spaces", " bug ", types.TypeBug, false, ""}, {"feature with tabs", "\tfeature\t", types.TypeFeature, false, ""}, - + // Invalid issue types {"invalid type", "invalid", types.TypeTask, true, "invalid issue type"}, {"empty string", "", types.TypeTask, true, "invalid issue type"}, @@ -190,3 +194,73 @@ func TestValidatePrefix(t *testing.T) { }) } } + +func TestValidateAgentID(t *testing.T) { + tests := []struct { + name string + id string + wantError bool + errorContains string + }{ + // Town-level agents (no rig) + {"valid mayor", "gt-mayor", false, ""}, + {"valid deacon", "gt-deacon", false, ""}, + + // Per-rig agents (witness, refinery) + {"valid witness gastown", "gt-witness-gastown", false, ""}, + {"valid refinery beads", "gt-refinery-beads", false, ""}, + // Note: hyphenated rig names like "my-rig" are ambiguous and not supported + // for witness/refinery. Use simple rig names. + + // Named agents (crew, polecat) + {"valid polecat", "gt-polecat-gastown-nux", false, ""}, + {"valid crew", "gt-crew-beads-dave", false, ""}, + {"valid polecat with complex name", "gt-polecat-gastown-war-boy-1", false, ""}, + + // Invalid: wrong prefix + {"wrong prefix bd", "bd-mayor", true, "must start with 'gt-'"}, + {"wrong prefix empty", "mayor", true, "must start with 'gt-'"}, + + // Invalid: empty + {"empty id", "", true, "agent ID is required"}, + + // Invalid: unknown role + {"unknown role", "gt-admin-gastown", true, "invalid agent role"}, + {"unknown role foo", "gt-foo", true, "invalid agent role"}, + + // Invalid: town-level with rig + {"mayor with rig", "gt-mayor-gastown", true, "cannot have rig suffix"}, + {"deacon with rig", "gt-deacon-beads", true, "cannot have rig suffix"}, + + // Invalid: per-rig without rig + {"witness no rig", "gt-witness", true, "requires rig"}, + {"refinery no rig", "gt-refinery", true, "requires rig"}, + + // Invalid: named agent without name + {"polecat no name", "gt-polecat-gastown", true, "requires name"}, + {"crew no name", "gt-crew-beads", true, "requires name"}, + + // Invalid: witness/refinery with extra parts + {"witness with name", "gt-witness-gastown-extra", true, "takes only rig"}, + {"refinery with name", "gt-refinery-beads-extra", true, "takes only rig"}, + + // Invalid: empty components + {"empty rig", "gt-witness-", true, "rig name cannot be empty"}, + {"just gt", "gt-", true, "must include role type"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateAgentID(tt.id) + if (err != nil) != tt.wantError { + t.Errorf("ValidateAgentID(%q) error = %v, wantError %v", tt.id, err, tt.wantError) + return + } + if err != nil && tt.errorContains != "" { + if !strings.Contains(err.Error(), tt.errorContains) { + t.Errorf("ValidateAgentID(%q) error = %q, should contain %q", tt.id, err.Error(), tt.errorContains) + } + } + }) + } +}