aidp 0.15.2 → 0.16.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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +47 -0
  3. data/lib/aidp/analyze/error_handler.rb +14 -15
  4. data/lib/aidp/analyze/runner.rb +27 -5
  5. data/lib/aidp/analyze/steps.rb +4 -0
  6. data/lib/aidp/cli/jobs_command.rb +2 -1
  7. data/lib/aidp/cli.rb +812 -3
  8. data/lib/aidp/concurrency/backoff.rb +148 -0
  9. data/lib/aidp/concurrency/exec.rb +192 -0
  10. data/lib/aidp/concurrency/wait.rb +148 -0
  11. data/lib/aidp/concurrency.rb +71 -0
  12. data/lib/aidp/config.rb +20 -0
  13. data/lib/aidp/daemon/runner.rb +9 -8
  14. data/lib/aidp/debug_mixin.rb +1 -0
  15. data/lib/aidp/errors.rb +12 -0
  16. data/lib/aidp/execute/interactive_repl.rb +102 -11
  17. data/lib/aidp/execute/repl_macros.rb +776 -2
  18. data/lib/aidp/execute/runner.rb +27 -5
  19. data/lib/aidp/execute/steps.rb +2 -0
  20. data/lib/aidp/harness/config_loader.rb +24 -2
  21. data/lib/aidp/harness/enhanced_runner.rb +16 -2
  22. data/lib/aidp/harness/error_handler.rb +1 -1
  23. data/lib/aidp/harness/provider_info.rb +19 -15
  24. data/lib/aidp/harness/provider_manager.rb +47 -41
  25. data/lib/aidp/harness/runner.rb +3 -11
  26. data/lib/aidp/harness/state/persistence.rb +1 -6
  27. data/lib/aidp/harness/state_manager.rb +115 -7
  28. data/lib/aidp/harness/status_display.rb +11 -18
  29. data/lib/aidp/harness/ui/navigation/submenu.rb +1 -0
  30. data/lib/aidp/harness/ui/workflow_controller.rb +1 -1
  31. data/lib/aidp/harness/user_interface.rb +12 -15
  32. data/lib/aidp/jobs/background_runner.rb +15 -5
  33. data/lib/aidp/providers/codex.rb +0 -1
  34. data/lib/aidp/providers/cursor.rb +0 -1
  35. data/lib/aidp/providers/github_copilot.rb +0 -1
  36. data/lib/aidp/providers/opencode.rb +0 -1
  37. data/lib/aidp/skills/composer.rb +178 -0
  38. data/lib/aidp/skills/loader.rb +205 -0
  39. data/lib/aidp/skills/registry.rb +220 -0
  40. data/lib/aidp/skills/skill.rb +174 -0
  41. data/lib/aidp/skills.rb +30 -0
  42. data/lib/aidp/version.rb +1 -1
  43. data/lib/aidp/watch/build_processor.rb +93 -28
  44. data/lib/aidp/watch/runner.rb +3 -2
  45. data/lib/aidp/workstream_executor.rb +244 -0
  46. data/lib/aidp/workstream_state.rb +212 -0
  47. data/lib/aidp/worktree.rb +208 -0
  48. data/lib/aidp.rb +6 -0
  49. metadata +17 -4
@@ -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
@@ -0,0 +1,212 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+
6
+ module Aidp
7
+ # Manages per-workstream state (task, iterations, timestamps, event log)
8
+ # Stored under: .aidp/workstreams/<slug>/state.json and history.jsonl
9
+ module WorkstreamState
10
+ class Error < StandardError; end
11
+
12
+ class << self
13
+ def root_dir(project_dir)
14
+ File.join(project_dir, ".aidp", "workstreams")
15
+ end
16
+
17
+ def workstream_dir(slug, project_dir)
18
+ File.join(root_dir(project_dir), slug)
19
+ end
20
+
21
+ def state_file(slug, project_dir)
22
+ File.join(workstream_dir(slug, project_dir), "state.json")
23
+ end
24
+
25
+ def history_file(slug, project_dir)
26
+ File.join(workstream_dir(slug, project_dir), "history.jsonl")
27
+ end
28
+
29
+ # Per-worktree state files (mirrored for local inspection)
30
+ def worktree_state_file(slug, project_dir)
31
+ worktree_path = File.join(project_dir, ".worktrees", slug)
32
+ return nil unless Dir.exist?(worktree_path)
33
+ File.join(worktree_path, ".aidp", "workstreams", slug, "state.json")
34
+ end
35
+
36
+ def worktree_history_file(slug, project_dir)
37
+ worktree_path = File.join(project_dir, ".worktrees", slug)
38
+ return nil unless Dir.exist?(worktree_path)
39
+ File.join(worktree_path, ".aidp", "workstreams", slug, "history.jsonl")
40
+ end
41
+
42
+ # Initialize state for a new workstream
43
+ def init(slug:, project_dir:, task: nil)
44
+ dir = workstream_dir(slug, project_dir)
45
+ FileUtils.mkdir_p(dir)
46
+ now = Time.now.utc
47
+ state = {
48
+ slug: slug,
49
+ status: "active",
50
+ task: task,
51
+ started_at: now.iso8601,
52
+ updated_at: now.iso8601,
53
+ iterations: 0
54
+ }
55
+ write_json(state_file(slug, project_dir), state)
56
+ # Mirror to worktree if it exists
57
+ mirror_to_worktree(slug, project_dir, state)
58
+ append_event(slug: slug, project_dir: project_dir, type: "created", data: {task: task})
59
+ state
60
+ end
61
+
62
+ # Read current state (returns hash or nil)
63
+ def read(slug:, project_dir:)
64
+ file = state_file(slug, project_dir)
65
+ return nil unless File.exist?(file)
66
+ JSON.parse(File.read(file), symbolize_names: true)
67
+ rescue JSON::ParserError
68
+ nil
69
+ end
70
+
71
+ # Update selected attributes; updates updated_at automatically
72
+ def update(slug:, project_dir:, **attrs)
73
+ state = read(slug: slug, project_dir: project_dir) || init(slug: slug, project_dir: project_dir)
74
+ state.merge!(attrs.transform_keys(&:to_sym))
75
+ state[:updated_at] = Time.now.utc.iso8601
76
+ write_json(state_file(slug, project_dir), state)
77
+ # Mirror to worktree if it exists
78
+ mirror_to_worktree(slug, project_dir, state)
79
+ state
80
+ end
81
+
82
+ # Increment iteration counter and record event
83
+ def increment_iteration(slug:, project_dir:)
84
+ state = read(slug: slug, project_dir: project_dir) || init(slug: slug, project_dir: project_dir)
85
+ state[:iterations] = (state[:iterations] || 0) + 1
86
+ state[:updated_at] = Time.now.utc.iso8601
87
+ # Update status to active if paused (auto-resume on iteration)
88
+ state[:status] = "active" if state[:status] == "paused"
89
+ write_json(state_file(slug, project_dir), state)
90
+ # Mirror to worktree if it exists
91
+ mirror_to_worktree(slug, project_dir, state)
92
+ append_event(slug: slug, project_dir: project_dir, type: "iteration", data: {count: state[:iterations]})
93
+ state
94
+ end
95
+
96
+ # Append event to history.jsonl
97
+ def append_event(slug:, project_dir:, type:, data: {})
98
+ file = history_file(slug, project_dir)
99
+ FileUtils.mkdir_p(File.dirname(file))
100
+ event = {
101
+ timestamp: Time.now.utc.iso8601,
102
+ type: type,
103
+ data: data
104
+ }
105
+ File.open(file, "a") { |f| f.puts(JSON.generate(event)) }
106
+ # Mirror to worktree if it exists
107
+ wt_file = worktree_history_file(slug, project_dir)
108
+ if wt_file
109
+ FileUtils.mkdir_p(File.dirname(wt_file))
110
+ File.open(wt_file, "a") { |f| f.puts(JSON.generate(event)) }
111
+ end
112
+ event
113
+ end
114
+
115
+ # Read recent N events
116
+ def recent_events(slug:, project_dir:, limit: 5)
117
+ file = history_file(slug, project_dir)
118
+ return [] unless File.exist?(file)
119
+ lines = File.readlines(file, chomp: true)
120
+ lines.last(limit).map do |line|
121
+ JSON.parse(line, symbolize_names: true)
122
+ rescue JSON::ParserError
123
+ nil
124
+ end.compact
125
+ end
126
+
127
+ def elapsed_seconds(slug:, project_dir:)
128
+ state = read(slug: slug, project_dir: project_dir)
129
+ return 0 unless state && state[:started_at]
130
+ (Time.now.utc - Time.parse(state[:started_at])).to_i
131
+ end
132
+
133
+ # Check if workstream appears stalled (no activity for threshold seconds)
134
+ def stalled?(slug:, project_dir:, threshold_seconds: 3600)
135
+ state = read(slug: slug, project_dir: project_dir)
136
+ return false unless state && state[:updated_at]
137
+ return false if state[:status] != "active" # Only check active workstreams
138
+ (Time.now.utc - Time.parse(state[:updated_at])).to_i > threshold_seconds
139
+ end
140
+
141
+ # Auto-complete stalled workstreams
142
+ def auto_complete_stalled(slug:, project_dir:, threshold_seconds: 3600)
143
+ return unless stalled?(slug: slug, project_dir: project_dir, threshold_seconds: threshold_seconds)
144
+ complete(slug: slug, project_dir: project_dir)
145
+ append_event(slug: slug, project_dir: project_dir, type: "auto_completed", data: {reason: "stalled"})
146
+ end
147
+
148
+ def mark_removed(slug:, project_dir:)
149
+ state = read(slug: slug, project_dir: project_dir)
150
+ # Auto-complete if active when removing
151
+ if state && state[:status] == "active"
152
+ complete(slug: slug, project_dir: project_dir)
153
+ end
154
+ update(slug: slug, project_dir: project_dir, status: "removed")
155
+ append_event(slug: slug, project_dir: project_dir, type: "removed", data: {})
156
+ end
157
+
158
+ # Pause workstream (stop iteration without completion)
159
+ def pause(slug:, project_dir:)
160
+ state = read(slug: slug, project_dir: project_dir)
161
+ return {error: "Workstream not found"} unless state
162
+ return {error: "Already paused"} if state[:status] == "paused"
163
+
164
+ now = Time.now.utc.iso8601
165
+ update(slug: slug, project_dir: project_dir, status: "paused", paused_at: now)
166
+ append_event(slug: slug, project_dir: project_dir, type: "paused", data: {})
167
+ {status: "paused"}
168
+ end
169
+
170
+ # Resume workstream (return to active status)
171
+ def resume(slug:, project_dir:)
172
+ state = read(slug: slug, project_dir: project_dir)
173
+ return {error: "Workstream not found"} unless state
174
+ return {error: "Not paused"} unless state[:status] == "paused"
175
+
176
+ now = Time.now.utc.iso8601
177
+ update(slug: slug, project_dir: project_dir, status: "active", resumed_at: now)
178
+ append_event(slug: slug, project_dir: project_dir, type: "resumed", data: {})
179
+ {status: "active"}
180
+ end
181
+
182
+ # Mark workstream as completed
183
+ def complete(slug:, project_dir:)
184
+ state = read(slug: slug, project_dir: project_dir)
185
+ return {error: "Workstream not found"} unless state
186
+ return {error: "Already completed"} if state[:status] == "completed"
187
+
188
+ now = Time.now.utc.iso8601
189
+ update(slug: slug, project_dir: project_dir, status: "completed", completed_at: now)
190
+ append_event(slug: slug, project_dir: project_dir, type: "completed", data: {iterations: state[:iterations]})
191
+ {status: "completed"}
192
+ end
193
+
194
+ private
195
+
196
+ def write_json(path, obj)
197
+ File.write(path, JSON.pretty_generate(obj))
198
+ end
199
+
200
+ # Mirror state to worktree's .aidp directory for local visibility
201
+ def mirror_to_worktree(slug, project_dir, state)
202
+ wt_file = worktree_state_file(slug, project_dir)
203
+ return unless wt_file
204
+ FileUtils.mkdir_p(File.dirname(wt_file))
205
+ write_json(wt_file, state)
206
+ rescue
207
+ # Silently ignore mirroring errors to not disrupt main operation
208
+ nil
209
+ end
210
+ end
211
+ end
212
+ end
@@ -0,0 +1,208 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require_relative "workstream_state"
6
+
7
+ module Aidp
8
+ # Manages git worktree operations for parallel workstreams.
9
+ # Each workstream gets an isolated git worktree with its own branch,
10
+ # allowing multiple agents to work concurrently without conflicts.
11
+ module Worktree
12
+ class Error < StandardError; end
13
+ class NotInGitRepo < Error; end
14
+ class WorktreeExists < Error; end
15
+ class WorktreeNotFound < Error; end
16
+
17
+ class << self
18
+ # Create a new git worktree for a workstream
19
+ #
20
+ # @param slug [String] Short identifier for this workstream (e.g., "iss-123-fix-login")
21
+ # @param project_dir [String] Project root directory
22
+ # @param branch [String, nil] Branch name (defaults to "aidp/#{slug}")
23
+ # @param base_branch [String] Branch to create from (defaults to current branch)
24
+ # @return [Hash] Worktree info: { path:, branch:, slug: }
25
+ def create(slug:, project_dir: Dir.pwd, branch: nil, base_branch: nil, task: nil)
26
+ ensure_git_repo!(project_dir)
27
+
28
+ branch ||= "aidp/#{slug}"
29
+ worktree_path = worktree_path_for(slug, project_dir)
30
+
31
+ if Dir.exist?(worktree_path)
32
+ raise WorktreeExists, "Worktree already exists at #{worktree_path}"
33
+ end
34
+
35
+ # Create the worktree
36
+ cmd = ["git", "worktree", "add", "-b", branch, worktree_path]
37
+ cmd << base_branch if base_branch
38
+
39
+ Dir.chdir(project_dir) do
40
+ success = system(*cmd, out: File::NULL, err: File::NULL)
41
+ unless success
42
+ raise Error, "Failed to create worktree: #{$?.exitstatus}"
43
+ end
44
+ end
45
+
46
+ # Initialize .aidp directory in the worktree
47
+ ensure_aidp_dir(worktree_path)
48
+
49
+ # Register the worktree
50
+ register_worktree(slug, worktree_path, branch, project_dir)
51
+
52
+ # Initialize per-workstream state (task, counters, events)
53
+ Aidp::WorkstreamState.init(slug: slug, project_dir: project_dir, task: task)
54
+
55
+ {
56
+ slug: slug,
57
+ path: worktree_path,
58
+ branch: branch
59
+ }
60
+ end
61
+
62
+ # List all active worktrees for this project
63
+ #
64
+ # @param project_dir [String] Project root directory
65
+ # @return [Array<Hash>] Array of worktree info hashes
66
+ def list(project_dir: Dir.pwd)
67
+ registry = load_registry(project_dir)
68
+ registry.map do |slug, info|
69
+ {
70
+ slug: slug,
71
+ path: info["path"],
72
+ branch: info["branch"],
73
+ created_at: info["created_at"],
74
+ active: Dir.exist?(info["path"])
75
+ }
76
+ end
77
+ end
78
+
79
+ # Remove a worktree and optionally its branch
80
+ #
81
+ # @param slug [String] Workstream identifier
82
+ # @param project_dir [String] Project root directory
83
+ # @param delete_branch [Boolean] Whether to delete the git branch
84
+ def remove(slug:, project_dir: Dir.pwd, delete_branch: false)
85
+ registry = load_registry(project_dir)
86
+ info = registry[slug]
87
+
88
+ raise WorktreeNotFound, "Worktree '#{slug}' not found" unless info
89
+
90
+ worktree_path = info["path"]
91
+ branch = info["branch"]
92
+
93
+ # Remove the git worktree
94
+ if Dir.exist?(worktree_path)
95
+ Dir.chdir(project_dir) do
96
+ system("git", "worktree", "remove", worktree_path, "--force", out: File::NULL, err: File::NULL)
97
+ end
98
+ end
99
+
100
+ # Remove the branch if requested
101
+ if delete_branch
102
+ Dir.chdir(project_dir) do
103
+ system("git", "branch", "-D", branch, out: File::NULL, err: File::NULL)
104
+ end
105
+ end
106
+
107
+ # Mark state removed (if exists) then unregister
108
+ if Aidp::WorkstreamState.read(slug: slug, project_dir: project_dir)
109
+ Aidp::WorkstreamState.mark_removed(slug: slug, project_dir: project_dir)
110
+ end
111
+ # Unregister the worktree
112
+ unregister_worktree(slug, project_dir)
113
+
114
+ true
115
+ end
116
+
117
+ # Get info for a specific worktree
118
+ #
119
+ # @param slug [String] Workstream identifier
120
+ # @param project_dir [String] Project root directory
121
+ # @return [Hash, nil] Worktree info or nil if not found
122
+ def info(slug:, project_dir: Dir.pwd)
123
+ registry = load_registry(project_dir)
124
+ data = registry[slug]
125
+ return nil unless data
126
+
127
+ {
128
+ slug: slug,
129
+ path: data["path"],
130
+ branch: data["branch"],
131
+ created_at: data["created_at"],
132
+ active: Dir.exist?(data["path"])
133
+ }
134
+ end
135
+
136
+ # Check if a worktree exists
137
+ #
138
+ # @param slug [String] Workstream identifier
139
+ # @param project_dir [String] Project root directory
140
+ # @return [Boolean]
141
+ def exists?(slug:, project_dir: Dir.pwd)
142
+ !info(slug: slug, project_dir: project_dir).nil?
143
+ end
144
+
145
+ private
146
+
147
+ # Ensure we're in a git repository
148
+ def ensure_git_repo!(project_dir)
149
+ Dir.chdir(project_dir) do
150
+ unless system("git", "rev-parse", "--git-dir", out: File::NULL, err: File::NULL)
151
+ raise NotInGitRepo, "Not in a git repository: #{project_dir}"
152
+ end
153
+ end
154
+ end
155
+
156
+ # Get the worktree path for a slug
157
+ def worktree_path_for(slug, project_dir)
158
+ File.join(project_dir, ".worktrees", slug)
159
+ end
160
+
161
+ # Ensure the .aidp directory exists in a worktree
162
+ def ensure_aidp_dir(worktree_path)
163
+ aidp_dir = File.join(worktree_path, ".aidp")
164
+ FileUtils.mkdir_p(aidp_dir) unless Dir.exist?(aidp_dir)
165
+ end
166
+
167
+ # Load the worktree registry
168
+ def load_registry(project_dir)
169
+ registry_file = registry_file_path(project_dir)
170
+ return {} unless File.exist?(registry_file)
171
+
172
+ JSON.parse(File.read(registry_file))
173
+ rescue JSON::ParserError
174
+ {}
175
+ end
176
+
177
+ # Save the worktree registry
178
+ def save_registry(registry, project_dir)
179
+ registry_file = registry_file_path(project_dir)
180
+ FileUtils.mkdir_p(File.dirname(registry_file))
181
+ File.write(registry_file, JSON.pretty_generate(registry))
182
+ end
183
+
184
+ # Register a new worktree
185
+ def register_worktree(slug, path, branch, project_dir)
186
+ registry = load_registry(project_dir)
187
+ registry[slug] = {
188
+ "path" => path,
189
+ "branch" => branch,
190
+ "created_at" => Time.now.utc.iso8601
191
+ }
192
+ save_registry(registry, project_dir)
193
+ end
194
+
195
+ # Unregister a worktree
196
+ def unregister_worktree(slug, project_dir)
197
+ registry = load_registry(project_dir)
198
+ registry.delete(slug)
199
+ save_registry(registry, project_dir)
200
+ end
201
+
202
+ # Path to the worktree registry file
203
+ def registry_file_path(project_dir)
204
+ File.join(project_dir, ".aidp", "worktrees.json")
205
+ end
206
+ end
207
+ end
208
+ end
data/lib/aidp.rb CHANGED
@@ -8,6 +8,7 @@ require_relative "aidp/version"
8
8
  require_relative "aidp/config"
9
9
  require_relative "aidp/util"
10
10
  require_relative "aidp/message_display"
11
+ require_relative "aidp/concurrency"
11
12
  require_relative "aidp/setup/wizard"
12
13
  require_relative "aidp/init"
13
14
  require_relative "aidp/watch"
@@ -69,6 +70,11 @@ require_relative "aidp/logger"
69
70
  require_relative "aidp/daemon/process_manager"
70
71
  require_relative "aidp/daemon/runner"
71
72
 
73
+ # Workstream/worktree management
74
+ require_relative "aidp/worktree"
75
+ require_relative "aidp/workstream_state"
76
+ require_relative "aidp/workstream_executor"
77
+
72
78
  # Harness mode
73
79
  require_relative "aidp/harness/configuration"
74
80
  require_relative "aidp/harness/config_schema"