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,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Superkick
4
+ module Integrations
5
+ module Shortcut
6
+ # Liquid Drop for Shortcut member data. Wraps the symbol-keyed
7
+ # hash returned by ShortcutMonitor#resolve_member /
8
+ # ShortcutSpawner#resolve_member so Liquid templates can access
9
+ # properties via dot notation (e.g. {{ author.display_name }}).
10
+ class MemberDrop < Superkick::Drop
11
+ def self.drop_type = "shortcut_member"
12
+
13
+ def display_name = @data[:display_name]
14
+
15
+ def mention_name = @data[:mention_name]
16
+
17
+ # "Display Name (@mention)" — used in member_list filter and
18
+ # directly in templates via {{ member.tag }}.
19
+ def tag = "#{display_name} (@#{mention_name})"
20
+ end
21
+
22
+ # Liquid Drop for a Shortcut story. Wraps the common story fields
23
+ # (id, name, url) that appear in every Shortcut event, plus optional
24
+ # extended fields from spawner events (description, type, etc.).
25
+ #
26
+ # The `ref` method replaces the `story_ref` filter — it formats
27
+ # the story reference as `sc-{id} "truncated name"`.
28
+ class StoryDrop < Superkick::Drop
29
+ def self.drop_type = "shortcut_story"
30
+
31
+ def id = @data[:id]
32
+
33
+ def name = @data[:name]
34
+
35
+ def url = @data[:url]
36
+
37
+ def description = @data[:description]
38
+
39
+ def type = @data[:type]
40
+
41
+ def workflow_state = @data[:workflow_state]
42
+
43
+ def labels = @data[:labels]
44
+
45
+ def owners = @data[:owners]
46
+
47
+ def epic_name = @data[:epic_name]
48
+
49
+ def tasks = @data[:tasks]
50
+
51
+ def comments = @data[:comments]
52
+
53
+ # Formatted reference: sc-123 "Story title" — replaces story_ref filter.
54
+ def ref
55
+ truncated = name.to_s
56
+ truncated = "#{truncated[0, 47]}..." if truncated.length > 50
57
+ name_part = truncated.empty? ? "" : " \"#{truncated}\""
58
+ "sc-#{id}#{name_part}"
59
+ end
60
+ end
61
+
62
+ # Liquid Drop for Shortcut story tasks.
63
+ class TaskDrop < Superkick::Drop
64
+ def self.drop_type = "shortcut_task"
65
+
66
+ def description = @data[:description]
67
+
68
+ def complete = @data[:complete]
69
+ end
70
+
71
+ # Liquid Drop for Shortcut story comments.
72
+ class CommentDrop < Superkick::Drop
73
+ def self.drop_type = "shortcut_comment"
74
+
75
+ def author
76
+ raw = @data[:author]
77
+ raw.is_a?(MemberDrop) ? raw : MemberDrop.new(raw)
78
+ end
79
+
80
+ def body = @data[:body]
81
+
82
+ def created_at = @data[:created_at]
83
+ end
84
+ end
85
+ end
86
+ end
87
+
88
+ Superkick::Drop.register(Superkick::Integrations::Shortcut::MemberDrop)
89
+ Superkick::Drop.register(Superkick::Integrations::Shortcut::StoryDrop)
90
+ Superkick::Drop.register(Superkick::Integrations::Shortcut::TaskDrop)
91
+ Superkick::Drop.register(Superkick::Integrations::Shortcut::CommentDrop)
@@ -0,0 +1,582 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "json"
5
+ require "digest"
6
+
7
+ module Superkick
8
+ module Integrations
9
+ module Shortcut
10
+ # Polls Shortcut (formerly Clubhouse) for story-level changes.
11
+ #
12
+ # Config keys provided by probe: story_id (auto-detected from sc-XXXXX branch)
13
+ # Optional config keys: token (or SHORTCUT_API_TOKEN env var),
14
+ # workspace_slug (for building web URLs),
15
+ # member (UUID, email, or mention name to identify current user),
16
+ # ignore_self (default true — skip self-authored changes)
17
+ #
18
+ # Events emitted: story_state_changed, story_comment, story_blocker,
19
+ # story_unblocked, story_owner_changed,
20
+ # story_description_changed, related_story_changed
21
+ #
22
+ # Watermarks persisted: last_state_id, last_comment_id, last_blocker,
23
+ # last_owner_ids, last_description_hash
24
+ class Monitor < Superkick::Monitor
25
+ API_BASE = "https://api.app.shortcut.com"
26
+ BRANCH_STORY_RE = /sc-(\d+)/
27
+
28
+ attr_reader :conn
29
+
30
+ def self.type = :shortcut
31
+
32
+ def self.description
33
+ "Monitors a Shortcut story for state changes, new comments, blockers, " \
34
+ "owner changes, and description updates. Also watches linked and sibling stories " \
35
+ "under the same epic. story_id is auto-detected from sc-XXXXX branch pattern. " \
36
+ "Optionally accepts a token (or set SHORTCUT_API_TOKEN env var) and workspace_slug. " \
37
+ "Self-authored changes are ignored by default (set ignore_self: false to disable)."
38
+ end
39
+
40
+ def self.required_config = %i[story_id]
41
+
42
+ def self.setup_label = "Shortcut"
43
+
44
+ def self.setup_config
45
+ <<~YAML
46
+ shortcut:
47
+ token: <%= env("SHORTCUT_API_TOKEN") %>
48
+ # story_id is auto-detected from sc-XXXXX branch pattern.
49
+ # Uncomment to set explicitly:
50
+ # story_id: 12345
51
+ # workspace_slug: my-workspace # enables story URL generation
52
+ # ignore_self: true # skip self-authored changes (default)
53
+ YAML
54
+ end
55
+
56
+ # Fill in missing story_id from the git branch name (sc-XXXXX pattern).
57
+ def self.resolve_config(config, environment: {})
58
+ unless config[:story_id]
59
+ branch = environment[:git_branch].to_s
60
+ match = BRANCH_STORY_RE.match(branch)
61
+ config[:story_id] = match[1] if match
62
+ end
63
+ config
64
+ end
65
+
66
+ def self.templates_dir
67
+ File.join(__dir__, "templates")
68
+ end
69
+
70
+ def initialize(name:, config:, handler:, agent: nil, server_context: {}, connection: nil)
71
+ super(name:, config:, handler:, agent:, server_context:)
72
+ @conn = connection
73
+ end
74
+
75
+ def tick
76
+ story_id = self[:story_id].to_i
77
+ story = fetch_story(story_id)
78
+ return unless story
79
+
80
+ check_state_change(story)
81
+ check_comments(story)
82
+ check_blocker(story)
83
+ check_owners(story)
84
+ check_description(story)
85
+ check_related_stories(story)
86
+ end
87
+
88
+ def on_start
89
+ @conn ||= build_connection
90
+ @workflow_states = fetch_workflow_states
91
+ @members = fetch_members
92
+ @current_member_id = detect_current_member
93
+ end
94
+
95
+ private
96
+
97
+ # ── Self-action filtering ────────────────────────────────────────────────
98
+
99
+ def ignore_self?
100
+ self[:ignore_self].nil? || self[:ignore_self]
101
+ end
102
+
103
+ UUID_PATTERN = /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i
104
+ private_constant :UUID_PATTERN
105
+
106
+ def detect_current_member
107
+ member = self[:member]
108
+ if member
109
+ resolved = resolve_member_config(member)
110
+ if resolved
111
+ Superkick.logger.info(log_tag) { "Resolved member #{member.inspect} to #{resolved}" }
112
+ return resolved
113
+ end
114
+ Superkick.logger.warn(log_tag) { "Could not resolve member #{member.inspect}" }
115
+ return nil
116
+ end
117
+
118
+ # Auto-detect from API token
119
+ resp = get("/api/v3/member")
120
+ return nil unless resp
121
+
122
+ data = resp.body
123
+ member_id = data["id"]
124
+ mention = data["mention_name"] || data.dig("profile", "mention_name") || member_id
125
+ Superkick.logger.info(log_tag) { "Detected current member: #{mention} (#{member_id})" }
126
+ member_id
127
+ rescue => e
128
+ Superkick.logger.warn(log_tag) { "Could not detect current member: #{e.message}" }
129
+ nil
130
+ end
131
+
132
+ def resolve_member_config(value)
133
+ # UUID — use directly
134
+ return value if value.match?(UUID_PATTERN)
135
+
136
+ # Email — lookup by email_address
137
+ # Mention name — lookup by mention_name
138
+ resp = get("/api/v3/members")
139
+ return nil unless resp
140
+
141
+ members = resp.body
142
+ match = if value.include?("@")
143
+ members.find { |m| (m.dig("profile", "email_address") || "").casecmp?(value) }
144
+ else
145
+ members.find { |m| (m.dig("profile", "mention_name") || "") == value }
146
+ end
147
+ match&.dig("id")
148
+ end
149
+
150
+ def self_action?(actor_id)
151
+ ignore_self? && @current_member_id && actor_id == @current_member_id
152
+ end
153
+
154
+ def latest_history_actor(story_id)
155
+ resp = get("/api/v3/stories/#{story_id}/history")
156
+ return nil unless resp
157
+
158
+ entries = resp.body
159
+ return nil if entries.empty?
160
+
161
+ # History entries are chronological; last entry is most recent
162
+ latest = entries.last
163
+ latest["member_id"]
164
+ rescue => e
165
+ Superkick.logger.debug(log_tag) { "Could not fetch story history: #{e.message}" }
166
+ nil
167
+ end
168
+
169
+ # ── Story fetch ─────────────────────────────────────────────────────────
170
+
171
+ def fetch_story(story_id)
172
+ resp = get("/api/v3/stories/#{story_id}")
173
+ return nil unless resp
174
+
175
+ resp.body
176
+ end
177
+
178
+ # ── State changes ───────────────────────────────────────────────────────
179
+
180
+ def check_state_change(story)
181
+ current_state_id = story["workflow_state_id"]
182
+ last_state_id = self[:last_state_id]
183
+
184
+ # Seed watermark on first tick
185
+ if last_state_id.nil?
186
+ @agent.set_monitor_field(@name, :last_state_id, current_state_id)
187
+ @config[:last_state_id] = current_state_id
188
+ return
189
+ end
190
+
191
+ return if current_state_id == last_state_id
192
+
193
+ old_state = resolve_state(last_state_id)
194
+ new_state = resolve_state(current_state_id)
195
+ new_state_type = resolve_state_type(current_state_id)
196
+
197
+ @agent.set_monitor_field(@name, :last_state_id, current_state_id)
198
+ @config[:last_state_id] = current_state_id
199
+
200
+ actor_id = latest_history_actor(story["id"])
201
+ return if self_action?(actor_id)
202
+
203
+ dispatch(
204
+ event_type: :story_state_changed,
205
+ story: story_drop(story),
206
+ old_state:,
207
+ new_state:,
208
+ new_state_type:,
209
+ actor: actor_id ? member_drop(actor_id) : nil
210
+ )
211
+ end
212
+
213
+ # ── Comments ────────────────────────────────────────────────────────────
214
+
215
+ def check_comments(story)
216
+ comments = story["comments"] || []
217
+ last_id = self[:last_comment_id]&.to_i || 0
218
+
219
+ # Seed watermark on first tick
220
+ if self[:last_comment_id].nil? && comments.any?
221
+ max_id = comments.map { |c| c["id"] }.max
222
+ @agent.set_monitor_field(@name, :last_comment_id, max_id)
223
+ @config[:last_comment_id] = max_id
224
+ return
225
+ end
226
+
227
+ new_comments = comments.select { |c| c["id"] > last_id }
228
+ return if new_comments.empty?
229
+
230
+ # Always advance the watermark (even for self-authored comments)
231
+ max_id = new_comments.map { |c| c["id"] }.max
232
+ @agent.set_monitor_field(@name, :last_comment_id, max_id)
233
+ @config[:last_comment_id] = max_id
234
+
235
+ # Filter out self-authored comments
236
+ if ignore_self? && @current_member_id
237
+ new_comments = new_comments.reject { |c| c["author_id"] == @current_member_id }
238
+ end
239
+
240
+ new_comments.each do |comment|
241
+ dispatch(
242
+ event_type: :story_comment,
243
+ story: story_drop(story),
244
+ author: member_drop(comment["author_id"]),
245
+ body: comment["text"].to_s.strip
246
+ )
247
+ end
248
+ end
249
+
250
+ # ── Blocker status ──────────────────────────────────────────────────────
251
+
252
+ def check_blocker(story)
253
+ current_blocker = story["blocker"] || false
254
+ last_blocker = self[:last_blocker]
255
+
256
+ # Seed watermark on first tick
257
+ if last_blocker.nil?
258
+ @agent.set_monitor_field(@name, :last_blocker, current_blocker)
259
+ @config[:last_blocker] = current_blocker
260
+ return
261
+ end
262
+
263
+ return if current_blocker == last_blocker
264
+
265
+ @agent.set_monitor_field(@name, :last_blocker, current_blocker)
266
+ @config[:last_blocker] = current_blocker
267
+
268
+ actor_id = latest_history_actor(story["id"])
269
+ return if self_action?(actor_id)
270
+
271
+ if current_blocker
272
+ # Look for blocker comments
273
+ comments = story["comments"] || []
274
+ blocker_comments = comments.select { |c| c["blocker"] }
275
+ blocker_reason = blocker_comments.last&.dig("text")
276
+
277
+ dispatch(
278
+ event_type: :story_blocker,
279
+ story: story_drop(story),
280
+ blocker_reason: blocker_reason,
281
+ actor: actor_id ? member_drop(actor_id) : nil,
282
+ injection_priority: :high,
283
+ injection_supersede_key: "story_blocker_status"
284
+ )
285
+ else
286
+ dispatch(
287
+ event_type: :story_unblocked,
288
+ story: story_drop(story),
289
+ actor: actor_id ? member_drop(actor_id) : nil,
290
+ injection_ttl: 120,
291
+ injection_supersede_key: "story_blocker_status"
292
+ )
293
+ end
294
+ end
295
+
296
+ # ── Owner changes ───────────────────────────────────────────────────────
297
+
298
+ def check_owners(story)
299
+ owner_ids = story["owner_ids"] || []
300
+ current_ids = owner_ids.sort
301
+ last_ids_json = self[:last_owner_ids]
302
+
303
+ # Seed watermark on first tick
304
+ if last_ids_json.nil?
305
+ @agent.set_monitor_field(@name, :last_owner_ids, JSON.generate(current_ids))
306
+ @config[:last_owner_ids] = JSON.generate(current_ids)
307
+ return
308
+ end
309
+
310
+ last_ids = JSON.parse(last_ids_json).sort
311
+ return if current_ids == last_ids
312
+
313
+ @agent.set_monitor_field(@name, :last_owner_ids, JSON.generate(current_ids))
314
+ @config[:last_owner_ids] = JSON.generate(current_ids)
315
+
316
+ actor_id = latest_history_actor(story["id"])
317
+ return if self_action?(actor_id)
318
+
319
+ added = current_ids - last_ids
320
+ removed = last_ids - current_ids
321
+
322
+ dispatch(
323
+ event_type: :story_owner_changed,
324
+ story: story_drop(story),
325
+ added_owners: added.map { member_drop(it) },
326
+ removed_owners: removed.map { member_drop(it) },
327
+ actor: actor_id ? member_drop(actor_id) : nil
328
+ )
329
+ end
330
+
331
+ # ── Description changes ─────────────────────────────────────────────────
332
+
333
+ def check_description(story)
334
+ current_name = story["name"].to_s
335
+ current_description = story["description"].to_s
336
+ content = "#{current_name}\n#{current_description}"
337
+ current_hash = Digest::SHA256.hexdigest(content)
338
+ last_hash = self[:last_description_hash]
339
+
340
+ # Seed watermark on first tick
341
+ if last_hash.nil?
342
+ @last_name = current_name
343
+ @last_description = current_description
344
+ @agent.set_monitor_field(@name, :last_description_hash, current_hash)
345
+ @config[:last_description_hash] = current_hash
346
+ return
347
+ end
348
+
349
+ return if current_hash == last_hash
350
+
351
+ old_name = @last_name || current_name
352
+ old_description = @last_description || current_description
353
+
354
+ @last_name = current_name
355
+ @last_description = current_description
356
+ @agent.set_monitor_field(@name, :last_description_hash, current_hash)
357
+ @config[:last_description_hash] = current_hash
358
+
359
+ actor_id = latest_history_actor(story["id"])
360
+ return if self_action?(actor_id)
361
+
362
+ dispatch(
363
+ event_type: :story_description_changed,
364
+ story: story_drop(story),
365
+ old_name:,
366
+ new_name: current_name,
367
+ old_description:,
368
+ new_description: current_description,
369
+ actor: actor_id ? member_drop(actor_id) : nil
370
+ )
371
+ end
372
+
373
+ # ── Related stories (linked + epic siblings) ────────────────────────────
374
+
375
+ def check_related_stories(story)
376
+ @related_states ||= {}
377
+
378
+ related_ids = collect_related_ids(story)
379
+ return if related_ids.empty?
380
+
381
+ related_ids.each do |rid|
382
+ related = fetch_story(rid)
383
+ next unless related
384
+
385
+ current_state_id = related["workflow_state_id"]
386
+ last_state_id = @related_states[rid]
387
+
388
+ if last_state_id.nil?
389
+ @related_states[rid] = current_state_id
390
+ next
391
+ end
392
+
393
+ next if current_state_id == last_state_id
394
+
395
+ @related_states[rid] = current_state_id
396
+
397
+ dispatch(
398
+ event_type: :related_story_changed,
399
+ story: story_drop(related),
400
+ old_state: resolve_state(last_state_id),
401
+ new_state: resolve_state(current_state_id),
402
+ primary_story: story_drop(story),
403
+ relationship: directed_verbs(story, related),
404
+ epic_sibling: epic_sibling?(story, related)
405
+ )
406
+ end
407
+ end
408
+
409
+ def collect_related_ids(story)
410
+ ids = Set.new
411
+
412
+ # Linked stories
413
+ story_links = story["story_links"] || []
414
+ story_links.each do |link|
415
+ other_id = (link["subject_id"] == story["id"]) ? link["object_id"] : link["subject_id"]
416
+ ids.add(other_id)
417
+ end
418
+
419
+ # Epic siblings
420
+ epic_id = story["epic_id"]
421
+ if epic_id && !@epic_stories_fetched
422
+ @epic_stories_fetched = true
423
+ @epic_story_ids = fetch_epic_story_ids(epic_id) - [story["id"]]
424
+ end
425
+
426
+ ids.merge(@epic_story_ids || [])
427
+ ids.to_a.first(10) # Cap to avoid too many API calls
428
+ end
429
+
430
+ def fetch_epic_story_ids(epic_id)
431
+ resp = get("/api/v3/epics/#{epic_id}/stories")
432
+ return [] unless resp
433
+
434
+ stories = resp.body
435
+ stories.map { |s| s["id"] }
436
+ end
437
+
438
+ INVERSE_VERBS = {
439
+ "blocks" => "is blocked by",
440
+ "duplicates" => "is duplicated by",
441
+ "relates to" => "relates to"
442
+ }.freeze
443
+
444
+ def directed_verbs(primary, related)
445
+ primary_links = primary["story_links"] || []
446
+ links = primary_links.select do |l|
447
+ l["subject_id"] == related["id"] || l["object_id"] == related["id"]
448
+ end
449
+
450
+ links.map do |l|
451
+ verb = l["verb"]
452
+ if l["subject_id"] == primary["id"]
453
+ verb
454
+ else
455
+ INVERSE_VERBS[verb] || verb
456
+ end
457
+ end
458
+ end
459
+
460
+ def epic_sibling?(primary, related)
461
+ primary["epic_id"] && primary["epic_id"] == related["epic_id"]
462
+ end
463
+
464
+ # ── Workflow state resolution ───────────────────────────────────────────
465
+
466
+ def fetch_workflow_states
467
+ resp = get("/api/v3/workflows")
468
+ return {} unless resp
469
+
470
+ states = {}
471
+ workflows = resp.body
472
+ workflows.each do |wf|
473
+ wf_states = wf["states"] || []
474
+ wf_states.each do |s|
475
+ states[s["id"]] = {name: s["name"], type: s["type"]}
476
+ end
477
+ end
478
+ states
479
+ end
480
+
481
+ def resolve_state(state_id)
482
+ entry = @workflow_states[state_id]
483
+ return entry[:name] if entry
484
+
485
+ # Cache miss — refetch
486
+ @workflow_states = fetch_workflow_states
487
+ @workflow_states.dig(state_id, :name) || "Unknown (#{state_id})"
488
+ end
489
+
490
+ def resolve_state_type(state_id)
491
+ entry = @workflow_states[state_id]
492
+ return entry[:type] if entry
493
+
494
+ @workflow_states = fetch_workflow_states
495
+ @workflow_states.dig(state_id, :type) || "unknown"
496
+ end
497
+
498
+ # ── Member resolution ───────────────────────────────────────────────────
499
+
500
+ def fetch_members
501
+ resp = get("/api/v3/members")
502
+ return {} unless resp
503
+
504
+ members = resp.body
505
+ members.each_with_object({}) do |m, h|
506
+ profile = m["profile"] || {}
507
+ h[m["id"]] = {
508
+ mention_name: profile["mention_name"] || m["id"],
509
+ display_name: profile["name"] || profile["mention_name"] || m["id"]
510
+ }
511
+ end
512
+ end
513
+
514
+ def resolve_member(member_id)
515
+ return {mention_name: "unknown", display_name: "unknown"} unless member_id
516
+
517
+ info = @members[member_id]
518
+ return info if info
519
+
520
+ # Cache miss — refetch
521
+ @members = fetch_members
522
+ @members[member_id] || {mention_name: member_id.to_s, display_name: member_id.to_s}
523
+ end
524
+
525
+ # ── Drop helpers ──────────────────────────────────────────────────────
526
+
527
+ def member_drop(member_id)
528
+ MemberDrop.new(resolve_member(member_id))
529
+ end
530
+
531
+ def story_drop(story, **extra)
532
+ StoryDrop.new({
533
+ id: story["id"],
534
+ name: story["name"],
535
+ url: story_url(story["id"]),
536
+ **extra
537
+ })
538
+ end
539
+
540
+ # ── URL helpers ─────────────────────────────────────────────────────────
541
+
542
+ def story_url(story_id)
543
+ slug = self[:workspace_slug]
544
+ return nil unless slug
545
+
546
+ "https://app.shortcut.com/#{slug}/story/#{story_id}"
547
+ end
548
+
549
+ # ── HTTP helpers ────────────────────────────────────────────────────────
550
+
551
+ def get(path)
552
+ resp = @conn.get(path)
553
+ return nil unless handle_response!(resp)
554
+ resp
555
+ end
556
+
557
+ # Returns true on success, raises on auth/rate-limit, returns false on 404.
558
+ def handle_response!(resp)
559
+ case resp.status
560
+ when 200..299 then true
561
+ when 401, 403 then raise FatalError, "Shortcut auth failed (HTTP #{resp.status})"
562
+ when 429 then raise RateLimited, "Shortcut rate limited (HTTP 429)"
563
+ when 404
564
+ Superkick.logger.warn(log_tag) { "Shortcut resource not found (HTTP 404)" }
565
+ false
566
+ else
567
+ false
568
+ end
569
+ end
570
+
571
+ def build_connection
572
+ token = self[:token] || ENV["SHORTCUT_API_TOKEN"]
573
+
574
+ Faraday.new(url: API_BASE) do |f|
575
+ f.headers["Shortcut-Token"] = token if token
576
+ f.response :json
577
+ end
578
+ end
579
+ end
580
+ end
581
+ end
582
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Superkick
4
+ module Integrations
5
+ module Shortcut
6
+ # Detects a Shortcut story ID from the agent's git branch name.
7
+ #
8
+ # Shortcut's Git integration creates branches with patterns like:
9
+ # user/sc-12345/my-feature
10
+ # sc-12345/my-feature
11
+ # sc-12345
12
+ class Monitor::Probe < Superkick::Monitor::Probe
13
+ def self.type = :shortcut
14
+
15
+ def self.description
16
+ "Detects Shortcut story ID from git branch name (sc-XXXXX pattern)."
17
+ end
18
+
19
+ def self.environment_actions
20
+ [{action: :git_branch}]
21
+ end
22
+
23
+ # @param environment [Hash] environment data from the agent
24
+ # @return [Hash] { shortcut: { type: "shortcut", story_id: "12345" } } or {}
25
+ def self.detect(environment:)
26
+ config = Monitor.resolve_config({}, environment:)
27
+ return {} unless config[:story_id]
28
+
29
+ {shortcut: config.merge(type: "shortcut")}
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end