superkick 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (199) hide show
  1. checksums.yaml +7 -0
  2. data/CLA.md +91 -0
  3. data/CLAUDE.md +2226 -0
  4. data/CONTRIBUTING.md +104 -0
  5. data/LICENSE +108 -0
  6. data/LICENSE-COMMERCIAL.md +39 -0
  7. data/PLAN.md +161 -0
  8. data/README.md +1155 -0
  9. data/exe/superkick +6 -0
  10. data/lib/superkick/agent/runtime.rb +82 -0
  11. data/lib/superkick/agent/runtimes/local.rb +74 -0
  12. data/lib/superkick/agent/runtimes.rb +4 -0
  13. data/lib/superkick/agent.rb +209 -0
  14. data/lib/superkick/agent_store.rb +85 -0
  15. data/lib/superkick/attach/client.rb +245 -0
  16. data/lib/superkick/attach/protocol.rb +71 -0
  17. data/lib/superkick/attach/server.rb +371 -0
  18. data/lib/superkick/budget_checker.rb +120 -0
  19. data/lib/superkick/buffer/client.rb +91 -0
  20. data/lib/superkick/buffer/server.rb +127 -0
  21. data/lib/superkick/cli/agent.rb +524 -0
  22. data/lib/superkick/cli/completion.rb +591 -0
  23. data/lib/superkick/cli/goal.rb +71 -0
  24. data/lib/superkick/cli/mcp.rb +34 -0
  25. data/lib/superkick/cli/monitor.rb +47 -0
  26. data/lib/superkick/cli/notifier.rb +39 -0
  27. data/lib/superkick/cli/repository.rb +46 -0
  28. data/lib/superkick/cli/server.rb +106 -0
  29. data/lib/superkick/cli/setup.rb +166 -0
  30. data/lib/superkick/cli/spawner.rb +85 -0
  31. data/lib/superkick/cli/team.rb +407 -0
  32. data/lib/superkick/cli.rb +175 -0
  33. data/lib/superkick/client_registry.rb +30 -0
  34. data/lib/superkick/configuration.rb +178 -0
  35. data/lib/superkick/connection.rb +56 -0
  36. data/lib/superkick/control/client.rb +78 -0
  37. data/lib/superkick/control/reply.rb +43 -0
  38. data/lib/superkick/control/server.rb +1271 -0
  39. data/lib/superkick/cost_accumulator.rb +53 -0
  40. data/lib/superkick/cost_extractor.rb +65 -0
  41. data/lib/superkick/cost_poller.rb +70 -0
  42. data/lib/superkick/driver/profile_source.rb +134 -0
  43. data/lib/superkick/driver.rb +179 -0
  44. data/lib/superkick/drivers/claude_code.rb +110 -0
  45. data/lib/superkick/drivers/codex.rb +57 -0
  46. data/lib/superkick/drivers/copilot.rb +75 -0
  47. data/lib/superkick/drivers/gemini.rb +86 -0
  48. data/lib/superkick/drivers/goose.rb +74 -0
  49. data/lib/superkick/drivers.rb +16 -0
  50. data/lib/superkick/drop.rb +80 -0
  51. data/lib/superkick/drops.rb +76 -0
  52. data/lib/superkick/environment_executor.rb +90 -0
  53. data/lib/superkick/goal.rb +95 -0
  54. data/lib/superkick/goals/agent_exit.rb +41 -0
  55. data/lib/superkick/goals/agent_signal.rb +42 -0
  56. data/lib/superkick/goals/command.rb +103 -0
  57. data/lib/superkick/history_buffer.rb +38 -0
  58. data/lib/superkick/hosted/attach/bridge.rb +52 -0
  59. data/lib/superkick/hosted/attach/client.rb +208 -0
  60. data/lib/superkick/hosted/attach/relay.rb +313 -0
  61. data/lib/superkick/hosted/attach/relay_store.rb +48 -0
  62. data/lib/superkick/hosted/bridge.rb +263 -0
  63. data/lib/superkick/hosted/buffer/bridge.rb +42 -0
  64. data/lib/superkick/hosted/buffer/client.rb +63 -0
  65. data/lib/superkick/hosted/buffer/relay.rb +126 -0
  66. data/lib/superkick/hosted/buffer/relay_store.rb +42 -0
  67. data/lib/superkick/hosted/control/client.rb +84 -0
  68. data/lib/superkick/hosted/mcp_proxy.rb +144 -0
  69. data/lib/superkick/inject_handler.rb +24 -0
  70. data/lib/superkick/injection_guard.rb +26 -0
  71. data/lib/superkick/injection_queue.rb +177 -0
  72. data/lib/superkick/injector.rb +65 -0
  73. data/lib/superkick/input_buffer.rb +171 -0
  74. data/lib/superkick/integrations/bugsnag/README.md +98 -0
  75. data/lib/superkick/integrations/bugsnag/spawner.rb +307 -0
  76. data/lib/superkick/integrations/bugsnag/templates/error_opened.liquid +17 -0
  77. data/lib/superkick/integrations/bugsnag.rb +7 -0
  78. data/lib/superkick/integrations/circleci/README.md +75 -0
  79. data/lib/superkick/integrations/circleci/monitor.rb +185 -0
  80. data/lib/superkick/integrations/circleci/probe.rb +36 -0
  81. data/lib/superkick/integrations/circleci/templates/ci_failure.liquid +8 -0
  82. data/lib/superkick/integrations/circleci/templates/ci_success.liquid +1 -0
  83. data/lib/superkick/integrations/circleci.rb +8 -0
  84. data/lib/superkick/integrations/datadog/README.md +253 -0
  85. data/lib/superkick/integrations/datadog/alert_goal.rb +94 -0
  86. data/lib/superkick/integrations/datadog/alert_monitor.rb +163 -0
  87. data/lib/superkick/integrations/datadog/alert_spawner.rb +201 -0
  88. data/lib/superkick/integrations/datadog/notification_templates/default.liquid +10 -0
  89. data/lib/superkick/integrations/datadog/notifier.rb +294 -0
  90. data/lib/superkick/integrations/datadog/spawner.rb +201 -0
  91. data/lib/superkick/integrations/datadog/templates/alert_changed.liquid +8 -0
  92. data/lib/superkick/integrations/datadog/templates/alert_escalated.liquid +8 -0
  93. data/lib/superkick/integrations/datadog/templates/alert_recovered.liquid +14 -0
  94. data/lib/superkick/integrations/datadog/templates/alert_triggered.liquid +29 -0
  95. data/lib/superkick/integrations/datadog/templates/error_opened.liquid +15 -0
  96. data/lib/superkick/integrations/datadog.rb +14 -0
  97. data/lib/superkick/integrations/docker/README.md +256 -0
  98. data/lib/superkick/integrations/docker/client.rb +295 -0
  99. data/lib/superkick/integrations/docker/runtime.rb +218 -0
  100. data/lib/superkick/integrations/docker.rb +4 -0
  101. data/lib/superkick/integrations/git/repository_source.rb +66 -0
  102. data/lib/superkick/integrations/git/version_control.rb +119 -0
  103. data/lib/superkick/integrations/git.rb +8 -0
  104. data/lib/superkick/integrations/github/README.md +300 -0
  105. data/lib/superkick/integrations/github/check_failed_spawner.rb +199 -0
  106. data/lib/superkick/integrations/github/drops.rb +114 -0
  107. data/lib/superkick/integrations/github/goal.rb +135 -0
  108. data/lib/superkick/integrations/github/issue_goal.rb +104 -0
  109. data/lib/superkick/integrations/github/issue_spawner.rb +160 -0
  110. data/lib/superkick/integrations/github/monitor.rb +251 -0
  111. data/lib/superkick/integrations/github/probe.rb +30 -0
  112. data/lib/superkick/integrations/github/repository_source.rb +228 -0
  113. data/lib/superkick/integrations/github/templates/check_failed.liquid +10 -0
  114. data/lib/superkick/integrations/github/templates/ci_failure.liquid +5 -0
  115. data/lib/superkick/integrations/github/templates/ci_success.liquid +1 -0
  116. data/lib/superkick/integrations/github/templates/issue_opened.liquid +20 -0
  117. data/lib/superkick/integrations/github/templates/pr_comment.liquid +2 -0
  118. data/lib/superkick/integrations/github/templates/pr_review.liquid +4 -0
  119. data/lib/superkick/integrations/github.rb +16 -0
  120. data/lib/superkick/integrations/honeybadger/README.md +97 -0
  121. data/lib/superkick/integrations/honeybadger/notification_templates/default.liquid +8 -0
  122. data/lib/superkick/integrations/honeybadger/notifier.rb +250 -0
  123. data/lib/superkick/integrations/honeybadger/spawner.rb +214 -0
  124. data/lib/superkick/integrations/honeybadger/templates/error_opened.liquid +17 -0
  125. data/lib/superkick/integrations/honeybadger.rb +9 -0
  126. data/lib/superkick/integrations/shell/README.md +83 -0
  127. data/lib/superkick/integrations/shell/monitor.rb +87 -0
  128. data/lib/superkick/integrations/shell/templates/shell_alert.liquid +6 -0
  129. data/lib/superkick/integrations/shell/templates/shell_success.liquid +6 -0
  130. data/lib/superkick/integrations/shell.rb +7 -0
  131. data/lib/superkick/integrations/shortcut/README.md +193 -0
  132. data/lib/superkick/integrations/shortcut/drops.rb +91 -0
  133. data/lib/superkick/integrations/shortcut/monitor.rb +582 -0
  134. data/lib/superkick/integrations/shortcut/probe.rb +34 -0
  135. data/lib/superkick/integrations/shortcut/spawner.rb +264 -0
  136. data/lib/superkick/integrations/shortcut/templates/related_story_changed.liquid +6 -0
  137. data/lib/superkick/integrations/shortcut/templates/story_blocker.liquid +8 -0
  138. data/lib/superkick/integrations/shortcut/templates/story_comment.liquid +5 -0
  139. data/lib/superkick/integrations/shortcut/templates/story_description_changed.liquid +19 -0
  140. data/lib/superkick/integrations/shortcut/templates/story_owner_changed.liquid +10 -0
  141. data/lib/superkick/integrations/shortcut/templates/story_ready.liquid +41 -0
  142. data/lib/superkick/integrations/shortcut/templates/story_state_changed.liquid +9 -0
  143. data/lib/superkick/integrations/shortcut/templates/story_unblocked.liquid +5 -0
  144. data/lib/superkick/integrations/shortcut.rb +11 -0
  145. data/lib/superkick/integrations/slack/README.md +297 -0
  146. data/lib/superkick/integrations/slack/drops.rb +70 -0
  147. data/lib/superkick/integrations/slack/notifier.rb +426 -0
  148. data/lib/superkick/integrations/slack/spawner.rb +251 -0
  149. data/lib/superkick/integrations/slack/templates/default.liquid +17 -0
  150. data/lib/superkick/integrations/slack/templates/slack_reply.liquid +3 -0
  151. data/lib/superkick/integrations/slack/templates/spawn/slack_message.liquid +10 -0
  152. data/lib/superkick/integrations/slack/thread_monitor.rb +161 -0
  153. data/lib/superkick/integrations/slack.rb +12 -0
  154. data/lib/superkick/liquid.rb +129 -0
  155. data/lib/superkick/local/repository_source.rb +148 -0
  156. data/lib/superkick/mcp_server.rb +596 -0
  157. data/lib/superkick/monitor.rb +215 -0
  158. data/lib/superkick/notification_dispatcher.rb +280 -0
  159. data/lib/superkick/notifier.rb +173 -0
  160. data/lib/superkick/notifier_state_store.rb +55 -0
  161. data/lib/superkick/notifier_template.rb +121 -0
  162. data/lib/superkick/notifiers/command.rb +124 -0
  163. data/lib/superkick/notifiers/terminal_bell.rb +41 -0
  164. data/lib/superkick/output_logger.rb +54 -0
  165. data/lib/superkick/poller.rb +126 -0
  166. data/lib/superkick/process_runner.rb +87 -0
  167. data/lib/superkick/pty_proxy.rb +403 -0
  168. data/lib/superkick/registry.rb +75 -0
  169. data/lib/superkick/repository_source.rb +195 -0
  170. data/lib/superkick/server.rb +211 -0
  171. data/lib/superkick/session_recorder.rb +154 -0
  172. data/lib/superkick/setup.rb +160 -0
  173. data/lib/superkick/spawn/agent_spawner.rb +311 -0
  174. data/lib/superkick/spawn/approval_store.rb +113 -0
  175. data/lib/superkick/spawn/handler.rb +144 -0
  176. data/lib/superkick/spawn/injector.rb +119 -0
  177. data/lib/superkick/spawn/workflow_executor.rb +196 -0
  178. data/lib/superkick/spawn/workflow_validator.rb +77 -0
  179. data/lib/superkick/spawner.rb +67 -0
  180. data/lib/superkick/supervisor.rb +516 -0
  181. data/lib/superkick/team/artifact_store.rb +92 -0
  182. data/lib/superkick/team/log.rb +140 -0
  183. data/lib/superkick/team/log_entry_drop.rb +34 -0
  184. data/lib/superkick/team/log_monitor.rb +84 -0
  185. data/lib/superkick/team/log_notifier.rb +96 -0
  186. data/lib/superkick/team/log_store.rb +40 -0
  187. data/lib/superkick/template_filters.rb +24 -0
  188. data/lib/superkick/template_renderer.rb +223 -0
  189. data/lib/superkick/templates/team_log/planning_agent.liquid +38 -0
  190. data/lib/superkick/templates/team_log/team_digest.liquid +45 -0
  191. data/lib/superkick/templates/team_log/teammate_message.liquid +7 -0
  192. data/lib/superkick/templates/team_log/worker_kickoff.liquid +37 -0
  193. data/lib/superkick/templates/workflow/workflow_triggered.liquid +22 -0
  194. data/lib/superkick/version.rb +5 -0
  195. data/lib/superkick/version_control.rb +135 -0
  196. data/lib/superkick/yaml_config.rb +302 -0
  197. data/lib/superkick.rb +198 -0
  198. data/plan.md +267 -0
  199. metadata +404 -0
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Superkick
6
+ # Orchestrates a single injection attempt by sending a rendered prompt to
7
+ # the agent's InjectionQueue via the buffer client.
8
+ #
9
+ # The server no longer checks preconditions (idle state, guards) — the
10
+ # agent's InjectionQueue handles all gating locally with zero latency.
11
+ #
12
+ # Returns :enqueued or :skipped.
13
+ class Injector
14
+ def initialize(store:, notification_dispatcher:, buffer_client: nil, config: Superkick.config)
15
+ @store = store
16
+ @config = config
17
+ @notification_dispatcher = notification_dispatcher
18
+ @buffer_client = buffer_client || Buffer.client_from(store:, config:)
19
+ end
20
+
21
+ def inject(agent_id:, event:)
22
+ unless @buffer_client.reachable?(agent_id)
23
+ Superkick.logger.debug("injector") { "Agent #{agent_id} not reachable — skipping" }
24
+ return :skipped
25
+ end
26
+
27
+ prompt_text = TemplateRenderer.render(event)
28
+
29
+ @buffer_client.send_command(agent_id, "enqueue_injection",
30
+ id: SecureRandom.uuid,
31
+ prompt: prompt_text,
32
+ monitor_type: event[:monitor_type],
33
+ monitor_name: event[:monitor_name],
34
+ priority: event[:injection_priority] || :normal,
35
+ ttl: event[:injection_ttl] || monitor_ttl(event),
36
+ supersede_key: event[:injection_supersede_key])
37
+
38
+ Superkick.logger.info("injector") { "Enqueued #{event[:event_type]} for agent #{agent_id}" }
39
+ :enqueued
40
+ rescue => e
41
+ Superkick.logger.error("injector") { "Injection error for #{agent_id}: #{e.message}\n#{e.backtrace.first(5).join("\n")}" }
42
+ :error
43
+ end
44
+
45
+ private
46
+
47
+ def monitor_ttl(event)
48
+ # Allow per-monitor TTL override via monitor config
49
+ agent_id = event[:agent_id]
50
+ monitor_name = event[:monitor_name]
51
+
52
+ if agent_id && monitor_name
53
+ agent = @store.get(agent_id)
54
+ config = agent&.monitor_config(monitor_name)
55
+ return config[:injection_ttl] if config&.dig(:injection_ttl)
56
+ end
57
+
58
+ InjectionQueue::DEFAULT_TTL
59
+ end
60
+
61
+ def dispatch_notification(...)
62
+ @notification_dispatcher.dispatch(...)
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Superkick
4
+ # Thread-safe buffer that tracks the user's current partial (unsubmitted)
5
+ # input and any active injection guards.
6
+ #
7
+ # Raw terminal byte sequences handled:
8
+ # - Printable UTF-8 chars → append to buffer
9
+ # - \x03 (C-c) → clear buffer, clear submit-on-clear guards
10
+ # - \r outside paste mode → clear buffer, clear submit-on-clear guards
11
+ # - \r inside paste mode → treated as \n (pasted newline, not submit)
12
+ # - \x7f / \x08 → backspace (remove last char)
13
+ # - \x1b[200~ / \x1b[201~ → bracketed paste start/end markers
14
+ # - Other ANSI escapes → discarded (not stored in buffer)
15
+ class InputBuffer
16
+ def initialize
17
+ @mutex = Mutex.new
18
+ @buffer = "".dup
19
+ @paste_mode = false
20
+ # guards: { name_string => { reason: String, clear_on_submit: bool } }
21
+ @guards = {}
22
+ end
23
+
24
+ # Feed raw bytes from the terminal into the buffer.
25
+ def append(bytes)
26
+ bytes = bytes.dup.force_encoding(Encoding::BINARY)
27
+
28
+ @mutex.synchronize do
29
+ i = 0
30
+ while i < bytes.bytesize
31
+ b = bytes.byteslice(i, 1)
32
+
33
+ # Bracketed paste start: ESC [ 2 0 0 ~
34
+ if bytes.byteslice(i, 6) == "\x1b[200~"
35
+ @paste_mode = true
36
+ i += 6
37
+ next
38
+ end
39
+
40
+ # Bracketed paste end: ESC [ 2 0 1 ~
41
+ if bytes.byteslice(i, 6) == "\x1b[201~"
42
+ @paste_mode = false
43
+ i += 6
44
+ next
45
+ end
46
+
47
+ # ANSI / escape sequences
48
+ if b == "\x1b"
49
+ nxt = bytes.byteslice(i + 1, 1)
50
+ if nxt == "["
51
+ # CSI sequence: skip parameter bytes then one final byte
52
+ j = i + 2
53
+ j += 1 while j < bytes.bytesize && bytes.byteslice(j, 1) =~ /[0-9;]/
54
+ i = j + 1
55
+ elsif nxt == "]"
56
+ # OSC: skip until BEL (\x07) or ST (ESC \); advance j past the terminator
57
+ j = i + 2
58
+ loop do
59
+ break if j >= bytes.bytesize
60
+ if bytes.byteslice(j, 1) == "\x07"
61
+ j += 1 # skip BEL
62
+ break
63
+ end
64
+ if bytes.byteslice(j, 1) == "\x1b" && bytes.byteslice(j + 1, 1) == "\\"
65
+ j += 2 # skip ESC \
66
+ break
67
+ end
68
+ j += 1
69
+ end
70
+ i = j
71
+ else
72
+ # Two-byte escape — skip
73
+ i += 2
74
+ end
75
+ next
76
+ end
77
+
78
+ ord = b.ord
79
+
80
+ # C-c → submit (clear buffer + guards)
81
+ if ord == 0x03
82
+ @buffer = "".dup
83
+ clear_submit_guards!
84
+ i += 1
85
+ next
86
+ end
87
+
88
+ # CR → submit outside paste, newline inside paste
89
+ if ord == 0x0D
90
+ if @paste_mode
91
+ @buffer << "\n"
92
+ else
93
+ @buffer = "".dup
94
+ clear_submit_guards!
95
+ end
96
+ i += 1
97
+ next
98
+ end
99
+
100
+ # Backspace / DEL
101
+ if ord == 0x7F || ord == 0x08
102
+ @buffer.chop! unless @buffer.empty?
103
+ i += 1
104
+ next
105
+ end
106
+
107
+ # Printable (including high UTF-8 bytes)
108
+ if ord >= 0x20
109
+ char_len = utf8_char_byte_length(ord)
110
+ @buffer << bytes.byteslice(i, char_len).force_encoding(Encoding::UTF_8)
111
+ i += char_len
112
+ next
113
+ end
114
+
115
+ # Everything else — skip
116
+ i += 1
117
+ end
118
+ end
119
+ end
120
+
121
+ # Returns the current partial input string (copy).
122
+ def contents
123
+ @mutex.synchronize { @buffer.dup }
124
+ end
125
+
126
+ # Unconditionally clears the buffer (called by Buffer::Server on inject).
127
+ def clear
128
+ @mutex.synchronize do
129
+ @buffer = "".dup
130
+ @paste_mode = false
131
+ end
132
+ end
133
+
134
+ # --- Injection guards --------------------------------------------------
135
+
136
+ def set_guard(name, reason, clear_on_submit: true)
137
+ @mutex.synchronize do
138
+ @guards[name.to_s] = {reason:, clear_on_submit:}
139
+ end
140
+ end
141
+
142
+ def clear_guard(name)
143
+ @mutex.synchronize { @guards.delete(name.to_s) }
144
+ end
145
+
146
+ # Returns { name_string => reason_string } for active guards.
147
+ def guards
148
+ @mutex.synchronize do
149
+ @guards.transform_values { it[:reason] }
150
+ end
151
+ end
152
+
153
+ def guards_active?
154
+ @mutex.synchronize { !@guards.empty? }
155
+ end
156
+
157
+ private
158
+
159
+ def clear_submit_guards!
160
+ @guards.reject! { |_, v| v[:clear_on_submit] }
161
+ end
162
+
163
+ def utf8_char_byte_length(first_byte_ord)
164
+ if first_byte_ord >= 0xF0 then 4
165
+ elsif first_byte_ord >= 0xE0 then 3
166
+ elsif first_byte_ord >= 0xC0 then 2
167
+ else 1
168
+ end
169
+ end
170
+ end
171
+ end
@@ -0,0 +1,98 @@
1
+ # Bugsnag Spawner
2
+
3
+ Type: `:bugsnag`
4
+
5
+ Watches Bugsnag for open errors and spawns AI coding agents to fix them. Polls
6
+ the Bugsnag Data Access API for errors matching configurable filters (severity,
7
+ release stage, error class, version, minimum event count).
8
+
9
+ Errors are tracked by ID to prevent re-dispatching. Errors that fail client-side
10
+ filters (e.g. below `minimum_events`) are retried on subsequent ticks so they
11
+ fire once the threshold is crossed.
12
+
13
+ ## Configuration
14
+
15
+ ```yaml
16
+ spawners:
17
+ bugsnag:
18
+ type: bugsnag
19
+ project_id: "abc123def456"
20
+ token: <%= env("BUGSNAG_TOKEN") %>
21
+ driver: claude_code
22
+ severity:
23
+ - error
24
+ release_stage: production
25
+ minimum_events: 5
26
+ error_classes:
27
+ include:
28
+ - NoMethodError
29
+ - RuntimeError
30
+ exclude:
31
+ - Net::ReadTimeout
32
+ versions:
33
+ - latest
34
+ filters:
35
+ app.release_stage:
36
+ - production
37
+ ```
38
+
39
+ | Key | Required | Default | Description |
40
+ |-----|----------|---------|-------------|
41
+ | `project_id` | yes | — | Bugsnag project ID |
42
+ | `token` | no | `$BUGSNAG_TOKEN` | Bugsnag API auth token |
43
+ | `severity` | no | `["error"]` | Severity filter (array or include/exclude hash) |
44
+ | `release_stage` | no | `"production"` | Release stage name |
45
+ | `minimum_events` | no | `1` | Minimum event count before dispatching |
46
+ | `error_classes` | no | — | Error class filter (array or include/exclude hash) |
47
+ | `versions` | no | — | Version filter (array or include/exclude hash; `"latest"` resolves via releases API) |
48
+ | `filters` | no | — | Additional Bugsnag API filter fields (hash, passed through directly) |
49
+
50
+ ### List filter format
51
+
52
+ The `severity`, `error_classes`, and `versions` keys accept two formats:
53
+
54
+ - **Array** — treated as an include list: `["error", "warning"]`
55
+ - **Hash** — explicit include/exclude: `{ include: ["error"], exclude: ["info"] }`
56
+
57
+ Include filters are applied server-side (via the Bugsnag API). Exclude filters
58
+ are applied client-side after fetching.
59
+
60
+ ## Agent ID format
61
+
62
+ ```
63
+ bugsnag-error-{error_id}
64
+ ```
65
+
66
+ Where `error_id` is the Bugsnag error ID. Dedup is handled by `AgentStore` —
67
+ if an agent with the same ID already exists, the spawn is skipped.
68
+
69
+ ## Events
70
+
71
+ ### `error_opened`
72
+
73
+ Dispatched when a new open error is found that passes all filters.
74
+
75
+ Template variables:
76
+
77
+ | Variable | Description |
78
+ |----------|-------------|
79
+ | `error_id` | Bugsnag error ID |
80
+ | `error_class` | Exception class name (e.g. `NoMethodError`) |
81
+ | `message` | Error message string |
82
+ | `severity` | Severity level (`error`, `warning`, `info`) |
83
+ | `status` | Bugsnag error status (e.g. `open`) |
84
+ | `events` | Total event count |
85
+ | `users` | Number of affected users |
86
+ | `first_seen` | Timestamp of first occurrence |
87
+ | `last_seen` | Timestamp of most recent occurrence |
88
+ | `release_stages` | Array of release stages where the error was seen |
89
+ | `url` | Bugsnag dashboard URL for this error |
90
+ | `project_url` | Bugsnag dashboard URL for the project |
91
+ | `project_id` | Bugsnag project ID |
92
+
93
+ ## Error handling
94
+
95
+ - **401/403** — raises `FatalError`, stops the spawner (authentication failure)
96
+ - **429** — raises `RateLimited`, backs off per the standard poller backoff
97
+ - **404** — logged as warning, skipped (resource not found)
98
+ - Other HTTP errors — logged as warning, skipped
@@ -0,0 +1,307 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "json"
5
+
6
+ module Superkick
7
+ module Integrations
8
+ module Bugsnag
9
+ # Watches Bugsnag for open errors and spawns AI coding agents to fix them.
10
+ #
11
+ # Polls the Bugsnag Data Access API for errors matching configurable filters
12
+ # (severity, release stage, error class, event count, version). Tracks
13
+ # dispatched error IDs in-memory to avoid re-dispatching. Errors that
14
+ # fail client-side filters (e.g. below minimum_events) are retried on
15
+ # subsequent ticks so they fire once the threshold is crossed.
16
+ #
17
+ # Config keys:
18
+ # project_id (required) — Bugsnag project ID
19
+ # token (optional) — API auth token, falls back to $BUGSNAG_TOKEN
20
+ # severity (optional) — array or include/exclude hash, default ["error"]
21
+ # release_stage (optional) — release stage name, default "production"
22
+ # minimum_events (optional) — minimum event count to dispatch, default 1
23
+ # error_classes (optional) — array or include/exclude hash
24
+ # versions (optional) — array or include/exclude hash; "latest" resolves to newest release
25
+ # filters (optional) — hash of additional Bugsnag API filter fields
26
+ #
27
+ # List filter configs (severity, error_classes, versions) accept either form:
28
+ # - Array → treated as include list: ["error", "warning"]
29
+ # - Hash → include/exclude keys: { include: ["error"], exclude: ["info"] }
30
+ class Spawner < Superkick::Spawner
31
+ attr_reader :conn
32
+
33
+ API_BASE = "https://api.bugsnag.com"
34
+ DEFAULT_SEVERITY = %w[error].freeze
35
+ DEFAULT_RELEASE_STAGE = "production"
36
+ DEFAULT_MINIMUM_EVENTS = 1
37
+ PER_PAGE = 30
38
+
39
+ def self.type = :bugsnag
40
+
41
+ def self.description
42
+ "Watches Bugsnag for open errors and spawns AI coding agents to " \
43
+ "fix them. Supports filtering by severity, release stage, error " \
44
+ "class, version, and minimum event count."
45
+ end
46
+
47
+ def self.required_config = %i[project_id]
48
+
49
+ def self.spawn_templates_dir
50
+ File.join(__dir__, "templates")
51
+ end
52
+
53
+ def self.agent_id(event)
54
+ "bugsnag-error-#{event[:error_id]}"
55
+ end
56
+
57
+ def self.setup_label = "Bugsnag"
58
+
59
+ def self.setup_config
60
+ <<~YAML
61
+ bugsnag:
62
+ type: bugsnag
63
+ project_id: "abc123def456"
64
+ token: <%= env("BUGSNAG_TOKEN") %>
65
+ # severity: # error severities to watch (default: [error])
66
+ # - error
67
+ # release_stage: production # default
68
+ # minimum_events: 1 # minimum occurrences before spawning
69
+ # max_duration: 3600
70
+ YAML
71
+ end
72
+
73
+ def initialize(name:, config:, handler:, connection: nil)
74
+ super(name:, config:, handler:)
75
+ @conn = connection
76
+ end
77
+
78
+ def tick
79
+ errors = fetch_errors
80
+ errors.each do |error|
81
+ next if filtered_by_severity?(error)
82
+ next if filtered_by_error_class?(error)
83
+ next if filtered_by_minimum_events?(error)
84
+
85
+ # Mark as seen only after passing all filters — an error below
86
+ # minimum_events today may cross the threshold on a future tick.
87
+ @seen_error_ids.add(error["id"])
88
+
89
+ dispatch(
90
+ event_type: :error_opened,
91
+ error_id: error["id"],
92
+ error_class: error["error_class"],
93
+ message: error["message"].to_s,
94
+ severity: error["severity"],
95
+ status: error["status"],
96
+ events: error["events"],
97
+ users: error["users_affected"] || error["users"],
98
+ first_seen: error["first_seen"],
99
+ last_seen: error["last_seen"],
100
+ release_stages: error["release_stages"],
101
+ url: error["url"],
102
+ project_url: error["project_url"],
103
+ project_id: self[:project_id]
104
+ )
105
+ end
106
+ end
107
+
108
+ def on_start
109
+ @conn ||= build_connection
110
+ @seen_error_ids = Set.new
111
+ @latest_version = nil
112
+ end
113
+
114
+ private
115
+
116
+ # -- Bugsnag errors API ---------------------------------------------------
117
+
118
+ def fetch_errors
119
+ project_id = self[:project_id]
120
+ params = build_params
121
+
122
+ path = "/projects/#{project_id}/errors"
123
+ Superkick.logger.debug(log_tag) { "Fetching errors: #{path} #{params.inspect}" }
124
+
125
+ resp = get(path, params)
126
+ return [] unless resp
127
+
128
+ errors = resp.body
129
+ return [] unless errors.is_a?(Array)
130
+
131
+ # Filter out already-dispatched errors. New IDs are added to
132
+ # @seen_error_ids in tick, after client-side filters pass, so an
133
+ # error that was skipped (e.g. below minimum_events) gets retried.
134
+ new_errors = errors.reject { @seen_error_ids.include?(it["id"]) }
135
+
136
+ Superkick.logger.info(log_tag) { "Found #{errors.size} errors, #{new_errors.size} new" }
137
+ new_errors
138
+ end
139
+
140
+ def build_params
141
+ params = {
142
+ "sort" => "last_seen",
143
+ "direction" => "desc",
144
+ "status" => "open",
145
+ "per_page" => PER_PAGE.to_s
146
+ }
147
+
148
+ filters = build_filters
149
+ params["filters"] = JSON.generate(filters) unless filters.empty?
150
+
151
+ params
152
+ end
153
+
154
+ def build_filters
155
+ filters = {}
156
+
157
+ # Severity filter
158
+ sev = normalize_filter(self[:severity], default_include: DEFAULT_SEVERITY)
159
+ if sev[:include].any?
160
+ filters["event.severity"] = sev[:include].map { {type: "eq", value: it} }
161
+ end
162
+
163
+ # Release stage filter
164
+ stage = self[:release_stage]
165
+ stage = DEFAULT_RELEASE_STAGE if stage.nil?
166
+ if stage && !stage.empty?
167
+ filters["app.release_stage"] = [{type: "eq", value: stage}]
168
+ end
169
+
170
+ # Version filter (resolve "latest" to actual version string)
171
+ ver = normalize_filter(self[:versions])
172
+ if ver[:include].any?
173
+ resolved = resolve_versions(ver[:include])
174
+ if resolved.any?
175
+ filters["version.seen_in"] = resolved.map { {type: "eq", value: it} }
176
+ end
177
+ end
178
+
179
+ # Error class include filter (server-side)
180
+ ec = normalize_filter(self[:error_classes])
181
+ if ec[:include].any?
182
+ filters["event.class"] = ec[:include].map { {type: "eq", value: it} }
183
+ end
184
+
185
+ # Merge in user-provided passthrough filters
186
+ if self[:filters].is_a?(Hash)
187
+ self[:filters].each do |field, values|
188
+ values = Array(values)
189
+ filters[field.to_s] = values.map { {type: "eq", value: it} }
190
+ end
191
+ end
192
+
193
+ filters
194
+ end
195
+
196
+ def resolve_versions(versions)
197
+ versions.flat_map do |v|
198
+ if v.to_s.downcase == "latest"
199
+ latest = fetch_latest_version
200
+ latest ? [latest] : []
201
+ else
202
+ [v.to_s]
203
+ end
204
+ end
205
+ end
206
+
207
+ def fetch_latest_version
208
+ return @latest_version if @latest_version
209
+
210
+ resp = get("/projects/#{self[:project_id]}/releases", {"per_page" => "1"})
211
+ return nil unless resp
212
+
213
+ releases = resp.body
214
+ return nil unless releases.is_a?(Array) && releases.any?
215
+
216
+ @latest_version = releases.first["release_group"] || releases.first["version"]
217
+ Superkick.logger.debug(log_tag) { "Resolved latest version: #{@latest_version}" }
218
+ @latest_version
219
+ rescue => e
220
+ Superkick.logger.warn(log_tag) { "Could not fetch latest version: #{e.message}" }
221
+ nil
222
+ end
223
+
224
+ # -- Client-side filters --------------------------------------------------
225
+
226
+ def filtered_by_severity?(error)
227
+ excludes = normalize_filter(self[:severity])[:exclude]
228
+ return false unless excludes.any?
229
+
230
+ excludes.include?(error["severity"].to_s)
231
+ end
232
+
233
+ def filtered_by_error_class?(error)
234
+ excludes = normalize_filter(self[:error_classes])[:exclude]
235
+ return false unless excludes.any?
236
+
237
+ excludes.include?(error["error_class"].to_s)
238
+ end
239
+
240
+ def filtered_by_version?(error)
241
+ excludes = normalize_filter(self[:versions])[:exclude]
242
+ return false unless excludes.any?
243
+
244
+ # Version info isn't directly on the error object from the list endpoint,
245
+ # so version exclude is best-effort via the seen_in field if available.
246
+ seen_in = error["seen_in_versions"] || []
247
+ seen_in.any? { excludes.include?(it) }
248
+ end
249
+
250
+ def filtered_by_minimum_events?(error)
251
+ min = self[:minimum_events] || DEFAULT_MINIMUM_EVENTS
252
+ count = error["events"] || 0
253
+ count < min
254
+ end
255
+
256
+ # -- Filter normalization -------------------------------------------------
257
+
258
+ # Accepts either an Array (treated as include-only) or a Hash with
259
+ # :include / :exclude keys. Returns { include: [...], exclude: [...] }.
260
+ def normalize_filter(value, default_include: [])
261
+ case value
262
+ when Array
263
+ {include: value, exclude: []}
264
+ when Hash
265
+ {include: Array(value[:include]), exclude: Array(value[:exclude])}
266
+ else
267
+ {include: default_include, exclude: []}
268
+ end
269
+ end
270
+
271
+ # -- HTTP -----------------------------------------------------------------
272
+
273
+ def get(path, params = {})
274
+ resp = @conn.get(path) do |req|
275
+ params.each { |k, v| req.params[k] = v }
276
+ end
277
+ return nil unless handle_response!(resp)
278
+ resp
279
+ end
280
+
281
+ # Returns true on success, raises on auth/rate-limit, returns false on 404.
282
+ def handle_response!(resp)
283
+ case resp.status
284
+ when 200..299 then true
285
+ when 401, 403 then raise FatalError, "Bugsnag auth failed (HTTP #{resp.status})"
286
+ when 429 then raise RateLimited, "Bugsnag rate limited"
287
+ when 404
288
+ Superkick.logger.warn(log_tag) { "Bugsnag 404: resource not found" }
289
+ false
290
+ else
291
+ Superkick.logger.warn(log_tag) { "Bugsnag HTTP #{resp.status}" }
292
+ false
293
+ end
294
+ end
295
+
296
+ def build_connection
297
+ token = self[:token] || ENV["BUGSNAG_TOKEN"]
298
+
299
+ Faraday.new(url: API_BASE) do |f|
300
+ f.request :authorization, "token", token if token
301
+ f.response :json
302
+ end
303
+ end
304
+ end
305
+ end
306
+ end
307
+ end
@@ -0,0 +1,17 @@
1
+ SUPERKICK [{{ "now" | time }}]: Bugsnag error — {{ error_class }}
2
+ {% if url -%}
3
+ URL: {{ url }}
4
+ {% endif -%}
5
+ Severity: {{ severity }} | Events: {{ events }}{% if users %} | Users: {{ users }}{% endif %}
6
+ First seen: {{ first_seen }} | Last seen: {{ last_seen }}
7
+ {% if release_stages.size > 0 -%}
8
+ Release stages: {{ release_stages | join: ", " }}
9
+ {% endif -%}
10
+
11
+ ## Error
12
+
13
+ **{{ error_class }}**: {% if message == "" %}(no message){% else %}{{ message }}{% endif %}
14
+
15
+ Please investigate this error, identify the root cause, implement a fix, and
16
+ write tests to prevent regression. Check the Bugsnag dashboard for full stack
17
+ traces and contextual data.
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "bugsnag/spawner"
4
+
5
+ module Superkick
6
+ Spawner.register(Integrations::Bugsnag::Spawner)
7
+ end