aia 1.1.0 → 2.0.0.0.pre.alpha

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 (170) hide show
  1. checksums.yaml +4 -4
  2. data/.envrc +5 -1
  3. data/.loki +231 -0
  4. data/.quality/flay_baseline.txt +1 -0
  5. data/.quality/flog_baseline.txt +29 -0
  6. data/.quality/reek_baseline.txt +80 -0
  7. data/.rubocop.yml +116 -0
  8. data/.version +1 -1
  9. data/CHANGELOG.md +266 -42
  10. data/IMPLEMENTATION_PLAN.md +506 -0
  11. data/README.md +266 -238
  12. data/Rakefile +118 -5
  13. data/architecture_review.md +314 -0
  14. data/bin/aia +16 -0
  15. data/docs/AGENTS.md +40 -0
  16. data/docs/advanced-prompting.md +67 -3
  17. data/docs/cli-reference.md +312 -56
  18. data/docs/configuration.md +130 -19
  19. data/docs/contributing.md +56 -2
  20. data/docs/directives-reference.md +593 -78
  21. data/docs/faq.md +85 -3
  22. data/docs/guides/available-models.md +1 -1
  23. data/docs/guides/basic-usage.md +6 -6
  24. data/docs/guides/chat.md +40 -16
  25. data/docs/guides/crew.md +239 -0
  26. data/docs/guides/executable-prompts.md +1 -1
  27. data/docs/guides/index.md +1 -0
  28. data/docs/guides/models.md +15 -0
  29. data/docs/index.md +29 -2
  30. data/docs/installation.md +44 -17
  31. data/docs/mcp-integration.md +40 -0
  32. data/docs/prompt_management.md +85 -86
  33. data/docs/security.md +47 -0
  34. data/docs/special_projects_guide.md +386 -0
  35. data/docs/tools-and-mcp-examples.md +23 -0
  36. data/docs/workflows-and-pipelines.md +84 -7
  37. data/examples/.gitignore +1 -0
  38. data/examples/00_setup_aia.sh +27 -44
  39. data/examples/11_multi_model.sh +4 -14
  40. data/examples/12_token_usage.sh +3 -12
  41. data/examples/18_tools.sh +10 -2
  42. data/examples/22_chat_mode.sh +0 -10
  43. data/examples/23_verify.sh +139 -0
  44. data/examples/24_decompose.sh +139 -0
  45. data/examples/25_spawn.sh +139 -0
  46. data/examples/26_debate.sh +97 -0
  47. data/examples/27_mention_routing.sh +157 -0
  48. data/examples/28_model_switching.sh +106 -0
  49. data/examples/29_agent_harness.sh +177 -0
  50. data/examples/README.md +65 -0
  51. data/examples/advanced_multi_robot_capabilities_without_examples.md +106 -0
  52. data/examples/aia_config.yml +1 -1
  53. data/examples/aia_config_orchestrator.yml +45 -0
  54. data/examples/common.sh +19 -0
  55. data/examples/context/tech_stack.md +2 -2
  56. data/examples/prompts_dir/project_summary +2 -2
  57. data/examples/prompts_dir/roles/orchestrator.md +21 -0
  58. data/examples/requirements/sinatra_taskflow_app.md +139 -0
  59. data/examples/rules/01_classify_ruby.rb +16 -0
  60. data/examples/rules/02_prefer_claude_for_code.rb +19 -0
  61. data/examples/rules/03_gate_prompt_length.rb +19 -0
  62. data/examples/rules/04_tool_selection.rb +41 -0
  63. data/examples/rules/README.md +30 -0
  64. data/examples/run_all.sh +48 -15
  65. data/examples/tools/word_count_tool.rb +1 -1
  66. data/lib/AGENTS.md +57 -0
  67. data/lib/aia/chat_loop.rb +306 -159
  68. data/lib/aia/config/cli_parser.rb +174 -111
  69. data/lib/aia/config/defaults.yml +62 -33
  70. data/lib/aia/config/mcp_parser.rb +39 -46
  71. data/lib/aia/config/model_spec.rb +34 -2
  72. data/lib/aia/config/validator.rb +121 -138
  73. data/lib/aia/config.rb +110 -145
  74. data/lib/aia/content_extractor.rb +153 -0
  75. data/lib/aia/cost_calculator.rb +38 -0
  76. data/lib/aia/crew.rb +164 -0
  77. data/lib/aia/debate_handler.rb +166 -0
  78. data/lib/aia/delegate_handler.rb +112 -0
  79. data/lib/aia/directive.rb +33 -18
  80. data/lib/aia/directive_processor.rb +16 -7
  81. data/lib/aia/directives/configuration_directives.rb +160 -20
  82. data/lib/aia/directives/context_directives.rb +38 -26
  83. data/lib/aia/directives/execution_directives.rb +136 -4
  84. data/lib/aia/directives/model_directives.rb +76 -34
  85. data/lib/aia/directives/trakflow_directives.rb +44 -0
  86. data/lib/aia/directives/utility_directives.rb +203 -6
  87. data/lib/aia/directives/web_and_file_directives.rb +96 -60
  88. data/lib/aia/errors.rb +15 -0
  89. data/lib/aia/fact_asserter.rb +27 -0
  90. data/lib/aia/fzf.rb +9 -31
  91. data/lib/aia/handler_context.rb +17 -0
  92. data/lib/aia/handler_protocol.rb +19 -0
  93. data/lib/aia/history_transfer.rb +55 -0
  94. data/lib/aia/input_collector.rb +3 -3
  95. data/lib/aia/layered_orchestrator.rb +448 -0
  96. data/lib/aia/logger.rb +24 -4
  97. data/lib/aia/mcp_config_normalizer.rb +35 -0
  98. data/lib/aia/mcp_connection_manager.rb +305 -0
  99. data/lib/aia/mcp_discovery.rb +44 -0
  100. data/lib/aia/mcp_grouper.rb +33 -0
  101. data/lib/aia/mcp_utility.rb +57 -0
  102. data/lib/aia/mention_router.rb +260 -0
  103. data/lib/aia/model_alias_registry.rb +97 -0
  104. data/lib/aia/model_switch_handler.rb +100 -0
  105. data/lib/aia/network_builder.rb +155 -0
  106. data/lib/aia/network_memory_manager.rb +55 -0
  107. data/lib/aia/patches/ruby_llm_streaming_error.rb +43 -0
  108. data/lib/aia/patches/ruby_llm_tool_error.rb +96 -0
  109. data/lib/aia/pipeline_orchestrator.rb +262 -0
  110. data/lib/aia/plugin_loader.rb +170 -0
  111. data/lib/aia/plugin_monitor.rb +208 -0
  112. data/lib/aia/prompt_decomposer.rb +157 -0
  113. data/lib/aia/prompt_handler.rb +19 -39
  114. data/lib/aia/robot_builder.rb +51 -0
  115. data/lib/aia/robot_factory.rb +334 -0
  116. data/lib/aia/robot_namer.rb +116 -0
  117. data/lib/aia/session.rb +83 -17
  118. data/lib/aia/session_tracker.rb +209 -0
  119. data/lib/aia/similarity_scorer.rb +39 -0
  120. data/lib/aia/skill_utils.rb +105 -1
  121. data/lib/aia/spawn_handler.rb +129 -0
  122. data/lib/aia/spawn_spec_parser.rb +65 -0
  123. data/lib/aia/special_mode_handler.rb +302 -0
  124. data/lib/aia/startup_coordinator.rb +150 -0
  125. data/lib/aia/streaming_runner.rb +169 -0
  126. data/lib/aia/system_prompt_assembler.rb +88 -0
  127. data/lib/aia/task_coordinator.rb +202 -0
  128. data/lib/aia/task_decomposer.rb +57 -0
  129. data/lib/aia/task_executor.rb +51 -0
  130. data/lib/aia/tfidf_math.rb +27 -0
  131. data/lib/aia/tool_filter/tfidf.rb +116 -0
  132. data/lib/aia/tool_filter/wordnet_expander.rb +127 -0
  133. data/lib/aia/tool_filter.rb +82 -0
  134. data/lib/aia/tool_filter_registry.rb +30 -0
  135. data/lib/aia/tool_filter_strategy.rb +143 -0
  136. data/lib/aia/tool_loader.rb +210 -0
  137. data/lib/aia/tool_utility.rb +30 -0
  138. data/lib/aia/tools/delegate_to_foreman_tool.rb +70 -0
  139. data/lib/aia/tools/recruit_robot_tool.rb +60 -0
  140. data/lib/aia/tools/reskill_robot_tool.rb +44 -0
  141. data/lib/aia/tools/task_board_tool.rb +114 -0
  142. data/lib/aia/trakflow_bridge.rb +173 -0
  143. data/lib/aia/turn_state.rb +94 -0
  144. data/lib/aia/ui_presenter.rb +166 -198
  145. data/lib/aia/utility.rb +134 -87
  146. data/lib/aia/{history_manager.rb → variable_input_collector.rb} +8 -9
  147. data/lib/aia/verification_network.rb +58 -0
  148. data/lib/aia.rb +108 -63
  149. data/mkdocs.yml +1 -0
  150. metadata +179 -56
  151. data/justfile +0 -215
  152. data/lib/aia/adapter/chat_execution.rb +0 -242
  153. data/lib/aia/adapter/error_handler.rb +0 -68
  154. data/lib/aia/adapter/gem_activator.rb +0 -57
  155. data/lib/aia/adapter/mcp_connector.rb +0 -274
  156. data/lib/aia/adapter/modality_handlers.rb +0 -167
  157. data/lib/aia/adapter/model_registry.rb +0 -81
  158. data/lib/aia/adapter/multi_model_chat.rb +0 -218
  159. data/lib/aia/adapter/provider_configurator.rb +0 -59
  160. data/lib/aia/adapter/tool_filter.rb +0 -85
  161. data/lib/aia/adapter/tool_loader.rb +0 -90
  162. data/lib/aia/chat_processor_service.rb +0 -164
  163. data/lib/aia/prompt_pipeline.rb +0 -183
  164. data/lib/aia/ruby_llm_adapter.rb +0 -95
  165. data/lib/extensions/openstruct_merge.rb +0 -48
  166. data/lib/extensions/ruby_llm/.irbrc +0 -56
  167. data/lib/extensions/ruby_llm/modalities.rb +0 -36
  168. data/lib/extensions/ruby_llm/provider_fix.rb +0 -79
  169. data/lib/refinements/string.rb +0 -16
  170. data/main.just +0 -76
@@ -0,0 +1,448 @@
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
+ # Tier 1 prompt: decompose requirements into layers
38
+ # Deliberately short and directive to minimise qwen3 think-block noise.
39
+ LAYER_DECOMPOSE_PROMPT = <<~PROMPT
40
+ TASK: Decompose the application requirements below into architectural layers.
41
+ RESPOND WITH ONLY A JSON ARRAY. No explanation. No markdown. No code fences.
42
+
43
+ JSON schema (array of objects):
44
+ name - snake_case layer identifier
45
+ title - short human-readable title
46
+ description - one sentence describing what this layer covers
47
+ requirements - comma-separated list of things to implement in this layer
48
+
49
+ Rules: 3 to %{max_layers} layers. Infrastructure first, UI last.
50
+
51
+ APPLICATION REQUIREMENTS:
52
+ %{requirements}
53
+
54
+ JSON ARRAY RESPONSE:
55
+ PROMPT
56
+
57
+ # Tier 2 prompt: lead agent decomposes its layer into tasks
58
+ TASK_DECOMPOSE_PROMPT = <<~PROMPT
59
+ TASK: Break the %{layer_title} layer into implementation tasks.
60
+ RESPOND WITH ONLY A JSON ARRAY. No explanation. No markdown. No code fences.
61
+
62
+ JSON schema (array of objects):
63
+ title - short task title
64
+ specialist - specialist role name (e.g. sequel-migration-writer)
65
+ artifact - exact filename to produce (e.g. db/migrations/001_create_users.rb)
66
+ prompt - self-contained instruction for the specialist
67
+
68
+ Rules: 2 to %{max_tasks} tasks. Each task produces exactly one file.
69
+
70
+ LAYER: %{layer_title}
71
+ REQUIREMENTS: %{requirements}
72
+
73
+ JSON ARRAY RESPONSE:
74
+ PROMPT
75
+
76
+ # Layer synthesis prompt
77
+ LAYER_SYNTHESIS_PROMPT = <<~PROMPT
78
+ You implemented the %{layer_title} layer. Here are the artifacts produced
79
+ by your specialist team:
80
+
81
+ %{task_results}
82
+
83
+ Write a brief (3-5 sentence) integration summary: what was built, how the
84
+ artifacts fit together, and what the next layer depends on from this one.
85
+ PROMPT
86
+
87
+ # Final synthesis prompt for Tobor
88
+ FINAL_SYNTHESIS_PROMPT = <<~PROMPT
89
+ All architectural layers of the application have been built by the agent teams.
90
+ Here is what each layer produced:
91
+
92
+ %{layer_summaries}
93
+
94
+ Original requirements excerpt:
95
+ %{requirements_excerpt}
96
+
97
+ Provide a final integration summary:
98
+ 1. What was built overall
99
+ 2. How the layers connect (what each layer depends on from the layers below it)
100
+ 3. The minimal steps to make the application runnable (config, migrations, startup)
101
+ PROMPT
102
+
103
+ def initialize(robot:, ui_presenter:, tracker:, build_dir: nil)
104
+ @robot = robot
105
+ @ui_presenter = ui_presenter
106
+ @tracker = tracker
107
+ @build_dir = build_dir || default_build_dir
108
+ end
109
+
110
+ def default_build_dir
111
+ File.join(Dir.pwd, "orchestrated_build_#{Time.now.strftime('%Y%m%d_%H%M%S')}")
112
+ end
113
+
114
+ # Print orchestration progress to stdout so it appears inline with the chat.
115
+ # (display_info uses $stderr which is invisible in interactive sessions.)
116
+ def say(msg)
117
+ $stdout.puts msg
118
+ $stdout.flush
119
+ end
120
+
121
+ attr_writer :robot
122
+
123
+ # Entry point called by SpecialModeHandler.
124
+ #
125
+ # @param context [HandlerContext] — reads context.prompt as requirements text
126
+ # @return [String, nil] final synthesis or nil on failure
127
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
128
+ def handle(context)
129
+ requirements = context.prompt
130
+ primary = @robot.chief
131
+
132
+ FileUtils.mkdir_p(@build_dir)
133
+ say("")
134
+ say("╔══════════════════════════════════════════════════════════╗")
135
+ say("║ LAYERED ORCHESTRATION — 3-TIER BUILD ║")
136
+ say("╚══════════════════════════════════════════════════════════╝")
137
+ say("Requirements: #{requirements.lines.first.strip}")
138
+ say("Build output: #{@build_dir}")
139
+ say("")
140
+
141
+ # Tier 1: Tobor decomposes requirements into layers
142
+ layers = decompose_to_layers(primary, requirements)
143
+ if layers.empty?
144
+ say("Could not decompose requirements into layers. Aborting orchestration.")
145
+ return nil
146
+ end
147
+
148
+ display_layer_plan(layers)
149
+
150
+ # Tier 2: All lead agents run in parallel (Async::Barrier)
151
+ say("Running #{layers.size} lead agents in parallel...")
152
+ layer_task_map = run_leads_wave(layers)
153
+
154
+ if layer_task_map.values.all?(&:empty?)
155
+ say("All lead agents failed. Aborting orchestration.")
156
+ raise OrchestratorError, "All lead agents failed to produce tasks"
157
+ end
158
+
159
+ # Tier 3: All specialists run in parallel (Async::Barrier)
160
+ say("")
161
+ say("Running specialists in parallel...")
162
+ specialist_results = run_specialists_wave(layer_task_map)
163
+
164
+ # Assemble layer_results for synthesis (same shape as the former sequential approach)
165
+ layer_results = build_layer_results(layers, layer_task_map, specialist_results)
166
+
167
+ # Final synthesis by Tobor
168
+ say("")
169
+ say("━━━ Tobor synthesizing all #{layers.size} layers ━━━")
170
+ final_result = synthesize_all(primary, requirements, layers, layer_results)
171
+ final_text = extract_content(final_result)
172
+
173
+ save_final_report(final_text)
174
+ say("Build complete: #{@build_dir}")
175
+
176
+ @tracker.record_turn(
177
+ model: AIA.config.models.first.name,
178
+ input: requirements,
179
+ result: final_text
180
+ )
181
+
182
+ final_text
183
+ rescue StandardError => e
184
+ say("✗ Orchestration error: #{e.class}: #{e.message}")
185
+ e.backtrace&.first(5)&.each { |line| say(" #{line}") }
186
+ nil
187
+ end
188
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
189
+
190
+ private
191
+
192
+ # Tier 1: use a probe robot to decompose requirements into layer specs
193
+ def decompose_to_layers(robot, requirements)
194
+ say("Tier 1 ▶ #{robot.name} decomposing requirements into layers...")
195
+ probe = build_probe(robot.name + "-layer-probe")
196
+ prompt = LAYER_DECOMPOSE_PROMPT % {
197
+ requirements: requirements,
198
+ max_layers: MAX_LAYERS
199
+ }
200
+
201
+ result = probe.run(prompt, mcp: :none, tools: :none)
202
+ content = extract_content(result)
203
+ layers = parse_json_array(content)
204
+
205
+ if layers.empty?
206
+ preview = content.to_s.gsub(%r{<think>.*?</think>}m, '').strip[0, 300]
207
+ say(" ⚠ Layer decomposition parse failed.")
208
+ say(" Raw response preview: #{preview}")
209
+ end
210
+
211
+ layers.first(MAX_LAYERS)
212
+ rescue StandardError => e
213
+ say(" ✗ decompose_to_layers failed: #{e.class}: #{e.message}")
214
+ e.backtrace&.first(3)&.each { |line| say(" #{line}") }
215
+ []
216
+ end
217
+
218
+ def display_layer_plan(layers)
219
+ say("Tier 1 ▶ #{layers.size} layers identified:")
220
+ layers.each_with_index do |layer, i|
221
+ say(" #{i + 1}. #{layer['title']}: #{layer['description']}")
222
+ end
223
+ end
224
+
225
+ # Tier 2 Wave: run all lead agents in parallel via Async::Barrier.
226
+ # Each lead decomposes its layer into task specifications.
227
+ #
228
+ # @param layers [Array<Hash>] layer specs from Tier 1
229
+ # @return [Hash{ String => Array<Hash> }] layer_name => task list ([] on failure)
230
+ def run_leads_wave(layers)
231
+ results = {}
232
+
233
+ Sync do
234
+ barrier = Async::Barrier.new
235
+ layers.each do |layer|
236
+ barrier.async do
237
+ probe = build_probe("#{layer['name']}-lead")
238
+ prompt = TASK_DECOMPOSE_PROMPT % {
239
+ layer_title: layer['title'],
240
+ requirements: layer['requirements'].to_s,
241
+ max_tasks: MAX_TASKS_PER_LAYER
242
+ }
243
+ result = probe.run(prompt, mcp: :none, tools: :none)
244
+ tasks = parse_json_array(extract_content(result)).first(MAX_TASKS_PER_LAYER)
245
+ say(" ✓ #{layer['title']} lead: #{tasks.size} task(s)")
246
+ results[layer['name']] = tasks
247
+ rescue => e
248
+ say(" ✗ #{layer['title']} lead failed: #{e.message}")
249
+ results[layer['name']] = []
250
+ end
251
+ end
252
+ barrier.wait
253
+ end
254
+
255
+ results
256
+ end
257
+
258
+ # Tier 3 Wave: run all specialists in parallel via Async::Barrier.
259
+ # Flattens tasks from all layers into a single barrier so specialists
260
+ # across all layers execute concurrently.
261
+ #
262
+ # @param layer_task_map [Hash{ String => Array<Hash> }] output of run_leads_wave
263
+ # @return [Hash{ String => Hash }] "layer_name|artifact_path" => result hash
264
+ def run_specialists_wave(layer_task_map)
265
+ jobs = layer_task_map.flat_map do |layer_name, tasks|
266
+ tasks.map { |task| { layer_name: layer_name, task: task } }
267
+ end
268
+
269
+ results = {}
270
+
271
+ # rubocop:disable Metrics/BlockLength
272
+ Sync do
273
+ barrier = Async::Barrier.new
274
+ jobs.each do |job|
275
+ barrier.async do
276
+ task = job[:task]
277
+ key = "#{job[:layer_name]}|#{task['artifact']}"
278
+ probe = build_probe("#{task['specialist']}-specialist")
279
+
280
+ full_prompt = "Artifact to produce: #{task['artifact']}\n\n#{task['prompt']}"
281
+ result = probe.run(full_prompt, mcp: :none, tools: :none)
282
+ output = extract_content(result)
283
+
284
+ save_artifact(task['artifact'], output)
285
+ say(" ✓ #{task['artifact']}")
286
+
287
+ results[key] = {
288
+ task: task['title'],
289
+ specialist: task['specialist'],
290
+ artifact: task['artifact'],
291
+ output: output
292
+ }
293
+ rescue => e
294
+ task = job[:task]
295
+ key = "#{job[:layer_name]}|#{task['artifact']}"
296
+ say(" ✗ #{task['artifact']} failed: #{e.message}")
297
+ results[key] = {
298
+ task: task['title'],
299
+ specialist: task['specialist'],
300
+ artifact: task['artifact'],
301
+ output: "[FAILED: #{e.message}]"
302
+ }
303
+ end
304
+ end
305
+ # rubocop:enable Metrics/BlockLength
306
+ barrier.wait
307
+ end
308
+ results
309
+ end
310
+
311
+ # Assemble the layer_results array expected by synthesize_all.
312
+ # Shape: [{ layer:, tasks: [result_hashes], summary: }]
313
+ #
314
+ # @param layers [Array<Hash>] original layer specs
315
+ # @param layer_task_map [Hash] output of run_leads_wave
316
+ # @param specialist_results [Hash] output of run_specialists_wave
317
+ # @return [Array<Hash>]
318
+ def build_layer_results(layers, layer_task_map, specialist_results)
319
+ layers.map do |layer|
320
+ tasks_for_layer = layer_task_map[layer['name']] || []
321
+ task_results = tasks_for_layer.map do |task|
322
+ key = "#{layer['name']}|#{task['artifact']}"
323
+ specialist_results[key] || {
324
+ task: task['title'],
325
+ specialist: task['specialist'],
326
+ artifact: task['artifact'],
327
+ output: "[FAILED: no result]"
328
+ }
329
+ end
330
+
331
+ summary = synthesize_layer_from_results(layer['title'], task_results)
332
+ say(" ✓ #{layer['title']} synthesis complete")
333
+ { layer: layer['title'], tasks: task_results, summary: summary }
334
+ end
335
+ end
336
+
337
+ # Synthesize all task outputs for a layer into an integration summary.
338
+ # Uses a fresh probe robot (no conversation history).
339
+ def synthesize_layer_from_results(layer_title, task_results)
340
+ results_text = task_results.map do |r|
341
+ "### #{r[:task]} — #{r[:artifact]}\n#{r[:output]}"
342
+ end.join("\n\n---\n\n")
343
+
344
+ prompt = LAYER_SYNTHESIS_PROMPT % {
345
+ layer_title: layer_title,
346
+ task_results: results_text
347
+ }
348
+
349
+ probe = build_probe("#{layer_title.downcase.gsub(/\s+/, '-')}-synthesis")
350
+ result = probe.run(prompt, mcp: :none, tools: :none)
351
+ extract_content(result)
352
+ rescue StandardError
353
+ "(layer synthesis failed)"
354
+ end
355
+
356
+ # Tobor synthesizes all layer summaries into a final integration report
357
+ def synthesize_all(primary, requirements, layers, layer_results)
358
+ summaries = layer_results.each_with_index.map do |lr, i|
359
+ header = "## Layer #{i + 1}: #{lr[:layer]}"
360
+ tasks = Array(lr[:tasks]).map { |t| "- #{t[:artifact]}: #{t[:task]}" }.join("\n")
361
+ "#{header}\n#{tasks}\n\nSummary: #{lr[:summary]}"
362
+ end.join("\n\n")
363
+
364
+ primary.run(
365
+ FINAL_SYNTHESIS_PROMPT % {
366
+ layer_summaries: summaries,
367
+ requirements_excerpt: requirements.lines.first(15).join
368
+ },
369
+ mcp: :none, tools: :none
370
+ )
371
+ end
372
+
373
+ # Parse JSON array from model output.
374
+ # Tries multiple extraction strategies to handle think blocks,
375
+ # markdown fences, prose before/after the JSON, and partial wrapping.
376
+ def parse_json_array(content)
377
+ return [] if content.nil? || content.strip.empty?
378
+
379
+ # Strip think blocks (qwen3 reasoning models wrap output in <think>...)
380
+ text = content.gsub(%r{<think>.*?</think>}m, '').strip
381
+
382
+ # Strategy 1: extract content from markdown code fences
383
+ if (m = text.match(/```(?:json)?\s*\n?(.*?)```/m))
384
+ candidate = m[1].strip
385
+ result = try_json_parse(candidate)
386
+ return result unless result.empty?
387
+ end
388
+
389
+ # Strategy 2: find the first [...] block in the response
390
+ if (m = text.match(/(\[[\s\S]*\])/m))
391
+ result = try_json_parse(m[1])
392
+ return result unless result.empty?
393
+ end
394
+
395
+ # Strategy 3: parse the whole stripped text
396
+ try_json_parse(text)
397
+ end
398
+
399
+ def try_json_parse(text)
400
+ result = JSON.parse(text.strip)
401
+ result.is_a?(Array) ? result.grep(Hash) : []
402
+ rescue JSON::ParserError
403
+ []
404
+ end
405
+
406
+ # Build a short-lived probe robot with no conversation history.
407
+ # Uses the primary model spec so the provider is correctly wired.
408
+ def build_probe(name)
409
+ config = AIA.config
410
+ run_config = RobotFactory.build_run_config(config)
411
+ model_spec = config.models.first
412
+
413
+ RobotFactory.build_robot(
414
+ model_spec,
415
+ name: name,
416
+ system_prompt: nil,
417
+ config: run_config
418
+ )
419
+ end
420
+
421
+ # Write an artifact to the build directory.
422
+ # Strips markdown code fences if the entire output is wrapped in them.
423
+ def save_artifact(artifact_path, content)
424
+ return unless content && !content.strip.empty?
425
+
426
+ # Strip a single wrapping code fence if the whole content is fenced
427
+ code = content.strip
428
+ if (m = code.match(/\A```\w*\n(.*)\n```\z/m))
429
+ code = m[1]
430
+ end
431
+
432
+ dest = File.join(@build_dir, artifact_path)
433
+ FileUtils.mkdir_p(File.dirname(dest))
434
+ File.write(dest, code)
435
+ rescue StandardError => e
436
+ say(" ⚠ Could not save #{artifact_path}: #{e.message}")
437
+ end
438
+
439
+ # Write the integration report to BUILD_DIR/INTEGRATION_REPORT.md
440
+ def save_final_report(text)
441
+ dest = File.join(@build_dir, "INTEGRATION_REPORT.md")
442
+ File.write(dest, text)
443
+ say("Integration report: #{dest}")
444
+ rescue StandardError
445
+ # best-effort
446
+ end
447
+ end
448
+ end
data/lib/aia/logger.rb CHANGED
@@ -124,10 +124,9 @@ module AIA
124
124
  config.log_file = resolve_log_file_io(file)
125
125
  end
126
126
 
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
127
+ return unless 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)
131
130
  end
132
131
 
133
132
  # Convert log file specification to IO object or file path
@@ -160,6 +159,27 @@ module AIA
160
159
  @test_mode = false
161
160
  end
162
161
 
162
+ # Update existing loggers' levels in-place from current config.
163
+ # Call this after runtime config changes (e.g. /config debug = false)
164
+ # so the log level takes effect immediately without recreating loggers.
165
+ def reconfigure_levels!
166
+ return if test_mode?
167
+
168
+ %i[aia llm mcp].each do |system|
169
+ logger = instance_variable_get(:"@#{system}_logger")
170
+ next unless logger
171
+
172
+ config = logger_config_for(system)
173
+ level = effective_log_level(config, system)
174
+ numeric = LOG_LEVELS.fetch(level, Lumberjack::Severity::WARN)
175
+ logger.level = numeric
176
+ end
177
+
178
+ # Keep RubyLLM / MCP configs in sync so they don't override us
179
+ configure_llm_logger
180
+ configure_mcp_logger
181
+ end
182
+
163
183
  # =======================================================================
164
184
  # Test Mode Support
165
185
  # =======================================================================
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ # lib/aia/mcp_config_normalizer.rb
4
+ #
5
+ # Normalizes a single MCP server config from AIA flat format to the nested
6
+ # transport format expected by robot_lab. Server selection/filtering is
7
+ # the responsibility of MCPDiscovery — this class only transforms shape.
8
+
9
+ module AIA
10
+ class MCPConfigNormalizer
11
+ class << self
12
+ # Normalize a single MCP server config to robot_lab's nested transport format.
13
+ #
14
+ # @param server [Hash]
15
+ # @return [Hash]
16
+ def normalize(server)
17
+ server = server.is_a?(Hash) ? server.transform_keys(&:to_sym) : server.to_h.transform_keys(&:to_sym)
18
+
19
+ # Already in robot_lab format — pass through
20
+ return server if server[:transport]
21
+
22
+ # Legacy flat format: wrap command/args/env into transport
23
+ name = server[:name]
24
+ transport = { type: server[:type] || 'stdio' }
25
+ transport[:command] = server[:command] if server[:command]
26
+ transport[:args] = Array(server[:args]) if server[:args]
27
+ transport[:env] = server[:env] if server[:env]
28
+
29
+ result = { name: name, transport: transport }
30
+ result[:timeout] = server[:timeout] if server[:timeout]
31
+ result
32
+ end
33
+ end
34
+ end
35
+ end