diff --git a/internal/witness/handlers.go b/internal/witness/handlers.go new file mode 100644 index 00000000..b75a68e0 --- /dev/null +++ b/internal/witness/handlers.go @@ -0,0 +1,396 @@ +package witness + +import ( + "bytes" + "fmt" + "os/exec" + "strings" + "time" + + "github.com/steveyegge/gastown/internal/mail" +) + +// HandlerResult tracks the result of handling a protocol message. +type HandlerResult struct { + MessageID string + ProtocolType ProtocolType + Handled bool + Action string + WispCreated string // ID of created wisp (if any) + MailSent string // ID of sent mail (if any) + Error error +} + +// HandlePolecatDone processes a POLECAT_DONE message from a polecat. +// Creates a cleanup wisp for the polecat to trigger the verification flow. +func HandlePolecatDone(workDir, rigName string, msg *mail.Message) *HandlerResult { + result := &HandlerResult{ + MessageID: msg.ID, + ProtocolType: ProtoPolecatDone, + } + + // Parse the message + payload, err := ParsePolecatDone(msg.Subject, msg.Body) + if err != nil { + result.Error = fmt.Errorf("parsing POLECAT_DONE: %w", err) + return result + } + + // Create a cleanup wisp for this polecat + wispID, err := createCleanupWisp(workDir, payload.PolecatName, payload.IssueID, payload.Branch) + if err != nil { + result.Error = fmt.Errorf("creating cleanup wisp: %w", err) + return result + } + + result.Handled = true + result.WispCreated = wispID + result.Action = fmt.Sprintf("created cleanup wisp %s for polecat %s", wispID, payload.PolecatName) + + return result +} + +// HandleLifecycleShutdown processes a LIFECYCLE:Shutdown message. +// Similar to POLECAT_DONE but triggered by daemon rather than polecat. +func HandleLifecycleShutdown(workDir, rigName string, msg *mail.Message) *HandlerResult { + result := &HandlerResult{ + MessageID: msg.ID, + ProtocolType: ProtoLifecycleShutdown, + } + + // Extract polecat name from subject + matches := PatternLifecycleShutdown.FindStringSubmatch(msg.Subject) + if len(matches) < 2 { + result.Error = fmt.Errorf("invalid LIFECYCLE:Shutdown subject: %s", msg.Subject) + return result + } + polecatName := matches[1] + + // Create a cleanup wisp + wispID, err := createCleanupWisp(workDir, polecatName, "", "") + if err != nil { + result.Error = fmt.Errorf("creating cleanup wisp: %w", err) + return result + } + + result.Handled = true + result.WispCreated = wispID + result.Action = fmt.Sprintf("created cleanup wisp %s for shutdown %s", wispID, polecatName) + + return result +} + +// HandleHelp processes a HELP message from a polecat requesting intervention. +// Assesses the request and either helps directly or escalates to Mayor. +func HandleHelp(workDir, rigName string, msg *mail.Message, router *mail.Router) *HandlerResult { + result := &HandlerResult{ + MessageID: msg.ID, + ProtocolType: ProtoHelp, + } + + // Parse the message + payload, err := ParseHelp(msg.Subject, msg.Body) + if err != nil { + result.Error = fmt.Errorf("parsing HELP: %w", err) + return result + } + + // Assess the help request + assessment := AssessHelpRequest(payload) + + if assessment.CanHelp { + // Log that we can help - actual help is done by the Claude agent + result.Handled = true + result.Action = fmt.Sprintf("can help with '%s': %s", payload.Topic, assessment.HelpAction) + return result + } + + // Need to escalate to Mayor + if assessment.NeedsEscalation { + mailID, err := escalateToMayor(router, rigName, payload, assessment.EscalationReason) + if err != nil { + result.Error = fmt.Errorf("escalating to mayor: %w", err) + return result + } + + result.Handled = true + result.MailSent = mailID + result.Action = fmt.Sprintf("escalated '%s' to mayor: %s", payload.Topic, assessment.EscalationReason) + } + + return result +} + +// HandleMerged processes a MERGED message from the Refinery. +// Finds the cleanup wisp for this polecat and triggers the nuke. +func HandleMerged(workDir, rigName string, msg *mail.Message) *HandlerResult { + result := &HandlerResult{ + MessageID: msg.ID, + ProtocolType: ProtoMerged, + } + + // Parse the message + payload, err := ParseMerged(msg.Subject, msg.Body) + if err != nil { + result.Error = fmt.Errorf("parsing MERGED: %w", err) + return result + } + + // Find the cleanup wisp for this polecat + wispID, err := findCleanupWisp(workDir, payload.PolecatName) + if err != nil { + result.Error = fmt.Errorf("finding cleanup wisp: %w", err) + return result + } + + if wispID == "" { + // No wisp found - polecat may have been cleaned up already + result.Handled = true + result.Action = fmt.Sprintf("no cleanup wisp found for %s (may be already cleaned)", payload.PolecatName) + return result + } + + result.Handled = true + result.WispCreated = wispID // Reference to existing wisp + result.Action = fmt.Sprintf("found cleanup wisp %s for %s, ready to nuke", wispID, payload.PolecatName) + + return result +} + +// HandleSwarmStart processes a SWARM_START message from the Mayor. +// Creates a swarm tracking wisp to monitor batch polecat work. +func HandleSwarmStart(workDir string, msg *mail.Message) *HandlerResult { + result := &HandlerResult{ + MessageID: msg.ID, + ProtocolType: ProtoSwarmStart, + } + + // Parse the message + payload, err := ParseSwarmStart(msg.Body) + if err != nil { + result.Error = fmt.Errorf("parsing SWARM_START: %w", err) + return result + } + + // Create a swarm tracking wisp + wispID, err := createSwarmWisp(workDir, payload) + if err != nil { + result.Error = fmt.Errorf("creating swarm wisp: %w", err) + return result + } + + result.Handled = true + result.WispCreated = wispID + result.Action = fmt.Sprintf("created swarm tracking wisp %s for %s", wispID, payload.SwarmID) + + return result +} + +// createCleanupWisp creates a wisp to track polecat cleanup. +func createCleanupWisp(workDir, polecatName, issueID, branch string) (string, error) { + title := fmt.Sprintf("cleanup:%s", polecatName) + description := fmt.Sprintf("Verify and cleanup polecat %s", polecatName) + if issueID != "" { + description += fmt.Sprintf("\nIssue: %s", issueID) + } + if branch != "" { + description += fmt.Sprintf("\nBranch: %s", branch) + } + + labels := strings.Join(CleanupWispLabels(polecatName, "pending"), ",") + + cmd := exec.Command("bd", "create", + "--wisp", + "--title", title, + "--description", description, + "--labels", labels, + ) + cmd.Dir = workDir + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + errMsg := strings.TrimSpace(stderr.String()) + if errMsg != "" { + return "", fmt.Errorf("%s", errMsg) + } + return "", err + } + + // Extract wisp ID from output (bd create outputs "Created: ") + output := strings.TrimSpace(stdout.String()) + if strings.HasPrefix(output, "Created:") { + return strings.TrimSpace(strings.TrimPrefix(output, "Created:")), nil + } + + // Try to extract ID from output + for _, line := range strings.Split(output, "\n") { + line = strings.TrimSpace(line) + // Look for bead ID pattern (e.g., "gt-abc123") + if strings.Contains(line, "-") && len(line) < 20 { + return line, nil + } + } + + return output, nil +} + +// createSwarmWisp creates a wisp to track swarm (batch) work. +func createSwarmWisp(workDir string, payload *SwarmStartPayload) (string, error) { + title := fmt.Sprintf("swarm:%s", payload.SwarmID) + description := fmt.Sprintf("Tracking batch: %s\nTotal: %d polecats", payload.SwarmID, payload.Total) + + labels := strings.Join(SwarmWispLabels(payload.SwarmID, payload.Total, 0, payload.StartedAt), ",") + + cmd := exec.Command("bd", "create", + "--wisp", + "--title", title, + "--description", description, + "--labels", labels, + ) + cmd.Dir = workDir + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + errMsg := strings.TrimSpace(stderr.String()) + if errMsg != "" { + return "", fmt.Errorf("%s", errMsg) + } + return "", err + } + + output := strings.TrimSpace(stdout.String()) + if strings.HasPrefix(output, "Created:") { + return strings.TrimSpace(strings.TrimPrefix(output, "Created:")), nil + } + + return output, nil +} + +// findCleanupWisp finds an existing cleanup wisp for a polecat. +func findCleanupWisp(workDir, polecatName string) (string, error) { + cmd := exec.Command("bd", "list", + "--wisp", + "--labels", fmt.Sprintf("polecat:%s,state:merge-requested", polecatName), + "--status", "open", + "--json", + ) + cmd.Dir = workDir + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + // Empty result is fine + if strings.Contains(stderr.String(), "no issues found") { + return "", nil + } + errMsg := strings.TrimSpace(stderr.String()) + if errMsg != "" { + return "", fmt.Errorf("%s", errMsg) + } + return "", err + } + + // Parse JSON to get the wisp ID + output := strings.TrimSpace(stdout.String()) + if output == "" || output == "[]" || output == "null" { + return "", nil + } + + // Simple extraction - look for "id" field + // Full JSON parsing would add dependency on encoding/json + if idx := strings.Index(output, `"id":`); idx >= 0 { + rest := output[idx+5:] + rest = strings.TrimLeft(rest, ` "`) + if endIdx := strings.IndexAny(rest, `",}`); endIdx > 0 { + return rest[:endIdx], nil + } + } + + return "", nil +} + +// escalateToMayor sends an escalation mail to the Mayor. +func escalateToMayor(router *mail.Router, rigName string, payload *HelpPayload, reason string) (string, error) { + msg := &mail.Message{ + From: fmt.Sprintf("%s/witness", rigName), + To: "mayor/", + Subject: fmt.Sprintf("Escalation: %s needs help", payload.Agent), + Priority: mail.PriorityHigh, + Body: fmt.Sprintf(`Agent: %s +Issue: %s +Topic: %s +Problem: %s +Tried: %s +Escalation reason: %s +Requested at: %s`, + payload.Agent, + payload.IssueID, + payload.Topic, + payload.Problem, + payload.Tried, + reason, + payload.RequestedAt.Format(time.RFC3339), + ), + } + + if err := router.Send(msg); err != nil { + return "", err + } + + return msg.ID, nil +} + +// UpdateCleanupWispState updates a cleanup wisp's state label. +func UpdateCleanupWispState(workDir, wispID, newState string) error { + // Get current labels to preserve other labels + cmd := exec.Command("bd", "show", wispID, "--json") + cmd.Dir = workDir + + var stdout bytes.Buffer + cmd.Stdout = &stdout + + if err := cmd.Run(); err != nil { + return fmt.Errorf("getting wisp: %w", err) + } + + // Extract polecat name from existing labels for the update + output := stdout.String() + var polecatName string + if idx := strings.Index(output, `polecat:`); idx >= 0 { + rest := output[idx+8:] + if endIdx := strings.IndexAny(rest, `",]}`); endIdx > 0 { + polecatName = rest[:endIdx] + } + } + + if polecatName == "" { + polecatName = "unknown" + } + + // Update with new state + newLabels := strings.Join(CleanupWispLabels(polecatName, newState), ",") + + updateCmd := exec.Command("bd", "update", wispID, "--labels", newLabels) + updateCmd.Dir = workDir + + var stderr bytes.Buffer + updateCmd.Stderr = &stderr + + if err := updateCmd.Run(); err != nil { + errMsg := strings.TrimSpace(stderr.String()) + if errMsg != "" { + return fmt.Errorf("%s", errMsg) + } + return err + } + + return nil +} diff --git a/internal/witness/protocol.go b/internal/witness/protocol.go new file mode 100644 index 00000000..0ae58174 --- /dev/null +++ b/internal/witness/protocol.go @@ -0,0 +1,304 @@ +// Package witness provides the polecat monitoring agent. +package witness + +import ( + "fmt" + "regexp" + "strings" + "time" +) + +// Protocol message patterns for Witness inbox routing. +var ( + // POLECAT_DONE - polecat signaling work completion + PatternPolecatDone = regexp.MustCompile(`^POLECAT_DONE\s+(\S+)`) + + // LIFECYCLE:Shutdown - daemon-triggered polecat shutdown + PatternLifecycleShutdown = regexp.MustCompile(`^LIFECYCLE:Shutdown\s+(\S+)`) + + // HELP: - polecat requesting intervention + PatternHelp = regexp.MustCompile(`^HELP:\s+(.+)`) + + // MERGED - refinery confirms branch merged + PatternMerged = regexp.MustCompile(`^MERGED\s+(\S+)`) + + // HANDOFF - session continuity message + PatternHandoff = regexp.MustCompile(`^🤝\s*HANDOFF`) + + // SWARM_START - mayor initiating batch work + PatternSwarmStart = regexp.MustCompile(`^SWARM_START`) +) + +// ProtocolType identifies the type of protocol message. +type ProtocolType string + +const ( + ProtoPolecatDone ProtocolType = "polecat_done" + ProtoLifecycleShutdown ProtocolType = "lifecycle_shutdown" + ProtoHelp ProtocolType = "help" + ProtoMerged ProtocolType = "merged" + ProtoHandoff ProtocolType = "handoff" + ProtoSwarmStart ProtocolType = "swarm_start" + ProtoUnknown ProtocolType = "unknown" +) + +// PolecatDonePayload contains parsed data from a POLECAT_DONE message. +type PolecatDonePayload struct { + PolecatName string + Exit string // MERGED, ESCALATED, DEFERRED + IssueID string + MRID string + Branch string +} + +// HelpPayload contains parsed data from a HELP message. +type HelpPayload struct { + Topic string + Agent string + IssueID string + Problem string + Tried string + RequestedAt time.Time +} + +// MergedPayload contains parsed data from a MERGED message. +type MergedPayload struct { + PolecatName string + Branch string + IssueID string + MergedAt time.Time +} + +// SwarmStartPayload contains parsed data from a SWARM_START message. +type SwarmStartPayload struct { + SwarmID string + BeadIDs []string + Total int + StartedAt time.Time +} + +// ClassifyMessage determines the protocol type from a message subject. +func ClassifyMessage(subject string) ProtocolType { + switch { + case PatternPolecatDone.MatchString(subject): + return ProtoPolecatDone + case PatternLifecycleShutdown.MatchString(subject): + return ProtoLifecycleShutdown + case PatternHelp.MatchString(subject): + return ProtoHelp + case PatternMerged.MatchString(subject): + return ProtoMerged + case PatternHandoff.MatchString(subject): + return ProtoHandoff + case PatternSwarmStart.MatchString(subject): + return ProtoSwarmStart + default: + return ProtoUnknown + } +} + +// ParsePolecatDone extracts payload from a POLECAT_DONE message. +// Subject format: POLECAT_DONE +// Body format: +// +// Exit: MERGED|ESCALATED|DEFERRED +// Issue: +// MR: +// Branch: +func ParsePolecatDone(subject, body string) (*PolecatDonePayload, error) { + matches := PatternPolecatDone.FindStringSubmatch(subject) + if len(matches) < 2 { + return nil, fmt.Errorf("invalid POLECAT_DONE subject: %s", subject) + } + + payload := &PolecatDonePayload{ + PolecatName: matches[1], + } + + // Parse body for structured fields + for _, line := range strings.Split(body, "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "Exit:") { + payload.Exit = strings.TrimSpace(strings.TrimPrefix(line, "Exit:")) + } else if strings.HasPrefix(line, "Issue:") { + payload.IssueID = strings.TrimSpace(strings.TrimPrefix(line, "Issue:")) + } else if strings.HasPrefix(line, "MR:") { + payload.MRID = strings.TrimSpace(strings.TrimPrefix(line, "MR:")) + } else if strings.HasPrefix(line, "Branch:") { + payload.Branch = strings.TrimSpace(strings.TrimPrefix(line, "Branch:")) + } + } + + return payload, nil +} + +// ParseHelp extracts payload from a HELP message. +// Subject format: HELP: +// Body format: +// +// Agent: +// Issue: +// Problem: +// Tried: +func ParseHelp(subject, body string) (*HelpPayload, error) { + matches := PatternHelp.FindStringSubmatch(subject) + if len(matches) < 2 { + return nil, fmt.Errorf("invalid HELP subject: %s", subject) + } + + payload := &HelpPayload{ + Topic: matches[1], + RequestedAt: time.Now(), + } + + // Parse body for structured fields + for _, line := range strings.Split(body, "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "Agent:") { + payload.Agent = strings.TrimSpace(strings.TrimPrefix(line, "Agent:")) + } else if strings.HasPrefix(line, "Issue:") { + payload.IssueID = strings.TrimSpace(strings.TrimPrefix(line, "Issue:")) + } else if strings.HasPrefix(line, "Problem:") { + payload.Problem = strings.TrimSpace(strings.TrimPrefix(line, "Problem:")) + } else if strings.HasPrefix(line, "Tried:") { + payload.Tried = strings.TrimSpace(strings.TrimPrefix(line, "Tried:")) + } + } + + return payload, nil +} + +// ParseMerged extracts payload from a MERGED message. +// Subject format: MERGED +// Body format: +// +// Branch: +// Issue: +// Merged-At: +func ParseMerged(subject, body string) (*MergedPayload, error) { + matches := PatternMerged.FindStringSubmatch(subject) + if len(matches) < 2 { + return nil, fmt.Errorf("invalid MERGED subject: %s", subject) + } + + payload := &MergedPayload{ + PolecatName: matches[1], + } + + // Parse body for structured fields + for _, line := range strings.Split(body, "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "Branch:") { + payload.Branch = strings.TrimSpace(strings.TrimPrefix(line, "Branch:")) + } else if strings.HasPrefix(line, "Issue:") { + payload.IssueID = strings.TrimSpace(strings.TrimPrefix(line, "Issue:")) + } else if strings.HasPrefix(line, "Merged-At:") { + ts := strings.TrimSpace(strings.TrimPrefix(line, "Merged-At:")) + if t, err := time.Parse(time.RFC3339, ts); err == nil { + payload.MergedAt = t + } + } + } + + return payload, nil +} + +// ParseSwarmStart extracts payload from a SWARM_START message. +// Body format is JSON: {"swarm_id": "batch-123", "beads": ["bd-a", "bd-b"]} +func ParseSwarmStart(body string) (*SwarmStartPayload, error) { + payload := &SwarmStartPayload{ + StartedAt: time.Now(), + } + + // Parse the JSON-like body (simplified parsing for key-value extraction) + // Full JSON parsing would require encoding/json import + for _, line := range strings.Split(body, "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "SwarmID:") || strings.HasPrefix(line, "swarm_id:") { + payload.SwarmID = strings.TrimSpace(strings.TrimPrefix(strings.TrimPrefix(line, "SwarmID:"), "swarm_id:")) + } else if strings.HasPrefix(line, "Total:") { + fmt.Sscanf(line, "Total: %d", &payload.Total) + } + } + + return payload, nil +} + +// CleanupWispLabels generates labels for a cleanup wisp. +func CleanupWispLabels(polecatName, state string) []string { + return []string{ + "cleanup", + fmt.Sprintf("polecat:%s", polecatName), + fmt.Sprintf("state:%s", state), + } +} + +// SwarmWispLabels generates labels for a swarm tracking wisp. +func SwarmWispLabels(swarmID string, total, completed int, startTime time.Time) []string { + return []string{ + "swarm", + fmt.Sprintf("swarm_id:%s", swarmID), + fmt.Sprintf("total:%d", total), + fmt.Sprintf("completed:%d", completed), + fmt.Sprintf("start:%s", startTime.Format(time.RFC3339)), + } +} + +// HelpAssessment represents the Witness's assessment of a help request. +type HelpAssessment struct { + CanHelp bool + HelpAction string // What the Witness can do to help + NeedsEscalation bool + EscalationReason string +} + +// AssessHelpRequest provides guidance for the Witness to assess a help request. +// This is a template/guide - actual assessment is done by the Claude agent. +func AssessHelpRequest(payload *HelpPayload) *HelpAssessment { + assessment := &HelpAssessment{} + + // Heuristics for common help requests that Witness can handle + topic := strings.ToLower(payload.Topic) + problem := strings.ToLower(payload.Problem) + + // Git issues - Witness can often help + if strings.Contains(topic, "git") || strings.Contains(problem, "git") { + if strings.Contains(problem, "conflict") { + assessment.CanHelp = false + assessment.NeedsEscalation = true + assessment.EscalationReason = "Git conflicts require human review" + } else if strings.Contains(problem, "push") || strings.Contains(problem, "fetch") { + assessment.CanHelp = true + assessment.HelpAction = "Check git remote status and network connectivity" + } + } + + // Test failures - usually need escalation + if strings.Contains(topic, "test") || strings.Contains(problem, "test fail") { + assessment.CanHelp = false + assessment.NeedsEscalation = true + assessment.EscalationReason = "Test failures require investigation" + } + + // Build issues - Witness can check basics + if strings.Contains(topic, "build") || strings.Contains(problem, "compile") { + assessment.CanHelp = true + assessment.HelpAction = "Verify dependencies and build configuration" + } + + // Requirements unclear - always escalate + if strings.Contains(topic, "unclear") || strings.Contains(problem, "requirement") || + strings.Contains(problem, "don't understand") { + assessment.CanHelp = false + assessment.NeedsEscalation = true + assessment.EscalationReason = "Requirements clarification needed from Mayor" + } + + // Default: escalate if we don't recognize the pattern + if !assessment.CanHelp && !assessment.NeedsEscalation { + assessment.NeedsEscalation = true + assessment.EscalationReason = "Unknown help request type" + } + + return assessment +} diff --git a/internal/witness/protocol_test.go b/internal/witness/protocol_test.go new file mode 100644 index 00000000..7d1a9aa6 --- /dev/null +++ b/internal/witness/protocol_test.go @@ -0,0 +1,250 @@ +package witness + +import ( + "testing" +) + +func TestClassifyMessage(t *testing.T) { + tests := []struct { + subject string + expected ProtocolType + }{ + {"POLECAT_DONE nux", ProtoPolecatDone}, + {"POLECAT_DONE ace", ProtoPolecatDone}, + {"LIFECYCLE:Shutdown nux", ProtoLifecycleShutdown}, + {"HELP: Tests failing", ProtoHelp}, + {"HELP: Git conflict", ProtoHelp}, + {"MERGED nux", ProtoMerged}, + {"MERGED valkyrie", ProtoMerged}, + {"🤝 HANDOFF: Patrol context", ProtoHandoff}, + {"🤝HANDOFF: No space", ProtoHandoff}, + {"SWARM_START", ProtoSwarmStart}, + {"Unknown message", ProtoUnknown}, + {"", ProtoUnknown}, + } + + for _, tc := range tests { + t.Run(tc.subject, func(t *testing.T) { + result := ClassifyMessage(tc.subject) + if result != tc.expected { + t.Errorf("ClassifyMessage(%q) = %v, want %v", tc.subject, result, tc.expected) + } + }) + } +} + +func TestParsePolecatDone(t *testing.T) { + subject := "POLECAT_DONE nux" + body := `Exit: MERGED +Issue: gt-abc123 +MR: gt-mr-xyz +Branch: feature-branch` + + payload, err := ParsePolecatDone(subject, body) + if err != nil { + t.Fatalf("ParsePolecatDone() error = %v", err) + } + + if payload.PolecatName != "nux" { + t.Errorf("PolecatName = %q, want %q", payload.PolecatName, "nux") + } + if payload.Exit != "MERGED" { + t.Errorf("Exit = %q, want %q", payload.Exit, "MERGED") + } + if payload.IssueID != "gt-abc123" { + t.Errorf("IssueID = %q, want %q", payload.IssueID, "gt-abc123") + } + if payload.MRID != "gt-mr-xyz" { + t.Errorf("MRID = %q, want %q", payload.MRID, "gt-mr-xyz") + } + if payload.Branch != "feature-branch" { + t.Errorf("Branch = %q, want %q", payload.Branch, "feature-branch") + } +} + +func TestParsePolecatDone_MinimalBody(t *testing.T) { + subject := "POLECAT_DONE ace" + body := "Exit: DEFERRED" + + payload, err := ParsePolecatDone(subject, body) + if err != nil { + t.Fatalf("ParsePolecatDone() error = %v", err) + } + + if payload.PolecatName != "ace" { + t.Errorf("PolecatName = %q, want %q", payload.PolecatName, "ace") + } + if payload.Exit != "DEFERRED" { + t.Errorf("Exit = %q, want %q", payload.Exit, "DEFERRED") + } + if payload.IssueID != "" { + t.Errorf("IssueID = %q, want empty", payload.IssueID) + } +} + +func TestParsePolecatDone_InvalidSubject(t *testing.T) { + _, err := ParsePolecatDone("Invalid subject", "body") + if err == nil { + t.Error("ParsePolecatDone() expected error for invalid subject") + } +} + +func TestParseHelp(t *testing.T) { + subject := "HELP: Tests failing on CI" + body := `Agent: gastown/polecats/nux +Issue: gt-abc123 +Problem: Unit tests timeout after 30 seconds +Tried: Increased timeout, checked for deadlocks` + + payload, err := ParseHelp(subject, body) + if err != nil { + t.Fatalf("ParseHelp() error = %v", err) + } + + if payload.Topic != "Tests failing on CI" { + t.Errorf("Topic = %q, want %q", payload.Topic, "Tests failing on CI") + } + if payload.Agent != "gastown/polecats/nux" { + t.Errorf("Agent = %q, want %q", payload.Agent, "gastown/polecats/nux") + } + if payload.IssueID != "gt-abc123" { + t.Errorf("IssueID = %q, want %q", payload.IssueID, "gt-abc123") + } + if payload.Problem != "Unit tests timeout after 30 seconds" { + t.Errorf("Problem = %q, want %q", payload.Problem, "Unit tests timeout after 30 seconds") + } + if payload.Tried != "Increased timeout, checked for deadlocks" { + t.Errorf("Tried = %q, want %q", payload.Tried, "Increased timeout, checked for deadlocks") + } +} + +func TestParseHelp_InvalidSubject(t *testing.T) { + _, err := ParseHelp("Not a help message", "body") + if err == nil { + t.Error("ParseHelp() expected error for invalid subject") + } +} + +func TestParseMerged(t *testing.T) { + subject := "MERGED nux" + body := `Branch: feature-nux +Issue: gt-abc123 +Merged-At: 2025-12-30T10:30:00Z` + + payload, err := ParseMerged(subject, body) + if err != nil { + t.Fatalf("ParseMerged() error = %v", err) + } + + if payload.PolecatName != "nux" { + t.Errorf("PolecatName = %q, want %q", payload.PolecatName, "nux") + } + if payload.Branch != "feature-nux" { + t.Errorf("Branch = %q, want %q", payload.Branch, "feature-nux") + } + if payload.IssueID != "gt-abc123" { + t.Errorf("IssueID = %q, want %q", payload.IssueID, "gt-abc123") + } + if payload.MergedAt.IsZero() { + t.Error("MergedAt should not be zero") + } +} + +func TestParseMerged_InvalidSubject(t *testing.T) { + _, err := ParseMerged("Not merged", "body") + if err == nil { + t.Error("ParseMerged() expected error for invalid subject") + } +} + +func TestCleanupWispLabels(t *testing.T) { + labels := CleanupWispLabels("nux", "pending") + + expected := []string{"cleanup", "polecat:nux", "state:pending"} + if len(labels) != len(expected) { + t.Fatalf("CleanupWispLabels() returned %d labels, want %d", len(labels), len(expected)) + } + + for i, label := range labels { + if label != expected[i] { + t.Errorf("labels[%d] = %q, want %q", i, label, expected[i]) + } + } +} + +func TestAssessHelpRequest_GitConflict(t *testing.T) { + payload := &HelpPayload{ + Topic: "Git issue", + Problem: "Merge conflict in main.go", + } + + assessment := AssessHelpRequest(payload) + + if assessment.CanHelp { + t.Error("Should not be able to help with git conflicts") + } + if !assessment.NeedsEscalation { + t.Error("Git conflicts should need escalation") + } +} + +func TestAssessHelpRequest_GitPush(t *testing.T) { + payload := &HelpPayload{ + Topic: "Git push failing", + Problem: "Cannot push to remote", + } + + assessment := AssessHelpRequest(payload) + + if !assessment.CanHelp { + t.Error("Should be able to help with git push issues") + } + if assessment.HelpAction == "" { + t.Error("HelpAction should not be empty") + } +} + +func TestAssessHelpRequest_TestFailures(t *testing.T) { + payload := &HelpPayload{ + Topic: "Test failures", + Problem: "Tests fail on CI", + } + + assessment := AssessHelpRequest(payload) + + if assessment.CanHelp { + t.Error("Should not be able to help with test failures") + } + if !assessment.NeedsEscalation { + t.Error("Test failures should need escalation") + } +} + +func TestAssessHelpRequest_RequirementsUnclear(t *testing.T) { + payload := &HelpPayload{ + Topic: "Requirements unclear", + Problem: "Don't understand the requirements for this task", + } + + assessment := AssessHelpRequest(payload) + + if assessment.CanHelp { + t.Error("Should not be able to help with unclear requirements") + } + if !assessment.NeedsEscalation { + t.Error("Unclear requirements should need escalation") + } +} + +func TestAssessHelpRequest_BuildIssues(t *testing.T) { + payload := &HelpPayload{ + Topic: "Build failing", + Problem: "Cannot compile the project", + } + + assessment := AssessHelpRequest(payload) + + if !assessment.CanHelp { + t.Error("Should be able to help with build issues") + } +}