diff --git a/internal/beads/beads.go b/internal/beads/beads.go index 99da8da3..d45c24cb 100644 --- a/internal/beads/beads.go +++ b/internal/beads/beads.go @@ -770,15 +770,23 @@ func (b *Beads) IsBeadsRepo() bool { // AgentFields holds structured fields for agent beads. // These are stored as "key: value" lines in the description. type AgentFields struct { - RoleType string // polecat, witness, refinery, deacon, mayor - Rig string // Rig name (empty for global agents like mayor/deacon) - AgentState string // spawning, working, done, stuck - HookBead string // Currently pinned work bead ID - RoleBead string // Role definition bead ID (canonical location; may not exist yet) - CleanupStatus string // ZFC: polecat self-reports git state (clean, has_uncommitted, has_stash, has_unpushed) - ActiveMR string // Currently active merge request bead ID (for traceability) + RoleType string // polecat, witness, refinery, deacon, mayor + Rig string // Rig name (empty for global agents like mayor/deacon) + AgentState string // spawning, working, done, stuck + HookBead string // Currently pinned work bead ID + RoleBead string // Role definition bead ID (canonical location; may not exist yet) + CleanupStatus string // ZFC: polecat self-reports git state (clean, has_uncommitted, has_stash, has_unpushed) + ActiveMR string // Currently active merge request bead ID (for traceability) + NotificationLevel string // DND mode: verbose, normal, muted (default: normal) } +// Notification level constants +const ( + NotifyVerbose = "verbose" // All notifications (mail, convoy events, etc.) + NotifyNormal = "normal" // Important events only (default) + NotifyMuted = "muted" // Silent/DND mode - batch for later +) + // FormatAgentDescription creates a description string from agent fields. func FormatAgentDescription(title string, fields *AgentFields) string { if fields == nil { @@ -822,6 +830,12 @@ func FormatAgentDescription(title string, fields *AgentFields) string { lines = append(lines, "active_mr: null") } + if fields.NotificationLevel != "" { + lines = append(lines, fmt.Sprintf("notification_level: %s", fields.NotificationLevel)) + } else { + lines = append(lines, "notification_level: null") + } + return strings.Join(lines, "\n") } @@ -861,6 +875,8 @@ func ParseAgentFields(description string) *AgentFields { fields.CleanupStatus = value case "active_mr": fields.ActiveMR = value + case "notification_level": + fields.NotificationLevel = value } } @@ -995,6 +1011,47 @@ func (b *Beads) UpdateAgentActiveMR(id string, activeMR string) error { return b.Update(id, UpdateOptions{Description: &description}) } +// UpdateAgentNotificationLevel updates the notification_level field in an agent bead. +// Valid levels: verbose, normal, muted (DND mode). +// Pass empty string to reset to default (normal). +func (b *Beads) UpdateAgentNotificationLevel(id string, level string) error { + // Validate level + if level != "" && level != NotifyVerbose && level != NotifyNormal && level != NotifyMuted { + return fmt.Errorf("invalid notification level %q: must be verbose, normal, or muted", level) + } + + // First get current issue to preserve other fields + issue, err := b.Show(id) + if err != nil { + return err + } + + // Parse existing fields + fields := ParseAgentFields(issue.Description) + fields.NotificationLevel = level + + // Format new description + description := FormatAgentDescription(issue.Title, fields) + + return b.Update(id, UpdateOptions{Description: &description}) +} + +// GetAgentNotificationLevel returns the notification level for an agent. +// Returns "normal" if not set (the default). +func (b *Beads) GetAgentNotificationLevel(id string) (string, error) { + _, fields, err := b.GetAgentBead(id) + if err != nil { + return "", err + } + if fields == nil { + return NotifyNormal, nil + } + if fields.NotificationLevel == "" { + return NotifyNormal, nil + } + return fields.NotificationLevel, nil +} + // DeleteAgentBead permanently deletes an agent bead. // Uses --hard --force for immediate permanent deletion (no tombstone). func (b *Beads) DeleteAgentBead(id string) error { diff --git a/internal/cmd/dnd.go b/internal/cmd/dnd.go new file mode 100644 index 00000000..6c9a9bc6 --- /dev/null +++ b/internal/cmd/dnd.go @@ -0,0 +1,129 @@ +package cmd + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + "github.com/steveyegge/gastown/internal/beads" + "github.com/steveyegge/gastown/internal/style" + "github.com/steveyegge/gastown/internal/workspace" +) + +var dndCmd = &cobra.Command{ + Use: "dnd [on|off|status]", + GroupID: GroupComm, + Short: "Toggle Do Not Disturb mode for notifications", + Long: `Control notification level for the current agent. + +Do Not Disturb (DND) mode mutes non-critical notifications, +allowing you to focus on work without interruption. + +Subcommands: + on Enable DND mode (mute notifications) + off Disable DND mode (resume normal notifications) + status Show current notification level + +Without arguments, toggles DND mode. + +Related: gt notify - for fine-grained notification level control`, + Args: cobra.MaximumNArgs(1), + RunE: runDnd, +} + +func init() { + rootCmd.AddCommand(dndCmd) +} + +func runDnd(cmd *cobra.Command, args []string) error { + // Get current agent bead ID + cwd, err := os.Getwd() + if err != nil { + return fmt.Errorf("getting current directory: %w", err) + } + + townRoot, err := workspace.FindFromCwdOrError() + if err != nil { + return fmt.Errorf("not in a Gas Town workspace: %w", err) + } + + roleInfo, err := GetRoleWithContext(cwd, townRoot) + if err != nil { + return fmt.Errorf("determining role: %w", err) + } + + ctx := RoleContext{ + Role: roleInfo.Role, + Rig: roleInfo.Rig, + Polecat: roleInfo.Polecat, + TownRoot: townRoot, + WorkDir: cwd, + } + + agentBeadID := getAgentBeadID(ctx) + if agentBeadID == "" { + return fmt.Errorf("could not determine agent bead ID for role %s", roleInfo.Role) + } + + bd := beads.New(townRoot) + + // Get current level + currentLevel, err := bd.GetAgentNotificationLevel(agentBeadID) + if err != nil { + // Agent bead might not exist yet - default to normal + currentLevel = beads.NotifyNormal + } + + // Determine action + var action string + if len(args) == 0 { + // Toggle: if muted -> normal, else -> muted + if currentLevel == beads.NotifyMuted { + action = "off" + } else { + action = "on" + } + } else { + action = args[0] + } + + switch action { + case "on": + if err := bd.UpdateAgentNotificationLevel(agentBeadID, beads.NotifyMuted); err != nil { + return fmt.Errorf("enabling DND: %w", err) + } + fmt.Printf("%s DND enabled - notifications muted\n", style.SuccessPrefix) + fmt.Printf(" Run %s to resume notifications\n", style.Bold.Render("gt dnd off")) + + case "off": + if err := bd.UpdateAgentNotificationLevel(agentBeadID, beads.NotifyNormal); err != nil { + return fmt.Errorf("disabling DND: %w", err) + } + fmt.Printf("%s DND disabled - notifications resumed\n", style.SuccessPrefix) + + case "status": + levelDisplay := currentLevel + if levelDisplay == "" { + levelDisplay = beads.NotifyNormal + } + + icon := "🔔" + description := "All important notifications" + switch levelDisplay { + case beads.NotifyVerbose: + icon = "🔊" + description = "All notifications (verbose)" + case beads.NotifyMuted: + icon = "🔕" + description = "Notifications muted (DND)" + } + + fmt.Printf("%s Notification level: %s\n", icon, style.Bold.Render(levelDisplay)) + fmt.Printf(" %s\n", style.Dim.Render(description)) + + default: + return fmt.Errorf("unknown action %q: use on, off, or status", action) + } + + return nil +} diff --git a/internal/cmd/dnd_test.go b/internal/cmd/dnd_test.go new file mode 100644 index 00000000..cd792112 --- /dev/null +++ b/internal/cmd/dnd_test.go @@ -0,0 +1,33 @@ +package cmd + +import ( + "testing" +) + +func TestAddressToAgentBeadID(t *testing.T) { + tests := []struct { + address string + expected string + }{ + {"mayor", "gt-mayor"}, + {"deacon", "gt-deacon"}, + {"gastown/witness", "gt-gastown-witness"}, + {"gastown/refinery", "gt-gastown-refinery"}, + {"gastown/alpha", "gt-gastown-polecat-alpha"}, + {"gastown/crew/max", "gt-gastown-crew-max"}, + {"beads/witness", "gt-beads-witness"}, + {"beads/beta", "gt-beads-polecat-beta"}, + // Invalid addresses should return empty string + {"invalid", ""}, + {"", ""}, + } + + for _, tt := range tests { + t.Run(tt.address, func(t *testing.T) { + got := addressToAgentBeadID(tt.address) + if got != tt.expected { + t.Errorf("addressToAgentBeadID(%q) = %q, want %q", tt.address, got, tt.expected) + } + }) + } +} diff --git a/internal/cmd/notify.go b/internal/cmd/notify.go new file mode 100644 index 00000000..2ae9ba38 --- /dev/null +++ b/internal/cmd/notify.go @@ -0,0 +1,131 @@ +package cmd + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + "github.com/steveyegge/gastown/internal/beads" + "github.com/steveyegge/gastown/internal/style" + "github.com/steveyegge/gastown/internal/workspace" +) + +var notifyCmd = &cobra.Command{ + Use: "notify [verbose|normal|muted]", + GroupID: GroupComm, + Short: "Set notification level", + Long: `Control the notification level for the current agent. + +Notification levels: + verbose All notifications (mail, convoy events, status updates) + normal Important notifications only (default) + muted Silent/DND mode - batch notifications for later + +Without arguments, shows the current notification level. + +Examples: + gt notify # Show current level + gt notify verbose # Enable all notifications + gt notify normal # Default notification level + gt notify muted # Enable DND mode + +Related: gt dnd - quick toggle for DND mode`, + Args: cobra.MaximumNArgs(1), + RunE: runNotify, +} + +func init() { + rootCmd.AddCommand(notifyCmd) +} + +func runNotify(cmd *cobra.Command, args []string) error { + // Get current agent bead ID + cwd, err := os.Getwd() + if err != nil { + return fmt.Errorf("getting current directory: %w", err) + } + + townRoot, err := workspace.FindFromCwdOrError() + if err != nil { + return fmt.Errorf("not in a Gas Town workspace: %w", err) + } + + roleInfo, err := GetRoleWithContext(cwd, townRoot) + if err != nil { + return fmt.Errorf("determining role: %w", err) + } + + ctx := RoleContext{ + Role: roleInfo.Role, + Rig: roleInfo.Rig, + Polecat: roleInfo.Polecat, + TownRoot: townRoot, + WorkDir: cwd, + } + + agentBeadID := getAgentBeadID(ctx) + if agentBeadID == "" { + return fmt.Errorf("could not determine agent bead ID for role %s", roleInfo.Role) + } + + bd := beads.New(townRoot) + + // Get current level + currentLevel, err := bd.GetAgentNotificationLevel(agentBeadID) + if err != nil { + // Agent bead might not exist yet - default to normal + currentLevel = beads.NotifyNormal + } + + // No args: show current level + if len(args) == 0 { + showNotificationLevel(currentLevel) + return nil + } + + // Set new level + newLevel := args[0] + switch newLevel { + case beads.NotifyVerbose, beads.NotifyNormal, beads.NotifyMuted: + // Valid level + default: + return fmt.Errorf("invalid level %q: use verbose, normal, or muted", newLevel) + } + + if err := bd.UpdateAgentNotificationLevel(agentBeadID, newLevel); err != nil { + return fmt.Errorf("setting notification level: %w", err) + } + + fmt.Printf("%s Notification level set to %s\n", style.SuccessPrefix, style.Bold.Render(newLevel)) + showNotificationLevelDescription(newLevel) + + return nil +} + +func showNotificationLevel(level string) { + if level == "" { + level = beads.NotifyNormal + } + + icon := "🔔" + switch level { + case beads.NotifyVerbose: + icon = "🔊" + case beads.NotifyMuted: + icon = "🔕" + } + + fmt.Printf("%s Notification level: %s\n", icon, style.Bold.Render(level)) + showNotificationLevelDescription(level) +} + +func showNotificationLevelDescription(level string) { + switch level { + case beads.NotifyVerbose: + fmt.Printf(" %s\n", style.Dim.Render("All notifications: mail, convoy events, status updates")) + case beads.NotifyNormal: + fmt.Printf(" %s\n", style.Dim.Render("Important notifications: convoy landed, escalations")) + case beads.NotifyMuted: + fmt.Printf(" %s\n", style.Dim.Render("Silent mode: notifications batched for later review")) + } +} diff --git a/internal/cmd/nudge.go b/internal/cmd/nudge.go index 983b338c..6e5a71df 100644 --- a/internal/cmd/nudge.go +++ b/internal/cmd/nudge.go @@ -6,6 +6,7 @@ import ( "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/session" @@ -15,10 +16,12 @@ import ( ) var nudgeMessageFlag string +var nudgeForceFlag bool func init() { rootCmd.AddCommand(nudgeCmd) nudgeCmd.Flags().StringVarP(&nudgeMessageFlag, "message", "m", "", "Message to send") + nudgeCmd.Flags().BoolVarP(&nudgeForceFlag, "force", "f", false, "Send even if target has DND enabled") } var nudgeCmd = &cobra.Command{ @@ -46,6 +49,10 @@ Channel syntax: ~/gt/config/messaging.json under "nudge_channels". Patterns like "gastown/polecats/*" are expanded. +DND (Do Not Disturb): + If the target has DND enabled (gt dnd on), the nudge is skipped. + Use --force to override DND and send anyway. + Examples: gt nudge greenplace/furiosa "Check your mail and start working" gt nudge greenplace/alpha -m "What's your status?" @@ -100,6 +107,17 @@ func runNudge(cmd *cobra.Command, args []string) error { // Prefix message with sender message = fmt.Sprintf("[from %s] %s", sender, message) + // Check DND status for target (unless force flag or channel target) + townRoot, _ := workspace.FindFromCwd() + if townRoot != "" && !nudgeForceFlag && !strings.HasPrefix(target, "channel:") { + shouldSend, level, _ := shouldNudgeTarget(townRoot, target, nudgeForceFlag) + if !shouldSend { + fmt.Printf("%s Target has DND enabled (%s) - nudge skipped\n", style.Dim.Render("○"), level) + fmt.Printf(" Use %s to override\n", style.Bold.Render("--force")) + return nil + } + } + t := tmux.NewTmux() // Expand role shortcuts to session names @@ -399,3 +417,74 @@ func resolveNudgePattern(pattern string, agents []*AgentSession) []string { return results } + +// shouldNudgeTarget checks if a nudge should be sent based on the target's notification level. +// Returns (shouldSend bool, level string, err error). +// If force is true, always returns true. +// If the agent bead cannot be found, returns true (fail-open for backward compatibility). +func shouldNudgeTarget(townRoot, targetAddress string, force bool) (bool, string, error) { + if force { + return true, "", nil + } + + // Try to determine agent bead ID from address + agentBeadID := addressToAgentBeadID(targetAddress) + if agentBeadID == "" { + // Can't determine agent bead, allow the nudge + return true, "", nil + } + + bd := beads.New(townRoot) + level, err := bd.GetAgentNotificationLevel(agentBeadID) + if err != nil { + // Agent bead might not exist, allow the nudge + return true, "", nil + } + + // Allow nudge if level is not muted + return level != beads.NotifyMuted, level, nil +} + +// addressToAgentBeadID converts a target address to an agent bead ID. +// Examples: +// - "mayor" -> "gt-mayor" (or similar) +// - "gastown/witness" -> "gt-gastown-witness" +// - "gastown/alpha" -> "gt-gastown-polecat-alpha" +// +// Returns empty string if the address cannot be converted. +func addressToAgentBeadID(address string) string { + // Handle special cases + switch address { + case "mayor": + return "gt-mayor" + case "deacon": + return "gt-deacon" + } + + // Parse rig/role format + if !strings.Contains(address, "/") { + return "" + } + + parts := strings.SplitN(address, "/", 2) + if len(parts) != 2 { + return "" + } + + rig := parts[0] + role := parts[1] + + switch role { + case "witness": + return fmt.Sprintf("gt-%s-witness", rig) + case "refinery": + return fmt.Sprintf("gt-%s-refinery", rig) + default: + // Assume polecat + if strings.HasPrefix(role, "crew/") { + crewName := strings.TrimPrefix(role, "crew/") + return fmt.Sprintf("gt-%s-crew-%s", rig, crewName) + } + return fmt.Sprintf("gt-%s-polecat-%s", rig, role) + } +}