aidp 0.48.0 → 0.49.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: 497afaf23ef14fc5ebced21f0ca90a090bcc66f642002fc289d3757dc9a136f8
4
- data.tar.gz: f2a8e664cb040b4869a5b2b1ccf15fabe9524baed622bf6b9a04ce57e0d3c864
3
+ metadata.gz: 0c5a91840d333d7b33122434b8cc2efcad0149becf640e3932d5d1ea6fd799f1
4
+ data.tar.gz: 0604c7d3798c6dc90aa1ccf543916e73e63fabdd4bcc58ae1a0864cbb0277a0a
5
5
  SHA512:
6
- metadata.gz: 69b44c42f6c45c7d7239674c2f06f3326b1437c477bea43f98dfcf9fdfab3ed1a9811c14fd20ec49e7af0af0efe798c0cfe7e74443f43a39de263f30e62cb745
7
- data.tar.gz: 5be71e4d983121166264108f9711245d7f26129a26e318a6a0ce9f38b3a5aaabba30544e818c41a6606671554e8a6264adba4abdd622b0c49e8d0c0e90051591
6
+ metadata.gz: 8dbaefb32e45cd9687524dea4cd72dc4c38eb13bb766f7e5b476e67481338ad76e6b6c72c0a80cd1e479487701266036c8d1b8cbab74ae288ff44ccaa5d891ac
7
+ data.tar.gz: b2de14911ec9b019235a4966d74208a61d5e2970a9fd710b31d0b58d2ec6f741bbdd6eb0a8314a6fbc7916bbd3c24c6352efa577080d356001dbd583b4aed6f3
@@ -280,13 +280,45 @@ module Aidp
280
280
  # Extract wizard-compatible config including custom_ports
281
281
  {
282
282
  providers: aidp_config.dig("providers")&.keys,
283
- test_framework: aidp_config.dig("work_loop", "test_commands", 0, "framework"),
283
+ test_framework: extract_test_framework_for_generation(aidp_config),
284
284
  linters: aidp_config.dig("work_loop", "linting", "tools"),
285
285
  watch_mode: aidp_config.dig("work_loop", "watch", "enabled"),
286
286
  custom_ports: aidp_config.dig("devcontainer", "custom_ports")
287
287
  }.compact
288
288
  end
289
289
 
290
+ def extract_test_framework_for_generation(aidp_config)
291
+ framework_from_work_loop_commands(aidp_config) ||
292
+ aidp_config.dig("work_loop", "test_commands", 0, "framework")
293
+ end
294
+
295
+ def framework_from_work_loop_commands(aidp_config)
296
+ candidates = Array(aidp_config.dig("work_loop", "commands"))
297
+ command_text = tagged_test_command(candidates) || first_recognized_string_command(candidates)
298
+ framework = Aidp::ToolingDetector.framework_from_command(command_text)
299
+ return if framework == :unknown
300
+
301
+ framework.to_s
302
+ end
303
+
304
+ # `work_loop.commands` entries may be hashes with a category, or bare
305
+ # command strings (no category available). Prefer an explicitly tagged
306
+ # test command; when only bare strings are present, fall back to the
307
+ # first one whose command text matches a known test framework.
308
+ def tagged_test_command(candidates)
309
+ candidates.find do |candidate|
310
+ candidate.is_a?(Hash) &&
311
+ # `generate_yaml` writes category as a symbol (e.g. `:test`); YAML
312
+ # preserves symbols on load, so coerce to string before comparing.
313
+ candidate["category"].to_s == "test"
314
+ end&.dig("command")
315
+ end
316
+
317
+ def first_recognized_string_command(candidates)
318
+ candidates.select { |candidate| candidate.is_a?(String) }
319
+ .find { |command| Aidp::ToolingDetector.framework_from_command(command) != :unknown }
320
+ end
321
+
290
322
  def build_config_from_aidp_yml(devcontainer_config)
291
323
  config = {}
292
324
 
@@ -909,23 +909,28 @@ module Aidp
909
909
  # Load commands from configuration, supporting both new generic format
910
910
  # and legacy category-specific format for backwards compatibility
911
911
  def load_commands
912
- commands = []
913
-
914
912
  # Load from new generic commands array if present
915
913
  raw_commands = work_loop_config[:commands] || []
916
- commands.concat(normalize_generic_commands(raw_commands))
917
-
918
- # Load from legacy category-specific arrays for backwards compatibility
919
- commands.concat(load_legacy_commands)
914
+ generic_commands = normalize_generic_commands(raw_commands)
915
+ legacy_commands = load_legacy_commands
916
+ commands = deduplicated_commands(generic_commands, legacy_commands)
920
917
 
921
918
  Aidp.log_debug("configuration", "loaded_commands",
922
919
  total: commands.size,
923
920
  from_generic: raw_commands.size,
924
- from_legacy: commands.size - raw_commands.size)
921
+ from_legacy: legacy_commands.size)
925
922
 
926
923
  commands
927
924
  end
928
925
 
926
+ def deduplicated_commands(*command_sets)
927
+ command_sets.flatten.uniq { |command| deduplication_key(command) }
928
+ end
929
+
930
+ def deduplication_key(command)
931
+ command.slice(:command, :required, :run_after, :category, :timeout_seconds)
932
+ end
933
+
929
934
  # Normalize generic command format
930
935
  # @param commands [Array] Raw command configurations
931
936
  # @return [Array<Hash>] Normalized command configs
@@ -9,6 +9,7 @@ require "json"
9
9
  require "ostruct"
10
10
  require_relative "in_memory_config_adapter"
11
11
  require_relative "in_memory_config_manager"
12
+ require_relative "../tooling_detector"
12
13
  require_relative "../security"
13
14
 
14
15
  module Aidp
@@ -34,6 +35,20 @@ module Aidp
34
35
  balanced: 10,
35
36
  quick_sketch: 3
36
37
  }.freeze
38
+ PHASE_TWO_SUGGESTION_SCHEMA = {
39
+ "commands" => [
40
+ {
41
+ "name" => "identifier",
42
+ "command" => "shell command",
43
+ "category" => "test|lint|formatter|build|documentation|custom",
44
+ "run_after" => "each_unit|full_loop|on_completion",
45
+ "required" => true
46
+ }
47
+ ],
48
+ "watch_patterns" => ["spec/**/*_spec.rb"],
49
+ "guard_include_patterns" => ["lib/**/*"],
50
+ "guard_exclude_patterns" => ["node_modules/**"]
51
+ }.freeze
37
52
 
38
53
  attr_reader :project_dir, :prompt, :dry_run
39
54
  # Expose state for testability
@@ -46,47 +61,50 @@ module Aidp
46
61
  @warnings = []
47
62
  @existing_config = load_existing_config
48
63
  @config = deep_symbolize(@existing_config)
64
+ @original_config_content = File.exist?(config_path) ? File.read(config_path) : nil
49
65
  @saved = false
66
+ @phase_one_persisted = false
50
67
  end
51
68
 
52
69
  def run
70
+ final_save_succeeded = false
53
71
  display_welcome
54
72
  # Normalize any legacy tier/model_family entries before prompting
55
73
  normalize_existing_model_families!
56
74
  normalize_existing_thinking_tiers!
57
75
  return @saved if skip_wizard?
58
76
 
59
- configure_providers
60
- configure_harness_settings
61
- configure_thinking_tiers
62
- configure_work_loop
63
- configure_prd_generation
64
- configure_branching
65
- configure_artifacts
66
- configure_nfrs
67
- configure_logging
68
- configure_auto_update
69
- configure_modes
70
- configure_devcontainer
77
+ run_phase_one
78
+ persist_phase_one_config unless dry_run
79
+ run_phase_two
71
80
 
72
81
  yaml_content = generate_yaml
73
82
  display_preview(yaml_content)
74
- display_diff(yaml_content) if @existing_config.any?
83
+ display_diff(yaml_content) if @original_config_content
75
84
 
76
85
  return true if dry_run_mode?(yaml_content)
77
86
 
78
87
  if prompt.yes?("Save this configuration?", default: true)
79
88
  save_config(yaml_content)
89
+ final_save_succeeded = true
80
90
  prompt.ok("āœ… Configuration saved to #{relative_config_path}")
81
91
  show_next_steps
82
92
  display_warnings
83
93
  @saved = true
84
94
  else
95
+ rollback_phase_one_config
85
96
  prompt.warn("Configuration not saved")
86
97
  display_warnings
87
98
  end
88
99
 
89
100
  @saved
101
+ ensure
102
+ # Only roll back when the phase-one snapshot has not been replaced
103
+ # by a successful final write. save_config clears @phase_one_persisted
104
+ # once the YAML is on disk, so a post-write artifact failure leaves
105
+ # the user's chosen config intact instead of being overwritten by
106
+ # the original.
107
+ rollback_phase_one_config if @phase_one_persisted && !final_save_succeeded
90
108
  end
91
109
 
92
110
  def saved?
@@ -117,6 +135,25 @@ module Aidp
117
135
  # -------------------------------------------
118
136
  # Provider configuration
119
137
  # -------------------------------------------
138
+ def run_phase_one
139
+ configure_providers
140
+ configure_harness_settings
141
+ configure_work_loop_phase_one
142
+ configure_prd_generation
143
+ configure_branching
144
+ configure_artifacts
145
+ configure_nfrs
146
+ configure_logging
147
+ configure_auto_update
148
+ configure_modes
149
+ configure_devcontainer
150
+ end
151
+
152
+ def run_phase_two
153
+ configure_thinking_tiers
154
+ configure_work_loop_phase_two
155
+ end
156
+
120
157
  def discover_available_providers
121
158
  require "agent_harness"
122
159
 
@@ -494,16 +531,24 @@ module Aidp
494
531
  prompt.say("\nāš™ļø Work loop configuration")
495
532
  prompt.say("-" * 40)
496
533
 
534
+ configure_work_loop_phase_one
535
+ configure_work_loop_phase_two
536
+ end
537
+
538
+ def configure_work_loop_phase_one
497
539
  configure_work_loop_limits
498
- configure_output_filtering
499
- configure_commands
500
- configure_watch_patterns
501
- configure_guards
502
540
  configure_coverage
503
541
  configure_interactive_testing
504
542
  configure_vcs_behavior
505
543
  end
506
544
 
545
+ def configure_work_loop_phase_two
546
+ configure_commands
547
+ configure_watch_patterns
548
+ configure_guards
549
+ configure_output_filtering
550
+ end
551
+
507
552
  def configure_prd_generation
508
553
  prompt.say("\n🧭 PRD generation style")
509
554
  prompt.say("-" * 40)
@@ -532,7 +577,7 @@ module Aidp
532
577
  prompt.say(" Commands run automatically during work loops to validate changes")
533
578
  prompt.say(" Commands can run: after each unit, at full loop end, or on completion")
534
579
 
535
- existing_commands = get(%i[work_loop commands]) || []
580
+ existing_commands = effective_work_loop_commands
536
581
 
537
582
  # If user has existing commands, offer to edit or start fresh
538
583
  if existing_commands.any?
@@ -545,7 +590,10 @@ module Aidp
545
590
  end
546
591
 
547
592
  case action
548
- when :skip, :keep, nil
593
+ when :keep
594
+ save_work_loop_commands(existing_commands)
595
+ return
596
+ when :skip, nil
549
597
  return
550
598
  when :replace
551
599
  existing_commands = []
@@ -557,6 +605,7 @@ module Aidp
557
605
  end
558
606
  else
559
607
  return unless prompt.yes?("Configure deterministic commands?", default: true)
608
+ existing_commands = phase_two_suggestions[:commands]
560
609
  end
561
610
 
562
611
  commands = existing_commands.dup
@@ -589,7 +638,7 @@ module Aidp
589
638
  end
590
639
  end
591
640
 
592
- set(%i[work_loop commands], commands)
641
+ save_work_loop_commands(commands)
593
642
  prompt.say("āœ… Configured #{commands.size} command(s)")
594
643
  end
595
644
 
@@ -892,56 +941,27 @@ module Aidp
892
941
  end
893
942
 
894
943
  def collect_commands_for_filtering
895
- commands = []
896
-
897
- # Test commands
898
- test_config = get(%i[work_loop test]) || {}
899
- if test_config[:unit] && !test_config[:unit].start_with?("echo")
900
- commands << {
901
- key: "unit_test",
902
- name: "Unit Tests",
903
- command: test_config[:unit],
904
- type: :test
905
- }
906
- end
907
- if test_config[:integration] && !test_config[:integration].to_s.empty? && !test_config[:integration].start_with?("echo")
908
- commands << {
909
- key: "integration_test",
910
- name: "Integration Tests",
911
- command: test_config[:integration],
912
- type: :test
913
- }
914
- end
915
- if test_config[:e2e] && !test_config[:e2e].to_s.empty? && !test_config[:e2e].start_with?("echo")
916
- commands << {
917
- key: "e2e_test",
918
- name: "E2E Tests",
919
- command: test_config[:e2e],
920
- type: :test
921
- }
922
- end
944
+ effective_work_loop_commands.filter_map do |command|
945
+ next unless %i[test lint].include?(command[:category].to_sym)
946
+ next if command[:command].to_s.start_with?("echo")
923
947
 
924
- # Lint commands
925
- lint_config = get(%i[work_loop lint]) || {}
926
- if lint_config[:command] && !lint_config[:command].start_with?("echo")
927
- commands << {
928
- key: "lint",
929
- name: "Linter",
930
- command: lint_config[:command],
931
- type: :lint
948
+ {
949
+ key: command[:name].to_s.gsub(/[^a-z0-9]+/i, "_").downcase,
950
+ name: command[:name].to_s.tr("_", " ").split.map(&:capitalize).join(" "),
951
+ command: command[:command],
952
+ type: command[:category].to_sym
932
953
  }
933
954
  end
934
-
935
- commands
936
955
  end
937
956
 
938
957
  def create_filter_factory
939
- # Build in-memory configuration adapters for the factory
940
- # This enables AGD to work before the config file is written
958
+ # Always use in-memory config objects here. Phase-two edits
959
+ # (e.g. configure_thinking_tiers) live only in @config until the
960
+ # final save; switching to disk-backed Configuration would miss
961
+ # those user choices and pick a stale model via ThinkingDepthManager.
941
962
  config_adapter = build_in_memory_config_adapter
942
963
  config_manager = build_in_memory_config_manager
943
964
 
944
- # Create provider factory with in-memory config manager
945
965
  provider_factory = Aidp::Harness::ProviderFactory.new(config_manager)
946
966
 
947
967
  Aidp.log_debug("setup_wizard", "creating_filter_factory",
@@ -1035,18 +1055,19 @@ module Aidp
1035
1055
 
1036
1056
  def configure_watch_patterns
1037
1057
  existing = get(%i[work_loop test watch]) || {}
1038
- default_patterns = detect_watch_patterns
1058
+ default_patterns = existing[:patterns] || detect_watch_patterns
1039
1059
 
1040
- watch_patterns = ask_list("Test watch patterns (comma-separated)", existing.fetch(:patterns, default_patterns))
1060
+ watch_patterns = ask_list("Test watch patterns (comma-separated)", default_patterns)
1041
1061
  set(%i[work_loop test watch], {patterns: watch_patterns}) if watch_patterns.any?
1042
1062
  end
1043
1063
 
1044
1064
  def configure_guards
1045
1065
  existing = get(%i[work_loop guards]) || {}
1046
1066
 
1047
- include_patterns = ask_list("Guard include patterns", existing[:include] || detect_source_patterns)
1067
+ include_patterns = ask_list("Guard include patterns",
1068
+ existing[:include] || detect_source_patterns)
1048
1069
  exclude_patterns = ask_list("Guard exclude patterns",
1049
- existing[:exclude] || ["node_modules/**", "dist/**", "build/**"])
1070
+ existing[:exclude] || detect_guard_exclude_patterns)
1050
1071
  max_lines = ask_with_default("Max lines changed per commit",
1051
1072
  (existing[:max_lines_changed_per_commit] || 300).to_s) do |value|
1052
1073
  value.to_i
@@ -1976,7 +1997,7 @@ module Aidp
1976
1997
  end
1977
1998
 
1978
1999
  def display_diff(yaml_content)
1979
- existing_yaml = File.read(config_path)
2000
+ existing_yaml = @original_config_content || File.read(config_path)
1980
2001
  diff_lines = line_diff(existing_yaml, yaml_content)
1981
2002
  return if diff_lines.empty?
1982
2003
 
@@ -2000,20 +2021,47 @@ module Aidp
2000
2021
  return false unless dry_run
2001
2022
 
2002
2023
  prompt.ok("Dry run mode active – configuration was NOT written.")
2024
+ rollback_phase_one_config
2003
2025
  display_warnings
2004
2026
  @saved = false
2005
2027
  true
2006
2028
  end
2007
2029
 
2008
- def save_config(yaml_content)
2030
+ def save_config(yaml_content, generate_artifacts: true)
2009
2031
  Aidp::ConfigPaths.ensure_config_dir(project_dir)
2010
2032
  File.write(config_path, yaml_content)
2033
+ # Once the YAML is on disk, treat the final save as committed even if
2034
+ # artifact generation fails afterwards. Clearing @phase_one_persisted
2035
+ # here keeps a post-write raise from triggering the run-level rollback
2036
+ # in #run's ensure block, which would otherwise overwrite the user's
2037
+ # just-saved configuration with the original phase-one snapshot.
2038
+ @phase_one_persisted = false
2039
+ return unless generate_artifacts
2040
+
2011
2041
  generate_mcp_risk_profile
2012
2042
 
2013
2043
  # Generate devcontainer if managed
2014
2044
  generate_devcontainer_file
2015
2045
  end
2016
2046
 
2047
+ def persist_phase_one_config
2048
+ save_config(generate_yaml, generate_artifacts: false)
2049
+ reload_config_from_disk
2050
+ @phase_one_persisted = true
2051
+ end
2052
+
2053
+ def rollback_phase_one_config
2054
+ return unless @phase_one_persisted
2055
+
2056
+ if @original_config_content
2057
+ File.write(config_path, @original_config_content)
2058
+ elsif File.exist?(config_path)
2059
+ File.delete(config_path)
2060
+ end
2061
+
2062
+ @phase_one_persisted = false
2063
+ end
2064
+
2017
2065
  def generate_mcp_risk_profile
2018
2066
  classifier = Aidp::Security::McpToolRiskClassifier.new(
2019
2067
  build_in_memory_config_adapter,
@@ -2107,6 +2155,9 @@ module Aidp
2107
2155
  end
2108
2156
 
2109
2157
  def detect_unit_test_command
2158
+ command = detect_tooling.test_commands.first
2159
+ return command if command
2160
+
2110
2161
  return "bundle exec rspec" if project_file?("Gemfile") && Dir.exist?(File.join(project_dir, "spec"))
2111
2162
  return "npm test" if project_file?("package.json")
2112
2163
  return "pytest" if project_file?("pytest.ini") || Dir.exist?(File.join(project_dir, "tests"))
@@ -2115,6 +2166,9 @@ module Aidp
2115
2166
  end
2116
2167
 
2117
2168
  def detect_lint_command
2169
+ command = detect_tooling.lint_commands.first
2170
+ return command if command
2171
+
2118
2172
  return "bundle exec rubocop" if project_file?(".rubocop.yml")
2119
2173
  return "npm run lint" if project_file?("package.json")
2120
2174
  return "ruff check ." if project_file?("pyproject.toml")
@@ -2123,6 +2177,9 @@ module Aidp
2123
2177
  end
2124
2178
 
2125
2179
  def detect_format_command
2180
+ command = detect_tooling.formatter_commands.first
2181
+ return command if command
2182
+
2126
2183
  return "bundle exec rubocop -A" if project_file?(".rubocop.yml")
2127
2184
  return "npm run format" if project_file?("package.json")
2128
2185
  return "ruff format ." if project_file?("pyproject.toml")
@@ -2131,6 +2188,9 @@ module Aidp
2131
2188
  end
2132
2189
 
2133
2190
  def detect_watch_patterns
2191
+ suggestions = phase_two_suggestions[:watch_patterns]
2192
+ return suggestions if suggestions.any?
2193
+
2134
2194
  if project_file?("Gemfile")
2135
2195
  ["spec/**/*_spec.rb", "lib/**/*.rb"]
2136
2196
  elsif project_file?("package.json")
@@ -2141,6 +2201,9 @@ module Aidp
2141
2201
  end
2142
2202
 
2143
2203
  def detect_source_patterns
2204
+ suggestions = phase_two_suggestions[:guard_include_patterns]
2205
+ return suggestions if suggestions.any?
2206
+
2144
2207
  if project_file?("Gemfile")
2145
2208
  %w[app/**/* lib/**/*]
2146
2209
  elsif project_file?("package.json")
@@ -2152,6 +2215,13 @@ module Aidp
2152
2215
  end
2153
2216
  end
2154
2217
 
2218
+ def detect_guard_exclude_patterns
2219
+ suggestions = phase_two_suggestions[:guard_exclude_patterns]
2220
+ return suggestions if suggestions.any?
2221
+
2222
+ ["node_modules/**", "dist/**", "build/**"]
2223
+ end
2224
+
2155
2225
  def detect_coverage_command(tool)
2156
2226
  case tool
2157
2227
  when "simplecov"
@@ -2644,6 +2714,11 @@ module Aidp
2644
2714
  Aidp::ConfigPaths.config_file(project_dir)
2645
2715
  end
2646
2716
 
2717
+ def reload_config_from_disk
2718
+ @existing_config = load_existing_config
2719
+ @config = deep_symbolize(@existing_config)
2720
+ end
2721
+
2647
2722
  def relative_config_path
2648
2723
  config_path.sub("#{project_dir}/", "")
2649
2724
  end
@@ -2673,6 +2748,35 @@ module Aidp
2673
2748
  parent.delete(path.last.to_sym)
2674
2749
  end
2675
2750
 
2751
+ def save_work_loop_commands(commands)
2752
+ set(%i[work_loop commands], commands)
2753
+ delete_legacy_work_loop_command_keys
2754
+ delete_structured_legacy_work_loop_command_keys
2755
+ end
2756
+
2757
+ def delete_legacy_work_loop_command_keys
2758
+ %i[test_commands lint_commands formatter_commands build_commands documentation_commands].each do |key|
2759
+ delete_path([:work_loop, key])
2760
+ end
2761
+ end
2762
+
2763
+ def delete_structured_legacy_work_loop_command_keys
2764
+ delete_structured_legacy_command_fields(:test, %i[unit integration e2e timeout_seconds])
2765
+ delete_structured_legacy_command_fields(:lint, %i[command format])
2766
+ end
2767
+
2768
+ def delete_structured_legacy_command_fields(section, fields)
2769
+ config = get([:work_loop, section])
2770
+ return unless config.is_a?(Hash)
2771
+
2772
+ fields.each do |field|
2773
+ config.delete(field)
2774
+ config.delete(field.to_s)
2775
+ end
2776
+
2777
+ delete_path([:work_loop, section]) if config.empty?
2778
+ end
2779
+
2676
2780
  def deep_symbolize(object)
2677
2781
  case object
2678
2782
  when Hash
@@ -2771,6 +2875,421 @@ module Aidp
2771
2875
  %w[mini standard thinking pro max]
2772
2876
  end
2773
2877
 
2878
+ def detect_tooling
2879
+ @detect_tooling ||= Aidp::ToolingDetector.detect(project_dir)
2880
+ end
2881
+
2882
+ def phase_two_suggestions
2883
+ @phase_two_suggestions ||= build_phase_two_suggestions
2884
+ end
2885
+
2886
+ def build_phase_two_suggestions
2887
+ defaults = {
2888
+ commands: default_phase_two_commands,
2889
+ watch_patterns: [],
2890
+ guard_include_patterns: [],
2891
+ guard_exclude_patterns: ["node_modules/**", "dist/**", "build/**"]
2892
+ }
2893
+
2894
+ tooling_detected = detect_tooling.test_commands.any? ||
2895
+ detect_tooling.lint_commands.any? ||
2896
+ detect_tooling.formatter_commands.any?
2897
+
2898
+ generated = if tooling_detected
2899
+ generate_phase_two_suggestions_with_ai
2900
+ else
2901
+ generate_phase_two_suggestions_with_ai(collect_phase_two_tooling_context)
2902
+ end
2903
+
2904
+ defaults.merge(generated) { |_key, fallback, suggested| normalize_phase_two_value(suggested, fallback) }
2905
+ rescue => e
2906
+ Aidp.log_warn("setup_wizard", "phase_two_suggestions_failed", error: e.message)
2907
+ defaults
2908
+ end
2909
+
2910
+ def collect_phase_two_tooling_context
2911
+ @phase_two_tooling_context ||= begin
2912
+ stack = select_phase_two_stack
2913
+
2914
+ {
2915
+ stack: stack,
2916
+ test_tool: ask_with_default("Primary test tool or command (optional)"),
2917
+ lint_tool: ask_with_default("Primary lint tool or command (optional)"),
2918
+ formatter_tool: ask_with_default("Primary formatter tool or command (optional)")
2919
+ }.compact
2920
+ end
2921
+ end
2922
+
2923
+ def select_phase_two_stack
2924
+ default_stack = detect_stack
2925
+ default_label = phase_two_stack_options.find { |label, value| value == default_stack }&.first
2926
+
2927
+ selected_stack = prompt.select(
2928
+ "Tooling detection came up empty. Which stack best matches this repo?",
2929
+ default: default_label
2930
+ ) do |menu|
2931
+ phase_two_stack_options.each do |label, value|
2932
+ menu.choice label, value
2933
+ end
2934
+ end
2935
+
2936
+ return ask_with_default("Name this stack", "custom") if selected_stack == :other
2937
+
2938
+ selected_stack.to_s
2939
+ end
2940
+
2941
+ def phase_two_stack_options
2942
+ [
2943
+ ["Rails / Ruby", :rails],
2944
+ ["Node / JavaScript", :node],
2945
+ ["Python", :python],
2946
+ ["Other / Custom", :other]
2947
+ ]
2948
+ end
2949
+
2950
+ def default_phase_two_commands
2951
+ commands = []
2952
+
2953
+ detect_tooling.test_commands.each_with_index do |command, index|
2954
+ commands << {
2955
+ name: index.zero? ? "test" : "test_#{index + 1}",
2956
+ command: command,
2957
+ category: :test,
2958
+ run_after: :each_unit,
2959
+ required: true,
2960
+ timeout_seconds: 1800
2961
+ }
2962
+ end
2963
+
2964
+ detect_tooling.lint_commands.each_with_index do |command, index|
2965
+ commands << {
2966
+ name: index.zero? ? "lint" : "lint_#{index + 1}",
2967
+ command: command,
2968
+ category: :lint,
2969
+ run_after: :each_unit,
2970
+ required: true,
2971
+ timeout_seconds: 300
2972
+ }
2973
+ end
2974
+
2975
+ detect_tooling.formatter_commands.each_with_index do |command, index|
2976
+ commands << {
2977
+ name: index.zero? ? "format" : "format_#{index + 1}",
2978
+ command: command,
2979
+ category: :formatter,
2980
+ run_after: :on_completion,
2981
+ required: false,
2982
+ timeout_seconds: 120
2983
+ }
2984
+ end
2985
+
2986
+ commands
2987
+ end
2988
+
2989
+ def generate_phase_two_suggestions_with_ai(tooling_context = nil)
2990
+ config_manager = build_in_memory_config_manager
2991
+ provider_name = config_manager.default_provider
2992
+ return {} unless provider_name
2993
+
2994
+ provider_factory = Aidp::Harness::ProviderFactory.new(config_manager)
2995
+ provider = provider_factory.create_provider(provider_name, {})
2996
+ response = provider.send_message(prompt: phase_two_suggestion_prompt(tooling_context), session: nil)
2997
+
2998
+ parse_phase_two_suggestions(response)
2999
+ rescue => e
3000
+ Aidp.log_warn("setup_wizard", "phase_two_suggestions_ai_failed",
3001
+ error: e.message, provider: provider_name)
3002
+ {}
3003
+ end
3004
+
3005
+ def phase_two_suggestion_prompt(tooling_context = nil)
3006
+ <<~PROMPT
3007
+ Generate deterministic work loop defaults for this repository.
3008
+ Use the detected tooling and frameworks. Prefer concise, safe defaults.
3009
+ Return ONLY JSON matching this schema:
3010
+ #{JSON.pretty_generate(PHASE_TWO_SUGGESTION_SCHEMA)}
3011
+
3012
+ Detected test commands: #{detect_tooling.test_commands.to_json}
3013
+ Detected lint commands: #{detect_tooling.lint_commands.to_json}
3014
+ Detected formatter commands: #{detect_tooling.formatter_commands.to_json}
3015
+ Detected frameworks: #{detect_tooling.frameworks.values.uniq.to_json}
3016
+ User-supplied tooling hints: #{tooling_context.to_json}
3017
+
3018
+ Rules:
3019
+ - Keep command values close to the detected commands.
3020
+ - When detection is empty, use the user-supplied tooling hints as the primary signal.
3021
+ - Use category values from the schema.
3022
+ - Use run_after each_unit for tests and linters, on_completion for formatters.
3023
+ - Watch patterns should focus on test and source files for detected tools.
3024
+ - Guard include patterns should focus on source paths.
3025
+ - Guard exclude patterns should include generated/vendor directories when relevant.
3026
+ PROMPT
3027
+ end
3028
+
3029
+ def parse_phase_two_suggestions(response)
3030
+ response_text = response.is_a?(String) ? response : response.to_s
3031
+ parsed = parse_phase_two_suggestion_payload(response_text)
3032
+ return {} unless parsed.is_a?(Hash)
3033
+
3034
+ {
3035
+ commands: normalize_suggested_commands(parsed[:commands]),
3036
+ watch_patterns: normalize_suggested_patterns(parsed[:watch_patterns]),
3037
+ guard_include_patterns: normalize_suggested_patterns(parsed[:guard_include_patterns]),
3038
+ guard_exclude_patterns: normalize_suggested_patterns(parsed[:guard_exclude_patterns])
3039
+ }
3040
+ rescue JSON::ParserError => e
3041
+ Aidp.log_warn("setup_wizard", "phase_two_suggestions_parse_failed", error: e.message)
3042
+ {}
3043
+ end
3044
+
3045
+ def normalize_suggested_patterns(patterns)
3046
+ Array(patterns).select { |pattern| pattern.is_a?(String) && !pattern.empty? }
3047
+ end
3048
+
3049
+ def normalize_suggested_commands(commands)
3050
+ Array(commands).filter_map do |command|
3051
+ next unless command.is_a?(Hash)
3052
+
3053
+ category = command[:category].to_s
3054
+ run_after = command[:run_after].to_s
3055
+ command_value = command[:command]
3056
+ timeout_seconds = normalize_timeout_seconds(command[:timeout_seconds] || command["timeout_seconds"],
3057
+ invalid: :invalid)
3058
+ next unless command_value.is_a?(String) && !command_value.empty?
3059
+ next unless %w[test lint formatter build documentation custom].include?(category)
3060
+ next unless %w[each_unit full_loop on_completion].include?(run_after)
3061
+ next if timeout_seconds == :invalid
3062
+
3063
+ {
3064
+ name: command[:name].to_s.empty? ? "command" : command[:name].to_s,
3065
+ command: command_value,
3066
+ category: category.to_sym,
3067
+ run_after: run_after.to_sym,
3068
+ required: command[:required] != false,
3069
+ timeout_seconds: timeout_seconds
3070
+ }
3071
+ end
3072
+ end
3073
+
3074
+ def effective_work_loop_commands
3075
+ generic_commands = normalize_generic_work_loop_commands(get(%i[work_loop commands]))
3076
+ legacy_commands = normalize_legacy_work_loop_commands
3077
+ structured_legacy_commands = normalize_structured_legacy_work_loop_commands
3078
+
3079
+ deduplicated_work_loop_commands(generic_commands, legacy_commands, structured_legacy_commands)
3080
+ end
3081
+
3082
+ def deduplicated_work_loop_commands(*command_sets)
3083
+ command_sets.flatten.uniq { |command| deduplication_key(command) }
3084
+ end
3085
+
3086
+ def deduplication_key(command)
3087
+ command.slice(:command, :required, :run_after, :category, :timeout_seconds)
3088
+ end
3089
+
3090
+ def normalize_generic_work_loop_commands(commands)
3091
+ Array(commands).filter_map.with_index do |command, index|
3092
+ normalize_generic_work_loop_command(command, index)
3093
+ end
3094
+ end
3095
+
3096
+ def normalize_generic_work_loop_command(command, index)
3097
+ case command
3098
+ when String
3099
+ {
3100
+ name: "command_#{index}",
3101
+ command: command,
3102
+ category: :custom,
3103
+ run_after: :each_unit,
3104
+ required: true,
3105
+ timeout_seconds: nil
3106
+ }
3107
+ when Hash
3108
+ category = (command[:category] || command["category"] || :custom).to_sym
3109
+ {
3110
+ name: (command[:name] || command["name"] || "command_#{index}").to_s,
3111
+ command: (command[:command] || command["command"]).to_s,
3112
+ category: category,
3113
+ run_after: normalize_run_after(command[:run_after] || command["run_after"]),
3114
+ required: required_value(command),
3115
+ timeout_seconds: normalize_timeout_seconds(command[:timeout_seconds] || command["timeout_seconds"])
3116
+ }
3117
+ end
3118
+ end
3119
+
3120
+ def normalize_legacy_work_loop_commands
3121
+ legacy_command_mappings.flat_map do |config_key, defaults|
3122
+ Array(get([:work_loop, config_key])).filter_map.with_index do |command, index|
3123
+ normalize_legacy_work_loop_command(command, defaults, index)
3124
+ end
3125
+ end
3126
+ end
3127
+
3128
+ def normalize_structured_legacy_work_loop_commands
3129
+ normalize_structured_legacy_test_commands + normalize_structured_legacy_lint_commands
3130
+ end
3131
+
3132
+ def normalize_structured_legacy_test_commands
3133
+ test_config = get(%i[work_loop test]) || {}
3134
+ timeout_seconds = normalize_timeout_seconds(test_config[:timeout_seconds] || test_config["timeout_seconds"])
3135
+
3136
+ [
3137
+ normalize_structured_legacy_command(test_config[:unit] || test_config["unit"],
3138
+ name: "unit_test", category: :test, run_after: :each_unit, timeout_seconds: timeout_seconds),
3139
+ normalize_structured_legacy_command(test_config[:integration] || test_config["integration"],
3140
+ name: "integration_test", category: :test, run_after: :full_loop, timeout_seconds: timeout_seconds),
3141
+ normalize_structured_legacy_command(test_config[:e2e] || test_config["e2e"],
3142
+ name: "e2e_test", category: :test, run_after: :full_loop, timeout_seconds: timeout_seconds)
3143
+ ].compact
3144
+ end
3145
+
3146
+ def normalize_structured_legacy_lint_commands
3147
+ lint_config = get(%i[work_loop lint]) || {}
3148
+
3149
+ [
3150
+ normalize_structured_legacy_command(lint_config[:command] || lint_config["command"],
3151
+ name: "lint", category: :lint, run_after: :each_unit),
3152
+ normalize_structured_legacy_command(lint_config[:format] || lint_config["format"],
3153
+ name: "format", category: :formatter, run_after: :on_completion, required: false)
3154
+ ].compact
3155
+ end
3156
+
3157
+ def normalize_structured_legacy_command(command, name:, category:, run_after:, required: true,
3158
+ timeout_seconds: nil)
3159
+ return if command.nil? || command.to_s.empty?
3160
+
3161
+ {
3162
+ name: name,
3163
+ command: command.to_s,
3164
+ category: category,
3165
+ run_after: run_after,
3166
+ required: required,
3167
+ timeout_seconds: timeout_seconds
3168
+ }
3169
+ end
3170
+
3171
+ def normalize_legacy_work_loop_command(command, defaults, index)
3172
+ case command
3173
+ when String
3174
+ {
3175
+ name: "#{defaults[:category]}_#{index}",
3176
+ command: command,
3177
+ category: defaults[:category],
3178
+ run_after: defaults[:run_after],
3179
+ required: true,
3180
+ timeout_seconds: nil
3181
+ }
3182
+ when Hash
3183
+ {
3184
+ name: (command[:name] || command["name"] || "#{defaults[:category]}_#{index}").to_s,
3185
+ command: (command[:command] || command["command"]).to_s,
3186
+ category: defaults[:category],
3187
+ run_after: defaults[:run_after],
3188
+ required: required_value(command),
3189
+ timeout_seconds: normalize_timeout_seconds(command[:timeout_seconds] || command["timeout_seconds"])
3190
+ }
3191
+ end
3192
+ end
3193
+
3194
+ def legacy_command_mappings
3195
+ {
3196
+ test_commands: {category: :test, run_after: :each_unit},
3197
+ lint_commands: {category: :lint, run_after: :each_unit},
3198
+ formatter_commands: {category: :formatter, run_after: :on_completion},
3199
+ build_commands: {category: :build, run_after: :each_unit},
3200
+ documentation_commands: {category: :documentation, run_after: :on_completion}
3201
+ }
3202
+ end
3203
+
3204
+ def normalize_run_after(value)
3205
+ case value.to_s
3206
+ when "full_loop", "loop"
3207
+ :full_loop
3208
+ when "on_completion", "completion"
3209
+ :on_completion
3210
+ else
3211
+ :each_unit
3212
+ end
3213
+ end
3214
+
3215
+ def required_value(command)
3216
+ return command[:required] if command.key?(:required)
3217
+ return command["required"] if command.key?("required")
3218
+
3219
+ true
3220
+ end
3221
+
3222
+ def normalize_timeout_seconds(value, invalid: nil)
3223
+ return nil if value.nil?
3224
+ return nil if value.respond_to?(:empty?) && value.empty?
3225
+
3226
+ timeout_seconds = Integer(value, exception: false)
3227
+ return invalid unless timeout_seconds&.positive?
3228
+
3229
+ timeout_seconds
3230
+ end
3231
+
3232
+ def parse_phase_two_suggestion_payload(response_text)
3233
+ JSON.parse(response_text, symbolize_names: true)
3234
+ rescue JSON::ParserError
3235
+ json_payload = extract_phase_two_suggestion_json(response_text)
3236
+ return unless json_payload
3237
+
3238
+ JSON.parse(json_payload, symbolize_names: true)
3239
+ end
3240
+
3241
+ def extract_phase_two_suggestion_json(response_text)
3242
+ extract_fenced_json_object(response_text) || extract_balanced_json_object(response_text)
3243
+ end
3244
+
3245
+ def extract_fenced_json_object(response_text)
3246
+ match = response_text.match(/```(?:json)?\s*(\{.*?\})\s*```/mi)
3247
+ match && match[1]
3248
+ end
3249
+
3250
+ def extract_balanced_json_object(response_text)
3251
+ start_index = response_text.index("{")
3252
+ return unless start_index
3253
+
3254
+ depth = 0
3255
+ in_string = false
3256
+ escaped = false
3257
+
3258
+ response_text.each_char.with_index do |char, index|
3259
+ next if index < start_index
3260
+
3261
+ if in_string
3262
+ if escaped
3263
+ escaped = false
3264
+ elsif char == "\\"
3265
+ escaped = true
3266
+ elsif char == '"'
3267
+ in_string = false
3268
+ end
3269
+ next
3270
+ end
3271
+
3272
+ case char
3273
+ when '"'
3274
+ in_string = true
3275
+ when "{"
3276
+ depth += 1
3277
+ when "}"
3278
+ depth -= 1
3279
+ return response_text[start_index..index] if depth.zero?
3280
+ end
3281
+ end
3282
+
3283
+ nil
3284
+ end
3285
+
3286
+ def normalize_phase_two_value(value, fallback)
3287
+ return fallback if value.nil?
3288
+ return fallback if value.respond_to?(:empty?) && value.empty?
3289
+
3290
+ value
3291
+ end
3292
+
2774
3293
  def configure_devcontainer
2775
3294
  prompt.say("\n🐳 Devcontainer Configuration")
2776
3295
  Aidp.log_debug(DEVCONTAINER_COMPONENT, "configure.start")
@@ -2850,7 +3369,7 @@ module Aidp
2850
3369
  def build_wizard_config_for_devcontainer
2851
3370
  {
2852
3371
  providers: @config[:providers]&.keys,
2853
- test_framework: @config.dig(:work_loop, :test_commands)&.first&.dig(:framework),
3372
+ test_framework: devcontainer_test_framework,
2854
3373
  linters: @config.dig(:work_loop, :linting, :tools),
2855
3374
  watch_mode: @config.dig(:work_loop, :watch, :enabled),
2856
3375
  app_type: detect_app_type,
@@ -2859,6 +3378,44 @@ module Aidp
2859
3378
  }.compact
2860
3379
  end
2861
3380
 
3381
+ def devcontainer_test_framework
3382
+ command = first_work_loop_command_for(:test)
3383
+ framework = framework_for_work_loop_command(command)
3384
+ return framework if framework
3385
+
3386
+ detected_framework = framework_from_detected_tooling(command)
3387
+ return detected_framework if detected_framework
3388
+
3389
+ @config.dig(:work_loop, :test_commands)&.first&.dig(:framework)&.to_s
3390
+ end
3391
+
3392
+ def first_work_loop_command_for(category)
3393
+ effective_work_loop_commands.find do |command|
3394
+ command[:category].to_sym == category
3395
+ end
3396
+ end
3397
+
3398
+ def framework_for_work_loop_command(command)
3399
+ framework = Aidp::ToolingDetector.framework_from_command(command&.dig(:command))
3400
+ return if framework == :unknown
3401
+
3402
+ framework.to_s
3403
+ end
3404
+
3405
+ def framework_from_detected_tooling(command)
3406
+ candidates = [
3407
+ command&.dig(:command),
3408
+ detect_tooling.test_commands.first
3409
+ ].compact.uniq
3410
+
3411
+ candidates.each do |candidate|
3412
+ framework = detect_tooling.framework_for_command(candidate)
3413
+ return framework.to_s if framework != :unknown
3414
+ end
3415
+
3416
+ nil
3417
+ end
3418
+
2862
3419
  def detect_app_type
2863
3420
  return "rails_web" if project_file?("config/routes.rb")
2864
3421
  return "sinatra" if project_file?("config.ru")
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.48.0"
4
+ VERSION = "0.49.0"
5
5
  end
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.48.0
4
+ version: 0.49.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Bart Agapinan