cct-cli 0.7.9.0__py3-none-any.whl

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 (325) hide show
  1. calc_terminal/__init__.py +14 -0
  2. calc_terminal/__main__.py +14 -0
  3. calc_terminal/activity.py +1334 -0
  4. calc_terminal/agent.py +3387 -0
  5. calc_terminal/agent_runtime.py +519 -0
  6. calc_terminal/ai_context.py +447 -0
  7. calc_terminal/ai_modes.py +752 -0
  8. calc_terminal/ai_personalization.py +286 -0
  9. calc_terminal/ai_preview_feedback.py +213 -0
  10. calc_terminal/aicore.py +2572 -0
  11. calc_terminal/anim.py +367 -0
  12. calc_terminal/app.py +3685 -0
  13. calc_terminal/art.py +639 -0
  14. calc_terminal/atomsim.py +368 -0
  15. calc_terminal/attachments.py +743 -0
  16. calc_terminal/benchmark_system.py +414 -0
  17. calc_terminal/browser/__init__.py +36 -0
  18. calc_terminal/browser/browser_state.py +346 -0
  19. calc_terminal/browser/devserver.py +176 -0
  20. calc_terminal/browser/engine.py +494 -0
  21. calc_terminal/browser/navigation.py +84 -0
  22. calc_terminal/browser/preview.py +429 -0
  23. calc_terminal/browser/preview_entry.py +95 -0
  24. calc_terminal/browser/project_detector.py +144 -0
  25. calc_terminal/browser/server.py +449 -0
  26. calc_terminal/browser/state.py +75 -0
  27. calc_terminal/browser/watcher.py +99 -0
  28. calc_terminal/browser_gui/__init__.py +1 -0
  29. calc_terminal/browser_gui/__main__.py +3 -0
  30. calc_terminal/browser_gui/launcher.py +173 -0
  31. calc_terminal/browser_gui/playwright_browser.py +117 -0
  32. calc_terminal/browser_gui/qt_browser.py +1501 -0
  33. calc_terminal/browser_gui/webview_browser.py +57 -0
  34. calc_terminal/capabilities/__init__.py +35 -0
  35. calc_terminal/capabilities/adapters/__init__.py +33 -0
  36. calc_terminal/capabilities/adapters/bioinformatics.py +204 -0
  37. calc_terminal/capabilities/adapters/browser_adapter.py +205 -0
  38. calc_terminal/capabilities/adapters/filesystem.py +206 -0
  39. calc_terminal/capabilities/adapters/git_adapter.py +202 -0
  40. calc_terminal/capabilities/adapters/jupyter_adapter.py +138 -0
  41. calc_terminal/capabilities/adapters/ml_frameworks.py +158 -0
  42. calc_terminal/capabilities/adapters/platforms.py +200 -0
  43. calc_terminal/capabilities/adapters/python_exec.py +93 -0
  44. calc_terminal/capabilities/adapters/quantum_adapter.py +150 -0
  45. calc_terminal/capabilities/adapters/scientific_comp.py +123 -0
  46. calc_terminal/capabilities/adapters/structural_bio.py +161 -0
  47. calc_terminal/capabilities/adapters/terminal.py +99 -0
  48. calc_terminal/capabilities/bus.py +178 -0
  49. calc_terminal/capabilities/discovery.py +207 -0
  50. calc_terminal/capabilities/schema.py +221 -0
  51. calc_terminal/cat.ico +0 -0
  52. calc_terminal/cat_browser.py +2018 -0
  53. calc_terminal/chat_store.py +703 -0
  54. calc_terminal/cli.py +1178 -0
  55. calc_terminal/code_editor.py +640 -0
  56. calc_terminal/collaboration.py +723 -0
  57. calc_terminal/commands_data.py +139 -0
  58. calc_terminal/compatibility_engine.py +352 -0
  59. calc_terminal/compute/__init__.py +31 -0
  60. calc_terminal/compute/fabric.py +350 -0
  61. calc_terminal/config.py +227 -0
  62. calc_terminal/core/__init__.py +41 -0
  63. calc_terminal/core/checkpoint.py +156 -0
  64. calc_terminal/core/input/__init__.py +45 -0
  65. calc_terminal/core/mode_registry.py +300 -0
  66. calc_terminal/core/project_graph.py +172 -0
  67. calc_terminal/core/recovery.py +129 -0
  68. calc_terminal/core/security_layer.py +112 -0
  69. calc_terminal/core/task_graph.py +202 -0
  70. calc_terminal/core/unified_runtime.py +184 -0
  71. calc_terminal/core/verification.py +257 -0
  72. calc_terminal/customization.py +1566 -0
  73. calc_terminal/derivations.py +153 -0
  74. calc_terminal/device_control.py +263 -0
  75. calc_terminal/diagnostics/__init__.py +27 -0
  76. calc_terminal/diagnostics/doctor_engine.py +382 -0
  77. calc_terminal/diagnostics/self_test.py +247 -0
  78. calc_terminal/doctor.py +519 -0
  79. calc_terminal/easter_eggs.py +274 -0
  80. calc_terminal/editor/__init__.py +1 -0
  81. calc_terminal/editor/actions.py +263 -0
  82. calc_terminal/editor/commands.py +160 -0
  83. calc_terminal/editor/shortcuts.py +226 -0
  84. calc_terminal/engine.py +259 -0
  85. calc_terminal/errors.py +120 -0
  86. calc_terminal/event_stream.py +146 -0
  87. calc_terminal/eventbus.py +133 -0
  88. calc_terminal/extensions.py +733 -0
  89. calc_terminal/fallback_cli.py +1321 -0
  90. calc_terminal/first_run.py +265 -0
  91. calc_terminal/fomoji_auth.py +1043 -0
  92. calc_terminal/formulas.py +82 -0
  93. calc_terminal/fs_cache.py +121 -0
  94. calc_terminal/fs_watcher.py +277 -0
  95. calc_terminal/game.py +193 -0
  96. calc_terminal/gen1.py +5 -0
  97. calc_terminal/generators.py +245 -0
  98. calc_terminal/gestures/__init__.py +42 -0
  99. calc_terminal/gestures/bindings.py +175 -0
  100. calc_terminal/gestures/manager.py +477 -0
  101. calc_terminal/goodbye.py +363 -0
  102. calc_terminal/gpu3d.py +290 -0
  103. calc_terminal/graphs.py +358 -0
  104. calc_terminal/hardware_analyzer.py +440 -0
  105. calc_terminal/host/__init__.py +30 -0
  106. calc_terminal/host/browser_manager.py +187 -0
  107. calc_terminal/host/desktop.py +1386 -0
  108. calc_terminal/host/launcher.py +395 -0
  109. calc_terminal/host/terminal.py +279 -0
  110. calc_terminal/identity.py +216 -0
  111. calc_terminal/input/__init__.py +54 -0
  112. calc_terminal/input/capabilities.py +258 -0
  113. calc_terminal/input/focus.py +87 -0
  114. calc_terminal/input/gestures.py +64 -0
  115. calc_terminal/input/pointer.py +114 -0
  116. calc_terminal/input/touch.py +345 -0
  117. calc_terminal/keys.py +84 -0
  118. calc_terminal/live_automation.py +165 -0
  119. calc_terminal/mathtext.py +433 -0
  120. calc_terminal/mcp.py +386 -0
  121. calc_terminal/memory.py +337 -0
  122. calc_terminal/memory_v2.py +479 -0
  123. calc_terminal/metrics.py +333 -0
  124. calc_terminal/mode_detection.py +146 -0
  125. calc_terminal/model.py +2431 -0
  126. calc_terminal/model_router.py +665 -0
  127. calc_terminal/models/__init__.py +0 -0
  128. calc_terminal/models/active_state.py +187 -0
  129. calc_terminal/models/dynamic_registry.py +584 -0
  130. calc_terminal/models/manager.py +781 -0
  131. calc_terminal/models/model_metadata.json +3526 -0
  132. calc_terminal/models/profiles.py +194 -0
  133. calc_terminal/models/registry.py +265 -0
  134. calc_terminal/models/schema.py +197 -0
  135. calc_terminal/models/validator.py +287 -0
  136. calc_terminal/models/verification_engine.py +368 -0
  137. calc_terminal/native_picker.py +215 -0
  138. calc_terminal/ollama_catalog.py +279 -0
  139. calc_terminal/ollama_download.py +233 -0
  140. calc_terminal/orchestrator.py +304 -0
  141. calc_terminal/package_research.py +322 -0
  142. calc_terminal/packages.py +1024 -0
  143. calc_terminal/pc_specs.py +116 -0
  144. calc_terminal/permissions.py +334 -0
  145. calc_terminal/pet.py +106 -0
  146. calc_terminal/pipeline.py +505 -0
  147. calc_terminal/platform/__init__.py +491 -0
  148. calc_terminal/platform/desktop.py +491 -0
  149. calc_terminal/platform/web.py +781 -0
  150. calc_terminal/preview/__init__.py +1 -0
  151. calc_terminal/preview/dev_server.py +303 -0
  152. calc_terminal/preview/diagnostics.py +131 -0
  153. calc_terminal/preview/live_reload.py +66 -0
  154. calc_terminal/preview/manager.py +129 -0
  155. calc_terminal/project_stats.py +209 -0
  156. calc_terminal/projects.py +328 -0
  157. calc_terminal/providers/__init__.py +0 -0
  158. calc_terminal/providers/adapters/__init__.py +80 -0
  159. calc_terminal/providers/adapters/anthropic_adapter.py +127 -0
  160. calc_terminal/providers/adapters/base.py +106 -0
  161. calc_terminal/providers/adapters/chinese_adapters.py +420 -0
  162. calc_terminal/providers/adapters/gemini_adapter.py +101 -0
  163. calc_terminal/providers/adapters/ollama_adapter.py +83 -0
  164. calc_terminal/providers/adapters/openai_adapter.py +159 -0
  165. calc_terminal/providers/adapters/other_adapters.py +246 -0
  166. calc_terminal/providers/anthropic_provider.py +172 -0
  167. calc_terminal/providers/auto_update.py +416 -0
  168. calc_terminal/providers/base_provider.py +105 -0
  169. calc_terminal/providers/discovery_manager.py +207 -0
  170. calc_terminal/providers/gemini_provider.py +178 -0
  171. calc_terminal/providers/lifecycle.py +767 -0
  172. calc_terminal/providers/ollama_adapter.py +707 -0
  173. calc_terminal/providers/openai_provider.py +254 -0
  174. calc_terminal/providers/provider_manager.py +1827 -0
  175. calc_terminal/providers/providers.json +4075 -0
  176. calc_terminal/reactionsim.py +279 -0
  177. calc_terminal/registry.py +337 -0
  178. calc_terminal/report.py +162 -0
  179. calc_terminal/research/__init__.py +45 -0
  180. calc_terminal/research/artifact_intel.py +126 -0
  181. calc_terminal/research/data_lineage.py +123 -0
  182. calc_terminal/research/experiment_ledger.py +303 -0
  183. calc_terminal/research/reproducibility.py +131 -0
  184. calc_terminal/resilience/__init__.py +47 -0
  185. calc_terminal/resilience/agent_state.py +121 -0
  186. calc_terminal/resilience/capability_matcher.py +174 -0
  187. calc_terminal/resilience/circuit_breaker.py +158 -0
  188. calc_terminal/resilience/failover_engine.py +230 -0
  189. calc_terminal/resilience/health_monitor.py +192 -0
  190. calc_terminal/resilience/ollama_adapter.py +125 -0
  191. calc_terminal/resilience/orchestrator.py +312 -0
  192. calc_terminal/resilience/types.py +134 -0
  193. calc_terminal/sandbox.py +98 -0
  194. calc_terminal/scires.py +558 -0
  195. calc_terminal/security_scanner.py +126 -0
  196. calc_terminal/session.py +294 -0
  197. calc_terminal/sim3d.py +206 -0
  198. calc_terminal/solver.py +276 -0
  199. calc_terminal/sound.py +127 -0
  200. calc_terminal/task_reports.py +287 -0
  201. calc_terminal/terminal_host.py +201 -0
  202. calc_terminal/terminal_identity.py +411 -0
  203. calc_terminal/test_ai_mode_reliability.py +344 -0
  204. calc_terminal/test_browser.py +368 -0
  205. calc_terminal/test_code_editor_upgrade.py +485 -0
  206. calc_terminal/test_customization.py +1148 -0
  207. calc_terminal/test_customization_ui.py +612 -0
  208. calc_terminal/test_dynamic_registry.py +304 -0
  209. calc_terminal/test_extensions.py +436 -0
  210. calc_terminal/test_overhaul.py +557 -0
  211. calc_terminal/test_project_detect.py +255 -0
  212. calc_terminal/test_root_cause_fix.py +527 -0
  213. calc_terminal/test_stability.py +532 -0
  214. calc_terminal/test_terminal_identity.py +132 -0
  215. calc_terminal/test_v079_speed.py +460 -0
  216. calc_terminal/theme.py +1107 -0
  217. calc_terminal/timeline.py +139 -0
  218. calc_terminal/todos.py +246 -0
  219. calc_terminal/tool_call_normalizer.py +419 -0
  220. calc_terminal/tui.py +104 -0
  221. calc_terminal/ui/__init__.py +8 -0
  222. calc_terminal/ui/activity_panel.py +231 -0
  223. calc_terminal/ui/activity_stream_panel.py +238 -0
  224. calc_terminal/ui/animations.py +122 -0
  225. calc_terminal/ui/app.py +7271 -0
  226. calc_terminal/ui/attach_panel.py +597 -0
  227. calc_terminal/ui/attachments.py +424 -0
  228. calc_terminal/ui/backup_panel.py +810 -0
  229. calc_terminal/ui/browser_shell.py +887 -0
  230. calc_terminal/ui/cat_agent.py +357 -0
  231. calc_terminal/ui/chats_panel.py +899 -0
  232. calc_terminal/ui/command_palette.py +125 -0
  233. calc_terminal/ui/command_palette_modal.py +166 -0
  234. calc_terminal/ui/composer.py +1141 -0
  235. calc_terminal/ui/context_menu.py +197 -0
  236. calc_terminal/ui/conversation.py +1435 -0
  237. calc_terminal/ui/customization_panel.py +1229 -0
  238. calc_terminal/ui/dashboard.py +404 -0
  239. calc_terminal/ui/design_system.py +557 -0
  240. calc_terminal/ui/diff_panel.py +213 -0
  241. calc_terminal/ui/editor.py +2102 -0
  242. calc_terminal/ui/empty_state.py +302 -0
  243. calc_terminal/ui/events.py +487 -0
  244. calc_terminal/ui/extensions_panel.py +815 -0
  245. calc_terminal/ui/footer.py +166 -0
  246. calc_terminal/ui/gestures_panel.py +383 -0
  247. calc_terminal/ui/goodbye_screen.py +100 -0
  248. calc_terminal/ui/header.py +1034 -0
  249. calc_terminal/ui/help_panel.py +254 -0
  250. calc_terminal/ui/live_activities.py +914 -0
  251. calc_terminal/ui/mcp_panel.py +570 -0
  252. calc_terminal/ui/memory_center.py +524 -0
  253. calc_terminal/ui/mode_colors_panel.py +525 -0
  254. calc_terminal/ui/nav_screens.py +747 -0
  255. calc_terminal/ui/ollama_panel.py +536 -0
  256. calc_terminal/ui/palette.py +221 -0
  257. calc_terminal/ui/permission_panel.py +269 -0
  258. calc_terminal/ui/personalization_panel.py +517 -0
  259. calc_terminal/ui/personalize_center.py +1568 -0
  260. calc_terminal/ui/preview_panel.py +441 -0
  261. calc_terminal/ui/resizers.py +402 -0
  262. calc_terminal/ui/sidebar.py +1285 -0
  263. calc_terminal/ui/statusbar.py +168 -0
  264. calc_terminal/ui/theme_css.py +1396 -0
  265. calc_terminal/ui/thinking.py +226 -0
  266. calc_terminal/ui/timeline_panel.py +102 -0
  267. calc_terminal/ui/todo_panel.py +193 -0
  268. calc_terminal/ui/viewport.py +136 -0
  269. calc_terminal/ui/vision_panel.py +489 -0
  270. calc_terminal/ui/welcome_modal.py +343 -0
  271. calc_terminal/ui/widgets.py +160 -0
  272. calc_terminal/ui/workspace.py +831 -0
  273. calc_terminal/viewers/__init__.py +1 -0
  274. calc_terminal/viewers/document_viewer.py +252 -0
  275. calc_terminal/viewers/image_viewer.py +241 -0
  276. calc_terminal/viewers/pdf_viewer.py +203 -0
  277. calc_terminal/viewers/presentation_viewer.py +164 -0
  278. calc_terminal/viewers/registry.py +120 -0
  279. calc_terminal/viewers/spreadsheet_viewer.py +204 -0
  280. calc_terminal/vision/__init__.py +89 -0
  281. calc_terminal/vision/analysis.py +194 -0
  282. calc_terminal/vision/annotations.py +297 -0
  283. calc_terminal/vision/capture.py +171 -0
  284. calc_terminal/vision/context.py +231 -0
  285. calc_terminal/vision/correlation.py +169 -0
  286. calc_terminal/vision/cursor.py +258 -0
  287. calc_terminal/vision/events.py +66 -0
  288. calc_terminal/vision/frame_pipeline.py +259 -0
  289. calc_terminal/vision/priority.py +218 -0
  290. calc_terminal/vision/provider.py +180 -0
  291. calc_terminal/vision/safety.py +149 -0
  292. calc_terminal/vision/session.py +281 -0
  293. calc_terminal/vision/verify.py +162 -0
  294. calc_terminal/vision.py +514 -0
  295. calc_terminal/vscode_integration.py +113 -0
  296. calc_terminal/web/__init__.py +8 -0
  297. calc_terminal/web/cat_runtime.py +710 -0
  298. calc_terminal/web/server.py +2891 -0
  299. calc_terminal/web/static/css/app.css +3152 -0
  300. calc_terminal/web/static/icons/badge-72.png +0 -0
  301. calc_terminal/web/static/icons/cat.ico +0 -0
  302. calc_terminal/web/static/icons/icon-128.png +0 -0
  303. calc_terminal/web/static/icons/icon-144.png +0 -0
  304. calc_terminal/web/static/icons/icon-152.png +0 -0
  305. calc_terminal/web/static/icons/icon-192.png +0 -0
  306. calc_terminal/web/static/icons/icon-384.png +0 -0
  307. calc_terminal/web/static/icons/icon-512.png +0 -0
  308. calc_terminal/web/static/icons/icon-72.png +0 -0
  309. calc_terminal/web/static/icons/icon-96.png +0 -0
  310. calc_terminal/web/static/icons/icon.svg +34 -0
  311. calc_terminal/web/static/icons/new-project.png +0 -0
  312. calc_terminal/web/static/icons/open-project.png +0 -0
  313. calc_terminal/web/static/index.html +734 -0
  314. calc_terminal/web/static/js/app.js +2403 -0
  315. calc_terminal/web/static/manifest.json +88 -0
  316. calc_terminal/web/static/sw.js +230 -0
  317. calc_terminal/workflow_engine.py +769 -0
  318. calc_terminal/workspace.py +593 -0
  319. calc_terminal/workspace_index.py +385 -0
  320. cct_cli-0.7.9.0.dist-info/METADATA +210 -0
  321. cct_cli-0.7.9.0.dist-info/RECORD +325 -0
  322. cct_cli-0.7.9.0.dist-info/WHEEL +5 -0
  323. cct_cli-0.7.9.0.dist-info/entry_points.txt +4 -0
  324. cct_cli-0.7.9.0.dist-info/licenses/LICENSE +21 -0
  325. cct_cli-0.7.9.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,723 @@
1
+ """
2
+ CCT — collaboration.py: Multi-AI Collaboration Engine.
3
+
4
+ Extends the single-orchestrator pattern (orchestrator.py) into a richer
5
+ multi-agent collaboration framework with:
6
+
7
+ - A shared CollaborationContext that every agent reads from and writes to.
8
+ - Named agent roles, each with a dedicated system prompt.
9
+ - File-lock coordination so two agents never edit the same file at once.
10
+ - A MultiAgentOrchestrator that sequences roles, feeds the real tool
11
+ system (agent.TOOLS) to the coding agent, and collects structured
12
+ findings from every stage.
13
+
14
+ Usage:
15
+ from .collaboration import MultiAgentOrchestrator, CollaborationContext
16
+
17
+ orch = MultiAgentOrchestrator(query_fn=aicore.query_ai, tools_dict=agent.TOOLS)
18
+ final_text, steps, meta, agents = orch.run_collaborative("Plot the Arrhenius curve for ...")
19
+ """
20
+
21
+ if __name__ == "__main__":
22
+ print("This is a library file and is not meant to be run directly.")
23
+ import sys
24
+ sys.exit(1)
25
+
26
+ import json
27
+ import re
28
+ import threading
29
+ import time
30
+ from concurrent.futures import ThreadPoolExecutor
31
+ from dataclasses import dataclass, field
32
+ from typing import Any, Callable, Dict, List, Optional, Tuple
33
+
34
+ from . import aicore
35
+ from . import tool_call_normalizer
36
+ from . import agent
37
+ from . import security_scanner
38
+ from . import sandbox
39
+
40
+ # =====================================================================
41
+ # Agent Role Constants
42
+ # =====================================================================
43
+
44
+ PLANNER = "planner"
45
+ RESEARCHER = "researcher"
46
+ ARCHITECT = "architect"
47
+ CODER = "coder"
48
+ UI_DESIGNER = "ui_designer"
49
+ DEBUGGER = "debugger"
50
+ REVIEWER = "reviewer"
51
+ TESTER = "tester"
52
+ SECURITY = "security"
53
+ FINALIZER = "finalizer"
54
+
55
+ # =====================================================================
56
+ # Agent Role System Prompts
57
+ # =====================================================================
58
+
59
+ ROLE_PROMPTS: Dict[str, str] = {
60
+ PLANNER: (
61
+ "You are the PLANNER on a multi-AI team working inside CAT "
62
+ "(Chemistry Calc Terminal). You break a user request into a concise, "
63
+ "ordered list of concrete subtasks that other agents can execute "
64
+ "independently. You do NOT solve anything or call tools yourself.\n"
65
+ "Reply with ONLY a JSON object:\n"
66
+ '{"plan": ["subtask 1", "subtask 2", ...]}\n'
67
+ "Keep it to 2-8 actionable steps. No prose outside the JSON."
68
+ ),
69
+ RESEARCHER: (
70
+ "You are the RESEARCHER on a multi-AI team working inside CCT. You "
71
+ "gather external context via live web search so other agents do not "
72
+ "reason from stale training data on fast-moving facts.\n"
73
+ "You receive the original request and any prior findings. Produce a "
74
+ "short, cited research brief — bullet points with sources — that the "
75
+ "team can fold into their work. If nothing relevant is found, say so "
76
+ "plainly. No fabrication."
77
+ ),
78
+ ARCHITECT: (
79
+ "You are the ARCHITECT on a multi-AI team working inside CCT. You "
80
+ "review the structural design of proposed solutions: module layout, "
81
+ "file organisation, API boundaries, data flow.\n"
82
+ "You receive the original request and the current solution. Reply "
83
+ "with ONLY a JSON object:\n"
84
+ '{"ok": true/false, "note": "<concrete structural observation or '
85
+ 'improvement suggestion, one sentence>"}'
86
+ ),
87
+ CODER: (
88
+ "You are the CODER on a multi-AI team working inside CCT. You have "
89
+ "access to real tools (solve formulas, plot, write files, run "
90
+ "commands, etc.). Use them — never fabricate results that could be "
91
+ "computed.\n"
92
+ "Follow the plan handed to you. When you are finished, return a "
93
+ "comprehensive answer using the standard CAT notebook format."
94
+ ),
95
+ UI_DESIGNER: (
96
+ "You are the UI_DESIGNER on a multi-AI team working inside CCT. You "
97
+ "evaluate the usability, layout, and visual clarity of UI-facing "
98
+ "proposals.\n"
99
+ "Reply with ONLY a JSON object:\n"
100
+ '{"ok": true/false, "note": "<concrete UI improvement or approval>"}'
101
+ ),
102
+ DEBUGGER: (
103
+ "You are the DEBUGGER on a multi-AI team working inside CCT. You "
104
+ "review generated code for correctness, edge cases, and runtime "
105
+ "errors. You may also run sandboxed tests.\n"
106
+ "Reply with ONLY a JSON object:\n"
107
+ '{"ok": true/false, "note": "<issue description or \'no issues\'>"}'
108
+ ),
109
+ REVIEWER: (
110
+ "You are the REVIEWER on a multi-AI team working inside CCT. You "
111
+ "cross-check the CODER's final output against the original request. "
112
+ "Look for unanswered parts, implied assumptions, and inconsistencies.\n"
113
+ "Reply with ONLY a JSON object:\n"
114
+ '{"ok": true/false, "note": "<what is missing or wrong, one sentence>"}'
115
+ ),
116
+ TESTER: (
117
+ "You are the TESTER on a multi-AI team working inside CCT. You "
118
+ "spot-check arithmetic, units, and internal consistency of the "
119
+ "answer against the given data.\n"
120
+ "Reply with ONLY a JSON object:\n"
121
+ '{"ok": true/false, "note": "<what looks wrong, one sentence>"}'
122
+ ),
123
+ SECURITY: (
124
+ "You are the SECURITY agent on a multi-AI team working inside CCT. "
125
+ "You run a static security scan on any generated code and flag "
126
+ "dangerous patterns (subprocess calls, eval, exec, file-system "
127
+ "deletion, credential leaks, network calls, etc.).\n"
128
+ "Reply with ONLY a JSON object:\n"
129
+ '{"ok": true/false, "note": "<findings summary or \'clean\'>"}'
130
+ ),
131
+ FINALIZER: (
132
+ "You are the FINALIZER on a multi-AI team working inside CCT. You "
133
+ "receive all prior agent findings, decisions, and the coder's output. "
134
+ "Your job is to produce the single polished final answer the user "
135
+ "will see — merging feedback, resolving conflicts, and ensuring "
136
+ "completeness.\n"
137
+ "Reply with ONLY the final answer text, no JSON wrapper."
138
+ ),
139
+ }
140
+
141
+ # =====================================================================
142
+ # Content-gate patterns (which roles to activate for which task types)
143
+ # =====================================================================
144
+
145
+ _CODE_HINTS = re.compile(
146
+ r"\b(code|python|script|program|function|write a .*(class|module)|"
147
+ r"generate.*(code|script))\b", re.I)
148
+ _UI_HINTS = re.compile(
149
+ r"\b(ui|interface|screen|layout|widget|button|menu|dashboard|"
150
+ r"frontend|web page|landing page|design)\b", re.I)
151
+ _ARCH_HINTS = re.compile(
152
+ r"\b(architecture|design|structure|module|layers?|service|"
153
+ r"microservice|api design|database schema|data model)\b", re.I)
154
+ _RESEARCH_HINTS = re.compile(
155
+ r"\b(latest|recent|current|news|today|this year|search|look up|find out|"
156
+ r"who is|what happened|price of|update on)\b", re.I)
157
+
158
+ # =====================================================================
159
+ # CollaborationContext — shared mutable state passed between agents
160
+ # =====================================================================
161
+
162
+ @dataclass
163
+ class CollaborationContext:
164
+ """Mutable state bag shared across all agents during a collaborative run.
165
+
166
+ Every agent reads from and appends to the fields it cares about. The
167
+ orchestrator is responsible for serialising this context into each
168
+ agent's prompt so they can see what prior agents decided / found.
169
+ """
170
+ task: str = ""
171
+ project_state: Dict[str, Any] = field(default_factory=dict)
172
+ relevant_files: List[str] = field(default_factory=list)
173
+ agent_findings: Dict[str, Any] = field(default_factory=dict)
174
+ decisions: List[str] = field(default_factory=list)
175
+ issues: List[str] = field(default_factory=list)
176
+ tool_results: List[Dict[str, Any]] = field(default_factory=list)
177
+ open_questions: List[str] = field(default_factory=list)
178
+
179
+ # ------------------------------------------------------------------
180
+ # serialisation helpers
181
+ # ------------------------------------------------------------------
182
+ def to_prompt_block(self, exclude_roles: Optional[List[str]] = None) -> str:
183
+ """Render the context as a short text block suitable for injection
184
+ into a system prompt so an agent can see what came before it."""
185
+ exclude = set(exclude_roles or [])
186
+ lines: List[str] = []
187
+ if self.task:
188
+ lines.append(f"TASK: {self.task}")
189
+ if self.decisions:
190
+ lines.append("DECISIONS:")
191
+ for d in self.decisions:
192
+ lines.append(f" - {d}")
193
+ if self.issues:
194
+ lines.append("OPEN ISSUES:")
195
+ for i in self.issues:
196
+ lines.append(f" - {i}")
197
+ if self.open_questions:
198
+ lines.append("OPEN QUESTIONS:")
199
+ for q in self.open_questions:
200
+ lines.append(f" - {q}")
201
+ if self.relevant_files:
202
+ lines.append("RELEVANT FILES: " + ", ".join(self.relevant_files))
203
+ for role_key, finding in self.agent_findings.items():
204
+ if role_key in exclude:
205
+ continue
206
+ label = role_key.replace("_", " ").title()
207
+ lines.append(f"{label.upper()} FINDING: {finding}")
208
+ return "\n".join(lines)
209
+
210
+
211
+ # =====================================================================
212
+ # FileLockManager — prevent concurrent edits to the same file
213
+ # =====================================================================
214
+
215
+ class FileLockManager:
216
+ """Lightweight in-process file lock.
217
+
218
+ Agents request an exclusive lock before editing a file and release it
219
+ when done. If another agent already holds the lock the request fails
220
+ (returns False) so the orchestrator can retry or skip.
221
+
222
+ Thread-safe via a lock on the internal dict.
223
+ """
224
+
225
+ def __init__(self) -> None:
226
+ self._locked: Dict[str, str] = {} # file_path -> agent_id
227
+ self._lock = threading.Lock()
228
+
229
+ def lock(self, file_path: str, agent_id: str) -> bool:
230
+ """Attempt to acquire an exclusive lock for *file_path*.
231
+
232
+ Returns True on success, False if another agent already holds it.
233
+ """
234
+ with self._lock:
235
+ if file_path in self._locked:
236
+ return False
237
+ self._locked[file_path] = agent_id
238
+ return True
239
+
240
+ def unlock(self, file_path: str, agent_id: str) -> None:
241
+ """Release the lock on *file_path* if *agent_id* is the current owner.
242
+
243
+ Safe to call even if the lock was already released.
244
+ """
245
+ with self._lock:
246
+ if self._locked.get(file_path) == agent_id:
247
+ del self._locked[file_path]
248
+
249
+ def is_locked(self, file_path: str) -> bool:
250
+ """Return True if *file_path* is currently locked by any agent."""
251
+ with self._lock:
252
+ return file_path in self._locked
253
+
254
+ def get_lock_owner(self, file_path: str) -> Optional[str]:
255
+ """Return the agent_id that holds the lock, or None."""
256
+ with self._lock:
257
+ return self._locked.get(file_path)
258
+
259
+ def release_all(self, agent_id: str) -> List[str]:
260
+ """Release every lock held by *agent_id*. Returns list of paths released."""
261
+ released: List[str] = []
262
+ with self._lock:
263
+ for path in list(self._locked):
264
+ if self._locked[path] == agent_id:
265
+ del self._locked[path]
266
+ released.append(path)
267
+ return released
268
+
269
+
270
+ # =====================================================================
271
+ # MultiAgentOrchestrator
272
+ # =====================================================================
273
+
274
+ def _extract_json(text: str) -> Dict[str, Any]:
275
+ """Best-effort extraction of the first JSON object from *text*."""
276
+ if not text:
277
+ return {}
278
+ try:
279
+ text = re.sub(r"^```(?:json)?\s*|\s*```$", "", text.strip(), flags=re.MULTILINE)
280
+ m = re.search(r"\{.*\}", text, re.S)
281
+ return json.loads(m.group(0)) if m else {}
282
+ except Exception:
283
+ return {}
284
+
285
+
286
+ class MultiAgentOrchestrator:
287
+ """Sequences specialist AI agents through a shared CollaborationContext.
288
+
289
+ Parameters
290
+ ----------
291
+ query_fn : callable
292
+ The AI query function — ``aicore.query_ai`` by default. Signature:
293
+ ``query_fn(prompt, system_prompt=...) -> str``.
294
+ tools_dict : dict
295
+ The tools dictionary — ``agent.TOOLS`` by default. Only the CODER
296
+ role receives this directly; other roles see a filtered tool list
297
+ or no tools at all.
298
+ """
299
+
300
+ def __init__(self, query_fn: Optional[Callable] = None,
301
+ tools_dict: Optional[Dict[str, Any]] = None) -> None:
302
+ self.query_fn = query_fn or aicore.query_ai
303
+ self.tools_dict = tools_dict or agent.TOOLS
304
+ self.file_locks = FileLockManager()
305
+
306
+ # ------------------------------------------------------------------
307
+ # Internal helpers
308
+ # ------------------------------------------------------------------
309
+
310
+ def _query(self, prompt: str, system_prompt: str) -> str:
311
+ """Thin wrapper so all AI calls go through one choke-point."""
312
+ try:
313
+ raw = self.query_fn(prompt, system_prompt=system_prompt)
314
+ return raw or ""
315
+ except Exception:
316
+ return ""
317
+
318
+ def _extract_code_block(self, text: str) -> Optional[str]:
319
+ """Pull the first fenced Python code block out of *text*."""
320
+ m = re.search(r"```(?:python)?\s*\n(.*?)```", text or "", re.S)
321
+ return m.group(1) if m else None
322
+
323
+ def _activate_roles(self, user_text: str,
324
+ roles: Optional[List[str]] = None) -> List[str]:
325
+ """Determine which roles to activate based on task content and the
326
+ explicit *roles* override.
327
+
328
+ v0.7.9.0 (requirement #21 — DYNAMIC TEAM): the roster size now
329
+ follows real task complexity instead of always fielding everyone.
330
+ The model_router's complexity score decides:
331
+ simple (<= 0.35) → planner + coder + finalizer
332
+ moderate (<= 0.6) → + reviewer + tester
333
+ complex (> 0.6) → full gated roster (architect/ui/security/
334
+ debugger/researcher as content warrants)
335
+ """
336
+ if roles is not None:
337
+ return list(roles)
338
+
339
+ try:
340
+ from . import model_router as _mr
341
+ types = _mr.classify(user_text)
342
+ complexity = _mr.complexity_score(types, user_text)
343
+ except Exception:
344
+ types, complexity = set(), 0.4
345
+
346
+ roster: List[Dict[str, str]] = [PLANNER, CODER]
347
+ wants_code = bool(_CODE_HINTS.search(user_text))
348
+ wants_ui = bool(_UI_HINTS.search(user_text))
349
+ wants_arch = bool(_ARCH_HINTS.search(user_text))
350
+ wants_research = bool(_RESEARCH_HINTS.search(user_text))
351
+
352
+ if complexity <= 0.35:
353
+ # Simple task: a second opinion is latency theatre — skip it.
354
+ pass
355
+ elif complexity <= 0.6:
356
+ roster.extend([REVIEWER, TESTER])
357
+ else:
358
+ if wants_research:
359
+ roster.append(RESEARCHER)
360
+ if wants_arch:
361
+ roster.append(ARCHITECT)
362
+ if wants_ui:
363
+ roster.append(UI_DESIGNER)
364
+ roster.append(REVIEWER)
365
+ if wants_code:
366
+ roster.extend([DEBUGGER, SECURITY])
367
+ roster.append(TESTER)
368
+
369
+ roster.append(FINALIZER)
370
+ # De-duplicate preserving order.
371
+ seen: set = set()
372
+ return [r for r in roster if not (r in seen or seen.add(r))]
373
+
374
+ def _run_single_role(self, role: str, ctx: CollaborationContext,
375
+ user_text: str, plan: List[str],
376
+ coder_result: str, steps: List,
377
+ permission_callback: Optional[Callable] = None,
378
+ on_agent: Optional[Callable] = None,
379
+ record_finding: bool = True) -> str:
380
+ """Run one non-CODER specialist role and return its output string.
381
+
382
+ v0.7.9.0 fixes:
383
+ * The output is now actually recorded into
384
+ `ctx.agent_findings[role]` — previously the planner's plan was
385
+ written nowhere, so run_collaborative always read an empty
386
+ finding and silently fell back to a generic one-step plan.
387
+ * RESEARCHER performs a REAL web search (aicore.web_search) and
388
+ hands the genuine snippets to the model, instead of pretending.
389
+ """
390
+ role_label = role.replace("_", " ").title()
391
+ if on_agent:
392
+ on_agent(role, role_label, "start", "")
393
+
394
+ ctx_block = ctx.to_prompt_block(exclude_roles=[role])
395
+ prompt_parts: List[str] = []
396
+
397
+ # Build role-specific prompt
398
+ if role == PLANNER:
399
+ prompt_parts.append(f"User request:\n{user_text}")
400
+ elif role == RESEARCHER:
401
+ prompt_parts.append(f"User request:\n{user_text}")
402
+ prompt_parts.append(f"Context:\n{ctx_block}")
403
+ elif role == ARCHITECT:
404
+ prompt_parts.append(f"ORIGINAL REQUEST:\n{user_text}")
405
+ prompt_parts.append(f"CURRENT SOLUTION:\n{coder_result or '(not yet produced)'}")
406
+ prompt_parts.append(f"CONTEXT:\n{ctx_block}")
407
+ elif role == UI_DESIGNER:
408
+ prompt_parts.append(f"ORIGINAL REQUEST:\n{user_text}")
409
+ prompt_parts.append(f"CURRENT SOLUTION:\n{coder_result}")
410
+ prompt_parts.append(f"CONTEXT:\n{ctx_block}")
411
+ elif role == REVIEWER:
412
+ prompt_parts.append(f"ORIGINAL REQUEST:\n{user_text}")
413
+ prompt_parts.append(f"PLAN:\n" + "\n".join(f"- {p}" for p in plan))
414
+ prompt_parts.append(f"CODER OUTPUT:\n{coder_result}")
415
+ prompt_parts.append(f"CONTEXT:\n{ctx_block}")
416
+ elif role == TESTER:
417
+ prompt_parts.append(f"ORIGINAL REQUEST:\n{user_text}")
418
+ prompt_parts.append(f"PLAN:\n" + "\n".join(f"- {p}" for p in plan))
419
+ prompt_parts.append(f"CODER OUTPUT:\n{coder_result}")
420
+ prompt_parts.append(f"CONTEXT:\n{ctx_block}")
421
+ elif role == DEBUGGER:
422
+ prompt_parts.append(f"CODE TO REVIEW:\n{coder_result}")
423
+ prompt_parts.append(f"CONTEXT:\n{ctx_block}")
424
+ elif role == SECURITY:
425
+ prompt_parts.append(f"CODE TO SCAN:\n{coder_result}")
426
+ prompt_parts.append(f"CONTEXT:\n{ctx_block}")
427
+ elif role == FINALIZER:
428
+ prompt_parts.append(f"ORIGINAL REQUEST:\n{user_text}")
429
+ prompt_parts.append(f"PLAN:\n" + "\n".join(f"- {p}" for p in plan))
430
+ prompt_parts.append(f"CODER OUTPUT:\n{coder_result}")
431
+ prompt_parts.append(f"ALL FINDINGS:\n{ctx_block}")
432
+ else:
433
+ prompt_parts.append(f"User request:\n{user_text}")
434
+ prompt_parts.append(f"Context:\n{ctx_block}")
435
+
436
+ prompt = "\n\n".join(prompt_parts)
437
+ system = ROLE_PROMPTS.get(role, ROLE_PROMPTS[PLANNER])
438
+
439
+ # RESEARCHER: real web retrieval first; the model then synthesizes
440
+ # over the ACTUAL results (never fabricated sources).
441
+ if role == RESEARCHER:
442
+ try:
443
+ results = aicore.web_search(user_text, max_results=5) or []
444
+ except Exception:
445
+ results = []
446
+ if results:
447
+ evidence = "\n\n".join(
448
+ f"[{i + 1}] {r['title']}\n{r['url']}\n{r['snippet']}"
449
+ for i, r in enumerate(results))
450
+ prompt += ("\n\nLIVE SEARCH RESULTS (use ONLY these as external "
451
+ "facts, cite by bracket number):\n" + evidence)
452
+ system += " Cite the bracketed result numbers you used."
453
+ else:
454
+ prompt += ("\n\nLIVE SEARCH RESULTS: none retrieved (search "
455
+ "unreachable). Say so plainly — do not invent sources.")
456
+ system += " No web results were retrievable; say so plainly."
457
+
458
+ output = self._query(prompt, system)
459
+
460
+ # Parse structured JSON responses where expected
461
+ if role in (PLANNER, REVIEWER, TESTER, UI_DESIGNER, ARCHITECT,
462
+ DEBUGGER, SECURITY):
463
+ data = _extract_json(output)
464
+ if role == PLANNER:
465
+ plan_data = data.get("plan")
466
+ if isinstance(plan_data, list) and plan_data:
467
+ output = json.dumps({"plan": [str(s) for s in plan_data[:8]]})
468
+ # For review-type roles, store as-is; orchestrator decides what to do
469
+
470
+ # THE FIX: record the finding so later roles (and the orchestrator)
471
+ # can actually see this agent's work via the shared context.
472
+ if record_finding:
473
+ try:
474
+ ctx.agent_findings[role] = output
475
+ except Exception:
476
+ pass
477
+
478
+ if on_agent:
479
+ summary = output[:120].replace("\n", " ") if output else "(empty)"
480
+ on_agent(role, role_label, "done", summary)
481
+
482
+ return output
483
+
484
+ # ------------------------------------------------------------------
485
+ # File-locking integration for the CODER role
486
+ # ------------------------------------------------------------------
487
+
488
+ def _acquire_file_locks(self, coder_result: str, coder_id: str = "coder") -> None:
489
+ """Scan coder output for write_file / create_folder tool calls and
490
+ acquire file locks for the paths they touch."""
491
+ for tr in coder_result if isinstance(coder_result, list) else []:
492
+ if isinstance(tr, dict):
493
+ tool_name = tr.get("tool", "")
494
+ args = tr.get("args", {})
495
+ path = args.get("path", "")
496
+ if tool_name in ("write_file", "rename_file", "delete_file") and path:
497
+ self.file_locks.lock(path, coder_id)
498
+ elif tool_name == "create_folder" and path:
499
+ self.file_locks.lock(path, coder_id)
500
+
501
+ def _release_file_locks(self, coder_id: str = "coder") -> None:
502
+ """Release all locks held by the coder agent."""
503
+ self.file_locks.release_all(coder_id)
504
+
505
+ # ------------------------------------------------------------------
506
+ # Main collaborative pipeline
507
+ # ------------------------------------------------------------------
508
+
509
+ def run_collaborative(
510
+ self,
511
+ user_text: str,
512
+ roles: Optional[List[str]] = None,
513
+ on_agent: Optional[Callable] = None,
514
+ permission_callback: Optional[Callable] = None,
515
+ max_steps: int = 12,
516
+ ) -> Tuple[str, list, dict, list]:
517
+ """Run the full multi-agent collaborative pipeline.
518
+
519
+ Parameters
520
+ ----------
521
+ user_text : str
522
+ The user's original request.
523
+ roles : list[str] | None
524
+ Explicit roster of role keys to activate. When *None* the
525
+ roster is chosen automatically based on task content.
526
+ on_agent : callable | None
527
+ ``on_agent(role_key, role_label, status, summary)`` — called
528
+ on every agent start/done event so the UI can show a live
529
+ roster. *status* is ``"start"`` or ``"done"``.
530
+ permission_callback : callable | None
531
+ Forwarded to the CODER's ``agent.run_agent`` call for
532
+ write-permission gates.
533
+ max_steps : int
534
+ Maximum tool-loop steps for the CODER agent.
535
+
536
+ Returns
537
+ -------
538
+ (final_text, steps, meta, agents)
539
+ final_text : str — the merged, polished answer.
540
+ steps : list — tool-call steps from the CODER agent.
541
+ meta : dict — pipeline metadata (roster, plan, etc.).
542
+ agents : list[dict] — ``{key, label, status, summary}`` for
543
+ every agent event (chronological).
544
+ """
545
+
546
+ agents: List[Dict[str, str]] = []
547
+ steps: List[Any] = []
548
+ ctx = CollaborationContext(task=user_text)
549
+
550
+ def _report(key: str, status: str, summary: str = "") -> None:
551
+ label = key.replace("_", " ").title()
552
+ agents.append({"key": key, "label": label, "status": status, "summary": summary})
553
+ if on_agent:
554
+ on_agent(key, label, status, summary)
555
+ try:
556
+ from .event_stream import stream as _es, MULTI_AGENT_PROGRESS
557
+ _es.emit(MULTI_AGENT_PROGRESS, source="multi_ai",
558
+ agent=key, status=status, summary=str(summary)[:160])
559
+ except Exception:
560
+ pass
561
+
562
+ try:
563
+ from .event_stream import stream as _es, MULTI_AGENT_STARTED
564
+ roster_preview = self._activate_roles(user_text, roles)
565
+ _es.emit(MULTI_AGENT_STARTED, source="multi_ai",
566
+ roster=roster_preview, task=user_text[:200])
567
+ except Exception:
568
+ pass
569
+
570
+ # ---- 1. Determine which roles to activate --------------------
571
+ roster = self._activate_roles(user_text, roles)
572
+ _report("orchestrator", "start",
573
+ f"roster: {', '.join(r.replace('_', ' ').title() for r in roster)}")
574
+
575
+ # ---- 2. PLANNER — break task into subtasks -------------------
576
+ plan: List[str] = []
577
+ if PLANNER in roster:
578
+ self._run_single_role(PLANNER, ctx, user_text, [], "", steps,
579
+ permission_callback, on_agent)
580
+ # Parse plan from the planner's REAL recorded finding
581
+ # (ctx.agent_findings is now actually written — see
582
+ # _run_single_role's v0.7.9.0 fix).
583
+ planner_findings = ctx.agent_findings.get(PLANNER, "")
584
+ data = _extract_json(planner_findings)
585
+ plan_raw = data.get("plan")
586
+ if isinstance(plan_raw, list) and plan_raw:
587
+ plan = [str(s) for s in plan_raw[:8]]
588
+ else:
589
+ plan = ["Work out and answer the question directly."]
590
+ ctx.decisions.append(f"Plan: {len(plan)} subtasks defined")
591
+ else:
592
+ plan = ["Work out and answer the question directly."]
593
+
594
+ # ---- 3. RESEARCHER — real web search for context ------------------
595
+ research_brief = ""
596
+ if RESEARCHER in roster:
597
+ research_brief = self._run_single_role(
598
+ RESEARCHER, ctx, user_text, plan, "", steps,
599
+ permission_callback, on_agent)
600
+ if research_brief:
601
+ ctx.decisions.append("Research brief obtained")
602
+
603
+ # ---- 4. CODER — run the real tool loop -----------------------
604
+ coder_result = ""
605
+ coder_steps: list = []
606
+ if CODER in roster:
607
+ _report(CODER, "start", "running tool loop")
608
+ plan_note = "\n\nTEAM PLAN (from the Planner — follow it, adapt if needed):\n" + \
609
+ "\n".join(f"{i+1}. {p}" for i, p in enumerate(plan))
610
+ if research_brief:
611
+ plan_note += "\n\nRESEARCH BRIEF:\n" + research_brief
612
+
613
+ try:
614
+ coder_result, coder_steps, coder_meta = agent.run_agent(
615
+ user_text + plan_note,
616
+ max_steps=max_steps,
617
+ verbose=False,
618
+ mode="agent",
619
+ permission_callback=permission_callback,
620
+ )
621
+ except Exception as exc:
622
+ coder_result = f"(Coder agent failed: {exc})"
623
+ coder_meta = {}
624
+
625
+ steps.extend(coder_steps)
626
+ ctx.agent_findings[CODER] = coder_result
627
+ # Shared structured state (requirement #22): every later role
628
+ # sees the actual tool results instead of rediscovering them.
629
+ ctx.tool_results = [{"tool": t, "args": a, "result": str(r)[:400]}
630
+ for t, a, r, _c in coder_steps]
631
+ ctx.relevant_files = [
632
+ (a or {}).get("path") for t, a, _r, _c in coder_steps
633
+ if isinstance(a, dict) and a.get("path")]
634
+ ctx.decisions.append(f"Coder completed {len(coder_steps)} tool step(s)")
635
+
636
+ # Acquire file locks for any files the coder wrote
637
+ self._acquire_file_locks(coder_steps, "coder")
638
+ _report(CODER, "done", f"{len(coder_steps)} tool step(s)")
639
+
640
+ # ---- 5-8. INDEPENDENT REVIEW ROLES — run in PARALLEL ----------
641
+ # REVIEWER / SECURITY / DEBUGGER / TESTER / ARCHITECT /
642
+ # UI_DESIGNER all consume the SAME finished coder output and do
643
+ # not write files, so they are genuinely independent work
644
+ # (requirement #11/#24): executed concurrently on a thread pool,
645
+ # cutting wall-clock review time to roughly the slowest single
646
+ # role instead of the sum of all of them.
647
+ parallel_roles = [r for r in (SECURITY, DEBUGGER, REVIEWER, TESTER,
648
+ ARCHITECT, UI_DESIGNER) if r in roster]
649
+ if parallel_roles:
650
+ try:
651
+ from .event_stream import stream as _es
652
+ _es.emit(MULTI_AGENT_PROGRESS, source="multi_ai",
653
+ agent="review-team",
654
+ status="start",
655
+ summary=f"{len(parallel_roles)} reviewer(s) running concurrently")
656
+ except Exception:
657
+ pass
658
+ max_workers = min(4, len(parallel_roles))
659
+ with ThreadPoolExecutor(max_workers=max_workers,
660
+ thread_name_prefix="cat-review") as pool:
661
+ futures = {
662
+ r: pool.submit(
663
+ self._run_single_role, r, ctx, user_text, plan,
664
+ coder_result, steps, permission_callback, on_agent)
665
+ for r in parallel_roles
666
+ }
667
+ outputs = {}
668
+ for role_key, fut in futures.items():
669
+ try:
670
+ outputs[role_key] = fut.result()
671
+ except Exception as exc:
672
+ outputs[role_key] = ""
673
+ ctx.issues.append(f"{role_key}: reviewer crashed ({exc})")
674
+ for role_key, out in outputs.items():
675
+ data = _extract_json(out or "")
676
+ if isinstance(data, dict) and data.get("ok") is False and data.get("note"):
677
+ label = {"security": "Security", "debugger": "Debugger",
678
+ "reviewer": "Reviewer", "tester": "Tester",
679
+ "architect": "Architecture",
680
+ "ui_designer": "UI"}.get(role_key, role_key.title())
681
+ ctx.issues.append(f"{label}: {data['note']}")
682
+ ctx.agent_findings.setdefault(role_key, out or "")
683
+
684
+ # ---- 9. FINALIZER — merge everything into the final answer ---
685
+ final_text = coder_result
686
+ if FINALIZER in roster:
687
+ final_text = self._run_single_role(
688
+ FINALIZER, ctx, user_text, plan, coder_result, steps,
689
+ permission_callback, on_agent)
690
+ # Finalizer may have returned a polished version; fall back
691
+ # to coder_result if it returned empty/garbled output.
692
+ if not final_text or len(final_text.strip()) < 10:
693
+ final_text = coder_result
694
+
695
+ # Append a standard issues/flags block if any reviewer flagged issues
696
+ if ctx.issues:
697
+ flag_lines = "\n".join(f" {i}" for i in ctx.issues)
698
+ final_text = final_text.rstrip() + \
699
+ f"\n\n[Agent Review Flags]\n{flag_lines}\n"
700
+
701
+ # ---- 10. Release file locks ----------------------------------
702
+ self._release_file_locks("coder")
703
+
704
+ # ---- 11. Build metadata --------------------------------------
705
+ meta: Dict[str, Any] = {
706
+ "roster": [a["key"] for a in agents],
707
+ "plan": plan,
708
+ "research_brief": research_brief,
709
+ "issues": ctx.issues,
710
+ "decisions": ctx.decisions,
711
+ "tool_steps": len(steps),
712
+ }
713
+ _report("orchestrator", "done",
714
+ f"{len(agents)} agent events, {len(ctx.issues)} issue(s)")
715
+ try:
716
+ from .event_stream import stream as _es, MULTI_AGENT_FINISHED
717
+ _es.emit(MULTI_AGENT_FINISHED, source="multi_ai",
718
+ agents=len(agents), issues=len(ctx.issues),
719
+ tool_steps=len(steps))
720
+ except Exception:
721
+ pass
722
+
723
+ return final_text, steps, meta, agents