From c6fe9d71ca917f475ad31b7224327173e7a9a7a6 Mon Sep 17 00:00:00 2001 From: wolf Date: Sat, 3 Jan 2026 12:43:58 -0800 Subject: [PATCH] fix(validation): support hyphenated rig names in agent IDs (GH#854) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrites ValidateAgentID to scan right-to-left for known role tokens instead of relying on fixed position parsing. This allows rig names containing hyphens (e.g., ob-my-project-witness). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/validation/bead.go | 81 ++++++++++++++++---------------- internal/validation/bead_test.go | 12 ++++- 2 files changed, 51 insertions(+), 42 deletions(-) diff --git a/internal/validation/bead.go b/internal/validation/bead.go index 9fe60948..d67e5a9d 100644 --- a/internal/validation/bead.go +++ b/internal/validation/bead.go @@ -146,6 +146,8 @@ func isNamedRole(s string) bool { // - Per-rig named: --- (e.g., gt-gastown-polecat-nux) // // The prefix can be any rig's configured prefix (gt-, bd-, etc.). +// Rig names may contain hyphens (e.g., my-project), so we parse by scanning +// for known role tokens from the right side of the ID. // Returns nil if the ID is valid, or an error describing the issue. func ValidateAgentID(id string) error { if id == "" { @@ -165,7 +167,7 @@ func ValidateAgentID(id string) error { return fmt.Errorf("agent ID must include content after prefix (got %q)", id) } - // Case 1: Town-level roles (gt-mayor, gt-deacon) + // Case 1: Single part after prefix - must be town-level role if len(parts) == 1 { role := parts[0] if isTownLevelRole(role) { @@ -177,53 +179,52 @@ func ValidateAgentID(id string) error { return fmt.Errorf("invalid agent role %q (valid: %s)", role, strings.Join(ValidAgentRoles, ", ")) } - // Case 2: Rig-level roles (--witness, --refinery) - if len(parts) == 2 { - rig, role := parts[0], parts[1] - - if isRigLevelRole(role) { - if rig == "" { - return fmt.Errorf("rig name cannot be empty in %q", id) - } - return nil // Valid rig-level singleton agent + // For 2+ parts, scan from the right to find a known role. + // This allows rig names to contain hyphens (e.g., "my-project"). + roleIdx := -1 + var role string + for i := len(parts) - 1; i >= 0; i-- { + if isValidRole(parts[i]) { + roleIdx = i + role = parts[i] + break } - - if isNamedRole(role) { - return fmt.Errorf("agent role %q requires name: --%s- (got %q)", role, role, id) - } - - if isTownLevelRole(role) { - return fmt.Errorf("town-level agent %q cannot have rig suffix (expected -%s, got %q)", role, role, id) - } - - // First part might be a rig name with second part being something invalid - return fmt.Errorf("invalid agent format: expected --[-] where role is one of: %s (got %q)", strings.Join(ValidAgentRoles, ", "), id) } - // Case 3: Named roles (--crew-, --polecat-) - if len(parts) >= 3 { - rig, role := parts[0], parts[1] - name := strings.Join(parts[2:], "-") // Allow hyphens in names + if roleIdx == -1 { + return fmt.Errorf("invalid agent format: no valid role found in %q (valid roles: %s)", id, strings.Join(ValidAgentRoles, ", ")) + } - if isNamedRole(role) { - if rig == "" { - return fmt.Errorf("rig name cannot be empty in %q", id) - } - if name == "" { - return fmt.Errorf("agent name cannot be empty in %q", id) - } - return nil // Valid named agent - } + // Extract rig (everything before role) and name (everything after role) + rig := strings.Join(parts[:roleIdx], "-") + name := strings.Join(parts[roleIdx+1:], "-") - if isRigLevelRole(role) { - return fmt.Errorf("agent role %q cannot have name suffix (expected --%s, got %q)", role, role, id) - } - - if isTownLevelRole(role) { + // Validate based on role type + if isTownLevelRole(role) { + if rig != "" || name != "" { return fmt.Errorf("town-level agent %q cannot have rig/name suffixes (expected -%s, got %q)", role, role, id) } + return nil + } - return fmt.Errorf("invalid agent format: expected --- where role is one of: %s (got %q)", strings.Join(NamedRoles, ", "), id) + if isRigLevelRole(role) { + if rig == "" { + return fmt.Errorf("agent role %q requires rig: --%s (got %q)", role, role, id) + } + if name != "" { + return fmt.Errorf("agent role %q cannot have name suffix (expected --%s, got %q)", role, role, id) + } + return nil // Valid rig-level singleton agent + } + + if isNamedRole(role) { + if rig == "" { + return fmt.Errorf("rig name cannot be empty in %q", id) + } + if name == "" { + return fmt.Errorf("agent role %q requires name: --%s- (got %q)", role, role, id) + } + return nil // Valid named agent } return fmt.Errorf("invalid agent ID format: %q", id) diff --git a/internal/validation/bead_test.go b/internal/validation/bead_test.go index 73827ff6..27d5c940 100644 --- a/internal/validation/bead_test.go +++ b/internal/validation/bead_test.go @@ -220,6 +220,14 @@ func TestValidateAgentID(t *testing.T) { {"valid bd-beads-polecat-pearl", "bd-beads-polecat-pearl", false, ""}, {"valid bd-beads-witness", "bd-beads-witness", false, ""}, + // Valid: hyphenated rig names (GH#854) + {"hyphenated rig witness", "ob-my-project-witness", false, ""}, + {"hyphenated rig refinery", "gt-foo-bar-refinery", false, ""}, + {"hyphenated rig crew", "bd-my-cool-project-crew-fang", false, ""}, + {"hyphenated rig polecat", "gt-some-long-rig-name-polecat-nux", false, ""}, + {"hyphenated rig and name", "gt-my-rig-polecat-war-boy", false, ""}, + {"multi-hyphen rig crew", "ob-a-b-c-d-crew-dave", false, ""}, + // Invalid: no prefix (missing hyphen) {"no prefix", "mayor", true, "must have a prefix followed by '-'"}, @@ -230,8 +238,8 @@ func TestValidateAgentID(t *testing.T) { {"unknown role", "gt-gastown-admin", true, "invalid agent format"}, // Invalid: town-level with rig (put role first) - {"mayor with rig suffix", "gt-gastown-mayor", true, "cannot have rig suffix"}, - {"deacon with rig suffix", "gt-beads-deacon", true, "cannot have rig suffix"}, + {"mayor with rig suffix", "gt-gastown-mayor", true, "cannot have rig/name suffixes"}, + {"deacon with rig suffix", "gt-beads-deacon", true, "cannot have rig/name suffixes"}, // Invalid: per-rig role without rig {"witness alone", "gt-witness", true, "requires rig"},