From db2b25d78990abc098a6bc77f3f2e10a2fc3d9c3 Mon Sep 17 00:00:00 2001 From: dementus Date: Thu, 1 Jan 2026 17:32:04 -0800 Subject: [PATCH] feat(nudge): Add channel:name syntax for fan-out nudges (gt-p2o6s) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends gt nudge to support channel-based targeting: - gt nudge channel: nudges all channel members - Channels defined in ~/gt/config/messaging.json under "nudge_channels" - Supports patterns: gastown/polecats/*, */witness, gastown/crew/* 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/cmd/nudge.go | 204 ++++++++++++++++++++++++++++++++++++- internal/cmd/nudge_test.go | 112 ++++++++++++++++++++ 2 files changed, 315 insertions(+), 1 deletion(-) create mode 100644 internal/cmd/nudge_test.go diff --git a/internal/cmd/nudge.go b/internal/cmd/nudge.go index 91beebc2..983b338c 100644 --- a/internal/cmd/nudge.go +++ b/internal/cmd/nudge.go @@ -3,8 +3,10 @@ package cmd import ( "fmt" "strings" + "time" "github.com/spf13/cobra" + "github.com/steveyegge/gastown/internal/config" "github.com/steveyegge/gastown/internal/events" "github.com/steveyegge/gastown/internal/session" "github.com/steveyegge/gastown/internal/style" @@ -39,12 +41,18 @@ Role shortcuts (expand to session names): witness Maps to gt--witness (uses current rig) refinery Maps to gt--refinery (uses current rig) +Channel syntax: + channel: Nudges all members of a named channel defined in + ~/gt/config/messaging.json under "nudge_channels". + Patterns like "gastown/polecats/*" are expanded. + Examples: gt nudge greenplace/furiosa "Check your mail and start working" gt nudge greenplace/alpha -m "What's your status?" gt nudge mayor "Status update requested" gt nudge witness "Check polecat health" - gt nudge deacon session-started`, + gt nudge deacon session-started + gt nudge channel:workers "New priority work available"`, Args: cobra.RangeArgs(1, 2), RunE: runNudge, } @@ -62,6 +70,12 @@ func runNudge(cmd *cobra.Command, args []string) error { return fmt.Errorf("message required: use -m flag or provide as second argument") } + // Handle channel syntax: channel: + if strings.HasPrefix(target, "channel:") { + channelName := strings.TrimPrefix(target, "channel:") + return runNudgeChannel(channelName, message) + } + // Identify sender for message prefix sender := "unknown" if roleInfo, err := GetRole(); err == nil { @@ -197,3 +211,191 @@ func runNudge(cmd *cobra.Command, args []string) error { return nil } + +// runNudgeChannel nudges all members of a named channel. +func runNudgeChannel(channelName, message string) error { + // Find town root + townRoot, err := workspace.FindFromCwdOrError() + if err != nil { + return fmt.Errorf("cannot find town root: %w", err) + } + + // Load messaging config + msgConfigPath := config.MessagingConfigPath(townRoot) + msgConfig, err := config.LoadMessagingConfig(msgConfigPath) + if err != nil { + return fmt.Errorf("loading messaging config: %w", err) + } + + // Look up channel + patterns, ok := msgConfig.NudgeChannels[channelName] + if !ok { + return fmt.Errorf("nudge channel %q not found in messaging config", channelName) + } + + if len(patterns) == 0 { + return fmt.Errorf("nudge channel %q has no members", channelName) + } + + // Identify sender for message prefix + sender := "unknown" + if roleInfo, err := GetRole(); err == nil { + switch roleInfo.Role { + case RoleMayor: + sender = "mayor" + case RoleCrew: + sender = fmt.Sprintf("%s/crew/%s", roleInfo.Rig, roleInfo.Polecat) + case RolePolecat: + sender = fmt.Sprintf("%s/%s", roleInfo.Rig, roleInfo.Polecat) + case RoleWitness: + sender = fmt.Sprintf("%s/witness", roleInfo.Rig) + case RoleRefinery: + sender = fmt.Sprintf("%s/refinery", roleInfo.Rig) + case RoleDeacon: + sender = "deacon" + default: + sender = string(roleInfo.Role) + } + } + + // Prefix message with sender + prefixedMessage := fmt.Sprintf("[from %s] %s", sender, message) + + // Get all running sessions for pattern matching + agents, err := getAgentSessions(true) + if err != nil { + return fmt.Errorf("listing sessions: %w", err) + } + + // Resolve patterns to session names + var targets []string + seenTargets := make(map[string]bool) + + for _, pattern := range patterns { + resolved := resolveNudgePattern(pattern, agents) + for _, sessionName := range resolved { + if !seenTargets[sessionName] { + seenTargets[sessionName] = true + targets = append(targets, sessionName) + } + } + } + + if len(targets) == 0 { + fmt.Printf("%s No sessions match channel %q patterns\n", style.WarningPrefix, channelName) + return nil + } + + // Send nudges + t := tmux.NewTmux() + var succeeded, failed int + var failures []string + + fmt.Printf("Nudging channel %q (%d target(s))...\n\n", channelName, len(targets)) + + for i, sessionName := range targets { + if err := t.NudgeSession(sessionName, prefixedMessage); err != nil { + failed++ + failures = append(failures, fmt.Sprintf("%s: %v", sessionName, err)) + fmt.Printf(" %s %s\n", style.ErrorPrefix, sessionName) + } else { + succeeded++ + fmt.Printf(" %s %s\n", style.SuccessPrefix, sessionName) + } + + // Small delay between nudges + if i < len(targets)-1 { + time.Sleep(100 * time.Millisecond) + } + } + + fmt.Println() + + // Log nudge event + _ = events.LogFeed(events.TypeNudge, sender, events.NudgePayload("", "channel:"+channelName, message)) + + if failed > 0 { + fmt.Printf("%s Channel nudge complete: %d succeeded, %d failed\n", + style.WarningPrefix, succeeded, failed) + for _, f := range failures { + fmt.Printf(" %s\n", style.Dim.Render(f)) + } + return fmt.Errorf("%d nudge(s) failed", failed) + } + + fmt.Printf("%s Channel nudge complete: %d target(s) nudged\n", style.SuccessPrefix, succeeded) + return nil +} + +// resolveNudgePattern resolves a nudge channel pattern to session names. +// Patterns can be: +// - Literal: "gastown/witness" → gt-gastown-witness +// - Wildcard: "gastown/polecats/*" → all polecat sessions in gastown +// - Role: "*/witness" → all witness sessions +// - Special: "mayor", "deacon" → gt-mayor, gt-deacon +func resolveNudgePattern(pattern string, agents []*AgentSession) []string { + var results []string + + // Handle special cases + switch pattern { + case "mayor": + return []string{session.MayorSessionName()} + case "deacon": + return []string{DeaconSessionName} + } + + // Parse pattern + if !strings.Contains(pattern, "/") { + // Unknown pattern format + return nil + } + + parts := strings.SplitN(pattern, "/", 2) + rigPattern := parts[0] + targetPattern := parts[1] + + for _, agent := range agents { + // Match rig pattern + if rigPattern != "*" && rigPattern != agent.Rig { + continue + } + + // Match target pattern + if strings.HasPrefix(targetPattern, "polecats/") { + // polecats/* or polecats/ + if agent.Type != AgentPolecat { + continue + } + suffix := strings.TrimPrefix(targetPattern, "polecats/") + if suffix != "*" && suffix != agent.AgentName { + continue + } + } else if strings.HasPrefix(targetPattern, "crew/") { + // crew/* or crew/ + if agent.Type != AgentCrew { + continue + } + suffix := strings.TrimPrefix(targetPattern, "crew/") + if suffix != "*" && suffix != agent.AgentName { + continue + } + } else if targetPattern == "witness" { + if agent.Type != AgentWitness { + continue + } + } else if targetPattern == "refinery" { + if agent.Type != AgentRefinery { + continue + } + } else { + // Assume it's a polecat name (legacy short format) + if agent.Type != AgentPolecat || agent.AgentName != targetPattern { + continue + } + } + + results = append(results, agent.Name) + } + + return results +} diff --git a/internal/cmd/nudge_test.go b/internal/cmd/nudge_test.go new file mode 100644 index 00000000..097c8346 --- /dev/null +++ b/internal/cmd/nudge_test.go @@ -0,0 +1,112 @@ +package cmd + +import ( + "testing" +) + +func TestResolveNudgePattern(t *testing.T) { + // Create test agent sessions + agents := []*AgentSession{ + {Name: "gt-mayor", Type: AgentMayor}, + {Name: "gt-deacon", Type: AgentDeacon}, + {Name: "gt-gastown-witness", Type: AgentWitness, Rig: "gastown"}, + {Name: "gt-gastown-refinery", Type: AgentRefinery, Rig: "gastown"}, + {Name: "gt-gastown-crew-max", Type: AgentCrew, Rig: "gastown", AgentName: "max"}, + {Name: "gt-gastown-crew-jack", Type: AgentCrew, Rig: "gastown", AgentName: "jack"}, + {Name: "gt-gastown-alpha", Type: AgentPolecat, Rig: "gastown", AgentName: "alpha"}, + {Name: "gt-gastown-beta", Type: AgentPolecat, Rig: "gastown", AgentName: "beta"}, + {Name: "gt-beads-witness", Type: AgentWitness, Rig: "beads"}, + {Name: "gt-beads-gamma", Type: AgentPolecat, Rig: "beads", AgentName: "gamma"}, + } + + tests := []struct { + name string + pattern string + expected []string + }{ + { + name: "mayor special case", + pattern: "mayor", + expected: []string{"gt-mayor"}, + }, + { + name: "deacon special case", + pattern: "deacon", + expected: []string{"gt-deacon"}, + }, + { + name: "specific witness", + pattern: "gastown/witness", + expected: []string{"gt-gastown-witness"}, + }, + { + name: "all witnesses", + pattern: "*/witness", + expected: []string{"gt-gastown-witness", "gt-beads-witness"}, + }, + { + name: "specific refinery", + pattern: "gastown/refinery", + expected: []string{"gt-gastown-refinery"}, + }, + { + name: "all polecats in rig", + pattern: "gastown/polecats/*", + expected: []string{"gt-gastown-alpha", "gt-gastown-beta"}, + }, + { + name: "specific polecat", + pattern: "gastown/polecats/alpha", + expected: []string{"gt-gastown-alpha"}, + }, + { + name: "all crew in rig", + pattern: "gastown/crew/*", + expected: []string{"gt-gastown-crew-max", "gt-gastown-crew-jack"}, + }, + { + name: "specific crew member", + pattern: "gastown/crew/max", + expected: []string{"gt-gastown-crew-max"}, + }, + { + name: "legacy polecat format", + pattern: "gastown/alpha", + expected: []string{"gt-gastown-alpha"}, + }, + { + name: "no matches", + pattern: "nonexistent/polecats/*", + expected: nil, + }, + { + name: "invalid pattern", + pattern: "invalid", + expected: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := resolveNudgePattern(tt.pattern, agents) + + if len(got) != len(tt.expected) { + t.Errorf("resolveNudgePattern(%q) returned %d results, want %d: got %v, want %v", + tt.pattern, len(got), len(tt.expected), got, tt.expected) + return + } + + // Check each expected value is present + gotMap := make(map[string]bool) + for _, g := range got { + gotMap[g] = true + } + for _, e := range tt.expected { + if !gotMap[e] { + t.Errorf("resolveNudgePattern(%q) missing expected %q, got %v", + tt.pattern, e, got) + } + } + }) + } +}