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,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Superkick
4
+ module Team
5
+ # Log — append-only shared log for a team of agents.
6
+ #
7
+ # Each team has its own log stored at ~/.superkick/teams/<team_id>.log as
8
+ # newline-delimited JSON (same framing pattern as IPC). Teammates append
9
+ # status updates, decisions, blockers, messages, lifecycle events, and
10
+ # artifact publications. Any teammate can read the log.
11
+ #
12
+ # Entry schema:
13
+ # timestamp — ISO 8601 UTC with microseconds
14
+ # category — :update, :message, :lifecycle, :system, :artifact
15
+ # agent_id — the agent that created this entry
16
+ # agent_role — team role of the agent (lead, worker, member)
17
+ # role — human-readable role label (optional)
18
+ # message — free-text content
19
+ # kind — sub-type within the category (e.g. :blocker, :decision, :spawned)
20
+ # target_agent_id — recipient agent for messages (optional)
21
+ # artifact_name — name of published artifact (optional)
22
+ class Log
23
+ Entry = Data.define(
24
+ :timestamp, :category, :agent_id, :agent_role,
25
+ :role, :message, :kind, :target_agent_id, :artifact_name
26
+ ) do
27
+ # Build an Entry from a parsed hash, filling nil defaults for optional
28
+ # fields that were compacted away during persistence.
29
+ def self.from_hash(raw)
30
+ raw[:category] = raw[:category].to_sym if raw[:category]
31
+ raw[:kind] = raw[:kind].to_sym if raw[:kind]
32
+ defaults = members.each_with_object({}) { |m, h| h[m] = nil }
33
+ new(**defaults.merge(raw))
34
+ end
35
+ end
36
+
37
+ CATEGORIES = %i[update message lifecycle system artifact].freeze
38
+
39
+ attr_reader :team_id
40
+
41
+ def initialize(team_id, teams_dir: Superkick.config.teams_dir)
42
+ @team_id = team_id
43
+ @entries = []
44
+ @mutex = Mutex.new
45
+ @file_path = File.join(teams_dir, "#{team_id}.log")
46
+ FileUtils.mkdir_p(File.dirname(@file_path))
47
+ load_from_disk
48
+ end
49
+
50
+ def append(agent_id:, agent_role:, category:, message:,
51
+ role: nil, kind: nil, target_agent_id: nil, artifact_name: nil)
52
+ cat = category.to_sym
53
+ unless CATEGORIES.include?(cat)
54
+ raise ArgumentError, "Invalid category: #{category}. Must be one of: #{CATEGORIES.join(", ")}"
55
+ end
56
+
57
+ entry = Entry.new(
58
+ timestamp: Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%6NZ"),
59
+ category: cat,
60
+ agent_id: agent_id.to_s,
61
+ agent_role: agent_role.to_s,
62
+ role: role,
63
+ message: message.to_s,
64
+ kind: kind&.to_sym,
65
+ target_agent_id: target_agent_id,
66
+ artifact_name: artifact_name
67
+ )
68
+
69
+ @mutex.synchronize do
70
+ @entries << entry
71
+ persist_entry(entry)
72
+ end
73
+
74
+ entry
75
+ end
76
+
77
+ def entries(since: nil, category: nil)
78
+ @mutex.synchronize do
79
+ result = @entries.dup
80
+ result = result.select { it.timestamp > since } if since
81
+ result = result.select { it.category == category.to_sym } if category
82
+ result
83
+ end
84
+ end
85
+
86
+ # Condensed view: latest entry per agent + any unresolved blockers.
87
+ def summary
88
+ @mutex.synchronize do
89
+ latest_by_agent = {}
90
+ blockers = []
91
+
92
+ @entries.each do |entry|
93
+ latest_by_agent[entry.agent_id] = entry
94
+ blockers << entry if entry.category == :update && entry.kind == :blocker
95
+ end
96
+
97
+ # Remove resolved blockers (if same agent posted a non-blocker after)
98
+ unresolved = blockers.select do |b|
99
+ latest = latest_by_agent[b.agent_id]
100
+ latest == b || (latest.category == :update && latest.kind == :blocker)
101
+ end
102
+
103
+ {latest_by_agent: latest_by_agent.values, unresolved_blockers: unresolved}
104
+ end
105
+ end
106
+
107
+ def size
108
+ @mutex.synchronize { @entries.size }
109
+ end
110
+
111
+ private
112
+
113
+ def persist_entry(entry)
114
+ # Only persist non-nil fields to keep the NDJSON compact
115
+ h = entry.to_h.compact
116
+ File.open(@file_path, "a") do |f|
117
+ f.puts(JSON.generate(h))
118
+ f.flush
119
+ end
120
+ rescue => e
121
+ Superkick.logger.warn("team_log") { "Failed to persist entry: #{e.message}" }
122
+ end
123
+
124
+ def load_from_disk
125
+ return unless File.exist?(@file_path)
126
+
127
+ File.readlines(@file_path).each do |line|
128
+ raw = JSON.parse(line.strip, symbolize_names: true)
129
+ @entries << Entry.from_hash(raw)
130
+ rescue JSON::ParserError, ArgumentError
131
+ next
132
+ end
133
+ rescue => e
134
+ Superkick.logger.warn("team_log") { "Failed to load team log: #{e.message}" }
135
+ end
136
+ end
137
+ end
138
+
139
+ # Backwards-compatible alias
140
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Superkick
4
+ module Team
5
+ # Liquid Drop for team log entries. Wraps the symbol-keyed hash
6
+ # from Team::Log::Entry#to_h so the team_digest template can access
7
+ # properties via dot notation (e.g. {{ e.agent_id }}, {{ e.kind }}).
8
+ class LogEntryDrop < Superkick::Drop
9
+ def self.drop_type = "team_log_entry"
10
+
11
+ def timestamp = @data[:timestamp]
12
+
13
+ def category = @data[:category].to_s
14
+
15
+ def agent_id = @data[:agent_id]
16
+
17
+ def agent_role = @data[:agent_role]
18
+
19
+ def role = @data[:role]
20
+
21
+ def message = @data[:message]
22
+
23
+ def kind = @data[:kind].to_s
24
+
25
+ def target_agent_id = @data[:target_agent_id]
26
+
27
+ def artifact_name = @data[:artifact_name]
28
+ end
29
+ end
30
+
31
+ # Backwards-compatible alias
32
+ end
33
+
34
+ Superkick::Drop.register(Superkick::Team::LogEntryDrop)
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Superkick
4
+ module Team
5
+ # LogMonitor — polls the team log and injects digest events.
6
+ #
7
+ # Registered as `:team_log` in the Monitor registry. Auto-added by the
8
+ # server for team leads and human team members (`:member`). Workers can
9
+ # opt in via superkick_add_monitor.
10
+ #
11
+ # On each tick, reads team log entries since the last injection, filters
12
+ # out self-entries, and dispatches a single `team_digest` event with
13
+ # batched entries grouped by category.
14
+ #
15
+ # Config:
16
+ # team_id — (required) the team to watch
17
+ class LogMonitor < Monitor
18
+ class << self
19
+ def type = :team_log
20
+
21
+ def description
22
+ "Polls the team log and injects digest summaries of teammate activity."
23
+ end
24
+
25
+ def required_config = %i[team_id]
26
+
27
+ def event_types = %w[team_digest]
28
+
29
+ def templates_dir
30
+ File.join(__dir__, "..", "templates", "team_log")
31
+ end
32
+ end
33
+
34
+ def on_start
35
+ @last_check_at = Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%6NZ")
36
+ end
37
+
38
+ def tick
39
+ team_id = self[:team_id]
40
+ return unless team_id
41
+
42
+ log = team_log_store&.get(team_id)
43
+ return unless log
44
+
45
+ entries = log.entries(since: @last_check_at)
46
+
47
+ # Filter out entries from this agent (no self-echo)
48
+ agent_id = @agent&.id
49
+ entries = entries.reject { it.agent_id == agent_id }
50
+
51
+ return if entries.empty?
52
+
53
+ @last_check_at = entries.last.timestamp
54
+
55
+ # Group entries by category for the template
56
+ grouped = entries.group_by(&:category)
57
+
58
+ entry_drops = entries.map { Team::LogEntryDrop.new(it.to_h) }
59
+ grouped_drops = grouped.transform_keys(&:to_s).transform_values { |v| v.map { Team::LogEntryDrop.new(it.to_h) } }
60
+
61
+ has_blockers = entries.any? { it.category == :update && it.kind == :blocker }
62
+
63
+ dispatch(
64
+ event_type: :team_digest,
65
+ entries: entry_drops,
66
+ grouped: grouped_drops,
67
+ entry_count: entries.size,
68
+ has_blockers:,
69
+ agent_role: @agent&.team_role&.to_s || "unknown",
70
+ injection_ttl: 120,
71
+ **(has_blockers ? {injection_priority: :high} : {})
72
+ )
73
+ end
74
+
75
+ private
76
+
77
+ def team_log_store
78
+ server_context[:team_log_store]
79
+ end
80
+ end
81
+ end
82
+
83
+ Monitor.register(Team::LogMonitor)
84
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Superkick
4
+ module Team
5
+ # Writes team log entries as a notification side-effect.
6
+ #
7
+ # This notifier is automatically wired into the NotificationDispatcher
8
+ # when a team_log_store is available. It converts notification payloads
9
+ # into Team::Log entries for any agent that belongs to a team.
10
+ #
11
+ # Not user-configurable — it's an internal notifier.
12
+ class LogNotifier < Superkick::Notifier
13
+ def self.type = :team_log
14
+
15
+ # Event type → { category:, kind: } mapping.
16
+ # Events not listed here are ignored (no team log entry).
17
+ EVENT_MAP = {
18
+ "agent_update" => {category: :update},
19
+ "teammate_message" => {category: :message},
20
+ "teammate_blocker" => {category: :update, kind: :blocker},
21
+ "agent_spawned" => {category: :lifecycle},
22
+ "worker_spawned" => {category: :lifecycle, kind: :spawned},
23
+ "agent_completed" => {category: :lifecycle, kind: :completed},
24
+ "agent_failed" => {category: :lifecycle, kind: :failed},
25
+ "agent_timed_out" => {category: :lifecycle, kind: :timed_out},
26
+ "agent_blocked" => {category: :lifecycle, kind: :blocked},
27
+ "agent_terminated" => {category: :lifecycle, kind: :terminated},
28
+ "agent_claimed" => {category: :lifecycle, kind: :claimed},
29
+ "agent_unclaimed" => {category: :lifecycle, kind: :unclaimed},
30
+ "artifact_published" => {category: :artifact}
31
+ }.freeze
32
+
33
+ def initialize(team_log_store:, **kwargs)
34
+ super(**kwargs)
35
+ @team_log_store = team_log_store
36
+ end
37
+
38
+ def stateful? = true
39
+
40
+ def notify(payload)
41
+ team_id = payload.dig(:context, :team)&.id
42
+ return unless team_id
43
+
44
+ mapping = EVENT_MAP[payload[:event_type]]
45
+ return unless mapping
46
+
47
+ entry = build_entry(payload, mapping)
48
+ log = @team_log_store.get_or_create(team_id)
49
+ log.append(**entry)
50
+ rescue => e
51
+ Superkick.logger.warn("notifier:team_log") { "Failed to write team log: #{e.message}" }
52
+ end
53
+
54
+ def agent_finished(agent_id:)
55
+ # No cleanup needed — team log entries are persistent
56
+ end
57
+
58
+ private
59
+
60
+ def build_entry(payload, mapping)
61
+ agent = payload.dig(:context, :agent)
62
+ event_kind = payload.dig(:context, :kind)
63
+
64
+ entry = {
65
+ agent_id: payload[:agent_id],
66
+ agent_role: agent&.team_role || "unknown",
67
+ role: agent&.role,
68
+ category: mapping[:category],
69
+ message: payload[:message]
70
+ }
71
+
72
+ # Kind: use mapping default, but allow event-level override (e.g. agent_update kinds)
73
+ # For agent_spawned, infer kind from team_role (member → joined, others → spawned)
74
+ kind = event_kind || mapping[:kind]
75
+ if payload[:event_type] == "agent_spawned" && !kind
76
+ kind = (agent&.team_role == "member") ? :joined : :spawned
77
+ end
78
+ entry[:kind] = kind
79
+
80
+ # Directed messages include target
81
+ if payload[:event_type] == "teammate_message"
82
+ target = payload.dig(:context, :target_agent_id) ||
83
+ payload[:monitor]&.name # fallback
84
+ entry[:target_agent_id] = target if target
85
+ end
86
+
87
+ # Artifact entries include the artifact name
88
+ if payload[:event_type] == "artifact_published"
89
+ entry[:artifact_name] = payload.dig(:context, :artifact_name)
90
+ end
91
+
92
+ entry
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Superkick
4
+ module Team
5
+ # LogStore — server-level registry of active team logs.
6
+ #
7
+ # Thread-safe. Lazily creates Team::Log instances on first access.
8
+ class LogStore
9
+ def initialize(teams_dir: Superkick.config.teams_dir)
10
+ @teams_dir = teams_dir
11
+ @logs = {}
12
+ @mutex = Mutex.new
13
+ end
14
+
15
+ # Get or create the team log for the given team_id.
16
+ def get_or_create(team_id)
17
+ @mutex.synchronize do
18
+ @logs[team_id.to_s] ||= Team::Log.new(team_id, teams_dir: @teams_dir)
19
+ end
20
+ end
21
+
22
+ # Get the team log if it exists, nil otherwise.
23
+ def get(team_id)
24
+ @mutex.synchronize { @logs[team_id.to_s] }
25
+ end
26
+
27
+ # List all active team IDs.
28
+ def team_ids
29
+ @mutex.synchronize { @logs.keys.dup }
30
+ end
31
+
32
+ # Remove the team log entry (does not delete the file).
33
+ def cleanup(team_id)
34
+ @mutex.synchronize { @logs.delete(team_id.to_s) }
35
+ end
36
+ end
37
+ end
38
+
39
+ # Backwards-compatible alias
40
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Superkick
4
+ # Core Liquid filters available in all templates.
5
+ #
6
+ # These are registered globally on every Liquid::Environment.
7
+ # Integration-specific filters are declared inline via the `liquid do`
8
+ # DSL on each monitor/spawner class.
9
+ module TemplateFilters
10
+ def time(input = nil)
11
+ t = input.is_a?(Time) ? input : Time.now
12
+ t.strftime("%H:%M")
13
+ end
14
+
15
+ def short_sha(sha)
16
+ sha.to_s[0, 7]
17
+ end
18
+
19
+ def truncate(str, max = 72)
20
+ str = str.to_s
21
+ (str.length <= max) ? str : "#{str[0, max - 1]}…"
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,223 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "liquid"
4
+ require "time"
5
+
6
+ module Superkick
7
+ # Resolves and renders Liquid templates for injected events.
8
+ #
9
+ # Resolution order:
10
+ # 1. ~/.superkick/templates/<monitor_name>/<event_type>.liquid (user per-instance)
11
+ # 2. ~/.superkick/templates/<monitor_type>/<event_type>.liquid (user per-type)
12
+ # 3. <Monitor.templates_dir>/<event_type>.liquid (monitor bundled)
13
+ #
14
+ # Templates are rendered with Liquid. All event keys are available as
15
+ # template variables. Built-in filters: time, truncate, short_sha.
16
+ # Integration-specific filters are resolved via the monitor/spawner class.
17
+ module TemplateRenderer
18
+ FALLBACK_TEMPLATE = "SUPERKICK [{{ \"now\" | time }}]: {{ event_type }} — {{ monitor_name | truncate: 40 }}"
19
+
20
+ # Render an event hash to a string.
21
+ # @param event [Hash] must include :monitor_type and :event_type
22
+ # @return [String]
23
+ def self.render(event)
24
+ template_path = resolve(event)
25
+ source = template_path ? File.read(template_path, encoding: "utf-8") : FALLBACK_TEMPLATE
26
+ render_source(source, event, monitor_type: event[:monitor_type])
27
+ end
28
+
29
+ # Render a Liquid source string against an event hash.
30
+ # Useful for testing and for callers that have already resolved a template.
31
+ # @param source [String] Liquid template source
32
+ # @param event [Hash] event data; all keys become template variables
33
+ # @param monitor_type [String, nil] monitor type key; used to look up template filters
34
+ # @return [String]
35
+ def self.render_source(source, event, monitor_type: nil)
36
+ env = environment_for_type(monitor_type)
37
+ template = ::Liquid::Template.parse(source, environment: env)
38
+ assigns = build_assigns(event)
39
+ template.render(assigns).strip
40
+ rescue => e
41
+ Superkick.logger.error("renderer") { "Template render error: #{e.message}\n#{e.backtrace.first(5).join("\n")}" }
42
+ "SUPERKICK: #{event[:event_type]} in #{event[:repo]}"
43
+ end
44
+
45
+ # Copy all built-in monitor templates to ~/.superkick/templates/ for user customisation.
46
+ # Iterates every registered monitor and copies its bundled templates,
47
+ # namespaced under <type>/ within the user templates directory.
48
+ # @param force [Boolean] overwrite existing files
49
+ def self.install_defaults(force: false)
50
+ Monitor.registered.each do |type, monitor_class|
51
+ next unless (templates_dir = monitor_class.templates_dir) && File.directory?(templates_dir)
52
+
53
+ install_from(templates_dir, File.join(Superkick.config.templates_dir, type.to_s), force:)
54
+ end
55
+ end
56
+
57
+ # Copy all built-in spawner templates to ~/.superkick/templates/spawners/ for
58
+ # user customisation. Iterates every registered Spawner and copies
59
+ # its bundled spawn templates, namespaced under spawners/<type>/.
60
+ # @param force [Boolean] overwrite existing files
61
+ def self.install_spawner_defaults(force: false)
62
+ Spawner.registered.each do |type, spawner_class|
63
+ next unless (templates_dir = spawner_class.spawn_templates_dir) && File.directory?(templates_dir)
64
+
65
+ install_from(templates_dir, File.join(Superkick.config.templates_dir, "spawners", type.to_s), force:)
66
+ end
67
+ end
68
+
69
+ def self.install_from(source_dir, dest_dir, force: false)
70
+ Dir.glob("#{source_dir}/**/*.liquid").each do |source_path|
71
+ rel = source_path.sub("#{source_dir}/", "")
72
+ dest = File.join(dest_dir, rel)
73
+
74
+ if File.exist?(dest) && !force
75
+ Superkick.logger.info("installer") { "Skipping (exists): #{dest}" }
76
+ next
77
+ end
78
+
79
+ FileUtils.mkdir_p(File.dirname(dest))
80
+ FileUtils.cp(source_path, dest)
81
+ Superkick.logger.info("installer") { "Installed: #{dest}" }
82
+ end
83
+ end
84
+ private_class_method :install_from
85
+
86
+ # Build a Liquid::Environment for a class that includes Superkick::Liquid.
87
+ # Combines global TemplateFilters + inline filters and block tags from the
88
+ # liquid_config DSL. Cached per class.
89
+ # @param klass [Class] monitor or spawner class
90
+ # @return [Liquid::Environment]
91
+ def self.environment_for(klass)
92
+ @environment_cache ||= {}
93
+ @environment_cache[klass] ||= build_environment(klass)
94
+ end
95
+
96
+ # Look up the monitor/spawner class for a type key and return its environment.
97
+ # Falls back to a global-filters-only environment for unknown types.
98
+ private_class_method def self.environment_for_type(monitor_type)
99
+ return global_environment unless monitor_type
100
+
101
+ key = monitor_type.to_sym
102
+ klass = Monitor.registered[key] || Spawner.registered[key]
103
+ return global_environment unless klass
104
+
105
+ environment_for(klass)
106
+ end
107
+
108
+ # Environment with only global TemplateFilters — used when no class-specific
109
+ # customizations are needed.
110
+ private_class_method def self.global_environment
111
+ @global_environment ||= ::Liquid::Environment.build do |env|
112
+ env.register_filter(TemplateFilters)
113
+ end
114
+ end
115
+
116
+ private_class_method def self.build_environment(klass)
117
+ config = klass.respond_to?(:liquid_config) ? klass.liquid_config : nil
118
+
119
+ ::Liquid::Environment.build do |env|
120
+ # Global filters — always present
121
+ env.register_filter(TemplateFilters)
122
+
123
+ # Inline filters from liquid_config DSL
124
+ if config && !config.filters.empty?
125
+ mod = Module.new
126
+ config.filters.each do |name, handler|
127
+ mod.define_method(name, &handler)
128
+ end
129
+ env.register_filter(mod)
130
+ end
131
+
132
+ # Block tags from liquid_config DSL
133
+ if config
134
+ config.blocks.each do |name, handler|
135
+ env.register_tag(name.to_s, NotifierTemplate.create_block_class(handler))
136
+ end
137
+
138
+ # Bodyless tags from liquid_config DSL
139
+ config.tags.each do |name, handler|
140
+ env.register_tag(name.to_s, NotifierTemplate.create_tag_class(handler))
141
+ end
142
+ end
143
+ end
144
+ end
145
+ private_class_method :build_environment
146
+
147
+ # Clear cached environments. Called from tests via reset_config!.
148
+ def self.reset_environments!
149
+ @environment_cache = nil
150
+ @global_environment = nil
151
+ end
152
+
153
+ # Build Liquid assigns from event hash.
154
+ # Liquid requires string keys.
155
+ private_class_method def self.build_assigns(event)
156
+ event.transform_keys(&:to_s)
157
+ end
158
+
159
+ # Resolve a notification template for a notifier class and event type.
160
+ # Returns the template source string, or nil if no template is found.
161
+ #
162
+ # Resolution order:
163
+ # 1. ~/.superkick/templates/notifications/<type>/<event_type>.liquid (user override)
164
+ # 2. <Notifier.templates_dir>/<event_type>.liquid (bundled per-event)
165
+ # 3. ~/.superkick/templates/notifications/<type>/default.liquid (user catch-all)
166
+ # 4. <Notifier.templates_dir>/default.liquid (bundled catch-all)
167
+ def self.resolve_notification(notifier_class, event_type)
168
+ type = notifier_class.type.to_s
169
+ event_type = event_type.to_s
170
+ user_dir = File.join(Superkick.config.templates_dir, "notifications", type)
171
+ bundled_dir = notifier_class.templates_dir
172
+
173
+ # 1. User per-event override
174
+ user_event = File.join(user_dir, "#{event_type}.liquid")
175
+ return File.read(user_event, encoding: "utf-8") if File.exist?(user_event)
176
+
177
+ # 2. Bundled per-event
178
+ if bundled_dir
179
+ bundled_event = File.join(bundled_dir, "#{event_type}.liquid")
180
+ return File.read(bundled_event, encoding: "utf-8") if File.exist?(bundled_event)
181
+ end
182
+
183
+ # 3. User default catch-all
184
+ user_default = File.join(user_dir, "default.liquid")
185
+ return File.read(user_default, encoding: "utf-8") if File.exist?(user_default)
186
+
187
+ # 4. Bundled default catch-all
188
+ if bundled_dir
189
+ bundled_default = File.join(bundled_dir, "default.liquid")
190
+ return File.read(bundled_default, encoding: "utf-8") if File.exist?(bundled_default)
191
+ end
192
+
193
+ nil
194
+ end
195
+
196
+ private_class_method def self.resolve(event)
197
+ monitor_type = event[:monitor_type]&.to_s
198
+ monitor_name = event[:monitor_name]&.to_s
199
+ event_type = event[:event_type]&.to_s # Symbol from dispatch, needs String for path
200
+
201
+ return nil unless monitor_type && event_type
202
+
203
+ # 1. User per-instance override (by monitor name)
204
+ if monitor_name && monitor_name != monitor_type
205
+ name_path = File.join(Superkick.config.templates_dir, monitor_name, "#{event_type}.liquid")
206
+ return name_path if File.exist?(name_path)
207
+ end
208
+
209
+ # 2. User per-type override
210
+ user_path = File.join(Superkick.config.templates_dir, monitor_type, "#{event_type}.liquid")
211
+ return user_path if File.exist?(user_path)
212
+
213
+ # 3. Monitor bundled templates
214
+ monitor_class = Monitor.registered[monitor_type.to_sym]
215
+ if monitor_class&.templates_dir
216
+ bundled = File.join(monitor_class.templates_dir, "#{event_type}.liquid")
217
+ return bundled if File.exist?(bundled)
218
+ end
219
+
220
+ nil
221
+ end
222
+ end
223
+ end
@@ -0,0 +1,38 @@
1
+ You are a planning agent coordinating a team of AI coding agents.
2
+
3
+ ## Task
4
+ {{ event_type }}: {{ title }}
5
+ {{ description }}
6
+
7
+ ## Available repositories
8
+ {{ repository_context }}
9
+
10
+ ## Your responsibilities
11
+ 1. Analyze the task and determine which repositories need changes.
12
+ 2. For each affected repository, define a clear sub-task with specific instructions.
13
+ 3. Spawn a worker agent for each repository using the superkick_spawn_worker tool.
14
+ 4. Monitor team progress using superkick_team_status.
15
+ 5. Coordinate between agents if one depends on another's work.
16
+ 6. Use superkick_post_update to share your planning decisions with the team.
17
+ 7. Read worker artifacts (implementation plans, summaries) to track detailed progress.
18
+
19
+ ## Available tools
20
+ - superkick_spawn_worker: Create a worker agent for a specific repository
21
+ - superkick_discover_repositories: List available repositories with descriptions and tags
22
+ - superkick_team_status: Check what all teammates are doing
23
+ - superkick_post_update: Broadcast a status update to the team
24
+ - superkick_post_update (with target_agent_id): Send a message to a specific teammate
25
+ - superkick_list_teammates: List all agents on your team
26
+ - superkick_publish_artifact: Share structured data (plans, contracts) with the team
27
+ - superkick_read_artifact: Read an artifact published by a teammate
28
+ - superkick_list_artifacts: List all published artifacts
29
+ - superkick_signal_goal: Signal when the team's work is complete
30
+
31
+ ## Guidelines
32
+ - Spawn workers in parallel when their work is independent.
33
+ - If repository B depends on repository A's changes, note this in repository B's
34
+ instructions and tell its worker to check team status before starting dependent work.
35
+ - Keep sub-tasks focused — each worker should have a clear, achievable goal.
36
+ - Give each worker a descriptive role label (e.g. "API migration", "Test writer").
37
+ - Use superkick_post_update with category "decision" to explain your decomposition.
38
+ - Read worker artifacts periodically to review their implementation plans.