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:
309
internal/beads/beads_escalation.go
Normal file
309
internal/beads/beads_escalation.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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")
|
|
||||||
}
|
|
||||||
|
|||||||
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 ""
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user