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,264 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+
5
+ module Superkick
6
+ module Integrations
7
+ module Shortcut
8
+ # Watches Shortcut for stories matching a search query and spawns
9
+ # AI coding sessions to work on them.
10
+ #
11
+ # Server-level spawner — distinct from the agent-bound
12
+ # Monitor which tracks a single story.
13
+ #
14
+ # Config keys:
15
+ # query (required) — Shortcut search query string
16
+ # token (optional) — API token, falls back to $SHORTCUT_API_TOKEN
17
+ # workspace_slug (optional) — workspace slug for building story URLs
18
+ # owner (optional) — additional owner filter appended to query
19
+ # label (optional) — additional label filter appended to query
20
+ class Spawner < Superkick::Spawner
21
+ API_BASE = Monitor::API_BASE
22
+ attr_reader :workflow_states, :members, :seen_story_ids, :conn
23
+
24
+ def self.type = :shortcut
25
+
26
+ def self.description
27
+ "Watches Shortcut for stories matching a search query and spawns " \
28
+ "AI coding sessions to work on them. Configurable via query, " \
29
+ "workflow_state_types, owner, and label filters."
30
+ end
31
+
32
+ def self.required_config = %i[query]
33
+
34
+ def self.spawn_templates_dir
35
+ File.join(__dir__, "templates")
36
+ end
37
+
38
+ def self.agent_id(event)
39
+ "shortcut-story-#{event[:story].id}"
40
+ end
41
+
42
+ def self.setup_label = "Shortcut"
43
+
44
+ def self.setup_config
45
+ <<~YAML
46
+ shortcut:
47
+ type: shortcut
48
+ query: "state:unstarted label:ai"
49
+ token: <%= env("SHORTCUT_API_TOKEN") %>
50
+ # workspace_slug: my-workspace
51
+ # owner: mention-name # filter by story owner
52
+ # label: ai # filter by label
53
+ # max_duration: 3600
54
+ YAML
55
+ end
56
+
57
+ def initialize(name:, config:, handler:, connection: nil)
58
+ super(name:, config:, handler:)
59
+ @conn = connection
60
+ end
61
+
62
+ def tick
63
+ stories = search_stories
64
+ stories.each do |story|
65
+ dispatch(
66
+ event_type: :story_ready,
67
+ story: story_drop(story)
68
+ )
69
+ end
70
+ end
71
+
72
+ def on_start
73
+ @conn ||= build_connection
74
+ @workflow_states = fetch_workflow_states
75
+ @members = fetch_members
76
+ @seen_story_ids = Set.new
77
+ end
78
+
79
+ def build_query
80
+ parts = [self[:query]]
81
+
82
+ if self[:owner]
83
+ parts << "owner:#{self[:owner]}"
84
+ end
85
+
86
+ if self[:label]
87
+ parts << "label:\"#{self[:label]}\""
88
+ end
89
+
90
+ parts.compact.join(" ")
91
+ end
92
+
93
+ private
94
+
95
+ def member_drop(member_id)
96
+ MemberDrop.new(resolve_member(member_id))
97
+ end
98
+
99
+ def story_drop(story)
100
+ StoryDrop.new({
101
+ id: story["id"],
102
+ name: story["name"],
103
+ url: story_url(story["id"]),
104
+ description: story["description"].to_s,
105
+ type: story["story_type"],
106
+ workflow_state: resolve_state(story["workflow_state_id"]),
107
+ labels: (story["labels"] || []).map { |l| l["name"] },
108
+ owners: (story["owner_ids"] || []).map { member_drop(it) },
109
+ epic_name: story["epic_id"] ? fetch_epic_name(story["epic_id"]) : nil,
110
+ tasks: extract_tasks(story).map { TaskDrop.new(it) },
111
+ comments: extract_recent_comments(story).map { CommentDrop.new(it) }
112
+ })
113
+ end
114
+
115
+ # -- Shortcut search API ------------------------------------------------
116
+
117
+ def search_stories
118
+ query = build_query
119
+ Superkick.logger.debug(log_tag) { "Searching: #{query}" }
120
+
121
+ resp = post("/api/v3/search/stories", {query:, page_size: 25})
122
+ return [] unless resp
123
+
124
+ stories = resp.body["data"] || []
125
+
126
+ # Filter to only newly-seen stories (first time in results).
127
+ # The AgentStore handles cross-restart dedup; this filters within
128
+ # a single server lifetime to avoid re-dispatching on every tick.
129
+ new_stories = stories.reject { |s| @seen_story_ids.include?(s["id"]) }
130
+ new_stories.each { |s| @seen_story_ids.add(s["id"]) }
131
+
132
+ Superkick.logger.info(log_tag) { "Found #{stories.size} matching, #{new_stories.size} new" }
133
+ new_stories
134
+ end
135
+
136
+ # -- Story data extraction ----------------------------------------------
137
+
138
+ def extract_tasks(story)
139
+ tasks = story["tasks"] || []
140
+ tasks.map do |t|
141
+ {description: t["description"], complete: t["complete"]}
142
+ end
143
+ end
144
+
145
+ def extract_recent_comments(story, limit: 5)
146
+ comments = story["comments"] || []
147
+ comments.last(limit).map do |c|
148
+ {
149
+ author: member_drop(c["author_id"]),
150
+ body: c["text"].to_s.strip,
151
+ created_at: c["created_at"]
152
+ }
153
+ end
154
+ end
155
+
156
+ def fetch_epic_name(epic_id)
157
+ return @epic_names[epic_id] if @epic_names&.key?(epic_id)
158
+
159
+ @epic_names ||= {}
160
+ resp = get("/api/v3/epics/#{epic_id}")
161
+ return nil unless resp
162
+
163
+ epic = resp.body
164
+ @epic_names[epic_id] = epic["name"]
165
+ epic["name"]
166
+ rescue => e
167
+ Superkick.logger.debug(log_tag) { "Could not fetch epic #{epic_id}: #{e.message}" }
168
+ nil
169
+ end
170
+
171
+ # -- Shared API helpers (same patterns as ShortcutMonitor) --------------
172
+
173
+ def story_url(story_id)
174
+ slug = self[:workspace_slug]
175
+ return nil unless slug
176
+ "https://app.shortcut.com/#{slug}/story/#{story_id}"
177
+ end
178
+
179
+ def resolve_state(state_id)
180
+ entry = @workflow_states[state_id]
181
+ return entry[:name] if entry
182
+
183
+ @workflow_states = fetch_workflow_states
184
+ @workflow_states.dig(state_id, :name) || "Unknown"
185
+ end
186
+
187
+ def resolve_member(member_id)
188
+ return {mention_name: "unknown", display_name: "unknown"} unless member_id
189
+
190
+ info = @members[member_id]
191
+ return info if info
192
+
193
+ @members = fetch_members
194
+ @members[member_id] || {mention_name: member_id.to_s, display_name: member_id.to_s}
195
+ end
196
+
197
+ def fetch_workflow_states
198
+ resp = get("/api/v3/workflows")
199
+ return {} unless resp
200
+
201
+ states = {}
202
+ resp.body.each do |wf|
203
+ wf_states = wf["states"] || []
204
+ wf_states.each do |s|
205
+ states[s["id"]] = {name: s["name"], type: s["type"]}
206
+ end
207
+ end
208
+ states
209
+ end
210
+
211
+ def fetch_members
212
+ resp = get("/api/v3/members")
213
+ return {} unless resp
214
+
215
+ resp.body.each_with_object({}) do |m, h|
216
+ profile = m["profile"] || {}
217
+ h[m["id"]] = {
218
+ mention_name: profile["mention_name"] || m["id"],
219
+ display_name: profile["name"] || profile["mention_name"] || m["id"]
220
+ }
221
+ end
222
+ end
223
+
224
+ # -- HTTP ---------------------------------------------------------------
225
+
226
+ def get(path)
227
+ resp = @conn.get(path)
228
+ return nil unless handle_response!(resp)
229
+ resp
230
+ end
231
+
232
+ def post(path, body)
233
+ resp = @conn.post(path, body)
234
+ return nil unless handle_response!(resp)
235
+ resp
236
+ end
237
+
238
+ # Returns true on success, raises on auth/rate-limit, returns false on 404.
239
+ def handle_response!(resp)
240
+ case resp.status
241
+ when 200..299 then true
242
+ when 401, 403 then raise FatalError, "Shortcut auth failed (HTTP #{resp.status})"
243
+ when 429 then raise RateLimited, "Shortcut rate limited"
244
+ when 404
245
+ Superkick.logger.warn(log_tag) { "Shortcut 404: resource not found" }
246
+ false
247
+ else
248
+ false
249
+ end
250
+ end
251
+
252
+ def build_connection
253
+ token = self[:token] || ENV["SHORTCUT_API_TOKEN"]
254
+
255
+ Faraday.new(url: API_BASE) do |f|
256
+ f.headers["Shortcut-Token"] = token if token
257
+ f.request :json
258
+ f.response :json
259
+ end
260
+ end
261
+ end
262
+ end
263
+ end
264
+ end
@@ -0,0 +1,6 @@
1
+ {% assign labels = relationship %}{% if epic_sibling %}{% assign labels = labels | push: "epic sibling" %}{% endif -%}
2
+ SUPERKICK [{{ "now" | time }}]: Related story {{ story.ref }} ({{ labels | join: ", " }}) moved from "{{ old_state }}" to "{{ new_state }}"
3
+ This may affect your work on {{ primary_story.ref }}.
4
+ {% if story.url -%}
5
+ {{ story.url }}
6
+ {% endif -%}
@@ -0,0 +1,8 @@
1
+ SUPERKICK [{{ "now" | time }}]: Story {{ story.ref }} is now BLOCKED{% if actor %} by {{ actor.display_name }}{% endif %}.
2
+ {% if blocker_reason -%}
3
+ Reason: {{ blocker_reason | truncate: 200 }}
4
+ {% endif -%}
5
+ You may want to check if you can unblock this or switch to another task.
6
+ {% if story.url -%}
7
+ {{ story.url }}
8
+ {% endif -%}
@@ -0,0 +1,5 @@
1
+ SUPERKICK [{{ "now" | time }}]: New comment on {{ story.ref }} from {{ author.tag }}:
2
+ "{{ body | truncate: 300 }}"
3
+ {% if story.url -%}
4
+ {{ story.url }}
5
+ {% endif -%}
@@ -0,0 +1,19 @@
1
+ SUPERKICK [{{ "now" | time }}]: Description updated on {{ story.ref }}{% if actor %} by {{ actor.display_name }}{% endif %}
2
+ {% if old_name != new_name -%}
3
+ Title changed from "{{ old_name | truncate: 80 }}" to "{{ new_name | truncate: 80 }}"
4
+ {% endif -%}
5
+ {% if old_description != new_description -%}
6
+
7
+ Previous description:
8
+ {{ old_description | truncate: 2000 }}
9
+
10
+ Updated description:
11
+ {{ new_description | truncate: 2000 }}
12
+
13
+ Please review the changes above — the story requirements may have changed.
14
+ {% else -%}
15
+ The story requirements may have changed. Consider reviewing the updated description.
16
+ {% endif -%}
17
+ {% if story.url -%}
18
+ {{ story.url }}
19
+ {% endif -%}
@@ -0,0 +1,10 @@
1
+ SUPERKICK [{{ "now" | time }}]: Owners changed on {{ story.ref }}{% if actor %} by {{ actor.display_name }}{% endif %}
2
+ {% if added_owners.size > 0 -%}
3
+ Added: {{ added_owners | map: "tag" | join: ", " }}
4
+ {% endif -%}
5
+ {% if removed_owners.size > 0 -%}
6
+ Removed: {{ removed_owners | map: "tag" | join: ", " }}
7
+ {% endif -%}
8
+ {% if story.url -%}
9
+ {{ story.url }}
10
+ {% endif -%}
@@ -0,0 +1,41 @@
1
+ You have been assigned to work on a Shortcut story.
2
+
3
+ ## Story: sc-{{ story.id }} — {{ story.name }}
4
+ {% if story.url -%}
5
+ URL: {{ story.url }}
6
+ {% endif -%}
7
+ Type: {{ story.type }}
8
+ State: {{ story.workflow_state }}
9
+ {% if story.epic_name -%}
10
+ Epic: {{ story.epic_name }}
11
+ {% endif -%}
12
+ {% if story.labels.size > 0 -%}
13
+ Labels: {{ story.labels | join: ", " }}
14
+ {% endif -%}
15
+ {% if story.owners.size > 0 -%}
16
+ Owners: {{ story.owners | map: "tag" | join: ", " }}
17
+ {% endif -%}
18
+
19
+ ## Description
20
+
21
+ {% if story.description == "" %}(no description){% else %}{{ story.description }}{% endif %}
22
+ {% if story.tasks.size > 0 -%}
23
+
24
+ ## Tasks
25
+
26
+ {% for t in story.tasks -%}
27
+ - [{% if t.complete %}x{% else %} {% endif %}] {{ t.description }}
28
+ {% endfor -%}
29
+ {% endif -%}
30
+ {% if story.comments.size > 0 -%}
31
+
32
+ ## Recent Comments
33
+
34
+ {% for c in story.comments -%}
35
+ **{{ c.author.display_name }}**: {{ c.body | truncate: 200 }}
36
+ {% endfor -%}
37
+ {% endif -%}
38
+
39
+ Please read the story description and tasks carefully. Implement the requested
40
+ changes, write tests, and commit your work. If anything is unclear, check the
41
+ story comments or linked stories for additional context.
@@ -0,0 +1,9 @@
1
+ SUPERKICK [{{ "now" | time }}]: Story {{ story.ref }} moved from "{{ old_state }}" to "{{ new_state }}"{% if actor %} by {{ actor.display_name }}{% endif %}
2
+ {% if new_state_type == "done" -%}
3
+ The story has been marked as complete.
4
+ {% elsif new_state_type == "backlog" -%}
5
+ The story has been moved back to the backlog. Check if there are blockers or scope changes.
6
+ {% endif -%}
7
+ {% if story.url -%}
8
+ {{ story.url }}
9
+ {% endif -%}
@@ -0,0 +1,5 @@
1
+ SUPERKICK [{{ "now" | time }}]: Story {{ story.ref }} is no longer blocked{% if actor %} by {{ actor.display_name }}{% endif %}.
2
+ You can resume work on this story.
3
+ {% if story.url -%}
4
+ {{ story.url }}
5
+ {% endif -%}
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "shortcut/drops"
4
+ require_relative "shortcut/monitor"
5
+ require_relative "shortcut/probe"
6
+ require_relative "shortcut/spawner"
7
+
8
+ module Superkick
9
+ Monitor.register(Integrations::Shortcut::Monitor)
10
+ Spawner.register(Integrations::Shortcut::Spawner)
11
+ end
@@ -0,0 +1,297 @@
1
+ # Slack Integration
2
+
3
+ The Slack integration provides three components:
4
+
5
+ - **Notifier** (`:slack`) — posts Block Kit notifications to Slack
6
+ - **Spawner** (`:slack`) — watches a channel for messages and spawns agents
7
+ - **Thread Monitor** (`:slack_thread`) — polls a thread for replies and injects them
8
+
9
+ Plus **Liquid Drops** for type-safe template access to Slack data.
10
+
11
+ ---
12
+
13
+ ## Notifier
14
+
15
+ Type: `:slack`
16
+
17
+ Posts rich Block Kit messages to Slack when an injection or lifecycle event
18
+ occurs. Supports two authentication modes.
19
+
20
+ ### Configuration
21
+
22
+ **Incoming Webhook** — posts to a single channel, no bot token needed:
23
+
24
+ ```yaml
25
+ notifications:
26
+ - type: slack
27
+ webhook_url: <%= env("SLACK_WEBHOOK_URL") %>
28
+ ```
29
+
30
+ **Web API** — can target any channel, requires a bot token with `chat:write` scope:
31
+
32
+ ```yaml
33
+ notifications:
34
+ - type: slack
35
+ token: <%= env("SLACK_BOT_TOKEN") %>
36
+ channel: "#superkick-notifications"
37
+ events:
38
+ - agent_completed
39
+ - agent_failed
40
+ - agent_blocked
41
+ ```
42
+
43
+ | Key | Required | Default | Description |
44
+ |-----|----------|---------|-------------|
45
+ | `webhook_url` | one of webhook or token | — | Slack Incoming Webhook URL |
46
+ | `token` | one of webhook or token | — | Slack Bot User OAuth Token (`xoxb-...`) |
47
+ | `channel` | with token | — | Channel name or ID to post to |
48
+
49
+ ### Message format
50
+
51
+ Messages use Slack's Block Kit for rich formatting. The default template
52
+ produces a header, section, and context block. Custom templates have access
53
+ to the full set of Block Kit block types via Liquid tags:
54
+
55
+ **Block tags:** `{% header %}`, `{% section %}`, `{% context %}`, `{% image %}`,
56
+ `{% button %}`, `{% fields %}`, `{% rich_text %}`, `{% video %}`, `{% input %}`
57
+
58
+ **Bodyless tags:** `{% divider %}`, `{% file %}`
59
+
60
+ Consecutive `{% button %}` tags auto-group into a single `actions` block.
61
+
62
+ A plain-text `text` field is included as a fallback for notification previews.
63
+
64
+ ### Stateful threading (Web API mode)
65
+
66
+ In Web API mode, the Slack notifier is **stateful** — it tracks `thread_ts` per
67
+ agent or team so related events are threaded together in the channel:
68
+
69
+ - When an agent has a `team_id`, messages are threaded by **team** (all agents in
70
+ the same team share a single Slack thread)
71
+ - Without a team, messages are threaded by **agent** (each agent gets its own thread)
72
+
73
+ The first event creates a new top-level message; subsequent events for the same
74
+ agent/team reply to it, keeping the channel clean.
75
+
76
+ Webhook mode is **stateless** — Slack webhooks don't return a message `ts`, so
77
+ each message is standalone.
78
+
79
+ State is stored via `NotifierStateStore` (in-memory for local server, database-backed
80
+ for hosted).
81
+
82
+ ### Event filtering
83
+
84
+ Restrict which events a notifier receives with `events:`:
85
+
86
+ ```yaml
87
+ notifications:
88
+ - type: slack
89
+ token: <%= env("SLACK_BOT_TOKEN") %>
90
+ channel: "#superkick-alerts"
91
+ events:
92
+ - agent_blocked
93
+ - agent_failed
94
+ - budget_exceeded
95
+ ```
96
+
97
+ When `events:` is absent, the notifier receives all events.
98
+
99
+ ### Event emoji
100
+
101
+ Every lifecycle event has a mapped emoji via `EMOJI_MAP`. For unknown event
102
+ types, the `event_emoji` filter uses keyword substring matching — if the event
103
+ type contains a recognized keyword (e.g. `completed`, `failed`, `spawned`,
104
+ `warning`), the corresponding emoji is used. Fully unrecognized events fall
105
+ back to `:bell:`.
106
+
107
+ This means custom events like `deployment_completed` or `build_failed`
108
+ automatically get reasonable emoji without explicit registration.
109
+
110
+ ### Custom templates
111
+
112
+ Override the default Block Kit template per event type or globally:
113
+
114
+ ```
115
+ ~/.superkick/templates/notifications/slack/<event_type>.liquid # per-event
116
+ ~/.superkick/templates/notifications/slack/default.liquid # catch-all
117
+ ```
118
+
119
+ Templates use the `liquid do` DSL with Slack-specific block and bodyless tags.
120
+ The accumulator builds up a Block Kit `blocks` array and optional metadata. See
121
+ the [notifier skill](../../../../skills/superkick-new-notifier/SKILL.md) for the
122
+ full template authoring guide.
123
+
124
+ ### Liquid filters
125
+
126
+ | Filter | Description |
127
+ |--------|-------------|
128
+ | `event_emoji` | Maps event types to Slack emoji (3-tier: explicit → keyword → `:bell:`) |
129
+ | `format_title` | Formats a notification title with event type |
130
+ | `join_present` | Joins non-nil/non-empty values with a separator |
131
+
132
+ ---
133
+
134
+ ## Spawner
135
+
136
+ Type: `:slack`
137
+
138
+ Watches a Slack channel for new top-level messages via `conversations.history`
139
+ and spawns an agent for each one. When `monitor_thread` is enabled (the
140
+ default), the spawned agent automatically gets a `slack_thread` monitor that
141
+ feeds user replies back as high-priority injections.
142
+
143
+ ### Configuration
144
+
145
+ ```yaml
146
+ spawners:
147
+ slack_support:
148
+ type: slack
149
+ channel: C0123456789
150
+ channel_name: support
151
+ token: <%= env("SLACK_BOT_TOKEN") %>
152
+ driver: claude_code
153
+ max_duration: 3600
154
+ ```
155
+
156
+ | Key | Required | Default | Description |
157
+ |-----|----------|---------|-------------|
158
+ | `channel` | yes | — | Slack channel ID (e.g. `C0123456789`) |
159
+ | `token` | no | `SLACK_BOT_TOKEN` env | Bot User OAuth Token |
160
+ | `channel_name` | no | — | Human-readable channel name for templates |
161
+ | `filter_pattern` | no | — | Regex; only matching messages trigger spawns |
162
+ | `ignore_bots` | no | `true` | Skip bot messages and the bot's own user ID |
163
+ | `ignore_threads` | no | `true` | Skip threaded replies (only top-level messages) |
164
+ | `monitor_thread` | no | `true` | Auto-attach `slack_thread` monitor to spawned agents |
165
+
166
+ ### Bot token scopes
167
+
168
+ The bot token needs these OAuth scopes:
169
+
170
+ - `channels:history` — read channel messages
171
+ - `users:read` — resolve user display names
172
+ - `chat:write` — (if using the Slack notifier for replies)
173
+
174
+ ### Agent ID format
175
+
176
+ `slack-message-{channel_id}-{message_ts}` — unique per message.
177
+
178
+ ### Spawn event
179
+
180
+ The spawner dispatches events with these keys:
181
+
182
+ | Key | Description |
183
+ |-----|-------------|
184
+ | `event_type` | `:slack_message` |
185
+ | `channel_id` | Slack channel ID |
186
+ | `channel_name` | Human-readable channel name |
187
+ | `message_ts` | Message timestamp |
188
+ | `slack_thread_ts` | Thread timestamp (same as `message_ts` for top-level) |
189
+ | `user` | Slack user ID |
190
+ | `user_name` | Resolved display name |
191
+ | `text` | Message text |
192
+ | `message` | `MessageDrop` (with nested `sender` and `channel`) |
193
+ | `sender` | `UserDrop` |
194
+ | `channel` | `ChannelDrop` |
195
+
196
+ ### Thread monitoring
197
+
198
+ When `monitor_thread: true` (default), the spawner attaches a `_spawn_monitors`
199
+ entry to the event. The spawn handler automatically starts a `slack_thread`
200
+ monitor for the spawned agent, so replies in the Slack thread are injected
201
+ as high-priority events.
202
+
203
+ ---
204
+
205
+ ## Thread Monitor
206
+
207
+ Type: `:slack_thread`
208
+
209
+ Polls a Slack thread for new replies via `conversations.replies` and injects
210
+ them into the agent as high-priority events. Typically auto-attached by the
211
+ Slack spawner — not configured manually.
212
+
213
+ ### Configuration
214
+
215
+ Injected automatically at spawn time:
216
+
217
+ | Key | Required | Default | Description |
218
+ |-----|----------|---------|-------------|
219
+ | `channel_id` | yes | — | Slack channel ID |
220
+ | `thread_ts` | yes | — | Root message timestamp |
221
+ | `token` | no | `SLACK_BOT_TOKEN` env | Bot User OAuth Token |
222
+
223
+ ### Injection event
224
+
225
+ | Key | Description |
226
+ |-----|-------------|
227
+ | `event_type` | `:slack_reply` |
228
+ | `user` | Slack user ID |
229
+ | `user_name` | Resolved display name |
230
+ | `text` | Reply text |
231
+ | `message_ts` | Reply timestamp |
232
+ | `sender` | `UserDrop` |
233
+ | `channel` | `ChannelDrop` |
234
+ | `injection_priority` | `:high` |
235
+ | `injection_ttl` | `900` seconds |
236
+
237
+ ---
238
+
239
+ ## Drops
240
+
241
+ Three Liquid Drop classes provide type-safe template access to Slack data.
242
+ All drops survive serialization/rehydration via `_drop_type` markers.
243
+
244
+ ### `MessageDrop` (`slack_message`)
245
+
246
+ Wraps a Slack message with nested sender and channel drops.
247
+
248
+ | Method | Returns | Description |
249
+ |--------|---------|-------------|
250
+ | `text` | String | Message text |
251
+ | `sender` | `UserDrop` | Nested user drop |
252
+ | `channel` | `ChannelDrop` | Nested channel drop |
253
+ | `message_ts` | String | Message timestamp |
254
+ | `thread_ts` | String | Thread timestamp |
255
+ | `ref` | String | Formatted: `@Alice in #engineering` |
256
+
257
+ ### `UserDrop` (`slack_user`)
258
+
259
+ Wraps a Slack user.
260
+
261
+ | Method | Returns | Description |
262
+ |--------|---------|-------------|
263
+ | `id` | String | Slack user ID (e.g. `U123`) |
264
+ | `name` | String | Display name |
265
+ | `tag` | String | Formatted: `Alice (<@U123>)` |
266
+
267
+ ### `ChannelDrop` (`slack_channel`)
268
+
269
+ Wraps a Slack channel.
270
+
271
+ | Method | Returns | Description |
272
+ |--------|---------|-------------|
273
+ | `id` | String | Channel ID (e.g. `C456`) |
274
+ | `name` | String | Channel name |
275
+ | `ref` | String | Formatted: `#engineering` (falls back to ID) |
276
+
277
+ ### Template usage
278
+
279
+ ```liquid
280
+ Message from {{ message.sender.tag }} in {{ message.channel.ref }}:
281
+ {{ message.text }}
282
+ ```
283
+
284
+ ### Serialization
285
+
286
+ Nested drops are serialized recursively via `to_h` and rehydrated via
287
+ `Drop.rehydrate`:
288
+
289
+ ```ruby
290
+ serialized = message_drop.to_h
291
+ # => { _drop_type: "slack_message", text: "...",
292
+ # sender: { _drop_type: "slack_user", id: "U123", name: "Alice" },
293
+ # channel: { _drop_type: "slack_channel", id: "C456", name: "engineering" } }
294
+
295
+ rehydrated = Superkick::Drop.rehydrate(serialized)
296
+ rehydrated.sender.name # => "Alice"
297
+ ```