aidp 0.14.2 → 0.15.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e9ed49c611f18d153eef9794e7ad5077a4bd369988b21dc6849b26b4f9489691
4
- data.tar.gz: 97d9812b8cb6437471b4e4bdfdff218b588e20d0187fe6878dd03fdc6fed9b25
3
+ metadata.gz: 9a7dbc13b3136399f56fc64c00753506be8cbb35d5d3bf0f932799cc12a10504
4
+ data.tar.gz: 20afb73742ef4379f51fc05a9ae9cfb71ec95269856d79da0c0ce48c07c0aa39
5
5
  SHA512:
6
- metadata.gz: f88b7ff6548415eb12a364782a7b2f1a8dfdbc2983c9a776a4714c8d2cc0c58501c4180795ff62734b6dee26e2448d0cbfcab1a1a0e5ecb577b49bb24d2184b9
7
- data.tar.gz: 67867d11f4909e48f2b6954f966e06291a3b4e3ad2196fc993bf5ebe0ad5f411547f6ed0bdd76bb864a0f0850148a87a2d0ba5265207a447788bb020f72b29b4
6
+ metadata.gz: 41837e458506b1dc31724b611d46792d446da2f9b0303a37ded28fddbb8f29cfbaeeb417fddc24a3f34a0ed40f5ef3b9d96bf6c827c548be92436ca5ead751c2
7
+ data.tar.gz: fd57d77e5f8a85c38b3343c4db7a96ec7764066c420e2810d650ef97b51b4d47461c6779d3d9d856baa6730e15a204708b0c0b151d2bc4c00b71d86df68f988d
data/README.md CHANGED
@@ -20,11 +20,8 @@ aidp config --interactive
20
20
  aidp init
21
21
  # Creates LLM_STYLE_GUIDE.md, PROJECT_ANALYSIS.md, CODE_QUALITY_PLAN.md
22
22
 
23
- # Start an interactive workflow
24
- aidp execute
25
-
26
- # Or run in background
27
- aidp execute --background
23
+ # Start Copilot interactive mode (default)
24
+ aidp
28
25
  ```
29
26
 
30
27
  ### First-Time Setup
@@ -58,16 +55,12 @@ AIDP implements **work loops** - an iterative execution pattern where AI agents
58
55
 
59
56
  See [Work Loops Guide](docs/WORK_LOOPS_GUIDE.md) for details.
60
57
 
61
- ### Background Execution
58
+ ### Job Management
62
59
 
63
- Run workflows in the background while monitoring progress from separate terminals:
60
+ Monitor and control background jobs:
64
61
 
65
62
  ```bash
66
- # Start in background
67
- aidp execute --background
68
- ✓ Started background job: 20251005_235912_a1b2c3d4
69
-
70
- # Monitor progress
63
+ # List and manage jobs
71
64
  aidp jobs list # List all jobs
72
65
  aidp jobs status <job_id> # Show job status
73
66
  aidp jobs logs <job_id> --tail # View recent logs
@@ -102,17 +95,14 @@ aidp checkpoint history 20
102
95
 
103
96
  ## Command Reference
104
97
 
105
- ### Execution Modes
98
+ ### Copilot Mode
106
99
 
107
100
  ```bash
108
- # Execute mode - Build new features
109
- aidp execute # Interactive workflow selection
110
- aidp execute --background # Run in background
111
- aidp execute --background --follow # Start and follow logs
112
-
113
- # Analyze mode - Analyze codebase
114
- aidp analyze # Interactive analysis
115
- aidp analyze --background # Background analysis
101
+ # Start interactive Copilot mode (default)
102
+ aidp # AI-guided workflow selection
103
+
104
+ # Copilot can perform both analysis and development based on your needs
105
+ # It will interactively help you choose the right approach
116
106
  ```
117
107
 
118
108
  ### Job Management
@@ -207,6 +197,21 @@ harness:
207
197
  - "bundle exec rspec"
208
198
  lint_commands:
209
199
  - "bundle exec standardrb"
200
+ units:
201
+ deterministic:
202
+ - name: run_full_tests
203
+ command: "bundle exec rake spec"
204
+ enabled: false
205
+ next:
206
+ success: agentic
207
+ failure: decide_whats_next
208
+ - name: wait_for_github
209
+ type: "wait"
210
+ metadata:
211
+ interval_seconds: 60
212
+ defaults:
213
+ on_no_next_step: wait_for_github
214
+ fallback_agentic: decide_whats_next
210
215
 
211
216
  providers:
212
217
  claude:
@@ -329,40 +334,36 @@ The questions file is only created when the AI needs additional information beyo
329
334
  ### Standard Interactive Workflow
330
335
 
331
336
  ```bash
332
- # Start execute mode
333
- aidp execute
334
-
335
- # Select workflow type (e.g., "Full PRD to Implementation")
336
- # Answer any questions interactively
337
- # Review generated files (PRD, architecture, etc.)
338
- # Workflow runs automatically with harness managing retries
337
+ # Start Copilot mode (default)
338
+ aidp
339
+
340
+ # Copilot will guide you through:
341
+ # - Understanding your project goals
342
+ # - Selecting the right workflow (analysis, development, or both)
343
+ # - Answering questions about your requirements
344
+ # - Reviewing generated files (PRD, architecture, etc.)
345
+ # - Automatic execution with harness managing retries
339
346
  ```
340
347
 
341
- ### Background Workflow with Monitoring
348
+ ### Project Analysis
342
349
 
343
350
  ```bash
344
- # Terminal 1: Start background execution
345
- aidp execute --background
346
- ✓ Started background job: 20251005_235912_a1b2c3d4
347
-
348
- # Terminal 2: Watch progress in real-time
349
- aidp checkpoint summary --watch
350
-
351
- # Terminal 3: Monitor job status
352
- aidp jobs status 20251005_235912_a1b2c3d4 --follow
351
+ # High-level project analysis and documentation
352
+ aidp init
353
353
 
354
- # Later: Check final results
355
- aidp checkpoint summary
356
- aidp jobs logs 20251005_235912_a1b2c3d4 --tail
354
+ # Creates:
355
+ # - LLM_STYLE_GUIDE.md
356
+ # - PROJECT_ANALYSIS.md
357
+ # - CODE_QUALITY_PLAN.md
357
358
  ```
358
359
 
359
- ### Quick Analysis
360
+ ### Progress Monitoring
360
361
 
361
362
  ```bash
362
- # Run analysis in background
363
- aidp analyze --background
363
+ # Watch progress in real-time
364
+ aidp checkpoint summary --watch
364
365
 
365
- # Check progress
366
+ # Check job status
366
367
  aidp jobs list
367
368
  aidp checkpoint summary
368
369
  ```
data/lib/aidp/cli.rb CHANGED
@@ -196,15 +196,15 @@ module Aidp
196
196
  tui.start_display_loop
197
197
 
198
198
  begin
199
- # First question: Choose mode
200
- mode = select_mode_interactive(tui)
199
+ # Copilot is now the default mode - no menu selection
200
+ # The guided workflow selector will internally choose appropriate mode
201
+ mode = :guided
201
202
 
202
203
  # Get workflow configuration (no spinner - may wait for user input)
203
204
  workflow_config = workflow_selector.select_workflow(harness_mode: false, mode: mode)
204
205
 
205
- # For guided mode, use the mode determined by the guided workflow selector
206
- # Otherwise use the initially selected mode
207
- actual_mode = workflow_config[:mode] || mode
206
+ # Use the mode determined by the guided workflow selector
207
+ actual_mode = workflow_config[:mode] || :execute
208
208
 
209
209
  # Pass workflow configuration to harness
210
210
  harness_options = {
@@ -265,8 +265,7 @@ module Aidp
265
265
  opts.separator "AI Development Pipeline - Autonomous development workflow automation"
266
266
  opts.separator ""
267
267
  opts.separator "Commands:"
268
- opts.separator " analyze [--background] Start analyze mode workflow"
269
- opts.separator " execute [--background] Start execute mode workflow"
268
+ opts.separator " (no command) Start Copilot interactive mode (default)"
270
269
  opts.separator " init Analyse project and bootstrap quality docs"
271
270
  opts.separator " watch <issues_url> Run fully automatic watch mode"
272
271
  opts.separator " status Show current system status"
@@ -302,9 +301,12 @@ module Aidp
302
301
 
303
302
  opts.separator ""
304
303
  opts.separator "Examples:"
305
- opts.separator " # Start background execution"
306
- opts.separator " aidp execute --background"
307
- opts.separator " aidp execute --background --follow # Start and follow logs"
304
+ opts.separator " # Start interactive Copilot mode"
305
+ opts.separator " aidp"
306
+ opts.separator ""
307
+ opts.separator " # Project bootstrap"
308
+ opts.separator " aidp init # High-level analysis and docs"
309
+ opts.separator " aidp config --interactive # Configure providers"
308
310
  opts.separator ""
309
311
  opts.separator " # Monitor background jobs"
310
312
  opts.separator " aidp jobs list # List all jobs"
@@ -315,20 +317,14 @@ module Aidp
315
317
  opts.separator " aidp checkpoint summary --watch # Auto-refresh every 5s"
316
318
  opts.separator " aidp checkpoint summary --watch --interval 10"
317
319
  opts.separator ""
318
- opts.separator " # Project bootstrap"
319
- opts.separator " aidp init"
320
- opts.separator " aidp config --interactive"
321
320
  opts.separator " # Fully automatic orchestration"
322
- opts.separator ""
323
321
  opts.separator " aidp watch https://github.com/<org>/<repo>/issues"
324
322
  opts.separator " aidp watch owner/repo --interval 120 --provider claude"
325
323
  opts.separator ""
326
324
  opts.separator " # Other commands"
327
325
  opts.separator " aidp providers # Check provider health"
328
326
  opts.separator " aidp providers info claude # Show detailed provider info"
329
- opts.separator " aidp providers refresh # Refresh all provider info"
330
327
  opts.separator " aidp mcp # Show MCP server dashboard"
331
- opts.separator " aidp mcp check dash-api filesystem # Check provider eligibility"
332
328
  opts.separator " aidp checkpoint history 20 # Show last 20 checkpoints"
333
329
  opts.separator ""
334
330
  opts.separator "For more information, visit: https://github.com/viamin/aidp"
@@ -342,7 +338,7 @@ module Aidp
342
338
  # Determine if the invocation is a subcommand style call
343
339
  def subcommand?(args)
344
340
  return false if args.nil? || args.empty?
345
- %w[status jobs kb harness execute analyze providers checkpoint mcp issue config init watch].include?(args.first)
341
+ %w[status jobs kb harness providers checkpoint mcp issue config init watch].include?(args.first)
346
342
  end
347
343
 
348
344
  def run_subcommand(args)
@@ -352,8 +348,6 @@ module Aidp
352
348
  when "jobs" then run_jobs_command(args)
353
349
  when "kb" then run_kb_command(args)
354
350
  when "harness" then run_harness_command(args)
355
- when "execute" then run_execute_command(args)
356
- when "analyze" then run_execute_command(args, mode: :analyze) # symmetry
357
351
  when "providers" then run_providers_command(args)
358
352
  when "checkpoint" then run_checkpoint_command(args)
359
353
  when "mcp" then run_mcp_command(args)
@@ -923,25 +917,6 @@ module Aidp
923
917
  mode&.to_sym
924
918
  end
925
919
 
926
- def select_mode_interactive(tui)
927
- mode_options = [
928
- "🤖 Guided Workflow (Copilot) - AI helps you choose the right workflow",
929
- "🔬 Analyze Mode - Analyze your codebase for insights and recommendations",
930
- "🏗️ Execute Mode - Build new features with guided development workflow"
931
- ]
932
- selected = tui.single_select("Welcome to AI Dev Pipeline! Choose your mode", mode_options, default: 1)
933
- # Announce mode explicitly in headless contexts (handled internally otherwise)
934
- if (defined?(RSpec) || ENV["RSPEC_RUNNING"]) && tui.respond_to?(:announce_mode)
935
- tui.announce_mode(:guided) if selected == mode_options[0]
936
- tui.announce_mode(:analyze) if selected == mode_options[1]
937
- tui.announce_mode(:execute) if selected == mode_options[2]
938
- end
939
- return :guided if selected == mode_options[0]
940
- return :analyze if selected == mode_options[1]
941
- return :execute if selected == mode_options[2]
942
- :analyze
943
- end
944
-
945
920
  def display_harness_result(result)
946
921
  case result[:status]
947
922
  when "completed"
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aidp
4
+ module Execute
5
+ module AgentSignalParser
6
+ def self.extract_next_unit(output)
7
+ return nil unless output
8
+
9
+ output.to_s.each_line do |line|
10
+ token = token_from_line(line)
11
+ next unless token
12
+
13
+ return normalize_token(token)
14
+ end
15
+
16
+ nil
17
+ end
18
+
19
+ def self.normalize_token(raw)
20
+ return nil if raw.nil? || raw.empty?
21
+
22
+ token = raw.downcase.strip
23
+ token.gsub!(/\s+/, "_")
24
+ token.to_sym
25
+ end
26
+
27
+ def self.token_from_line(line)
28
+ return nil unless line
29
+
30
+ trimmed = line.lstrip
31
+ separator_index = trimmed.index(":") || trimmed.index("=")
32
+ return nil unless separator_index
33
+
34
+ key = trimmed[0...separator_index].strip
35
+ value = trimmed[(separator_index + 1)..]&.strip
36
+
37
+ return nil unless key && value
38
+ return value if key.casecmp("next_unit").zero? || key.casecmp("next_step").zero?
39
+
40
+ nil
41
+ end
42
+
43
+ private_class_method :token_from_line
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,254 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require_relative "../message_display"
5
+ require_relative "../rescue_logging"
6
+ require_relative "../util"
7
+
8
+ module Aidp
9
+ module Execute
10
+ module DeterministicUnits
11
+ # Represents a deterministic unit configured in aidp.yml.
12
+ # Definitions are immutable and provide helper accessors used by the scheduler.
13
+ class Definition
14
+ VALID_TYPES = [:command, :wait].freeze
15
+ NEXT_KEY_ALIASES = {
16
+ if_pass: :success,
17
+ if_success: :success,
18
+ if_fail: :failure,
19
+ if_failure: :failure,
20
+ if_error: :failure,
21
+ if_timeout: :timeout,
22
+ if_wait: :waiting,
23
+ if_new_item: :event,
24
+ if_event: :event
25
+ }.freeze
26
+
27
+ attr_reader :name, :type, :command, :output_file, :next_map,
28
+ :min_interval_seconds, :max_backoff_seconds, :backoff_multiplier,
29
+ :enabled, :metadata
30
+
31
+ def initialize(config)
32
+ @name = config.fetch(:name)
33
+ @type = normalize_type(config[:type]) || default_type_for(config)
34
+ validate_type!
35
+
36
+ @command = config[:command]
37
+ @output_file = config[:output_file]
38
+ @next_map = normalize_next_config(config[:next] || {})
39
+ @min_interval_seconds = config.fetch(:min_interval_seconds, 60)
40
+ @max_backoff_seconds = config.fetch(:max_backoff_seconds, 900)
41
+ @backoff_multiplier = config.fetch(:backoff_multiplier, 2.0)
42
+ @enabled = config.fetch(:enabled, true)
43
+ @metadata = config.fetch(:metadata, {}).dup
44
+ end
45
+
46
+ def command?
47
+ type == :command
48
+ end
49
+
50
+ def wait?
51
+ type == :wait
52
+ end
53
+
54
+ def next_for(result_status, default: nil)
55
+ next_map[result_status.to_sym] || default || next_map[:else]
56
+ end
57
+
58
+ private
59
+
60
+ def normalize_type(type)
61
+ return nil if type.nil?
62
+ symbol = type.to_sym
63
+ VALID_TYPES.include?(symbol) ? symbol : nil
64
+ end
65
+
66
+ def default_type_for(config)
67
+ return :command if config[:command]
68
+ :wait
69
+ end
70
+
71
+ def validate_type!
72
+ return if VALID_TYPES.include?(type)
73
+ raise ArgumentError, "Unsupported deterministic unit type: #{type.inspect}"
74
+ end
75
+
76
+ def normalize_next_config(raw)
77
+ return {} unless raw
78
+
79
+ raw.each_with_object({}) do |(key, value), normalized|
80
+ symbol_key = key.to_sym
81
+ normalized[NEXT_KEY_ALIASES.fetch(symbol_key, symbol_key)] = value
82
+ end
83
+ end
84
+ end
85
+
86
+ # Result wrapper returned after executing a deterministic unit.
87
+ class Result
88
+ attr_reader :name, :status, :output_path, :started_at, :finished_at,
89
+ :duration, :data, :error
90
+
91
+ def initialize(name:, status:, output_path:, started_at:, finished_at:, data: {}, error: nil)
92
+ @name = name
93
+ @status = status.to_sym
94
+ @output_path = output_path
95
+ @started_at = started_at
96
+ @finished_at = finished_at
97
+ @duration = finished_at - started_at
98
+ @data = data
99
+ @error = error
100
+ end
101
+
102
+ def success?
103
+ status == :success
104
+ end
105
+
106
+ def failure?
107
+ status == :failure
108
+ end
109
+
110
+ def timeout?
111
+ status == :timeout
112
+ end
113
+ end
114
+
115
+ # Executes deterministic units by running commands or internal behaviours.
116
+ class Runner
117
+ include Aidp::MessageDisplay
118
+ include Aidp::RescueLogging
119
+
120
+ DEFAULT_TIMEOUT = 3600 # One hour ceiling for long-running commands
121
+
122
+ def initialize(project_dir, command_runner: nil, clock: Time)
123
+ @project_dir = project_dir
124
+ @clock = clock
125
+ @command_runner = command_runner || build_default_command_runner
126
+ end
127
+
128
+ def run(definition, context = {})
129
+ raise ArgumentError, "Unit #{definition.name} is not enabled" unless definition.enabled
130
+
131
+ case definition.type
132
+ when :command
133
+ execute_command_unit(definition, context)
134
+ when :wait
135
+ execute_wait_unit(definition, context)
136
+ else
137
+ raise ArgumentError, "Unsupported deterministic unit type: #{definition.type}"
138
+ end
139
+ end
140
+
141
+ private
142
+
143
+ def execute_command_unit(definition, context)
144
+ started_at = @clock.now
145
+ display_message("🛠️ Running deterministic unit: #{definition.name}", type: :info)
146
+
147
+ result = @command_runner.call(definition.command, context)
148
+
149
+ data = {
150
+ exit_status: result[:exit_status],
151
+ stdout: result[:stdout],
152
+ stderr: result[:stderr]
153
+ }
154
+
155
+ status = result[:exit_status].to_i.zero? ? :success : :failure
156
+ output_path = write_output(definition, data)
157
+
158
+ display_message("✅ Deterministic unit #{definition.name} finished with status #{status}", type: :success) if status == :success
159
+ display_message("⚠️ Deterministic unit #{definition.name} finished with status #{status}", type: :warning) if status != :success
160
+
161
+ DeterministicUnits::Result.new(
162
+ name: definition.name,
163
+ status: status,
164
+ output_path: output_path,
165
+ started_at: started_at,
166
+ finished_at: @clock.now,
167
+ data: data
168
+ )
169
+ rescue => e
170
+ finished_at = @clock.now
171
+ log_rescue(e, component: "deterministic_runner", action: "execute_command_unit", fallback: "failure", unit: definition.name)
172
+ display_message("❌ Deterministic unit #{definition.name} failed: #{e.message}", type: :error)
173
+
174
+ output_path = write_output(definition, {error: e.message})
175
+
176
+ DeterministicUnits::Result.new(
177
+ name: definition.name,
178
+ status: :failure,
179
+ output_path: output_path,
180
+ started_at: started_at,
181
+ finished_at: finished_at,
182
+ data: {error: e.message},
183
+ error: e
184
+ )
185
+ end
186
+
187
+ def execute_wait_unit(definition, context)
188
+ started_at = @clock.now
189
+
190
+ wait_seconds = definition.metadata.fetch(:interval_seconds, 60)
191
+ backoff_seconds = definition.metadata.fetch(:backoff_seconds, wait_seconds)
192
+ reason = context[:reason] || "Waiting for GitHub activity"
193
+
194
+ display_message("🕒 Deterministic wait: #{definition.name} (#{reason})", type: :info)
195
+ max_window = definition.max_backoff_seconds || backoff_seconds
196
+ sleep_duration = backoff_seconds.clamp(1, max_window)
197
+
198
+ sleep_handler = context[:sleep_handler] || method(:sleep)
199
+ sleep_handler.call(sleep_duration)
200
+
201
+ event_detected = context[:event_detected] == true
202
+
203
+ payload = {
204
+ message: "Waited #{sleep_duration} seconds",
205
+ reason: reason,
206
+ backoff_seconds: sleep_duration,
207
+ event_detected: event_detected
208
+ }
209
+
210
+ output_path = write_output(definition, payload)
211
+
212
+ DeterministicUnits::Result.new(
213
+ name: definition.name,
214
+ status: event_detected ? :event : :waiting,
215
+ output_path: output_path,
216
+ started_at: started_at,
217
+ finished_at: @clock.now,
218
+ data: payload
219
+ )
220
+ end
221
+
222
+ def write_output(definition, payload)
223
+ return nil unless definition.output_file
224
+
225
+ path = File.join(@project_dir, definition.output_file)
226
+ Aidp::Util.safe_file_write(path, payload.to_yaml)
227
+ path
228
+ end
229
+
230
+ def build_default_command_runner
231
+ lambda do |command, _context|
232
+ require "tty-command"
233
+
234
+ cmd = TTY::Command.new(printer: :quiet)
235
+ result = cmd.run(command, chdir: @project_dir)
236
+
237
+ {
238
+ exit_status: result.exit_status,
239
+ stdout: result.out,
240
+ stderr: result.err
241
+ }
242
+ rescue TTY::Command::ExitError => e
243
+ result = e.result
244
+ {
245
+ exit_status: result.exit_status,
246
+ stdout: result.out,
247
+ stderr: result.err
248
+ }
249
+ end
250
+ end
251
+ end
252
+ end
253
+ end
254
+ end
@@ -128,7 +128,7 @@ module Aidp
128
128
  # Simple task execution - for one-off commands and simple fixes
129
129
  "99_SIMPLE_TASK" => {
130
130
  "templates" => ["implementation/simple_task.md"],
131
- "description" => "Execute Simple Task (one-off commands, quick fixes, linting)",
131
+ "description" => "Execute Simple Task (one-off commands, quick fixes, linting; emit NEXT_UNIT when more tooling is needed)",
132
132
  "outs" => [],
133
133
  "gate" => false,
134
134
  "simple" => true # Special step for simple, focused tasks
@@ -4,6 +4,9 @@ require_relative "prompt_manager"
4
4
  require_relative "checkpoint"
5
5
  require_relative "checkpoint_display"
6
6
  require_relative "guard_policy"
7
+ require_relative "work_loop_unit_scheduler"
8
+ require_relative "deterministic_unit"
9
+ require_relative "agent_signal_parser"
7
10
  require_relative "../harness/test_runner"
8
11
 
9
12
  module Aidp
@@ -53,6 +56,8 @@ module Aidp
53
56
  @options = options
54
57
  @current_state = :ready
55
58
  @state_history = []
59
+ @deterministic_runner = DeterministicUnits::Runner.new(project_dir)
60
+ @unit_scheduler = nil
56
61
  end
57
62
 
58
63
  # Execute a step using fix-forward work loop pattern
@@ -63,86 +68,212 @@ module Aidp
63
68
  @iteration_count = 0
64
69
  transition_to(:ready)
65
70
 
66
- Aidp.logger.info("work_loop", "Starting fix-forward execution", step: step_name, max_iterations: MAX_ITERATIONS)
71
+ Aidp.logger.info("work_loop", "Starting hybrid work loop execution", step: step_name, max_iterations: MAX_ITERATIONS)
67
72
 
68
- display_message("🔄 Starting fix-forward work loop for step: #{step_name}", type: :info)
69
- display_message(" State machine: READY APPLY_PATCH TEST → {PASS → DONE | FAIL → DIAGNOSE → NEXT_PATCH}", type: :info)
73
+ display_message("🔄 Starting hybrid work loop for step: #{step_name}", type: :info)
74
+ display_message(" Flow: Deterministic Agentic with fix-forward core", type: :info)
70
75
 
71
- # Display guard policy status
72
76
  display_guard_policy_status
73
77
 
74
- # Create initial PROMPT.md
78
+ @unit_scheduler = WorkLoopUnitScheduler.new(units_config)
79
+ base_context = context.dup
80
+
81
+ loop do
82
+ unit = @unit_scheduler.next_unit
83
+ break unless unit
84
+
85
+ if unit.deterministic?
86
+ result = @deterministic_runner.run(unit.definition, reason: "scheduled by work loop")
87
+ @unit_scheduler.record_deterministic_result(unit.definition, result)
88
+ next
89
+ end
90
+
91
+ enriched_context = base_context.merge(
92
+ deterministic_outputs: @unit_scheduler.deterministic_context,
93
+ previous_agent_summary: @unit_scheduler.last_agentic_summary
94
+ )
95
+
96
+ agentic_payload = if unit.name == :decide_whats_next
97
+ run_decider_agentic_unit(enriched_context)
98
+ else
99
+ run_primary_agentic_unit(step_spec, enriched_context)
100
+ end
101
+
102
+ @unit_scheduler.record_agentic_result(
103
+ agentic_payload[:raw_result] || {},
104
+ requested_next: agentic_payload[:requested_next],
105
+ summary: agentic_payload[:summary],
106
+ completed: agentic_payload[:completed]
107
+ )
108
+
109
+ return agentic_payload[:response] if agentic_payload[:terminate]
110
+ end
111
+
112
+ build_max_iterations_result
113
+ end
114
+
115
+ private
116
+
117
+ def run_primary_agentic_unit(step_spec, context)
118
+ Aidp.logger.info("work_loop", "Running primary agentic unit", step: @step_name)
119
+
120
+ display_message(" State machine: READY → APPLY_PATCH → TEST → {PASS → DONE | FAIL → DIAGNOSE → NEXT_PATCH}", type: :info)
121
+
122
+ @iteration_count = 0
123
+ @current_state = :ready
124
+ @state_history.clear
125
+
75
126
  create_initial_prompt(step_spec, context)
76
127
 
77
- # Main fix-forward work loop
78
128
  loop do
79
129
  @iteration_count += 1
80
130
  display_message(" Iteration #{@iteration_count} [State: #{STATES[@current_state]}]", type: :info)
81
131
 
82
132
  if @iteration_count > MAX_ITERATIONS
83
133
  Aidp.logger.error("work_loop", "Max iterations exceeded", step: @step_name, iterations: @iteration_count)
84
- break
134
+ display_message("⚠️ Max iterations (#{MAX_ITERATIONS}) reached for #{@step_name}", type: :warning)
135
+ display_state_summary
136
+ archive_and_cleanup
137
+ return build_agentic_payload(
138
+ agent_result: nil,
139
+ response: build_max_iterations_result,
140
+ summary: nil,
141
+ completed: false,
142
+ terminate: true
143
+ )
85
144
  end
86
145
 
87
- # State: READY - Starting new iteration
88
146
  transition_to(:ready) unless @current_state == :ready
89
147
 
90
- # State: APPLY_PATCH - Agent applies changes
91
148
  transition_to(:apply_patch)
92
- result = apply_patch
149
+ agent_result = apply_patch
93
150
 
94
- # State: TEST - Run tests and linters
95
151
  transition_to(:test)
96
152
  test_results = @test_runner.run_tests
97
153
  lint_results = @test_runner.run_linters
98
154
 
99
- # Record checkpoint at intervals
100
155
  record_periodic_checkpoint(test_results, lint_results)
101
156
 
102
- # Check if tests passed
103
157
  tests_pass = test_results[:success] && lint_results[:success]
104
158
 
105
159
  if tests_pass
106
- # State: PASS - Tests passed
107
160
  transition_to(:pass)
108
161
 
109
- # Check if agent marked work complete
110
- if agent_marked_complete?(result)
111
- # State: DONE - Work complete
162
+ if agent_marked_complete?(agent_result)
112
163
  transition_to(:done)
113
164
  record_final_checkpoint(test_results, lint_results)
114
- display_message("✅ Step #{step_name} completed after #{@iteration_count} iterations", type: :success)
165
+ display_message("✅ Step #{@step_name} completed after #{@iteration_count} iterations", type: :success)
115
166
  display_state_summary
116
167
  archive_and_cleanup
117
- return build_success_result(result)
168
+
169
+ return build_agentic_payload(
170
+ agent_result: agent_result,
171
+ response: build_success_result(agent_result),
172
+ summary: agent_result[:output],
173
+ completed: true,
174
+ terminate: true
175
+ )
118
176
  else
119
- # Tests pass but work not complete - continue
120
177
  display_message(" Tests passed but work not marked complete", type: :info)
121
178
  transition_to(:next_patch)
122
179
  end
123
180
  else
124
- # State: FAIL - Tests failed
125
181
  transition_to(:fail)
126
182
  display_message(" Tests or linters failed", type: :warning)
127
183
 
128
- # State: DIAGNOSE - Analyze failures
129
184
  transition_to(:diagnose)
130
185
  diagnostic = diagnose_failures(test_results, lint_results)
131
186
 
132
- # State: NEXT_PATCH - Prepare for next iteration
133
187
  transition_to(:next_patch)
134
188
  prepare_next_iteration(test_results, lint_results, diagnostic)
135
189
  end
136
190
  end
191
+ end
137
192
 
138
- # Safety: max iterations reached
139
- display_message("⚠️ Max iterations (#{MAX_ITERATIONS}) reached for #{step_name}", type: :warning)
140
- display_state_summary
141
- archive_and_cleanup
142
- build_max_iterations_result
193
+ def run_decider_agentic_unit(context)
194
+ Aidp.logger.info("work_loop", "Running decide_whats_next agentic unit", step: @step_name)
195
+
196
+ prompt = build_decider_prompt(context)
197
+
198
+ agent_result = @provider_manager.execute_with_provider(
199
+ @provider_manager.current_provider,
200
+ prompt,
201
+ {
202
+ step_name: @step_name,
203
+ iteration: @iteration_count,
204
+ project_dir: @project_dir,
205
+ mode: :decide_whats_next
206
+ }
207
+ )
208
+
209
+ requested = AgentSignalParser.extract_next_unit(agent_result[:output])
210
+
211
+ build_agentic_payload(
212
+ agent_result: agent_result,
213
+ response: agent_result,
214
+ summary: agent_result[:output],
215
+ completed: false,
216
+ terminate: false,
217
+ requested_next: requested
218
+ )
143
219
  end
144
220
 
145
- private
221
+ def units_config
222
+ if @config.respond_to?(:work_loop_units_config)
223
+ @config.work_loop_units_config
224
+ else
225
+ {}
226
+ end
227
+ end
228
+
229
+ def build_agentic_payload(agent_result:, response:, summary:, completed:, terminate:, requested_next: nil)
230
+ {
231
+ raw_result: agent_result,
232
+ response: response,
233
+ summary: summary,
234
+ requested_next: requested_next || AgentSignalParser.extract_next_unit(agent_result&.dig(:output)),
235
+ completed: completed,
236
+ terminate: terminate
237
+ }
238
+ end
239
+
240
+ def build_decider_prompt(context)
241
+ outputs = Array(context[:deterministic_outputs])
242
+ summary = context[:previous_agent_summary]
243
+
244
+ sections = []
245
+ sections << "# Decide Next Work Loop Unit"
246
+ sections << ""
247
+ sections << "You are operating in the Aidp work loop. Determine what should happen next."
248
+ sections << ""
249
+ sections << "## Recent Deterministic Outputs"
250
+
251
+ if outputs.empty?
252
+ sections << "- None recorded yet."
253
+ else
254
+ outputs.each do |entry|
255
+ sections << "- #{entry[:name]} (status: #{entry[:status]}, finished_at: #{entry[:finished_at]})"
256
+ sections << " Output: #{entry[:output_path] || "n/a"}"
257
+ end
258
+ end
259
+
260
+ if summary
261
+ sections << ""
262
+ sections << "## Previous Agent Summary"
263
+ sections << summary
264
+ end
265
+
266
+ sections << ""
267
+ sections << "## Instructions"
268
+ sections << "- Decide whether to run another deterministic unit or resume agentic editing."
269
+ sections << "- Announce your decision with `NEXT_UNIT: <unit_name>`."
270
+ sections << "- Valid values: names defined in configuration, `agentic`, or `wait_for_github`."
271
+ sections << "- Provide a concise rationale below."
272
+ sections << ""
273
+ sections << "## Rationale"
274
+
275
+ sections.join("\n")
276
+ end
146
277
 
147
278
  # Transition to a new state in the fix-forward state machine
148
279
  def transition_to(new_state)
@@ -216,20 +347,24 @@ module Aidp
216
347
  prd_content = load_prd
217
348
  style_guide = load_style_guide
218
349
  user_input = format_user_input(context[:user_input])
350
+ deterministic_outputs = Array(context[:deterministic_outputs])
351
+ previous_summary = context[:previous_agent_summary]
219
352
 
220
353
  initial_prompt = build_initial_prompt_content(
221
354
  template: template_content,
222
355
  prd: prd_content,
223
356
  style_guide: style_guide,
224
357
  user_input: user_input,
225
- step_name: @step_name
358
+ step_name: @step_name,
359
+ deterministic_outputs: deterministic_outputs,
360
+ previous_agent_summary: previous_summary
226
361
  )
227
362
 
228
363
  @prompt_manager.write(initial_prompt)
229
364
  display_message(" Created PROMPT.md (#{initial_prompt.length} chars)", type: :info)
230
365
  end
231
366
 
232
- def build_initial_prompt_content(template:, prd:, style_guide:, user_input:, step_name:)
367
+ def build_initial_prompt_content(template:, prd:, style_guide:, user_input:, step_name:, deterministic_outputs:, previous_agent_summary:)
233
368
  parts = []
234
369
 
235
370
  parts << "# Work Loop: #{step_name}"
@@ -259,6 +394,21 @@ module Aidp
259
394
  parts << ""
260
395
  end
261
396
 
397
+ if previous_agent_summary && !previous_agent_summary.empty?
398
+ parts << "## Previous Agent Summary"
399
+ parts << previous_agent_summary
400
+ parts << ""
401
+ end
402
+
403
+ unless deterministic_outputs.empty?
404
+ parts << "## Recent Deterministic Outputs"
405
+ deterministic_outputs.each do |entry|
406
+ parts << "- #{entry[:name]} (status: #{entry[:status]})"
407
+ parts << " Output: #{entry[:output_path] || "n/a"}"
408
+ end
409
+ parts << ""
410
+ end
411
+
262
412
  if style_guide
263
413
  parts << "## LLM Style Guide"
264
414
  parts << style_guide
@@ -0,0 +1,190 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "deterministic_unit"
4
+ require_relative "../logger"
5
+
6
+ module Aidp
7
+ module Execute
8
+ class WorkLoopUnitScheduler
9
+ Unit = Struct.new(:type, :name, :definition, keyword_init: true) do
10
+ def agentic?
11
+ type == :agentic
12
+ end
13
+
14
+ def deterministic?
15
+ type == :deterministic
16
+ end
17
+ end
18
+
19
+ attr_reader :last_agentic_summary
20
+
21
+ def initialize(units_config, clock: Time)
22
+ @clock = clock
23
+ @deterministic_definitions = build_deterministic_definitions(units_config[:deterministic])
24
+ @defaults = default_options.merge(units_config[:defaults] || {})
25
+ @pending_units = []
26
+ @deterministic_history = []
27
+ @deterministic_state = Hash.new { |h, key| h[key] = default_deterministic_state }
28
+ @agentic_runs = []
29
+ @last_agentic_summary = nil
30
+ @consecutive_deciders = 0
31
+ @completed = false
32
+ @started = false
33
+ end
34
+
35
+ def next_unit
36
+ return nil if @completed
37
+
38
+ unless @started
39
+ @started = true
40
+ queue_requested_unit(@defaults[:initial_unit] || :agentic)
41
+ end
42
+
43
+ unit = @pending_units.shift
44
+ return unit if unit
45
+
46
+ queue_requested_unit(@defaults[:on_no_next_step] || :agentic)
47
+
48
+ @pending_units.shift
49
+ end
50
+
51
+ def record_agentic_result(result, requested_next: nil, summary: nil, completed: false)
52
+ @last_agentic_summary = summarize(summary)
53
+ @agentic_runs << {timestamp: @clock.now, result: result}
54
+
55
+ queue_requested_unit(requested_next) if requested_next
56
+
57
+ mark_completed if completed && !requested_next
58
+ end
59
+
60
+ def record_deterministic_result(definition, result)
61
+ state = @deterministic_state[definition.name]
62
+ state[:last_run_at] = result.finished_at
63
+
64
+ state[:current_backoff] = if result.success? || result.status == :event
65
+ definition.min_interval_seconds
66
+ else
67
+ [
68
+ state[:current_backoff] * definition.backoff_multiplier,
69
+ definition.max_backoff_seconds
70
+ ].min
71
+ end
72
+
73
+ @deterministic_history << {
74
+ name: definition.name,
75
+ status: result.status,
76
+ output_path: result.output_path,
77
+ finished_at: result.finished_at,
78
+ data: result.data
79
+ }
80
+
81
+ requested = definition.next_for(result.status)
82
+ queue_requested_unit(requested) if requested
83
+ end
84
+
85
+ def deterministic_context(limit: 5)
86
+ @deterministic_history.last(limit)
87
+ end
88
+
89
+ def completed?
90
+ @completed
91
+ end
92
+
93
+ private
94
+
95
+ def summarize(summary)
96
+ return nil unless summary
97
+ content = summary.to_s.strip
98
+ return nil if content.empty?
99
+ (content.length > 500) ? "#{content[0...500]}…" : content
100
+ end
101
+
102
+ def mark_completed
103
+ @completed = true
104
+ @pending_units.clear
105
+ end
106
+
107
+ def queue_requested_unit(identifier)
108
+ return if identifier.nil?
109
+
110
+ case identifier.to_sym
111
+ when :agentic
112
+ enqueue_agentic(:primary)
113
+ when :decide_whats_next
114
+ enqueue_agentic(:decide_whats_next)
115
+ else
116
+ enqueue_deterministic(identifier.to_s)
117
+ end
118
+ rescue NoMethodError
119
+ enqueue_agentic(:primary)
120
+ end
121
+
122
+ def enqueue_agentic(name)
123
+ if name == :decide_whats_next
124
+ if @consecutive_deciders >= @defaults[:max_consecutive_deciders]
125
+ enqueue_agentic(:primary)
126
+ return
127
+ end
128
+ @consecutive_deciders += 1
129
+ else
130
+ @consecutive_deciders = 0
131
+ end
132
+
133
+ @pending_units << Unit.new(type: :agentic, name: name)
134
+ end
135
+
136
+ def enqueue_deterministic(name)
137
+ definition = @deterministic_definitions[name]
138
+ unless definition
139
+ enqueue_agentic((@defaults[:fallback_agentic] || :agentic).to_sym)
140
+ return
141
+ end
142
+ return unless definition.enabled
143
+
144
+ state = @deterministic_state[definition.name]
145
+ state[:current_backoff] ||= definition.min_interval_seconds
146
+
147
+ if cooldown_remaining(definition).positive?
148
+ enqueue_agentic((@defaults[:fallback_agentic] || :agentic).to_sym)
149
+ return
150
+ end
151
+
152
+ @pending_units << Unit.new(type: :deterministic, name: definition.name, definition: definition)
153
+ end
154
+
155
+ def cooldown_remaining(definition)
156
+ state = @deterministic_state[definition.name]
157
+ state[:current_backoff] ||= definition.min_interval_seconds
158
+
159
+ return 0 unless state[:last_run_at]
160
+
161
+ next_allowed_at = state[:last_run_at] + state[:current_backoff]
162
+ remaining = next_allowed_at - @clock.now
163
+ remaining.positive? ? remaining : 0
164
+ end
165
+
166
+ def build_deterministic_definitions(config_list)
167
+ Array(config_list).each_with_object({}) do |config, mapping|
168
+ definition = DeterministicUnits::Definition.new(config.transform_keys(&:to_sym))
169
+ mapping[definition.name] = definition
170
+ rescue KeyError, ArgumentError => e
171
+ Aidp.logger.warn("work_loop", "Skipping invalid deterministic unit configuration",
172
+ name: config[:name], error: e.message)
173
+ end
174
+ end
175
+
176
+ def default_deterministic_state
177
+ {last_run_at: nil, current_backoff: nil}
178
+ end
179
+
180
+ def default_options
181
+ {
182
+ initial_unit: :agentic,
183
+ on_no_next_step: :agentic,
184
+ fallback_agentic: :decide_whats_next,
185
+ max_consecutive_deciders: 1
186
+ }
187
+ end
188
+ end
189
+ end
190
+ end
@@ -374,7 +374,8 @@ module Aidp
374
374
  enabled: true,
375
375
  max_iterations: 50,
376
376
  test_commands: [],
377
- lint_commands: []
377
+ lint_commands: [],
378
+ units: {}
378
379
  },
379
380
  properties: {
380
381
  enabled: {
@@ -405,6 +406,95 @@ module Aidp
405
406
  type: :string
406
407
  }
407
408
  },
409
+ units: {
410
+ type: :hash,
411
+ required: false,
412
+ default: {},
413
+ properties: {
414
+ deterministic: {
415
+ type: :array,
416
+ required: false,
417
+ default: [],
418
+ items: {
419
+ type: :hash,
420
+ properties: {
421
+ name: {
422
+ type: :string,
423
+ required: true
424
+ },
425
+ command: {
426
+ type: :string,
427
+ required: false
428
+ },
429
+ type: {
430
+ type: :string,
431
+ required: false,
432
+ enum: ["command", "wait"]
433
+ },
434
+ output_file: {
435
+ type: :string,
436
+ required: false
437
+ },
438
+ enabled: {
439
+ type: :boolean,
440
+ required: false,
441
+ default: true
442
+ },
443
+ min_interval_seconds: {
444
+ type: :integer,
445
+ required: false,
446
+ min: 1
447
+ },
448
+ max_backoff_seconds: {
449
+ type: :integer,
450
+ required: false,
451
+ min: 1
452
+ },
453
+ backoff_multiplier: {
454
+ type: :number,
455
+ required: false,
456
+ min: 1.0
457
+ },
458
+ metadata: {
459
+ type: :hash,
460
+ required: false,
461
+ default: {}
462
+ },
463
+ next: {
464
+ type: :hash,
465
+ required: false,
466
+ default: {}
467
+ }
468
+ }
469
+ }
470
+ },
471
+ defaults: {
472
+ type: :hash,
473
+ required: false,
474
+ default: {},
475
+ properties: {
476
+ initial_unit: {
477
+ type: :string,
478
+ required: false
479
+ },
480
+ on_no_next_step: {
481
+ type: :string,
482
+ required: false
483
+ },
484
+ fallback_agentic: {
485
+ type: :string,
486
+ required: false
487
+ },
488
+ max_consecutive_deciders: {
489
+ type: :integer,
490
+ required: false,
491
+ min: 1,
492
+ max: 10
493
+ }
494
+ }
495
+ }
496
+ }
497
+ },
408
498
  guards: {
409
499
  type: :hash,
410
500
  required: false,
@@ -150,6 +150,10 @@ module Aidp
150
150
  harness_config[:work_loop] || default_work_loop_config
151
151
  end
152
152
 
153
+ def work_loop_units_config
154
+ work_loop_config[:units] || default_units_config
155
+ end
156
+
153
157
  # Check if work loops are enabled
154
158
  def work_loop_enabled?
155
159
  work_loop_config[:enabled]
@@ -483,7 +487,62 @@ module Aidp
483
487
  max_iterations: 50,
484
488
  test_commands: [],
485
489
  lint_commands: [],
486
- guards: default_guards_config
490
+ guards: default_guards_config,
491
+ units: default_units_config
492
+ }
493
+ end
494
+
495
+ def default_units_config
496
+ {
497
+ deterministic: [
498
+ {
499
+ name: "run_full_tests",
500
+ command: "bundle exec rake spec",
501
+ output_file: ".aidp/out/run_full_tests.yml",
502
+ enabled: false,
503
+ min_interval_seconds: 300,
504
+ max_backoff_seconds: 1800,
505
+ next: {
506
+ success: :agentic,
507
+ failure: :decide_whats_next,
508
+ else: :decide_whats_next
509
+ }
510
+ },
511
+ {
512
+ name: "run_lint",
513
+ command: "bundle exec standardrb",
514
+ output_file: ".aidp/out/run_lint.yml",
515
+ enabled: false,
516
+ min_interval_seconds: 300,
517
+ max_backoff_seconds: 1800,
518
+ next: {
519
+ success: :agentic,
520
+ failure: :decide_whats_next,
521
+ else: :decide_whats_next
522
+ }
523
+ },
524
+ {
525
+ name: "wait_for_github",
526
+ type: :wait,
527
+ output_file: ".aidp/out/wait_for_github.yml",
528
+ metadata: {
529
+ interval_seconds: 60,
530
+ backoff_seconds: 60
531
+ },
532
+ min_interval_seconds: 60,
533
+ max_backoff_seconds: 900,
534
+ next: {
535
+ event: :agentic,
536
+ else: :wait_for_github
537
+ }
538
+ }
539
+ ],
540
+ defaults: {
541
+ initial_unit: :agentic,
542
+ on_no_next_step: :wait_for_github,
543
+ fallback_agentic: :decide_whats_next,
544
+ max_consecutive_deciders: 1
545
+ }
487
546
  }
488
547
  end
489
548
 
data/lib/aidp/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Aidp
4
- VERSION = "0.14.2"
4
+ VERSION = "0.15.0"
5
5
  end
@@ -12,6 +12,9 @@ You are executing a simple, focused task within the AIDP work loop.
12
12
  2. **Execute the task exactly as described**
13
13
  3. **Verify your work** by running any validation commands specified
14
14
  4. **Edit this PROMPT.md** to track progress and mark complete when done
15
+ 5. **Request follow-up units** by adding `NEXT_UNIT: <unit_name>` to your
16
+ response when deterministic work (tests, linting, wait states) should run
17
+ next
15
18
 
16
19
  ## Completion Criteria
17
20
 
@@ -34,3 +37,5 @@ STATUS: COMPLETE
34
37
  - Keep your changes minimal and focused on the task
35
38
  - If the task involves running commands, show the command output
36
39
  - If the task involves fixing issues, list what was fixed
40
+ - When you need automation to continue after this task, emit `NEXT_UNIT: agentic`
41
+ or a deterministic unit such as `run_full_tests` or `wait_for_github`
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: aidp
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.14.2
4
+ version: 0.15.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Bart Agapinan
@@ -260,9 +260,11 @@ files:
260
260
  - lib/aidp/daemon/process_manager.rb
261
261
  - lib/aidp/daemon/runner.rb
262
262
  - lib/aidp/debug_mixin.rb
263
+ - lib/aidp/execute/agent_signal_parser.rb
263
264
  - lib/aidp/execute/async_work_loop_runner.rb
264
265
  - lib/aidp/execute/checkpoint.rb
265
266
  - lib/aidp/execute/checkpoint_display.rb
267
+ - lib/aidp/execute/deterministic_unit.rb
266
268
  - lib/aidp/execute/future_work_backlog.rb
267
269
  - lib/aidp/execute/guard_policy.rb
268
270
  - lib/aidp/execute/instruction_queue.rb
@@ -274,6 +276,7 @@ files:
274
276
  - lib/aidp/execute/steps.rb
275
277
  - lib/aidp/execute/work_loop_runner.rb
276
278
  - lib/aidp/execute/work_loop_state.rb
279
+ - lib/aidp/execute/work_loop_unit_scheduler.rb
277
280
  - lib/aidp/execute/workflow_selector.rb
278
281
  - lib/aidp/harness/completion_checker.rb
279
282
  - lib/aidp/harness/condition_detector.rb