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 package cmd
import ( import (
"fmt"
"os"
"os/exec"
"strings"
"github.com/spf13/cobra" "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. // Escalate command flags
// These map to mail priorities and indicate urgency for human attention. var (
const ( escalateSeverity string
// SeverityCritical (P0) - System-threatening issues requiring immediate human attention. escalateReason string
// Examples: data corruption, security breach, complete system failure. escalateRelatedBead string
SeverityCritical = "CRITICAL" escalateJSON bool
escalateListJSON bool
// SeverityHigh (P1) - Important blockers that need human attention soon. escalateListAll bool
// Examples: unresolvable merge conflicts, critical blocking bugs, ambiguous requirements. escalateStaleJSON bool
SeverityHigh = "HIGH" escalateDryRun bool
escalateCloseReason string
// SeverityMedium (P2) - Standard escalations for human attention at convenience.
// Examples: unclear requirements, design decisions needed, non-blocking issues.
SeverityMedium = "MEDIUM"
) )
var escalateCmd = &cobra.Command{ var escalateCmd = &cobra.Command{
Use: "escalate <topic>", Use: "escalate [description]",
GroupID: GroupComm, GroupID: GroupComm,
Short: "Escalate an issue to the human overseer", Short: "Escalation system for critical issues",
Long: `Escalate an issue to the human overseer for attention. RunE: runEscalate,
Long: `Create and manage escalations for critical issues.
This is the structured escalation channel for Gas Town. Any agent can use this The escalation system provides severity-based routing for issues that need
to request human intervention when automated resolution isn't possible. human or mayor attention. Escalations are tracked as beads with gt:escalation label.
Severity levels: SEVERITY LEVELS:
CRITICAL (P0) - System-threatening, immediate attention required critical (P0) Immediate attention required
Examples: data corruption, security breach, system down high (P1) Urgent, needs attention soon
HIGH (P1) - Important blocker, needs human soon normal (P2) Standard escalation (default)
Examples: unresolvable conflict, critical bug, ambiguous spec low (P3) Informational, can wait
MEDIUM (P2) - Standard escalation, human attention at convenience
Examples: design decision needed, unclear requirements
The escalation creates an audit trail bead and sends mail to the overseer WORKFLOW:
with appropriate priority. All molecular algebra edge cases should escalate 1. Agent encounters blocking issue
here rather than failing silently. 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: Examples:
gt escalate "Database migration failed" gt escalate "Build failing" --severity critical --reason "CI blocked"
gt escalate -s CRITICAL "Data corruption detected in user table" gt escalate "Need API credentials" --severity high
gt escalate -s HIGH "Merge conflict cannot be resolved automatically" gt escalate "Code review requested" --reason "PR #123 ready"
gt escalate -s MEDIUM "Need clarification on API design" -m "Details here..."`, gt escalate list # Show open escalations
Args: cobra.MinimumNArgs(1), gt escalate ack hq-abc123 # Acknowledge
RunE: runEscalate, gt escalate close hq-abc123 --reason "Fixed in commit abc"
gt escalate stale # Show unacked escalations`,
} }
var ( var escalateListCmd = &cobra.Command{
escalateSeverity string Use: "list",
escalateMessage string Short: "List open escalations",
escalateDryRun bool 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() { func init() {
escalateCmd.Flags().StringVarP(&escalateSeverity, "severity", "s", SeverityMedium, // Main escalate command flags
"Severity level: CRITICAL, HIGH, or MEDIUM") escalateCmd.Flags().StringVarP(&escalateSeverity, "severity", "s", "normal", "Severity level: critical, high, normal, low")
escalateCmd.Flags().StringVarP(&escalateMessage, "message", "m", "", escalateCmd.Flags().StringVarP(&escalateReason, "reason", "r", "", "Detailed reason for escalation")
"Additional details about the escalation") escalateCmd.Flags().StringVar(&escalateRelatedBead, "related", "", "Related bead ID (task, bug, etc.)")
escalateCmd.Flags().BoolVarP(&escalateDryRun, "dry-run", "n", false, escalateCmd.Flags().BoolVar(&escalateJSON, "json", false, "Output as JSON")
"Show what would be done without executing") 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) 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 prefix := entry.BeadsConfig.Prefix
return strings.TrimSuffix(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), 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" TypePatrolStarted = "patrol_started"
TypePolecatChecked = "polecat_checked" TypePolecatChecked = "polecat_checked"
TypePolecatNudged = "polecat_nudged" TypePolecatNudged = "polecat_nudged"
TypeEscalationSent = "escalation_sent" TypeEscalationSent = "escalation_sent"
TypePatrolComplete = "patrol_complete" TypeEscalationAcked = "escalation_acked"
TypeEscalationClosed = "escalation_closed"
TypePatrolComplete = "patrol_complete"
// Merge queue events (emitted by refinery) // Merge queue events (emitted by refinery)
TypeMergeStarted = "merge_started" TypeMergeStarted = "merge_started"