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,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+
6
+ module Superkick
7
+ module Drivers
8
+ class Copilot < Driver
9
+ DEFAULT_CONFIG_DIR = File.join(Dir.home, ".copilot").freeze
10
+
11
+ def self.driver_name = :copilot
12
+
13
+ def initialize(cli_command: "copilot", config_dir: DEFAULT_CONFIG_DIR, **)
14
+ @cli_command = cli_command
15
+ @config_dir = config_dir
16
+ end
17
+
18
+ attr_reader :cli_command
19
+
20
+ def driver_name = :copilot
21
+
22
+ def prompt_patterns
23
+ [/^\s*@\s*$/]
24
+ end
25
+
26
+ def apply_config_dir(config_dir, env:, args:)
27
+ env["COPILOT_HOME"] = config_dir
28
+ end
29
+
30
+ def install_mcp(exe_path:)
31
+ config_path = File.join(@config_dir, "mcp-config.json")
32
+ config = load_config(config_path)
33
+ return print_manual_instructions(exe_path) unless config
34
+
35
+ servers = config["servers"] || []
36
+ existing = servers.find { |s| s["name"] == "superkick" }
37
+
38
+ if existing && existing["command"] == exe_path
39
+ $stdout.puts "Superkick already configured in #{config_path}."
40
+ return
41
+ end
42
+
43
+ if existing
44
+ existing["command"] = exe_path
45
+ else
46
+ servers << {"name" => "superkick", "type" => "local",
47
+ "command" => exe_path, "args" => ["mcp"]}
48
+ config["servers"] = servers
49
+ end
50
+
51
+ write_config(config_path, config)
52
+ verb = existing ? "Updated" : "Installed"
53
+ $stdout.puts "#{verb} superkick MCP server in #{config_path}."
54
+ end
55
+
56
+ private
57
+
58
+ def load_config(path)
59
+ return {} unless File.exist?(path)
60
+
61
+ JSON.parse(File.read(path))
62
+ rescue JSON::ParserError => e
63
+ $stdout.puts "Parse error in #{path}: #{e.message}"
64
+ nil
65
+ end
66
+
67
+ def write_config(path, config)
68
+ FileUtils.mkdir_p(File.dirname(path))
69
+ File.write(path, JSON.pretty_generate(config))
70
+ end
71
+ end
72
+ end
73
+ end
74
+
75
+ Superkick::Driver.register(Superkick::Drivers::Copilot)
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+
6
+ module Superkick
7
+ module Drivers
8
+ class Gemini < Driver
9
+ DEFAULT_CONFIG_DIR = File.join(Dir.home, ".gemini").freeze
10
+
11
+ def self.driver_name = :gemini
12
+
13
+ def initialize(cli_command: "gemini", config_dir: DEFAULT_CONFIG_DIR, **)
14
+ @cli_command = cli_command
15
+ @config_dir = config_dir
16
+ end
17
+
18
+ attr_reader :cli_command
19
+
20
+ def driver_name = :gemini
21
+
22
+ def prompt_patterns
23
+ [/gemini>\s*$/, /^\s*>\s*$/]
24
+ end
25
+
26
+ def cost_patterns
27
+ [
28
+ CostPattern.new(
29
+ pattern: /Estimated cost:\s*\$([0-9]+\.?[0-9]*)/,
30
+ extractor: ->(m) { {cost_usd: m[1].to_f} }
31
+ ),
32
+ CostPattern.new(
33
+ pattern: /Tokens used:\s*([\d,]+)\s*input,\s*([\d,]+)\s*output/,
34
+ extractor: ->(m) {
35
+ {tokens_in: m[1].delete(",").to_i,
36
+ tokens_out: m[2].delete(",").to_i}
37
+ }
38
+ )
39
+ ]
40
+ end
41
+
42
+ def cost_command = "/stats"
43
+
44
+ def apply_config_dir(config_dir, env:, args:)
45
+ env["GEMINI_CLI_HOME"] = config_dir
46
+ end
47
+
48
+ def install_mcp(exe_path:)
49
+ config_path = File.join(@config_dir, "settings.json")
50
+ config = load_config(config_path)
51
+ return print_manual_instructions(exe_path) unless config
52
+
53
+ existing = config.dig("mcpServers", "superkick", "command")
54
+
55
+ if existing == exe_path
56
+ $stdout.puts "Superkick already configured in #{config_path}."
57
+ return
58
+ end
59
+
60
+ config["mcpServers"] ||= {}
61
+ config["mcpServers"]["superkick"] = {"command" => exe_path, "args" => ["mcp"]}
62
+ write_config(config_path, config)
63
+ verb = existing ? "Updated" : "Installed"
64
+ $stdout.puts "#{verb} superkick MCP server in #{config_path}."
65
+ end
66
+
67
+ private
68
+
69
+ def load_config(path)
70
+ return {} unless File.exist?(path)
71
+
72
+ JSON.parse(File.read(path))
73
+ rescue JSON::ParserError => e
74
+ $stdout.puts "Parse error in #{path}: #{e.message}"
75
+ nil
76
+ end
77
+
78
+ def write_config(path, config)
79
+ FileUtils.mkdir_p(File.dirname(path))
80
+ File.write(path, JSON.pretty_generate(config))
81
+ end
82
+ end
83
+ end
84
+ end
85
+
86
+ Superkick::Driver.register(Superkick::Drivers::Gemini)
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "fileutils"
5
+
6
+ module Superkick
7
+ module Drivers
8
+ class Goose < Driver
9
+ DEFAULT_CONFIG_DIR = File.join(Dir.home, ".config", "goose").freeze
10
+
11
+ def self.driver_name = :goose
12
+
13
+ def initialize(cli_command: "goose", config_dir: DEFAULT_CONFIG_DIR, **)
14
+ @cli_command = cli_command
15
+ @config_dir = config_dir
16
+ end
17
+
18
+ attr_reader :cli_command
19
+
20
+ def driver_name = :goose
21
+
22
+ def prompt_patterns
23
+ [/goose>\s*$/, /^\s*>\s*$/]
24
+ end
25
+
26
+ def apply_config_dir(config_dir, env:, args:)
27
+ env["GOOSE_CONFIG_DIR"] = config_dir
28
+ end
29
+
30
+ def install_mcp(exe_path:)
31
+ config_path = File.join(@config_dir, "config.yaml")
32
+ config = load_config(config_path)
33
+ return print_manual_instructions(exe_path) unless config
34
+
35
+ extensions = config["extensions"] || {}
36
+ existing = extensions.dig("superkick", "command")
37
+
38
+ if existing == exe_path
39
+ $stdout.puts "Superkick already configured in #{config_path} (path matches)."
40
+ return
41
+ end
42
+
43
+ extensions["superkick"] = {
44
+ "command" => exe_path,
45
+ "args" => ["mcp"],
46
+ "type" => "stdio",
47
+ "timeout" => 300
48
+ }
49
+ config["extensions"] = extensions
50
+ write_config(config_path, config)
51
+ verb = existing ? "Updated" : "Installed"
52
+ $stdout.puts "#{verb} superkick MCP server in #{config_path}."
53
+ end
54
+
55
+ private
56
+
57
+ def load_config(path)
58
+ return {} unless File.exist?(path)
59
+
60
+ YAML.safe_load_file(path) || {}
61
+ rescue Psych::SyntaxError => e
62
+ $stdout.puts "Parse error in #{path}: #{e.message}"
63
+ nil
64
+ end
65
+
66
+ def write_config(path, config)
67
+ FileUtils.mkdir_p(File.dirname(path))
68
+ File.write(path, YAML.dump(config))
69
+ end
70
+ end
71
+ end
72
+ end
73
+
74
+ Superkick::Driver.register(Superkick::Drivers::Goose)
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Namespace for driver subclasses. Each driver lives in its own file under
4
+ # lib/superkick/drivers/<name>.rb and subclasses Superkick::Driver.
5
+ module Superkick
6
+ module Drivers
7
+ end
8
+ end
9
+
10
+ # Load the base class first, then all built-in drivers.
11
+ require_relative "driver"
12
+ require_relative "drivers/claude_code"
13
+ require_relative "drivers/copilot"
14
+ require_relative "drivers/codex"
15
+ require_relative "drivers/gemini"
16
+ require_relative "drivers/goose"
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "liquid"
4
+
5
+ module Superkick
6
+ # Base class for all Superkick Liquid Drops. Provides serialization,
7
+ # deserialization, and the shared `initialize(data)` / `@data` pattern
8
+ # that every Drop follows.
9
+ #
10
+ # Subclasses must implement:
11
+ # - `self.drop_type` → unique String identifier (e.g. "github_issue")
12
+ #
13
+ # Register subclasses with `Superkick::Drop.register(MyDrop)`.
14
+ #
15
+ # Serialization stamps `_drop_type` markers on hashes so Drops can be
16
+ # reconstructed from JSON-persisted data (e.g. spawn_info[:context]).
17
+ class Drop < ::Liquid::Drop
18
+ @registry = {}
19
+
20
+ def initialize(data)
21
+ super()
22
+ @data = data
23
+ end
24
+
25
+ def self.drop_type
26
+ raise NotImplementedError, "#{name} must implement self.drop_type"
27
+ end
28
+
29
+ class << self
30
+ include Superkick::Registry
31
+ end
32
+
33
+ def self.register(klass)
34
+ @registry[klass.drop_type] = klass
35
+ end
36
+
37
+ def self.lookup(type)
38
+ @registry[type.to_s]
39
+ end
40
+
41
+ def self.registered
42
+ @registry.dup.freeze
43
+ end
44
+
45
+ # Recursively serialize Drops into plain hashes with _drop_type markers.
46
+ def self.serialize(value)
47
+ case value
48
+ when Superkick::Drop then value.to_h
49
+ when Array then value.map { serialize(it) }
50
+ when Hash then value.transform_values { serialize(it) }
51
+ else value
52
+ end
53
+ end
54
+
55
+ # Recursively reconstruct Drops from _drop_type-marked hashes.
56
+ def self.rehydrate(value)
57
+ case value
58
+ when Hash
59
+ if (type = value[:_drop_type])
60
+ klass = lookup(type)
61
+ return value unless klass # unknown type, pass through
62
+
63
+ data = value.except(:_drop_type)
64
+ .transform_values { rehydrate(it) }
65
+ klass.new(data)
66
+ else
67
+ value.transform_values { rehydrate(it) }
68
+ end
69
+ when Array then value.map { rehydrate(it) }
70
+ else value
71
+ end
72
+ end
73
+
74
+ # Serialize this Drop to a JSON-safe hash with a _drop_type marker.
75
+ def to_h
76
+ @data.transform_values { Superkick::Drop.serialize(it) }
77
+ .merge(_drop_type: self.class.drop_type)
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Superkick
4
+ # Liquid Drops for core Superkick objects used in notification templates,
5
+ # team digest templates, and workflow templates.
6
+
7
+ # Wraps team information for template access.
8
+ #
9
+ # Properties: id, members
10
+ class TeamDrop < Superkick::Drop
11
+ def self.drop_type = "team"
12
+
13
+ def id = @data[:id]
14
+
15
+ def members = @data[:members]
16
+ end
17
+
18
+ # Wraps spawner information for template access.
19
+ #
20
+ # Properties: name
21
+ class SpawnerDrop < Superkick::Drop
22
+ def self.drop_type = "spawner"
23
+
24
+ def name = @data[:name]&.to_s
25
+ end
26
+
27
+ # Wraps monitor information for template access.
28
+ #
29
+ # Properties: name, type
30
+ class MonitorDrop < Superkick::Drop
31
+ def self.drop_type = "monitor"
32
+
33
+ def name = @data[:name]&.to_s
34
+
35
+ def type = @data[:type]&.to_s
36
+ end
37
+
38
+ # Wraps goal state for template access in notifications.
39
+ # Nested under AgentDrop — accessed as {{ agent.goal.status }}.
40
+ #
41
+ # Properties: status, summary
42
+ class GoalDrop < Superkick::Drop
43
+ def self.drop_type = "goal"
44
+
45
+ def status = @data[:status]&.to_s
46
+
47
+ def summary = @data[:summary]
48
+ end
49
+
50
+ # Wraps agent metadata for template access in notifications.
51
+ #
52
+ # Properties: id, role, team_role, spawned_at, cost_usd, claimed, goal
53
+ class AgentDrop < Superkick::Drop
54
+ def self.drop_type = "agent"
55
+
56
+ def id = @data[:id]
57
+
58
+ def role = @data[:role]&.to_s
59
+
60
+ def team_role = @data[:team_role]&.to_s
61
+
62
+ def spawned_at = @data[:spawned_at]
63
+
64
+ def cost_usd = @data[:cost_usd]
65
+
66
+ def claimed = @data[:claimed]
67
+
68
+ def goal = @data[:goal]
69
+ end
70
+
71
+ Drop.register(TeamDrop)
72
+ Drop.register(SpawnerDrop)
73
+ Drop.register(MonitorDrop)
74
+ Drop.register(GoalDrop)
75
+ Drop.register(AgentDrop)
76
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Superkick
4
+ # EnvironmentExecutor — agent-side action executor for server-driven probes.
5
+ #
6
+ # The server sends an array of action hashes describing what environment data
7
+ # it needs (git branch, git remotes, file existence checks). The executor
8
+ # runs each action in the agent's working directory and returns the results.
9
+ #
10
+ # The executor has no knowledge of monitors or probes — it is a simple,
11
+ # self-contained action runner.
12
+ #
13
+ # Supported actions:
14
+ # { action: :git_branch }
15
+ # → String or nil (current branch name)
16
+ #
17
+ # { action: :git_remotes }
18
+ # → Array of { name: String, url: String } or nil
19
+ #
20
+ # { action: :file_exists, paths: [String, ...] }
21
+ # → Hash of { path => Boolean }
22
+ class EnvironmentExecutor
23
+ def initialize(working_dir:)
24
+ @working_dir = working_dir
25
+ end
26
+
27
+ # Execute a list of action hashes and return the merged results.
28
+ #
29
+ # @param actions [Array<Hash>] each with an :action key
30
+ # @return [Hash] merged results keyed by action name
31
+ def execute(actions)
32
+ actions.each_with_object({}) do |action, result|
33
+ key = action[:action]&.to_sym
34
+ next unless key
35
+
36
+ case key
37
+ when :git_branch
38
+ result[:git_branch] ||= execute_git_branch
39
+ when :git_remotes
40
+ result[:git_remotes] ||= execute_git_remotes
41
+ when :file_exists
42
+ existing = result[:file_exists] || {}
43
+ existing.merge!(execute_file_exists(action[:paths] || []))
44
+ result[:file_exists] = existing
45
+ else
46
+ Superkick.logger.debug("environment_executor") { "Unknown action: #{key}" }
47
+ end
48
+ end
49
+ end
50
+
51
+ private
52
+
53
+ def execute_git_branch
54
+ out = run_git("symbolic-ref", "--short", "HEAD")&.strip
55
+ out unless out&.empty?
56
+ end
57
+
58
+ def execute_git_remotes
59
+ out = run_git("remote", "-v")
60
+ return nil unless out
61
+
62
+ remotes = {}
63
+ out.each_line do |line|
64
+ parts = line.split
65
+ next unless parts.size >= 2
66
+
67
+ name = parts[0]
68
+ url = parts[1]
69
+ # git remote -v shows each remote twice (fetch + push); deduplicate by name
70
+ remotes[name] ||= {name:, url:}
71
+ end
72
+
73
+ remotes.values
74
+ end
75
+
76
+ def execute_file_exists(paths)
77
+ paths.each_with_object({}) do |path, result|
78
+ full_path = File.join(@working_dir, path)
79
+ result[path] = File.exist?(full_path)
80
+ end
81
+ end
82
+
83
+ def run_git(*args)
84
+ IO.popen(["git", "-C", @working_dir, *args], err: File::NULL) { |io| io.read }
85
+ rescue SystemCallError, IOError => e
86
+ Superkick.logger.debug("environment_executor") { "git command failed: #{e.message}" }
87
+ nil
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Superkick
4
+ # Goal — base class for spawned agent goal checks.
5
+ #
6
+ # A goal defines what "finished" means for a spawned agent. The Supervisor
7
+ # runs a periodic check thread per spawned agent; on each tick it calls
8
+ # `check` and acts on the result.
9
+ #
10
+ # Subclass contract:
11
+ # self.type → unique Symbol (e.g. :command, :agent_exit, :agent_signal)
12
+ # check → one of STATUSES
13
+ # teardown → optional cleanup (default no-op)
14
+ #
15
+ # Status vocabulary:
16
+ # :pending — not started / waiting (non-terminal, default)
17
+ # :in_progress — actively working (non-terminal, stored for observability)
18
+ # :errored — potentially recoverable issue (non-terminal, stored)
19
+ # :completed — succeeded (terminal → terminates agent)
20
+ # :failed — irrecoverable (terminal → terminates agent)
21
+ # :timed_out — max_duration exceeded (terminal, set by Supervisor)
22
+ #
23
+ # Subclasses register themselves:
24
+ # Superkick::Goal.register(MyGoal)
25
+ #
26
+ # Configuration (inside a spawner's goal: block in config.yml):
27
+ #
28
+ # spawners:
29
+ # my-spawner:
30
+ # goal:
31
+ # type: command
32
+ # run: "gh pr list --head $BRANCH --json number -q '.[0].number'"
33
+ class Goal
34
+ STATUSES = %i[pending in_progress errored completed failed timed_out].freeze
35
+ TERMINAL_STATUSES = %i[completed failed timed_out].freeze
36
+
37
+ attr_reader :config, :agent_id
38
+
39
+ def initialize(config:, agent_id:)
40
+ @config = config
41
+ @agent_id = agent_id
42
+ end
43
+
44
+ # ── Registry (stores classes, keyed by type) ─────────────────────────
45
+ @registry = {}
46
+
47
+ class << self
48
+ include Superkick::Registry
49
+
50
+ def register(goal_class)
51
+ key = goal_class.type
52
+ raise ArgumentError, "Goal :#{key} already registered" if @registry.key?(key)
53
+ @registry[key] = goal_class
54
+ end
55
+
56
+ def lookup(name)
57
+ @registry[name.to_sym] or raise ArgumentError, "Unknown goal type: #{name.inspect}"
58
+ end
59
+
60
+ def registered
61
+ @registry.dup.freeze
62
+ end
63
+
64
+ # Build a goal instance from a spawner's goal config hash.
65
+ def build(config, agent_id:)
66
+ type_name = config[:type]
67
+ raise ArgumentError, "Goal config must include :type" unless type_name
68
+ klass = lookup(type_name.to_s.to_sym)
69
+ klass.new(config:, agent_id:)
70
+ end
71
+ end
72
+
73
+ # ── Instance interface ───────────────────────────────────────────────
74
+
75
+ def self.type
76
+ raise NotImplementedError, "#{self}.type not implemented"
77
+ end
78
+
79
+ # Human-readable description for discovery. Override in subclasses.
80
+ def self.description = nil
81
+
82
+ # Required config keys. Override in subclasses that need specific config.
83
+ def self.required_config = []
84
+
85
+ # Check whether the goal has been reached.
86
+ # @return [Symbol] one of STATUSES
87
+ def check
88
+ raise NotImplementedError, "#{self.class}#check not implemented"
89
+ end
90
+
91
+ def teardown
92
+ # Default no-op — goals may override to clean up resources.
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Superkick
4
+ module Goals
5
+ # Goal that completes when the AI CLI process exits.
6
+ #
7
+ # This doesn't actively check anything — the Supervisor calls
8
+ # `signal!` when the agent process ends (via the unregister flow).
9
+ # Clean exit (0) → :completed, non-zero → :failed.
10
+ #
11
+ # Configuration:
12
+ # goal:
13
+ # type: agent_exit
14
+ class AgentExit < Goal
15
+ def self.type = :agent_exit
16
+
17
+ def self.description
18
+ "Completes when the AI CLI process exits. Clean exit (0) maps to " \
19
+ "completed, non-zero maps to failed."
20
+ end
21
+
22
+ def initialize(config:, agent_id:)
23
+ super
24
+ @status = :pending
25
+ @mutex = Mutex.new
26
+ end
27
+
28
+ # Called by the Supervisor when the agent process exits.
29
+ # @param status [Symbol] :completed or :failed
30
+ def signal!(status)
31
+ @mutex.synchronize { @status = status.to_sym }
32
+ end
33
+
34
+ def check
35
+ @mutex.synchronize { @status }
36
+ end
37
+ end
38
+ end
39
+ end
40
+
41
+ Superkick::Goal.register(Superkick::Goals::AgentExit)
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Superkick
4
+ module Goals
5
+ # Goal that completes when the AI CLI calls the `superkick_signal_goal` MCP tool.
6
+ #
7
+ # The signal is received via IPC → stored on the Agent's goal_status →
8
+ # and `check` reads it. This goal is purely reactive; it never polls.
9
+ #
10
+ # This is the default goal type when no goal is specified in spawner config.
11
+ #
12
+ # Configuration:
13
+ # goal:
14
+ # type: agent_signal
15
+ class AgentSignal < Goal
16
+ def self.type = :agent_signal
17
+
18
+ def self.description
19
+ "Completes when the agent calls the superkick_signal_goal MCP tool. " \
20
+ "Use this when the agent should decide when it is done. " \
21
+ "This is the default goal type."
22
+ end
23
+
24
+ def initialize(config:, agent_id:)
25
+ super
26
+ @status = :pending
27
+ @mutex = Mutex.new
28
+ end
29
+
30
+ # Called by the IPC server when a superkick_signal_goal arrives.
31
+ def signal!(status)
32
+ @mutex.synchronize { @status = status.to_sym }
33
+ end
34
+
35
+ def check
36
+ @mutex.synchronize { @status }
37
+ end
38
+ end
39
+ end
40
+ end
41
+
42
+ Superkick::Goal.register(Superkick::Goals::AgentSignal)