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:
@@ -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")
|
||||
}
|
||||
|
||||
452
internal/cmd/escalate_impl.go
Normal file
452
internal/cmd/escalate_impl.go
Normal 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 ""
|
||||
}
|
||||
Reference in New Issue
Block a user