ScuttleBot

Merge pull request #150 from ConflictHQ/feature/56-shepherd-bot feat: shepherd bot — goal-directed agent coordination

noreply 2026-04-05 18:45 trunk merge
Commit 039edb2bd2226018e4053aa97ac0aa1edb981dc280343afd74b9441ab5df34fa
--- internal/api/policies.go
+++ internal/api/policies.go
@@ -172,10 +172,16 @@
172172
Name: "Steward",
173173
Description: "Acts on sentinel incident reports — issues warnings, mutes, or kicks based on severity. Operators can also issue direct commands via DM.",
174174
Nick: "steward",
175175
JoinAllChannels: true,
176176
},
177
+ {
178
+ ID: "shepherd",
179
+ Name: "Shepherd",
180
+ Description: "Goal-directed agent coordinator. Assigns work, tracks progress, checks in on agents, generates plans using LLM. Configurable with any LLM provider.",
181
+ Nick: "shepherd",
182
+ },
177183
}
178184
179185
// BotCommand describes a single command a bot responds to.
180186
type BotCommand struct {
181187
Command string `json:"command"`
@@ -204,10 +210,19 @@
204210
{Command: "status", Usage: "status", Description: "Show snitch monitoring status and alert history."},
205211
},
206212
"herald": {
207213
{Command: "announce", Usage: "announce #channel <message>", Description: "Post an announcement to a channel."},
208214
},
215
+ "shepherd": {
216
+ {Command: "goal", Usage: "GOAL <description>", Description: "Set a goal for the current channel."},
217
+ {Command: "goals", Usage: "GOALS", Description: "List all active goals."},
218
+ {Command: "done", Usage: "DONE <goal-id>", Description: "Mark a goal as completed."},
219
+ {Command: "status", Usage: "STATUS", Description: "Report progress on current goals (LLM-enhanced)."},
220
+ {Command: "assign", Usage: "ASSIGN <nick> <task>", Description: "Manually assign a task to an agent."},
221
+ {Command: "checkin", Usage: "CHECKIN", Description: "Trigger a check-in round with assigned agents."},
222
+ {Command: "plan", Usage: "PLAN", Description: "Generate a work plan from goals using LLM."},
223
+ },
209224
}
210225
211226
// PolicyStore persists Policies to a JSON file or database.
212227
type PolicyStore struct {
213228
mu sync.RWMutex
214229
--- internal/api/policies.go
+++ internal/api/policies.go
@@ -172,10 +172,16 @@
172 Name: "Steward",
173 Description: "Acts on sentinel incident reports — issues warnings, mutes, or kicks based on severity. Operators can also issue direct commands via DM.",
174 Nick: "steward",
175 JoinAllChannels: true,
176 },
 
 
 
 
 
 
177 }
178
179 // BotCommand describes a single command a bot responds to.
180 type BotCommand struct {
181 Command string `json:"command"`
@@ -204,10 +210,19 @@
204 {Command: "status", Usage: "status", Description: "Show snitch monitoring status and alert history."},
205 },
206 "herald": {
207 {Command: "announce", Usage: "announce #channel <message>", Description: "Post an announcement to a channel."},
208 },
 
 
 
 
 
 
 
 
 
209 }
210
211 // PolicyStore persists Policies to a JSON file or database.
212 type PolicyStore struct {
213 mu sync.RWMutex
214
--- internal/api/policies.go
+++ internal/api/policies.go
@@ -172,10 +172,16 @@
172 Name: "Steward",
173 Description: "Acts on sentinel incident reports — issues warnings, mutes, or kicks based on severity. Operators can also issue direct commands via DM.",
174 Nick: "steward",
175 JoinAllChannels: true,
176 },
177 {
178 ID: "shepherd",
179 Name: "Shepherd",
180 Description: "Goal-directed agent coordinator. Assigns work, tracks progress, checks in on agents, generates plans using LLM. Configurable with any LLM provider.",
181 Nick: "shepherd",
182 },
183 }
184
185 // BotCommand describes a single command a bot responds to.
186 type BotCommand struct {
187 Command string `json:"command"`
@@ -204,10 +210,19 @@
210 {Command: "status", Usage: "status", Description: "Show snitch monitoring status and alert history."},
211 },
212 "herald": {
213 {Command: "announce", Usage: "announce #channel <message>", Description: "Post an announcement to a channel."},
214 },
215 "shepherd": {
216 {Command: "goal", Usage: "GOAL <description>", Description: "Set a goal for the current channel."},
217 {Command: "goals", Usage: "GOALS", Description: "List all active goals."},
218 {Command: "done", Usage: "DONE <goal-id>", Description: "Mark a goal as completed."},
219 {Command: "status", Usage: "STATUS", Description: "Report progress on current goals (LLM-enhanced)."},
220 {Command: "assign", Usage: "ASSIGN <nick> <task>", Description: "Manually assign a task to an agent."},
221 {Command: "checkin", Usage: "CHECKIN", Description: "Trigger a check-in round with assigned agents."},
222 {Command: "plan", Usage: "PLAN", Description: "Generate a work plan from goals using LLM."},
223 },
224 }
225
226 // PolicyStore persists Policies to a JSON file or database.
227 type PolicyStore struct {
228 mu sync.RWMutex
229
--- internal/api/ui/index.html
+++ internal/api/ui/index.html
@@ -2753,10 +2753,17 @@
27532753
{ key:'auto_act', label:'Auto-act', type:'checkbox', hint:'Automatically act on sentinel incident reports' },
27542754
{ key:'warn_on_low', label:'Warn on low', type:'checkbox', hint:'Send a warning notice for low-severity incidents' },
27552755
{ key:'mute_duration_sec', label:'Mute duration (s)',type:'number', placeholder:'600', hint:'How long medium-severity mutes last' },
27562756
{ key:'cooldown_sec', label:'Cooldown (s)', type:'number', placeholder:'300', hint:'Min seconds between automated actions on the same nick' },
27572757
],
2758
+ shepherd: [
2759
+ { key:'backend', label:'LLM backend', type:'llm-backend', hint:'Backend for reasoning and plan generation' },
2760
+ { key:'model', label:'Model override', type:'model-override', backendKey:'backend', hint:'Override the backend default model' },
2761
+ { key:'report_channel', label:'Report channel', type:'text', placeholder:'#ops', hint:'Channel for status reports' },
2762
+ { key:'checkin_interval_sec', label:'Check-in interval (s)', type:'number', placeholder:'0', hint:'Seconds between automatic check-ins (0 = disabled)' },
2763
+ { key:'goal_source', label:'Goal source URL', type:'text', placeholder:'https://github.com/org/repo/milestone/1', hint:'GitHub milestone URL or other goal source' },
2764
+ ],
27582765
};
27592766
27602767
function renderBehConfig(b) {
27612768
const schema = BEHAVIOR_SCHEMAS[b.id];
27622769
if (!schema) return '';
27632770
--- internal/api/ui/index.html
+++ internal/api/ui/index.html
@@ -2753,10 +2753,17 @@
2753 { key:'auto_act', label:'Auto-act', type:'checkbox', hint:'Automatically act on sentinel incident reports' },
2754 { key:'warn_on_low', label:'Warn on low', type:'checkbox', hint:'Send a warning notice for low-severity incidents' },
2755 { key:'mute_duration_sec', label:'Mute duration (s)',type:'number', placeholder:'600', hint:'How long medium-severity mutes last' },
2756 { key:'cooldown_sec', label:'Cooldown (s)', type:'number', placeholder:'300', hint:'Min seconds between automated actions on the same nick' },
2757 ],
 
 
 
 
 
 
 
2758 };
2759
2760 function renderBehConfig(b) {
2761 const schema = BEHAVIOR_SCHEMAS[b.id];
2762 if (!schema) return '';
2763
--- internal/api/ui/index.html
+++ internal/api/ui/index.html
@@ -2753,10 +2753,17 @@
2753 { key:'auto_act', label:'Auto-act', type:'checkbox', hint:'Automatically act on sentinel incident reports' },
2754 { key:'warn_on_low', label:'Warn on low', type:'checkbox', hint:'Send a warning notice for low-severity incidents' },
2755 { key:'mute_duration_sec', label:'Mute duration (s)',type:'number', placeholder:'600', hint:'How long medium-severity mutes last' },
2756 { key:'cooldown_sec', label:'Cooldown (s)', type:'number', placeholder:'300', hint:'Min seconds between automated actions on the same nick' },
2757 ],
2758 shepherd: [
2759 { key:'backend', label:'LLM backend', type:'llm-backend', hint:'Backend for reasoning and plan generation' },
2760 { key:'model', label:'Model override', type:'model-override', backendKey:'backend', hint:'Override the backend default model' },
2761 { key:'report_channel', label:'Report channel', type:'text', placeholder:'#ops', hint:'Channel for status reports' },
2762 { key:'checkin_interval_sec', label:'Check-in interval (s)', type:'number', placeholder:'0', hint:'Seconds between automatic check-ins (0 = disabled)' },
2763 { key:'goal_source', label:'Goal source URL', type:'text', placeholder:'https://github.com/org/repo/milestone/1', hint:'GitHub milestone URL or other goal source' },
2764 ],
2765 };
2766
2767 function renderBehConfig(b) {
2768 const schema = BEHAVIOR_SCHEMAS[b.id];
2769 if (!schema) return '';
2770
--- internal/bots/manager/manager.go
+++ internal/bots/manager/manager.go
@@ -17,10 +17,11 @@
1717
"github.com/conflicthq/scuttlebot/internal/bots/herald"
1818
"github.com/conflicthq/scuttlebot/internal/bots/oracle"
1919
"github.com/conflicthq/scuttlebot/internal/bots/scribe"
2020
"github.com/conflicthq/scuttlebot/internal/bots/scroll"
2121
"github.com/conflicthq/scuttlebot/internal/bots/sentinel"
22
+ "github.com/conflicthq/scuttlebot/internal/bots/shepherd"
2223
"github.com/conflicthq/scuttlebot/internal/bots/snitch"
2324
"github.com/conflicthq/scuttlebot/internal/bots/steward"
2425
"github.com/conflicthq/scuttlebot/internal/bots/systembot"
2526
"github.com/conflicthq/scuttlebot/internal/bots/warden"
2627
"github.com/conflicthq/scuttlebot/internal/llm"
@@ -335,10 +336,45 @@
335336
WarnOnLow: cfgBool(cfg, "warn_on_low", true),
336337
CooldownPerNick: time.Duration(cfgInt(cfg, "cooldown_sec", 300)) * time.Second,
337338
Channels: channels,
338339
}, m.log), nil
339340
341
+ case "shepherd":
342
+ apiKey := cfgStr(cfg, "api_key", "")
343
+ if apiKey == "" {
344
+ if env := cfgStr(cfg, "api_key_env", ""); env != "" {
345
+ apiKey = os.Getenv(env)
346
+ }
347
+ }
348
+ var provider shepherd.LLMProvider
349
+ if apiKey != "" {
350
+ llmCfg := llm.BackendConfig{
351
+ Backend: cfgStr(cfg, "backend", "openai"),
352
+ APIKey: apiKey,
353
+ BaseURL: cfgStr(cfg, "base_url", ""),
354
+ Model: cfgStr(cfg, "model", ""),
355
+ Region: cfgStr(cfg, "region", ""),
356
+ AWSKeyID: cfgStr(cfg, "aws_key_id", ""),
357
+ AWSSecretKey: cfgStr(cfg, "aws_secret_key", ""),
358
+ }
359
+ p, err := llm.New(llmCfg)
360
+ if err != nil {
361
+ return nil, fmt.Errorf("shepherd: build llm provider: %w", err)
362
+ }
363
+ provider = p
364
+ }
365
+ checkinSec := cfgInt(cfg, "checkin_interval_sec", 0)
366
+ return shepherd.New(shepherd.Config{
367
+ IRCAddr: m.ircAddr,
368
+ Nick: spec.Nick,
369
+ Password: pass,
370
+ Channels: channels,
371
+ ReportChannel: cfgStr(cfg, "report_channel", "#ops"),
372
+ CheckinInterval: time.Duration(checkinSec) * time.Second,
373
+ GoalSource: cfgStr(cfg, "goal_source", ""),
374
+ }, provider, m.log), nil
375
+
340376
default:
341377
return nil, fmt.Errorf("unknown bot ID %q", spec.ID)
342378
}
343379
}
344380
345381
346382
ADDED internal/bots/shepherd/shepherd.go
--- internal/bots/manager/manager.go
+++ internal/bots/manager/manager.go
@@ -17,10 +17,11 @@
17 "github.com/conflicthq/scuttlebot/internal/bots/herald"
18 "github.com/conflicthq/scuttlebot/internal/bots/oracle"
19 "github.com/conflicthq/scuttlebot/internal/bots/scribe"
20 "github.com/conflicthq/scuttlebot/internal/bots/scroll"
21 "github.com/conflicthq/scuttlebot/internal/bots/sentinel"
 
22 "github.com/conflicthq/scuttlebot/internal/bots/snitch"
23 "github.com/conflicthq/scuttlebot/internal/bots/steward"
24 "github.com/conflicthq/scuttlebot/internal/bots/systembot"
25 "github.com/conflicthq/scuttlebot/internal/bots/warden"
26 "github.com/conflicthq/scuttlebot/internal/llm"
@@ -335,10 +336,45 @@
335 WarnOnLow: cfgBool(cfg, "warn_on_low", true),
336 CooldownPerNick: time.Duration(cfgInt(cfg, "cooldown_sec", 300)) * time.Second,
337 Channels: channels,
338 }, m.log), nil
339
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
340 default:
341 return nil, fmt.Errorf("unknown bot ID %q", spec.ID)
342 }
343 }
344
345
346 DDED internal/bots/shepherd/shepherd.go
--- internal/bots/manager/manager.go
+++ internal/bots/manager/manager.go
@@ -17,10 +17,11 @@
17 "github.com/conflicthq/scuttlebot/internal/bots/herald"
18 "github.com/conflicthq/scuttlebot/internal/bots/oracle"
19 "github.com/conflicthq/scuttlebot/internal/bots/scribe"
20 "github.com/conflicthq/scuttlebot/internal/bots/scroll"
21 "github.com/conflicthq/scuttlebot/internal/bots/sentinel"
22 "github.com/conflicthq/scuttlebot/internal/bots/shepherd"
23 "github.com/conflicthq/scuttlebot/internal/bots/snitch"
24 "github.com/conflicthq/scuttlebot/internal/bots/steward"
25 "github.com/conflicthq/scuttlebot/internal/bots/systembot"
26 "github.com/conflicthq/scuttlebot/internal/bots/warden"
27 "github.com/conflicthq/scuttlebot/internal/llm"
@@ -335,10 +336,45 @@
336 WarnOnLow: cfgBool(cfg, "warn_on_low", true),
337 CooldownPerNick: time.Duration(cfgInt(cfg, "cooldown_sec", 300)) * time.Second,
338 Channels: channels,
339 }, m.log), nil
340
341 case "shepherd":
342 apiKey := cfgStr(cfg, "api_key", "")
343 if apiKey == "" {
344 if env := cfgStr(cfg, "api_key_env", ""); env != "" {
345 apiKey = os.Getenv(env)
346 }
347 }
348 var provider shepherd.LLMProvider
349 if apiKey != "" {
350 llmCfg := llm.BackendConfig{
351 Backend: cfgStr(cfg, "backend", "openai"),
352 APIKey: apiKey,
353 BaseURL: cfgStr(cfg, "base_url", ""),
354 Model: cfgStr(cfg, "model", ""),
355 Region: cfgStr(cfg, "region", ""),
356 AWSKeyID: cfgStr(cfg, "aws_key_id", ""),
357 AWSSecretKey: cfgStr(cfg, "aws_secret_key", ""),
358 }
359 p, err := llm.New(llmCfg)
360 if err != nil {
361 return nil, fmt.Errorf("shepherd: build llm provider: %w", err)
362 }
363 provider = p
364 }
365 checkinSec := cfgInt(cfg, "checkin_interval_sec", 0)
366 return shepherd.New(shepherd.Config{
367 IRCAddr: m.ircAddr,
368 Nick: spec.Nick,
369 Password: pass,
370 Channels: channels,
371 ReportChannel: cfgStr(cfg, "report_channel", "#ops"),
372 CheckinInterval: time.Duration(checkinSec) * time.Second,
373 GoalSource: cfgStr(cfg, "goal_source", ""),
374 }, provider, m.log), nil
375
376 default:
377 return nil, fmt.Errorf("unknown bot ID %q", spec.ID)
378 }
379 }
380
381
382 DDED internal/bots/shepherd/shepherd.go
--- a/internal/bots/shepherd/shepherd.go
+++ b/internal/bots/shepherd/shepherd.go
@@ -0,0 +1,376 @@
1
+// Package shepherd implements a goal-directed agent coordination bot.
2
+//
3
+// Shepherd monitors channels, tracks agent activity, assigns work from
4
+// configured goal sources, checks in on progress, and reports status.
5
+// It uses an LLM to reason about priorities, detect blockers, and
6
+// generate summaries.
7
+//
8
+// Commands (via DM or channel mention):
9
+//
10
+// GOAL <text> — set a goal for the current channel
11
+// STATUS — report progress on current goals
12
+// ASSIGN <nick> <task> — manually assign a task to an agent
13
+// CHECKIN — trigger a check-in round
14
+// PLAN — generate a work plan from current goals
15
+package shepherd
16
+
17
+import (
18
+ "context"
19
+ "fmt"
20
+ "log/slog"
21
+ "net"
22
+ "strconv"
23
+ "strings"
24
+ "sync"
25
+ "time"
26
+
27
+ "github.com/lrstanley/girc"
28
+
29
+ "github.com/conflicthq/scuttlebot/internal/bots/cmdparse"
30
+)
31
+
32
+const defaultNick = "shepherd"
33
+
34
+// LLMProvider calls a language model for reasoning.
35
+type LLMProvider interface {
36
+ Summarize(ctx context.Context, prompt string) (string, error)
37
+}
38
+
39
+// Config controls shepherd's behaviour.
40
+type Config struct {
41
+ IRCAddr string
42
+ Nick string
43
+ Password string
44
+
45
+ // Channels to join and monitor.
46
+ Channels []string
47
+
48
+ // ReportChannel is where status reports go (e.g. "#ops").
49
+ ReportChannel string
50
+
51
+ // CheckinInterval is how often to check in on agents. 0 = disabled.
52
+ CheckinInterval time.Duration
53
+
54
+ // GoalSource is an optional URL for seeding goals (e.g. GitHub milestone).
55
+ GoalSource string
56
+}
57
+
58
+// Goal is a tracked objective.
59
+type Goal struct {
60
+ ID string `json:"id"`
61
+ Channel string `json:"channel"`
62
+ Description string `json:"description"`
63
+ CreatedAt time.Time `json:"created_at"`
64
+ CreatedBy string `json:"created_by"`
65
+ Status string `json:"status"` // "active", "done", "blocked"
66
+}
67
+
68
+// Assignment tracks which agent is working on what.
69
+type Assignment struct {
70
+ Nick string `json:"nick"`
71
+ Task string `json:"task"`
72
+ Channel string `json:"channel"`
73
+ AssignedAt time.Time `json:"assigned_at"`
74
+ LastUpdate time.Time `json:"last_update"`
75
+}
76
+
77
+// Bot is the shepherd bot.
78
+type Bot struct {
79
+ cfg Config
80
+ llm LLMProvider
81
+ log *slog.Logger
82
+ client *girc.Client
83
+
84
+ mu sync.Mutex
85
+ goals map[string][]Goal // channel → goals
86
+ assignments map[string]*Assignment // nick → assignment
87
+ activity map[string]time.Time // nick → last message time
88
+ history map[string][]string // channel → recent messages for LLM context
89
+}
90
+
91
+// New creates a shepherd bot.
92
+func New(cfg Config, llm LLMProvider, log *slog.Logger) *Bot {
93
+ if cfg.Nick == "" {
94
+ cfg.Nick = defaultNick
95
+ }
96
+ return &Bot{
97
+ cfg: cfg,
98
+ llm: llm,
99
+ log: log,
100
+ goals: make(map[string][]Goal),
101
+ assignments: make(map[string]*Assignment),
102
+ activity: make(map[string]time.Time),
103
+ history: make(map[string][]string),
104
+ }
105
+}
106
+
107
+// Name returns the bot's IRC nick.
108
+func (b *Bot) Name() string { return b.cfg.Nick }
109
+
110
+// Start connects to IRC and begins shepherding. Blocks until ctx is cancelled.
111
+func (b *Bot) Start(ctx context.Context) error {
112
+ host, port, err := splitHostPort(b.cfg.IRCAddr)
113
+ if err != nil {
114
+ return fmt.Errorf("shepherd: %w", err)
115
+ }
116
+
117
+ c := girc.New(girc.Config{
118
+ Server: host,
119
+ Port: port,
120
+ Nick: b.cfg.Nick,
121
+ User: b.cfg.Nick,
122
+ Name: "scuttlebot shepherd",
123
+ SASL: &girc.SASLPlain{User: b.cfg.Nick, Pass: b.cfg.Password},
124
+ PingDelay: 30 * time.Second,
125
+ PingTimeout: 30 * time.Second,
126
+ })
127
+
128
+ router := cmdparse.NewRouter(b.cfg.Nick)
129
+ router.Register(cmdparse.Command{
130
+ Name: "goal",
131
+ Usage: "GOAL <description>",
132
+ Description: "set a goal for the current channel",
133
+ Handler: func(cmdCtx *cmdparse.Context, args string) string {
134
+ return b.handleGoal(cmdCtx.Channel, cmdCtx.Nick, args)
135
+ },
136
+ })
137
+ router.Register(cmdparse.Command{
138
+ Name: "status",
139
+ Usage: "STATUS",
140
+ Description: "report progress on current goals",
141
+ Handler: func(cmdCtx *cmdparse.Context, args string) string {
142
+ return b.handleStatus(ctx, cmdCtx.Channel)
143
+ },
144
+ })
145
+ router.Register(cmdparse.Command{
146
+ Name: "assign",
147
+ Usage: "ASSIGN <nick> <task>",
148
+ Description: "manually assign a task to an agent",
149
+ Handler: func(cmdCtx *cmdparse.Context, args string) string {
150
+ return b.handleAssign(cmdCtx.Channel, args)
151
+ },
152
+ })
153
+ router.Register(cmdparse.Command{
154
+ Name: "checkin",
155
+ Usage: "CHECKIN",
156
+ Description: "trigger a check-in round with all assigned agents",
157
+ Handler: func(cmdCtx *cmdparse.Context, args string) string {
158
+ b.runCheckin(c)
159
+ return "check-in round started"
160
+ },
161
+ })
162
+ router.Register(cmdparse.Command{
163
+ Name: "plan",
164
+ Usage: "PLAN",
165
+ Description: "generate a work plan from current goals using LLM",
166
+ Handler: func(cmdCtx *cmdparse.Context, args string) string {
167
+ return b.handlePlan(ctx, cmdCtx.Channel)
168
+ },
169
+ })
170
+ router.Register(cmdparse.Command{
171
+ Name: "goals",
172
+ Usage: "GOALS",
173
+ Description: "list all active goals for this channel",
174
+ Handler: func(cmdCtx *cmdparse.Context, args string) string {
175
+ return b.handleListGoals(cmdCtx.Channel)
176
+ },
177
+ })
178
+ router.Register(cmdparse.Command{
179
+ Name: "done",
180
+ Usage: "DONE <goal-id>",
181
+ Description: "mark a goal as completed",
182
+ Handler: func(cmdCtx *cmdparse.Context, args string) string {
183
+ return b.handleDone(cmdCtx.Channel, args)
184
+ },
185
+ })
186
+
187
+ c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
188
+ cl.Cmd.Mode(cl.GetNick(), "+B")
189
+ for _, ch := range b.cfg.Channels eportChannel)
190
+ }
191
+ }()
192
+ if b.cfg.ReportChannel != "" {
193
+ cl.Cmd.Join(b.cfg.ReportChannel)
194
+ }
195
+ if b.log != nil {
196
+ b.log.Info("shepherd connected", "channels", b.cfg.Channels)
197
+ }
198
+ })
199
+
200
+ c.Handlers.AddBg(girc.INVITE, func(cl *girc.Client, e girc.Event) {
201
+ if ch := e.Last(); strings.HasPrefix(ch, "#") {
202
+ cl.Cmd.Join(ch)
203
+ }
204
+ })
205
+
206
+ c.Handlers.AddBg(girc.PRIVMSG, func(cl *girc.Client, e girc.Event) {
207
+ if len(e.Params) < 1 || e.Source == nil {
208
+ return
209
+ }
210
+ nick := e.Source.Name
211
+ target := e.Params[0]
212
+ text := strings.TrimSpace(e.Last())
213
+
214
+ // Track activity.
215
+ b.mu.Lock()
216
+ b.activity[nick] = time.Now()
217
+ if strings.HasPrefix(target, "#") {
218
+ hist := b.history[target]
219
+ hist = append(hist, fmt.Sprintf("[%s] %s", nick, text))
220
+ if len(hist) > 100 {
221
+ hist = hist[len(hist)-100:]
222
+ }
223
+ b.history[target] = hist
224
+ }
225
+ b.mu.Unlock()
226
+
227
+ // Dispatch commands.
228
+ if reply := router.Dispatch(nick, target, text); reply != nil {
229
+ cl.Cmd.Message(reply.Target, reply.Text)
230
+ }
231
+ })
232
+
233
+ b.client = c
234
+
235
+ // Start periodic check-in if configured.
236
+ if b.cfg.CheckinInterval > 0 {
237
+ go b.checkinLoop(ctx, c)
238
+ }
239
+
240
+ errCh := make(chan error, 1)
241
+ go func() {
242
+ if err := c.Connect(); err != nil && ctx.Err() == nil {
243
+ errCh <- err
244
+ }
245
+ }()
246
+
247
+ select {
248
+ case <-ctx.Done():
249
+ c.Close()
250
+ return nil
251
+ case err := <-errCh:
252
+ return fmt.Errorf("shepherd: irc: %w", err)
253
+ }
254
+}
255
+
256
+// Stop disconnects the bot.
257
+func (b *Bot) Stop() {
258
+ if b.client != nil {
259
+ b.client.Close()
260
+ }
261
+}
262
+
263
+// --- Command handlers ---
264
+
265
+func (b *Bot) handleGoal(channel, nick, desc string) string {
266
+ desc = strings.TrimSpace(desc)
267
+ if desc == "" {
268
+ return "usage: GOAL <description>"
269
+ }
270
+ b.mu.Lock()
271
+ defer b.mu.Unlock()
272
+ id := fmt.Sprintf("G%d", len(b.goals[channel])+1)
273
+ b.goals[channel] = append(b.goals[channel], Goal{
274
+ ID: id,
275
+ Channel: channel,
276
+ Description: desc,
277
+ CreatedAt: time.Now(),
278
+ CreatedBy: nick,
279
+ Status: "active",
280
+ })
281
+ return fmt.Sprintf("goal %s set: %s", id, desc)
282
+}
283
+
284
+func (b *Bot) handleListGoals(channel string) string {
285
+ b.mu.Lock()
286
+ defer b.mu.Unlock()
287
+ goals := b.goals[channel]
288
+ if len(goals) == 0 {
289
+ return "no goals set for " + channel
290
+ }
291
+ var lines []string
292
+ for _, g := range goals {
293
+ lines = append(lines, fmt.Sprintf("[%s] %s (%s) — %s", g.ID, g.Description, g.Status, g.CreatedBy))
294
+ }
295
+ return strings.Join(lines, " | ")
296
+}
297
+
298
+func (b *Bot) handleDone(channel, goalID string) string {
299
+ goalID = strings.TrimSpace(goalID)
300
+ b.mu.Lock()
301
+ defer b.mu.Unlock()
302
+ for i, g := range b.goals[channel] {
303
+ if strings.EqualFold(g.ID, goalID) {
304
+ b.goals[channel][i].Status = "done"
305
+ return fmt.Sprintf("goal %s marked done: %s", g.ID, g.Description)
306
+ }
307
+ }
308
+ return fmt.Sprintf("goal %q not found in %s", goalID, channel)
309
+}
310
+
311
+func (b *Bot) handleAssign(channel, args string) string {
312
+ parts := strings.SplitN(strings.TrimSpace(args), " ", 2)
313
+ if len(parts) < 2 {
314
+ return "usage: ASSIGN <nick> <task>"
315
+ }
316
+ nick, task := parts[0], parts[1]
317
+ b.mu.Lock()
318
+ b.assignments[nick] = &Assignment{
319
+ Nick: nick,
320
+ Task: task,
321
+ Channel: channel,
322
+ AssignedAt: time.Now(),
323
+ LastUpdate: time.Now(),
324
+ }
325
+ b.mu.Unlock()
326
+ return fmt.Sprintf("assigned %s to %s", nick, task)
327
+}
328
+
329
+func (b *Bot) handleStatus(ctx context.Context, channel string) string {
330
+ b.mu.Lock()
331
+ goals := b.goals[channel]
332
+ var active, done int
333
+ for _, g := range goals {
334
+ if g.Status == "done" {
335
+ done++
336
+ } else {
337
+ active++
338
+ }
339
+ }
340
+ hist := b.history[channel]
341
+ var assignments []string
342
+ for _, a := range b.assignments {
343
+ if a.Channel == channel {
344
+ assignments = append(assignments, fmt.Sprintf("%s: %s", a.Nick, a.Task))
345
+ }
346
+ }
347
+ b.mu.Unlock()
348
+
349
+ summary := fmt.Sprintf("goals: %d active, %d done", active, done)
350
+ if len(assignments) > 0 {
351
+ summary += " | assignments: " + strings.Join(assignments, ", ")
352
+ }
353
+
354
+ // Use LLM for richer summary if available and there's context.
355
+ if b.llm != nil && len(hist) > 5 {
356
+ prompt := fmt.Sprintf("Summarize the current status of work in %s. "+
357
+ "Goals: %d active, %d done. Assignments: %s. "+
358
+ "Recent conversation:\n%s\n\n"+
359
+ "Give a brief status report (2-3 sentences).",
360
+ channel, active, done, strings.Join(assignments, ", "),
361
+ strings.Join(hist[max(0, len(hist)-30):], "\n"))
362
+ if llmSummary, err := b.llm.Summarize(ctx, prompt); err == nil {
363
+ return llmSummary
364
+ }
365
+ }
366
+
367
+ return summary
368
+}
369
+
370
+func (b *Bot) handlePlan(ctx context.Context, channel string) string {
371
+ b.mu.Lock()
372
+ goals := b.goals[channel]
373
+ var goalDescs []string
374
+ for _, g := range goals {
375
+ if g.Status == "active" {
376
+ goalDescs =
--- a/internal/bots/shepherd/shepherd.go
+++ b/internal/bots/shepherd/shepherd.go
@@ -0,0 +1,376 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/internal/bots/shepherd/shepherd.go
+++ b/internal/bots/shepherd/shepherd.go
@@ -0,0 +1,376 @@
1 // Package shepherd implements a goal-directed agent coordination bot.
2 //
3 // Shepherd monitors channels, tracks agent activity, assigns work from
4 // configured goal sources, checks in on progress, and reports status.
5 // It uses an LLM to reason about priorities, detect blockers, and
6 // generate summaries.
7 //
8 // Commands (via DM or channel mention):
9 //
10 // GOAL <text> — set a goal for the current channel
11 // STATUS — report progress on current goals
12 // ASSIGN <nick> <task> — manually assign a task to an agent
13 // CHECKIN — trigger a check-in round
14 // PLAN — generate a work plan from current goals
15 package shepherd
16
17 import (
18 "context"
19 "fmt"
20 "log/slog"
21 "net"
22 "strconv"
23 "strings"
24 "sync"
25 "time"
26
27 "github.com/lrstanley/girc"
28
29 "github.com/conflicthq/scuttlebot/internal/bots/cmdparse"
30 )
31
32 const defaultNick = "shepherd"
33
34 // LLMProvider calls a language model for reasoning.
35 type LLMProvider interface {
36 Summarize(ctx context.Context, prompt string) (string, error)
37 }
38
39 // Config controls shepherd's behaviour.
40 type Config struct {
41 IRCAddr string
42 Nick string
43 Password string
44
45 // Channels to join and monitor.
46 Channels []string
47
48 // ReportChannel is where status reports go (e.g. "#ops").
49 ReportChannel string
50
51 // CheckinInterval is how often to check in on agents. 0 = disabled.
52 CheckinInterval time.Duration
53
54 // GoalSource is an optional URL for seeding goals (e.g. GitHub milestone).
55 GoalSource string
56 }
57
58 // Goal is a tracked objective.
59 type Goal struct {
60 ID string `json:"id"`
61 Channel string `json:"channel"`
62 Description string `json:"description"`
63 CreatedAt time.Time `json:"created_at"`
64 CreatedBy string `json:"created_by"`
65 Status string `json:"status"` // "active", "done", "blocked"
66 }
67
68 // Assignment tracks which agent is working on what.
69 type Assignment struct {
70 Nick string `json:"nick"`
71 Task string `json:"task"`
72 Channel string `json:"channel"`
73 AssignedAt time.Time `json:"assigned_at"`
74 LastUpdate time.Time `json:"last_update"`
75 }
76
77 // Bot is the shepherd bot.
78 type Bot struct {
79 cfg Config
80 llm LLMProvider
81 log *slog.Logger
82 client *girc.Client
83
84 mu sync.Mutex
85 goals map[string][]Goal // channel → goals
86 assignments map[string]*Assignment // nick → assignment
87 activity map[string]time.Time // nick → last message time
88 history map[string][]string // channel → recent messages for LLM context
89 }
90
91 // New creates a shepherd bot.
92 func New(cfg Config, llm LLMProvider, log *slog.Logger) *Bot {
93 if cfg.Nick == "" {
94 cfg.Nick = defaultNick
95 }
96 return &Bot{
97 cfg: cfg,
98 llm: llm,
99 log: log,
100 goals: make(map[string][]Goal),
101 assignments: make(map[string]*Assignment),
102 activity: make(map[string]time.Time),
103 history: make(map[string][]string),
104 }
105 }
106
107 // Name returns the bot's IRC nick.
108 func (b *Bot) Name() string { return b.cfg.Nick }
109
110 // Start connects to IRC and begins shepherding. Blocks until ctx is cancelled.
111 func (b *Bot) Start(ctx context.Context) error {
112 host, port, err := splitHostPort(b.cfg.IRCAddr)
113 if err != nil {
114 return fmt.Errorf("shepherd: %w", err)
115 }
116
117 c := girc.New(girc.Config{
118 Server: host,
119 Port: port,
120 Nick: b.cfg.Nick,
121 User: b.cfg.Nick,
122 Name: "scuttlebot shepherd",
123 SASL: &girc.SASLPlain{User: b.cfg.Nick, Pass: b.cfg.Password},
124 PingDelay: 30 * time.Second,
125 PingTimeout: 30 * time.Second,
126 })
127
128 router := cmdparse.NewRouter(b.cfg.Nick)
129 router.Register(cmdparse.Command{
130 Name: "goal",
131 Usage: "GOAL <description>",
132 Description: "set a goal for the current channel",
133 Handler: func(cmdCtx *cmdparse.Context, args string) string {
134 return b.handleGoal(cmdCtx.Channel, cmdCtx.Nick, args)
135 },
136 })
137 router.Register(cmdparse.Command{
138 Name: "status",
139 Usage: "STATUS",
140 Description: "report progress on current goals",
141 Handler: func(cmdCtx *cmdparse.Context, args string) string {
142 return b.handleStatus(ctx, cmdCtx.Channel)
143 },
144 })
145 router.Register(cmdparse.Command{
146 Name: "assign",
147 Usage: "ASSIGN <nick> <task>",
148 Description: "manually assign a task to an agent",
149 Handler: func(cmdCtx *cmdparse.Context, args string) string {
150 return b.handleAssign(cmdCtx.Channel, args)
151 },
152 })
153 router.Register(cmdparse.Command{
154 Name: "checkin",
155 Usage: "CHECKIN",
156 Description: "trigger a check-in round with all assigned agents",
157 Handler: func(cmdCtx *cmdparse.Context, args string) string {
158 b.runCheckin(c)
159 return "check-in round started"
160 },
161 })
162 router.Register(cmdparse.Command{
163 Name: "plan",
164 Usage: "PLAN",
165 Description: "generate a work plan from current goals using LLM",
166 Handler: func(cmdCtx *cmdparse.Context, args string) string {
167 return b.handlePlan(ctx, cmdCtx.Channel)
168 },
169 })
170 router.Register(cmdparse.Command{
171 Name: "goals",
172 Usage: "GOALS",
173 Description: "list all active goals for this channel",
174 Handler: func(cmdCtx *cmdparse.Context, args string) string {
175 return b.handleListGoals(cmdCtx.Channel)
176 },
177 })
178 router.Register(cmdparse.Command{
179 Name: "done",
180 Usage: "DONE <goal-id>",
181 Description: "mark a goal as completed",
182 Handler: func(cmdCtx *cmdparse.Context, args string) string {
183 return b.handleDone(cmdCtx.Channel, args)
184 },
185 })
186
187 c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
188 cl.Cmd.Mode(cl.GetNick(), "+B")
189 for _, ch := range b.cfg.Channels eportChannel)
190 }
191 }()
192 if b.cfg.ReportChannel != "" {
193 cl.Cmd.Join(b.cfg.ReportChannel)
194 }
195 if b.log != nil {
196 b.log.Info("shepherd connected", "channels", b.cfg.Channels)
197 }
198 })
199
200 c.Handlers.AddBg(girc.INVITE, func(cl *girc.Client, e girc.Event) {
201 if ch := e.Last(); strings.HasPrefix(ch, "#") {
202 cl.Cmd.Join(ch)
203 }
204 })
205
206 c.Handlers.AddBg(girc.PRIVMSG, func(cl *girc.Client, e girc.Event) {
207 if len(e.Params) < 1 || e.Source == nil {
208 return
209 }
210 nick := e.Source.Name
211 target := e.Params[0]
212 text := strings.TrimSpace(e.Last())
213
214 // Track activity.
215 b.mu.Lock()
216 b.activity[nick] = time.Now()
217 if strings.HasPrefix(target, "#") {
218 hist := b.history[target]
219 hist = append(hist, fmt.Sprintf("[%s] %s", nick, text))
220 if len(hist) > 100 {
221 hist = hist[len(hist)-100:]
222 }
223 b.history[target] = hist
224 }
225 b.mu.Unlock()
226
227 // Dispatch commands.
228 if reply := router.Dispatch(nick, target, text); reply != nil {
229 cl.Cmd.Message(reply.Target, reply.Text)
230 }
231 })
232
233 b.client = c
234
235 // Start periodic check-in if configured.
236 if b.cfg.CheckinInterval > 0 {
237 go b.checkinLoop(ctx, c)
238 }
239
240 errCh := make(chan error, 1)
241 go func() {
242 if err := c.Connect(); err != nil && ctx.Err() == nil {
243 errCh <- err
244 }
245 }()
246
247 select {
248 case <-ctx.Done():
249 c.Close()
250 return nil
251 case err := <-errCh:
252 return fmt.Errorf("shepherd: irc: %w", err)
253 }
254 }
255
256 // Stop disconnects the bot.
257 func (b *Bot) Stop() {
258 if b.client != nil {
259 b.client.Close()
260 }
261 }
262
263 // --- Command handlers ---
264
265 func (b *Bot) handleGoal(channel, nick, desc string) string {
266 desc = strings.TrimSpace(desc)
267 if desc == "" {
268 return "usage: GOAL <description>"
269 }
270 b.mu.Lock()
271 defer b.mu.Unlock()
272 id := fmt.Sprintf("G%d", len(b.goals[channel])+1)
273 b.goals[channel] = append(b.goals[channel], Goal{
274 ID: id,
275 Channel: channel,
276 Description: desc,
277 CreatedAt: time.Now(),
278 CreatedBy: nick,
279 Status: "active",
280 })
281 return fmt.Sprintf("goal %s set: %s", id, desc)
282 }
283
284 func (b *Bot) handleListGoals(channel string) string {
285 b.mu.Lock()
286 defer b.mu.Unlock()
287 goals := b.goals[channel]
288 if len(goals) == 0 {
289 return "no goals set for " + channel
290 }
291 var lines []string
292 for _, g := range goals {
293 lines = append(lines, fmt.Sprintf("[%s] %s (%s) — %s", g.ID, g.Description, g.Status, g.CreatedBy))
294 }
295 return strings.Join(lines, " | ")
296 }
297
298 func (b *Bot) handleDone(channel, goalID string) string {
299 goalID = strings.TrimSpace(goalID)
300 b.mu.Lock()
301 defer b.mu.Unlock()
302 for i, g := range b.goals[channel] {
303 if strings.EqualFold(g.ID, goalID) {
304 b.goals[channel][i].Status = "done"
305 return fmt.Sprintf("goal %s marked done: %s", g.ID, g.Description)
306 }
307 }
308 return fmt.Sprintf("goal %q not found in %s", goalID, channel)
309 }
310
311 func (b *Bot) handleAssign(channel, args string) string {
312 parts := strings.SplitN(strings.TrimSpace(args), " ", 2)
313 if len(parts) < 2 {
314 return "usage: ASSIGN <nick> <task>"
315 }
316 nick, task := parts[0], parts[1]
317 b.mu.Lock()
318 b.assignments[nick] = &Assignment{
319 Nick: nick,
320 Task: task,
321 Channel: channel,
322 AssignedAt: time.Now(),
323 LastUpdate: time.Now(),
324 }
325 b.mu.Unlock()
326 return fmt.Sprintf("assigned %s to %s", nick, task)
327 }
328
329 func (b *Bot) handleStatus(ctx context.Context, channel string) string {
330 b.mu.Lock()
331 goals := b.goals[channel]
332 var active, done int
333 for _, g := range goals {
334 if g.Status == "done" {
335 done++
336 } else {
337 active++
338 }
339 }
340 hist := b.history[channel]
341 var assignments []string
342 for _, a := range b.assignments {
343 if a.Channel == channel {
344 assignments = append(assignments, fmt.Sprintf("%s: %s", a.Nick, a.Task))
345 }
346 }
347 b.mu.Unlock()
348
349 summary := fmt.Sprintf("goals: %d active, %d done", active, done)
350 if len(assignments) > 0 {
351 summary += " | assignments: " + strings.Join(assignments, ", ")
352 }
353
354 // Use LLM for richer summary if available and there's context.
355 if b.llm != nil && len(hist) > 5 {
356 prompt := fmt.Sprintf("Summarize the current status of work in %s. "+
357 "Goals: %d active, %d done. Assignments: %s. "+
358 "Recent conversation:\n%s\n\n"+
359 "Give a brief status report (2-3 sentences).",
360 channel, active, done, strings.Join(assignments, ", "),
361 strings.Join(hist[max(0, len(hist)-30):], "\n"))
362 if llmSummary, err := b.llm.Summarize(ctx, prompt); err == nil {
363 return llmSummary
364 }
365 }
366
367 return summary
368 }
369
370 func (b *Bot) handlePlan(ctx context.Context, channel string) string {
371 b.mu.Lock()
372 goals := b.goals[channel]
373 var goalDescs []string
374 for _, g := range goals {
375 if g.Status == "active" {
376 goalDescs =

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button