feat(dnd): Add notification level control (gt-xmsme)
Implements Do Not Disturb mode for Gas Town agents: - Add notification_level to agent bead schema (verbose, normal, muted) - gt dnd [on|off|status] - quick toggle for DND mode - gt notify [verbose|normal|muted] - fine-grained control - gt nudge checks target DND status, skips if muted - --force flag on nudge to override DND This allows agents (especially Mayor) to mute notifications during focused work like swarm coordination. Generated with Claude Code Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 {
|
||||
|
||||
129
internal/cmd/dnd.go
Normal file
129
internal/cmd/dnd.go
Normal file
@@ -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
|
||||
}
|
||||
33
internal/cmd/dnd_test.go
Normal file
33
internal/cmd/dnd_test.go
Normal file
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
131
internal/cmd/notify.go
Normal file
131
internal/cmd/notify.go
Normal file
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user