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.
- checksums.yaml +7 -0
- data/CLA.md +91 -0
- data/CLAUDE.md +2226 -0
- data/CONTRIBUTING.md +104 -0
- data/LICENSE +108 -0
- data/LICENSE-COMMERCIAL.md +39 -0
- data/PLAN.md +161 -0
- data/README.md +1155 -0
- data/exe/superkick +6 -0
- data/lib/superkick/agent/runtime.rb +82 -0
- data/lib/superkick/agent/runtimes/local.rb +74 -0
- data/lib/superkick/agent/runtimes.rb +4 -0
- data/lib/superkick/agent.rb +209 -0
- data/lib/superkick/agent_store.rb +85 -0
- data/lib/superkick/attach/client.rb +245 -0
- data/lib/superkick/attach/protocol.rb +71 -0
- data/lib/superkick/attach/server.rb +371 -0
- data/lib/superkick/budget_checker.rb +120 -0
- data/lib/superkick/buffer/client.rb +91 -0
- data/lib/superkick/buffer/server.rb +127 -0
- data/lib/superkick/cli/agent.rb +524 -0
- data/lib/superkick/cli/completion.rb +591 -0
- data/lib/superkick/cli/goal.rb +71 -0
- data/lib/superkick/cli/mcp.rb +34 -0
- data/lib/superkick/cli/monitor.rb +47 -0
- data/lib/superkick/cli/notifier.rb +39 -0
- data/lib/superkick/cli/repository.rb +46 -0
- data/lib/superkick/cli/server.rb +106 -0
- data/lib/superkick/cli/setup.rb +166 -0
- data/lib/superkick/cli/spawner.rb +85 -0
- data/lib/superkick/cli/team.rb +407 -0
- data/lib/superkick/cli.rb +175 -0
- data/lib/superkick/client_registry.rb +30 -0
- data/lib/superkick/configuration.rb +178 -0
- data/lib/superkick/connection.rb +56 -0
- data/lib/superkick/control/client.rb +78 -0
- data/lib/superkick/control/reply.rb +43 -0
- data/lib/superkick/control/server.rb +1271 -0
- data/lib/superkick/cost_accumulator.rb +53 -0
- data/lib/superkick/cost_extractor.rb +65 -0
- data/lib/superkick/cost_poller.rb +70 -0
- data/lib/superkick/driver/profile_source.rb +134 -0
- data/lib/superkick/driver.rb +179 -0
- data/lib/superkick/drivers/claude_code.rb +110 -0
- data/lib/superkick/drivers/codex.rb +57 -0
- data/lib/superkick/drivers/copilot.rb +75 -0
- data/lib/superkick/drivers/gemini.rb +86 -0
- data/lib/superkick/drivers/goose.rb +74 -0
- data/lib/superkick/drivers.rb +16 -0
- data/lib/superkick/drop.rb +80 -0
- data/lib/superkick/drops.rb +76 -0
- data/lib/superkick/environment_executor.rb +90 -0
- data/lib/superkick/goal.rb +95 -0
- data/lib/superkick/goals/agent_exit.rb +41 -0
- data/lib/superkick/goals/agent_signal.rb +42 -0
- data/lib/superkick/goals/command.rb +103 -0
- data/lib/superkick/history_buffer.rb +38 -0
- data/lib/superkick/hosted/attach/bridge.rb +52 -0
- data/lib/superkick/hosted/attach/client.rb +208 -0
- data/lib/superkick/hosted/attach/relay.rb +313 -0
- data/lib/superkick/hosted/attach/relay_store.rb +48 -0
- data/lib/superkick/hosted/bridge.rb +263 -0
- data/lib/superkick/hosted/buffer/bridge.rb +42 -0
- data/lib/superkick/hosted/buffer/client.rb +63 -0
- data/lib/superkick/hosted/buffer/relay.rb +126 -0
- data/lib/superkick/hosted/buffer/relay_store.rb +42 -0
- data/lib/superkick/hosted/control/client.rb +84 -0
- data/lib/superkick/hosted/mcp_proxy.rb +144 -0
- data/lib/superkick/inject_handler.rb +24 -0
- data/lib/superkick/injection_guard.rb +26 -0
- data/lib/superkick/injection_queue.rb +177 -0
- data/lib/superkick/injector.rb +65 -0
- data/lib/superkick/input_buffer.rb +171 -0
- data/lib/superkick/integrations/bugsnag/README.md +98 -0
- data/lib/superkick/integrations/bugsnag/spawner.rb +307 -0
- data/lib/superkick/integrations/bugsnag/templates/error_opened.liquid +17 -0
- data/lib/superkick/integrations/bugsnag.rb +7 -0
- data/lib/superkick/integrations/circleci/README.md +75 -0
- data/lib/superkick/integrations/circleci/monitor.rb +185 -0
- data/lib/superkick/integrations/circleci/probe.rb +36 -0
- data/lib/superkick/integrations/circleci/templates/ci_failure.liquid +8 -0
- data/lib/superkick/integrations/circleci/templates/ci_success.liquid +1 -0
- data/lib/superkick/integrations/circleci.rb +8 -0
- data/lib/superkick/integrations/datadog/README.md +253 -0
- data/lib/superkick/integrations/datadog/alert_goal.rb +94 -0
- data/lib/superkick/integrations/datadog/alert_monitor.rb +163 -0
- data/lib/superkick/integrations/datadog/alert_spawner.rb +201 -0
- data/lib/superkick/integrations/datadog/notification_templates/default.liquid +10 -0
- data/lib/superkick/integrations/datadog/notifier.rb +294 -0
- data/lib/superkick/integrations/datadog/spawner.rb +201 -0
- data/lib/superkick/integrations/datadog/templates/alert_changed.liquid +8 -0
- data/lib/superkick/integrations/datadog/templates/alert_escalated.liquid +8 -0
- data/lib/superkick/integrations/datadog/templates/alert_recovered.liquid +14 -0
- data/lib/superkick/integrations/datadog/templates/alert_triggered.liquid +29 -0
- data/lib/superkick/integrations/datadog/templates/error_opened.liquid +15 -0
- data/lib/superkick/integrations/datadog.rb +14 -0
- data/lib/superkick/integrations/docker/README.md +256 -0
- data/lib/superkick/integrations/docker/client.rb +295 -0
- data/lib/superkick/integrations/docker/runtime.rb +218 -0
- data/lib/superkick/integrations/docker.rb +4 -0
- data/lib/superkick/integrations/git/repository_source.rb +66 -0
- data/lib/superkick/integrations/git/version_control.rb +119 -0
- data/lib/superkick/integrations/git.rb +8 -0
- data/lib/superkick/integrations/github/README.md +300 -0
- data/lib/superkick/integrations/github/check_failed_spawner.rb +199 -0
- data/lib/superkick/integrations/github/drops.rb +114 -0
- data/lib/superkick/integrations/github/goal.rb +135 -0
- data/lib/superkick/integrations/github/issue_goal.rb +104 -0
- data/lib/superkick/integrations/github/issue_spawner.rb +160 -0
- data/lib/superkick/integrations/github/monitor.rb +251 -0
- data/lib/superkick/integrations/github/probe.rb +30 -0
- data/lib/superkick/integrations/github/repository_source.rb +228 -0
- data/lib/superkick/integrations/github/templates/check_failed.liquid +10 -0
- data/lib/superkick/integrations/github/templates/ci_failure.liquid +5 -0
- data/lib/superkick/integrations/github/templates/ci_success.liquid +1 -0
- data/lib/superkick/integrations/github/templates/issue_opened.liquid +20 -0
- data/lib/superkick/integrations/github/templates/pr_comment.liquid +2 -0
- data/lib/superkick/integrations/github/templates/pr_review.liquid +4 -0
- data/lib/superkick/integrations/github.rb +16 -0
- data/lib/superkick/integrations/honeybadger/README.md +97 -0
- data/lib/superkick/integrations/honeybadger/notification_templates/default.liquid +8 -0
- data/lib/superkick/integrations/honeybadger/notifier.rb +250 -0
- data/lib/superkick/integrations/honeybadger/spawner.rb +214 -0
- data/lib/superkick/integrations/honeybadger/templates/error_opened.liquid +17 -0
- data/lib/superkick/integrations/honeybadger.rb +9 -0
- data/lib/superkick/integrations/shell/README.md +83 -0
- data/lib/superkick/integrations/shell/monitor.rb +87 -0
- data/lib/superkick/integrations/shell/templates/shell_alert.liquid +6 -0
- data/lib/superkick/integrations/shell/templates/shell_success.liquid +6 -0
- data/lib/superkick/integrations/shell.rb +7 -0
- data/lib/superkick/integrations/shortcut/README.md +193 -0
- data/lib/superkick/integrations/shortcut/drops.rb +91 -0
- data/lib/superkick/integrations/shortcut/monitor.rb +582 -0
- data/lib/superkick/integrations/shortcut/probe.rb +34 -0
- data/lib/superkick/integrations/shortcut/spawner.rb +264 -0
- data/lib/superkick/integrations/shortcut/templates/related_story_changed.liquid +6 -0
- data/lib/superkick/integrations/shortcut/templates/story_blocker.liquid +8 -0
- data/lib/superkick/integrations/shortcut/templates/story_comment.liquid +5 -0
- data/lib/superkick/integrations/shortcut/templates/story_description_changed.liquid +19 -0
- data/lib/superkick/integrations/shortcut/templates/story_owner_changed.liquid +10 -0
- data/lib/superkick/integrations/shortcut/templates/story_ready.liquid +41 -0
- data/lib/superkick/integrations/shortcut/templates/story_state_changed.liquid +9 -0
- data/lib/superkick/integrations/shortcut/templates/story_unblocked.liquid +5 -0
- data/lib/superkick/integrations/shortcut.rb +11 -0
- data/lib/superkick/integrations/slack/README.md +297 -0
- data/lib/superkick/integrations/slack/drops.rb +70 -0
- data/lib/superkick/integrations/slack/notifier.rb +426 -0
- data/lib/superkick/integrations/slack/spawner.rb +251 -0
- data/lib/superkick/integrations/slack/templates/default.liquid +17 -0
- data/lib/superkick/integrations/slack/templates/slack_reply.liquid +3 -0
- data/lib/superkick/integrations/slack/templates/spawn/slack_message.liquid +10 -0
- data/lib/superkick/integrations/slack/thread_monitor.rb +161 -0
- data/lib/superkick/integrations/slack.rb +12 -0
- data/lib/superkick/liquid.rb +129 -0
- data/lib/superkick/local/repository_source.rb +148 -0
- data/lib/superkick/mcp_server.rb +596 -0
- data/lib/superkick/monitor.rb +215 -0
- data/lib/superkick/notification_dispatcher.rb +280 -0
- data/lib/superkick/notifier.rb +173 -0
- data/lib/superkick/notifier_state_store.rb +55 -0
- data/lib/superkick/notifier_template.rb +121 -0
- data/lib/superkick/notifiers/command.rb +124 -0
- data/lib/superkick/notifiers/terminal_bell.rb +41 -0
- data/lib/superkick/output_logger.rb +54 -0
- data/lib/superkick/poller.rb +126 -0
- data/lib/superkick/process_runner.rb +87 -0
- data/lib/superkick/pty_proxy.rb +403 -0
- data/lib/superkick/registry.rb +75 -0
- data/lib/superkick/repository_source.rb +195 -0
- data/lib/superkick/server.rb +211 -0
- data/lib/superkick/session_recorder.rb +154 -0
- data/lib/superkick/setup.rb +160 -0
- data/lib/superkick/spawn/agent_spawner.rb +311 -0
- data/lib/superkick/spawn/approval_store.rb +113 -0
- data/lib/superkick/spawn/handler.rb +144 -0
- data/lib/superkick/spawn/injector.rb +119 -0
- data/lib/superkick/spawn/workflow_executor.rb +196 -0
- data/lib/superkick/spawn/workflow_validator.rb +77 -0
- data/lib/superkick/spawner.rb +67 -0
- data/lib/superkick/supervisor.rb +516 -0
- data/lib/superkick/team/artifact_store.rb +92 -0
- data/lib/superkick/team/log.rb +140 -0
- data/lib/superkick/team/log_entry_drop.rb +34 -0
- data/lib/superkick/team/log_monitor.rb +84 -0
- data/lib/superkick/team/log_notifier.rb +96 -0
- data/lib/superkick/team/log_store.rb +40 -0
- data/lib/superkick/template_filters.rb +24 -0
- data/lib/superkick/template_renderer.rb +223 -0
- data/lib/superkick/templates/team_log/planning_agent.liquid +38 -0
- data/lib/superkick/templates/team_log/team_digest.liquid +45 -0
- data/lib/superkick/templates/team_log/teammate_message.liquid +7 -0
- data/lib/superkick/templates/team_log/worker_kickoff.liquid +37 -0
- data/lib/superkick/templates/workflow/workflow_triggered.liquid +22 -0
- data/lib/superkick/version.rb +5 -0
- data/lib/superkick/version_control.rb +135 -0
- data/lib/superkick/yaml_config.rb +302 -0
- data/lib/superkick.rb +198 -0
- data/plan.md +267 -0
- 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,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,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
|
+
```
|