feat(escalate): align config schema with design doc

- Change EscalationConfig to use Routes map with action strings
- Rename severity "normal" to "medium" per design doc
- Move config from config/ to settings/escalation.json
- Add --source flag for escalation source tracking
- Add Source field to EscalationFields
- Add executeExternalActions() for email/sms/slack with warnings
- Add default escalation config creation in gt install
- Add comprehensive unit tests for config loading
- Update help text with correct severity levels and paths

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
mayor
2026-01-12 02:29:56 -08:00
committed by beads/crew/emma
parent b9ecb7b82e
commit 9779ae3190
7 changed files with 882 additions and 163 deletions

View File

@@ -8,6 +8,7 @@ import (
var (
escalateSeverity string
escalateReason string
escalateSource string
escalateRelatedBead string
escalateJSON bool
escalateListJSON bool
@@ -30,30 +31,31 @@ human or mayor attention. Escalations are tracked as beads with gt:escalation la
SEVERITY LEVELS:
critical (P0) Immediate attention required
high (P1) Urgent, needs attention soon
normal (P2) Standard escalation (default)
medium (P2) Standard escalation (default)
low (P3) Informational, can wait
WORKFLOW:
1. Agent encounters blocking issue
2. Runs: gt escalate "Description" --severity high --reason "details"
3. Escalation is routed based on config/escalation.json
3. Escalation is routed based on settings/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
Routing is configured in ~/gt/settings/escalation.json:
- routes: Map severity to action lists (bead, mail:mayor, email:human, sms:human)
- contacts: Human email/SMS for external notifications
- stale_threshold: When unacked escalations are re-escalated (default: 4h)
- max_reescalations: How many times to bump severity (default: 2)
Examples:
gt escalate "Build failing" --severity critical --reason "CI blocked"
gt escalate "Need API credentials" --severity high
gt escalate "Need API credentials" --severity high --source "plugin:rebuild-gt"
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`,
gt escalate stale # Re-escalate stale escalations`,
}
var escalateListCmd = &cobra.Command{
@@ -101,15 +103,23 @@ Examples:
var escalateStaleCmd = &cobra.Command{
Use: "stale",
Short: "Show stale unacknowledged escalations",
Long: `Show escalations that haven't been acknowledged within the threshold.
Short: "Re-escalate stale unacknowledged escalations",
Long: `Find and re-escalate 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.
When run without --dry-run, this command:
1. Finds escalations older than the stale threshold (default: 4h)
2. Bumps their severity: low→medium→high→critical
3. Re-routes them according to the new severity level
4. Sends mail to the new routing targets
Respects max_reescalations from config (default: 2) to prevent infinite escalation.
The threshold is configured in settings/escalation.json.
Examples:
gt escalate stale # Show stale escalations
gt escalate stale --json # JSON output`,
gt escalate stale # Re-escalate stale escalations
gt escalate stale --dry-run # Show what would be done
gt escalate stale --json # JSON output of results`,
RunE: runEscalateStale,
}
@@ -127,8 +137,9 @@ Examples:
func init() {
// Main escalate command flags
escalateCmd.Flags().StringVarP(&escalateSeverity, "severity", "s", "normal", "Severity level: critical, high, normal, low")
escalateCmd.Flags().StringVarP(&escalateSeverity, "severity", "s", "medium", "Severity level: critical, high, medium, low")
escalateCmd.Flags().StringVarP(&escalateReason, "reason", "r", "", "Detailed reason for escalation")
escalateCmd.Flags().StringVar(&escalateSource, "source", "", "Source identifier (e.g., plugin:rebuild-gt, patrol:deacon)")
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")
@@ -143,6 +154,7 @@ func init() {
// Stale subcommand flags
escalateStaleCmd.Flags().BoolVar(&escalateStaleJSON, "json", false, "Output as JSON")
escalateStaleCmd.Flags().BoolVarP(&escalateDryRun, "dry-run", "n", false, "Show what would be re-escalated without acting")
// Show subcommand flags
escalateShowCmd.Flags().BoolVar(&escalateJSON, "json", false, "Output as JSON")

View File

@@ -26,14 +26,8 @@ func runEscalate(cmd *cobra.Command, args []string) error {
// 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)
if !config.IsValidSeverity(severity) {
return fmt.Errorf("invalid severity '%s': must be critical, high, medium, or low", escalateSeverity)
}
// Find workspace
@@ -48,10 +42,6 @@ func runEscalate(cmd *cobra.Command, args []string) error {
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 == "" {
@@ -60,17 +50,19 @@ func runEscalate(cmd *cobra.Command, args []string) error {
// Dry run mode
if escalateDryRun {
route := escalationConfig.GetRouteForSeverity(severity)
actions := escalationConfig.GetRouteForSeverity(severity)
targets := extractMailTargetsFromActions(actions)
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")
if escalateSource != "" {
fmt.Printf(" Source: %s\n", escalateSource)
}
fmt.Printf(" Actions: %s\n", strings.Join(actions, ", "))
fmt.Printf(" Mail targets: %s\n", strings.Join(targets, ", "))
return nil
}
@@ -79,6 +71,7 @@ func runEscalate(cmd *cobra.Command, args []string) error {
fields := &beads.EscalationFields{
Severity: severity,
Reason: escalateReason,
Source: escalateSource,
EscalatedBy: agentID,
EscalatedAt: time.Now().Format(time.RFC3339),
RelatedBead: escalateRelatedBead,
@@ -89,12 +82,13 @@ func runEscalate(cmd *cobra.Command, args []string) error {
return fmt.Errorf("creating escalation bead: %w", err)
}
// Get routing for this severity
route := escalationConfig.GetRouteForSeverity(severity)
// Get routing actions for this severity
actions := escalationConfig.GetRouteForSeverity(severity)
targets := extractMailTargetsFromActions(actions)
// Send mail to each target
// Send mail to each target (actions with "mail:" prefix)
router := mail.NewRouter(townRoot)
for _, target := range route.Targets {
for _, target := range targets {
msg := &mail.Message{
From: agentID,
To: target,
@@ -109,7 +103,7 @@ func runEscalate(cmd *cobra.Command, args []string) error {
msg.Priority = mail.PriorityUrgent
case config.SeverityHigh:
msg.Priority = mail.PriorityHigh
case config.SeverityNormal:
case config.SeverityMedium:
msg.Priority = mail.PriorityNormal
default:
msg.Priority = mail.PriorityLow
@@ -120,24 +114,39 @@ func runEscalate(cmd *cobra.Command, args []string) error {
}
}
// Process external notification actions (email:, sms:, slack)
executeExternalActions(actions, escalationConfig, issue.ID, severity, description)
// Log to activity feed
payload := events.EscalationPayload(issue.ID, agentID, strings.Join(route.Targets, ","), description)
payload := events.EscalationPayload(issue.ID, agentID, strings.Join(targets, ","), description)
payload["severity"] = severity
payload["actions"] = strings.Join(actions, ",")
if escalateSource != "" {
payload["source"] = escalateSource
}
_ = events.LogFeed(events.TypeEscalationSent, agentID, payload)
// Output
if escalateJSON {
out, _ := json.MarshalIndent(map[string]interface{}{
result := map[string]interface{}{
"id": issue.ID,
"severity": severity,
"targets": route.Targets,
}, "", " ")
"actions": actions,
"targets": targets,
}
if escalateSource != "" {
result["source"] = escalateSource
}
out, _ := json.MarshalIndent(result, "", " ")
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, ", "))
if escalateSource != "" {
fmt.Printf(" Source: %s\n", escalateSource)
}
fmt.Printf(" Routed to: %s\n", strings.Join(targets, ", "))
}
return nil
@@ -267,13 +276,14 @@ func runEscalateStale(cmd *cobra.Command, args []string) error {
return fmt.Errorf("not in a Gas Town workspace: %w", err)
}
// Load escalation config for threshold
// Load escalation config for threshold and max reescalations
escalationConfig, err := config.LoadOrCreateEscalationConfig(config.EscalationConfigPath(townRoot))
if err != nil {
return fmt.Errorf("loading escalation config: %w", err)
}
threshold := escalationConfig.GetStaleThreshold()
maxReescalations := escalationConfig.GetMaxReescalations()
bd := beads.New(beads.ResolveBeadsDir(townRoot))
stale, err := bd.ListStaleEscalations(threshold)
@@ -281,31 +291,173 @@ func runEscalateStale(cmd *cobra.Command, args []string) error {
return fmt.Errorf("listing stale escalations: %w", err)
}
if len(stale) == 0 {
if !escalateStaleJSON {
fmt.Printf("No stale escalations (threshold: %s)\n", threshold)
} else {
fmt.Println("[]")
}
return nil
}
// Detect who is reescalating
reescalatedBy := detectSender()
if reescalatedBy == "" {
reescalatedBy = "system"
}
// Dry run mode - just show what would happen
if escalateDryRun {
fmt.Printf("Would re-escalate %d stale escalations (threshold: %s):\n\n", len(stale), threshold)
for _, issue := range stale {
fields := beads.ParseEscalationFields(issue.Description)
newSeverity := getNextSeverity(fields.Severity)
willSkip := maxReescalations > 0 && fields.ReescalationCount >= maxReescalations
if fields.Severity == "critical" {
willSkip = true
}
emoji := severityEmoji(fields.Severity)
if willSkip {
fmt.Printf(" %s %s [SKIP] %s\n", emoji, issue.ID, issue.Title)
if fields.Severity == "critical" {
fmt.Printf(" Already at critical severity\n")
} else {
fmt.Printf(" Already at max reescalations (%d)\n", maxReescalations)
}
} else {
fmt.Printf(" %s %s %s\n", emoji, issue.ID, issue.Title)
fmt.Printf(" %s → %s (reescalation %d/%d)\n",
fields.Severity, newSeverity, fields.ReescalationCount+1, maxReescalations)
}
fmt.Println()
}
return nil
}
// Perform re-escalation
var results []*beads.ReescalationResult
router := mail.NewRouter(townRoot)
for _, issue := range stale {
result, err := bd.ReescalateEscalation(issue.ID, reescalatedBy, maxReescalations)
if err != nil {
style.PrintWarning("failed to reescalate %s: %v", issue.ID, err)
continue
}
results = append(results, result)
// If not skipped, re-route to new severity targets
if !result.Skipped {
actions := escalationConfig.GetRouteForSeverity(result.NewSeverity)
targets := extractMailTargetsFromActions(actions)
// Send mail to each target about the reescalation
for _, target := range targets {
msg := &mail.Message{
From: reescalatedBy,
To: target,
Subject: fmt.Sprintf("[%s→%s] Re-escalated: %s", strings.ToUpper(result.OldSeverity), strings.ToUpper(result.NewSeverity), result.Title),
Body: formatReescalationMailBody(result, reescalatedBy),
Type: mail.TypeTask,
}
// Set priority based on new severity
switch result.NewSeverity {
case config.SeverityCritical:
msg.Priority = mail.PriorityUrgent
case config.SeverityHigh:
msg.Priority = mail.PriorityHigh
case config.SeverityMedium:
msg.Priority = mail.PriorityNormal
default:
msg.Priority = mail.PriorityLow
}
if err := router.Send(msg); err != nil {
style.PrintWarning("failed to send reescalation to %s: %v", target, err)
}
}
// Log to activity feed
_ = events.LogFeed(events.TypeEscalationSent, reescalatedBy, map[string]interface{}{
"escalation_id": result.ID,
"reescalated": true,
"old_severity": result.OldSeverity,
"new_severity": result.NewSeverity,
"reescalation_num": result.ReescalationNum,
"targets": strings.Join(targets, ","),
})
}
}
// Output results
if escalateStaleJSON {
out, _ := json.MarshalIndent(stale, "", " ")
out, _ := json.MarshalIndent(results, "", " ")
fmt.Println(string(out))
return nil
}
if len(stale) == 0 {
fmt.Printf("No stale escalations (threshold: %s)\n", threshold)
reescalated := 0
skipped := 0
for _, r := range results {
if r.Skipped {
skipped++
} else {
reescalated++
}
}
if reescalated == 0 && skipped > 0 {
fmt.Printf("No escalations re-escalated (%d at max level)\n", skipped)
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("🔄 Re-escalated %d stale escalations:\n\n", reescalated)
for _, result := range results {
if result.Skipped {
continue
}
emoji := severityEmoji(result.NewSeverity)
fmt.Printf(" %s %s: %s → %s (reescalation %d)\n",
emoji, result.ID, result.OldSeverity, result.NewSeverity, result.ReescalationNum)
}
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()
if skipped > 0 {
fmt.Printf("\n (%d skipped - at max level)\n", skipped)
}
return nil
}
func getNextSeverity(severity string) string {
switch severity {
case "low":
return "medium"
case "medium":
return "high"
case "high":
return "critical"
default:
return "critical"
}
}
func formatReescalationMailBody(result *beads.ReescalationResult, reescalatedBy string) string {
var lines []string
lines = append(lines, fmt.Sprintf("Escalation ID: %s", result.ID))
lines = append(lines, fmt.Sprintf("Severity bumped: %s → %s", result.OldSeverity, result.NewSeverity))
lines = append(lines, fmt.Sprintf("Reescalation #%d", result.ReescalationNum))
lines = append(lines, fmt.Sprintf("Reescalated by: %s", reescalatedBy))
lines = append(lines, "")
lines = append(lines, "This escalation was not acknowledged within the stale threshold and has been automatically re-escalated to a higher severity.")
lines = append(lines, "")
lines = append(lines, "---")
lines = append(lines, "To acknowledge: gt escalate ack "+result.ID)
lines = append(lines, "To close: gt escalate close "+result.ID+" --reason \"resolution\"")
return strings.Join(lines, "\n")
}
func runEscalateShow(cmd *cobra.Command, args []string) error {
escalationID := args[0]
@@ -370,6 +522,59 @@ func runEscalateShow(cmd *cobra.Command, args []string) error {
// Helper functions
// extractMailTargetsFromActions extracts mail targets from action strings.
// Action format: "mail:target" returns "target"
// E.g., ["bead", "mail:mayor", "email:human"] returns ["mayor"]
func extractMailTargetsFromActions(actions []string) []string {
var targets []string
for _, action := range actions {
if strings.HasPrefix(action, "mail:") {
target := strings.TrimPrefix(action, "mail:")
if target != "" {
targets = append(targets, target)
}
}
}
return targets
}
// executeExternalActions processes external notification actions (email:, sms:, slack).
// For now, this logs warnings if contacts aren't configured - actual sending is future work.
func executeExternalActions(actions []string, cfg *config.EscalationConfig, beadID, severity, description string) {
for _, action := range actions {
switch {
case strings.HasPrefix(action, "email:"):
if cfg.Contacts.HumanEmail == "" {
style.PrintWarning("email action '%s' skipped: contacts.human_email not configured in settings/escalation.json", action)
} else {
// TODO: Implement actual email sending
fmt.Printf(" 📧 Would send email to %s (not yet implemented)\n", cfg.Contacts.HumanEmail)
}
case strings.HasPrefix(action, "sms:"):
if cfg.Contacts.HumanSMS == "" {
style.PrintWarning("sms action '%s' skipped: contacts.human_sms not configured in settings/escalation.json", action)
} else {
// TODO: Implement actual SMS sending
fmt.Printf(" 📱 Would send SMS to %s (not yet implemented)\n", cfg.Contacts.HumanSMS)
}
case action == "slack":
if cfg.Contacts.SlackWebhook == "" {
style.PrintWarning("slack action skipped: contacts.slack_webhook not configured in settings/escalation.json")
} else {
// TODO: Implement actual Slack webhook posting
fmt.Printf(" 💬 Would post to Slack (not yet implemented)\n")
}
case action == "log":
// Log action always succeeds - writes to escalation log file
// TODO: Implement actual log file writing
fmt.Printf(" 📝 Logged to escalation log\n")
}
}
}
func formatEscalationMailBody(beadID, severity, reason, from, related string) string {
var lines []string
lines = append(lines, fmt.Sprintf("Escalation ID: %s", beadID))
@@ -397,7 +602,7 @@ func severityEmoji(severity string) string {
return "🚨"
case config.SeverityHigh:
return "⚠️"
case config.SeverityNormal:
case config.SeverityMedium:
return "📢"
case config.SeverityLow:
return ""

View File

@@ -268,6 +268,14 @@ func runInstall(cmd *cobra.Command, args []string) error {
}
}
// Create default escalation config in settings/escalation.json
escalationPath := config.EscalationConfigPath(absPath)
if err := config.SaveEscalationConfig(escalationPath, config.NewEscalationConfig()); err != nil {
fmt.Printf(" %s Could not create escalation config: %v\n", style.Dim.Render("⚠"), err)
} else {
fmt.Printf(" ✓ Created settings/escalation.json\n")
}
// Provision town-level slash commands (.claude/commands/)
// All agents inherit these via Claude's directory traversal - no per-workspace copies needed.
if err := templates.ProvisionCommands(absPath); err != nil {