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

@@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"os"
"strconv"
"strings"
"time"
)
@@ -13,15 +14,20 @@ import (
// 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.)
Severity string // critical, high, medium, low
Reason string // Why this was escalated
Source string // Source identifier (e.g., plugin:rebuild-gt, patrol:deacon)
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.)
OriginalSeverity string // Original severity before any re-escalation
ReescalationCount int // Number of times this has been re-escalated
LastReescalatedAt string // When last re-escalated (empty if never)
LastReescalatedBy string // Who last re-escalated (empty if never)
}
// EscalationState constants for bead status tracking.
@@ -42,6 +48,11 @@ func FormatEscalationDescription(title string, fields *EscalationFields) string
lines = append(lines, "")
lines = append(lines, fmt.Sprintf("severity: %s", fields.Severity))
lines = append(lines, fmt.Sprintf("reason: %s", fields.Reason))
if fields.Source != "" {
lines = append(lines, fmt.Sprintf("source: %s", fields.Source))
} else {
lines = append(lines, "source: null")
}
lines = append(lines, fmt.Sprintf("escalated_by: %s", fields.EscalatedBy))
lines = append(lines, fmt.Sprintf("escalated_at: %s", fields.EscalatedAt))
@@ -75,6 +86,24 @@ func FormatEscalationDescription(title string, fields *EscalationFields) string
lines = append(lines, "related_bead: null")
}
// Reescalation fields
if fields.OriginalSeverity != "" {
lines = append(lines, fmt.Sprintf("original_severity: %s", fields.OriginalSeverity))
} else {
lines = append(lines, "original_severity: null")
}
lines = append(lines, fmt.Sprintf("reescalation_count: %d", fields.ReescalationCount))
if fields.LastReescalatedAt != "" {
lines = append(lines, fmt.Sprintf("last_reescalated_at: %s", fields.LastReescalatedAt))
} else {
lines = append(lines, "last_reescalated_at: null")
}
if fields.LastReescalatedBy != "" {
lines = append(lines, fmt.Sprintf("last_reescalated_by: %s", fields.LastReescalatedBy))
} else {
lines = append(lines, "last_reescalated_by: null")
}
return strings.Join(lines, "\n")
}
@@ -104,6 +133,8 @@ func ParseEscalationFields(description string) *EscalationFields {
fields.Severity = value
case "reason":
fields.Reason = value
case "source":
fields.Source = value
case "escalated_by":
fields.EscalatedBy = value
case "escalated_at":
@@ -118,6 +149,16 @@ func ParseEscalationFields(description string) *EscalationFields {
fields.ClosedReason = value
case "related_bead":
fields.RelatedBead = value
case "original_severity":
fields.OriginalSeverity = value
case "reescalation_count":
if n, err := strconv.Atoi(value); err == nil {
fields.ReescalationCount = n
}
case "last_reescalated_at":
fields.LastReescalatedAt = value
case "last_reescalated_by":
fields.LastReescalatedBy = value
}
}
@@ -307,3 +348,94 @@ func (b *Beads) ListStaleEscalations(threshold time.Duration) ([]*Issue, error)
return stale, nil
}
// ReescalationResult holds the result of a reescalation operation.
type ReescalationResult struct {
ID string
Title string
OldSeverity string
NewSeverity string
ReescalationNum int
Skipped bool
SkipReason string
}
// ReescalateEscalation bumps the severity of an escalation and updates tracking fields.
// Returns the new severity if successful, or an error.
// reescalatedBy should be the identity of the agent/process doing the reescalation.
// maxReescalations limits how many times an escalation can be bumped (0 = unlimited).
func (b *Beads) ReescalateEscalation(id, reescalatedBy string, maxReescalations int) (*ReescalationResult, error) {
// Get the escalation
issue, fields, err := b.GetEscalationBead(id)
if err != nil {
return nil, err
}
if issue == nil {
return nil, fmt.Errorf("escalation not found: %s", id)
}
result := &ReescalationResult{
ID: id,
Title: issue.Title,
OldSeverity: fields.Severity,
}
// Check if already at max reescalations
if maxReescalations > 0 && fields.ReescalationCount >= maxReescalations {
result.Skipped = true
result.SkipReason = fmt.Sprintf("already at max reescalations (%d)", maxReescalations)
return result, nil
}
// Check if already at critical (can't bump further)
if fields.Severity == "critical" {
result.Skipped = true
result.SkipReason = "already at critical severity"
result.NewSeverity = "critical"
return result, nil
}
// Save original severity on first reescalation
if fields.OriginalSeverity == "" {
fields.OriginalSeverity = fields.Severity
}
// Bump severity
newSeverity := bumpSeverity(fields.Severity)
fields.Severity = newSeverity
fields.ReescalationCount++
fields.LastReescalatedAt = time.Now().Format(time.RFC3339)
fields.LastReescalatedBy = reescalatedBy
result.NewSeverity = newSeverity
result.ReescalationNum = fields.ReescalationCount
// Format new description
description := FormatEscalationDescription(issue.Title, fields)
// Update the bead with new description and severity label
if err := b.Update(id, UpdateOptions{
Description: &description,
AddLabels: []string{"reescalated", "severity:" + newSeverity},
RemoveLabels: []string{"severity:" + result.OldSeverity},
}); err != nil {
return nil, fmt.Errorf("updating escalation: %w", err)
}
return result, nil
}
// bumpSeverity returns the next higher severity level.
// low -> medium -> high -> critical
func bumpSeverity(severity string) string {
switch severity {
case "low":
return "medium"
case "medium":
return "high"
case "high":
return "critical"
default:
return "critical"
}
}