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,214 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+
5
+ module Superkick
6
+ module Integrations
7
+ module Honeybadger
8
+ # Watches Honeybadger for unresolved faults and spawns AI coding agents to
9
+ # fix them.
10
+ #
11
+ # Polls the Honeybadger Faults API for faults matching configurable filters
12
+ # (environment, fault class, minimum occurrence count). Tracks dispatched
13
+ # fault IDs in-memory to avoid re-dispatching. Faults that fail client-side
14
+ # filters (e.g. below minimum_occurrences) are retried on subsequent ticks
15
+ # so they fire once the threshold is crossed.
16
+ #
17
+ # Config keys:
18
+ # project_id (required) — Honeybadger project ID
19
+ # token (optional) — API auth token, falls back to $HONEYBADGER_TOKEN
20
+ # environment (optional) — environment name, default "production"
21
+ # minimum_occurrences (optional) — minimum occurrence count to dispatch, default 1
22
+ # fault_classes (optional) — array or include/exclude hash
23
+ class Spawner < Superkick::Spawner
24
+ attr_reader :conn
25
+
26
+ API_BASE = "https://app.honeybadger.io"
27
+ DEFAULT_ENVIRONMENT = "production"
28
+ DEFAULT_MINIMUM_OCCURRENCES = 1
29
+ PER_PAGE = 25
30
+
31
+ def self.type = :honeybadger
32
+
33
+ def self.description
34
+ "Watches Honeybadger for unresolved faults and spawns AI coding " \
35
+ "agents to fix them. Supports filtering by environment, fault " \
36
+ "class, and minimum occurrence count."
37
+ end
38
+
39
+ def self.required_config = %i[project_id]
40
+
41
+ def self.spawn_templates_dir
42
+ File.join(__dir__, "templates")
43
+ end
44
+
45
+ def self.agent_id(event)
46
+ "honeybadger-fault-#{event[:fault_id]}"
47
+ end
48
+
49
+ def self.setup_label = "Honeybadger"
50
+
51
+ def self.setup_config
52
+ <<~YAML
53
+ honeybadger:
54
+ type: honeybadger
55
+ project_id: 12345
56
+ token: <%= env("HONEYBADGER_TOKEN") %>
57
+ # environment: production # default
58
+ # minimum_occurrences: 1 # minimum occurrences before spawning
59
+ # max_duration: 3600
60
+ YAML
61
+ end
62
+
63
+ def initialize(name:, config:, handler:, connection: nil)
64
+ super(name:, config:, handler:)
65
+ @conn = connection
66
+ end
67
+
68
+ def tick
69
+ faults = fetch_faults
70
+ faults.each do |fault|
71
+ next if filtered_by_fault_class?(fault)
72
+ next if filtered_by_minimum_occurrences?(fault)
73
+
74
+ @seen_fault_ids.add(fault["id"])
75
+
76
+ dispatch(
77
+ event_type: :error_opened,
78
+ fault_id: fault["id"],
79
+ error_class: fault["klass"].to_s,
80
+ message: fault["message"].to_s,
81
+ environment: fault["environment"].to_s,
82
+ events: fault["notices_count"] || 0,
83
+ users: fault["impacted_users_count"],
84
+ first_seen: fault["created_at"].to_s,
85
+ last_seen: fault["last_notice_at"].to_s,
86
+ component: fault["component"].to_s,
87
+ action: fault["action"].to_s,
88
+ assignee: fault["assignee"],
89
+ url: fault_url(fault["id"]),
90
+ project_id: self[:project_id]
91
+ )
92
+ end
93
+ end
94
+
95
+ def on_start
96
+ @conn ||= build_connection
97
+ @seen_fault_ids = Set.new
98
+ end
99
+
100
+ private
101
+
102
+ # -- Honeybadger Faults API -----------------------------------------------
103
+
104
+ def fetch_faults
105
+ project_id = self[:project_id]
106
+ params = build_params
107
+
108
+ path = "/v2/projects/#{project_id}/faults"
109
+ Superkick.logger.debug(log_tag) { "Fetching faults: #{path} #{params.inspect}" }
110
+
111
+ resp = get(path, params)
112
+ return [] unless resp
113
+
114
+ body = resp.body
115
+ faults = body["results"]
116
+ return [] unless faults.is_a?(Array)
117
+
118
+ new_faults = faults.reject { @seen_fault_ids.include?(it["id"]) }
119
+
120
+ Superkick.logger.info(log_tag) { "Found #{faults.size} faults, #{new_faults.size} new" }
121
+ new_faults
122
+ end
123
+
124
+ def build_params
125
+ params = {
126
+ "order" => "recent",
127
+ "resolved" => "false",
128
+ "ignored" => "false",
129
+ "per_page" => PER_PAGE.to_s
130
+ }
131
+
132
+ env = self[:environment]
133
+ env = DEFAULT_ENVIRONMENT if env.nil?
134
+ params["environment"] = env if env && !env.to_s.empty?
135
+
136
+ # Server-side class filter (include only)
137
+ fc = normalize_filter(self[:fault_classes])
138
+ if fc[:include].any?
139
+ params["q"] = fc[:include].join(" ")
140
+ end
141
+
142
+ params
143
+ end
144
+
145
+ # -- Client-side filters --------------------------------------------------
146
+
147
+ def filtered_by_fault_class?(fault)
148
+ excludes = normalize_filter(self[:fault_classes])[:exclude]
149
+ return false unless excludes.any?
150
+
151
+ excludes.include?(fault["klass"].to_s)
152
+ end
153
+
154
+ def filtered_by_minimum_occurrences?(fault)
155
+ min = self[:minimum_occurrences] || DEFAULT_MINIMUM_OCCURRENCES
156
+ count = fault["notices_count"] || 0
157
+ count < min
158
+ end
159
+
160
+ # -- Filter normalization -------------------------------------------------
161
+
162
+ def normalize_filter(value, default_include: [])
163
+ case value
164
+ when Array
165
+ {include: value, exclude: []}
166
+ when Hash
167
+ {include: Array(value[:include]), exclude: Array(value[:exclude])}
168
+ else
169
+ {include: default_include, exclude: []}
170
+ end
171
+ end
172
+
173
+ # -- URL helpers ----------------------------------------------------------
174
+
175
+ def fault_url(fault_id)
176
+ "https://app.honeybadger.io/projects/#{self[:project_id]}/faults/#{fault_id}"
177
+ end
178
+
179
+ # -- HTTP -----------------------------------------------------------------
180
+
181
+ def get(path, params = {})
182
+ resp = @conn.get(path) do |req|
183
+ params.each { |k, v| req.params[k] = v }
184
+ end
185
+ return nil unless handle_response!(resp)
186
+ resp
187
+ end
188
+
189
+ def handle_response!(resp)
190
+ case resp.status
191
+ when 200..299 then true
192
+ when 401, 403 then raise FatalError, "Honeybadger auth failed (HTTP #{resp.status})"
193
+ when 429 then raise RateLimited, "Honeybadger rate limited"
194
+ when 404
195
+ Superkick.logger.warn(log_tag) { "Honeybadger 404: resource not found" }
196
+ false
197
+ else
198
+ Superkick.logger.warn(log_tag) { "Honeybadger HTTP #{resp.status}" }
199
+ false
200
+ end
201
+ end
202
+
203
+ def build_connection
204
+ token = self[:token] || ENV["HONEYBADGER_TOKEN"]
205
+
206
+ Faraday.new(url: API_BASE) do |f|
207
+ f.request :authorization, :basic, token, "" if token
208
+ f.response :json
209
+ end
210
+ end
211
+ end
212
+ end
213
+ end
214
+ end
@@ -0,0 +1,17 @@
1
+ SUPERKICK [{{ "now" | time }}]: Honeybadger fault — {{ error_class }}
2
+ {% if url -%}
3
+ URL: {{ url }}
4
+ {% endif -%}
5
+ {% unless environment == "" %}Env: {{ environment }} | {% endunless %}Occurrences: {{ events }}{% if users %} | Users: {{ users }}{% endif %}
6
+ {% unless component == "" and action == "" -%}
7
+ Component: {{ component }}{% unless action == "" %}#{{ action }}{% endunless %}
8
+ {% endunless -%}
9
+ First seen: {{ first_seen }} | Last seen: {{ last_seen }}
10
+
11
+ ## Error
12
+
13
+ **{{ error_class }}**: {% if message == "" %}(no message){% else %}{{ message }}{% endif %}
14
+
15
+ Please investigate this fault, identify the root cause, implement a fix, and
16
+ write tests to prevent regression. Check the Honeybadger dashboard for full
17
+ stack traces and contextual data.
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "honeybadger/notifier"
4
+ require_relative "honeybadger/spawner"
5
+
6
+ module Superkick
7
+ Notifier.register(Integrations::Honeybadger::Notifier)
8
+ Spawner.register(Integrations::Honeybadger::Spawner)
9
+ end
@@ -0,0 +1,83 @@
1
+ # Shell Monitor
2
+
3
+ Type: `:shell`
4
+
5
+ Runs a shell command on each poll interval and dispatches events based on exit
6
+ status. Designed for custom health checks, linters, or any script the user
7
+ wants Superkick to watch.
8
+
9
+ Supports multiple named instances — each with its own command, working
10
+ directory, and timeout.
11
+
12
+ ## Configuration
13
+
14
+ ```yaml
15
+ monitors:
16
+ disk-check:
17
+ type: shell
18
+ command: ./scripts/check-disk.sh
19
+ timeout: 10
20
+ lint:
21
+ type: shell
22
+ command: bundle exec standardrb --no-fix
23
+ working_dir: /home/user/myproject
24
+ report_success: true
25
+ ```
26
+
27
+ | Key | Required | Default | Description |
28
+ |-----|----------|---------|-------------|
29
+ | `command` | yes | — | Shell command string to execute |
30
+ | `working_dir` | no | inherited | Working directory for the command |
31
+ | `timeout` | no | `30` | Seconds before the command is killed with SIGTERM |
32
+ | `report_success` | no | `false` | Dispatch `shell_success` on zero exit (by default only failures are reported) |
33
+
34
+ The `type: shell` key is required when the monitor name doesn't match the
35
+ type (i.e. for all instances except one literally named `"shell"`).
36
+
37
+ ## Probe
38
+
39
+ `ShellMonitor::Probe` scans `.superkick/shell/` in the working directory for
40
+ executable files. Each script gets a monitor named `shell-<basename>` (without
41
+ extension).
42
+
43
+ Example layout:
44
+ ```
45
+ .superkick/shell/
46
+ check-disk.sh (executable)
47
+ lint.sh (executable)
48
+ ```
49
+
50
+ Produces:
51
+ ```ruby
52
+ {
53
+ "shell-check-disk" => { "type" => "shell", "command" => "/abs/path/.superkick/shell/check-disk.sh" },
54
+ "shell-lint" => { "type" => "shell", "command" => "/abs/path/.superkick/shell/lint.sh" }
55
+ }
56
+ ```
57
+
58
+ Returns `{}` if the directory doesn't exist or contains no executable files.
59
+
60
+ ## Events
61
+
62
+ ### `shell_alert`
63
+
64
+ Dispatched when the command exits with a non-zero status.
65
+
66
+ Template variables:
67
+ - `command` — the command string that was run
68
+ - `output` — combined stdout + stderr (captured, may be empty)
69
+ - `exit_code` — integer exit code
70
+
71
+ ### `shell_success`
72
+
73
+ Dispatched when the command exits with status 0 **and** `report_success` is
74
+ enabled. Disabled by default to avoid noise.
75
+
76
+ Template variables: same as `shell_alert`.
77
+
78
+ ## Error handling
79
+
80
+ - Command timeout → SIGTERM sent, output captured up to that point, logged as
81
+ warning. The tick continues (no event dispatched for the timeout itself).
82
+ - `SystemCallError` / `IOError` → logged, error message returned as output
83
+ with `nil` status.
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Superkick
4
+ module Integrations
5
+ module Shell
6
+ # Runs a shell command each tick and dispatches events based on exit status.
7
+ #
8
+ # Required config keys: command
9
+ # Optional config keys: working_dir, timeout, report_success
10
+ #
11
+ # Events emitted: shell_alert (non-zero exit), shell_success (zero exit)
12
+ class Monitor < Superkick::Monitor
13
+ def self.type = :shell
14
+
15
+ def self.description
16
+ "Runs a shell command on each poll interval and dispatches events based on exit status. " \
17
+ "Alerts on non-zero exit codes; optionally reports successes. " \
18
+ "Requires a command string. Optional: working_dir, timeout (default 30s), report_success (bool)."
19
+ end
20
+
21
+ def self.required_config = %i[command]
22
+
23
+ def self.setup_label = "Shell"
24
+
25
+ def self.setup_config
26
+ <<~YAML
27
+ my_check:
28
+ type: shell
29
+ command: .superkick/shell/my-check
30
+ # timeout: 30 # seconds before command is killed
31
+ # report_success: false # also dispatch on exit code 0
32
+ # working_dir: /path/to/dir # working directory for command
33
+ YAML
34
+ end
35
+
36
+ SHELL_DIR = ".superkick/shell"
37
+
38
+ # Resolve bare command names relative to .superkick/shell/.
39
+ # Absolute paths and explicit relative paths (starting with ./ or ../)
40
+ # are left unchanged.
41
+ def self.resolve_config(config, environment: {})
42
+ command = config[:command]
43
+ if command && !command.include?("/")
44
+ config[:command] = File.join(SHELL_DIR, command)
45
+ end
46
+ config
47
+ end
48
+
49
+ def self.templates_dir
50
+ File.join(__dir__, "templates")
51
+ end
52
+
53
+ def tick
54
+ output, status = run_command
55
+ return if output.nil? && status.nil?
56
+
57
+ success = status&.success?
58
+ return if success && !self[:report_success]
59
+
60
+ dispatch(
61
+ event_type: success ? :shell_success : :shell_alert,
62
+ command: self[:command],
63
+ output: output.to_s.strip,
64
+ exit_code: status&.exitstatus,
65
+ **(success ? {injection_ttl: 60} : {})
66
+ )
67
+ end
68
+
69
+ private
70
+
71
+ def run_command
72
+ cmd = self[:command]
73
+ dir = self[:working_dir]
74
+ timeout = self[:timeout]&.to_i || 30
75
+
76
+ result = ProcessRunner.run(cmd, timeout: timeout, chdir: dir)
77
+
78
+ if result[:timed_out]
79
+ Superkick.logger.warn(log_tag) { "Command timed out after #{timeout}s: #{cmd}" }
80
+ end
81
+
82
+ [result[:output], result[:status]]
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,6 @@
1
+ SUPERKICK [{{ "now" | time }}]: SHELL ALERT — "{{ monitor_name }}" exited with code {{ exit_code }}
2
+ Command: {{ command }}
3
+ {% if output and output != "" -%}
4
+ Output:
5
+ {{ output | truncate: 500 }}
6
+ {% endif -%}
@@ -0,0 +1,6 @@
1
+ SUPERKICK [{{ "now" | time }}]: SHELL OK — "{{ monitor_name }}" completed successfully
2
+ Command: {{ command }}
3
+ {% if output and output != "" -%}
4
+ Output:
5
+ {{ output | truncate: 500 }}
6
+ {% endif -%}
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "shell/monitor"
4
+
5
+ module Superkick
6
+ Monitor.register(Integrations::Shell::Monitor)
7
+ end
@@ -0,0 +1,193 @@
1
+ # Shortcut Monitor
2
+
3
+ Type: `:shortcut`
4
+
5
+ Polls [Shortcut](https://shortcut.com) (formerly Clubhouse) for story-level
6
+ changes. Watches a primary story plus its linked stories and epic siblings for
7
+ state changes, new comments, blockers, owner changes, and description updates.
8
+
9
+ ## Configuration
10
+
11
+ ```yaml
12
+ monitors:
13
+ shortcut:
14
+ token: <%= env("SHORTCUT_API_TOKEN") %>
15
+ workspace_slug: my-company
16
+ ```
17
+
18
+ | Key | Required | Default | Description |
19
+ |-----|----------|---------|-------------|
20
+ | `token` | no | `SHORTCUT_API_TOKEN` env var | API token for authentication |
21
+ | `workspace_slug` | no | — | Workspace slug, used to build web URLs in templates |
22
+ | `member` | no | auto-detected | UUID, email, or mention name to identify the current user for self-action filtering |
23
+ | `ignore_self` | no | `true` | Skip events caused by the current user (the API token owner) |
24
+
25
+ The `story_id` is auto-detected by the probe from the current git branch's
26
+ `sc-XXXXX` pattern — it is not a YAML config key.
27
+
28
+ ## Probe
29
+
30
+ `ShortcutMonitor::Probe` reads the current branch via `git symbolic-ref` and
31
+ matches against the regex `/sc-(\d+)/`. This covers common Shortcut branch
32
+ patterns:
33
+
34
+ - `user/sc-12345/my-feature`
35
+ - `sc-12345/fix-bug`
36
+ - `sc-12345`
37
+
38
+ Returns `{ "shortcut" => { "type" => "shortcut", "story_id" => "12345" } }` on
39
+ match, or `{}` if the branch doesn't contain an `sc-XXXXX` pattern.
40
+
41
+ The probe cannot detect the API token — that must come from YAML config or the
42
+ `SHORTCUT_API_TOKEN` environment variable.
43
+
44
+ ## Events
45
+
46
+ ### `story_state_changed`
47
+
48
+ Dispatched when the story's `workflow_state_id` changes between ticks. Resolves
49
+ state IDs to human-readable names via the cached workflow map.
50
+
51
+ Template variables:
52
+ - `story` — `StoryDrop` with `.id`, `.name`, `.url`, `.ref` (formatted as `sc-123 "title"`)
53
+ - `old_state` — previous workflow state name
54
+ - `new_state` — current workflow state name
55
+ - `new_state_type` — state category (`"backlog"`, `"unstarted"`, `"started"`, `"done"`)
56
+ - `actor` — `MemberDrop` or nil with `.display_name`, `.mention_name`, `.tag`
57
+
58
+ ### `story_comment`
59
+
60
+ Dispatched for each new comment on the tracked story. Uses a `last_comment_id`
61
+ watermark to avoid re-reporting.
62
+
63
+ Template variables:
64
+ - `story` — `StoryDrop`
65
+ - `author` — `MemberDrop` for the comment author
66
+ - `body` — comment text
67
+
68
+ ### `story_blocker`
69
+
70
+ Dispatched when the story's `blocker` field changes from `false` to `true`.
71
+
72
+ Template variables:
73
+ - `story` — `StoryDrop`
74
+ - `blocker_reason` — text of the latest blocker comment (if any)
75
+ - `actor` — `MemberDrop` or nil
76
+
77
+ ### `story_unblocked`
78
+
79
+ Dispatched when the story's `blocker` field changes from `true` to `false`.
80
+
81
+ Template variables:
82
+ - `story` — `StoryDrop`
83
+ - `actor` — `MemberDrop` or nil
84
+
85
+ ### `story_owner_changed`
86
+
87
+ Dispatched when the story's `owner_ids` array changes. Resolves member UUIDs
88
+ to display names via the cached member map.
89
+
90
+ Template variables:
91
+ - `story` — `StoryDrop`
92
+ - `added_owners` — array of `MemberDrop` for newly added owners
93
+ - `removed_owners` — array of `MemberDrop` for removed owners
94
+ - `actor` — `MemberDrop` or nil
95
+
96
+ ### `story_description_changed`
97
+
98
+ Dispatched when the SHA-256 hash of `name + description` changes, indicating
99
+ the story title or description was edited. Includes the full before and after
100
+ text so the AI agent can identify exactly what changed.
101
+
102
+ Template variables:
103
+ - `story` — `StoryDrop`
104
+ - `old_name` — previous story title
105
+ - `new_name` — current story title
106
+ - `old_description` — previous story description body
107
+ - `new_description` — current story description body
108
+ - `actor` — `MemberDrop` or nil
109
+
110
+ ### `related_story_changed`
111
+
112
+ Dispatched when a linked story or an epic sibling story changes workflow state.
113
+ Related stories are discovered from the primary story's `story_links` and
114
+ `epic_id` fields, capped at 10 to manage API budget.
115
+
116
+ Template variables:
117
+ - `story` — `StoryDrop` for the related story
118
+ - `old_state`, `new_state` — workflow state transition
119
+ - `primary_story` — `StoryDrop` for the primary tracked story
120
+ - `relationship` — array of directed verbs (e.g., `["blocks"]`, `["is_blocked_by"]`)
121
+ - `epic_sibling` — boolean
122
+
123
+ ## Self-action filtering
124
+
125
+ By default (`ignore_self: true`), the monitor skips events caused by the
126
+ current user — the person driving the AI agent. This prevents
127
+ redundant injections when you move your own story through workflow states,
128
+ post comments, or toggle blockers.
129
+
130
+ **How it works:**
131
+
132
+ 1. On startup, the monitor calls `GET /api/v3/member` to discover the
133
+ authenticated user's member ID. This can be overridden with the `member`
134
+ config key — accepts a UUID, email address, or mention name (useful when
135
+ using a shared/service API token).
136
+
137
+ 2. For **comments**, the monitor compares `comment.author_id` against the
138
+ current member ID. Self-authored comments are silently dropped.
139
+
140
+ 3. For **state changes, blockers, owner changes, and description changes**,
141
+ the monitor fetches `GET /api/v3/stories/{id}/history` when a change is
142
+ detected and checks the `member_id` on the most recent history entry. If
143
+ the actor is the current user, the event is skipped.
144
+
145
+ 4. **Watermarks are always advanced** regardless of filtering — a skipped
146
+ self-action won't be re-detected on the next tick.
147
+
148
+ 5. If the history endpoint is unavailable or returns an error, the monitor
149
+ falls back to dispatching the event (fail-open).
150
+
151
+ Set `ignore_self: false` to disable this filtering entirely.
152
+
153
+ ## Watermarks
154
+
155
+ The monitor persists these fields on the agent to avoid duplicate events
156
+ across ticks:
157
+
158
+ - `last_state_id` — last seen `workflow_state_id`
159
+ - `last_comment_id` — highest seen comment ID
160
+ - `last_blocker` — last seen `blocker` boolean
161
+ - `last_owner_ids` — JSON-encoded array of last seen `owner_ids`
162
+ - `last_description_hash` — SHA-256 of `name + description`
163
+
164
+ Related story states are tracked in memory (not persisted) — they reset when
165
+ the monitor restarts.
166
+
167
+ ## Caching
168
+
169
+ The monitor caches two reference datasets in `on_start`:
170
+
171
+ - **Workflow states** — fetched from `GET /api/v3/workflows`, maps state IDs to
172
+ names and types. Refreshed on cache miss.
173
+ - **Members** — fetched from `GET /api/v3/members`, maps member UUIDs to mention
174
+ names. Refreshed on cache miss.
175
+
176
+ ## API usage
177
+
178
+ The monitor uses the [Shortcut REST API v3](https://developer.shortcut.com/api/rest/v3)
179
+ via Faraday with the `Shortcut-Token` header for authentication. Rate limit is
180
+ 200 requests/minute.
181
+
182
+ Per-tick API budget:
183
+ - 1 request for the primary story
184
+ - 1 request per related story being tracked (up to 10)
185
+ - 1 request for `GET /api/v3/member` on startup (once)
186
+ - 1 request for story history per detected change (only when `ignore_self` is
187
+ enabled and a change is found — typically 0 per tick)
188
+
189
+ ## Error handling
190
+
191
+ - HTTP 401/403 → `FatalError` (bad token or no access — stops the monitor)
192
+ - HTTP 429 → `RateLimited` (backs off by `rate_limit_backoff`)
193
+ - HTTP 404 → logged and skipped (story may have been deleted or archived)