agentboard 0.2.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 (159) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +62 -0
  3. data/MIT-LICENSE +20 -0
  4. data/README.md +316 -0
  5. data/Rakefile +6 -0
  6. data/app/assets/stylesheets/agentboard/application.css +510 -0
  7. data/app/controllers/agentboard/agents_controller.rb +10 -0
  8. data/app/controllers/agentboard/analytics_controller.rb +37 -0
  9. data/app/controllers/agentboard/application_controller.rb +90 -0
  10. data/app/controllers/agentboard/approvals_controller.rb +15 -0
  11. data/app/controllers/agentboard/assets_controller.rb +32 -0
  12. data/app/controllers/agentboard/board_controller.rb +46 -0
  13. data/app/controllers/agentboard/captures_controller.rb +17 -0
  14. data/app/controllers/agentboard/comments_controller.rb +9 -0
  15. data/app/controllers/agentboard/criteria_controller.rb +17 -0
  16. data/app/controllers/agentboard/dependencies_controller.rb +20 -0
  17. data/app/controllers/agentboard/executions_controller.rb +48 -0
  18. data/app/controllers/agentboard/milestones_controller.rb +23 -0
  19. data/app/controllers/agentboard/pulse_controller.rb +12 -0
  20. data/app/controllers/agentboard/sessions_controller.rb +19 -0
  21. data/app/controllers/agentboard/settings_controller.rb +9 -0
  22. data/app/controllers/agentboard/tickets_controller.rb +139 -0
  23. data/app/controllers/agentboard/workflow_runs_controller.rb +16 -0
  24. data/app/controllers/agentboard/workflows_controller.rb +62 -0
  25. data/app/helpers/agentboard/application_helper.rb +196 -0
  26. data/app/javascript/agentboard/application.js +227 -0
  27. data/app/javascript/agentboard/capture.js +299 -0
  28. data/app/jobs/agentboard/application_job.rb +4 -0
  29. data/app/models/agentboard/acceptance_criterion.rb +23 -0
  30. data/app/models/agentboard/agent_profile.rb +16 -0
  31. data/app/models/agentboard/agent_session.rb +30 -0
  32. data/app/models/agentboard/application_record.rb +13 -0
  33. data/app/models/agentboard/approval.rb +29 -0
  34. data/app/models/agentboard/artifact.rb +11 -0
  35. data/app/models/agentboard/comment.rb +9 -0
  36. data/app/models/agentboard/event.rb +37 -0
  37. data/app/models/agentboard/execution.rb +51 -0
  38. data/app/models/agentboard/idempotency_key.rb +5 -0
  39. data/app/models/agentboard/milestone.rb +7 -0
  40. data/app/models/agentboard/outbox_message.rb +11 -0
  41. data/app/models/agentboard/project.rb +43 -0
  42. data/app/models/agentboard/repository_snapshot.rb +5 -0
  43. data/app/models/agentboard/session_ticket_link.rb +18 -0
  44. data/app/models/agentboard/ticket.rb +81 -0
  45. data/app/models/agentboard/ticket_dependency.rb +34 -0
  46. data/app/models/agentboard/ticket_lease.rb +11 -0
  47. data/app/models/agentboard/usage_record.rb +10 -0
  48. data/app/models/agentboard/verification_result.rb +12 -0
  49. data/app/models/agentboard/webhook_delivery.rb +7 -0
  50. data/app/models/agentboard/workflow_definition.rb +20 -0
  51. data/app/models/agentboard/workflow_run.rb +21 -0
  52. data/app/models/agentboard/workflow_step.rb +12 -0
  53. data/app/services/agentboard/approvals.rb +76 -0
  54. data/app/services/agentboard/captures.rb +113 -0
  55. data/app/services/agentboard/completion_policy.rb +117 -0
  56. data/app/services/agentboard/current.rb +5 -0
  57. data/app/services/agentboard/data_governance.rb +68 -0
  58. data/app/services/agentboard/doctor.rb +133 -0
  59. data/app/services/agentboard/event_store.rb +88 -0
  60. data/app/services/agentboard/evidence.rb +52 -0
  61. data/app/services/agentboard/executions.rb +198 -0
  62. data/app/services/agentboard/hook_ingestion.rb +100 -0
  63. data/app/services/agentboard/idempotency.rb +28 -0
  64. data/app/services/agentboard/lease_service.rb +107 -0
  65. data/app/services/agentboard/mcp_server.rb +138 -0
  66. data/app/services/agentboard/mcp_tools.rb +341 -0
  67. data/app/services/agentboard/outbox.rb +44 -0
  68. data/app/services/agentboard/pull_requests.rb +65 -0
  69. data/app/services/agentboard/runtime/execution_runner.rb +352 -0
  70. data/app/services/agentboard/runtime/ipc_handler.rb +55 -0
  71. data/app/services/agentboard/runtime/reconciler.rb +42 -0
  72. data/app/services/agentboard/runtime/supervisor.rb +96 -0
  73. data/app/services/agentboard/session_association.rb +44 -0
  74. data/app/services/agentboard/session_correlation.rb +56 -0
  75. data/app/services/agentboard/session_credential.rb +44 -0
  76. data/app/services/agentboard/ticket_advisor.rb +60 -0
  77. data/app/services/agentboard/tickets.rb +218 -0
  78. data/app/services/agentboard/verification_service.rb +60 -0
  79. data/app/services/agentboard/workflow_engine.rb +261 -0
  80. data/app/views/agentboard/agents/index.html.erb +51 -0
  81. data/app/views/agentboard/analytics/show.html.erb +78 -0
  82. data/app/views/agentboard/approvals/_approval.html.erb +27 -0
  83. data/app/views/agentboard/approvals/index.html.erb +31 -0
  84. data/app/views/agentboard/board/_card.html.erb +36 -0
  85. data/app/views/agentboard/board/show.html.erb +73 -0
  86. data/app/views/agentboard/executions/index.html.erb +33 -0
  87. data/app/views/agentboard/executions/show.html.erb +126 -0
  88. data/app/views/agentboard/milestones/index.html.erb +28 -0
  89. data/app/views/agentboard/settings/show.html.erb +33 -0
  90. data/app/views/agentboard/shared/_dag.html.erb +24 -0
  91. data/app/views/agentboard/tickets/_form.html.erb +38 -0
  92. data/app/views/agentboard/tickets/edit.html.erb +5 -0
  93. data/app/views/agentboard/tickets/index.html.erb +51 -0
  94. data/app/views/agentboard/tickets/new.html.erb +6 -0
  95. data/app/views/agentboard/tickets/show.html.erb +343 -0
  96. data/app/views/agentboard/tickets/suggestions.html.erb +21 -0
  97. data/app/views/agentboard/workflow_runs/show.html.erb +28 -0
  98. data/app/views/agentboard/workflows/index.html.erb +49 -0
  99. data/app/views/agentboard/workflows/show.html.erb +15 -0
  100. data/app/views/layouts/agentboard/application.html.erb +93 -0
  101. data/config/routes.rb +49 -0
  102. data/db/migrate/20261114000001_create_agentboard_core.rb +144 -0
  103. data/db/migrate/20261114000002_create_agentboard_runtime.rb +213 -0
  104. data/db/migrate/20261115000001_create_agentboard_webhook_deliveries.rb +18 -0
  105. data/exe/agentboard +7 -0
  106. data/lib/agentboard/agent_container.rb +124 -0
  107. data/lib/agentboard/authorization.rb +58 -0
  108. data/lib/agentboard/capture/middleware.rb +54 -0
  109. data/lib/agentboard/capture.rb +57 -0
  110. data/lib/agentboard/cli.rb +818 -0
  111. data/lib/agentboard/command_policy.rb +44 -0
  112. data/lib/agentboard/configuration.rb +160 -0
  113. data/lib/agentboard/container_sandbox.rb +65 -0
  114. data/lib/agentboard/context_builder.rb +243 -0
  115. data/lib/agentboard/egress_proxy.rb +95 -0
  116. data/lib/agentboard/engine.rb +27 -0
  117. data/lib/agentboard/errors.rb +42 -0
  118. data/lib/agentboard/event_schema.rb +37 -0
  119. data/lib/agentboard/extensions.rb +58 -0
  120. data/lib/agentboard/git/repository.rb +92 -0
  121. data/lib/agentboard/git/worktree_manager.rb +107 -0
  122. data/lib/agentboard/git.rb +4 -0
  123. data/lib/agentboard/ids.rb +33 -0
  124. data/lib/agentboard/json_log_formatter.rb +28 -0
  125. data/lib/agentboard/path_guard.rb +27 -0
  126. data/lib/agentboard/process_ancestry.rb +40 -0
  127. data/lib/agentboard/providers/base.rb +107 -0
  128. data/lib/agentboard/providers/claude_code.rb +219 -0
  129. data/lib/agentboard/providers/codex.rb +177 -0
  130. data/lib/agentboard/providers/compatibility/claude.yml +13 -0
  131. data/lib/agentboard/providers/compatibility/codex.yml +10 -0
  132. data/lib/agentboard/providers/config_editor.rb +106 -0
  133. data/lib/agentboard/providers/cursor.rb +40 -0
  134. data/lib/agentboard/providers/fake.rb +59 -0
  135. data/lib/agentboard/providers/fake_agent_script.rb +47 -0
  136. data/lib/agentboard/providers/generic_cli.rb +35 -0
  137. data/lib/agentboard/providers/registry.rb +31 -0
  138. data/lib/agentboard/providers/types.rb +32 -0
  139. data/lib/agentboard/providers.rb +26 -0
  140. data/lib/agentboard/rails_introspection.rb +49 -0
  141. data/lib/agentboard/redactor.rb +62 -0
  142. data/lib/agentboard/result.rb +46 -0
  143. data/lib/agentboard/runtime/ipc.rb +193 -0
  144. data/lib/agentboard/runtime.rb +1 -0
  145. data/lib/agentboard/state_machines.rb +71 -0
  146. data/lib/agentboard/subprocess.rb +168 -0
  147. data/lib/agentboard/templates/Dockerfile.agent +21 -0
  148. data/lib/agentboard/verification.rb +79 -0
  149. data/lib/agentboard/version.rb +3 -0
  150. data/lib/agentboard/workflows/condition.rb +166 -0
  151. data/lib/agentboard/workflows/definition.rb +208 -0
  152. data/lib/agentboard/workflows.rb +2 -0
  153. data/lib/agentboard.rb +73 -0
  154. data/lib/generators/agentboard/install/install_generator.rb +67 -0
  155. data/lib/generators/agentboard/install/templates/config.yml +81 -0
  156. data/lib/generators/agentboard/install/templates/feature_delivery.yml +25 -0
  157. data/lib/generators/agentboard/install/templates/initializer.rb +23 -0
  158. data/lib/tasks/agentboard_tasks.rake +6 -0
  159. metadata +285 -0
@@ -0,0 +1,818 @@
1
+ require "thor"
2
+ # Load Rails' engine base class first so the AgentBoard engine registers itself before the
3
+ # host application boots (the CLI requires agentboard before config/environment.rb).
4
+ require "rails"
5
+ require "rails/engine"
6
+ require "agentboard"
7
+
8
+ module Agentboard
9
+ # Command-line contract (spec §12). All commands are non-interactive, accept --help,
10
+ # and most accept --json for automation.
11
+ #
12
+ # Exit codes:
13
+ # 0 success 4 forbidden / policy denied / unauthorized
14
+ # 1 internal error 5 conflict (version, lease, workspace)
15
+ # 2 usage / validation error 6 provider or capability unavailable
16
+ # 3 not found 7 runtime daemon not running
17
+ module CLIHelpers
18
+ EXIT_CODES = {
19
+ "NOT_FOUND" => 3, "VALIDATION_FAILED" => 2, "INVALID_TRANSITION" => 2, "UNAUTHORIZED" => 4, "FORBIDDEN" => 4,
20
+ "POLICY_DENIED" => 4, "APPROVAL_REQUIRED" => 4, "VERSION_CONFLICT" => 5, "LEASE_CONFLICT" => 5, "LEASE_EXPIRED" => 5,
21
+ "WORKSPACE_CONFLICT" => 5, "CAPABILITY_UNAVAILABLE" => 6, "PROVIDER_UNAVAILABLE" => 6, "RATE_LIMITED" => 5
22
+ }.freeze
23
+
24
+ def self.included(base)
25
+ base.class_option :root, type: :string, desc: "Project root (defaults to the Git toplevel of the current directory)"
26
+ # No default: Thor merges parent options with subcommand defaults, which would reset --json.
27
+ base.class_option :json, type: :boolean, desc: "Machine-readable JSON output"
28
+ base.class_eval do
29
+ def self.exit_on_failure? = true
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ def root
36
+ @root ||= begin
37
+ dir = options[:root] || ENV["AGENTBOARD_ROOT"]
38
+ unless dir
39
+ out, _e, st = Open3.capture3("git", "rev-parse", "--show-toplevel")
40
+ dir = st.success? ? out.strip : Dir.pwd
41
+ # Prefer the nearest Rails app (config/environment.rb) between cwd and the git root.
42
+ cur = Dir.pwd
43
+ while cur.start_with?(dir)
44
+ (dir = cur) && break if File.exist?(File.join(cur, "config", "environment.rb"))
45
+ cur = File.dirname(cur)
46
+ end
47
+ end
48
+ File.expand_path(dir)
49
+ end
50
+ end
51
+
52
+ # Boots the host Rails application. stdout is redirected to stderr while booting so that
53
+ # commands like `mcp serve` keep stdout clean for protocol frames.
54
+ def boot!(quiet_stdout: false)
55
+ return if @booted
56
+
57
+ env_file = File.join(root, "config", "environment.rb")
58
+ raise Error.not_found("No Rails application found at #{root} (config/environment.rb). Use --root.") unless File.exist?(env_file)
59
+
60
+ ENV["RAILS_ENV"] ||= "development"
61
+ original = $stdout
62
+ $stdout = $stderr if quiet_stdout
63
+ Dir.chdir(root) { require env_file }
64
+ Agentboard.config.root = root if Agentboard.config.root.nil?
65
+ @booted = true
66
+ ensure
67
+ $stdout = original if quiet_stdout && original
68
+ end
69
+
70
+ def actor = Authorization.local_human
71
+
72
+ def project = @project ||= Project.current(root: Pathname.new(Agentboard.config.root_path))
73
+
74
+ def emit(data, human = nil)
75
+ if options[:json]
76
+ puts JSON.pretty_generate(data.is_a?(Struct) ? data.to_h : data)
77
+ elsif human
78
+ human.respond_to?(:call) ? human.call : puts(human)
79
+ end
80
+ end
81
+
82
+ def guard
83
+ yield
84
+ rescue Agentboard::Error => e
85
+ if options[:json]
86
+ puts JSON.pretty_generate(Result.failure(e).to_h)
87
+ else
88
+ warn "#{e.code}: #{e.message}"
89
+ Array(e.details[:missing]).each { |m| warn " • #{m}" }
90
+ end
91
+ exit(EXIT_CODES.fetch(e.code, 1))
92
+ rescue Thor::Error
93
+ raise
94
+ rescue StandardError => e
95
+ warn "INTERNAL_ERROR: #{e.class}: #{Redactor.redact(e.message)}"
96
+ warn e.backtrace.first(5).join("\n") if ENV["AGENTBOARD_DEBUG"]
97
+ exit 1
98
+ end
99
+
100
+ def ticket!(key) = Ticket.find_by_key!(key.to_s.upcase, project: project)
101
+
102
+ def table(rows, headers)
103
+ widths = headers.each_index.map { |i| ([ headers[i].to_s.size ] + rows.map { |r| r[i].to_s.size }).max.clamp(0, 60) }
104
+ line = ->(cols) { cols.each_with_index.map { |c, i| c.to_s[0, 60].ljust(widths[i]) }.join(" ") }
105
+ puts line.(headers)
106
+ puts widths.map { |w| "─" * w }.join(" ")
107
+ rows.each { |r| puts line.(r) }
108
+ end
109
+ end
110
+
111
+ class DaemonCLI < Thor
112
+ include CLIHelpers
113
+ namespace :daemon
114
+
115
+ desc "start", "Start the runtime daemon (separate from the web server)"
116
+ method_option :foreground, type: :boolean, default: false, aliases: "-f", desc: "Run in the foreground"
117
+ def start
118
+ guard do
119
+ boot!
120
+ status = Runtime::DaemonControl.status
121
+ if status[:running]
122
+ emit({ running: true, pid: status[:pid] }, "AgentBoard daemon already running (pid #{status[:pid]})")
123
+ return
124
+ end
125
+ if options[:foreground]
126
+ run_foreground
127
+ else
128
+ paths = Runtime::IPC.paths
129
+ Runtime::IPC.ensure_private_dir!(paths[:dir])
130
+ log = File.open(paths[:log], "a", 0o600)
131
+ pid = ::Process.spawn(RbConfig.ruby, File.expand_path("../../exe/agentboard", __dir__), "daemon", "start", "--foreground", "--root", root,
132
+ out: log, err: log, in: File::NULL, pgroup: true, chdir: root)
133
+ ::Process.detach(pid)
134
+ deadline = Time.now + 20
135
+ sleep 0.2 until Runtime::DaemonControl.status[:running] || Time.now > deadline
136
+ st = Runtime::DaemonControl.status
137
+ raise Error.provider_unavailable("Daemon failed to start; see #{paths[:log]}") unless st[:running]
138
+
139
+ emit({ running: true, pid: st[:pid], log: paths[:log].to_s }, "AgentBoard daemon started (pid #{st[:pid]}) · logs: #{paths[:log]}")
140
+ end
141
+ end
142
+ end
143
+
144
+ desc "stop", "Stop the runtime daemon gracefully"
145
+ def stop
146
+ guard do
147
+ boot!
148
+ stopped = Runtime::DaemonControl.stop
149
+ emit({ stopped: stopped }, stopped ? "AgentBoard daemon stopped" : "Daemon was not running")
150
+ end
151
+ end
152
+
153
+ desc "restart", "Restart the runtime daemon (running attempts are reconciled as interrupted)"
154
+ def restart
155
+ stop
156
+ @booted = true
157
+ invoke :start, [], options.merge(foreground: false)
158
+ end
159
+
160
+ desc "status", "Show runtime daemon status"
161
+ def status
162
+ guard do
163
+ boot!
164
+ st = Runtime::DaemonControl.status
165
+ emit(st, -> {
166
+ puts st[:running] ? "running · pid #{st[:pid]} · active #{st.dig(:health, "active")} · queued #{st.dig(:health, "queued")}" : "not running#{" (stale pid file)" if st[:stale_pid_file]}"
167
+ })
168
+ exit 7 unless st[:running]
169
+ end
170
+ end
171
+
172
+ private
173
+
174
+ def run_foreground
175
+ Extensions.run(:runtime_start)
176
+ lock = Runtime::DaemonControl.write_pid!
177
+ logger = Logger.new($stdout, progname: "agentboard")
178
+ logger.formatter = JsonLogFormatter.new(component: "runtime") unless ENV["AGENTBOARD_LOG_FORMAT"] == "text"
179
+ Agentboard.logger = logger
180
+ supervisor = Runtime::Supervisor.new(logger: logger)
181
+ server = Runtime::IPC::Server.new(handler: Runtime::IpcHandler.new(supervisor), logger: logger).start
182
+ %w[TERM INT].each { |sig| trap(sig) { supervisor.stop! } }
183
+ logger.info("AgentBoard runtime #{Agentboard::VERSION} started (pid #{::Process.pid}, root #{root})")
184
+ HookIngestion.replay_spool!(root: root) rescue nil
185
+ supervisor.run
186
+ ensure
187
+ server&.stop
188
+ if lock
189
+ File.delete(Runtime::IPC.paths[:pid]) rescue nil
190
+ lock.close
191
+ end
192
+ logger&.info("AgentBoard runtime stopped")
193
+ end
194
+ end
195
+
196
+ class ProviderCLI < Thor
197
+ include CLIHelpers
198
+ namespace :provider
199
+
200
+ desc "list", "List providers and detected capabilities"
201
+ def list
202
+ guard do
203
+ boot!
204
+ probes = Providers.registry.all.map(&:probe)
205
+ emit(probes.map(&:to_h), -> {
206
+ table(probes.map { |p| [ p.provider, p.installed ? (p.version || "?") : "—", p.validation, p.supported_capabilities.join(",") ] },
207
+ %w[PROVIDER VERSION STATUS CAPABILITIES])
208
+ })
209
+ end
210
+ end
211
+
212
+ desc "setup NAME", "Configure passive integration (MCP + hooks) for claude, codex or cursor"
213
+ method_option :notify, type: :boolean, default: false, desc: "Codex: also install the global notify hook (opt-in)"
214
+ def setup(name)
215
+ guard do
216
+ boot!
217
+ provider = Providers.registry.fetch(name)
218
+ probe = provider.probe
219
+ raise Error.provider_unavailable("#{provider.display_name} is not installed") unless probe.installed || name == "cursor"
220
+
221
+ result = name.to_s == "codex" ? provider.install_passive_hooks(project: project, root: root, notify: options[:notify]) :
222
+ provider.install_passive_hooks(project: project, root: root)
223
+ emit(result.to_h, -> {
224
+ puts "#{provider.display_name} #{probe.version} — passive integration"
225
+ result.changes.each { |c| puts " #{c[:action].to_s.ljust(9)} #{c[:path]} #{Array(c[:keys]).join(".")} #{Array(c[:events]).join(", ")}".rstrip }
226
+ result.backups.each { |b| puts " backup #{b}" }
227
+ result.warnings.each { |w| puts " note #{w}" }
228
+ puts " manifest #{ConfigEditor.manifest_path(root, provider.key)}" rescue nil
229
+ })
230
+ end
231
+ end
232
+
233
+ desc "remove NAME", "Remove AgentBoard-owned MCP/hook entries for a provider"
234
+ def remove(name)
235
+ guard do
236
+ boot!
237
+ provider = Providers.registry.fetch(name)
238
+ removed = provider.remove_passive_hooks(project: project, root: root, manifest: Providers::ConfigEditor.read_manifest(root, provider.key))
239
+ emit({ removed: removed }, removed.any? ? "Removed AgentBoard entries from: #{removed.join(", ")}" : "Nothing to remove")
240
+ end
241
+ end
242
+
243
+ desc "doctor NAME", "Probe one provider in detail"
244
+ def doctor(name)
245
+ guard do
246
+ boot!
247
+ probe = Providers.registry.fetch(name).probe
248
+ emit(probe.to_h, -> {
249
+ puts "#{name}: #{probe.installed ? "installed #{probe.version}" : "not installed"} (#{probe.validation})"
250
+ puts "capabilities: #{probe.supported_capabilities.join(", ").presence || "none"}"
251
+ probe.diagnostics.each { |d| puts " - #{d}" }
252
+ })
253
+ end
254
+ end
255
+ end
256
+
257
+ class TicketCLI < Thor
258
+ include CLIHelpers
259
+ namespace :ticket
260
+
261
+ desc "list", "List tickets"
262
+ method_option :status, type: :string
263
+ def list
264
+ guard do
265
+ boot!
266
+ scope = project.tickets.includes(:project).ordered
267
+ scope = scope.where(status: options[:status]) if options[:status]
268
+ rows = scope.limit(200).map { |t| { key: t.key, status: t.status, priority: t.priority, title: t.title } }
269
+ emit(rows, -> { table(rows.map(&:values), %w[KEY STATUS P TITLE]) })
270
+ end
271
+ end
272
+
273
+ desc "show KEY", "Show a ticket"
274
+ def show(key)
275
+ guard do
276
+ boot!
277
+ t = ticket!(key)
278
+ data = { key: t.key, title: t.title, status: t.status, priority: t.priority, kind: t.kind, version: t.lock_version,
279
+ description: t.description, criteria: t.acceptance_criteria.map { |c| { description: c.description, state: c.state } },
280
+ completion: CompletionPolicy.evaluate(t) }
281
+ emit(data, -> {
282
+ puts "#{t.key} #{t.title}"
283
+ puts "status #{t.status} · #{t.priority_label} · #{t.kind} · v#{t.lock_version}"
284
+ puts "", t.description unless t.description.blank?
285
+ puts "", "Acceptance criteria:"
286
+ t.acceptance_criteria.each { |c| puts " [#{c.satisfied? ? "x" : " "}] #{c.description}" }
287
+ puts "", "Completion: #{data[:completion][:ok] ? "ready" : "blocked"}"
288
+ data[:completion][:missing].each { |m| puts " • #{m}" }
289
+ })
290
+ end
291
+ end
292
+
293
+ desc "create TITLE", "Create a ticket"
294
+ method_option :description, type: :string, default: ""
295
+ method_option :criteria, type: :array, default: [], desc: "Acceptance criteria"
296
+ method_option :kind, type: :string, default: "task"
297
+ method_option :priority, type: :numeric, default: 2
298
+ method_option :ready, type: :boolean, default: false
299
+ def create(title)
300
+ guard do
301
+ boot!
302
+ t = Tickets.create!(project: project, actor: actor, title: title, description: options[:description], kind: options[:kind],
303
+ priority: options[:priority], criteria: options[:criteria], status: options[:ready] ? "ready" : "backlog")
304
+ emit({ key: t.key, status: t.status }, "Created #{t.key} (#{t.status})")
305
+ end
306
+ end
307
+
308
+ desc "move KEY STATUS", "Transition a ticket"
309
+ method_option :reason, type: :string
310
+ def move(key, status)
311
+ guard do
312
+ boot!
313
+ t = Tickets.transition!(ticket: ticket!(key), to: status, actor: actor, reason: options[:reason])
314
+ emit({ key: t.key, status: t.status }, "#{t.key} → #{t.status}")
315
+ end
316
+ end
317
+ end
318
+
319
+ class WorkflowCLI < Thor
320
+ include CLIHelpers
321
+ namespace :workflow
322
+
323
+ desc "validate FILE", "Validate a workflow YAML file"
324
+ def validate(file)
325
+ guard do
326
+ boot!
327
+ d = Workflows::Definition.parse_yaml(File.read(file))
328
+ emit({ ok: true, name: d.name, order: d.topological_order, digest: d.digest }, "✓ #{d.name}: #{d.topological_order.join(" → ")}")
329
+ end
330
+ end
331
+
332
+ desc "list", "List registered workflows (syncs .agentboard/workflows first)"
333
+ def list
334
+ guard do
335
+ boot!
336
+ WorkflowEngine.sync_from_disk!(project: project)
337
+ rows = WorkflowDefinition.where(project: project).order(:name, :version).map { |w| [ w.name, w.version, w.digest[0, 12] ] }
338
+ emit(rows, -> { table(rows, %w[NAME VERSION DIGEST]) })
339
+ end
340
+ end
341
+
342
+ map "run" => :run_workflow
343
+ desc "run NAME KEY", "Start a workflow run for a ticket"
344
+ def run_workflow(name, key)
345
+ guard do
346
+ boot!
347
+ WorkflowEngine.sync_from_disk!(project: project)
348
+ r = WorkflowEngine.start!(definition: WorkflowEngine.latest(project: project, name: name), ticket: ticket!(key), actor: actor)
349
+ emit({ run_id: r.id, status: r.status }, "Started #{name} for #{key} (run #{r.id}). The daemon advances it.")
350
+ end
351
+ end
352
+ end
353
+
354
+ class McpCLI < Thor
355
+ include CLIHelpers
356
+ namespace :mcp
357
+
358
+ desc "serve", "Run the MCP stdio server (stdout carries protocol frames only)"
359
+ method_option :provider, type: :string, default: nil, desc: "Provider label for this session (claude, codex, …)"
360
+ method_option :execution, type: :string, desc: "Bind to an AgentBoard-launched execution (permission bridge)"
361
+ def serve
362
+ boot!(quiet_stdout: true)
363
+ Agentboard.logger = Logger.new($stderr, level: Logger::WARN)
364
+ ActiveRecord::Base.logger = nil
365
+ provider = options[:provider] || ENV["AGENTBOARD_PROVIDER"] || detect_provider
366
+ execution = options[:execution] && Execution.find_by(id: options[:execution])
367
+ raise Error.not_found("Execution #{options[:execution]} not found") if options[:execution] && execution.nil?
368
+
369
+ McpServer.new(project: project, provider_key: provider, execution: execution).serve
370
+ rescue Agentboard::Error => e
371
+ warn "agentboard mcp: #{e.code}: #{e.message}"
372
+ exit(CLIHelpers::EXIT_CODES.fetch(e.code, 1))
373
+ end
374
+
375
+ private
376
+
377
+ def detect_provider
378
+ return "claude" if ENV["CLAUDECODE"] || ENV["CLAUDE_CODE_ENTRYPOINT"]
379
+ return "codex" if ENV.keys.any? { |k| k.start_with?("CODEX_") }
380
+
381
+ "mcp"
382
+ end
383
+ end
384
+
385
+ class HookCLI < Thor
386
+ include CLIHelpers
387
+ namespace :hook
388
+
389
+ desc "ingest", "Ingest a provider hook payload from stdin (bounded, redacted, deduplicated)"
390
+ method_option :provider, type: :string, required: true
391
+ method_option :event, type: :string, required: true
392
+ def ingest(*payload_args)
393
+ # Read before booting Rails (app constants are not loaded yet). Bounded read.
394
+ # Codex `notify` passes the JSON payload as the last argument instead of stdin.
395
+ raw = payload_args.last.to_s.start_with?("{") ? payload_args.last.to_s[0, 256 * 1024] : ($stdin.tty? ? "" : $stdin.read(256 * 1024 + 1).to_s)
396
+ begin
397
+ Timeout.timeout(8) do
398
+ boot!(quiet_stdout: true)
399
+ ActiveRecord::Base.logger = nil
400
+ HookIngestion.ingest!(provider_key: options[:provider], event_name: options[:event], raw: raw, project: project)
401
+ end
402
+ rescue Agentboard::Error => e
403
+ warn "agentboard hook: #{e.code}: #{e.message}" if ENV["AGENTBOARD_DEBUG"]
404
+ rescue StandardError, Timeout::Error => e
405
+ warn "agentboard hook: spooling (#{e.class}: #{e.message})" if ENV["AGENTBOARD_DEBUG"]
406
+ # App/DB unavailable: spool locally and never block the agent.
407
+ HookIngestion.spool!(root: root, provider_key: options[:provider], event_name: options[:event], raw: raw) rescue nil
408
+ end
409
+ exit 0 # hooks must never fail the provider's own workflow
410
+ end
411
+
412
+ desc "replay", "Replay spooled hook payloads"
413
+ def replay
414
+ guard do
415
+ boot!
416
+ n = HookIngestion.replay_spool!(root: root)
417
+ emit({ replayed: n }, "Replayed #{n} spooled hook payload(s)")
418
+ end
419
+ end
420
+ end
421
+
422
+ class SandboxCLI < Thor
423
+ include CLIHelpers
424
+ namespace :sandbox
425
+
426
+ desc "init", "Write .agentboard/sandbox/Dockerfile for the agent image"
427
+ def init
428
+ guard do
429
+ path = File.join(root, ".agentboard", "sandbox", "Dockerfile")
430
+ if File.exist?(path)
431
+ emit({ exists: path }, "#{path} already exists (left unchanged)")
432
+ else
433
+ FileUtils.mkdir_p(File.dirname(path))
434
+ File.write(path, File.read(File.expand_path("templates/Dockerfile.agent", __dir__)).sub("__RUBY_VERSION__", RUBY_VERSION))
435
+ emit({ written: path }, "Wrote #{path}. Next: agentboard sandbox build")
436
+ end
437
+ end
438
+ end
439
+
440
+ desc "build", "Build the agent image and print the config to enable container isolation"
441
+ method_option :tag, type: :string
442
+ def build
443
+ guard do
444
+ boot!
445
+ dockerfile = File.join(root, ".agentboard", "sandbox", "Dockerfile")
446
+ raise Error.not_found("Run `agentboard sandbox init` first") unless File.exist?(dockerfile)
447
+ raise Error.provider_unavailable("#{AgentContainer.runtime} is not running") unless ContainerSandbox.available?
448
+
449
+ tag = options[:tag] || "agentboard-agent:#{AgentContainer.project_id}"
450
+ ok = system(AgentContainer.runtime, "build", "-t", tag, "-f", dockerfile, root)
451
+ raise Error.provider_unavailable("image build failed") unless ok
452
+
453
+ emit({ image: tag }, -> {
454
+ puts "Built #{tag}. Enable it in .agentboard/config.yml:"
455
+ puts " security:\n sandbox: container\n sandbox:\n agents: true\n agent_image: #{tag}\n image: #{tag}\n credential_env: [ANTHROPIC_API_KEY, OPENAI_API_KEY]"
456
+ })
457
+ end
458
+ end
459
+
460
+ desc "status", "Show container isolation status (runtime, network, egress allowlist)"
461
+ def status
462
+ guard do
463
+ boot!
464
+ data = { runtime: AgentContainer.runtime, available: ContainerSandbox.available?, verification: ContainerSandbox.enabled?,
465
+ agents: AgentContainer.enabled?, agent_image: ContainerSandbox.settings["agent_image"], network: AgentContainer.network_name,
466
+ proxy: AgentContainer.proxy_name, egress_allowlist: AgentContainer.egress_allowlist }
467
+ emit(data, -> { data.each { |k, v| puts "#{k.to_s.ljust(18)} #{v.is_a?(Array) ? v.join(", ") : v}" } })
468
+ end
469
+ end
470
+
471
+ desc "down", "Remove the egress proxy and internal network of this project"
472
+ def down
473
+ guard do
474
+ boot!
475
+ Open3.capture3(AgentContainer.runtime, "rm", "-f", AgentContainer.proxy_name)
476
+ Open3.capture3(AgentContainer.runtime, "network", "rm", AgentContainer.network_name)
477
+ emit({ removed: [ AgentContainer.proxy_name, AgentContainer.network_name ] }, "Removed egress proxy and network")
478
+ end
479
+ end
480
+ end
481
+
482
+ class CLI < Thor
483
+ include CLIHelpers
484
+ package_name "agentboard"
485
+
486
+ map %w[--version -v] => :version
487
+ desc "version", "Print version"
488
+ def version = puts("agentboard #{Agentboard::VERSION}")
489
+
490
+ desc "daemon SUBCOMMAND", "Runtime daemon lifecycle (start/stop/status/restart)"
491
+ subcommand "daemon", DaemonCLI
492
+ desc "provider SUBCOMMAND", "Provider management (list/setup/remove/doctor)"
493
+ subcommand "provider", ProviderCLI
494
+ desc "ticket SUBCOMMAND", "Ticket management (list/show/create/move)"
495
+ subcommand "ticket", TicketCLI
496
+ desc "workflow SUBCOMMAND", "Workflows (validate/list/run)"
497
+ subcommand "workflow", WorkflowCLI
498
+ desc "mcp SUBCOMMAND", "MCP server"
499
+ subcommand "mcp", McpCLI
500
+ desc "hook SUBCOMMAND", "Provider hook ingestion"
501
+ subcommand "hook", HookCLI
502
+ desc "sandbox SUBCOMMAND", "Container isolation for agents (init/build/status/down)"
503
+ subcommand "sandbox", SandboxCLI
504
+
505
+ desc "init", "Set up .agentboard/ in this repository (idempotent, preserves existing settings)"
506
+ def init
507
+ guard do
508
+ out, _e, st = Open3.capture3("git", "rev-parse", "--show-toplevel", chdir: root)
509
+ raise Error.validation_failed("#{root} is not inside a Git repository") unless st.success?
510
+
511
+ created = []
512
+ %w[workflows instructions policies cache manifests].each do |d|
513
+ path = File.join(root, ".agentboard", d)
514
+ next if File.directory?(path)
515
+
516
+ FileUtils.mkdir_p(path)
517
+ created << ".agentboard/#{d}/"
518
+ end
519
+ config = File.join(root, ".agentboard", "config.yml")
520
+ unless File.exist?(config)
521
+ File.write(config, File.read(File.expand_path("../generators/agentboard/install/templates/config.yml", __dir__)))
522
+ created << ".agentboard/config.yml"
523
+ end
524
+ wf = File.join(root, ".agentboard", "workflows", "feature_delivery.yml")
525
+ unless File.exist?(wf)
526
+ File.write(wf, File.read(File.expand_path("../generators/agentboard/install/templates/feature_delivery.yml", __dir__)))
527
+ created << ".agentboard/workflows/feature_delivery.yml"
528
+ end
529
+ gitignore = File.join(root, ".gitignore")
530
+ lines = File.exist?(gitignore) ? File.read(gitignore).lines.map(&:strip) : []
531
+ wanted = [ "/.agentboard/worktrees/", "/.agentboard/logs/", "/.agentboard/cache/", "/.agentboard/spool/", "/.agentboard/manifests/", "*.agentboard-backup-*" ]
532
+ missing = wanted - lines
533
+ if missing.any?
534
+ File.open(gitignore, "a") { |f| f.puts("", "# AgentBoard local state", *missing) }
535
+ created << ".gitignore (#{missing.size} entries)"
536
+ end
537
+ providers = Providers.registry.all.reject { |p| p.key == "fake" }.map { |p| pr = p.probe; [ p.key, pr.installed, pr.version ] }
538
+ emit({ git_root: out.strip, created: created, providers: providers.map { |k, i, v| { provider: k, installed: i, version: v } } }, -> {
539
+ puts "AgentBoard initialised in #{root}"
540
+ created.each { |c| puts " created #{c}" }
541
+ puts " (nothing to create — already initialised)" if created.empty?
542
+ puts "", "Detected providers:"
543
+ providers.each { |k, i, v| puts " #{i ? "✓" : "·"} #{k} #{v}" }
544
+ puts "", "Next: bundle exec agentboard provider setup claude · bundle exec agentboard daemon start"
545
+ })
546
+ end
547
+ end
548
+
549
+ desc "doctor", "Diagnose the installation"
550
+ def doctor
551
+ guard do
552
+ boot!
553
+ checks = Doctor.new(root: root).checks
554
+ emit(checks.map(&:to_h), -> {
555
+ checks.each { |c| puts "#{{ "ok" => "✓", "warn" => "!", "fail" => "✗" }[c.status]} #{c.name.ljust(26)} #{c.detail}" }
556
+ })
557
+ exit 1 if checks.any? { |c| c.status == "fail" }
558
+ end
559
+ end
560
+
561
+ desc "attach KEY", "Associate the active provider session in this directory with a ticket"
562
+ method_option :session, type: :string, desc: "AgentBoard session id or provider session id"
563
+ method_option :provider, type: :string, default: "claude"
564
+ def attach(key)
565
+ guard do
566
+ boot!
567
+ t = ticket!(key)
568
+ session = if options[:session]
569
+ AgentSession.where(project: project).where("id = :s OR external_session_id = :s", s: options[:session]).first
570
+ else
571
+ AgentSession.where(project: project, provider_key: options[:provider], state: "active").where.not(trust_level: "untrusted")
572
+ .order(Arel.sql("COALESCE(last_seen_at, created_at) DESC")).first
573
+ end
574
+ raise Error.not_found("No active #{options[:provider]} session found; start the agent with hooks configured, or pass --session") unless session
575
+
576
+ SessionAssociation.link!(session: session, ticket: t, source: "cli_attach", actor: actor)
577
+ emit({ session_id: session.id, ticket: t.key }, "Attached #{session.display_name} → #{t.key}")
578
+ end
579
+ end
580
+
581
+ map "run" => :run_ticket
582
+ desc "run KEY", "Queue a managed execution for a ticket"
583
+ method_option :provider, type: :string, default: "fake"
584
+ method_option :action, type: :string, default: "implement"
585
+ method_option :workflow, type: :string
586
+ method_option :wait, type: :boolean, default: false, desc: "Wait for the execution to finish"
587
+ method_option :scenario, type: :string, desc: "Fake provider scenario"
588
+ def run_ticket(key)
589
+ guard do
590
+ boot!
591
+ t = ticket!(key)
592
+ if options[:workflow]
593
+ WorkflowEngine.sync_from_disk!(project: project)
594
+ r = WorkflowEngine.start!(definition: WorkflowEngine.latest(project: project, name: options[:workflow]), ticket: t, actor: actor)
595
+ emit({ workflow_run_id: r.id }, "Started workflow #{options[:workflow]} (#{r.id})")
596
+ return
597
+ end
598
+ e = Executions.enqueue!(ticket: t, provider_key: options[:provider], action: options[:action], actor: actor,
599
+ idempotency_key: SecureRandom.uuid, metadata: options[:scenario] ? { "scenario" => options[:scenario] } : {})
600
+ warn "Runtime daemon is not running; the execution stays queued until `agentboard daemon start`." unless Runtime::DaemonControl.status[:running]
601
+ unless options[:wait]
602
+ emit({ execution_id: e.id, status: e.status, attempt: e.attempt }, "Queued #{e.label} (#{e.id})")
603
+ return
604
+ end
605
+ sleep 0.5 until e.reload.terminal?
606
+ emit({ execution_id: e.id, status: e.status, head_sha: e.head_sha, failure_code: e.failure_code }, "#{e.label}: #{e.status}#{" (#{e.failure_code})" if e.failure_code}")
607
+ exit(e.status == "completed" ? 0 : 1)
608
+ end
609
+ end
610
+
611
+ desc "verify KEY", "Run the approved verification catalog against the repository HEAD for a ticket"
612
+ def verify(key)
613
+ guard do
614
+ boot!
615
+ results = VerificationService.verify_repository!(ticket: ticket!(key), actor: actor)
616
+ emit(results.map { |r| r.slice(:name, :status, :commit_sha, :exit_code) }, -> {
617
+ results.each { |r| puts "#{r.passed? ? "✓" : "✗"} #{r.name.ljust(14)} #{r.status.ljust(8)} #{r.commit_sha[0, 12]}" }
618
+ })
619
+ exit 1 unless results.select(&:required).all?(&:passed?)
620
+ end
621
+ end
622
+
623
+ desc "executions", "List recent executions"
624
+ method_option :limit, type: :numeric, default: 20
625
+ def executions
626
+ guard do
627
+ boot!
628
+ rows = Execution.joins(:ticket).where(agentboard_tickets: { project_id: project.id }).includes(:ticket).recent.limit(options[:limit])
629
+ .map { |e| { id: e.id, ticket: e.ticket.key, provider: e.provider_key, action: e.action, attempt: e.attempt, status: e.status, head_sha: e.head_sha } }
630
+ emit(rows, -> { table(rows.map { |r| [ r[:id][0, 13], r[:ticket], r[:provider], r[:action], r[:attempt], r[:status] ] }, %w[ID TICKET PROVIDER ACTION ATTEMPT STATUS]) })
631
+ end
632
+ end
633
+
634
+ desc "logs EXECUTION_ID", "Print (and optionally follow) the redacted execution log"
635
+ method_option :follow, type: :boolean, aliases: "-f", default: false
636
+ def logs(id)
637
+ guard do
638
+ boot!
639
+ e = find_execution(id)
640
+ offset = 0
641
+ loop do
642
+ if e.log_path && File.exist?(e.log_path)
643
+ File.open(e.log_path, "rb") do |f|
644
+ f.seek(offset)
645
+ chunk = f.read
646
+ offset += chunk.bytesize
647
+ $stdout.write(Redactor.redact(chunk.force_encoding("UTF-8").scrub))
648
+ end
649
+ end
650
+ break unless options[:follow] && !e.reload.terminal?
651
+
652
+ sleep 0.5
653
+ end
654
+ end
655
+ end
656
+
657
+ desc "cancel EXECUTION_ID", "Cancel an execution (process group gets SIGTERM, then SIGKILL)"
658
+ method_option :reason, type: :string, default: "cancelled from CLI"
659
+ def cancel(id)
660
+ guard do
661
+ boot!
662
+ e = Executions.request_cancel!(execution: find_execution(id), actor: actor, reason: options[:reason])
663
+ emit({ execution_id: e.id, status: e.status }, "Cancellation requested for #{e.label} (#{e.status})")
664
+ end
665
+ end
666
+
667
+ desc "resume EXECUTION_ID", "Resume a provider session as a new attempt, if the provider supports it"
668
+ method_option :message, type: :string, default: "Continue the task."
669
+ def resume(id)
670
+ guard do
671
+ boot!
672
+ e = find_execution(id)
673
+ provider = Providers.registry.fetch(e.provider_key)
674
+ raise UnsupportedCapability.new(:session_resume, provider: e.provider_key) unless provider.supports?(:session_resume) && e.metadata["provider_session_id"]
675
+
676
+ n = Executions.resume!(execution: e, actor: actor, message: options[:message])
677
+ emit({ execution_id: n.id }, "Queued resume as #{n.label}")
678
+ end
679
+ end
680
+
681
+ desc "approvals", "List pending approvals"
682
+ def approvals
683
+ guard do
684
+ boot!
685
+ rows = Approval.pending.where(project: project).order(:created_at).map { |a| { id: a.id, kind: a.action_kind, ticket: a.ticket&.key, head: a.head_sha&.slice(0, 10), by: a.requested_by } }
686
+ emit(rows, -> { table(rows.map(&:values), %w[ID KIND TICKET HEAD REQUESTED_BY]) })
687
+ end
688
+ end
689
+
690
+ desc "approve APPROVAL_ID", "Approve (or --reject) a pending approval as the local human"
691
+ method_option :reject, type: :boolean, default: false
692
+ method_option :reason, type: :string
693
+ def approve(id)
694
+ guard do
695
+ boot!
696
+ a = Approvals.decide!(approval: Approval.where(project: project).find(id), actor: actor, decision: options[:reject] ? "rejected" : "approved", reason: options[:reason])
697
+ emit({ id: a.id, decision: a.decision }, "Approval #{a.id[0, 8]}: #{a.decision}")
698
+ end
699
+ end
700
+
701
+ desc "cleanup", "Remove eligible worktrees and expired logs (never uncommitted work without --force)"
702
+ method_option :dry_run, type: :boolean, default: false
703
+ method_option :force, type: :boolean, default: false
704
+ def cleanup
705
+ guard do
706
+ boot!
707
+ days = Agentboard.config.settings.dig("retention", "worktree_days").to_i
708
+ manager = Git::WorktreeManager.new(repository: project.repository, workspace: Agentboard.config.workspace_path)
709
+ removed = []
710
+ skipped = []
711
+ Execution.joins(:ticket).where(agentboard_tickets: { project_id: project.id }).terminal.where.not(worktree_path: nil)
712
+ .where("finished_at < ?", days.days.ago).find_each do |e|
713
+ next unless e.worktree_path.start_with?(Agentboard.config.workspace_path.to_s) && File.directory?(e.worktree_path)
714
+ if e.ticket.status == "review" && !options[:force]
715
+ skipped << [ e.label, "ticket in review" ]
716
+ next
717
+ end
718
+ if options[:dry_run]
719
+ removed << e.label
720
+ next
721
+ end
722
+ manager.remove!(e.worktree_path, force: options[:force])
723
+ removed << e.label
724
+ rescue Agentboard::Error => err
725
+ skipped << [ e.label, err.message ]
726
+ end
727
+ manager.prune! unless options[:dry_run]
728
+ log_days = Agentboard.config.settings.dig("retention", "raw_log_days").to_i
729
+ logs = Dir[File.join(root, ".agentboard", "logs", "*")].select { |d| File.mtime(d) < log_days.days.ago }
730
+ FileUtils.rm_rf(logs) unless options[:dry_run]
731
+ emit({ removed: removed, skipped: skipped, logs_removed: logs.size, dry_run: options[:dry_run] }, -> {
732
+ puts "#{options[:dry_run] ? "Would remove" : "Removed"} #{removed.size} worktree(s), #{logs.size} log dir(s)"
733
+ skipped.each { |l, why| puts " skipped #{l}: #{why}" }
734
+ })
735
+ end
736
+ end
737
+
738
+ desc "export", "Export tickets, events and artifact manifest as JSON (redacted)"
739
+ method_option :output, type: :string, aliases: "-o"
740
+ def export
741
+ guard do
742
+ boot!
743
+ data = {
744
+ exported_at: Time.now.utc.iso8601, agentboard_version: Agentboard::VERSION, project: project.slice(:key, :name, :default_branch),
745
+ tickets: project.tickets.includes(:acceptance_criteria, :comments).map { |t|
746
+ t.slice(:number, :title, :kind, :status, :priority, :labels, :created_at, :completed_at).merge(
747
+ key: t.key, description: Redactor.redact(t.description),
748
+ criteria: t.acceptance_criteria.map { |c| c.slice(:description, :verification_kind, :state) },
749
+ comments: t.comments.map { |c| { author: c.actor_name, body: Redactor.redact(c.body), at: c.created_at } })
750
+ },
751
+ events: Event.where(project_id: project.id).order(:received_at).map { |e| Redactor.redact_value(e.envelope.deep_stringify_keys) },
752
+ artifacts: Artifact.joins(:ticket).where(agentboard_tickets: { project_id: project.id }).map { |a| a.slice(:id, :kind, :uri, :sha256, :commit_sha, :provenance) }
753
+ }
754
+ json = JSON.pretty_generate(data)
755
+ options[:output] ? File.write(options[:output], json) : puts(json)
756
+ warn "Exported #{data[:tickets].size} tickets, #{data[:events].size} events" if options[:output]
757
+ end
758
+ end
759
+
760
+
761
+ desc "retention", "Apply data retention (old raw logs, provider output events, delivered outbox)"
762
+ method_option :dry_run, type: :boolean, default: false
763
+ def retention
764
+ guard do
765
+ boot!
766
+ report = DataGovernance.apply_retention!(project: project, dry_run: options[:dry_run])
767
+ emit(report, -> { report.each { |k, v| puts "#{k.to_s.ljust(24)} #{v}" } })
768
+ end
769
+ end
770
+
771
+ desc "purge", "Permanently delete ALL AgentBoard data of this project (requires --confirm KEY)"
772
+ method_option :confirm, type: :string, required: true, desc: "Type the project key to confirm"
773
+ def purge
774
+ guard do
775
+ boot!
776
+ counts = DataGovernance.purge_project!(project: project, confirm: options[:confirm], actor: actor)
777
+ emit(counts, -> { puts "Purged project data: #{counts.map { |k, v| "#{k}=#{v}" }.join(" ")}" })
778
+ end
779
+ end
780
+
781
+ desc "uninstall", "Remove AgentBoard-owned provider config and hooks (never deletes ticket data)"
782
+ def uninstall
783
+ guard do
784
+ boot!
785
+ removed = Providers.registry.all.flat_map do |p|
786
+ manifest = Providers::ConfigEditor.read_manifest(root, p.key)
787
+ manifest ? p.remove_passive_hooks(project: project, root: root, manifest: manifest) : []
788
+ rescue Agentboard::Error
789
+ []
790
+ end
791
+ emit({ removed: removed }, -> {
792
+ puts removed.any? ? "Removed: #{removed.join(", ")}" : "No provider integrations installed"
793
+ puts "Ticket data, events and tables were kept. Drop them with a separate, explicit migration if you really want to."
794
+ })
795
+ end
796
+ end
797
+
798
+ private
799
+
800
+ def find_execution(id)
801
+ scope = Execution.joins(:ticket).where(agentboard_tickets: { project_id: project.id })
802
+ scope.find_by(id: id) || scope.where("agentboard_executions.id LIKE ?", "#{id}%").first || raise(Error.not_found("Execution #{id} not found"))
803
+ end
804
+ end
805
+ end
806
+ require "timeout"
807
+ require "logger"
808
+ require "open3"
809
+ require "rbconfig"
810
+ require "fileutils"
811
+
812
+ # Pro commands (support-bundle, audit-export, license) when agentboard-pro is installed.
813
+ begin
814
+ require "agentboard/pro"
815
+ require "agentboard/pro/cli"
816
+ rescue LoadError
817
+ nil
818
+ end