aia 1.1.1 → 2.0.0.0.pre.beta2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (175) hide show
  1. checksums.yaml +4 -4
  2. data/.envrc +9 -1
  3. data/.loki +11 -0
  4. data/.reek.yml +160 -0
  5. data/.rubocop.yml +116 -0
  6. data/.rubocop_strict.yml +15 -0
  7. data/.version +1 -1
  8. data/CHANGELOG.md +269 -55
  9. data/IMPLEMENTATION_PLAN.md +506 -0
  10. data/README.md +267 -239
  11. data/Rakefile +5 -5
  12. data/_typos.toml +10 -0
  13. data/aia.gemspec +92 -0
  14. data/architecture_review.md +314 -0
  15. data/bin/aia +16 -0
  16. data/config/aia.yml +13 -0
  17. data/docs/AGENTS.md +40 -0
  18. data/docs/advanced-prompting.md +67 -3
  19. data/docs/cli-reference.md +312 -56
  20. data/docs/configuration.md +130 -19
  21. data/docs/contributing.md +56 -2
  22. data/docs/directives-reference.md +593 -78
  23. data/docs/faq.md +85 -3
  24. data/docs/guides/available-models.md +1 -1
  25. data/docs/guides/basic-usage.md +6 -6
  26. data/docs/guides/chat.md +40 -16
  27. data/docs/guides/crew.md +239 -0
  28. data/docs/guides/executable-prompts.md +1 -1
  29. data/docs/guides/index.md +1 -0
  30. data/docs/guides/models.md +15 -0
  31. data/docs/index.md +29 -2
  32. data/docs/installation.md +44 -17
  33. data/docs/mcp-integration.md +40 -0
  34. data/docs/prompt_management.md +85 -86
  35. data/docs/security.md +47 -0
  36. data/docs/special_projects_guide.md +386 -0
  37. data/docs/tools-and-mcp-examples.md +23 -0
  38. data/docs/workflows-and-pipelines.md +84 -7
  39. data/examples/.gitignore +1 -0
  40. data/examples/00_setup_aia.sh +27 -44
  41. data/examples/11_multi_model.sh +4 -14
  42. data/examples/12_token_usage.sh +3 -12
  43. data/examples/18_tools.sh +10 -2
  44. data/examples/22_chat_mode.sh +0 -10
  45. data/examples/23_verify.sh +139 -0
  46. data/examples/24_decompose.sh +139 -0
  47. data/examples/25_spawn.sh +139 -0
  48. data/examples/26_debate.sh +97 -0
  49. data/examples/27_mention_routing.sh +157 -0
  50. data/examples/28_model_switching.sh +106 -0
  51. data/examples/29_agent_harness.sh +177 -0
  52. data/examples/README.md +65 -0
  53. data/examples/advanced_multi_robot_capabilities_without_examples.md +106 -0
  54. data/examples/aia_config.yml +1 -1
  55. data/examples/aia_config_orchestrator.yml +45 -0
  56. data/examples/common.sh +18 -6
  57. data/examples/context/tech_stack.md +2 -2
  58. data/examples/prompts_dir/roles/orchestrator.md +21 -0
  59. data/examples/requirements/sinatra_taskflow_app.md +139 -0
  60. data/examples/rules/01_classify_ruby.rb +16 -0
  61. data/examples/rules/02_prefer_claude_for_code.rb +19 -0
  62. data/examples/rules/03_gate_prompt_length.rb +19 -0
  63. data/examples/rules/04_tool_selection.rb +41 -0
  64. data/examples/rules/README.md +30 -0
  65. data/examples/run_all.sh +48 -15
  66. data/examples/tools/word_count_tool.rb +1 -1
  67. data/lib/AGENTS.md +57 -0
  68. data/lib/aia/chat_loop.rb +263 -167
  69. data/lib/aia/config/cli_parser.rb +217 -145
  70. data/lib/aia/config/defaults.yml +62 -33
  71. data/lib/aia/config/mcp_parser.rb +52 -51
  72. data/lib/aia/config/model_spec.rb +34 -2
  73. data/lib/aia/config/validator.rb +171 -216
  74. data/lib/aia/config.rb +111 -145
  75. data/lib/aia/content_extractor.rb +155 -0
  76. data/lib/aia/cost_calculator.rb +39 -0
  77. data/lib/aia/crew.rb +164 -0
  78. data/lib/aia/debate_handler.rb +174 -0
  79. data/lib/aia/delegate_handler.rb +116 -0
  80. data/lib/aia/directive.rb +43 -26
  81. data/lib/aia/directive_processor.rb +16 -7
  82. data/lib/aia/directives/configuration_directives.rb +214 -60
  83. data/lib/aia/directives/context_directives.rb +67 -52
  84. data/lib/aia/directives/execution_directives.rb +141 -4
  85. data/lib/aia/directives/model_directives.rb +163 -141
  86. data/lib/aia/directives/trakflow_directives.rb +62 -0
  87. data/lib/aia/directives/utility_directives.rb +227 -30
  88. data/lib/aia/directives/web_and_file_directives.rb +126 -77
  89. data/lib/aia/errors.rb +15 -0
  90. data/lib/aia/fact_asserter.rb +27 -0
  91. data/lib/aia/fzf.rb +9 -31
  92. data/lib/aia/handler_context.rb +17 -0
  93. data/lib/aia/handler_protocol.rb +19 -0
  94. data/lib/aia/history_transfer.rb +55 -0
  95. data/lib/aia/input_collector.rb +3 -3
  96. data/lib/aia/layered_orchestrator.rb +471 -0
  97. data/lib/aia/logger.rb +45 -25
  98. data/lib/aia/mcp_config_normalizer.rb +35 -0
  99. data/lib/aia/mcp_connection_manager.rb +315 -0
  100. data/lib/aia/mcp_discovery.rb +44 -0
  101. data/lib/aia/mcp_grouper.rb +33 -0
  102. data/lib/aia/mcp_server_config.rb +30 -0
  103. data/lib/aia/mcp_utility.rb +60 -0
  104. data/lib/aia/mention_router.rb +217 -0
  105. data/lib/aia/model_alias_registry.rb +97 -0
  106. data/lib/aia/model_switch_handler.rb +100 -0
  107. data/lib/aia/network_builder.rb +160 -0
  108. data/lib/aia/network_memory_manager.rb +55 -0
  109. data/lib/aia/patches/ruby_llm_streaming_error.rb +43 -0
  110. data/lib/aia/patches/ruby_llm_tool_error.rb +96 -0
  111. data/lib/aia/pipeline_orchestrator.rb +272 -0
  112. data/lib/aia/plugin_loader.rb +170 -0
  113. data/lib/aia/plugin_monitor.rb +211 -0
  114. data/lib/aia/prompt_decomposer.rb +159 -0
  115. data/lib/aia/prompt_handler.rb +56 -82
  116. data/lib/aia/robot_builder.rb +51 -0
  117. data/lib/aia/robot_factory.rb +338 -0
  118. data/lib/aia/robot_namer.rb +110 -0
  119. data/lib/aia/session.rb +87 -17
  120. data/lib/aia/session_tracker.rb +207 -0
  121. data/lib/aia/similarity_scorer.rb +41 -0
  122. data/lib/aia/skill_utils.rb +105 -1
  123. data/lib/aia/spawn_handler.rb +129 -0
  124. data/lib/aia/spawn_spec_parser.rb +65 -0
  125. data/lib/aia/special_mode_handler.rb +322 -0
  126. data/lib/aia/speech.rb +67 -0
  127. data/lib/aia/startup_coordinator.rb +151 -0
  128. data/lib/aia/streaming_runner.rb +172 -0
  129. data/lib/aia/system_prompt_assembler.rb +92 -0
  130. data/lib/aia/task_coordinator.rb +207 -0
  131. data/lib/aia/task_decomposer.rb +57 -0
  132. data/lib/aia/task_executor.rb +51 -0
  133. data/lib/aia/tfidf_math.rb +27 -0
  134. data/lib/aia/timing.rb +15 -0
  135. data/lib/aia/tool_filter/tfidf.rb +116 -0
  136. data/lib/aia/tool_filter/wordnet_expander.rb +127 -0
  137. data/lib/aia/tool_filter.rb +83 -0
  138. data/lib/aia/tool_filter_registry.rb +30 -0
  139. data/lib/aia/tool_filter_strategy.rb +146 -0
  140. data/lib/aia/tool_introspection.rb +17 -0
  141. data/lib/aia/tool_loader.rb +216 -0
  142. data/lib/aia/tool_utility.rb +30 -0
  143. data/lib/aia/tools/delegate_to_foreman_tool.rb +70 -0
  144. data/lib/aia/tools/recruit_robot_tool.rb +60 -0
  145. data/lib/aia/tools/reskill_robot_tool.rb +44 -0
  146. data/lib/aia/tools/task_board_tool.rb +115 -0
  147. data/lib/aia/trakflow_bridge.rb +175 -0
  148. data/lib/aia/turn_state.rb +95 -0
  149. data/lib/aia/ui_presenter.rb +182 -206
  150. data/lib/aia/utility.rb +136 -87
  151. data/lib/aia/{history_manager.rb → variable_input_collector.rb} +9 -9
  152. data/lib/aia/verification_network.rb +57 -0
  153. data/lib/aia.rb +124 -63
  154. data/mkdocs.yml +1 -0
  155. metadata +187 -58
  156. data/justfile +0 -215
  157. data/lib/aia/adapter/chat_execution.rb +0 -242
  158. data/lib/aia/adapter/error_handler.rb +0 -68
  159. data/lib/aia/adapter/gem_activator.rb +0 -57
  160. data/lib/aia/adapter/mcp_connector.rb +0 -274
  161. data/lib/aia/adapter/modality_handlers.rb +0 -167
  162. data/lib/aia/adapter/model_registry.rb +0 -81
  163. data/lib/aia/adapter/multi_model_chat.rb +0 -218
  164. data/lib/aia/adapter/provider_configurator.rb +0 -59
  165. data/lib/aia/adapter/tool_filter.rb +0 -85
  166. data/lib/aia/adapter/tool_loader.rb +0 -90
  167. data/lib/aia/chat_processor_service.rb +0 -178
  168. data/lib/aia/prompt_pipeline.rb +0 -183
  169. data/lib/aia/ruby_llm_adapter.rb +0 -95
  170. data/lib/extensions/openstruct_merge.rb +0 -48
  171. data/lib/extensions/ruby_llm/.irbrc +0 -56
  172. data/lib/extensions/ruby_llm/modalities.rb +0 -36
  173. data/lib/extensions/ruby_llm/provider_fix.rb +0 -79
  174. data/lib/refinements/string.rb +0 -16
  175. data/main.just +0 -76
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ # lib/aia/handler_protocol.rb
4
+ #
5
+ # Uniform interface for all turn-level handlers.
6
+ # Include this module and implement handle(context) where context
7
+ # is an AIA::HandlerContext value object.
8
+
9
+ module AIA
10
+ module HandlerProtocol
11
+ # Process a turn. Each handler reads only the context fields it needs.
12
+ #
13
+ # @param context [AIA::HandlerContext]
14
+ # @return handler-specific result (String content, Boolean, or nil)
15
+ def handle(context)
16
+ raise NotImplementedError, "#{self.class} must implement #handle(context)"
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ # lib/aia/history_transfer.rb
4
+ #
5
+ # Stateless module for transferring conversation history between robots.
6
+ # Extracted from RobotFactory to isolate the history transfer concern.
7
+
8
+ module AIA
9
+ module HistoryTransfer
10
+ module_function
11
+
12
+ # Replay conversation history from an old robot to a new one.
13
+ #
14
+ # Performance: O(N) API calls where N = number of user messages.
15
+ # Each user message triggers a full LLM round-trip on the new model.
16
+ # For a 10-turn conversation with a local model (~1s/turn), expect ~10s.
17
+ # For a cloud model (~2-5s/turn), expect 20-50s. MCP/tools are disabled
18
+ # during replay to avoid side effects.
19
+ def replay_history(old_robot, new_robot)
20
+ return unless old_robot.respond_to?(:messages)
21
+
22
+ old_robot.messages.each do |msg|
23
+ next unless msg.respond_to?(:role) && msg.role == :user
24
+ new_robot.run(msg.content, mcp: :none, tools: :none)
25
+ end
26
+ rescue StandardError => e
27
+ $stderr.puts "Warning: History replay failed: #{e.message}"
28
+ end
29
+
30
+ # Summarize conversation history and inject into new robot.
31
+ #
32
+ # Performance: Exactly 2 API calls regardless of conversation length.
33
+ # 1) Summarize on old model (input tokens proportional to conversation)
34
+ # 2) Inject summary into new model (small fixed-size prompt)
35
+ # Faster than :replay for conversations with >2 turns, but loses
36
+ # per-turn context fidelity. Total latency ~4-10s for cloud models.
37
+ def summarize_history(old_robot, new_robot)
38
+ return unless old_robot.respond_to?(:messages) && old_robot.messages.any?
39
+
40
+ summary_lines = old_robot.messages.map do |msg|
41
+ "#{msg.role}: #{msg.content}" if msg.respond_to?(:role)
42
+ end.compact
43
+
44
+ return if summary_lines.empty?
45
+
46
+ summary_prompt = "Summarize this conversation concisely for context transfer:\n#{summary_lines.join("\n")}"
47
+ summary = old_robot.run(summary_prompt, mcp: :none, tools: :none)
48
+ content = summary.respond_to?(:reply) ? summary.reply : summary.to_s
49
+
50
+ new_robot.run("Context from previous conversation: #{content}", mcp: :none, tools: :none)
51
+ rescue StandardError => e
52
+ $stderr.puts "Warning: History summarization failed: #{e.message}"
53
+ end
54
+ end
55
+ end
@@ -3,17 +3,17 @@
3
3
 
4
4
  module AIA
5
5
  class InputCollector
6
- # Collect variable values from user input via HistoryManager
6
+ # Collect variable values from user input via VariableInputCollector
7
7
  def collect(parameters)
8
8
  return {} if parameters.nil? || parameters.empty?
9
9
 
10
10
  values = {}
11
- input_manager = AIA::HistoryManager.new
11
+ input_manager = AIA::VariableInputCollector.new
12
12
 
13
13
  parameters.each do |name, default|
14
14
  value = input_manager.request_variable_value(
15
15
  variable_name: name,
16
- default_value: default,
16
+ default_value: default
17
17
  )
18
18
  values[name] = value
19
19
  end
@@ -0,0 +1,471 @@
1
+ # frozen_string_literal: true
2
+
3
+ # lib/aia/layered_orchestrator.rb
4
+ #
5
+ # Three-tier agent orchestration for complex application builds.
6
+ #
7
+ # Tier 1 — Orchestrator (Tobor)
8
+ # Receives full application requirements. Decomposes them into
9
+ # independent architectural layers (e.g. infrastructure, data models,
10
+ # auth, routes, views). Each layer is a distinct technical concern.
11
+ #
12
+ # Tier 2 — Lead Agents (one per layer, run in parallel via Async::Barrier)
13
+ # Each lead agent receives its layer's requirements and breaks them
14
+ # into specific implementation tasks, assigning a specialist type to
15
+ # each task.
16
+ #
17
+ # Tier 3 — Specialist Robots (one per task, all run in parallel via Async::Barrier)
18
+ # Each specialist receives a focused, concrete task and produces the
19
+ # actual implementation artifact: a code file, migration, spec, or
20
+ # configuration block.
21
+ #
22
+ # After all layers complete, Tobor synthesizes a final integration
23
+ # summary showing how the pieces fit together.
24
+
25
+ require 'json'
26
+ require 'fileutils'
27
+ require 'async'
28
+
29
+ module AIA
30
+ class LayeredOrchestrator
31
+ include ContentExtractor
32
+ include HandlerProtocol
33
+
34
+ MAX_LAYERS = 5
35
+ MAX_TASKS_PER_LAYER = 4
36
+
37
+ BUILD_BANNER = <<~BANNER
38
+ ╔══════════════════════════════════════════════════════════╗
39
+ ║ LAYERED ORCHESTRATION — 3-TIER BUILD ║
40
+ ╚══════════════════════════════════════════════════════════╝
41
+ BANNER
42
+
43
+ # Tier 1 prompt: decompose requirements into layers
44
+ # Deliberately short and directive to minimise qwen3 think-block noise.
45
+ LAYER_DECOMPOSE_PROMPT = <<~PROMPT
46
+ TASK: Decompose the application requirements below into architectural layers.
47
+ RESPOND WITH ONLY A JSON ARRAY. No explanation. No markdown. No code fences.
48
+
49
+ JSON schema (array of objects):
50
+ name - snake_case layer identifier
51
+ title - short human-readable title
52
+ description - one sentence describing what this layer covers
53
+ requirements - comma-separated list of things to implement in this layer
54
+
55
+ Rules: 3 to %{max_layers} layers. Infrastructure first, UI last.
56
+
57
+ APPLICATION REQUIREMENTS:
58
+ %{requirements}
59
+
60
+ JSON ARRAY RESPONSE:
61
+ PROMPT
62
+
63
+ # Tier 2 prompt: lead agent decomposes its layer into tasks
64
+ TASK_DECOMPOSE_PROMPT = <<~PROMPT
65
+ TASK: Break the %{layer_title} layer into implementation tasks.
66
+ RESPOND WITH ONLY A JSON ARRAY. No explanation. No markdown. No code fences.
67
+
68
+ JSON schema (array of objects):
69
+ title - short task title
70
+ specialist - specialist role name (e.g. sequel-migration-writer)
71
+ artifact - exact filename to produce (e.g. db/migrations/001_create_users.rb)
72
+ prompt - self-contained instruction for the specialist
73
+
74
+ Rules: 2 to %{max_tasks} tasks. Each task produces exactly one file.
75
+
76
+ LAYER: %{layer_title}
77
+ REQUIREMENTS: %{requirements}
78
+
79
+ JSON ARRAY RESPONSE:
80
+ PROMPT
81
+
82
+ # Layer synthesis prompt
83
+ LAYER_SYNTHESIS_PROMPT = <<~PROMPT
84
+ You implemented the %{layer_title} layer. Here are the artifacts produced
85
+ by your specialist team:
86
+
87
+ %{task_results}
88
+
89
+ Write a brief (3-5 sentence) integration summary: what was built, how the
90
+ artifacts fit together, and what the next layer depends on from this one.
91
+ PROMPT
92
+
93
+ # Final synthesis prompt for Tobor
94
+ FINAL_SYNTHESIS_PROMPT = <<~PROMPT
95
+ All architectural layers of the application have been built by the agent teams.
96
+ Here is what each layer produced:
97
+
98
+ %{layer_summaries}
99
+
100
+ Original requirements excerpt:
101
+ %{requirements_excerpt}
102
+
103
+ Provide a final integration summary:
104
+ 1. What was built overall
105
+ 2. How the layers connect (what each layer depends on from the layers below it)
106
+ 3. The minimal steps to make the application runnable (config, migrations, startup)
107
+ PROMPT
108
+
109
+ def initialize(robot:, ui_presenter:, tracker:, build_dir: nil)
110
+ @robot = robot
111
+ @ui_presenter = ui_presenter
112
+ @tracker = tracker
113
+ @build_dir = build_dir || default_build_dir
114
+ end
115
+
116
+ def default_build_dir
117
+ File.join(Dir.pwd, "orchestrated_build_#{Time.now.strftime('%Y%m%d_%H%M%S')}")
118
+ end
119
+
120
+ # Print orchestration progress to stdout so it appears inline with the chat.
121
+ # (display_info uses $stderr which is invisible in interactive sessions.)
122
+ def say(msg)
123
+ $stdout.puts msg
124
+ $stdout.flush
125
+ end
126
+
127
+ attr_writer :robot
128
+
129
+ # Entry point called by SpecialModeHandler.
130
+ #
131
+ # @param context [HandlerContext] — reads context.prompt as requirements text
132
+ # @return [String, nil] final synthesis or nil on failure
133
+ # :reek:TooManyStatements -- single orchestration script (banner, tier 1-3 waves, synthesis, report); the narrative order is the value
134
+ # :reek:DuplicateMethodCall -- say("") prints deliberate blank separator lines between build phases; not a hoistable value
135
+ def handle(context)
136
+ requirements = context.prompt
137
+ primary = @robot.chief
138
+
139
+ FileUtils.mkdir_p(@build_dir)
140
+ print_build_banner(requirements)
141
+
142
+ # Tier 1: Tobor decomposes requirements into layers
143
+ layers = decompose_to_layers(primary, requirements)
144
+ if layers.empty?
145
+ say("Could not decompose requirements into layers. Aborting orchestration.")
146
+ return nil
147
+ end
148
+
149
+ display_layer_plan(layers)
150
+
151
+ # Tier 2: All lead agents run in parallel (Async::Barrier)
152
+ say("Running #{layers.size} lead agents in parallel...")
153
+ layer_task_map = run_leads_wave(layers)
154
+
155
+ if layer_task_map.values.all?(&:empty?)
156
+ say("All lead agents failed. Aborting orchestration.")
157
+ raise OrchestratorError, "All lead agents failed to produce tasks"
158
+ end
159
+
160
+ # Tier 3: All specialists run in parallel (Async::Barrier)
161
+ say("")
162
+ say("Running specialists in parallel...")
163
+ specialist_results = run_specialists_wave(layer_task_map)
164
+
165
+ # Assemble layer_results for synthesis (same shape as the former sequential approach)
166
+ layer_results = build_layer_results(layers, layer_task_map, specialist_results)
167
+
168
+ # Final synthesis by Tobor
169
+ say("")
170
+ say("━━━ Tobor synthesizing all #{layers.size} layers ━━━")
171
+ final_result = synthesize_all(primary, requirements, layers, layer_results)
172
+ final_text = extract_content(final_result)
173
+
174
+ save_final_report(final_text)
175
+ say("Build complete: #{@build_dir}")
176
+
177
+ record_final_turn(requirements, final_text)
178
+ final_text
179
+ rescue StandardError => e
180
+ report_orchestration_error(e)
181
+ nil
182
+ end
183
+
184
+ private
185
+
186
+ def print_build_banner(requirements)
187
+ say("")
188
+ BUILD_BANNER.each_line { |line| say(line.chomp) }
189
+ say("Requirements: #{requirements.lines.first.strip}")
190
+ say("Build output: #{@build_dir}")
191
+ say("")
192
+ end
193
+
194
+ def record_final_turn(requirements, final_text)
195
+ @tracker.record_turn(
196
+ model: AIA.config.models.first.name,
197
+ input: requirements,
198
+ result: final_text
199
+ )
200
+ end
201
+
202
+ def report_orchestration_error(error)
203
+ say("✗ Orchestration error: #{error.class}: #{error.message}")
204
+ error.backtrace&.first(5)&.each { |line| say(" #{line}") }
205
+ end
206
+
207
+ # Tier 1: use a probe robot to decompose requirements into layer specs
208
+ # :reek:TooManyStatements -- probe run plus parse-failure diagnostics and rescue reporting
209
+ def decompose_to_layers(robot, requirements)
210
+ say("Tier 1 ▶ #{robot.name} decomposing requirements into layers...")
211
+ probe = build_probe(robot.name + "-layer-probe")
212
+ prompt = LAYER_DECOMPOSE_PROMPT % {
213
+ requirements: requirements,
214
+ max_layers: MAX_LAYERS
215
+ }
216
+
217
+ result = probe.run(prompt, mcp: :none, tools: :none)
218
+ content = extract_content(result)
219
+ layers = parse_json_array(content)
220
+
221
+ if layers.empty?
222
+ preview = content.to_s.gsub(%r{<think>.*?</think>}m, '').strip[0, 300]
223
+ say(" ⚠ Layer decomposition parse failed.")
224
+ say(" Raw response preview: #{preview}")
225
+ end
226
+
227
+ layers.first(MAX_LAYERS)
228
+ rescue StandardError => e
229
+ say(" ✗ decompose_to_layers failed: #{e.class}: #{e.message}")
230
+ e.backtrace&.first(3)&.each { |line| say(" #{line}") }
231
+ []
232
+ end
233
+
234
+ def display_layer_plan(layers)
235
+ say("Tier 1 ▶ #{layers.size} layers identified:")
236
+ layers.each_with_index do |layer, i|
237
+ say(" #{i + 1}. #{layer['title']}: #{layer['description']}")
238
+ end
239
+ end
240
+
241
+ # Tier 2 Wave: run all lead agents in parallel via Async::Barrier.
242
+ # Each lead decomposes its layer into task specifications.
243
+ #
244
+ # @param layers [Array<Hash>] layer specs from Tier 1
245
+ # @return [Hash{ String => Array<Hash> }] layer_name => task list ([] on failure)
246
+ # :reek:TooManyStatements -- per-layer async fan-out with per-layer error capture; the barrier plumbing is inherent
247
+ def run_leads_wave(layers)
248
+ results = {}
249
+
250
+ Sync do
251
+ barrier = Async::Barrier.new
252
+ layers.each do |layer|
253
+ barrier.async do
254
+ name = layer['name']
255
+ title = layer['title']
256
+ probe = build_probe("#{name}-lead")
257
+ prompt = TASK_DECOMPOSE_PROMPT % {
258
+ layer_title: title,
259
+ requirements: layer['requirements'].to_s,
260
+ max_tasks: MAX_TASKS_PER_LAYER
261
+ }
262
+ result = probe.run(prompt, mcp: :none, tools: :none)
263
+ tasks = parse_json_array(extract_content(result)).first(MAX_TASKS_PER_LAYER)
264
+ say(" ✓ #{title} lead: #{tasks.size} task(s)")
265
+ results[name] = tasks
266
+ rescue => e
267
+ say(" ✗ #{title} lead failed: #{e.message}")
268
+ results[name] = []
269
+ end
270
+ end
271
+ barrier.wait
272
+ end
273
+
274
+ results
275
+ end
276
+
277
+ # Tier 3 Wave: run all specialists in parallel via Async::Barrier.
278
+ # Flattens tasks from all layers into a single barrier so specialists
279
+ # across all layers execute concurrently.
280
+ #
281
+ # @param layer_task_map [Hash{ String => Array<Hash> }] output of run_leads_wave
282
+ # @return [Hash{ String => Hash }] "layer_name|artifact_path" => result hash
283
+ # :reek:TooManyStatements -- per-task async fan-out with success/failure result recording; the barrier plumbing is inherent
284
+ def run_specialists_wave(layer_task_map)
285
+ jobs = layer_task_map.flat_map do |layer_name, tasks|
286
+ tasks.map { |task| { layer_name: layer_name, task: task } }
287
+ end
288
+
289
+ results = {}
290
+
291
+ # rubocop:disable Metrics/BlockLength
292
+ Sync do
293
+ barrier = Async::Barrier.new
294
+ jobs.each do |job|
295
+ barrier.async do
296
+ task = job[:task]
297
+ artifact = task['artifact']
298
+ title = task['title']
299
+ specialist = task['specialist']
300
+ key = "#{job[:layer_name]}|#{artifact}"
301
+ probe = build_probe("#{specialist}-specialist")
302
+
303
+ full_prompt = "Artifact to produce: #{artifact}\n\n#{task['prompt']}"
304
+ result = probe.run(full_prompt, mcp: :none, tools: :none)
305
+ output = extract_content(result)
306
+
307
+ save_artifact(artifact, output)
308
+ say(" ✓ #{artifact}")
309
+
310
+ results[key] = {
311
+ task: title,
312
+ specialist: specialist,
313
+ artifact: artifact,
314
+ output: output
315
+ }
316
+ rescue => e
317
+ say(" ✗ #{artifact} failed: #{e.message}")
318
+ results[key] = {
319
+ task: title,
320
+ specialist: specialist,
321
+ artifact: artifact,
322
+ output: "[FAILED: #{e.message}]"
323
+ }
324
+ end
325
+ end
326
+ # rubocop:enable Metrics/BlockLength
327
+ barrier.wait
328
+ end
329
+ results
330
+ end
331
+
332
+ # Assemble the layer_results array expected by synthesize_all.
333
+ # Shape: [{ layer:, tasks: [result_hashes], summary: }]
334
+ #
335
+ # @param layers [Array<Hash>] original layer specs
336
+ # @param layer_task_map [Hash] output of run_leads_wave
337
+ # @param specialist_results [Hash] output of run_specialists_wave
338
+ # @return [Array<Hash>]
339
+ def build_layer_results(layers, layer_task_map, specialist_results)
340
+ layers.map do |layer|
341
+ name = layer['name']
342
+ title = layer['title']
343
+ tasks_for_layer = layer_task_map[name] || []
344
+ task_results = tasks_for_layer.map do |task|
345
+ key = "#{name}|#{task['artifact']}"
346
+ specialist_results[key] || {
347
+ task: task['title'],
348
+ specialist: task['specialist'],
349
+ artifact: task['artifact'],
350
+ output: "[FAILED: no result]"
351
+ }
352
+ end
353
+
354
+ summary = synthesize_layer_from_results(title, task_results)
355
+ say(" ✓ #{title} synthesis complete")
356
+ { layer: title, tasks: task_results, summary: summary }
357
+ end
358
+ end
359
+
360
+ # Synthesize all task outputs for a layer into an integration summary.
361
+ # Uses a fresh probe robot (no conversation history).
362
+ def synthesize_layer_from_results(layer_title, task_results)
363
+ results_text = task_results.map do |r|
364
+ "### #{r[:task]} — #{r[:artifact]}\n#{r[:output]}"
365
+ end.join("\n\n---\n\n")
366
+
367
+ prompt = LAYER_SYNTHESIS_PROMPT % {
368
+ layer_title: layer_title,
369
+ task_results: results_text
370
+ }
371
+
372
+ probe = build_probe("#{layer_title.downcase.gsub(/\s+/, '-')}-synthesis")
373
+ result = probe.run(prompt, mcp: :none, tools: :none)
374
+ extract_content(result)
375
+ rescue StandardError
376
+ "(layer synthesis failed)"
377
+ end
378
+
379
+ # Tobor synthesizes all layer summaries into a final integration report
380
+ def synthesize_all(primary, requirements, layers, layer_results)
381
+ summaries = layer_results.each_with_index.map do |lr, i|
382
+ header = "## Layer #{i + 1}: #{lr[:layer]}"
383
+ tasks = Array(lr[:tasks]).map { |t| "- #{t[:artifact]}: #{t[:task]}" }.join("\n")
384
+ "#{header}\n#{tasks}\n\nSummary: #{lr[:summary]}"
385
+ end.join("\n\n")
386
+
387
+ primary.run(
388
+ FINAL_SYNTHESIS_PROMPT % {
389
+ layer_summaries: summaries,
390
+ requirements_excerpt: requirements.lines.first(15).join
391
+ },
392
+ mcp: :none, tools: :none
393
+ )
394
+ end
395
+
396
+ # Parse JSON array from model output.
397
+ # Tries multiple extraction strategies to handle think blocks,
398
+ # markdown fences, prose before/after the JSON, and partial wrapping.
399
+ def parse_json_array(content)
400
+ return [] if content.nil? || content.strip.empty?
401
+
402
+ # Strip think blocks (qwen3 reasoning models wrap output in <think>...)
403
+ text = content.gsub(%r{<think>.*?</think>}m, '').strip
404
+
405
+ # Strategy 1: extract content from markdown code fences
406
+ if (m = text.match(/```(?:json)?\s*\n?(.*?)```/m))
407
+ candidate = m[1].strip
408
+ result = try_json_parse(candidate)
409
+ return result unless result.empty?
410
+ end
411
+
412
+ # Strategy 2: find the first [...] block in the response
413
+ if (m = text.match(/(\[[\s\S]*\])/m))
414
+ result = try_json_parse(m[1])
415
+ return result unless result.empty?
416
+ end
417
+
418
+ # Strategy 3: parse the whole stripped text
419
+ try_json_parse(text)
420
+ end
421
+
422
+ def try_json_parse(text)
423
+ result = JSON.parse(text.strip)
424
+ result.is_a?(Array) ? result.grep(Hash) : []
425
+ rescue JSON::ParserError
426
+ []
427
+ end
428
+
429
+ # Build a short-lived probe robot with no conversation history.
430
+ # Uses the primary model spec so the provider is correctly wired.
431
+ def build_probe(name)
432
+ config = AIA.config
433
+ run_config = RobotFactory.build_run_config(config)
434
+ model_spec = config.models.first
435
+
436
+ RobotFactory.build_robot(
437
+ model_spec,
438
+ name: name,
439
+ system_prompt: nil,
440
+ config: run_config
441
+ )
442
+ end
443
+
444
+ # Write an artifact to the build directory.
445
+ # Strips markdown code fences if the entire output is wrapped in them.
446
+ def save_artifact(artifact_path, content)
447
+ return unless content && !content.strip.empty?
448
+
449
+ # Strip a single wrapping code fence if the whole content is fenced
450
+ code = content.strip
451
+ if (m = code.match(/\A```\w*\n(.*)\n```\z/m))
452
+ code = m[1]
453
+ end
454
+
455
+ dest = File.join(@build_dir, artifact_path)
456
+ FileUtils.mkdir_p(File.dirname(dest))
457
+ File.write(dest, code)
458
+ rescue StandardError => e
459
+ say(" ⚠ Could not save #{artifact_path}: #{e.message}")
460
+ end
461
+
462
+ # Write the integration report to BUILD_DIR/INTEGRATION_REPORT.md
463
+ def save_final_report(text)
464
+ dest = File.join(@build_dir, "INTEGRATION_REPORT.md")
465
+ File.write(dest, text)
466
+ say("Integration report: #{dest}")
467
+ rescue StandardError
468
+ # best-effort
469
+ end
470
+ end
471
+ end
data/lib/aia/logger.rb CHANGED
@@ -76,22 +76,23 @@ module AIA
76
76
  def configure_llm_logger
77
77
  return unless defined?(RubyLLM)
78
78
 
79
- logger = llm_logger
79
+ logger = llm_logger
80
+ llm_config = RubyLLM.config
80
81
 
81
82
  # Set our logger on the RubyLLM config object
82
- if RubyLLM.config.respond_to?(:logger=)
83
- RubyLLM.config.logger = logger
83
+ if llm_config.respond_to?(:logger=)
84
+ llm_config.logger = logger
84
85
  end
85
86
 
86
87
  # Also set log_file and log_level in case the logger gets recreated
87
- if RubyLLM.config.respond_to?(:log_file=)
88
+ if llm_config.respond_to?(:log_file=)
88
89
  file = effective_log_file(logger_config_for(:llm))
89
- RubyLLM.config.log_file = resolve_log_file_io(file)
90
+ llm_config.log_file = resolve_log_file_io(file)
90
91
  end
91
92
 
92
- if RubyLLM.config.respond_to?(:log_level=)
93
+ if llm_config.respond_to?(:log_level=)
93
94
  level = effective_log_level(logger_config_for(:llm))
94
- RubyLLM.config.log_level = LOG_LEVELS.fetch(level, Lumberjack::Severity::WARN)
95
+ llm_config.log_level = LOG_LEVELS.fetch(level, Lumberjack::Severity::WARN)
95
96
  end
96
97
 
97
98
  # Reset the memoized @logger on RubyLLM module so next call uses our config
@@ -124,10 +125,9 @@ module AIA
124
125
  config.log_file = resolve_log_file_io(file)
125
126
  end
126
127
 
127
- if config.respond_to?(:log_level=)
128
- level = effective_log_level(logger_config_for(:mcp))
129
- config.log_level = LOG_LEVELS.fetch(level, Lumberjack::Severity::WARN)
130
- end
128
+ return unless config.respond_to?(:log_level=)
129
+ level = effective_log_level(logger_config_for(:mcp))
130
+ config.log_level = LOG_LEVELS.fetch(level, Lumberjack::Severity::WARN)
131
131
  end
132
132
 
133
133
  # Convert log file specification to IO object or file path
@@ -160,6 +160,27 @@ module AIA
160
160
  @test_mode = false
161
161
  end
162
162
 
163
+ # Update existing loggers' levels in-place from current config.
164
+ # Call this after runtime config changes (e.g. /config debug = false)
165
+ # so the log level takes effect immediately without recreating loggers.
166
+ def reconfigure_levels!
167
+ return if test_mode?
168
+
169
+ %i[aia llm mcp].each do |system|
170
+ logger = instance_variable_get(:"@#{system}_logger")
171
+ next unless logger
172
+
173
+ config = logger_config_for(system)
174
+ level = effective_log_level(config, system)
175
+ numeric = LOG_LEVELS.fetch(level, Lumberjack::Severity::WARN)
176
+ logger.level = numeric
177
+ end
178
+
179
+ # Keep RubyLLM / MCP configs in sync so they don't override us
180
+ configure_llm_logger
181
+ configure_mcp_logger
182
+ end
183
+
163
184
  # =======================================================================
164
185
  # Test Mode Support
165
186
  # =======================================================================
@@ -287,23 +308,21 @@ module AIA
287
308
  # @param file [String] The file config value
288
309
  # @param flush [Boolean] If true, flush immediately (no buffering)
289
310
  # @return [Lumberjack::Device] The device instance
311
+ # :reek:BooleanParameter -- flush: maps directly onto Lumberjack's autoflush; two constructors would obscure that single toggle
290
312
  def create_device(file, flush: true)
291
- # buffer_size: 0 means immediate flush (no buffering)
292
- buffer_size = flush ? 0 : 8192
293
-
294
313
  case file.to_s.upcase
295
314
  when 'STDOUT'
296
- Lumberjack::Device::Writer.new($stdout, buffer_size: buffer_size)
315
+ Lumberjack::Device::Writer.new($stdout, autoflush: flush)
297
316
  when 'STDERR'
298
- Lumberjack::Device::Writer.new($stderr, buffer_size: buffer_size)
317
+ Lumberjack::Device::Writer.new($stderr, autoflush: flush)
299
318
  else
300
319
  path = File.expand_path(file)
301
- # Use date rolling for file-based logs
302
- # Multiple loggers can safely write to the same file
303
- Lumberjack::Device::DateRollingLogFile.new(
320
+ # Daily date rolling via Logger::LogDevice, which also makes the
321
+ # file safe for multiple loggers to write to
322
+ Lumberjack::Device::LogFile.new(
304
323
  path,
305
- roll: :daily,
306
- buffer_size: buffer_size
324
+ shift_age: 'daily',
325
+ autoflush: flush
307
326
  )
308
327
  end
309
328
  end
@@ -313,12 +332,13 @@ module AIA
313
332
  # @param system [Symbol] The system (:aia, :llm, :mcp)
314
333
  # @return [ConfigSection, nil] The configuration section
315
334
  def logger_config_for(system)
316
- return nil unless AIA.config&.logger
335
+ logger_cfg = AIA.config&.logger
336
+ return nil unless logger_cfg
317
337
 
318
338
  case system
319
- when :aia then AIA.config.logger.aia
320
- when :llm then AIA.config.logger.llm
321
- when :mcp then AIA.config.logger.mcp
339
+ when :aia then logger_cfg.aia
340
+ when :llm then logger_cfg.llm
341
+ when :mcp then logger_cfg.mcp
322
342
  end
323
343
  rescue NoMethodError
324
344
  nil