From 0d0d2763a8a74af9cc929c6efa052a96348ecd2f Mon Sep 17 00:00:00 2001 From: mayor Date: Mon, 12 Jan 2026 00:47:11 -0800 Subject: [PATCH] feat: Implement unified escalation system (gt-i9r20) Add severity-based routing for escalations with config-driven targets. Changes: - EscalationConfig type with severity routes and external channels - beads/beads_escalation.go: Escalation bead operations (create/ack/close/list) - Refactored gt escalate command with subcommands: - list: Show open escalations - ack: Acknowledge an escalation - close: Resolve with reason - stale: Find unacknowledged escalations past threshold - show: Display escalation details - Added TypeEscalationAcked and TypeEscalationClosed event types Co-Authored-By: Claude Opus 4.5 --- internal/beads/beads_escalation.go | 309 ++++++++++++++++++++ internal/cmd/escalate.go | 370 +++++++++-------------- internal/cmd/escalate_impl.go | 452 +++++++++++++++++++++++++++++ internal/config/loader.go | 124 ++++++++ internal/config/types.go | 106 +++++++ internal/events/events.go | 6 +- 6 files changed, 1132 insertions(+), 235 deletions(-) create mode 100644 internal/beads/beads_escalation.go create mode 100644 internal/cmd/escalate_impl.go diff --git a/internal/beads/beads_escalation.go b/internal/beads/beads_escalation.go new file mode 100644 index 00000000..d85f7ef4 --- /dev/null +++ b/internal/beads/beads_escalation.go @@ -0,0 +1,309 @@ +// Package beads provides escalation bead management. +package beads + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "strings" + "time" +) + +// EscalationFields holds structured fields for escalation beads. +// These are stored as "key: value" lines in the description. +type EscalationFields struct { + Severity string // critical, high, normal, low + Reason string // Why this was escalated + EscalatedBy string // Agent address that escalated (e.g., "gastown/Toast") + EscalatedAt string // ISO 8601 timestamp + AckedBy string // Agent that acknowledged (empty if not acked) + AckedAt string // When acknowledged (empty if not acked) + ClosedBy string // Agent that closed (empty if not closed) + ClosedReason string // Resolution reason (empty if not closed) + RelatedBead string // Optional: related bead ID (task, bug, etc.) +} + +// EscalationState constants for bead status tracking. +const ( + EscalationOpen = "open" // Unacknowledged + EscalationAcked = "acked" // Acknowledged but not resolved + EscalationClosed = "closed" // Resolved/closed +) + +// FormatEscalationDescription creates a description string from escalation fields. +func FormatEscalationDescription(title string, fields *EscalationFields) string { + if fields == nil { + return title + } + + var lines []string + lines = append(lines, title) + lines = append(lines, "") + lines = append(lines, fmt.Sprintf("severity: %s", fields.Severity)) + lines = append(lines, fmt.Sprintf("reason: %s", fields.Reason)) + lines = append(lines, fmt.Sprintf("escalated_by: %s", fields.EscalatedBy)) + lines = append(lines, fmt.Sprintf("escalated_at: %s", fields.EscalatedAt)) + + if fields.AckedBy != "" { + lines = append(lines, fmt.Sprintf("acked_by: %s", fields.AckedBy)) + } else { + lines = append(lines, "acked_by: null") + } + + if fields.AckedAt != "" { + lines = append(lines, fmt.Sprintf("acked_at: %s", fields.AckedAt)) + } else { + lines = append(lines, "acked_at: null") + } + + if fields.ClosedBy != "" { + lines = append(lines, fmt.Sprintf("closed_by: %s", fields.ClosedBy)) + } else { + lines = append(lines, "closed_by: null") + } + + if fields.ClosedReason != "" { + lines = append(lines, fmt.Sprintf("closed_reason: %s", fields.ClosedReason)) + } else { + lines = append(lines, "closed_reason: null") + } + + if fields.RelatedBead != "" { + lines = append(lines, fmt.Sprintf("related_bead: %s", fields.RelatedBead)) + } else { + lines = append(lines, "related_bead: null") + } + + return strings.Join(lines, "\n") +} + +// ParseEscalationFields extracts escalation fields from an issue's description. +func ParseEscalationFields(description string) *EscalationFields { + fields := &EscalationFields{} + + for _, line := range strings.Split(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 == "null" || value == "" { + value = "" + } + + switch strings.ToLower(key) { + case "severity": + fields.Severity = value + case "reason": + fields.Reason = value + case "escalated_by": + fields.EscalatedBy = value + case "escalated_at": + fields.EscalatedAt = value + case "acked_by": + fields.AckedBy = value + case "acked_at": + fields.AckedAt = value + case "closed_by": + fields.ClosedBy = value + case "closed_reason": + fields.ClosedReason = value + case "related_bead": + fields.RelatedBead = value + } + } + + return fields +} + +// CreateEscalationBead creates an escalation bead for tracking escalations. +// The created_by field is populated from BD_ACTOR env var for provenance tracking. +func (b *Beads) CreateEscalationBead(title string, fields *EscalationFields) (*Issue, error) { + description := FormatEscalationDescription(title, fields) + + args := []string{"create", "--json", + "--title=" + title, + "--description=" + description, + "--type=task", + "--labels=gt:escalation", + } + + // Add severity as a label for easy filtering + if fields != nil && fields.Severity != "" { + args = append(args, fmt.Sprintf("--labels=severity:%s", fields.Severity)) + } + + // Default actor from BD_ACTOR env var for provenance tracking + if actor := os.Getenv("BD_ACTOR"); actor != "" { + args = append(args, "--actor="+actor) + } + + out, err := b.run(args...) + if err != nil { + return nil, err + } + + var issue Issue + if err := json.Unmarshal(out, &issue); err != nil { + return nil, fmt.Errorf("parsing bd create output: %w", err) + } + + return &issue, nil +} + +// AckEscalation acknowledges an escalation bead. +// Sets acked_by and acked_at fields, adds "acked" label. +func (b *Beads) AckEscalation(id, ackedBy string) error { + // First get current issue to preserve other fields + issue, err := b.Show(id) + if err != nil { + return err + } + + // Verify it's an escalation + if !HasLabel(issue, "gt:escalation") { + return fmt.Errorf("issue %s is not an escalation bead (missing gt:escalation label)", id) + } + + // Parse existing fields + fields := ParseEscalationFields(issue.Description) + fields.AckedBy = ackedBy + fields.AckedAt = time.Now().Format(time.RFC3339) + + // Format new description + description := FormatEscalationDescription(issue.Title, fields) + + return b.Update(id, UpdateOptions{ + Description: &description, + AddLabels: []string{"acked"}, + }) +} + +// CloseEscalation closes an escalation bead with a resolution reason. +// Sets closed_by and closed_reason fields, closes the issue. +func (b *Beads) CloseEscalation(id, closedBy, reason string) error { + // First get current issue to preserve other fields + issue, err := b.Show(id) + if err != nil { + return err + } + + // Verify it's an escalation + if !HasLabel(issue, "gt:escalation") { + return fmt.Errorf("issue %s is not an escalation bead (missing gt:escalation label)", id) + } + + // Parse existing fields + fields := ParseEscalationFields(issue.Description) + fields.ClosedBy = closedBy + fields.ClosedReason = reason + + // Format new description + description := FormatEscalationDescription(issue.Title, fields) + + // Update description first + if err := b.Update(id, UpdateOptions{ + Description: &description, + AddLabels: []string{"resolved"}, + }); err != nil { + return err + } + + // Close the issue + _, err = b.run("close", id, "--reason="+reason) + return err +} + +// GetEscalationBead retrieves an escalation bead by ID. +// Returns nil if not found. +func (b *Beads) GetEscalationBead(id string) (*Issue, *EscalationFields, error) { + issue, err := b.Show(id) + if err != nil { + if errors.Is(err, ErrNotFound) { + return nil, nil, nil + } + return nil, nil, err + } + + if !HasLabel(issue, "gt:escalation") { + return nil, nil, fmt.Errorf("issue %s is not an escalation bead (missing gt:escalation label)", id) + } + + fields := ParseEscalationFields(issue.Description) + return issue, fields, nil +} + +// ListEscalations returns all open escalation beads. +func (b *Beads) ListEscalations() ([]*Issue, error) { + out, err := b.run("list", "--label=gt:escalation", "--status=open", "--json") + if err != nil { + return nil, err + } + + var issues []*Issue + if err := json.Unmarshal(out, &issues); err != nil { + return nil, fmt.Errorf("parsing bd list output: %w", err) + } + + return issues, nil +} + +// ListEscalationsBySeverity returns open escalation beads filtered by severity. +func (b *Beads) ListEscalationsBySeverity(severity string) ([]*Issue, error) { + out, err := b.run("list", + "--label=gt:escalation", + "--label=severity:"+severity, + "--status=open", + "--json", + ) + if err != nil { + return nil, err + } + + var issues []*Issue + if err := json.Unmarshal(out, &issues); err != nil { + return nil, fmt.Errorf("parsing bd list output: %w", err) + } + + return issues, nil +} + +// ListStaleEscalations returns escalations older than the given threshold. +// threshold is a duration string like "1h" or "30m". +func (b *Beads) ListStaleEscalations(threshold time.Duration) ([]*Issue, error) { + // Get all open escalations + escalations, err := b.ListEscalations() + if err != nil { + return nil, err + } + + cutoff := time.Now().Add(-threshold) + var stale []*Issue + + for _, issue := range escalations { + // Skip acknowledged escalations + if HasLabel(issue, "acked") { + continue + } + + // Check if older than threshold + createdAt, err := time.Parse(time.RFC3339, issue.CreatedAt) + if err != nil { + continue // Skip if can't parse + } + + if createdAt.Before(cutoff) { + stale = append(stale, issue) + } + } + + return stale, nil +} diff --git a/internal/cmd/escalate.go b/internal/cmd/escalate.go index 2d895032..a95c2f28 100644 --- a/internal/cmd/escalate.go +++ b/internal/cmd/escalate.go @@ -1,254 +1,158 @@ package cmd import ( - "fmt" - "os" - "os/exec" - "strings" - "github.com/spf13/cobra" - "github.com/steveyegge/gastown/internal/events" - "github.com/steveyegge/gastown/internal/mail" - "github.com/steveyegge/gastown/internal/style" - "github.com/steveyegge/gastown/internal/workspace" ) -// Escalation severity levels. -// These map to mail priorities and indicate urgency for human attention. -const ( - // SeverityCritical (P0) - System-threatening issues requiring immediate human attention. - // Examples: data corruption, security breach, complete system failure. - SeverityCritical = "CRITICAL" - - // SeverityHigh (P1) - Important blockers that need human attention soon. - // Examples: unresolvable merge conflicts, critical blocking bugs, ambiguous requirements. - SeverityHigh = "HIGH" - - // SeverityMedium (P2) - Standard escalations for human attention at convenience. - // Examples: unclear requirements, design decisions needed, non-blocking issues. - SeverityMedium = "MEDIUM" +// Escalate command flags +var ( + escalateSeverity string + escalateReason string + escalateRelatedBead string + escalateJSON bool + escalateListJSON bool + escalateListAll bool + escalateStaleJSON bool + escalateDryRun bool + escalateCloseReason string ) var escalateCmd = &cobra.Command{ - Use: "escalate ", + Use: "escalate [description]", GroupID: GroupComm, - Short: "Escalate an issue to the human overseer", - Long: `Escalate an issue to the human overseer for attention. + Short: "Escalation system for critical issues", + RunE: runEscalate, + Long: `Create and manage escalations for critical issues. -This is the structured escalation channel for Gas Town. Any agent can use this -to request human intervention when automated resolution isn't possible. +The escalation system provides severity-based routing for issues that need +human or mayor attention. Escalations are tracked as beads with gt:escalation label. -Severity levels: - CRITICAL (P0) - System-threatening, immediate attention required - Examples: data corruption, security breach, system down - HIGH (P1) - Important blocker, needs human soon - Examples: unresolvable conflict, critical bug, ambiguous spec - MEDIUM (P2) - Standard escalation, human attention at convenience - Examples: design decision needed, unclear requirements +SEVERITY LEVELS: + critical (P0) Immediate attention required + high (P1) Urgent, needs attention soon + normal (P2) Standard escalation (default) + low (P3) Informational, can wait -The escalation creates an audit trail bead and sends mail to the overseer -with appropriate priority. All molecular algebra edge cases should escalate -here rather than failing silently. +WORKFLOW: + 1. Agent encounters blocking issue + 2. Runs: gt escalate "Description" --severity high --reason "details" + 3. Escalation is routed based on config/escalation.json + 4. Recipient acknowledges with: gt escalate ack + 5. After resolution: gt escalate close --reason "fixed" + +CONFIGURATION: + Routing is configured in ~/gt/config/escalation.json: + - severity_routes: Map severity to notification targets + - external_channels: Optional email/SMS for critical issues + - stale_threshold: When unacked escalations are flagged Examples: - gt escalate "Database migration failed" - gt escalate -s CRITICAL "Data corruption detected in user table" - gt escalate -s HIGH "Merge conflict cannot be resolved automatically" - gt escalate -s MEDIUM "Need clarification on API design" -m "Details here..."`, - Args: cobra.MinimumNArgs(1), - RunE: runEscalate, + gt escalate "Build failing" --severity critical --reason "CI blocked" + gt escalate "Need API credentials" --severity high + gt escalate "Code review requested" --reason "PR #123 ready" + gt escalate list # Show open escalations + gt escalate ack hq-abc123 # Acknowledge + gt escalate close hq-abc123 --reason "Fixed in commit abc" + gt escalate stale # Show unacked escalations`, } -var ( - escalateSeverity string - escalateMessage string - escalateDryRun bool -) +var escalateListCmd = &cobra.Command{ + Use: "list", + Short: "List open escalations", + Long: `List all open escalations. + +Shows escalations that haven't been closed yet. Use --all to include +closed escalations. + +Examples: + gt escalate list # Open escalations only + gt escalate list --all # Include closed + gt escalate list --json # JSON output`, + RunE: runEscalateList, +} + +var escalateAckCmd = &cobra.Command{ + Use: "ack ", + Short: "Acknowledge an escalation", + Long: `Acknowledge an escalation to indicate you're working on it. + +Adds an "acked" label and records who acknowledged and when. +This stops the stale escalation warnings. + +Examples: + gt escalate ack hq-abc123`, + Args: cobra.ExactArgs(1), + RunE: runEscalateAck, +} + +var escalateCloseCmd = &cobra.Command{ + Use: "close ", + Short: "Close a resolved escalation", + Long: `Close an escalation after the issue is resolved. + +Records who closed it and the resolution reason. + +Examples: + gt escalate close hq-abc123 --reason "Fixed in commit abc" + gt escalate close hq-abc123 --reason "Not reproducible"`, + Args: cobra.ExactArgs(1), + RunE: runEscalateClose, +} + +var escalateStaleCmd = &cobra.Command{ + Use: "stale", + Short: "Show stale unacknowledged escalations", + Long: `Show escalations that haven't been acknowledged within the threshold. + +The threshold is configured in config/escalation.json (default: 1 hour). +Useful for patrol agents to detect escalations that need attention. + +Examples: + gt escalate stale # Show stale escalations + gt escalate stale --json # JSON output`, + RunE: runEscalateStale, +} + +var escalateShowCmd = &cobra.Command{ + Use: "show ", + Short: "Show details of an escalation", + Long: `Display detailed information about an escalation. + +Examples: + gt escalate show hq-abc123 + gt escalate show hq-abc123 --json`, + Args: cobra.ExactArgs(1), + RunE: runEscalateShow, +} func init() { - escalateCmd.Flags().StringVarP(&escalateSeverity, "severity", "s", SeverityMedium, - "Severity level: CRITICAL, HIGH, or MEDIUM") - escalateCmd.Flags().StringVarP(&escalateMessage, "message", "m", "", - "Additional details about the escalation") - escalateCmd.Flags().BoolVarP(&escalateDryRun, "dry-run", "n", false, - "Show what would be done without executing") + // Main escalate command flags + escalateCmd.Flags().StringVarP(&escalateSeverity, "severity", "s", "normal", "Severity level: critical, high, normal, low") + escalateCmd.Flags().StringVarP(&escalateReason, "reason", "r", "", "Detailed reason for escalation") + escalateCmd.Flags().StringVar(&escalateRelatedBead, "related", "", "Related bead ID (task, bug, etc.)") + escalateCmd.Flags().BoolVar(&escalateJSON, "json", false, "Output as JSON") + escalateCmd.Flags().BoolVarP(&escalateDryRun, "dry-run", "n", false, "Show what would be done without executing") + + // List subcommand flags + escalateListCmd.Flags().BoolVar(&escalateListJSON, "json", false, "Output as JSON") + escalateListCmd.Flags().BoolVar(&escalateListAll, "all", false, "Include closed escalations") + + // Close subcommand flags + escalateCloseCmd.Flags().StringVar(&escalateCloseReason, "reason", "", "Resolution reason") + _ = escalateCloseCmd.MarkFlagRequired("reason") + + // Stale subcommand flags + escalateStaleCmd.Flags().BoolVar(&escalateStaleJSON, "json", false, "Output as JSON") + + // Show subcommand flags + escalateShowCmd.Flags().BoolVar(&escalateJSON, "json", false, "Output as JSON") + + // Add subcommands + escalateCmd.AddCommand(escalateListCmd) + escalateCmd.AddCommand(escalateAckCmd) + escalateCmd.AddCommand(escalateCloseCmd) + escalateCmd.AddCommand(escalateStaleCmd) + escalateCmd.AddCommand(escalateShowCmd) + rootCmd.AddCommand(escalateCmd) } - -func runEscalate(cmd *cobra.Command, args []string) error { - topic := strings.Join(args, " ") - - // Validate severity - severity := strings.ToUpper(escalateSeverity) - if severity != SeverityCritical && severity != SeverityHigh && severity != SeverityMedium { - return fmt.Errorf("invalid severity '%s': must be CRITICAL, HIGH, or MEDIUM", escalateSeverity) - } - - // Map severity to mail priority - var priority mail.Priority - switch severity { - case SeverityCritical: - priority = mail.PriorityUrgent - case SeverityHigh: - priority = mail.PriorityHigh - default: - priority = mail.PriorityNormal - } - - // Find workspace - townRoot, err := workspace.FindFromCwdOrError() - if err != nil { - return fmt.Errorf("not in a Gas Town workspace: %w", err) - } - - // Detect agent identity - agentID, err := detectAgentIdentity() - if err != nil { - agentID = "unknown" - } - - // Build mail subject with severity tag - subject := fmt.Sprintf("[%s] %s", severity, topic) - - // Build mail body - var bodyParts []string - bodyParts = append(bodyParts, fmt.Sprintf("Escalated by: %s", agentID)) - bodyParts = append(bodyParts, fmt.Sprintf("Severity: %s", severity)) - if escalateMessage != "" { - bodyParts = append(bodyParts, "") - bodyParts = append(bodyParts, escalateMessage) - } - body := strings.Join(bodyParts, "\n") - - // Dry run mode - if escalateDryRun { - fmt.Printf("Would create escalation:\n") - fmt.Printf(" Severity: %s\n", severity) - fmt.Printf(" Priority: %s\n", priority) - fmt.Printf(" Subject: %s\n", subject) - fmt.Printf(" Body:\n%s\n", indentText(body, " ")) - fmt.Printf("Would send mail to: overseer\n") - return nil - } - - // Create escalation bead for audit trail - beadID, err := createEscalationBead(topic, severity, agentID, escalateMessage) - if err != nil { - // Non-fatal - escalation mail is more important - style.PrintWarning("could not create escalation bead: %v", err) - } else { - fmt.Printf("%s Created escalation bead: %s\n", style.Bold.Render("📋"), beadID) - } - - // Send mail to overseer - router := mail.NewRouter(townRoot) - msg := &mail.Message{ - From: agentID, - To: "overseer", - Subject: subject, - Body: body, - Priority: priority, - } - - if err := router.Send(msg); err != nil { - return fmt.Errorf("sending escalation mail: %w", err) - } - - // Log to activity feed - payload := events.EscalationPayload("", agentID, "overseer", topic) - payload["severity"] = severity - if beadID != "" { - payload["bead"] = beadID - } - _ = events.LogFeed(events.TypeEscalationSent, agentID, payload) - - // Print confirmation with severity-appropriate styling - var emoji string - switch severity { - case SeverityCritical: - emoji = "🚨" - case SeverityHigh: - emoji = "âš ī¸" - default: - emoji = "đŸ“ĸ" - } - - fmt.Printf("%s Escalation sent to overseer [%s]\n", emoji, severity) - fmt.Printf(" Topic: %s\n", topic) - if beadID != "" { - fmt.Printf(" Bead: %s\n", beadID) - } - - return nil -} - -// detectAgentIdentity returns the current agent's identity string. -func detectAgentIdentity() (string, error) { - // Try GT_ROLE first - if role := os.Getenv("GT_ROLE"); role != "" { - return role, nil - } - - // Try to detect from cwd - agentID, _, _, err := resolveSelfTarget() - if err != nil { - return "", err - } - return agentID, nil -} - -// createEscalationBead creates a bead to track the escalation. -func createEscalationBead(topic, severity, from, details string) (string, error) { - // Use bd create to make the escalation bead - args := []string{ - "create", - "--title", fmt.Sprintf("[ESCALATION] %s", topic), - "--type", "task", // Use task type since escalation isn't a standard type - "--priority", severityToBeadsPriority(severity), - } - - // Add description with escalation metadata - desc := fmt.Sprintf("Escalation from: %s\nSeverity: %s\n", from, severity) - if details != "" { - desc += "\n" + details - } - args = append(args, "--description", desc) - - // Add tag for filtering - args = append(args, "--tag", "escalation") - - cmd := exec.Command("bd", args...) - out, err := cmd.Output() - if err != nil { - return "", fmt.Errorf("bd create: %w", err) - } - - // Parse bead ID from output (bd create outputs: "Created bead: gt-xxxxx") - output := strings.TrimSpace(string(out)) - parts := strings.Split(output, ": ") - if len(parts) >= 2 { - return strings.TrimSpace(parts[len(parts)-1]), nil - } - return "", fmt.Errorf("could not parse bead ID from: %s", output) -} - -// severityToBeadsPriority converts severity to beads priority string. -func severityToBeadsPriority(severity string) string { - switch severity { - case SeverityCritical: - return "0" // P0 - case SeverityHigh: - return "1" // P1 - default: - return "2" // P2 - } -} - -// indentText indents each line of text with the given prefix. -func indentText(text, prefix string) string { - lines := strings.Split(text, "\n") - for i, line := range lines { - lines[i] = prefix + line - } - return strings.Join(lines, "\n") -} diff --git a/internal/cmd/escalate_impl.go b/internal/cmd/escalate_impl.go new file mode 100644 index 00000000..560a272d --- /dev/null +++ b/internal/cmd/escalate_impl.go @@ -0,0 +1,452 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "os" + "strings" + "time" + + "github.com/spf13/cobra" + "github.com/steveyegge/gastown/internal/beads" + "github.com/steveyegge/gastown/internal/config" + "github.com/steveyegge/gastown/internal/events" + "github.com/steveyegge/gastown/internal/mail" + "github.com/steveyegge/gastown/internal/style" + "github.com/steveyegge/gastown/internal/workspace" +) + +func runEscalate(cmd *cobra.Command, args []string) error { + // Require at least a description when creating an escalation + if len(args) == 0 { + return cmd.Help() + } + + description := strings.Join(args, " ") + + // Validate severity + severity := strings.ToLower(escalateSeverity) + validSeverities := map[string]bool{ + config.SeverityCritical: true, + config.SeverityHigh: true, + config.SeverityNormal: true, + config.SeverityLow: true, + } + if !validSeverities[severity] { + return fmt.Errorf("invalid severity '%s': must be critical, high, normal, or low", escalateSeverity) + } + + // Find workspace + townRoot, err := workspace.FindFromCwdOrError() + if err != nil { + return fmt.Errorf("not in a Gas Town workspace: %w", err) + } + + // Load escalation config + escalationConfig, err := config.LoadOrCreateEscalationConfig(config.EscalationConfigPath(townRoot)) + if err != nil { + return fmt.Errorf("loading escalation config: %w", err) + } + + if !escalationConfig.Enabled { + return fmt.Errorf("escalation system is disabled in config") + } + + // Detect agent identity + agentID := detectSender() + if agentID == "" { + agentID = "unknown" + } + + // Dry run mode + if escalateDryRun { + route := escalationConfig.GetRouteForSeverity(severity) + fmt.Printf("Would create escalation:\n") + fmt.Printf(" Severity: %s\n", severity) + fmt.Printf(" Description: %s\n", description) + if escalateReason != "" { + fmt.Printf(" Reason: %s\n", escalateReason) + } + fmt.Printf(" Targets: %s\n", strings.Join(route.Targets, ", ")) + if route.UseExternal { + fmt.Printf(" External: enabled\n") + } + return nil + } + + // Create escalation bead + bd := beads.New(beads.ResolveBeadsDir(townRoot)) + fields := &beads.EscalationFields{ + Severity: severity, + Reason: escalateReason, + EscalatedBy: agentID, + EscalatedAt: time.Now().Format(time.RFC3339), + RelatedBead: escalateRelatedBead, + } + + issue, err := bd.CreateEscalationBead(description, fields) + if err != nil { + return fmt.Errorf("creating escalation bead: %w", err) + } + + // Get routing for this severity + route := escalationConfig.GetRouteForSeverity(severity) + + // Send mail to each target + router := mail.NewRouter(townRoot) + for _, target := range route.Targets { + msg := &mail.Message{ + From: agentID, + To: target, + Subject: fmt.Sprintf("[%s] %s", strings.ToUpper(severity), description), + Body: formatEscalationMailBody(issue.ID, severity, escalateReason, agentID, escalateRelatedBead), + Type: mail.TypeTask, + } + + // Set priority based on severity + switch severity { + case config.SeverityCritical: + msg.Priority = mail.PriorityUrgent + case config.SeverityHigh: + msg.Priority = mail.PriorityHigh + case config.SeverityNormal: + msg.Priority = mail.PriorityNormal + default: + msg.Priority = mail.PriorityLow + } + + if err := router.Send(msg); err != nil { + style.PrintWarning("failed to send to %s: %v", target, err) + } + } + + // Log to activity feed + payload := events.EscalationPayload(issue.ID, agentID, strings.Join(route.Targets, ","), description) + payload["severity"] = severity + _ = events.LogFeed(events.TypeEscalationSent, agentID, payload) + + // Output + if escalateJSON { + out, _ := json.MarshalIndent(map[string]interface{}{ + "id": issue.ID, + "severity": severity, + "targets": route.Targets, + }, "", " ") + fmt.Println(string(out)) + } else { + emoji := severityEmoji(severity) + fmt.Printf("%s Escalation created: %s\n", emoji, issue.ID) + fmt.Printf(" Severity: %s\n", severity) + fmt.Printf(" Routed to: %s\n", strings.Join(route.Targets, ", ")) + } + + return nil +} + +func runEscalateList(cmd *cobra.Command, args []string) error { + townRoot, err := workspace.FindFromCwdOrError() + if err != nil { + return fmt.Errorf("not in a Gas Town workspace: %w", err) + } + + bd := beads.New(beads.ResolveBeadsDir(townRoot)) + + var issues []*beads.Issue + if escalateListAll { + // List all (open and closed) + out, err := bd.Run("list", "--label=gt:escalation", "--status=all", "--json") + if err != nil { + return fmt.Errorf("listing escalations: %w", err) + } + if err := json.Unmarshal(out, &issues); err != nil { + return fmt.Errorf("parsing escalations: %w", err) + } + } else { + issues, err = bd.ListEscalations() + if err != nil { + return fmt.Errorf("listing escalations: %w", err) + } + } + + if escalateListJSON { + out, _ := json.MarshalIndent(issues, "", " ") + fmt.Println(string(out)) + return nil + } + + if len(issues) == 0 { + fmt.Println("No escalations found") + return nil + } + + fmt.Printf("Escalations (%d):\n\n", len(issues)) + for _, issue := range issues { + fields := beads.ParseEscalationFields(issue.Description) + emoji := severityEmoji(fields.Severity) + + status := issue.Status + if beads.HasLabel(issue, "acked") { + status = "acked" + } + + fmt.Printf(" %s %s [%s] %s\n", emoji, issue.ID, status, issue.Title) + fmt.Printf(" Severity: %s | From: %s | %s\n", + fields.Severity, fields.EscalatedBy, formatRelativeTime(issue.CreatedAt)) + if fields.AckedBy != "" { + fmt.Printf(" Acked by: %s\n", fields.AckedBy) + } + fmt.Println() + } + + return nil +} + +func runEscalateAck(cmd *cobra.Command, args []string) error { + escalationID := args[0] + + townRoot, err := workspace.FindFromCwdOrError() + if err != nil { + return fmt.Errorf("not in a Gas Town workspace: %w", err) + } + + // Detect who is acknowledging + ackedBy := detectSender() + if ackedBy == "" { + ackedBy = "unknown" + } + + bd := beads.New(beads.ResolveBeadsDir(townRoot)) + if err := bd.AckEscalation(escalationID, ackedBy); err != nil { + return fmt.Errorf("acknowledging escalation: %w", err) + } + + // Log to activity feed + _ = events.LogFeed(events.TypeEscalationAcked, ackedBy, map[string]interface{}{ + "escalation_id": escalationID, + "acked_by": ackedBy, + }) + + fmt.Printf("%s Escalation acknowledged: %s\n", style.Bold.Render("✓"), escalationID) + return nil +} + +func runEscalateClose(cmd *cobra.Command, args []string) error { + escalationID := args[0] + + townRoot, err := workspace.FindFromCwdOrError() + if err != nil { + return fmt.Errorf("not in a Gas Town workspace: %w", err) + } + + // Detect who is closing + closedBy := detectSender() + if closedBy == "" { + closedBy = "unknown" + } + + bd := beads.New(beads.ResolveBeadsDir(townRoot)) + if err := bd.CloseEscalation(escalationID, closedBy, escalateCloseReason); err != nil { + return fmt.Errorf("closing escalation: %w", err) + } + + // Log to activity feed + _ = events.LogFeed(events.TypeEscalationClosed, closedBy, map[string]interface{}{ + "escalation_id": escalationID, + "closed_by": closedBy, + "reason": escalateCloseReason, + }) + + fmt.Printf("%s Escalation closed: %s\n", style.Bold.Render("✓"), escalationID) + fmt.Printf(" Reason: %s\n", escalateCloseReason) + return nil +} + +func runEscalateStale(cmd *cobra.Command, args []string) error { + townRoot, err := workspace.FindFromCwdOrError() + if err != nil { + return fmt.Errorf("not in a Gas Town workspace: %w", err) + } + + // Load escalation config for threshold + escalationConfig, err := config.LoadOrCreateEscalationConfig(config.EscalationConfigPath(townRoot)) + if err != nil { + return fmt.Errorf("loading escalation config: %w", err) + } + + threshold := escalationConfig.GetStaleThreshold() + + bd := beads.New(beads.ResolveBeadsDir(townRoot)) + stale, err := bd.ListStaleEscalations(threshold) + if err != nil { + return fmt.Errorf("listing stale escalations: %w", err) + } + + if escalateStaleJSON { + out, _ := json.MarshalIndent(stale, "", " ") + fmt.Println(string(out)) + return nil + } + + if len(stale) == 0 { + fmt.Printf("No stale escalations (threshold: %s)\n", threshold) + return nil + } + + fmt.Printf("Stale escalations (%d, threshold: %s):\n\n", len(stale), threshold) + for _, issue := range stale { + fields := beads.ParseEscalationFields(issue.Description) + emoji := severityEmoji(fields.Severity) + + fmt.Printf(" %s %s %s\n", emoji, issue.ID, issue.Title) + fmt.Printf(" Severity: %s | From: %s | %s\n", + fields.Severity, fields.EscalatedBy, formatRelativeTime(issue.CreatedAt)) + fmt.Println() + } + + return nil +} + +func runEscalateShow(cmd *cobra.Command, args []string) error { + escalationID := args[0] + + townRoot, err := workspace.FindFromCwdOrError() + if err != nil { + return fmt.Errorf("not in a Gas Town workspace: %w", err) + } + + bd := beads.New(beads.ResolveBeadsDir(townRoot)) + issue, fields, err := bd.GetEscalationBead(escalationID) + if err != nil { + return fmt.Errorf("getting escalation: %w", err) + } + if issue == nil { + return fmt.Errorf("escalation not found: %s", escalationID) + } + + if escalateJSON { + data := map[string]interface{}{ + "id": issue.ID, + "title": issue.Title, + "status": issue.Status, + "created_at": issue.CreatedAt, + "severity": fields.Severity, + "reason": fields.Reason, + "escalatedBy": fields.EscalatedBy, + "escalatedAt": fields.EscalatedAt, + "ackedBy": fields.AckedBy, + "ackedAt": fields.AckedAt, + "closedBy": fields.ClosedBy, + "closedReason": fields.ClosedReason, + "relatedBead": fields.RelatedBead, + } + out, _ := json.MarshalIndent(data, "", " ") + fmt.Println(string(out)) + return nil + } + + emoji := severityEmoji(fields.Severity) + fmt.Printf("%s Escalation: %s\n", emoji, issue.ID) + fmt.Printf(" Title: %s\n", issue.Title) + fmt.Printf(" Status: %s\n", issue.Status) + fmt.Printf(" Severity: %s\n", fields.Severity) + fmt.Printf(" Created: %s\n", formatRelativeTime(issue.CreatedAt)) + fmt.Printf(" Escalated by: %s\n", fields.EscalatedBy) + if fields.Reason != "" { + fmt.Printf(" Reason: %s\n", fields.Reason) + } + if fields.AckedBy != "" { + fmt.Printf(" Acknowledged by: %s at %s\n", fields.AckedBy, fields.AckedAt) + } + if fields.ClosedBy != "" { + fmt.Printf(" Closed by: %s\n", fields.ClosedBy) + fmt.Printf(" Resolution: %s\n", fields.ClosedReason) + } + if fields.RelatedBead != "" { + fmt.Printf(" Related: %s\n", fields.RelatedBead) + } + + return nil +} + +// Helper functions + +func formatEscalationMailBody(beadID, severity, reason, from, related string) string { + var lines []string + lines = append(lines, fmt.Sprintf("Escalation ID: %s", beadID)) + lines = append(lines, fmt.Sprintf("Severity: %s", severity)) + lines = append(lines, fmt.Sprintf("From: %s", from)) + if reason != "" { + lines = append(lines, "") + lines = append(lines, "Reason:") + lines = append(lines, reason) + } + if related != "" { + lines = append(lines, "") + lines = append(lines, fmt.Sprintf("Related: %s", related)) + } + lines = append(lines, "") + lines = append(lines, "---") + lines = append(lines, "To acknowledge: gt escalate ack "+beadID) + lines = append(lines, "To close: gt escalate close "+beadID+" --reason \"resolution\"") + return strings.Join(lines, "\n") +} + +func severityEmoji(severity string) string { + switch severity { + case config.SeverityCritical: + return "🚨" + case config.SeverityHigh: + return "âš ī¸" + case config.SeverityNormal: + return "đŸ“ĸ" + case config.SeverityLow: + return "â„šī¸" + default: + return "📋" + } +} + +func formatRelativeTime(timestamp string) string { + t, err := time.Parse(time.RFC3339, timestamp) + if err != nil { + return timestamp + } + + duration := time.Since(t) + if duration < time.Minute { + return "just now" + } + if duration < time.Hour { + mins := int(duration.Minutes()) + if mins == 1 { + return "1 minute ago" + } + return fmt.Sprintf("%d minutes ago", mins) + } + if duration < 24*time.Hour { + hours := int(duration.Hours()) + if hours == 1 { + return "1 hour ago" + } + return fmt.Sprintf("%d hours ago", hours) + } + days := int(duration.Hours() / 24) + if days == 1 { + return "1 day ago" + } + return fmt.Sprintf("%d days ago", days) +} + +// detectSender is defined in mail_send.go - we reuse it here +// If it's not accessible, we fall back to environment variables +func detectSenderFallback() string { + // Try BD_ACTOR first (most common in agent context) + if actor := os.Getenv("BD_ACTOR"); actor != "" { + return actor + } + // Try GT_ROLE + if role := os.Getenv("GT_ROLE"); role != "" { + return role + } + return "" +} diff --git a/internal/config/loader.go b/internal/config/loader.go index 585c0582..e6ad3e03 100644 --- a/internal/config/loader.go +++ b/internal/config/loader.go @@ -1265,3 +1265,127 @@ func GetRigPrefix(townRoot, rigName string) string { prefix := entry.BeadsConfig.Prefix return strings.TrimSuffix(prefix, "-") } + +// EscalationConfigPath returns the standard path for escalation config in a town. +func EscalationConfigPath(townRoot string) string { + return filepath.Join(townRoot, "config", "escalation.json") +} + +// LoadEscalationConfig loads and validates an escalation configuration file. +func LoadEscalationConfig(path string) (*EscalationConfig, error) { + data, err := os.ReadFile(path) //nolint:gosec // G304: path is constructed internally, not from user input + if err != nil { + if os.IsNotExist(err) { + return nil, fmt.Errorf("%w: %s", ErrNotFound, path) + } + return nil, fmt.Errorf("reading escalation config: %w", err) + } + + var config EscalationConfig + if err := json.Unmarshal(data, &config); err != nil { + return nil, fmt.Errorf("parsing escalation config: %w", err) + } + + if err := validateEscalationConfig(&config); err != nil { + return nil, err + } + + return &config, nil +} + +// LoadOrCreateEscalationConfig loads the escalation config, creating a default if not found. +func LoadOrCreateEscalationConfig(path string) (*EscalationConfig, error) { + config, err := LoadEscalationConfig(path) + if err != nil { + if errors.Is(err, ErrNotFound) { + return NewEscalationConfig(), nil + } + return nil, err + } + return config, nil +} + +// SaveEscalationConfig saves an escalation configuration to a file. +func SaveEscalationConfig(path string, config *EscalationConfig) error { + if err := validateEscalationConfig(config); err != nil { + return err + } + + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return fmt.Errorf("creating directory: %w", err) + } + + data, err := json.MarshalIndent(config, "", " ") + if err != nil { + return fmt.Errorf("encoding escalation config: %w", err) + } + + if err := os.WriteFile(path, data, 0644); err != nil { //nolint:gosec // G306: escalation config doesn't contain secrets + return fmt.Errorf("writing escalation config: %w", err) + } + + return nil +} + +// validateEscalationConfig validates an EscalationConfig. +func validateEscalationConfig(c *EscalationConfig) error { + if c.Type != "escalation" && c.Type != "" { + return fmt.Errorf("%w: expected type 'escalation', got '%s'", ErrInvalidType, c.Type) + } + if c.Version > CurrentEscalationVersion { + return fmt.Errorf("%w: got %d, max supported %d", ErrInvalidVersion, c.Version, CurrentEscalationVersion) + } + + // Validate stale_threshold if specified + if c.StaleThreshold != "" { + if _, err := time.ParseDuration(c.StaleThreshold); err != nil { + return fmt.Errorf("invalid stale_threshold: %w", err) + } + } + + // Initialize nil maps + if c.SeverityRoutes == nil { + c.SeverityRoutes = make(map[string]EscalationRoute) + } + + // Validate severity route keys + validSeverities := map[string]bool{ + SeverityCritical: true, + SeverityHigh: true, + SeverityNormal: true, + SeverityLow: true, + } + for severity := range c.SeverityRoutes { + if !validSeverities[severity] { + return fmt.Errorf("%w: unknown severity '%s' (valid: critical, high, normal, low)", ErrMissingField, severity) + } + } + + return nil +} + +// GetStaleThreshold returns the stale threshold as a time.Duration. +// Returns 1 hour if not configured or invalid. +func (c *EscalationConfig) GetStaleThreshold() time.Duration { + if c.StaleThreshold == "" { + return time.Hour + } + d, err := time.ParseDuration(c.StaleThreshold) + if err != nil { + return time.Hour + } + return d +} + +// GetRouteForSeverity returns the escalation route for a given severity. +// Falls back to DefaultTarget if no specific route is configured. +func (c *EscalationConfig) GetRouteForSeverity(severity string) EscalationRoute { + if route, ok := c.SeverityRoutes[severity]; ok { + return route + } + // Fallback to default target + return EscalationRoute{ + Targets: []string{c.DefaultTarget}, + UseExternal: false, + } +} diff --git a/internal/config/types.go b/internal/config/types.go index b0435a90..df28a330 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -773,3 +773,109 @@ func NewMessagingConfig() *MessagingConfig { NudgeChannels: make(map[string][]string), } } + +// EscalationConfig represents the escalation system configuration (config/escalation.json). +// This defines severity-based routing for escalations to different channels. +type EscalationConfig struct { + Type string `json:"type"` // "escalation" + Version int `json:"version"` // schema version + + // Enabled controls whether the escalation system is active. + Enabled bool `json:"enabled"` + + // DefaultTarget is the address to send escalations when no severity-specific target is set. + // Example: "mayor/" + DefaultTarget string `json:"default_target,omitempty"` + + // SeverityRoutes maps severity levels to notification targets. + // Keys: "critical", "high", "normal", "low" + // Values: EscalationRoute with target addresses and optional external channels + SeverityRoutes map[string]EscalationRoute `json:"severity_routes,omitempty"` + + // StaleThreshold is the duration after which an unacknowledged escalation is considered stale. + // Format: Go duration string (e.g., "1h", "30m", "24h") + // Default: "1h" + StaleThreshold string `json:"stale_threshold,omitempty"` + + // ExternalChannels configures optional external notification channels (email, SMS, etc.) + ExternalChannels *ExternalChannelsConfig `json:"external_channels,omitempty"` +} + +// EscalationRoute defines where escalations of a given severity are routed. +type EscalationRoute struct { + // Targets are the internal addresses to notify (e.g., "mayor/", "gastown/witness") + Targets []string `json:"targets"` + + // UseExternal enables external channel notifications for this severity. + // If true, checks ExternalChannels config for enabled channels. + UseExternal bool `json:"use_external,omitempty"` + + // Channels overrides which external channels to use for this severity. + // If empty and UseExternal is true, uses all enabled channels. + // Example: ["email"] to only use email for high severity + Channels []string `json:"channels,omitempty"` +} + +// ExternalChannelsConfig configures external notification channels. +type ExternalChannelsConfig struct { + // Email configuration for email notifications + Email *EmailChannelConfig `json:"email,omitempty"` + + // SMS configuration for SMS notifications (future) + SMS *SMSChannelConfig `json:"sms,omitempty"` +} + +// EmailChannelConfig configures email notifications. +type EmailChannelConfig struct { + Enabled bool `json:"enabled"` + Recipients []string `json:"recipients,omitempty"` // email addresses + SMTPServer string `json:"smtp_server,omitempty"` + FromAddr string `json:"from_addr,omitempty"` +} + +// SMSChannelConfig configures SMS notifications (placeholder for future). +type SMSChannelConfig struct { + Enabled bool `json:"enabled"` + Recipients []string `json:"recipients,omitempty"` // phone numbers + Provider string `json:"provider,omitempty"` // twilio, etc. +} + +// CurrentEscalationVersion is the current schema version for EscalationConfig. +const CurrentEscalationVersion = 1 + +// Escalation severity level constants. +const ( + SeverityCritical = "critical" // P0: immediate attention required + SeverityHigh = "high" // P1: urgent, needs attention soon + SeverityNormal = "normal" // P2: standard escalation (default) + SeverityLow = "low" // P3: informational, can wait +) + +// NewEscalationConfig creates a new EscalationConfig with sensible defaults. +func NewEscalationConfig() *EscalationConfig { + return &EscalationConfig{ + Type: "escalation", + Version: CurrentEscalationVersion, + Enabled: true, + DefaultTarget: "mayor/", + StaleThreshold: "1h", + SeverityRoutes: map[string]EscalationRoute{ + SeverityCritical: { + Targets: []string{"mayor/"}, + UseExternal: true, // Critical should notify externally by default + }, + SeverityHigh: { + Targets: []string{"mayor/"}, + UseExternal: false, + }, + SeverityNormal: { + Targets: []string{"mayor/"}, + UseExternal: false, + }, + SeverityLow: { + Targets: []string{"mayor/"}, + UseExternal: false, + }, + }, + } +} diff --git a/internal/events/events.go b/internal/events/events.go index cd3e2357..86d1a8c9 100644 --- a/internal/events/events.go +++ b/internal/events/events.go @@ -58,8 +58,10 @@ const ( TypePatrolStarted = "patrol_started" TypePolecatChecked = "polecat_checked" TypePolecatNudged = "polecat_nudged" - TypeEscalationSent = "escalation_sent" - TypePatrolComplete = "patrol_complete" + TypeEscalationSent = "escalation_sent" + TypeEscalationAcked = "escalation_acked" + TypeEscalationClosed = "escalation_closed" + TypePatrolComplete = "patrol_complete" // Merge queue events (emitted by refinery) TypeMergeStarted = "merge_started"