aidp 0.15.2 → 0.17.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 (67) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +47 -0
  3. data/lib/aidp/analyze/error_handler.rb +46 -28
  4. data/lib/aidp/analyze/progress.rb +1 -1
  5. data/lib/aidp/analyze/runner.rb +27 -5
  6. data/lib/aidp/analyze/steps.rb +4 -0
  7. data/lib/aidp/cli/jobs_command.rb +2 -1
  8. data/lib/aidp/cli.rb +1086 -4
  9. data/lib/aidp/concurrency/backoff.rb +148 -0
  10. data/lib/aidp/concurrency/exec.rb +192 -0
  11. data/lib/aidp/concurrency/wait.rb +148 -0
  12. data/lib/aidp/concurrency.rb +71 -0
  13. data/lib/aidp/config.rb +21 -1
  14. data/lib/aidp/daemon/runner.rb +9 -8
  15. data/lib/aidp/debug_mixin.rb +1 -0
  16. data/lib/aidp/errors.rb +12 -0
  17. data/lib/aidp/execute/async_work_loop_runner.rb +2 -1
  18. data/lib/aidp/execute/checkpoint.rb +1 -1
  19. data/lib/aidp/execute/future_work_backlog.rb +1 -1
  20. data/lib/aidp/execute/interactive_repl.rb +102 -11
  21. data/lib/aidp/execute/progress.rb +1 -1
  22. data/lib/aidp/execute/repl_macros.rb +845 -2
  23. data/lib/aidp/execute/runner.rb +27 -5
  24. data/lib/aidp/execute/steps.rb +2 -0
  25. data/lib/aidp/harness/config_loader.rb +24 -2
  26. data/lib/aidp/harness/config_validator.rb +1 -1
  27. data/lib/aidp/harness/enhanced_runner.rb +16 -2
  28. data/lib/aidp/harness/error_handler.rb +1 -1
  29. data/lib/aidp/harness/provider_info.rb +19 -15
  30. data/lib/aidp/harness/provider_manager.rb +47 -41
  31. data/lib/aidp/harness/runner.rb +3 -11
  32. data/lib/aidp/harness/state/persistence.rb +1 -6
  33. data/lib/aidp/harness/state_manager.rb +115 -7
  34. data/lib/aidp/harness/status_display.rb +11 -18
  35. data/lib/aidp/harness/ui/navigation/submenu.rb +1 -0
  36. data/lib/aidp/harness/ui/workflow_controller.rb +1 -1
  37. data/lib/aidp/harness/user_interface.rb +12 -15
  38. data/lib/aidp/jobs/background_runner.rb +16 -6
  39. data/lib/aidp/providers/codex.rb +0 -1
  40. data/lib/aidp/providers/cursor.rb +0 -1
  41. data/lib/aidp/providers/github_copilot.rb +0 -1
  42. data/lib/aidp/providers/opencode.rb +0 -1
  43. data/lib/aidp/skills/composer.rb +178 -0
  44. data/lib/aidp/skills/loader.rb +205 -0
  45. data/lib/aidp/skills/registry.rb +222 -0
  46. data/lib/aidp/skills/router.rb +178 -0
  47. data/lib/aidp/skills/skill.rb +174 -0
  48. data/lib/aidp/skills/wizard/builder.rb +141 -0
  49. data/lib/aidp/skills/wizard/controller.rb +145 -0
  50. data/lib/aidp/skills/wizard/differ.rb +232 -0
  51. data/lib/aidp/skills/wizard/prompter.rb +317 -0
  52. data/lib/aidp/skills/wizard/template_library.rb +164 -0
  53. data/lib/aidp/skills/wizard/writer.rb +105 -0
  54. data/lib/aidp/skills.rb +30 -0
  55. data/lib/aidp/version.rb +1 -1
  56. data/lib/aidp/watch/build_processor.rb +93 -28
  57. data/lib/aidp/watch/runner.rb +3 -2
  58. data/lib/aidp/workstream_executor.rb +244 -0
  59. data/lib/aidp/workstream_state.rb +212 -0
  60. data/lib/aidp/worktree.rb +208 -0
  61. data/lib/aidp.rb +6 -0
  62. data/templates/skills/README.md +334 -0
  63. data/templates/skills/architecture_analyst/SKILL.md +173 -0
  64. data/templates/skills/product_strategist/SKILL.md +141 -0
  65. data/templates/skills/repository_analyst/SKILL.md +117 -0
  66. data/templates/skills/test_analyzer/SKILL.md +213 -0
  67. metadata +29 -4
@@ -6,6 +6,7 @@ require "time"
6
6
  require_relative "../message_display"
7
7
  require_relative "../execute/prompt_manager"
8
8
  require_relative "../harness/runner"
9
+ require_relative "../worktree"
9
10
 
10
11
  module Aidp
11
12
  module Watch
@@ -17,10 +18,11 @@ module Aidp
17
18
  BUILD_LABEL = "aidp-build"
18
19
  IMPLEMENTATION_STEP = "16_IMPLEMENTATION"
19
20
 
20
- def initialize(repository_client:, state_store:, project_dir: Dir.pwd)
21
+ def initialize(repository_client:, state_store:, project_dir: Dir.pwd, use_workstreams: true)
21
22
  @repository_client = repository_client
22
23
  @state_store = state_store
23
24
  @project_dir = project_dir
25
+ @use_workstreams = use_workstreams
24
26
  end
25
27
 
26
28
  def process(issue)
@@ -30,27 +32,36 @@ module Aidp
30
32
  plan_data = ensure_plan_data(number)
31
33
  return unless plan_data
32
34
 
35
+ slug = workstream_slug_for(issue)
33
36
  branch_name = branch_name_for(issue)
34
- @state_store.record_build_status(number, status: "running", details: {branch: branch_name, started_at: Time.now.utc.iso8601})
37
+ @state_store.record_build_status(number, status: "running", details: {branch: branch_name, workstream: slug, started_at: Time.now.utc.iso8601})
35
38
 
36
39
  ensure_git_repo!
37
40
  base_branch = detect_base_branch
38
41
 
39
- checkout_branch(base_branch, branch_name)
42
+ if @use_workstreams
43
+ workstream_path = setup_workstream(slug: slug, branch_name: branch_name, base_branch: base_branch)
44
+ working_dir = workstream_path
45
+ else
46
+ checkout_branch(base_branch, branch_name)
47
+ working_dir = @project_dir
48
+ end
49
+
40
50
  prompt_content = build_prompt(issue: issue, plan_data: plan_data)
41
- write_prompt(prompt_content)
51
+ write_prompt(prompt_content, working_dir: working_dir)
42
52
 
43
53
  user_input = build_user_input(issue: issue, plan_data: plan_data)
44
- result = run_harness(user_input: user_input)
54
+ result = run_harness(user_input: user_input, working_dir: working_dir)
45
55
 
46
56
  if result[:status] == "completed"
47
- handle_success(issue: issue, branch_name: branch_name, base_branch: base_branch, plan_data: plan_data)
57
+ handle_success(issue: issue, slug: slug, branch_name: branch_name, base_branch: base_branch, plan_data: plan_data, working_dir: working_dir)
48
58
  else
49
- handle_failure(issue: issue, result: result)
59
+ handle_failure(issue: issue, slug: slug, result: result)
50
60
  end
51
61
  rescue => e
52
62
  display_message("❌ Implementation failed: #{e.message}", type: :error)
53
63
  @state_store.record_build_status(issue[:number], status: "failed", details: {error: e.message})
64
+ cleanup_workstream(slug) if @use_workstreams && slug
54
65
  raise
55
66
  end
56
67
 
@@ -96,9 +107,56 @@ module Aidp
96
107
  display_message("🌿 Checked out #{branch_name}", type: :info)
97
108
  end
98
109
 
99
- def branch_name_for(issue)
110
+ def workstream_slug_for(issue)
100
111
  slug = issue[:title].to_s.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/^-|-$/, "")
101
- "aidp/issue-#{issue[:number]}-#{slug[0, 32]}"
112
+ "issue-#{issue[:number]}-#{slug[0, 32]}"
113
+ end
114
+
115
+ def branch_name_for(issue)
116
+ "aidp/#{workstream_slug_for(issue)}"
117
+ end
118
+
119
+ def setup_workstream(slug:, branch_name:, base_branch:)
120
+ # Check if workstream already exists
121
+ existing = Aidp::Worktree.info(slug: slug, project_dir: @project_dir)
122
+ if existing
123
+ display_message("🔄 Reusing existing workstream: #{slug}", type: :info)
124
+ Dir.chdir(existing[:path]) do
125
+ run_git(["checkout", existing[:branch]])
126
+ run_git(%w[pull --ff-only], allow_failure: true)
127
+ end
128
+ return existing[:path]
129
+ end
130
+
131
+ # Create new workstream
132
+ display_message("🌿 Creating workstream: #{slug}", type: :info)
133
+ result = Aidp::Worktree.create(
134
+ slug: slug,
135
+ project_dir: @project_dir,
136
+ branch: branch_name,
137
+ base_branch: base_branch
138
+ )
139
+
140
+ if result[:success]
141
+ display_message("✅ Workstream created at #{result[:path]}", type: :success)
142
+ result[:path]
143
+ else
144
+ raise "Failed to create workstream: #{result[:message]}"
145
+ end
146
+ end
147
+
148
+ def cleanup_workstream(slug)
149
+ return unless slug
150
+
151
+ display_message("🧹 Cleaning up workstream: #{slug}", type: :info)
152
+ result = Aidp::Worktree.remove(slug: slug, project_dir: @project_dir, force: true)
153
+ if result[:success]
154
+ display_message("✅ Workstream removed", type: :success)
155
+ else
156
+ display_message("⚠️ Failed to remove workstream: #{result[:message]}", type: :warn)
157
+ end
158
+ rescue => e
159
+ display_message("⚠️ Error cleaning up workstream: #{e.message}", type: :warn)
102
160
  end
103
161
 
104
162
  def build_prompt(issue:, plan_data:)
@@ -141,8 +199,8 @@ module Aidp
141
199
  "_Unable to parse comment thread._"
142
200
  end
143
201
 
144
- def write_prompt(content)
145
- prompt_manager = Aidp::Execute::PromptManager.new(@project_dir)
202
+ def write_prompt(content, working_dir: @project_dir)
203
+ prompt_manager = Aidp::Execute::PromptManager.new(working_dir)
146
204
  prompt_manager.write(content)
147
205
  display_message("📝 Wrote PROMPT.md with implementation contract", type: :info)
148
206
  end
@@ -156,23 +214,24 @@ module Aidp
156
214
  }.delete_if { |_k, v| v.nil? || v.empty? }
157
215
  end
158
216
 
159
- def run_harness(user_input:)
217
+ def run_harness(user_input:, working_dir: @project_dir)
160
218
  options = {
161
219
  selected_steps: [IMPLEMENTATION_STEP],
162
220
  workflow_type: :watch_mode,
163
221
  user_input: user_input
164
222
  }
165
- runner = Aidp::Harness::Runner.new(@project_dir, :execute, options)
223
+ runner = Aidp::Harness::Runner.new(working_dir, :execute, options)
166
224
  runner.run
167
225
  end
168
226
 
169
- def handle_success(issue:, branch_name:, base_branch:, plan_data:)
170
- stage_and_commit(issue)
171
- pr_url = create_pull_request(issue: issue, branch_name: branch_name, base_branch: base_branch)
227
+ def handle_success(issue:, slug:, branch_name:, base_branch:, plan_data:, working_dir:)
228
+ stage_and_commit(issue, working_dir: working_dir)
229
+ pr_url = create_pull_request(issue: issue, branch_name: branch_name, base_branch: base_branch, working_dir: working_dir)
172
230
 
231
+ workstream_note = @use_workstreams ? "\n- Workstream: `#{slug}`" : ""
173
232
  comment = <<~COMMENT
174
233
  ✅ Implementation complete for ##{issue[:number]}.
175
- - Branch: `#{branch_name}`
234
+ - Branch: `#{branch_name}`#{workstream_note}
176
235
  - Pull Request: #{pr_url}
177
236
 
178
237
  Summary:
@@ -183,32 +242,38 @@ module Aidp
183
242
  @state_store.record_build_status(
184
243
  issue[:number],
185
244
  status: "completed",
186
- details: {branch: branch_name, pr_url: pr_url}
245
+ details: {branch: branch_name, workstream: slug, pr_url: pr_url}
187
246
  )
188
247
  display_message("🎉 Posted completion comment for issue ##{issue[:number]}", type: :success)
248
+
249
+ # Keep workstream for review - don't auto-cleanup on success
250
+ if @use_workstreams
251
+ display_message("ℹ️ Workstream #{slug} preserved for review. Remove with: aidp ws rm #{slug}", type: :muted)
252
+ end
189
253
  end
190
254
 
191
- def handle_failure(issue:, result:)
255
+ def handle_failure(issue:, slug:, result:)
192
256
  message = result[:message] || "Unknown failure"
257
+ workstream_note = @use_workstreams ? " The workstream `#{slug}` has been left intact for debugging." : " The branch has been left intact for debugging."
193
258
  comment = <<~COMMENT
194
259
  ❌ Implementation attempt for ##{issue[:number]} failed.
195
260
 
196
261
  Status: #{result[:status]}
197
262
  Details: #{message}
198
263
 
199
- Please review the repository for partial changes. The branch has been left intact for debugging.
264
+ Please review the repository for partial changes.#{workstream_note}
200
265
  COMMENT
201
266
  @repository_client.post_comment(issue[:number], comment)
202
267
  @state_store.record_build_status(
203
268
  issue[:number],
204
269
  status: "failed",
205
- details: {message: message}
270
+ details: {message: message, workstream: slug}
206
271
  )
207
272
  display_message("⚠️ Build failure recorded for issue ##{issue[:number]}", type: :warn)
208
273
  end
209
274
 
210
- def stage_and_commit(issue)
211
- Dir.chdir(@project_dir) do
275
+ def stage_and_commit(issue, working_dir: @project_dir)
276
+ Dir.chdir(working_dir) do
212
277
  status_output = run_git(%w[status --porcelain])
213
278
  if status_output.strip.empty?
214
279
  display_message("ℹ️ No file changes detected after work loop.", type: :muted)
@@ -222,9 +287,9 @@ module Aidp
222
287
  end
223
288
  end
224
289
 
225
- def create_pull_request(issue:, branch_name:, base_branch:)
290
+ def create_pull_request(issue:, branch_name:, base_branch:, working_dir: @project_dir)
226
291
  title = "aidp: Resolve ##{issue[:number]} - #{issue[:title]}"
227
- test_summary = gather_test_summary
292
+ test_summary = gather_test_summary(working_dir: working_dir)
228
293
  body = <<~BODY
229
294
  ## Summary
230
295
  - Automated resolution for ##{issue[:number]}
@@ -244,8 +309,8 @@ module Aidp
244
309
  extract_pr_url(output)
245
310
  end
246
311
 
247
- def gather_test_summary
248
- Dir.chdir(@project_dir) do
312
+ def gather_test_summary(working_dir: @project_dir)
313
+ Dir.chdir(working_dir) do
249
314
  log_path = File.join(".aidp", "logs", "test_runner.log")
250
315
  return "- Fix-forward harness executed; refer to #{log_path}" unless File.exist?(log_path)
251
316
 
@@ -257,7 +322,7 @@ module Aidp
257
322
  end
258
323
  end
259
324
  rescue
260
- "- Fix-forward harness executed successfully."
325
+ "- Fix-forward harness extracted successfully."
261
326
  end
262
327
 
263
328
  def extract_pr_url(output)
@@ -18,7 +18,7 @@ module Aidp
18
18
 
19
19
  DEFAULT_INTERVAL = 30
20
20
 
21
- def initialize(issues_url:, interval: DEFAULT_INTERVAL, provider_name: nil, gh_available: nil, project_dir: Dir.pwd, once: false, prompt: TTY::Prompt.new)
21
+ def initialize(issues_url:, interval: DEFAULT_INTERVAL, provider_name: nil, gh_available: nil, project_dir: Dir.pwd, once: false, use_workstreams: true, prompt: TTY::Prompt.new)
22
22
  @prompt = prompt
23
23
  @interval = interval
24
24
  @once = once
@@ -35,7 +35,8 @@ module Aidp
35
35
  @build_processor = BuildProcessor.new(
36
36
  repository_client: @repository_client,
37
37
  state_store: @state_store,
38
- project_dir: project_dir
38
+ project_dir: project_dir,
39
+ use_workstreams: use_workstreams
39
40
  )
40
41
  end
41
42
 
@@ -0,0 +1,244 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "concurrent-ruby"
4
+ require "time"
5
+ require_relative "worktree"
6
+ require_relative "workstream_state"
7
+ require_relative "harness/runner"
8
+ require_relative "message_display"
9
+
10
+ module Aidp
11
+ # Executes multiple workstreams in parallel using concurrent-ruby.
12
+ # Provides true parallel execution with process isolation and status tracking.
13
+ class WorkstreamExecutor
14
+ include Aidp::MessageDisplay
15
+
16
+ # Result from executing a workstream
17
+ WorkstreamResult = Struct.new(
18
+ :slug,
19
+ :status,
20
+ :exit_code,
21
+ :started_at,
22
+ :completed_at,
23
+ :duration,
24
+ :error,
25
+ keyword_init: true
26
+ )
27
+
28
+ def initialize(project_dir: Dir.pwd, max_concurrent: 3)
29
+ @project_dir = project_dir
30
+ @max_concurrent = max_concurrent
31
+ @results = Concurrent::Hash.new
32
+ @start_times = Concurrent::Hash.new
33
+ end
34
+
35
+ # Execute multiple workstreams in parallel
36
+ #
37
+ # @param slugs [Array<String>] Workstream slugs to execute
38
+ # @param options [Hash] Execution options
39
+ # @option options [Array<String>] :selected_steps Steps to execute
40
+ # @option options [Symbol] :workflow_type Workflow type (:execute, :analyze, etc.)
41
+ # @option options [Hash] :user_input User input for harness
42
+ # @return [Array<WorkstreamResult>] Results for each workstream
43
+ def execute_parallel(slugs, options = {})
44
+ validate_workstreams!(slugs)
45
+
46
+ display_message("🚀 Starting parallel execution of #{slugs.size} workstreams (max #{@max_concurrent} concurrent)", type: :info)
47
+
48
+ # Create thread pool with max concurrent limit
49
+ pool = Concurrent::FixedThreadPool.new(@max_concurrent)
50
+
51
+ # Create futures for each workstream
52
+ futures = slugs.map do |slug|
53
+ Concurrent::Future.execute(executor: pool) do
54
+ execute_workstream(slug, options)
55
+ end
56
+ end
57
+
58
+ # Wait for all futures to complete
59
+ results = futures.map(&:value)
60
+
61
+ # Shutdown pool gracefully
62
+ pool.shutdown
63
+ pool.wait_for_termination(30)
64
+
65
+ display_execution_summary(results)
66
+ results
67
+ end
68
+
69
+ # Execute all active workstreams in parallel
70
+ #
71
+ # @param options [Hash] Execution options (same as execute_parallel)
72
+ # @return [Array<WorkstreamResult>] Results for each workstream
73
+ def execute_all(options = {})
74
+ workstreams = Aidp::Worktree.list(project_dir: @project_dir)
75
+ active_slugs = workstreams.select { |ws| ws[:active] }.map { |ws| ws[:slug] }
76
+
77
+ if active_slugs.empty?
78
+ display_message("⚠️ No active workstreams found", type: :warn)
79
+ return []
80
+ end
81
+
82
+ execute_parallel(active_slugs, options)
83
+ end
84
+
85
+ # Execute a single workstream (used by futures in parallel execution)
86
+ #
87
+ # @param slug [String] Workstream slug
88
+ # @param options [Hash] Execution options
89
+ # @return [WorkstreamResult] Execution result
90
+ def execute_workstream(slug, options = {})
91
+ started_at = Time.now
92
+ @start_times[slug] = started_at
93
+
94
+ workstream = Aidp::Worktree.info(slug: slug, project_dir: @project_dir)
95
+ unless workstream
96
+ return WorkstreamResult.new(
97
+ slug: slug,
98
+ status: "error",
99
+ exit_code: 1,
100
+ started_at: started_at,
101
+ completed_at: Time.now,
102
+ duration: 0,
103
+ error: "Workstream not found"
104
+ )
105
+ end
106
+
107
+ display_message("▶️ [#{slug}] Starting execution in #{workstream[:path]}", type: :info)
108
+
109
+ # Update workstream state to active
110
+ Aidp::WorkstreamState.update(
111
+ slug: slug,
112
+ project_dir: @project_dir,
113
+ status: "active",
114
+ started_at: started_at.utc.iso8601
115
+ )
116
+
117
+ # Execute in forked process for true isolation
118
+ pid = fork do
119
+ # Change to workstream directory
120
+ Dir.chdir(workstream[:path])
121
+
122
+ # Execute harness
123
+ runner = Aidp::Harness::Runner.new(
124
+ workstream[:path],
125
+ options[:mode] || :execute,
126
+ options
127
+ )
128
+
129
+ result = runner.run
130
+
131
+ # Update state on completion
132
+ exit_code = (result[:status] == "completed") ? 0 : 1
133
+ final_status = (result[:status] == "completed") ? "completed" : "failed"
134
+
135
+ Aidp::WorkstreamState.update(
136
+ slug: slug,
137
+ project_dir: @project_dir,
138
+ status: final_status,
139
+ completed_at: Time.now.utc.iso8601
140
+ )
141
+
142
+ exit(exit_code)
143
+ rescue => e
144
+ # Update state on error
145
+ Aidp::WorkstreamState.update(
146
+ slug: slug,
147
+ project_dir: @project_dir,
148
+ status: "failed",
149
+ completed_at: Time.now.utc.iso8601
150
+ )
151
+
152
+ # Log error and exit
153
+ warn("Error in workstream #{slug}: #{e.message}")
154
+ warn(e.backtrace.first(5).join("\n"))
155
+ exit(1)
156
+ end
157
+
158
+ # Wait for child process
159
+ _pid, status = Process.wait2(pid)
160
+ completed_at = Time.now
161
+ duration = completed_at - started_at
162
+
163
+ # Build result
164
+ result_status = status.success? ? "completed" : "failed"
165
+ result = WorkstreamResult.new(
166
+ slug: slug,
167
+ status: result_status,
168
+ exit_code: status.exitstatus,
169
+ started_at: started_at,
170
+ completed_at: completed_at,
171
+ duration: duration,
172
+ error: status.success? ? nil : "Process exited with code #{status.exitstatus}"
173
+ )
174
+
175
+ @results[slug] = result
176
+
177
+ display_message("#{status.success? ? "✅" : "❌"} [#{slug}] #{result_status.capitalize} in #{format_duration(duration)}", type: status.success? ? :success : :error)
178
+
179
+ result
180
+ rescue => e
181
+ completed_at = Time.now
182
+ duration = completed_at - started_at
183
+
184
+ WorkstreamResult.new(
185
+ slug: slug,
186
+ status: "error",
187
+ exit_code: 1,
188
+ started_at: started_at,
189
+ completed_at: completed_at,
190
+ duration: duration,
191
+ error: e.message
192
+ )
193
+ end
194
+
195
+ private
196
+
197
+ # Validate that all workstreams exist
198
+ def validate_workstreams!(slugs)
199
+ invalid = slugs.reject do |slug|
200
+ Aidp::Worktree.exists?(slug: slug, project_dir: @project_dir)
201
+ end
202
+
203
+ unless invalid.empty?
204
+ raise ArgumentError, "Workstreams not found: #{invalid.join(", ")}"
205
+ end
206
+ end
207
+
208
+ # Display execution summary
209
+ def display_execution_summary(results)
210
+ completed = results.count { |r| r.status == "completed" }
211
+ failed = results.count { |r| r.status == "failed" || r.status == "error" }
212
+ total_duration = results.sum(&:duration)
213
+
214
+ display_message("\n" + "=" * 60, type: :muted)
215
+ display_message("📊 Execution Summary", type: :info)
216
+ display_message("Total: #{results.size} | Completed: #{completed} | Failed: #{failed}", type: :info)
217
+ display_message("Total Duration: #{format_duration(total_duration)}", type: :info)
218
+
219
+ if failed > 0
220
+ display_message("\n❌ Failed Workstreams:", type: :error)
221
+ results.select { |r| r.status != "completed" }.each do |result|
222
+ display_message(" - #{result.slug}: #{result.error}", type: :error)
223
+ end
224
+ end
225
+
226
+ display_message("=" * 60, type: :muted)
227
+ end
228
+
229
+ # Format duration in human-readable format
230
+ def format_duration(seconds)
231
+ if seconds < 60
232
+ "#{seconds.round(1)}s"
233
+ elsif seconds < 3600
234
+ minutes = (seconds / 60).floor
235
+ secs = (seconds % 60).round
236
+ "#{minutes}m #{secs}s"
237
+ else
238
+ hours = (seconds / 3600).floor
239
+ minutes = ((seconds % 3600) / 60).floor
240
+ "#{hours}h #{minutes}m"
241
+ end
242
+ end
243
+ end
244
+ end