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:
dementus
2026-01-01 18:45:22 -08:00
committed by Steve Yegge
parent f883a09317
commit b5ac9a2e55
5 changed files with 446 additions and 7 deletions

View File

@@ -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
View 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
View 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
View 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"))
}
}

View File

@@ -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)
}
}