Files
gastown/internal/cmd/escalate_impl.go
mayor 0d0d2763a8 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>
2026-01-12 00:47:11 -08:00

453 lines
12 KiB
Go
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 ""
}