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
Commit
18e8fef0b93ce61cee6d004ccdec09f58db4ef50eac5fa323175240624189fa8
Parent
33a0aefa6fc4fa1…
12 files changed
+1
+66
+66
+66
+8
-3
+8
-3
+7
-3
+8
-3
+8
-3
+13
+10
+123
~
.gitignore
~
cmd/claude-relay/main.go
~
cmd/codex-relay/main.go
~
cmd/gemini-relay/main.go
~
internal/bots/auditbot/auditbot.go
~
internal/bots/herald/herald.go
~
internal/bots/scroll/scroll.go
~
internal/bots/systembot/systembot.go
~
internal/bots/warden/warden.go
~
internal/config/config.go
~
internal/ergo/ircdconfig.go
+
skills/project-setup/SKILL.md
+1
| --- .gitignore | ||
| +++ .gitignore | ||
| @@ -4,10 +4,11 @@ | ||
| 4 | 4 | bin/* |
| 5 | 5 | !bin/.gitkeep |
| 6 | 6 | *.log |
| 7 | 7 | *.pid |
| 8 | 8 | scuttlebot.yaml |
| 9 | +.scuttlebot.yaml | |
| 9 | 10 | tests/e2e/ |
| 10 | 11 | tmp-*.sh |
| 11 | 12 | REPORT.md |
| 12 | 13 | coverage.out |
| 13 | 14 | test-results/ |
| 14 | 15 |
| --- .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 |
+66
| --- cmd/claude-relay/main.go | ||
| +++ cmd/claude-relay/main.go | ||
| @@ -21,10 +21,11 @@ | ||
| 21 | 21 | |
| 22 | 22 | "github.com/conflicthq/scuttlebot/pkg/ircagent" |
| 23 | 23 | "github.com/conflicthq/scuttlebot/pkg/sessionrelay" |
| 24 | 24 | "github.com/creack/pty" |
| 25 | 25 | "golang.org/x/term" |
| 26 | + "gopkg.in/yaml.v3" | |
| 26 | 27 | ) |
| 27 | 28 | |
| 28 | 29 | const ( |
| 29 | 30 | defaultRelayURL = "http://localhost:8080" |
| 30 | 31 | defaultIRCAddr = "127.0.0.1:6667" |
| @@ -846,10 +847,15 @@ | ||
| 846 | 847 | target, err := targetCWD(args) |
| 847 | 848 | if err != nil { |
| 848 | 849 | return config{}, err |
| 849 | 850 | } |
| 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 | + } | |
| 851 | 857 | |
| 852 | 858 | sessionID := getenvOr(fileConfig, "SCUTTLEBOT_SESSION_ID", "") |
| 853 | 859 | if sessionID == "" { |
| 854 | 860 | sessionID = defaultSessionID(target) |
| 855 | 861 | } |
| @@ -1066,5 +1072,65 @@ | ||
| 1066 | 1072 | if errors.As(err, &exitErr) { |
| 1067 | 1073 | return exitErr.ExitCode() |
| 1068 | 1074 | } |
| 1069 | 1075 | return 1 |
| 1070 | 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 | +} | |
| 1071 | 1137 |
| --- 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 |
+66
| --- cmd/codex-relay/main.go | ||
| +++ cmd/codex-relay/main.go | ||
| @@ -21,10 +21,11 @@ | ||
| 21 | 21 | |
| 22 | 22 | "github.com/conflicthq/scuttlebot/pkg/ircagent" |
| 23 | 23 | "github.com/conflicthq/scuttlebot/pkg/sessionrelay" |
| 24 | 24 | "github.com/creack/pty" |
| 25 | 25 | "golang.org/x/term" |
| 26 | + "gopkg.in/yaml.v3" | |
| 26 | 27 | ) |
| 27 | 28 | |
| 28 | 29 | const ( |
| 29 | 30 | defaultRelayURL = "http://localhost:8080" |
| 30 | 31 | defaultIRCAddr = "127.0.0.1:6667" |
| @@ -537,10 +538,15 @@ | ||
| 537 | 538 | target, err := targetCWD(args) |
| 538 | 539 | if err != nil { |
| 539 | 540 | return config{}, err |
| 540 | 541 | } |
| 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 | + } | |
| 542 | 548 | |
| 543 | 549 | sessionID := getenvOr(fileConfig, "SCUTTLEBOT_SESSION_ID", "") |
| 544 | 550 | if sessionID == "" { |
| 545 | 551 | sessionID = getenvOr(fileConfig, "CODEX_SESSION_ID", "") |
| 546 | 552 | } |
| @@ -1132,5 +1138,65 @@ | ||
| 1132 | 1138 | if errors.As(err, &exitErr) { |
| 1133 | 1139 | return exitErr.ExitCode() |
| 1134 | 1140 | } |
| 1135 | 1141 | return 1 |
| 1136 | 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 | +} | |
| 1137 | 1203 |
| --- 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 |
+66
| --- cmd/gemini-relay/main.go | ||
| +++ cmd/gemini-relay/main.go | ||
| @@ -19,10 +19,11 @@ | ||
| 19 | 19 | |
| 20 | 20 | "github.com/conflicthq/scuttlebot/pkg/ircagent" |
| 21 | 21 | "github.com/conflicthq/scuttlebot/pkg/sessionrelay" |
| 22 | 22 | "github.com/creack/pty" |
| 23 | 23 | "golang.org/x/term" |
| 24 | + "gopkg.in/yaml.v3" | |
| 24 | 25 | ) |
| 25 | 26 | |
| 26 | 27 | const ( |
| 27 | 28 | defaultRelayURL = "http://localhost:8080" |
| 28 | 29 | defaultIRCAddr = "127.0.0.1:6667" |
| @@ -491,10 +492,15 @@ | ||
| 491 | 492 | target, err := targetCWD(args) |
| 492 | 493 | if err != nil { |
| 493 | 494 | return config{}, err |
| 494 | 495 | } |
| 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 | + } | |
| 496 | 502 | |
| 497 | 503 | sessionID := getenvOr(fileConfig, "SCUTTLEBOT_SESSION_ID", "") |
| 498 | 504 | if sessionID == "" { |
| 499 | 505 | sessionID = getenvOr(fileConfig, "GEMINI_SESSION_ID", "") |
| 500 | 506 | } |
| @@ -714,5 +720,65 @@ | ||
| 714 | 720 | if errors.As(err, &exitErr) { |
| 715 | 721 | return exitErr.ExitCode() |
| 716 | 722 | } |
| 717 | 723 | return 1 |
| 718 | 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 | +} | |
| 719 | 785 |
| --- 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 @@ | ||
| 13 | 13 | |
| 14 | 14 | import ( |
| 15 | 15 | "context" |
| 16 | 16 | "fmt" |
| 17 | 17 | "log/slog" |
| 18 | + "net" | |
| 19 | + "strconv" | |
| 18 | 20 | "strings" |
| 19 | 21 | "time" |
| 20 | 22 | |
| 21 | 23 | "github.com/lrstanley/girc" |
| 22 | 24 | |
| @@ -203,12 +205,15 @@ | ||
| 203 | 205 | } |
| 204 | 206 | return out |
| 205 | 207 | } |
| 206 | 208 | |
| 207 | 209 | 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 { | |
| 211 | 212 | return "", 0, fmt.Errorf("invalid address %q: %w", addr, err) |
| 212 | 213 | } |
| 214 | + port, err := strconv.Atoi(portStr) | |
| 215 | + if err != nil { | |
| 216 | + return "", 0, fmt.Errorf("invalid port in %q: %w", addr, err) | |
| 217 | + } | |
| 213 | 218 | return host, port, nil |
| 214 | 219 | } |
| 215 | 220 |
| --- 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 |
+8
-3
| --- internal/bots/herald/herald.go | ||
| +++ internal/bots/herald/herald.go | ||
| @@ -10,10 +10,12 @@ | ||
| 10 | 10 | |
| 11 | 11 | import ( |
| 12 | 12 | "context" |
| 13 | 13 | "fmt" |
| 14 | 14 | "log/slog" |
| 15 | + "net" | |
| 16 | + "strconv" | |
| 15 | 17 | "strings" |
| 16 | 18 | "sync" |
| 17 | 19 | "time" |
| 18 | 20 | |
| 19 | 21 | "github.com/lrstanley/girc" |
| @@ -243,19 +245,22 @@ | ||
| 243 | 245 | } |
| 244 | 246 | return b.routes.DefaultChannel |
| 245 | 247 | } |
| 246 | 248 | |
| 247 | 249 | 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 { | |
| 251 | 252 | return "", 0, fmt.Errorf("invalid address %q: %w", addr, err) |
| 252 | 253 | } |
| 254 | + port, err := strconv.Atoi(portStr) | |
| 255 | + if err != nil { | |
| 256 | + return "", 0, fmt.Errorf("invalid port in %q: %w", addr, err) | |
| 257 | + } | |
| 253 | 258 | return host, port, nil |
| 254 | 259 | } |
| 255 | 260 | |
| 256 | 261 | func min(a, b float64) float64 { |
| 257 | 262 | if a < b { |
| 258 | 263 | return a |
| 259 | 264 | } |
| 260 | 265 | return b |
| 261 | 266 | } |
| 262 | 267 |
| --- 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 |
+7
-3
| --- internal/bots/scroll/scroll.go | ||
| +++ internal/bots/scroll/scroll.go | ||
| @@ -12,10 +12,11 @@ | ||
| 12 | 12 | import ( |
| 13 | 13 | "context" |
| 14 | 14 | "encoding/json" |
| 15 | 15 | "fmt" |
| 16 | 16 | "log/slog" |
| 17 | + "net" | |
| 17 | 18 | "strconv" |
| 18 | 19 | "strings" |
| 19 | 20 | "sync" |
| 20 | 21 | "time" |
| 21 | 22 | |
| @@ -206,12 +207,15 @@ | ||
| 206 | 207 | |
| 207 | 208 | return req, nil |
| 208 | 209 | } |
| 209 | 210 | |
| 210 | 211 | 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 { | |
| 214 | 214 | return "", 0, fmt.Errorf("invalid address %q: %w", addr, err) |
| 215 | 215 | } |
| 216 | + port, err := strconv.Atoi(portStr) | |
| 217 | + if err != nil { | |
| 218 | + return "", 0, fmt.Errorf("invalid port in %q: %w", addr, err) | |
| 219 | + } | |
| 216 | 220 | return host, port, nil |
| 217 | 221 | } |
| 218 | 222 |
| --- 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 @@ | ||
| 11 | 11 | |
| 12 | 12 | import ( |
| 13 | 13 | "context" |
| 14 | 14 | "fmt" |
| 15 | 15 | "log/slog" |
| 16 | + "net" | |
| 17 | + "strconv" | |
| 16 | 18 | "strings" |
| 17 | 19 | "time" |
| 18 | 20 | |
| 19 | 21 | "github.com/lrstanley/girc" |
| 20 | 22 | ) |
| @@ -206,12 +208,15 @@ | ||
| 206 | 208 | b.log.Error("systembot: failed to write entry", "kind", e.Kind, "err", err) |
| 207 | 209 | } |
| 208 | 210 | } |
| 209 | 211 | |
| 210 | 212 | 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 { | |
| 214 | 215 | return "", 0, fmt.Errorf("invalid address %q: %w", addr, err) |
| 215 | 216 | } |
| 217 | + port, err := strconv.Atoi(portStr) | |
| 218 | + if err != nil { | |
| 219 | + return "", 0, fmt.Errorf("invalid port in %q: %w", addr, err) | |
| 220 | + } | |
| 216 | 221 | return host, port, nil |
| 217 | 222 | } |
| 218 | 223 |
| --- 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 |
+8
-3
| --- internal/bots/warden/warden.go | ||
| +++ internal/bots/warden/warden.go | ||
| @@ -10,10 +10,12 @@ | ||
| 10 | 10 | |
| 11 | 11 | import ( |
| 12 | 12 | "context" |
| 13 | 13 | "fmt" |
| 14 | 14 | "log/slog" |
| 15 | + "net" | |
| 16 | + "strconv" | |
| 15 | 17 | "strings" |
| 16 | 18 | "sync" |
| 17 | 19 | "time" |
| 18 | 20 | |
| 19 | 21 | "github.com/lrstanley/girc" |
| @@ -303,19 +305,22 @@ | ||
| 303 | 305 | cl.Cmd.Kick(channel, nick, "warden: "+reason) |
| 304 | 306 | } |
| 305 | 307 | } |
| 306 | 308 | |
| 307 | 309 | 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 { | |
| 311 | 312 | return "", 0, fmt.Errorf("invalid address %q: %w", addr, err) |
| 312 | 313 | } |
| 314 | + port, err := strconv.Atoi(portStr) | |
| 315 | + if err != nil { | |
| 316 | + return "", 0, fmt.Errorf("invalid port in %q: %w", addr, err) | |
| 317 | + } | |
| 313 | 318 | return host, port, nil |
| 314 | 319 | } |
| 315 | 320 | |
| 316 | 321 | func minF(a, b float64) float64 { |
| 317 | 322 | if a < b { |
| 318 | 323 | return a |
| 319 | 324 | } |
| 320 | 325 | return b |
| 321 | 326 | } |
| 322 | 327 |
| --- 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 @@ | ||
| 175 | 175 | |
| 176 | 176 | // DefaultChannelModes sets channel modes applied when a new channel is |
| 177 | 177 | // created. Common values: "+n" (no external messages), "+Rn" (registered |
| 178 | 178 | // users only). Default: "+n" |
| 179 | 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"` | |
| 180 | 190 | |
| 181 | 191 | // History configures persistent message history storage. |
| 182 | 192 | History HistoryConfig `yaml:"history"` |
| 183 | 193 | } |
| 184 | 194 | |
| @@ -376,10 +386,13 @@ | ||
| 376 | 386 | if c.Ergo.ServerName == "" { |
| 377 | 387 | c.Ergo.ServerName = "irc.scuttlebot.local" |
| 378 | 388 | } |
| 379 | 389 | if c.Ergo.IRCAddr == "" { |
| 380 | 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" | |
| 381 | 394 | } |
| 382 | 395 | if c.Ergo.APIAddr == "" { |
| 383 | 396 | c.Ergo.APIAddr = "127.0.0.1:8089" |
| 384 | 397 | } |
| 385 | 398 | if c.Datastore.Driver == "" { |
| 386 | 399 |
| --- 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 @@ | ||
| 13 | 13 | |
| 14 | 14 | server: |
| 15 | 15 | name: {{.ServerName}} |
| 16 | 16 | listeners: |
| 17 | 17 | "{{.IRCAddr}}": {} |
| 18 | +{{- if .TLSDomain}} | |
| 19 | + "{{.TLSAddr}}": | |
| 20 | + tls: | |
| 21 | + autocert: true | |
| 22 | + min-tls-version: 1.2 | |
| 23 | +{{- end}} | |
| 18 | 24 | casemapping: ascii |
| 19 | 25 | enforce-utf8: true |
| 20 | 26 | max-sendq: 96k |
| 21 | 27 | relaymsg: |
| 22 | 28 | enabled: false |
| @@ -99,10 +105,12 @@ | ||
| 99 | 105 | |
| 100 | 106 | type ircdTemplateData struct { |
| 101 | 107 | NetworkName string |
| 102 | 108 | ServerName string |
| 103 | 109 | IRCAddr string |
| 110 | + TLSDomain string | |
| 111 | + TLSAddr string | |
| 104 | 112 | DataDir string |
| 105 | 113 | APIAddr string |
| 106 | 114 | APIToken string |
| 107 | 115 | HistoryEnabled bool |
| 108 | 116 | PostgresDSN string |
| @@ -116,10 +124,12 @@ | ||
| 116 | 124 | func GenerateConfig(cfg config.ErgoConfig) ([]byte, error) { |
| 117 | 125 | data := ircdTemplateData{ |
| 118 | 126 | NetworkName: cfg.NetworkName, |
| 119 | 127 | ServerName: cfg.ServerName, |
| 120 | 128 | IRCAddr: cfg.IRCAddr, |
| 129 | + TLSDomain: cfg.TLSDomain, | |
| 130 | + TLSAddr: cfg.TLSAddr, | |
| 121 | 131 | DataDir: cfg.DataDir, |
| 122 | 132 | APIAddr: cfg.APIAddr, |
| 123 | 133 | APIToken: cfg.APIToken, |
| 124 | 134 | HistoryEnabled: cfg.History.Enabled, |
| 125 | 135 | PostgresDSN: cfg.History.PostgresDSN, |
| 126 | 136 | |
| 127 | 137 | 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. |