aidp 0.13.0 ā 0.14.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 +4 -4
- data/README.md +7 -0
- data/lib/aidp/cli/first_run_wizard.rb +28 -303
- data/lib/aidp/cli/issue_importer.rb +359 -0
- data/lib/aidp/cli.rb +151 -3
- data/lib/aidp/daemon/process_manager.rb +146 -0
- data/lib/aidp/daemon/runner.rb +232 -0
- data/lib/aidp/execute/async_work_loop_runner.rb +216 -0
- data/lib/aidp/execute/future_work_backlog.rb +411 -0
- data/lib/aidp/execute/guard_policy.rb +246 -0
- data/lib/aidp/execute/instruction_queue.rb +131 -0
- data/lib/aidp/execute/interactive_repl.rb +335 -0
- data/lib/aidp/execute/repl_macros.rb +651 -0
- data/lib/aidp/execute/steps.rb +8 -0
- data/lib/aidp/execute/work_loop_runner.rb +322 -36
- data/lib/aidp/execute/work_loop_state.rb +162 -0
- data/lib/aidp/harness/config_schema.rb +88 -0
- data/lib/aidp/harness/configuration.rb +48 -1
- data/lib/aidp/harness/ui/enhanced_workflow_selector.rb +2 -0
- data/lib/aidp/init/doc_generator.rb +256 -0
- data/lib/aidp/init/project_analyzer.rb +343 -0
- data/lib/aidp/init/runner.rb +83 -0
- data/lib/aidp/init.rb +5 -0
- data/lib/aidp/logger.rb +279 -0
- data/lib/aidp/setup/wizard.rb +777 -0
- data/lib/aidp/tooling_detector.rb +115 -0
- data/lib/aidp/version.rb +1 -1
- data/lib/aidp/watch/build_processor.rb +282 -0
- data/lib/aidp/watch/plan_generator.rb +166 -0
- data/lib/aidp/watch/plan_processor.rb +83 -0
- data/lib/aidp/watch/repository_client.rb +243 -0
- data/lib/aidp/watch/runner.rb +93 -0
- data/lib/aidp/watch/state_store.rb +105 -0
- data/lib/aidp/watch.rb +9 -0
- data/lib/aidp.rb +14 -0
- data/templates/implementation/simple_task.md +36 -0
- metadata +26 -1
@@ -0,0 +1,777 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "tty-prompt"
|
4
|
+
require "yaml"
|
5
|
+
require "time"
|
6
|
+
require "fileutils"
|
7
|
+
|
8
|
+
require_relative "../util"
|
9
|
+
require_relative "../config/paths"
|
10
|
+
|
11
|
+
module Aidp
|
12
|
+
module Setup
|
13
|
+
# Interactive setup wizard for configuring AIDP.
|
14
|
+
# Guides the user through provider, work loop, NFR, logging, and mode settings
|
15
|
+
# while remaining idempotent and safe to re-run.
|
16
|
+
class Wizard
|
17
|
+
SCHEMA_VERSION = 1
|
18
|
+
|
19
|
+
attr_reader :project_dir, :prompt, :dry_run
|
20
|
+
|
21
|
+
def initialize(project_dir = Dir.pwd, prompt: nil, dry_run: false)
|
22
|
+
@project_dir = project_dir
|
23
|
+
@prompt = prompt || TTY::Prompt.new
|
24
|
+
@dry_run = dry_run
|
25
|
+
@warnings = []
|
26
|
+
@existing_config = load_existing_config
|
27
|
+
@config = deep_symbolize(@existing_config)
|
28
|
+
@saved = false
|
29
|
+
end
|
30
|
+
|
31
|
+
def run
|
32
|
+
display_welcome
|
33
|
+
return @saved if skip_wizard?
|
34
|
+
|
35
|
+
configure_providers
|
36
|
+
configure_work_loop
|
37
|
+
configure_branching
|
38
|
+
configure_artifacts
|
39
|
+
configure_nfrs
|
40
|
+
configure_logging
|
41
|
+
configure_modes
|
42
|
+
|
43
|
+
yaml_content = generate_yaml
|
44
|
+
display_preview(yaml_content)
|
45
|
+
display_diff(yaml_content) if @existing_config.any?
|
46
|
+
|
47
|
+
return true if dry_run_mode?(yaml_content)
|
48
|
+
|
49
|
+
if prompt.yes?("Save this configuration?", default: true)
|
50
|
+
save_config(yaml_content)
|
51
|
+
prompt.ok("ā
Configuration saved to #{relative_config_path}")
|
52
|
+
show_next_steps
|
53
|
+
display_warnings
|
54
|
+
@saved = true
|
55
|
+
else
|
56
|
+
prompt.warn("Configuration not saved")
|
57
|
+
display_warnings
|
58
|
+
end
|
59
|
+
|
60
|
+
@saved
|
61
|
+
end
|
62
|
+
|
63
|
+
def saved?
|
64
|
+
@saved
|
65
|
+
end
|
66
|
+
|
67
|
+
private
|
68
|
+
|
69
|
+
def display_welcome
|
70
|
+
prompt.say("\n" + "=" * 80)
|
71
|
+
prompt.say("š§ AIDP Setup Wizard")
|
72
|
+
prompt.say("=" * 80)
|
73
|
+
prompt.say("\nThis wizard will help you configure AIDP for your project.")
|
74
|
+
prompt.say("Press Enter to keep defaults. Type 'clear' to remove a value.")
|
75
|
+
prompt.say("Run 'aidp config --interactive' anytime to revisit these settings.")
|
76
|
+
prompt.say("=" * 80 + "\n")
|
77
|
+
end
|
78
|
+
|
79
|
+
def skip_wizard?
|
80
|
+
return false unless @existing_config.any?
|
81
|
+
|
82
|
+
prompt.say("š Found existing configuration at #{relative_config_path}")
|
83
|
+
skip = !prompt.yes?("Would you like to update it?", default: true)
|
84
|
+
@saved = true if skip
|
85
|
+
skip
|
86
|
+
end
|
87
|
+
|
88
|
+
# -------------------------------------------
|
89
|
+
# Provider configuration
|
90
|
+
# -------------------------------------------
|
91
|
+
def discover_available_providers
|
92
|
+
providers_dir = File.join(__dir__, "../providers")
|
93
|
+
provider_files = Dir.glob("*.rb", base: providers_dir)
|
94
|
+
|
95
|
+
# Exclude base classes and utility classes
|
96
|
+
excluded_files = ["base.rb", "macos_ui.rb"]
|
97
|
+
provider_files -= excluded_files
|
98
|
+
|
99
|
+
providers = {}
|
100
|
+
|
101
|
+
provider_files.each do |file|
|
102
|
+
provider_name = File.basename(file, ".rb")
|
103
|
+
begin
|
104
|
+
# Require the provider file if not already loaded
|
105
|
+
require_relative "../providers/#{provider_name}"
|
106
|
+
|
107
|
+
# Convert to class name (e.g., "anthropic" -> "Anthropic")
|
108
|
+
class_name = provider_name.split("_").map(&:capitalize).join
|
109
|
+
provider_class = Aidp::Providers.const_get(class_name)
|
110
|
+
|
111
|
+
# Create a temporary instance to get the display name
|
112
|
+
if provider_class.respond_to?(:new)
|
113
|
+
instance = provider_class.new
|
114
|
+
display_name = instance.respond_to?(:display_name) ? instance.display_name : provider_name.capitalize
|
115
|
+
providers[display_name] = provider_name
|
116
|
+
end
|
117
|
+
rescue => e
|
118
|
+
# Skip providers that can't be loaded, but don't fail the entire discovery
|
119
|
+
warn "Warning: Could not load provider #{provider_name}: #{e.message}" if ENV["DEBUG"]
|
120
|
+
end
|
121
|
+
end
|
122
|
+
|
123
|
+
providers
|
124
|
+
end
|
125
|
+
|
126
|
+
def configure_providers
|
127
|
+
prompt.say("\nš¦ Provider configuration")
|
128
|
+
prompt.say("-" * 40)
|
129
|
+
|
130
|
+
@config.fetch(:providers, {}).fetch(:llm, {})
|
131
|
+
|
132
|
+
available_providers = discover_available_providers
|
133
|
+
|
134
|
+
# TODO: Add default selection back once TTY-Prompt default validation issue is resolved
|
135
|
+
# For now, the user will select manually from the dynamically discovered providers
|
136
|
+
provider_choice = prompt.select("Select your primary LLM provider:") do |menu|
|
137
|
+
available_providers.each do |display_name, provider_name|
|
138
|
+
menu.choice display_name, provider_name
|
139
|
+
end
|
140
|
+
menu.choice "Other/Custom", "custom"
|
141
|
+
end
|
142
|
+
|
143
|
+
# Prompt for fallback providers (excluding the primary)
|
144
|
+
fallback_choices = available_providers.reject { |_, name| name == provider_choice }
|
145
|
+
fallback_selected = prompt.multi_select("Select fallback providers (used if primary fails):") do |menu|
|
146
|
+
fallback_choices.each do |display_name, provider_name|
|
147
|
+
menu.choice display_name, provider_name
|
148
|
+
end
|
149
|
+
end
|
150
|
+
|
151
|
+
set([:harness, :fallback_providers], fallback_selected)
|
152
|
+
|
153
|
+
# No LLM settings needed; provider agent handles LLM config
|
154
|
+
|
155
|
+
configure_mcp
|
156
|
+
show_provider_secrets_help(provider_choice)
|
157
|
+
end
|
158
|
+
|
159
|
+
def configure_mcp
|
160
|
+
existing = get([:providers, :mcp]) || {}
|
161
|
+
enabled = prompt.yes?("Enable MCP (Model Context Protocol) tools?", default: existing.fetch(:enabled, true))
|
162
|
+
return delete_path([:providers, :mcp]) unless enabled
|
163
|
+
|
164
|
+
# TODO: Add default back once TTY-Prompt default validation issue is resolved
|
165
|
+
tools = prompt.multi_select("Select MCP tools:") do |menu|
|
166
|
+
menu.choice "Git", "git"
|
167
|
+
menu.choice "Shell", "shell"
|
168
|
+
menu.choice "Filesystem", "fs"
|
169
|
+
menu.choice "Browser", "browser"
|
170
|
+
menu.choice "GitHub", "github"
|
171
|
+
end
|
172
|
+
|
173
|
+
custom = ask_list("Custom MCP servers (comma-separated)", existing.fetch(:custom_servers, []))
|
174
|
+
|
175
|
+
set([:providers, :mcp], {
|
176
|
+
enabled: true,
|
177
|
+
tools: tools,
|
178
|
+
custom_servers: custom
|
179
|
+
}.compact)
|
180
|
+
end
|
181
|
+
|
182
|
+
# -------------------------------------------
|
183
|
+
# Work loop configuration
|
184
|
+
# -------------------------------------------
|
185
|
+
def configure_work_loop
|
186
|
+
prompt.say("\nāļø Work loop configuration")
|
187
|
+
prompt.say("-" * 40)
|
188
|
+
|
189
|
+
configure_test_commands
|
190
|
+
configure_linting
|
191
|
+
configure_watch_patterns
|
192
|
+
configure_guards
|
193
|
+
end
|
194
|
+
|
195
|
+
def configure_test_commands
|
196
|
+
existing = get([:work_loop, :test]) || {}
|
197
|
+
|
198
|
+
unit = ask_with_default("Unit test command", existing[:unit] || detect_unit_test_command)
|
199
|
+
integration = ask_with_default("Integration test command", existing[:integration])
|
200
|
+
e2e = ask_with_default("End-to-end test command", existing[:e2e])
|
201
|
+
|
202
|
+
timeout = ask_with_default("Test timeout (seconds)", (existing[:timeout_seconds] || 1800).to_s) { |value| value.to_i }
|
203
|
+
|
204
|
+
set([:work_loop, :test], {
|
205
|
+
unit: unit,
|
206
|
+
integration: integration,
|
207
|
+
e2e: e2e,
|
208
|
+
timeout_seconds: timeout
|
209
|
+
}.compact)
|
210
|
+
|
211
|
+
validate_command(unit)
|
212
|
+
validate_command(integration)
|
213
|
+
validate_command(e2e)
|
214
|
+
end
|
215
|
+
|
216
|
+
def configure_linting
|
217
|
+
existing = get([:work_loop, :lint]) || {}
|
218
|
+
|
219
|
+
lint_cmd = ask_with_default("Lint command", existing[:command] || detect_lint_command)
|
220
|
+
format_cmd = ask_with_default("Format command", existing[:format] || detect_format_command)
|
221
|
+
autofix = prompt.yes?("Run formatter automatically?", default: existing.fetch(:autofix, false))
|
222
|
+
|
223
|
+
set([:work_loop, :lint], {
|
224
|
+
command: lint_cmd,
|
225
|
+
format: format_cmd,
|
226
|
+
autofix: autofix
|
227
|
+
})
|
228
|
+
|
229
|
+
validate_command(lint_cmd)
|
230
|
+
validate_command(format_cmd)
|
231
|
+
end
|
232
|
+
|
233
|
+
def configure_watch_patterns
|
234
|
+
existing = get([:work_loop, :test, :watch]) || {}
|
235
|
+
default_patterns = detect_watch_patterns
|
236
|
+
|
237
|
+
watch_patterns = ask_list("Test watch patterns (comma-separated)", existing.fetch(:patterns, default_patterns))
|
238
|
+
set([:work_loop, :test, :watch], {patterns: watch_patterns}) if watch_patterns.any?
|
239
|
+
end
|
240
|
+
|
241
|
+
def configure_guards
|
242
|
+
existing = get([:work_loop, :guards]) || {}
|
243
|
+
|
244
|
+
include_patterns = ask_list("Guard include patterns", existing[:include] || detect_source_patterns)
|
245
|
+
exclude_patterns = ask_list("Guard exclude patterns", existing[:exclude] || ["node_modules/**", "dist/**", "build/**"])
|
246
|
+
max_lines = ask_with_default("Max lines changed per commit", (existing[:max_lines_changed_per_commit] || 300).to_s) { |value| value.to_i }
|
247
|
+
protected_paths = ask_list("Protected paths (require confirmation)", existing[:protected_paths] || [], allow_empty: true)
|
248
|
+
confirmation_required = prompt.yes?("Require confirmation before editing protected paths?", default: existing.fetch(:confirm_protected, true))
|
249
|
+
|
250
|
+
set([:work_loop, :guards], {
|
251
|
+
include: include_patterns,
|
252
|
+
exclude: exclude_patterns,
|
253
|
+
max_lines_changed_per_commit: max_lines,
|
254
|
+
protected_paths: protected_paths,
|
255
|
+
confirm_protected: confirmation_required
|
256
|
+
})
|
257
|
+
end
|
258
|
+
|
259
|
+
def configure_branching
|
260
|
+
prompt.say("\nšæ Branching strategy")
|
261
|
+
prompt.say("-" * 40)
|
262
|
+
existing = get([:work_loop, :branching]) || {}
|
263
|
+
|
264
|
+
prefix = ask_with_default("Branch prefix for work loops", existing[:prefix] || "aidp")
|
265
|
+
slug_format = ask_with_default("Slug format (use %{id} and %{title})", existing[:slug_format] || "issue-%{id}-%{title}")
|
266
|
+
checkpoint_tag = ask_with_default("Checkpoint tag template", existing[:checkpoint_tag] || "aidp-start/%{id}")
|
267
|
+
|
268
|
+
set([:work_loop, :branching], {
|
269
|
+
prefix: prefix,
|
270
|
+
slug_format: slug_format,
|
271
|
+
checkpoint_tag: checkpoint_tag
|
272
|
+
})
|
273
|
+
end
|
274
|
+
|
275
|
+
def configure_artifacts
|
276
|
+
prompt.say("\nš Artifact storage")
|
277
|
+
prompt.say("-" * 40)
|
278
|
+
existing = get([:work_loop, :artifacts]) || {}
|
279
|
+
|
280
|
+
evidence_dir = ask_with_default("Evidence pack directory", existing[:evidence_dir] || ".aidp/evidence")
|
281
|
+
logs_dir = ask_with_default("Logs directory", existing[:logs_dir] || ".aidp/logs")
|
282
|
+
screenshots_dir = ask_with_default("Screenshots directory", existing[:screenshots_dir] || ".aidp/screenshots")
|
283
|
+
|
284
|
+
set([:work_loop, :artifacts], {
|
285
|
+
evidence_dir: evidence_dir,
|
286
|
+
logs_dir: logs_dir,
|
287
|
+
screenshots_dir: screenshots_dir
|
288
|
+
})
|
289
|
+
end
|
290
|
+
|
291
|
+
# -------------------------------------------
|
292
|
+
# NFRs & libraries
|
293
|
+
# -------------------------------------------
|
294
|
+
def configure_nfrs
|
295
|
+
prompt.say("\nš Non-functional requirements & preferred libraries")
|
296
|
+
prompt.say("-" * 40)
|
297
|
+
|
298
|
+
return delete_path([:nfrs]) unless prompt.yes?("Configure NFRs?", default: true)
|
299
|
+
|
300
|
+
categories = %i[performance security reliability accessibility internationalization]
|
301
|
+
categories.each do |category|
|
302
|
+
existing = get([:nfrs, category])
|
303
|
+
value = ask_multiline("#{category.to_s.capitalize} requirements", existing)
|
304
|
+
value.nil? ? delete_path([:nfrs, category]) : set([:nfrs, category], value)
|
305
|
+
end
|
306
|
+
|
307
|
+
configure_preferred_libraries
|
308
|
+
configure_environment_overrides
|
309
|
+
end
|
310
|
+
|
311
|
+
def configure_preferred_libraries
|
312
|
+
return unless prompt.yes?("Configure preferred libraries/tools?", default: true)
|
313
|
+
|
314
|
+
stack = detect_stack
|
315
|
+
prompt.say("\nš Detected stack: #{(stack == :other) ? "Custom" : stack.to_s.capitalize}")
|
316
|
+
case stack
|
317
|
+
when :rails
|
318
|
+
set([:nfrs, :preferred_libraries, :rails], configure_rails_libraries)
|
319
|
+
when :node
|
320
|
+
set([:nfrs, :preferred_libraries, :node], configure_node_libraries)
|
321
|
+
when :python
|
322
|
+
set([:nfrs, :preferred_libraries, :python], configure_python_libraries)
|
323
|
+
else
|
324
|
+
custom_stack = ask_with_default("Name this stack (e.g. go, php)", "custom")
|
325
|
+
libs = ask_list("Preferred libraries (comma-separated)", [])
|
326
|
+
set([:nfrs, :preferred_libraries, custom_stack.to_sym], libs)
|
327
|
+
end
|
328
|
+
end
|
329
|
+
|
330
|
+
def configure_environment_overrides
|
331
|
+
return unless prompt.yes?("Add environment-specific overrides?", default: false)
|
332
|
+
|
333
|
+
environments = prompt.multi_select("Select environments:", default: []) do |menu|
|
334
|
+
menu.choice "Development", :development
|
335
|
+
menu.choice "Test", :test
|
336
|
+
menu.choice "Production", :production
|
337
|
+
end
|
338
|
+
|
339
|
+
environments.each do |env|
|
340
|
+
categories = ask_multiline("#{env.to_s.capitalize} overrides", get([:nfrs, :environment_overrides, env]))
|
341
|
+
set([:nfrs, :environment_overrides, env], categories) unless categories.nil? || categories.empty?
|
342
|
+
end
|
343
|
+
end
|
344
|
+
|
345
|
+
def configure_rails_libraries
|
346
|
+
existing = get([:nfrs, :preferred_libraries, :rails]) || {}
|
347
|
+
{
|
348
|
+
auth: ask_with_default("Authentication gem", existing[:auth] || "devise"),
|
349
|
+
authz: ask_with_default("Authorization gem", existing[:authz] || "pundit"),
|
350
|
+
jobs: ask_with_default("Background jobs", existing[:jobs] || "sidekiq"),
|
351
|
+
testing: ask_list("Testing gems", existing[:testing] || %w[rspec factory_bot])
|
352
|
+
}
|
353
|
+
end
|
354
|
+
|
355
|
+
def configure_node_libraries
|
356
|
+
existing = get([:nfrs, :preferred_libraries, :node]) || {}
|
357
|
+
{
|
358
|
+
validation: ask_with_default("Validation library", existing[:validation] || "zod"),
|
359
|
+
orm: ask_with_default("ORM/Database", existing[:orm] || "prisma"),
|
360
|
+
testing: ask_with_default("Testing framework", existing[:testing] || "jest")
|
361
|
+
}
|
362
|
+
end
|
363
|
+
|
364
|
+
def configure_python_libraries
|
365
|
+
existing = get([:nfrs, :preferred_libraries, :python]) || {}
|
366
|
+
linting = ask_list("Linting tools", existing[:linting] || %w[ruff mypy])
|
367
|
+
{
|
368
|
+
validation: ask_with_default("Validation library", existing[:validation] || "pydantic"),
|
369
|
+
testing: ask_with_default("Testing framework", existing[:testing] || "pytest"),
|
370
|
+
linting: linting
|
371
|
+
}
|
372
|
+
end
|
373
|
+
|
374
|
+
# -------------------------------------------
|
375
|
+
# Logging & modes
|
376
|
+
# -------------------------------------------
|
377
|
+
def configure_logging
|
378
|
+
prompt.say("\nš Logging configuration")
|
379
|
+
prompt.say("-" * 40)
|
380
|
+
existing = get([:logging]) || {}
|
381
|
+
|
382
|
+
# TODO: Add default back once TTY-Prompt default validation issue is resolved
|
383
|
+
log_level = prompt.select("Log level:") do |menu|
|
384
|
+
menu.choice "Debug", "debug"
|
385
|
+
menu.choice "Info", "info"
|
386
|
+
menu.choice "Error", "error"
|
387
|
+
end
|
388
|
+
json = prompt.yes?("Use JSON log format?", default: existing.fetch(:json, false))
|
389
|
+
max_size = ask_with_default("Max log size (MB)", (existing[:max_size_mb] || 10).to_s) { |value| value.to_i }
|
390
|
+
max_backups = ask_with_default("Max backup files", (existing[:max_backups] || 5).to_s) { |value| value.to_i }
|
391
|
+
|
392
|
+
set([:logging], {
|
393
|
+
level: log_level,
|
394
|
+
json: json,
|
395
|
+
max_size_mb: max_size,
|
396
|
+
max_backups: max_backups
|
397
|
+
})
|
398
|
+
end
|
399
|
+
|
400
|
+
def configure_modes
|
401
|
+
prompt.say("\nš Operational modes")
|
402
|
+
prompt.say("-" * 40)
|
403
|
+
existing = get([:modes]) || {}
|
404
|
+
|
405
|
+
background = prompt.yes?("Run in background mode by default?", default: existing.fetch(:background_default, false))
|
406
|
+
watch = prompt.yes?("Enable watch mode integrations?", default: existing.fetch(:watch_enabled, false))
|
407
|
+
quick_mode = prompt.yes?("Enable quick mode (short timeouts) by default?", default: existing.fetch(:quick_mode_default, false))
|
408
|
+
|
409
|
+
set([:modes], {
|
410
|
+
background_default: background,
|
411
|
+
watch_enabled: watch,
|
412
|
+
quick_mode_default: quick_mode
|
413
|
+
})
|
414
|
+
end
|
415
|
+
|
416
|
+
# -------------------------------------------
|
417
|
+
# Preview & persistence
|
418
|
+
# -------------------------------------------
|
419
|
+
def generate_yaml
|
420
|
+
payload = @config.dup
|
421
|
+
payload[:schema_version] = SCHEMA_VERSION
|
422
|
+
payload[:generated_by] = "aidp setup wizard v#{Aidp::VERSION}"
|
423
|
+
payload[:generated_at] = Time.now.utc.iso8601
|
424
|
+
|
425
|
+
yaml = deep_stringify(payload).to_yaml
|
426
|
+
comment_header + annotate_yaml(yaml)
|
427
|
+
end
|
428
|
+
|
429
|
+
def comment_header
|
430
|
+
<<~HEADER
|
431
|
+
# AIDP configuration generated by the interactive setup wizard.
|
432
|
+
# Re-run `aidp config --interactive` to update. Manual edits are preserved.
|
433
|
+
HEADER
|
434
|
+
end
|
435
|
+
|
436
|
+
def annotate_yaml(yaml)
|
437
|
+
yaml
|
438
|
+
.sub(/^schema_version:/, "# Tracks configuration migrations\nschema_version:")
|
439
|
+
.sub(/^providers:/, "# Provider configuration (no secrets stored)\nproviders:")
|
440
|
+
.sub(/^work_loop:/, "# Work loop execution settings\nwork_loop:")
|
441
|
+
.sub(/^nfrs:/, "# Non-functional requirements to reference during planning\nnfrs:")
|
442
|
+
.sub(/^logging:/, "# Logging configuration\nlogging:")
|
443
|
+
.sub(/^modes:/, "# Defaults for background/watch/quick modes\nmodes:")
|
444
|
+
end
|
445
|
+
|
446
|
+
def display_preview(yaml_content)
|
447
|
+
prompt.say("\n" + "=" * 80)
|
448
|
+
prompt.say("š Configuration preview")
|
449
|
+
prompt.say("=" * 80)
|
450
|
+
prompt.say(yaml_content)
|
451
|
+
prompt.say("=" * 80 + "\n")
|
452
|
+
end
|
453
|
+
|
454
|
+
def display_diff(yaml_content)
|
455
|
+
existing_yaml = File.read(config_path)
|
456
|
+
diff_lines = line_diff(existing_yaml, yaml_content)
|
457
|
+
return if diff_lines.empty?
|
458
|
+
|
459
|
+
prompt.say("š Diff with existing configuration:")
|
460
|
+
diff_lines.each do |line|
|
461
|
+
case line[0]
|
462
|
+
when "+"
|
463
|
+
prompt.say(line, color: :green)
|
464
|
+
when "-"
|
465
|
+
prompt.say(line, color: :red)
|
466
|
+
else
|
467
|
+
prompt.say(line, color: :bright_black)
|
468
|
+
end
|
469
|
+
end
|
470
|
+
prompt.say("")
|
471
|
+
rescue Errno::ENOENT
|
472
|
+
nil
|
473
|
+
end
|
474
|
+
|
475
|
+
def dry_run_mode?(yaml_content)
|
476
|
+
return false unless dry_run
|
477
|
+
|
478
|
+
prompt.ok("Dry run mode active ā configuration was NOT written.")
|
479
|
+
display_warnings
|
480
|
+
@saved = false
|
481
|
+
true
|
482
|
+
end
|
483
|
+
|
484
|
+
def save_config(yaml_content)
|
485
|
+
Aidp::ConfigPaths.ensure_config_dir(project_dir)
|
486
|
+
File.write(config_path, yaml_content)
|
487
|
+
end
|
488
|
+
|
489
|
+
def display_warnings
|
490
|
+
return if @warnings.empty?
|
491
|
+
|
492
|
+
prompt.warn("\nWarnings:")
|
493
|
+
@warnings.each { |warning| prompt.warn(" ⢠#{warning}") }
|
494
|
+
end
|
495
|
+
|
496
|
+
def show_next_steps
|
497
|
+
prompt.say("\nš Setup complete!")
|
498
|
+
prompt.say("\nNext steps:")
|
499
|
+
prompt.say(" 1. Export provider API keys as environment variables.")
|
500
|
+
prompt.say(" 2. Run 'aidp init' to analyze the project.")
|
501
|
+
prompt.say(" 3. Run 'aidp execute' to start a work loop.")
|
502
|
+
prompt.say("")
|
503
|
+
end
|
504
|
+
|
505
|
+
# -------------------------------------------
|
506
|
+
# Helpers
|
507
|
+
# -------------------------------------------
|
508
|
+
def ask_with_default(question, default = nil)
|
509
|
+
existing_text = default.nil? ? "" : " [#{display_value(default)}]"
|
510
|
+
answer = prompt.ask("#{question}#{existing_text}:")
|
511
|
+
|
512
|
+
if answer.nil? || answer.strip.empty?
|
513
|
+
return default if default.nil? || !block_given?
|
514
|
+
return yield(default)
|
515
|
+
end
|
516
|
+
|
517
|
+
return nil if answer.strip.casecmp("clear").zero?
|
518
|
+
|
519
|
+
block_given? ? yield(answer) : answer
|
520
|
+
end
|
521
|
+
|
522
|
+
def ask_multiline(question, default)
|
523
|
+
prompt.say("#{question}:")
|
524
|
+
prompt.say(" (Enter text; submit empty line to finish. Type 'clear' alone to remove.)")
|
525
|
+
lines = []
|
526
|
+
loop do
|
527
|
+
line = prompt.ask("", default: nil)
|
528
|
+
break if line.nil? || line.empty?
|
529
|
+
return nil if line.strip.casecmp("clear").zero?
|
530
|
+
lines << line
|
531
|
+
end
|
532
|
+
return default if lines.empty?
|
533
|
+
|
534
|
+
lines.join("\n")
|
535
|
+
end
|
536
|
+
|
537
|
+
def ask_list(question, existing = [], allow_empty: false)
|
538
|
+
existing = Array(existing).compact
|
539
|
+
display = existing.any? ? " [#{existing.join(", ")}]" : ""
|
540
|
+
answer = prompt.ask("#{question}#{display}:")
|
541
|
+
|
542
|
+
return existing if answer.nil? || answer.strip.empty?
|
543
|
+
return [] if answer.strip.casecmp("clear").zero? && allow_empty
|
544
|
+
|
545
|
+
answer.split(",").map { |item| item.strip }.reject(&:empty?)
|
546
|
+
end
|
547
|
+
|
548
|
+
def validate_command(command)
|
549
|
+
return if command.nil? || command.strip.empty?
|
550
|
+
return if command.start_with?("echo")
|
551
|
+
|
552
|
+
executable = command.split(/\s+/).first
|
553
|
+
return if Aidp::Util.which(executable)
|
554
|
+
|
555
|
+
@warnings << "Command '#{command}' not found in PATH."
|
556
|
+
end
|
557
|
+
|
558
|
+
def fetch_retry_attempts(llm)
|
559
|
+
policy = llm[:retry_policy] || {}
|
560
|
+
(policy[:attempts] || 3).to_s
|
561
|
+
end
|
562
|
+
|
563
|
+
def fetch_retry_backoff(llm)
|
564
|
+
policy = llm[:retry_policy] || {}
|
565
|
+
(policy[:backoff_seconds] || 10).to_s
|
566
|
+
end
|
567
|
+
|
568
|
+
def detect_unit_test_command
|
569
|
+
return "bundle exec rspec" if project_file?("Gemfile") && Dir.exist?(File.join(project_dir, "spec"))
|
570
|
+
return "npm test" if project_file?("package.json")
|
571
|
+
return "pytest" if project_file?("pytest.ini") || Dir.exist?(File.join(project_dir, "tests"))
|
572
|
+
"echo 'No tests configured'"
|
573
|
+
end
|
574
|
+
|
575
|
+
def detect_lint_command
|
576
|
+
return "bundle exec rubocop" if project_file?(".rubocop.yml")
|
577
|
+
return "npm run lint" if project_file?("package.json")
|
578
|
+
return "ruff check ." if project_file?("pyproject.toml")
|
579
|
+
"echo 'No linter configured'"
|
580
|
+
end
|
581
|
+
|
582
|
+
def detect_format_command
|
583
|
+
return "bundle exec rubocop -A" if project_file?(".rubocop.yml")
|
584
|
+
return "npm run format" if project_file?("package.json")
|
585
|
+
return "ruff format ." if project_file?("pyproject.toml")
|
586
|
+
"echo 'No formatter configured'"
|
587
|
+
end
|
588
|
+
|
589
|
+
def detect_watch_patterns
|
590
|
+
if project_file?("Gemfile")
|
591
|
+
["spec/**/*_spec.rb", "lib/**/*.rb"]
|
592
|
+
elsif project_file?("package.json")
|
593
|
+
["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts"]
|
594
|
+
else
|
595
|
+
["**/*"]
|
596
|
+
end
|
597
|
+
end
|
598
|
+
|
599
|
+
def detect_source_patterns
|
600
|
+
if project_file?("Gemfile")
|
601
|
+
%w[app/**/* lib/**/*]
|
602
|
+
elsif project_file?("package.json")
|
603
|
+
%w[src/**/* app/**/*]
|
604
|
+
elsif project_file?("pyproject.toml")
|
605
|
+
%w[src/**/*]
|
606
|
+
else
|
607
|
+
%w[**/*]
|
608
|
+
end
|
609
|
+
end
|
610
|
+
|
611
|
+
def detect_stack
|
612
|
+
return :rails if project_file?("Gemfile") && project_file?("config/application.rb")
|
613
|
+
return :node if project_file?("package.json")
|
614
|
+
return :python if project_file?("pyproject.toml") || project_file?("requirements.txt")
|
615
|
+
|
616
|
+
:other
|
617
|
+
end
|
618
|
+
|
619
|
+
def default_model(provider)
|
620
|
+
case provider
|
621
|
+
when "anthropic" then "claude-3-5-sonnet-20241022"
|
622
|
+
when "openai" then "gpt-4.1"
|
623
|
+
when "google" then "gemini-1.5-pro"
|
624
|
+
when "azure" then "gpt-4"
|
625
|
+
else "claude-3-5-sonnet-20241022"
|
626
|
+
end
|
627
|
+
end
|
628
|
+
|
629
|
+
def show_provider_secrets_help(provider)
|
630
|
+
prompt.say("\nš” Provider setup:")
|
631
|
+
case provider
|
632
|
+
when "anthropic"
|
633
|
+
prompt.say("Export API key: export ANTHROPIC_API_KEY=sk-ant-...")
|
634
|
+
when "openai", "azure"
|
635
|
+
prompt.say("Export API key: export OPENAI_API_KEY=sk-...")
|
636
|
+
when "google"
|
637
|
+
prompt.say("Export API key: export GOOGLE_API_KEY=...")
|
638
|
+
else
|
639
|
+
prompt.say("Configure API credentials via environment variables.")
|
640
|
+
end
|
641
|
+
end
|
642
|
+
|
643
|
+
def load_existing_config
|
644
|
+
return {} unless File.exist?(config_path)
|
645
|
+
YAML.safe_load_file(config_path, permitted_classes: [Time]) || {}
|
646
|
+
rescue => e
|
647
|
+
@warnings << "Failed to parse existing configuration: #{e.message}"
|
648
|
+
{}
|
649
|
+
end
|
650
|
+
|
651
|
+
def config_path
|
652
|
+
Aidp::ConfigPaths.config_file(project_dir)
|
653
|
+
end
|
654
|
+
|
655
|
+
def relative_config_path
|
656
|
+
config_path.sub("#{project_dir}/", "")
|
657
|
+
end
|
658
|
+
|
659
|
+
# -------------------------------------------
|
660
|
+
# Hash utilities
|
661
|
+
# -------------------------------------------
|
662
|
+
def get(path)
|
663
|
+
path.reduce(@config) do |acc, key|
|
664
|
+
acc.is_a?(Hash) ? acc[key.to_sym] : nil
|
665
|
+
end
|
666
|
+
end
|
667
|
+
|
668
|
+
def set(path, value)
|
669
|
+
parent = path[0...-1].reduce(@config) do |acc, key|
|
670
|
+
acc[key.to_sym] ||= {}
|
671
|
+
acc[key.to_sym]
|
672
|
+
end
|
673
|
+
parent[path.last.to_sym] = value
|
674
|
+
end
|
675
|
+
|
676
|
+
def delete_path(path)
|
677
|
+
parent = path[0...-1].reduce(@config) do |acc, key|
|
678
|
+
acc[key.to_sym] ||= {}
|
679
|
+
acc[key.to_sym]
|
680
|
+
end
|
681
|
+
parent.delete(path.last.to_sym)
|
682
|
+
end
|
683
|
+
|
684
|
+
def deep_symbolize(object)
|
685
|
+
case object
|
686
|
+
when Hash
|
687
|
+
object.each_with_object({}) do |(key, value), memo|
|
688
|
+
memo[key.to_sym] = deep_symbolize(value)
|
689
|
+
end
|
690
|
+
when Array
|
691
|
+
object.map { |item| deep_symbolize(item) }
|
692
|
+
else
|
693
|
+
object
|
694
|
+
end
|
695
|
+
end
|
696
|
+
|
697
|
+
def deep_stringify(object)
|
698
|
+
case object
|
699
|
+
when Hash
|
700
|
+
object.each_with_object({}) do |(key, value), memo|
|
701
|
+
memo[key.to_s] = deep_stringify(value)
|
702
|
+
end
|
703
|
+
when Array
|
704
|
+
object.map { |item| deep_stringify(item) }
|
705
|
+
else
|
706
|
+
object
|
707
|
+
end
|
708
|
+
end
|
709
|
+
|
710
|
+
# -------------------------------------------
|
711
|
+
# Diff utilities
|
712
|
+
# -------------------------------------------
|
713
|
+
def line_diff(old_str, new_str)
|
714
|
+
old_lines = old_str.split("\n")
|
715
|
+
new_lines = new_str.split("\n")
|
716
|
+
lcs_matrix = build_lcs_matrix(old_lines, new_lines)
|
717
|
+
backtrack_diff(lcs_matrix, old_lines, new_lines).reverse
|
718
|
+
end
|
719
|
+
|
720
|
+
def build_lcs_matrix(a_lines, b_lines)
|
721
|
+
Array.new(a_lines.length + 1) do
|
722
|
+
Array.new(b_lines.length + 1, 0)
|
723
|
+
end.tap do |matrix|
|
724
|
+
a_lines.each_index do |i|
|
725
|
+
b_lines.each_index do |j|
|
726
|
+
matrix[i + 1][j + 1] = if a_lines[i] == b_lines[j]
|
727
|
+
matrix[i][j] + 1
|
728
|
+
else
|
729
|
+
[matrix[i + 1][j], matrix[i][j + 1]].max
|
730
|
+
end
|
731
|
+
end
|
732
|
+
end
|
733
|
+
end
|
734
|
+
end
|
735
|
+
|
736
|
+
def backtrack_diff(matrix, a_lines, b_lines)
|
737
|
+
diff = []
|
738
|
+
i = a_lines.length
|
739
|
+
j = b_lines.length
|
740
|
+
|
741
|
+
while i > 0 && j > 0
|
742
|
+
if a_lines[i - 1] == b_lines[j - 1]
|
743
|
+
diff << " #{a_lines[i - 1]}"
|
744
|
+
i -= 1
|
745
|
+
j -= 1
|
746
|
+
elsif matrix[i - 1][j] >= matrix[i][j - 1]
|
747
|
+
diff << "- #{a_lines[i - 1]}"
|
748
|
+
i -= 1
|
749
|
+
else
|
750
|
+
diff << "+ #{b_lines[j - 1]}"
|
751
|
+
j -= 1
|
752
|
+
end
|
753
|
+
end
|
754
|
+
|
755
|
+
while i > 0
|
756
|
+
diff << "- #{a_lines[i - 1]}"
|
757
|
+
i -= 1
|
758
|
+
end
|
759
|
+
|
760
|
+
while j > 0
|
761
|
+
diff << "+ #{b_lines[j - 1]}"
|
762
|
+
j -= 1
|
763
|
+
end
|
764
|
+
|
765
|
+
diff
|
766
|
+
end
|
767
|
+
|
768
|
+
def display_value(value)
|
769
|
+
value.is_a?(Array) ? value.join(", ") : value
|
770
|
+
end
|
771
|
+
|
772
|
+
def project_file?(relative_path)
|
773
|
+
File.exist?(File.join(project_dir, relative_path))
|
774
|
+
end
|
775
|
+
end
|
776
|
+
end
|
777
|
+
end
|