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,309 @@
// Package beads provides escalation bead management.
package beads
import (
"encoding/json"
"errors"
"fmt"
"os"
"strings"
"time"
)
// 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.)
}
// EscalationState constants for bead status tracking.
const (
EscalationOpen = "open" // Unacknowledged
EscalationAcked = "acked" // Acknowledged but not resolved
EscalationClosed = "closed" // Resolved/closed
)
// FormatEscalationDescription creates a description string from escalation fields.
func FormatEscalationDescription(title string, fields *EscalationFields) string {
if fields == nil {
return title
}
var lines []string
lines = append(lines, title)
lines = append(lines, "")
lines = append(lines, fmt.Sprintf("severity: %s", fields.Severity))
lines = append(lines, fmt.Sprintf("reason: %s", fields.Reason))
lines = append(lines, fmt.Sprintf("escalated_by: %s", fields.EscalatedBy))
lines = append(lines, fmt.Sprintf("escalated_at: %s", fields.EscalatedAt))
if fields.AckedBy != "" {
lines = append(lines, fmt.Sprintf("acked_by: %s", fields.AckedBy))
} else {
lines = append(lines, "acked_by: null")
}
if fields.AckedAt != "" {
lines = append(lines, fmt.Sprintf("acked_at: %s", fields.AckedAt))
} else {
lines = append(lines, "acked_at: null")
}
if fields.ClosedBy != "" {
lines = append(lines, fmt.Sprintf("closed_by: %s", fields.ClosedBy))
} else {
lines = append(lines, "closed_by: null")
}
if fields.ClosedReason != "" {
lines = append(lines, fmt.Sprintf("closed_reason: %s", fields.ClosedReason))
} else {
lines = append(lines, "closed_reason: null")
}
if fields.RelatedBead != "" {
lines = append(lines, fmt.Sprintf("related_bead: %s", fields.RelatedBead))
} else {
lines = append(lines, "related_bead: null")
}
return strings.Join(lines, "\n")
}
// ParseEscalationFields extracts escalation fields from an issue's description.
func ParseEscalationFields(description string) *EscalationFields {
fields := &EscalationFields{}
for _, line := range strings.Split(description, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
colonIdx := strings.Index(line, ":")
if colonIdx == -1 {
continue
}
key := strings.TrimSpace(line[:colonIdx])
value := strings.TrimSpace(line[colonIdx+1:])
if value == "null" || value == "" {
value = ""
}
switch strings.ToLower(key) {
case "severity":
fields.Severity = value
case "reason":
fields.Reason = value
case "escalated_by":
fields.EscalatedBy = value
case "escalated_at":
fields.EscalatedAt = value
case "acked_by":
fields.AckedBy = value
case "acked_at":
fields.AckedAt = value
case "closed_by":
fields.ClosedBy = value
case "closed_reason":
fields.ClosedReason = value
case "related_bead":
fields.RelatedBead = value
}
}
return fields
}
// CreateEscalationBead creates an escalation bead for tracking escalations.
// The created_by field is populated from BD_ACTOR env var for provenance tracking.
func (b *Beads) CreateEscalationBead(title string, fields *EscalationFields) (*Issue, error) {
description := FormatEscalationDescription(title, fields)
args := []string{"create", "--json",
"--title=" + title,
"--description=" + description,
"--type=task",
"--labels=gt:escalation",
}
// Add severity as a label for easy filtering
if fields != nil && fields.Severity != "" {
args = append(args, fmt.Sprintf("--labels=severity:%s", fields.Severity))
}
// Default actor from BD_ACTOR env var for provenance tracking
if actor := os.Getenv("BD_ACTOR"); actor != "" {
args = append(args, "--actor="+actor)
}
out, err := b.run(args...)
if err != nil {
return nil, err
}
var issue Issue
if err := json.Unmarshal(out, &issue); err != nil {
return nil, fmt.Errorf("parsing bd create output: %w", err)
}
return &issue, nil
}
// AckEscalation acknowledges an escalation bead.
// Sets acked_by and acked_at fields, adds "acked" label.
func (b *Beads) AckEscalation(id, ackedBy string) error {
// First get current issue to preserve other fields
issue, err := b.Show(id)
if err != nil {
return err
}
// Verify it's an escalation
if !HasLabel(issue, "gt:escalation") {
return fmt.Errorf("issue %s is not an escalation bead (missing gt:escalation label)", id)
}
// Parse existing fields
fields := ParseEscalationFields(issue.Description)
fields.AckedBy = ackedBy
fields.AckedAt = time.Now().Format(time.RFC3339)
// Format new description
description := FormatEscalationDescription(issue.Title, fields)
return b.Update(id, UpdateOptions{
Description: &description,
AddLabels: []string{"acked"},
})
}
// CloseEscalation closes an escalation bead with a resolution reason.
// Sets closed_by and closed_reason fields, closes the issue.
func (b *Beads) CloseEscalation(id, closedBy, reason string) error {
// First get current issue to preserve other fields
issue, err := b.Show(id)
if err != nil {
return err
}
// Verify it's an escalation
if !HasLabel(issue, "gt:escalation") {
return fmt.Errorf("issue %s is not an escalation bead (missing gt:escalation label)", id)
}
// Parse existing fields
fields := ParseEscalationFields(issue.Description)
fields.ClosedBy = closedBy
fields.ClosedReason = reason
// Format new description
description := FormatEscalationDescription(issue.Title, fields)
// Update description first
if err := b.Update(id, UpdateOptions{
Description: &description,
AddLabels: []string{"resolved"},
}); err != nil {
return err
}
// Close the issue
_, err = b.run("close", id, "--reason="+reason)
return err
}
// GetEscalationBead retrieves an escalation bead by ID.
// Returns nil if not found.
func (b *Beads) GetEscalationBead(id string) (*Issue, *EscalationFields, error) {
issue, err := b.Show(id)
if err != nil {
if errors.Is(err, ErrNotFound) {
return nil, nil, nil
}
return nil, nil, err
}
if !HasLabel(issue, "gt:escalation") {
return nil, nil, fmt.Errorf("issue %s is not an escalation bead (missing gt:escalation label)", id)
}
fields := ParseEscalationFields(issue.Description)
return issue, fields, nil
}
// ListEscalations returns all open escalation beads.
func (b *Beads) ListEscalations() ([]*Issue, error) {
out, err := b.run("list", "--label=gt:escalation", "--status=open", "--json")
if err != nil {
return nil, err
}
var issues []*Issue
if err := json.Unmarshal(out, &issues); err != nil {
return nil, fmt.Errorf("parsing bd list output: %w", err)
}
return issues, nil
}
// ListEscalationsBySeverity returns open escalation beads filtered by severity.
func (b *Beads) ListEscalationsBySeverity(severity string) ([]*Issue, error) {
out, err := b.run("list",
"--label=gt:escalation",
"--label=severity:"+severity,
"--status=open",
"--json",
)
if err != nil {
return nil, err
}
var issues []*Issue
if err := json.Unmarshal(out, &issues); err != nil {
return nil, fmt.Errorf("parsing bd list output: %w", err)
}
return issues, nil
}
// ListStaleEscalations returns escalations older than the given threshold.
// threshold is a duration string like "1h" or "30m".
func (b *Beads) ListStaleEscalations(threshold time.Duration) ([]*Issue, error) {
// Get all open escalations
escalations, err := b.ListEscalations()
if err != nil {
return nil, err
}
cutoff := time.Now().Add(-threshold)
var stale []*Issue
for _, issue := range escalations {
// Skip acknowledged escalations
if HasLabel(issue, "acked") {
continue
}
// Check if older than threshold
createdAt, err := time.Parse(time.RFC3339, issue.CreatedAt)
if err != nil {
continue // Skip if can't parse
}
if createdAt.Before(cutoff) {
stale = append(stale, issue)
}
}
return stale, nil
}

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

View File

@@ -1265,3 +1265,127 @@ func GetRigPrefix(townRoot, rigName string) string {
prefix := entry.BeadsConfig.Prefix
return strings.TrimSuffix(prefix, "-")
}
// EscalationConfigPath returns the standard path for escalation config in a town.
func EscalationConfigPath(townRoot string) string {
return filepath.Join(townRoot, "config", "escalation.json")
}
// LoadEscalationConfig loads and validates an escalation configuration file.
func LoadEscalationConfig(path string) (*EscalationConfig, error) {
data, err := os.ReadFile(path) //nolint:gosec // G304: path is constructed internally, not from user input
if err != nil {
if os.IsNotExist(err) {
return nil, fmt.Errorf("%w: %s", ErrNotFound, path)
}
return nil, fmt.Errorf("reading escalation config: %w", err)
}
var config EscalationConfig
if err := json.Unmarshal(data, &config); err != nil {
return nil, fmt.Errorf("parsing escalation config: %w", err)
}
if err := validateEscalationConfig(&config); err != nil {
return nil, err
}
return &config, nil
}
// LoadOrCreateEscalationConfig loads the escalation config, creating a default if not found.
func LoadOrCreateEscalationConfig(path string) (*EscalationConfig, error) {
config, err := LoadEscalationConfig(path)
if err != nil {
if errors.Is(err, ErrNotFound) {
return NewEscalationConfig(), nil
}
return nil, err
}
return config, nil
}
// SaveEscalationConfig saves an escalation configuration to a file.
func SaveEscalationConfig(path string, config *EscalationConfig) error {
if err := validateEscalationConfig(config); err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return fmt.Errorf("creating directory: %w", err)
}
data, err := json.MarshalIndent(config, "", " ")
if err != nil {
return fmt.Errorf("encoding escalation config: %w", err)
}
if err := os.WriteFile(path, data, 0644); err != nil { //nolint:gosec // G306: escalation config doesn't contain secrets
return fmt.Errorf("writing escalation config: %w", err)
}
return nil
}
// validateEscalationConfig validates an EscalationConfig.
func validateEscalationConfig(c *EscalationConfig) error {
if c.Type != "escalation" && c.Type != "" {
return fmt.Errorf("%w: expected type 'escalation', got '%s'", ErrInvalidType, c.Type)
}
if c.Version > CurrentEscalationVersion {
return fmt.Errorf("%w: got %d, max supported %d", ErrInvalidVersion, c.Version, CurrentEscalationVersion)
}
// Validate stale_threshold if specified
if c.StaleThreshold != "" {
if _, err := time.ParseDuration(c.StaleThreshold); err != nil {
return fmt.Errorf("invalid stale_threshold: %w", err)
}
}
// Initialize nil maps
if c.SeverityRoutes == nil {
c.SeverityRoutes = make(map[string]EscalationRoute)
}
// Validate severity route keys
validSeverities := map[string]bool{
SeverityCritical: true,
SeverityHigh: true,
SeverityNormal: true,
SeverityLow: true,
}
for severity := range c.SeverityRoutes {
if !validSeverities[severity] {
return fmt.Errorf("%w: unknown severity '%s' (valid: critical, high, normal, low)", ErrMissingField, severity)
}
}
return nil
}
// GetStaleThreshold returns the stale threshold as a time.Duration.
// Returns 1 hour if not configured or invalid.
func (c *EscalationConfig) GetStaleThreshold() time.Duration {
if c.StaleThreshold == "" {
return time.Hour
}
d, err := time.ParseDuration(c.StaleThreshold)
if err != nil {
return time.Hour
}
return d
}
// GetRouteForSeverity returns the escalation route for a given severity.
// Falls back to DefaultTarget if no specific route is configured.
func (c *EscalationConfig) GetRouteForSeverity(severity string) EscalationRoute {
if route, ok := c.SeverityRoutes[severity]; ok {
return route
}
// Fallback to default target
return EscalationRoute{
Targets: []string{c.DefaultTarget},
UseExternal: false,
}
}

View File

@@ -773,3 +773,109 @@ func NewMessagingConfig() *MessagingConfig {
NudgeChannels: make(map[string][]string),
}
}
// EscalationConfig represents the escalation system configuration (config/escalation.json).
// This defines severity-based routing for escalations to different channels.
type EscalationConfig struct {
Type string `json:"type"` // "escalation"
Version int `json:"version"` // schema version
// Enabled controls whether the escalation system is active.
Enabled bool `json:"enabled"`
// DefaultTarget is the address to send escalations when no severity-specific target is set.
// Example: "mayor/"
DefaultTarget string `json:"default_target,omitempty"`
// SeverityRoutes maps severity levels to notification targets.
// Keys: "critical", "high", "normal", "low"
// Values: EscalationRoute with target addresses and optional external channels
SeverityRoutes map[string]EscalationRoute `json:"severity_routes,omitempty"`
// StaleThreshold is the duration after which an unacknowledged escalation is considered stale.
// Format: Go duration string (e.g., "1h", "30m", "24h")
// Default: "1h"
StaleThreshold string `json:"stale_threshold,omitempty"`
// ExternalChannels configures optional external notification channels (email, SMS, etc.)
ExternalChannels *ExternalChannelsConfig `json:"external_channels,omitempty"`
}
// EscalationRoute defines where escalations of a given severity are routed.
type EscalationRoute struct {
// Targets are the internal addresses to notify (e.g., "mayor/", "gastown/witness")
Targets []string `json:"targets"`
// UseExternal enables external channel notifications for this severity.
// If true, checks ExternalChannels config for enabled channels.
UseExternal bool `json:"use_external,omitempty"`
// Channels overrides which external channels to use for this severity.
// If empty and UseExternal is true, uses all enabled channels.
// Example: ["email"] to only use email for high severity
Channels []string `json:"channels,omitempty"`
}
// ExternalChannelsConfig configures external notification channels.
type ExternalChannelsConfig struct {
// Email configuration for email notifications
Email *EmailChannelConfig `json:"email,omitempty"`
// SMS configuration for SMS notifications (future)
SMS *SMSChannelConfig `json:"sms,omitempty"`
}
// EmailChannelConfig configures email notifications.
type EmailChannelConfig struct {
Enabled bool `json:"enabled"`
Recipients []string `json:"recipients,omitempty"` // email addresses
SMTPServer string `json:"smtp_server,omitempty"`
FromAddr string `json:"from_addr,omitempty"`
}
// SMSChannelConfig configures SMS notifications (placeholder for future).
type SMSChannelConfig struct {
Enabled bool `json:"enabled"`
Recipients []string `json:"recipients,omitempty"` // phone numbers
Provider string `json:"provider,omitempty"` // twilio, etc.
}
// CurrentEscalationVersion is the current schema version for EscalationConfig.
const CurrentEscalationVersion = 1
// Escalation severity level constants.
const (
SeverityCritical = "critical" // P0: immediate attention required
SeverityHigh = "high" // P1: urgent, needs attention soon
SeverityNormal = "normal" // P2: standard escalation (default)
SeverityLow = "low" // P3: informational, can wait
)
// NewEscalationConfig creates a new EscalationConfig with sensible defaults.
func NewEscalationConfig() *EscalationConfig {
return &EscalationConfig{
Type: "escalation",
Version: CurrentEscalationVersion,
Enabled: true,
DefaultTarget: "mayor/",
StaleThreshold: "1h",
SeverityRoutes: map[string]EscalationRoute{
SeverityCritical: {
Targets: []string{"mayor/"},
UseExternal: true, // Critical should notify externally by default
},
SeverityHigh: {
Targets: []string{"mayor/"},
UseExternal: false,
},
SeverityNormal: {
Targets: []string{"mayor/"},
UseExternal: false,
},
SeverityLow: {
Targets: []string{"mayor/"},
UseExternal: false,
},
},
}
}

View File

@@ -58,8 +58,10 @@ const (
TypePatrolStarted = "patrol_started"
TypePolecatChecked = "polecat_checked"
TypePolecatNudged = "polecat_nudged"
TypeEscalationSent = "escalation_sent"
TypePatrolComplete = "patrol_complete"
TypeEscalationSent = "escalation_sent"
TypeEscalationAcked = "escalation_acked"
TypeEscalationClosed = "escalation_closed"
TypePatrolComplete = "patrol_complete"
// Merge queue events (emitted by refinery)
TypeMergeStarted = "merge_started"