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

@@ -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 <topic>",
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 <id>
5. After resolution: gt escalate close <id> --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 <escalation-id>",
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 <escalation-id>",
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 <escalation-id>",
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")
}

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