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 <noreply@anthropic.com>
This commit is contained in:
mayor
2026-01-12 00:47:11 -08:00
committed by beads/crew/emma
parent ea5d72a07b
commit 0d0d2763a8
6 changed files with 1132 additions and 235 deletions

View File

@@ -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 ""
}