ScuttleBot

feat: TLS listener support, per-repo channel config, bot connection fixes - Add tls_domain/tls_addr to ErgoConfig for dual-listener setup (plaintext loopback for internal bots + public TLS for external agents) - Fix splitHostPort in 5 bot packages (auditbot, herald, scroll, systembot, warden) — replace broken fmt.Sscanf parser with net.SplitHostPort - Add per-repo .scuttlebot.yaml support to all three relay binaries — relays auto-join project channels based on the working directory config - Add project-setup skill documenting the channel hierarchy and issue workflow - Add .scuttlebot.yaml to gitignore

lmata 2026-04-03 18:56 trunk
Commit 18e8fef0b93ce61cee6d004ccdec09f58db4ef50eac5fa323175240624189fa8
+1
--- .gitignore
+++ .gitignore
@@ -4,10 +4,11 @@
44
bin/*
55
!bin/.gitkeep
66
*.log
77
*.pid
88
scuttlebot.yaml
9
+.scuttlebot.yaml
910
tests/e2e/
1011
tmp-*.sh
1112
REPORT.md
1213
coverage.out
1314
test-results/
1415
--- .gitignore
+++ .gitignore
@@ -4,10 +4,11 @@
4 bin/*
5 !bin/.gitkeep
6 *.log
7 *.pid
8 scuttlebot.yaml
 
9 tests/e2e/
10 tmp-*.sh
11 REPORT.md
12 coverage.out
13 test-results/
14
--- .gitignore
+++ .gitignore
@@ -4,10 +4,11 @@
4 bin/*
5 !bin/.gitkeep
6 *.log
7 *.pid
8 scuttlebot.yaml
9 .scuttlebot.yaml
10 tests/e2e/
11 tmp-*.sh
12 REPORT.md
13 coverage.out
14 test-results/
15
--- cmd/claude-relay/main.go
+++ cmd/claude-relay/main.go
@@ -21,10 +21,11 @@
2121
2222
"github.com/conflicthq/scuttlebot/pkg/ircagent"
2323
"github.com/conflicthq/scuttlebot/pkg/sessionrelay"
2424
"github.com/creack/pty"
2525
"golang.org/x/term"
26
+ "gopkg.in/yaml.v3"
2627
)
2728
2829
const (
2930
defaultRelayURL = "http://localhost:8080"
3031
defaultIRCAddr = "127.0.0.1:6667"
@@ -846,10 +847,15 @@
846847
target, err := targetCWD(args)
847848
if err != nil {
848849
return config{}, err
849850
}
850851
cfg.TargetCWD = target
852
+
853
+ // Merge per-repo config if present.
854
+ if rc, err := loadRepoConfig(target); err == nil && rc != nil {
855
+ cfg.Channels = mergeChannels(cfg.Channels, rc.allChannels())
856
+ }
851857
852858
sessionID := getenvOr(fileConfig, "SCUTTLEBOT_SESSION_ID", "")
853859
if sessionID == "" {
854860
sessionID = defaultSessionID(target)
855861
}
@@ -1066,5 +1072,65 @@
10661072
if errors.As(err, &exitErr) {
10671073
return exitErr.ExitCode()
10681074
}
10691075
return 1
10701076
}
1077
+
1078
+// repoConfig is the per-repo .scuttlebot.yaml format.
1079
+type repoConfig struct {
1080
+ Channel string `yaml:"channel"`
1081
+ Channels []string `yaml:"channels"`
1082
+}
1083
+
1084
+// allChannels returns the singular channel (if set) prepended to the channels list.
1085
+func (rc *repoConfig) allChannels() []string {
1086
+ if rc.Channel == "" {
1087
+ return rc.Channels
1088
+ }
1089
+ return append([]string{rc.Channel}, rc.Channels...)
1090
+}
1091
+
1092
+// loadRepoConfig walks up from dir looking for .scuttlebot.yaml.
1093
+// Stops at the git root (directory containing .git) or the filesystem root.
1094
+// Returns nil, nil if no config file is found.
1095
+func loadRepoConfig(dir string) (*repoConfig, error) {
1096
+ current := dir
1097
+ for {
1098
+ candidate := filepath.Join(current, ".scuttlebot.yaml")
1099
+ if data, err := os.ReadFile(candidate); err == nil {
1100
+ var rc repoConfig
1101
+ if err := yaml.Unmarshal(data, &rc); err != nil {
1102
+ return nil, fmt.Errorf("loadRepoConfig: parse %s: %w", candidate, err)
1103
+ }
1104
+ fmt.Fprintf(os.Stderr, "scuttlebot: loaded repo config from %s\n", candidate)
1105
+ return &rc, nil
1106
+ }
1107
+
1108
+ // Stop if this directory is a git root.
1109
+ if info, err := os.Stat(filepath.Join(current, ".git")); err == nil && info.IsDir() {
1110
+ return nil, nil
1111
+ }
1112
+
1113
+ parent := filepath.Dir(current)
1114
+ if parent == current {
1115
+ return nil, nil
1116
+ }
1117
+ current = parent
1118
+ }
1119
+}
1120
+
1121
+// mergeChannels appends extra channels to existing, deduplicating.
1122
+func mergeChannels(existing, extra []string) []string {
1123
+ seen := make(map[string]struct{}, len(existing))
1124
+ for _, ch := range existing {
1125
+ seen[ch] = struct{}{}
1126
+ }
1127
+ merged := append([]string(nil), existing...)
1128
+ for _, ch := range extra {
1129
+ if _, ok := seen[ch]; ok {
1130
+ continue
1131
+ }
1132
+ seen[ch] = struct{}{}
1133
+ merged = append(merged, ch)
1134
+ }
1135
+ return merged
1136
+}
10711137
--- cmd/claude-relay/main.go
+++ cmd/claude-relay/main.go
@@ -21,10 +21,11 @@
21
22 "github.com/conflicthq/scuttlebot/pkg/ircagent"
23 "github.com/conflicthq/scuttlebot/pkg/sessionrelay"
24 "github.com/creack/pty"
25 "golang.org/x/term"
 
26 )
27
28 const (
29 defaultRelayURL = "http://localhost:8080"
30 defaultIRCAddr = "127.0.0.1:6667"
@@ -846,10 +847,15 @@
846 target, err := targetCWD(args)
847 if err != nil {
848 return config{}, err
849 }
850 cfg.TargetCWD = target
 
 
 
 
 
851
852 sessionID := getenvOr(fileConfig, "SCUTTLEBOT_SESSION_ID", "")
853 if sessionID == "" {
854 sessionID = defaultSessionID(target)
855 }
@@ -1066,5 +1072,65 @@
1066 if errors.As(err, &exitErr) {
1067 return exitErr.ExitCode()
1068 }
1069 return 1
1070 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1071
--- cmd/claude-relay/main.go
+++ cmd/claude-relay/main.go
@@ -21,10 +21,11 @@
21
22 "github.com/conflicthq/scuttlebot/pkg/ircagent"
23 "github.com/conflicthq/scuttlebot/pkg/sessionrelay"
24 "github.com/creack/pty"
25 "golang.org/x/term"
26 "gopkg.in/yaml.v3"
27 )
28
29 const (
30 defaultRelayURL = "http://localhost:8080"
31 defaultIRCAddr = "127.0.0.1:6667"
@@ -846,10 +847,15 @@
847 target, err := targetCWD(args)
848 if err != nil {
849 return config{}, err
850 }
851 cfg.TargetCWD = target
852
853 // Merge per-repo config if present.
854 if rc, err := loadRepoConfig(target); err == nil && rc != nil {
855 cfg.Channels = mergeChannels(cfg.Channels, rc.allChannels())
856 }
857
858 sessionID := getenvOr(fileConfig, "SCUTTLEBOT_SESSION_ID", "")
859 if sessionID == "" {
860 sessionID = defaultSessionID(target)
861 }
@@ -1066,5 +1072,65 @@
1072 if errors.As(err, &exitErr) {
1073 return exitErr.ExitCode()
1074 }
1075 return 1
1076 }
1077
1078 // repoConfig is the per-repo .scuttlebot.yaml format.
1079 type repoConfig struct {
1080 Channel string `yaml:"channel"`
1081 Channels []string `yaml:"channels"`
1082 }
1083
1084 // allChannels returns the singular channel (if set) prepended to the channels list.
1085 func (rc *repoConfig) allChannels() []string {
1086 if rc.Channel == "" {
1087 return rc.Channels
1088 }
1089 return append([]string{rc.Channel}, rc.Channels...)
1090 }
1091
1092 // loadRepoConfig walks up from dir looking for .scuttlebot.yaml.
1093 // Stops at the git root (directory containing .git) or the filesystem root.
1094 // Returns nil, nil if no config file is found.
1095 func loadRepoConfig(dir string) (*repoConfig, error) {
1096 current := dir
1097 for {
1098 candidate := filepath.Join(current, ".scuttlebot.yaml")
1099 if data, err := os.ReadFile(candidate); err == nil {
1100 var rc repoConfig
1101 if err := yaml.Unmarshal(data, &rc); err != nil {
1102 return nil, fmt.Errorf("loadRepoConfig: parse %s: %w", candidate, err)
1103 }
1104 fmt.Fprintf(os.Stderr, "scuttlebot: loaded repo config from %s\n", candidate)
1105 return &rc, nil
1106 }
1107
1108 // Stop if this directory is a git root.
1109 if info, err := os.Stat(filepath.Join(current, ".git")); err == nil && info.IsDir() {
1110 return nil, nil
1111 }
1112
1113 parent := filepath.Dir(current)
1114 if parent == current {
1115 return nil, nil
1116 }
1117 current = parent
1118 }
1119 }
1120
1121 // mergeChannels appends extra channels to existing, deduplicating.
1122 func mergeChannels(existing, extra []string) []string {
1123 seen := make(map[string]struct{}, len(existing))
1124 for _, ch := range existing {
1125 seen[ch] = struct{}{}
1126 }
1127 merged := append([]string(nil), existing...)
1128 for _, ch := range extra {
1129 if _, ok := seen[ch]; ok {
1130 continue
1131 }
1132 seen[ch] = struct{}{}
1133 merged = append(merged, ch)
1134 }
1135 return merged
1136 }
1137
--- cmd/codex-relay/main.go
+++ cmd/codex-relay/main.go
@@ -21,10 +21,11 @@
2121
2222
"github.com/conflicthq/scuttlebot/pkg/ircagent"
2323
"github.com/conflicthq/scuttlebot/pkg/sessionrelay"
2424
"github.com/creack/pty"
2525
"golang.org/x/term"
26
+ "gopkg.in/yaml.v3"
2627
)
2728
2829
const (
2930
defaultRelayURL = "http://localhost:8080"
3031
defaultIRCAddr = "127.0.0.1:6667"
@@ -537,10 +538,15 @@
537538
target, err := targetCWD(args)
538539
if err != nil {
539540
return config{}, err
540541
}
541542
cfg.TargetCWD = target
543
+
544
+ // Merge per-repo config if present.
545
+ if rc, err := loadRepoConfig(target); err == nil && rc != nil {
546
+ cfg.Channels = mergeChannels(cfg.Channels, rc.allChannels())
547
+ }
542548
543549
sessionID := getenvOr(fileConfig, "SCUTTLEBOT_SESSION_ID", "")
544550
if sessionID == "" {
545551
sessionID = getenvOr(fileConfig, "CODEX_SESSION_ID", "")
546552
}
@@ -1132,5 +1138,65 @@
11321138
if errors.As(err, &exitErr) {
11331139
return exitErr.ExitCode()
11341140
}
11351141
return 1
11361142
}
1143
+
1144
+// repoConfig is the per-repo .scuttlebot.yaml format.
1145
+type repoConfig struct {
1146
+ Channel string `yaml:"channel"`
1147
+ Channels []string `yaml:"channels"`
1148
+}
1149
+
1150
+// allChannels returns the singular channel (if set) prepended to the channels list.
1151
+func (rc *repoConfig) allChannels() []string {
1152
+ if rc.Channel == "" {
1153
+ return rc.Channels
1154
+ }
1155
+ return append([]string{rc.Channel}, rc.Channels...)
1156
+}
1157
+
1158
+// loadRepoConfig walks up from dir looking for .scuttlebot.yaml.
1159
+// Stops at the git root (directory containing .git) or the filesystem root.
1160
+// Returns nil, nil if no config file is found.
1161
+func loadRepoConfig(dir string) (*repoConfig, error) {
1162
+ current := dir
1163
+ for {
1164
+ candidate := filepath.Join(current, ".scuttlebot.yaml")
1165
+ if data, err := os.ReadFile(candidate); err == nil {
1166
+ var rc repoConfig
1167
+ if err := yaml.Unmarshal(data, &rc); err != nil {
1168
+ return nil, fmt.Errorf("loadRepoConfig: parse %s: %w", candidate, err)
1169
+ }
1170
+ fmt.Fprintf(os.Stderr, "scuttlebot: loaded repo config from %s\n", candidate)
1171
+ return &rc, nil
1172
+ }
1173
+
1174
+ // Stop if this directory is a git root.
1175
+ if info, err := os.Stat(filepath.Join(current, ".git")); err == nil && info.IsDir() {
1176
+ return nil, nil
1177
+ }
1178
+
1179
+ parent := filepath.Dir(current)
1180
+ if parent == current {
1181
+ return nil, nil
1182
+ }
1183
+ current = parent
1184
+ }
1185
+}
1186
+
1187
+// mergeChannels appends extra channels to existing, deduplicating.
1188
+func mergeChannels(existing, extra []string) []string {
1189
+ seen := make(map[string]struct{}, len(existing))
1190
+ for _, ch := range existing {
1191
+ seen[ch] = struct{}{}
1192
+ }
1193
+ merged := append([]string(nil), existing...)
1194
+ for _, ch := range extra {
1195
+ if _, ok := seen[ch]; ok {
1196
+ continue
1197
+ }
1198
+ seen[ch] = struct{}{}
1199
+ merged = append(merged, ch)
1200
+ }
1201
+ return merged
1202
+}
11371203
--- cmd/codex-relay/main.go
+++ cmd/codex-relay/main.go
@@ -21,10 +21,11 @@
21
22 "github.com/conflicthq/scuttlebot/pkg/ircagent"
23 "github.com/conflicthq/scuttlebot/pkg/sessionrelay"
24 "github.com/creack/pty"
25 "golang.org/x/term"
 
26 )
27
28 const (
29 defaultRelayURL = "http://localhost:8080"
30 defaultIRCAddr = "127.0.0.1:6667"
@@ -537,10 +538,15 @@
537 target, err := targetCWD(args)
538 if err != nil {
539 return config{}, err
540 }
541 cfg.TargetCWD = target
 
 
 
 
 
542
543 sessionID := getenvOr(fileConfig, "SCUTTLEBOT_SESSION_ID", "")
544 if sessionID == "" {
545 sessionID = getenvOr(fileConfig, "CODEX_SESSION_ID", "")
546 }
@@ -1132,5 +1138,65 @@
1132 if errors.As(err, &exitErr) {
1133 return exitErr.ExitCode()
1134 }
1135 return 1
1136 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1137
--- cmd/codex-relay/main.go
+++ cmd/codex-relay/main.go
@@ -21,10 +21,11 @@
21
22 "github.com/conflicthq/scuttlebot/pkg/ircagent"
23 "github.com/conflicthq/scuttlebot/pkg/sessionrelay"
24 "github.com/creack/pty"
25 "golang.org/x/term"
26 "gopkg.in/yaml.v3"
27 )
28
29 const (
30 defaultRelayURL = "http://localhost:8080"
31 defaultIRCAddr = "127.0.0.1:6667"
@@ -537,10 +538,15 @@
538 target, err := targetCWD(args)
539 if err != nil {
540 return config{}, err
541 }
542 cfg.TargetCWD = target
543
544 // Merge per-repo config if present.
545 if rc, err := loadRepoConfig(target); err == nil && rc != nil {
546 cfg.Channels = mergeChannels(cfg.Channels, rc.allChannels())
547 }
548
549 sessionID := getenvOr(fileConfig, "SCUTTLEBOT_SESSION_ID", "")
550 if sessionID == "" {
551 sessionID = getenvOr(fileConfig, "CODEX_SESSION_ID", "")
552 }
@@ -1132,5 +1138,65 @@
1138 if errors.As(err, &exitErr) {
1139 return exitErr.ExitCode()
1140 }
1141 return 1
1142 }
1143
1144 // repoConfig is the per-repo .scuttlebot.yaml format.
1145 type repoConfig struct {
1146 Channel string `yaml:"channel"`
1147 Channels []string `yaml:"channels"`
1148 }
1149
1150 // allChannels returns the singular channel (if set) prepended to the channels list.
1151 func (rc *repoConfig) allChannels() []string {
1152 if rc.Channel == "" {
1153 return rc.Channels
1154 }
1155 return append([]string{rc.Channel}, rc.Channels...)
1156 }
1157
1158 // loadRepoConfig walks up from dir looking for .scuttlebot.yaml.
1159 // Stops at the git root (directory containing .git) or the filesystem root.
1160 // Returns nil, nil if no config file is found.
1161 func loadRepoConfig(dir string) (*repoConfig, error) {
1162 current := dir
1163 for {
1164 candidate := filepath.Join(current, ".scuttlebot.yaml")
1165 if data, err := os.ReadFile(candidate); err == nil {
1166 var rc repoConfig
1167 if err := yaml.Unmarshal(data, &rc); err != nil {
1168 return nil, fmt.Errorf("loadRepoConfig: parse %s: %w", candidate, err)
1169 }
1170 fmt.Fprintf(os.Stderr, "scuttlebot: loaded repo config from %s\n", candidate)
1171 return &rc, nil
1172 }
1173
1174 // Stop if this directory is a git root.
1175 if info, err := os.Stat(filepath.Join(current, ".git")); err == nil && info.IsDir() {
1176 return nil, nil
1177 }
1178
1179 parent := filepath.Dir(current)
1180 if parent == current {
1181 return nil, nil
1182 }
1183 current = parent
1184 }
1185 }
1186
1187 // mergeChannels appends extra channels to existing, deduplicating.
1188 func mergeChannels(existing, extra []string) []string {
1189 seen := make(map[string]struct{}, len(existing))
1190 for _, ch := range existing {
1191 seen[ch] = struct{}{}
1192 }
1193 merged := append([]string(nil), existing...)
1194 for _, ch := range extra {
1195 if _, ok := seen[ch]; ok {
1196 continue
1197 }
1198 seen[ch] = struct{}{}
1199 merged = append(merged, ch)
1200 }
1201 return merged
1202 }
1203
--- cmd/gemini-relay/main.go
+++ cmd/gemini-relay/main.go
@@ -19,10 +19,11 @@
1919
2020
"github.com/conflicthq/scuttlebot/pkg/ircagent"
2121
"github.com/conflicthq/scuttlebot/pkg/sessionrelay"
2222
"github.com/creack/pty"
2323
"golang.org/x/term"
24
+ "gopkg.in/yaml.v3"
2425
)
2526
2627
const (
2728
defaultRelayURL = "http://localhost:8080"
2829
defaultIRCAddr = "127.0.0.1:6667"
@@ -491,10 +492,15 @@
491492
target, err := targetCWD(args)
492493
if err != nil {
493494
return config{}, err
494495
}
495496
cfg.TargetCWD = target
497
+
498
+ // Merge per-repo config if present.
499
+ if rc, err := loadRepoConfig(target); err == nil && rc != nil {
500
+ cfg.Channels = mergeChannels(cfg.Channels, rc.allChannels())
501
+ }
496502
497503
sessionID := getenvOr(fileConfig, "SCUTTLEBOT_SESSION_ID", "")
498504
if sessionID == "" {
499505
sessionID = getenvOr(fileConfig, "GEMINI_SESSION_ID", "")
500506
}
@@ -714,5 +720,65 @@
714720
if errors.As(err, &exitErr) {
715721
return exitErr.ExitCode()
716722
}
717723
return 1
718724
}
725
+
726
+// repoConfig is the per-repo .scuttlebot.yaml format.
727
+type repoConfig struct {
728
+ Channel string `yaml:"channel"`
729
+ Channels []string `yaml:"channels"`
730
+}
731
+
732
+// allChannels returns the singular channel (if set) prepended to the channels list.
733
+func (rc *repoConfig) allChannels() []string {
734
+ if rc.Channel == "" {
735
+ return rc.Channels
736
+ }
737
+ return append([]string{rc.Channel}, rc.Channels...)
738
+}
739
+
740
+// loadRepoConfig walks up from dir looking for .scuttlebot.yaml.
741
+// Stops at the git root (directory containing .git) or the filesystem root.
742
+// Returns nil, nil if no config file is found.
743
+func loadRepoConfig(dir string) (*repoConfig, error) {
744
+ current := dir
745
+ for {
746
+ candidate := filepath.Join(current, ".scuttlebot.yaml")
747
+ if data, err := os.ReadFile(candidate); err == nil {
748
+ var rc repoConfig
749
+ if err := yaml.Unmarshal(data, &rc); err != nil {
750
+ return nil, fmt.Errorf("loadRepoConfig: parse %s: %w", candidate, err)
751
+ }
752
+ fmt.Fprintf(os.Stderr, "scuttlebot: loaded repo config from %s\n", candidate)
753
+ return &rc, nil
754
+ }
755
+
756
+ // Stop if this directory is a git root.
757
+ if info, err := os.Stat(filepath.Join(current, ".git")); err == nil && info.IsDir() {
758
+ return nil, nil
759
+ }
760
+
761
+ parent := filepath.Dir(current)
762
+ if parent == current {
763
+ return nil, nil
764
+ }
765
+ current = parent
766
+ }
767
+}
768
+
769
+// mergeChannels appends extra channels to existing, deduplicating.
770
+func mergeChannels(existing, extra []string) []string {
771
+ seen := make(map[string]struct{}, len(existing))
772
+ for _, ch := range existing {
773
+ seen[ch] = struct{}{}
774
+ }
775
+ merged := append([]string(nil), existing...)
776
+ for _, ch := range extra {
777
+ if _, ok := seen[ch]; ok {
778
+ continue
779
+ }
780
+ seen[ch] = struct{}{}
781
+ merged = append(merged, ch)
782
+ }
783
+ return merged
784
+}
719785
--- cmd/gemini-relay/main.go
+++ cmd/gemini-relay/main.go
@@ -19,10 +19,11 @@
19
20 "github.com/conflicthq/scuttlebot/pkg/ircagent"
21 "github.com/conflicthq/scuttlebot/pkg/sessionrelay"
22 "github.com/creack/pty"
23 "golang.org/x/term"
 
24 )
25
26 const (
27 defaultRelayURL = "http://localhost:8080"
28 defaultIRCAddr = "127.0.0.1:6667"
@@ -491,10 +492,15 @@
491 target, err := targetCWD(args)
492 if err != nil {
493 return config{}, err
494 }
495 cfg.TargetCWD = target
 
 
 
 
 
496
497 sessionID := getenvOr(fileConfig, "SCUTTLEBOT_SESSION_ID", "")
498 if sessionID == "" {
499 sessionID = getenvOr(fileConfig, "GEMINI_SESSION_ID", "")
500 }
@@ -714,5 +720,65 @@
714 if errors.As(err, &exitErr) {
715 return exitErr.ExitCode()
716 }
717 return 1
718 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
719
--- cmd/gemini-relay/main.go
+++ cmd/gemini-relay/main.go
@@ -19,10 +19,11 @@
19
20 "github.com/conflicthq/scuttlebot/pkg/ircagent"
21 "github.com/conflicthq/scuttlebot/pkg/sessionrelay"
22 "github.com/creack/pty"
23 "golang.org/x/term"
24 "gopkg.in/yaml.v3"
25 )
26
27 const (
28 defaultRelayURL = "http://localhost:8080"
29 defaultIRCAddr = "127.0.0.1:6667"
@@ -491,10 +492,15 @@
492 target, err := targetCWD(args)
493 if err != nil {
494 return config{}, err
495 }
496 cfg.TargetCWD = target
497
498 // Merge per-repo config if present.
499 if rc, err := loadRepoConfig(target); err == nil && rc != nil {
500 cfg.Channels = mergeChannels(cfg.Channels, rc.allChannels())
501 }
502
503 sessionID := getenvOr(fileConfig, "SCUTTLEBOT_SESSION_ID", "")
504 if sessionID == "" {
505 sessionID = getenvOr(fileConfig, "GEMINI_SESSION_ID", "")
506 }
@@ -714,5 +720,65 @@
720 if errors.As(err, &exitErr) {
721 return exitErr.ExitCode()
722 }
723 return 1
724 }
725
726 // repoConfig is the per-repo .scuttlebot.yaml format.
727 type repoConfig struct {
728 Channel string `yaml:"channel"`
729 Channels []string `yaml:"channels"`
730 }
731
732 // allChannels returns the singular channel (if set) prepended to the channels list.
733 func (rc *repoConfig) allChannels() []string {
734 if rc.Channel == "" {
735 return rc.Channels
736 }
737 return append([]string{rc.Channel}, rc.Channels...)
738 }
739
740 // loadRepoConfig walks up from dir looking for .scuttlebot.yaml.
741 // Stops at the git root (directory containing .git) or the filesystem root.
742 // Returns nil, nil if no config file is found.
743 func loadRepoConfig(dir string) (*repoConfig, error) {
744 current := dir
745 for {
746 candidate := filepath.Join(current, ".scuttlebot.yaml")
747 if data, err := os.ReadFile(candidate); err == nil {
748 var rc repoConfig
749 if err := yaml.Unmarshal(data, &rc); err != nil {
750 return nil, fmt.Errorf("loadRepoConfig: parse %s: %w", candidate, err)
751 }
752 fmt.Fprintf(os.Stderr, "scuttlebot: loaded repo config from %s\n", candidate)
753 return &rc, nil
754 }
755
756 // Stop if this directory is a git root.
757 if info, err := os.Stat(filepath.Join(current, ".git")); err == nil && info.IsDir() {
758 return nil, nil
759 }
760
761 parent := filepath.Dir(current)
762 if parent == current {
763 return nil, nil
764 }
765 current = parent
766 }
767 }
768
769 // mergeChannels appends extra channels to existing, deduplicating.
770 func mergeChannels(existing, extra []string) []string {
771 seen := make(map[string]struct{}, len(existing))
772 for _, ch := range existing {
773 seen[ch] = struct{}{}
774 }
775 merged := append([]string(nil), existing...)
776 for _, ch := range extra {
777 if _, ok := seen[ch]; ok {
778 continue
779 }
780 seen[ch] = struct{}{}
781 merged = append(merged, ch)
782 }
783 return merged
784 }
785
--- internal/bots/auditbot/auditbot.go
+++ internal/bots/auditbot/auditbot.go
@@ -13,10 +13,12 @@
1313
1414
import (
1515
"context"
1616
"fmt"
1717
"log/slog"
18
+ "net"
19
+ "strconv"
1820
"strings"
1921
"time"
2022
2123
"github.com/lrstanley/girc"
2224
@@ -203,12 +205,15 @@
203205
}
204206
return out
205207
}
206208
207209
func splitHostPort(addr string) (string, int, error) {
208
- var host string
209
- var port int
210
- if _, err := fmt.Sscanf(addr, "%[^:]:%d", &host, &port); err != nil {
210
+ host, portStr, err := net.SplitHostPort(addr)
211
+ if err != nil {
211212
return "", 0, fmt.Errorf("invalid address %q: %w", addr, err)
212213
}
214
+ port, err := strconv.Atoi(portStr)
215
+ if err != nil {
216
+ return "", 0, fmt.Errorf("invalid port in %q: %w", addr, err)
217
+ }
213218
return host, port, nil
214219
}
215220
--- internal/bots/auditbot/auditbot.go
+++ internal/bots/auditbot/auditbot.go
@@ -13,10 +13,12 @@
13
14 import (
15 "context"
16 "fmt"
17 "log/slog"
 
 
18 "strings"
19 "time"
20
21 "github.com/lrstanley/girc"
22
@@ -203,12 +205,15 @@
203 }
204 return out
205 }
206
207 func splitHostPort(addr string) (string, int, error) {
208 var host string
209 var port int
210 if _, err := fmt.Sscanf(addr, "%[^:]:%d", &host, &port); err != nil {
211 return "", 0, fmt.Errorf("invalid address %q: %w", addr, err)
212 }
 
 
 
 
213 return host, port, nil
214 }
215
--- internal/bots/auditbot/auditbot.go
+++ internal/bots/auditbot/auditbot.go
@@ -13,10 +13,12 @@
13
14 import (
15 "context"
16 "fmt"
17 "log/slog"
18 "net"
19 "strconv"
20 "strings"
21 "time"
22
23 "github.com/lrstanley/girc"
24
@@ -203,12 +205,15 @@
205 }
206 return out
207 }
208
209 func splitHostPort(addr string) (string, int, error) {
210 host, portStr, err := net.SplitHostPort(addr)
211 if err != nil {
 
212 return "", 0, fmt.Errorf("invalid address %q: %w", addr, err)
213 }
214 port, err := strconv.Atoi(portStr)
215 if err != nil {
216 return "", 0, fmt.Errorf("invalid port in %q: %w", addr, err)
217 }
218 return host, port, nil
219 }
220
--- internal/bots/herald/herald.go
+++ internal/bots/herald/herald.go
@@ -10,10 +10,12 @@
1010
1111
import (
1212
"context"
1313
"fmt"
1414
"log/slog"
15
+ "net"
16
+ "strconv"
1517
"strings"
1618
"sync"
1719
"time"
1820
1921
"github.com/lrstanley/girc"
@@ -243,19 +245,22 @@
243245
}
244246
return b.routes.DefaultChannel
245247
}
246248
247249
func splitHostPort(addr string) (string, int, error) {
248
- var host string
249
- var port int
250
- if _, err := fmt.Sscanf(addr, "%[^:]:%d", &host, &port); err != nil {
250
+ host, portStr, err := net.SplitHostPort(addr)
251
+ if err != nil {
251252
return "", 0, fmt.Errorf("invalid address %q: %w", addr, err)
252253
}
254
+ port, err := strconv.Atoi(portStr)
255
+ if err != nil {
256
+ return "", 0, fmt.Errorf("invalid port in %q: %w", addr, err)
257
+ }
253258
return host, port, nil
254259
}
255260
256261
func min(a, b float64) float64 {
257262
if a < b {
258263
return a
259264
}
260265
return b
261266
}
262267
--- internal/bots/herald/herald.go
+++ internal/bots/herald/herald.go
@@ -10,10 +10,12 @@
10
11 import (
12 "context"
13 "fmt"
14 "log/slog"
 
 
15 "strings"
16 "sync"
17 "time"
18
19 "github.com/lrstanley/girc"
@@ -243,19 +245,22 @@
243 }
244 return b.routes.DefaultChannel
245 }
246
247 func splitHostPort(addr string) (string, int, error) {
248 var host string
249 var port int
250 if _, err := fmt.Sscanf(addr, "%[^:]:%d", &host, &port); err != nil {
251 return "", 0, fmt.Errorf("invalid address %q: %w", addr, err)
252 }
 
 
 
 
253 return host, port, nil
254 }
255
256 func min(a, b float64) float64 {
257 if a < b {
258 return a
259 }
260 return b
261 }
262
--- internal/bots/herald/herald.go
+++ internal/bots/herald/herald.go
@@ -10,10 +10,12 @@
10
11 import (
12 "context"
13 "fmt"
14 "log/slog"
15 "net"
16 "strconv"
17 "strings"
18 "sync"
19 "time"
20
21 "github.com/lrstanley/girc"
@@ -243,19 +245,22 @@
245 }
246 return b.routes.DefaultChannel
247 }
248
249 func splitHostPort(addr string) (string, int, error) {
250 host, portStr, err := net.SplitHostPort(addr)
251 if err != nil {
 
252 return "", 0, fmt.Errorf("invalid address %q: %w", addr, err)
253 }
254 port, err := strconv.Atoi(portStr)
255 if err != nil {
256 return "", 0, fmt.Errorf("invalid port in %q: %w", addr, err)
257 }
258 return host, port, nil
259 }
260
261 func min(a, b float64) float64 {
262 if a < b {
263 return a
264 }
265 return b
266 }
267
--- internal/bots/scroll/scroll.go
+++ internal/bots/scroll/scroll.go
@@ -12,10 +12,11 @@
1212
import (
1313
"context"
1414
"encoding/json"
1515
"fmt"
1616
"log/slog"
17
+ "net"
1718
"strconv"
1819
"strings"
1920
"sync"
2021
"time"
2122
@@ -206,12 +207,15 @@
206207
207208
return req, nil
208209
}
209210
210211
func splitHostPort(addr string) (string, int, error) {
211
- var host string
212
- var port int
213
- if _, err := fmt.Sscanf(addr, "%[^:]:%d", &host, &port); err != nil {
212
+ host, portStr, err := net.SplitHostPort(addr)
213
+ if err != nil {
214214
return "", 0, fmt.Errorf("invalid address %q: %w", addr, err)
215215
}
216
+ port, err := strconv.Atoi(portStr)
217
+ if err != nil {
218
+ return "", 0, fmt.Errorf("invalid port in %q: %w", addr, err)
219
+ }
216220
return host, port, nil
217221
}
218222
--- internal/bots/scroll/scroll.go
+++ internal/bots/scroll/scroll.go
@@ -12,10 +12,11 @@
12 import (
13 "context"
14 "encoding/json"
15 "fmt"
16 "log/slog"
 
17 "strconv"
18 "strings"
19 "sync"
20 "time"
21
@@ -206,12 +207,15 @@
206
207 return req, nil
208 }
209
210 func splitHostPort(addr string) (string, int, error) {
211 var host string
212 var port int
213 if _, err := fmt.Sscanf(addr, "%[^:]:%d", &host, &port); err != nil {
214 return "", 0, fmt.Errorf("invalid address %q: %w", addr, err)
215 }
 
 
 
 
216 return host, port, nil
217 }
218
--- internal/bots/scroll/scroll.go
+++ internal/bots/scroll/scroll.go
@@ -12,10 +12,11 @@
12 import (
13 "context"
14 "encoding/json"
15 "fmt"
16 "log/slog"
17 "net"
18 "strconv"
19 "strings"
20 "sync"
21 "time"
22
@@ -206,12 +207,15 @@
207
208 return req, nil
209 }
210
211 func splitHostPort(addr string) (string, int, error) {
212 host, portStr, err := net.SplitHostPort(addr)
213 if err != nil {
 
214 return "", 0, fmt.Errorf("invalid address %q: %w", addr, err)
215 }
216 port, err := strconv.Atoi(portStr)
217 if err != nil {
218 return "", 0, fmt.Errorf("invalid port in %q: %w", addr, err)
219 }
220 return host, port, nil
221 }
222
--- internal/bots/systembot/systembot.go
+++ internal/bots/systembot/systembot.go
@@ -11,10 +11,12 @@
1111
1212
import (
1313
"context"
1414
"fmt"
1515
"log/slog"
16
+ "net"
17
+ "strconv"
1618
"strings"
1719
"time"
1820
1921
"github.com/lrstanley/girc"
2022
)
@@ -206,12 +208,15 @@
206208
b.log.Error("systembot: failed to write entry", "kind", e.Kind, "err", err)
207209
}
208210
}
209211
210212
func splitHostPort(addr string) (string, int, error) {
211
- var host string
212
- var port int
213
- if _, err := fmt.Sscanf(addr, "%[^:]:%d", &host, &port); err != nil {
213
+ host, portStr, err := net.SplitHostPort(addr)
214
+ if err != nil {
214215
return "", 0, fmt.Errorf("invalid address %q: %w", addr, err)
215216
}
217
+ port, err := strconv.Atoi(portStr)
218
+ if err != nil {
219
+ return "", 0, fmt.Errorf("invalid port in %q: %w", addr, err)
220
+ }
216221
return host, port, nil
217222
}
218223
--- internal/bots/systembot/systembot.go
+++ internal/bots/systembot/systembot.go
@@ -11,10 +11,12 @@
11
12 import (
13 "context"
14 "fmt"
15 "log/slog"
 
 
16 "strings"
17 "time"
18
19 "github.com/lrstanley/girc"
20 )
@@ -206,12 +208,15 @@
206 b.log.Error("systembot: failed to write entry", "kind", e.Kind, "err", err)
207 }
208 }
209
210 func splitHostPort(addr string) (string, int, error) {
211 var host string
212 var port int
213 if _, err := fmt.Sscanf(addr, "%[^:]:%d", &host, &port); err != nil {
214 return "", 0, fmt.Errorf("invalid address %q: %w", addr, err)
215 }
 
 
 
 
216 return host, port, nil
217 }
218
--- internal/bots/systembot/systembot.go
+++ internal/bots/systembot/systembot.go
@@ -11,10 +11,12 @@
11
12 import (
13 "context"
14 "fmt"
15 "log/slog"
16 "net"
17 "strconv"
18 "strings"
19 "time"
20
21 "github.com/lrstanley/girc"
22 )
@@ -206,12 +208,15 @@
208 b.log.Error("systembot: failed to write entry", "kind", e.Kind, "err", err)
209 }
210 }
211
212 func splitHostPort(addr string) (string, int, error) {
213 host, portStr, err := net.SplitHostPort(addr)
214 if err != nil {
 
215 return "", 0, fmt.Errorf("invalid address %q: %w", addr, err)
216 }
217 port, err := strconv.Atoi(portStr)
218 if err != nil {
219 return "", 0, fmt.Errorf("invalid port in %q: %w", addr, err)
220 }
221 return host, port, nil
222 }
223
--- internal/bots/warden/warden.go
+++ internal/bots/warden/warden.go
@@ -10,10 +10,12 @@
1010
1111
import (
1212
"context"
1313
"fmt"
1414
"log/slog"
15
+ "net"
16
+ "strconv"
1517
"strings"
1618
"sync"
1719
"time"
1820
1921
"github.com/lrstanley/girc"
@@ -303,19 +305,22 @@
303305
cl.Cmd.Kick(channel, nick, "warden: "+reason)
304306
}
305307
}
306308
307309
func splitHostPort(addr string) (string, int, error) {
308
- var host string
309
- var port int
310
- if _, err := fmt.Sscanf(addr, "%[^:]:%d", &host, &port); err != nil {
310
+ host, portStr, err := net.SplitHostPort(addr)
311
+ if err != nil {
311312
return "", 0, fmt.Errorf("invalid address %q: %w", addr, err)
312313
}
314
+ port, err := strconv.Atoi(portStr)
315
+ if err != nil {
316
+ return "", 0, fmt.Errorf("invalid port in %q: %w", addr, err)
317
+ }
313318
return host, port, nil
314319
}
315320
316321
func minF(a, b float64) float64 {
317322
if a < b {
318323
return a
319324
}
320325
return b
321326
}
322327
--- internal/bots/warden/warden.go
+++ internal/bots/warden/warden.go
@@ -10,10 +10,12 @@
10
11 import (
12 "context"
13 "fmt"
14 "log/slog"
 
 
15 "strings"
16 "sync"
17 "time"
18
19 "github.com/lrstanley/girc"
@@ -303,19 +305,22 @@
303 cl.Cmd.Kick(channel, nick, "warden: "+reason)
304 }
305 }
306
307 func splitHostPort(addr string) (string, int, error) {
308 var host string
309 var port int
310 if _, err := fmt.Sscanf(addr, "%[^:]:%d", &host, &port); err != nil {
311 return "", 0, fmt.Errorf("invalid address %q: %w", addr, err)
312 }
 
 
 
 
313 return host, port, nil
314 }
315
316 func minF(a, b float64) float64 {
317 if a < b {
318 return a
319 }
320 return b
321 }
322
--- internal/bots/warden/warden.go
+++ internal/bots/warden/warden.go
@@ -10,10 +10,12 @@
10
11 import (
12 "context"
13 "fmt"
14 "log/slog"
15 "net"
16 "strconv"
17 "strings"
18 "sync"
19 "time"
20
21 "github.com/lrstanley/girc"
@@ -303,19 +305,22 @@
305 cl.Cmd.Kick(channel, nick, "warden: "+reason)
306 }
307 }
308
309 func splitHostPort(addr string) (string, int, error) {
310 host, portStr, err := net.SplitHostPort(addr)
311 if err != nil {
 
312 return "", 0, fmt.Errorf("invalid address %q: %w", addr, err)
313 }
314 port, err := strconv.Atoi(portStr)
315 if err != nil {
316 return "", 0, fmt.Errorf("invalid port in %q: %w", addr, err)
317 }
318 return host, port, nil
319 }
320
321 func minF(a, b float64) float64 {
322 if a < b {
323 return a
324 }
325 return b
326 }
327
--- internal/config/config.go
+++ internal/config/config.go
@@ -175,10 +175,20 @@
175175
176176
// DefaultChannelModes sets channel modes applied when a new channel is
177177
// created. Common values: "+n" (no external messages), "+Rn" (registered
178178
// users only). Default: "+n"
179179
DefaultChannelModes string `yaml:"default_channel_modes"`
180
+
181
+ // TLSDomain enables a public TLS listener on TLSAddr with Let's Encrypt
182
+ // (ACME TLS-ALPN-01). When set, IRCAddr is kept as an internal plaintext
183
+ // listener for system bots, and a second TLS listener is added for
184
+ // external agents. Leave empty to use IRCAddr as the only listener.
185
+ TLSDomain string `yaml:"tls_domain"`
186
+
187
+ // TLSAddr is the address for the public TLS IRC listener.
188
+ // Only used when TLSDomain is set. Default: "0.0.0.0:6697"
189
+ TLSAddr string `yaml:"tls_addr"`
180190
181191
// History configures persistent message history storage.
182192
History HistoryConfig `yaml:"history"`
183193
}
184194
@@ -376,10 +386,13 @@
376386
if c.Ergo.ServerName == "" {
377387
c.Ergo.ServerName = "irc.scuttlebot.local"
378388
}
379389
if c.Ergo.IRCAddr == "" {
380390
c.Ergo.IRCAddr = "127.0.0.1:6667"
391
+ }
392
+ if c.Ergo.TLSDomain != "" && c.Ergo.TLSAddr == "" {
393
+ c.Ergo.TLSAddr = "0.0.0.0:6697"
381394
}
382395
if c.Ergo.APIAddr == "" {
383396
c.Ergo.APIAddr = "127.0.0.1:8089"
384397
}
385398
if c.Datastore.Driver == "" {
386399
--- internal/config/config.go
+++ internal/config/config.go
@@ -175,10 +175,20 @@
175
176 // DefaultChannelModes sets channel modes applied when a new channel is
177 // created. Common values: "+n" (no external messages), "+Rn" (registered
178 // users only). Default: "+n"
179 DefaultChannelModes string `yaml:"default_channel_modes"`
 
 
 
 
 
 
 
 
 
 
180
181 // History configures persistent message history storage.
182 History HistoryConfig `yaml:"history"`
183 }
184
@@ -376,10 +386,13 @@
376 if c.Ergo.ServerName == "" {
377 c.Ergo.ServerName = "irc.scuttlebot.local"
378 }
379 if c.Ergo.IRCAddr == "" {
380 c.Ergo.IRCAddr = "127.0.0.1:6667"
 
 
 
381 }
382 if c.Ergo.APIAddr == "" {
383 c.Ergo.APIAddr = "127.0.0.1:8089"
384 }
385 if c.Datastore.Driver == "" {
386
--- internal/config/config.go
+++ internal/config/config.go
@@ -175,10 +175,20 @@
175
176 // DefaultChannelModes sets channel modes applied when a new channel is
177 // created. Common values: "+n" (no external messages), "+Rn" (registered
178 // users only). Default: "+n"
179 DefaultChannelModes string `yaml:"default_channel_modes"`
180
181 // TLSDomain enables a public TLS listener on TLSAddr with Let's Encrypt
182 // (ACME TLS-ALPN-01). When set, IRCAddr is kept as an internal plaintext
183 // listener for system bots, and a second TLS listener is added for
184 // external agents. Leave empty to use IRCAddr as the only listener.
185 TLSDomain string `yaml:"tls_domain"`
186
187 // TLSAddr is the address for the public TLS IRC listener.
188 // Only used when TLSDomain is set. Default: "0.0.0.0:6697"
189 TLSAddr string `yaml:"tls_addr"`
190
191 // History configures persistent message history storage.
192 History HistoryConfig `yaml:"history"`
193 }
194
@@ -376,10 +386,13 @@
386 if c.Ergo.ServerName == "" {
387 c.Ergo.ServerName = "irc.scuttlebot.local"
388 }
389 if c.Ergo.IRCAddr == "" {
390 c.Ergo.IRCAddr = "127.0.0.1:6667"
391 }
392 if c.Ergo.TLSDomain != "" && c.Ergo.TLSAddr == "" {
393 c.Ergo.TLSAddr = "0.0.0.0:6697"
394 }
395 if c.Ergo.APIAddr == "" {
396 c.Ergo.APIAddr = "127.0.0.1:8089"
397 }
398 if c.Datastore.Driver == "" {
399
--- internal/ergo/ircdconfig.go
+++ internal/ergo/ircdconfig.go
@@ -13,10 +13,16 @@
1313
1414
server:
1515
name: {{.ServerName}}
1616
listeners:
1717
"{{.IRCAddr}}": {}
18
+{{- if .TLSDomain}}
19
+ "{{.TLSAddr}}":
20
+ tls:
21
+ autocert: true
22
+ min-tls-version: 1.2
23
+{{- end}}
1824
casemapping: ascii
1925
enforce-utf8: true
2026
max-sendq: 96k
2127
relaymsg:
2228
enabled: false
@@ -99,10 +105,12 @@
99105
100106
type ircdTemplateData struct {
101107
NetworkName string
102108
ServerName string
103109
IRCAddr string
110
+ TLSDomain string
111
+ TLSAddr string
104112
DataDir string
105113
APIAddr string
106114
APIToken string
107115
HistoryEnabled bool
108116
PostgresDSN string
@@ -116,10 +124,12 @@
116124
func GenerateConfig(cfg config.ErgoConfig) ([]byte, error) {
117125
data := ircdTemplateData{
118126
NetworkName: cfg.NetworkName,
119127
ServerName: cfg.ServerName,
120128
IRCAddr: cfg.IRCAddr,
129
+ TLSDomain: cfg.TLSDomain,
130
+ TLSAddr: cfg.TLSAddr,
121131
DataDir: cfg.DataDir,
122132
APIAddr: cfg.APIAddr,
123133
APIToken: cfg.APIToken,
124134
HistoryEnabled: cfg.History.Enabled,
125135
PostgresDSN: cfg.History.PostgresDSN,
126136
127137
ADDED skills/project-setup/SKILL.md
--- internal/ergo/ircdconfig.go
+++ internal/ergo/ircdconfig.go
@@ -13,10 +13,16 @@
13
14 server:
15 name: {{.ServerName}}
16 listeners:
17 "{{.IRCAddr}}": {}
 
 
 
 
 
 
18 casemapping: ascii
19 enforce-utf8: true
20 max-sendq: 96k
21 relaymsg:
22 enabled: false
@@ -99,10 +105,12 @@
99
100 type ircdTemplateData struct {
101 NetworkName string
102 ServerName string
103 IRCAddr string
 
 
104 DataDir string
105 APIAddr string
106 APIToken string
107 HistoryEnabled bool
108 PostgresDSN string
@@ -116,10 +124,12 @@
116 func GenerateConfig(cfg config.ErgoConfig) ([]byte, error) {
117 data := ircdTemplateData{
118 NetworkName: cfg.NetworkName,
119 ServerName: cfg.ServerName,
120 IRCAddr: cfg.IRCAddr,
 
 
121 DataDir: cfg.DataDir,
122 APIAddr: cfg.APIAddr,
123 APIToken: cfg.APIToken,
124 HistoryEnabled: cfg.History.Enabled,
125 PostgresDSN: cfg.History.PostgresDSN,
126
127 DDED skills/project-setup/SKILL.md
--- internal/ergo/ircdconfig.go
+++ internal/ergo/ircdconfig.go
@@ -13,10 +13,16 @@
13
14 server:
15 name: {{.ServerName}}
16 listeners:
17 "{{.IRCAddr}}": {}
18 {{- if .TLSDomain}}
19 "{{.TLSAddr}}":
20 tls:
21 autocert: true
22 min-tls-version: 1.2
23 {{- end}}
24 casemapping: ascii
25 enforce-utf8: true
26 max-sendq: 96k
27 relaymsg:
28 enabled: false
@@ -99,10 +105,12 @@
105
106 type ircdTemplateData struct {
107 NetworkName string
108 ServerName string
109 IRCAddr string
110 TLSDomain string
111 TLSAddr string
112 DataDir string
113 APIAddr string
114 APIToken string
115 HistoryEnabled bool
116 PostgresDSN string
@@ -116,10 +124,12 @@
124 func GenerateConfig(cfg config.ErgoConfig) ([]byte, error) {
125 data := ircdTemplateData{
126 NetworkName: cfg.NetworkName,
127 ServerName: cfg.ServerName,
128 IRCAddr: cfg.IRCAddr,
129 TLSDomain: cfg.TLSDomain,
130 TLSAddr: cfg.TLSAddr,
131 DataDir: cfg.DataDir,
132 APIAddr: cfg.APIAddr,
133 APIToken: cfg.APIToken,
134 HistoryEnabled: cfg.History.Enabled,
135 PostgresDSN: cfg.History.PostgresDSN,
136
137 DDED skills/project-setup/SKILL.md
--- a/skills/project-setup/SKILL.md
+++ b/skills/project-setup/SKILL.md
@@ -0,0 +1,123 @@
1
+---
2
+name: project-setup
3
+description: Wire any project repo for scuttlebot IRC coordination — creates .scuttlebot.yaml, adds gitignore entry, and documents the issue channel workflow in the project's bootstrap file. Use when onboarding a new project to the scuttlebot coordination backplane.
4
+---
5
+
6
+# Project Setup for Scuttlebot Coordination
7
+
8
+Use this skill to wire a project repo into the scuttlebot coordination backplane.
9
+This sets up per-project IRC channels and the issue-based workflow so agents
10
+working in the repo automatically coordinate through scuttlebot.
11
+
12
+## What this skill does
13
+
14
+1. Creates `.scuttlebot.yaml` in the project root (gitignored)
15
+2. Adds `.scuttlebot.yaml` to `.gitignore`
16
+3. Adds an IRC coordination section to the project's bootstrap doc
17
+
18
+## Channel hierarchy
19
+
20
+Every project uses three channel tiers:
21
+
22
+| Tier | Channel | Purpose | Lifecycle |
23
+|------|---------|---------|-----------|
24
+| General | `#general` | Cross-project coordination, operator chatter | Always joined |
25
+| Project | `#<project-name>` | Project-specific coordination, status, discussion | Joined at relay startup via `.scuttlebot.yaml` |
26
+| Issue | `#issue-<N>` | Solo work channel for a specific GitHub issue | `/join` when starting, `/part` when done |
27
+
28
+## Per-repo config: `.scuttlebot.yaml`
29
+
30
+Created in the project root, gitignored. The relay reads this at startup and
31
+merges its channels into the session channel set.
32
+
33
+```yaml
34
+# .scuttlebot.yaml — per-repo scuttlebot relay config (gitignored)
35
+channel: <project-name>
36
+```
37
+
38
+That's it. One field. The relay handles the rest.
39
+
40
+Optional additional channels:
41
+
42
+```yaml
43
+channel: <project-name>
44
+channels:
45
+ - ops
46
+ - deployments
47
+```
48
+
49
+## Issue channel workflow
50
+
51
+When an agent picks up a GitHub issue, it should:
52
+
53
+1. `/join #issue-<N>` — join the issue channel (auto-created if it doesn't exist)
54
+2. Work in that channel — all activity mirrors there
55
+3. `/part #issue-<N>` — leave when the issue is closed or work is complete
56
+
57
+This gives operators per-issue observability. Multiple agents on different issues
58
+work in isolation. An operator can watch `#kohakku` for project-level activity or
59
+drill into `#issue-42` for a specific task.
60
+
61
+## Bootstrap doc section
62
+
63
+Add this to the project's `bootstrap.md` (or equivalent conventions doc):
64
+
65
+```markdown
66
+## IRC Coordination
67
+
68
+This project uses scuttlebot for agent coordination via IRC.
69
+
70
+### Channels
71
+
72
+- `#general` — cross-project coordination (always joined)
73
+- `#<project-name>` — project coordination (auto-joined via `.scuttlebot.yaml`)
74
+- `#issue-<N>` — per-issue work channel (join/part dynamically)
75
+
76
+### Issue workflow
77
+
78
+When you start working on a GitHub issue:
79
+
80
+1. Join the issue channel: send `/join #issue-<N>` where N is the issue number
81
+2. Do your work — activity is mirrored to both the project channel and the issue channel
82
+3. When done, part the issue channel: send `/part #issue-<N>`
83
+
84
+### Setup
85
+
86
+The `.scuttlebot.yaml` file in the project root configures the relay to auto-join
87
+the project channel. This file is gitignored — each developer/agent creates their
88
+own. The relay config at `~/.config/scuttlebot-relay.env` provides the server
89
+URL, token, and transport settings.
90
+```
91
+
92
+## Step-by-step setup
93
+
94
+### For a new project
95
+
96
+Given a project named `myproject` in a repo at `/path/to/myproject`:
97
+
98
+1. Create `.scuttlebot.yaml`:
99
+ ```yaml
100
+ channel: myproject
101
+ ```
102
+
103
+2. Add to `.gitignore`:
104
+ ```
105
+ .scuttlebot.yaml
106
+ ```
107
+
108
+3. Add the IRC Coordination section to the project's bootstrap doc.
109
+
110
+4. Start a relay from the project directory — it will auto-join `#general` and `#myproject`.
111
+
112
+### For an existing project
113
+
114
+Same steps. The relay picks up `.scuttlebot.yaml` on next startup. No server-side
115
+config needed — channels are created on demand by Ergo when the first user joins.
116
+
117
+## What NOT to put in `.scuttlebot.yaml`
118
+
119
+- Tokens or credentials (use `~/.config/scuttlebot-relay.env`)
120
+- Server URL (use the global relay config)
121
+- Transport settings (use the global relay config)
122
+
123
+The per-repo file is only for channel routing. Everything else is global.
--- a/skills/project-setup/SKILL.md
+++ b/skills/project-setup/SKILL.md
@@ -0,0 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/skills/project-setup/SKILL.md
+++ b/skills/project-setup/SKILL.md
@@ -0,0 +1,123 @@
1 ---
2 name: project-setup
3 description: Wire any project repo for scuttlebot IRC coordination — creates .scuttlebot.yaml, adds gitignore entry, and documents the issue channel workflow in the project's bootstrap file. Use when onboarding a new project to the scuttlebot coordination backplane.
4 ---
5
6 # Project Setup for Scuttlebot Coordination
7
8 Use this skill to wire a project repo into the scuttlebot coordination backplane.
9 This sets up per-project IRC channels and the issue-based workflow so agents
10 working in the repo automatically coordinate through scuttlebot.
11
12 ## What this skill does
13
14 1. Creates `.scuttlebot.yaml` in the project root (gitignored)
15 2. Adds `.scuttlebot.yaml` to `.gitignore`
16 3. Adds an IRC coordination section to the project's bootstrap doc
17
18 ## Channel hierarchy
19
20 Every project uses three channel tiers:
21
22 | Tier | Channel | Purpose | Lifecycle |
23 |------|---------|---------|-----------|
24 | General | `#general` | Cross-project coordination, operator chatter | Always joined |
25 | Project | `#<project-name>` | Project-specific coordination, status, discussion | Joined at relay startup via `.scuttlebot.yaml` |
26 | Issue | `#issue-<N>` | Solo work channel for a specific GitHub issue | `/join` when starting, `/part` when done |
27
28 ## Per-repo config: `.scuttlebot.yaml`
29
30 Created in the project root, gitignored. The relay reads this at startup and
31 merges its channels into the session channel set.
32
33 ```yaml
34 # .scuttlebot.yaml — per-repo scuttlebot relay config (gitignored)
35 channel: <project-name>
36 ```
37
38 That's it. One field. The relay handles the rest.
39
40 Optional additional channels:
41
42 ```yaml
43 channel: <project-name>
44 channels:
45 - ops
46 - deployments
47 ```
48
49 ## Issue channel workflow
50
51 When an agent picks up a GitHub issue, it should:
52
53 1. `/join #issue-<N>` — join the issue channel (auto-created if it doesn't exist)
54 2. Work in that channel — all activity mirrors there
55 3. `/part #issue-<N>` — leave when the issue is closed or work is complete
56
57 This gives operators per-issue observability. Multiple agents on different issues
58 work in isolation. An operator can watch `#kohakku` for project-level activity or
59 drill into `#issue-42` for a specific task.
60
61 ## Bootstrap doc section
62
63 Add this to the project's `bootstrap.md` (or equivalent conventions doc):
64
65 ```markdown
66 ## IRC Coordination
67
68 This project uses scuttlebot for agent coordination via IRC.
69
70 ### Channels
71
72 - `#general` — cross-project coordination (always joined)
73 - `#<project-name>` — project coordination (auto-joined via `.scuttlebot.yaml`)
74 - `#issue-<N>` — per-issue work channel (join/part dynamically)
75
76 ### Issue workflow
77
78 When you start working on a GitHub issue:
79
80 1. Join the issue channel: send `/join #issue-<N>` where N is the issue number
81 2. Do your work — activity is mirrored to both the project channel and the issue channel
82 3. When done, part the issue channel: send `/part #issue-<N>`
83
84 ### Setup
85
86 The `.scuttlebot.yaml` file in the project root configures the relay to auto-join
87 the project channel. This file is gitignored — each developer/agent creates their
88 own. The relay config at `~/.config/scuttlebot-relay.env` provides the server
89 URL, token, and transport settings.
90 ```
91
92 ## Step-by-step setup
93
94 ### For a new project
95
96 Given a project named `myproject` in a repo at `/path/to/myproject`:
97
98 1. Create `.scuttlebot.yaml`:
99 ```yaml
100 channel: myproject
101 ```
102
103 2. Add to `.gitignore`:
104 ```
105 .scuttlebot.yaml
106 ```
107
108 3. Add the IRC Coordination section to the project's bootstrap doc.
109
110 4. Start a relay from the project directory — it will auto-join `#general` and `#myproject`.
111
112 ### For an existing project
113
114 Same steps. The relay picks up `.scuttlebot.yaml` on next startup. No server-side
115 config needed — channels are created on demand by Ergo when the first user joins.
116
117 ## What NOT to put in `.scuttlebot.yaml`
118
119 - Tokens or credentials (use `~/.config/scuttlebot-relay.env`)
120 - Server URL (use the global relay config)
121 - Transport settings (use the global relay config)
122
123 The per-repo file is only for channel routing. Everything else is global.

Keyboard Shortcuts

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