alysis-code 0.13.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 (458) hide show
  1. alysis_code/__init__.py +3 -0
  2. alysis_code/__main__.py +4 -0
  3. alysis_code/_build_info.py +14 -0
  4. alysis_code/account_login.py +468 -0
  5. alysis_code/agent/README.md +35 -0
  6. alysis_code/agent/__init__.py +11 -0
  7. alysis_code/agent/acceptance_contract.py +2217 -0
  8. alysis_code/agent/blast_radius.py +1403 -0
  9. alysis_code/agent/cache_keepalive.py +227 -0
  10. alysis_code/agent/completion_certificate.py +366 -0
  11. alysis_code/agent/completion_gate.py +306 -0
  12. alysis_code/agent/empty_response_stall.py +403 -0
  13. alysis_code/agent/errors.py +28 -0
  14. alysis_code/agent/llm_calls.py +475 -0
  15. alysis_code/agent/mutation_classification.py +227 -0
  16. alysis_code/agent/prompt_context.py +2508 -0
  17. alysis_code/agent/read_ledger.py +253 -0
  18. alysis_code/agent/regression_baseline.py +642 -0
  19. alysis_code/agent/reproduction_first.py +610 -0
  20. alysis_code/agent/sensitive_output.py +629 -0
  21. alysis_code/agent/session.py +3218 -0
  22. alysis_code/agent/steering.py +191 -0
  23. alysis_code/agent/subagent_execution.py +5177 -0
  24. alysis_code/agent/subagent_workspace.py +666 -0
  25. alysis_code/agent/tools_assembly.py +4728 -0
  26. alysis_code/agent/turn/__init__.py +101 -0
  27. alysis_code/agent/turn/core.py +8483 -0
  28. alysis_code/agent/turn/events.py +113 -0
  29. alysis_code/agent/turn/exploration.py +590 -0
  30. alysis_code/agent/turn/interventions.py +65 -0
  31. alysis_code/agent/turn/read_cache.py +420 -0
  32. alysis_code/agent/turn/snapshot.py +179 -0
  33. alysis_code/agent/turn_contract.py +661 -0
  34. alysis_code/agent/turn_path.py +129 -0
  35. alysis_code/agent/verification.py +2885 -0
  36. alysis_code/agent/verification_commands.py +512 -0
  37. alysis_code/agent/verification_evidence.py +738 -0
  38. alysis_code/agent_loop.py +693 -0
  39. alysis_code/agent_runtimes/__init__.py +51 -0
  40. alysis_code/agent_runtimes/base.py +114 -0
  41. alysis_code/agent_runtimes/builtins.py +129 -0
  42. alysis_code/agent_runtimes/codex_cli.py +664 -0
  43. alysis_code/agent_runtimes/host.py +263 -0
  44. alysis_code/agent_runtimes/registry.py +64 -0
  45. alysis_code/agent_runtimes/service.py +150 -0
  46. alysis_code/agentbox_client.py +416 -0
  47. alysis_code/agentbox_integration.py +310 -0
  48. alysis_code/alysis_cloud.py +152 -0
  49. alysis_code/approval_scope.py +276 -0
  50. alysis_code/assets/README.md +33 -0
  51. alysis_code/assets/__init__.py +126 -0
  52. alysis_code/assets/asset_read_core.py +281 -0
  53. alysis_code/assets/budget_allocator.py +456 -0
  54. alysis_code/assets/comprehender.py +759 -0
  55. alysis_code/assets/index.py +654 -0
  56. alysis_code/assets/ingestion.py +275 -0
  57. alysis_code/assets/legacy_migration.py +413 -0
  58. alysis_code/assets/models.py +263 -0
  59. alysis_code/assets/ocr.py +239 -0
  60. alysis_code/assets/owl/ascii/f-000.txt +13 -0
  61. alysis_code/assets/owl/ascii/f-001.txt +13 -0
  62. alysis_code/assets/owl/ascii/f-002.txt +13 -0
  63. alysis_code/assets/owl/ascii/f-003.txt +13 -0
  64. alysis_code/assets/owl/ascii/f-004.txt +13 -0
  65. alysis_code/assets/owl/ascii/f-005.txt +13 -0
  66. alysis_code/assets/owl/ascii/f-006.txt +13 -0
  67. alysis_code/assets/owl/ascii/f-007.txt +13 -0
  68. alysis_code/assets/owl/ascii/f-008.txt +13 -0
  69. alysis_code/assets/owl/ascii/f-009.txt +13 -0
  70. alysis_code/assets/owl/ascii/f-010.txt +13 -0
  71. alysis_code/assets/owl/ascii/f-011.txt +13 -0
  72. alysis_code/assets/owl/ascii/f-012.txt +13 -0
  73. alysis_code/assets/owl/ascii/f-013.txt +13 -0
  74. alysis_code/assets/owl/ascii/f-014.txt +13 -0
  75. alysis_code/assets/owl/ascii/f-015.txt +13 -0
  76. alysis_code/assets/owl/ascii/f-016.txt +13 -0
  77. alysis_code/assets/owl/ascii/f-017.txt +13 -0
  78. alysis_code/assets/owl/ascii/f-018.txt +13 -0
  79. alysis_code/assets/owl/ascii/f-019.txt +13 -0
  80. alysis_code/assets/owl/ascii/f-020.txt +13 -0
  81. alysis_code/assets/owl/index.html +98 -0
  82. alysis_code/assets/owl/show-owl.sh +761 -0
  83. alysis_code/assets/paths.py +49 -0
  84. alysis_code/assets/plan_binding.py +326 -0
  85. alysis_code/assets/planner_context.py +466 -0
  86. alysis_code/assets/planner_tools.py +184 -0
  87. alysis_code/assets/prompts.py +101 -0
  88. alysis_code/assets/replanner_context.py +239 -0
  89. alysis_code/assets/surface.py +521 -0
  90. alysis_code/assets/untrusted_content.py +48 -0
  91. alysis_code/assets/usage_logger.py +94 -0
  92. alysis_code/assets/worker_mirror.py +428 -0
  93. alysis_code/assets/worker_section.py +303 -0
  94. alysis_code/assets/worker_tools.py +468 -0
  95. alysis_code/atomic_io.py +83 -0
  96. alysis_code/auth_diagnostics.py +272 -0
  97. alysis_code/background_runner.py +366 -0
  98. alysis_code/branding.py +270 -0
  99. alysis_code/budget_policy.py +390 -0
  100. alysis_code/build_identity.py +465 -0
  101. alysis_code/builtin_hooks/__init__.py +7 -0
  102. alysis_code/builtin_hooks/notify_done_windows.py +65 -0
  103. alysis_code/bwrap_etc.py +76 -0
  104. alysis_code/cancellation.py +41 -0
  105. alysis_code/capabilities.py +137 -0
  106. alysis_code/chatgpt_codex_static_provider.py +133 -0
  107. alysis_code/cli.py +51 -0
  108. alysis_code/cli_impl/__init__.py +1 -0
  109. alysis_code/cli_impl/assets_cli.py +537 -0
  110. alysis_code/cli_impl/assets_modal.py +412 -0
  111. alysis_code/cli_impl/chat/__init__.py +156 -0
  112. alysis_code/cli_impl/chat/commands.py +2616 -0
  113. alysis_code/cli_impl/chat/loop.py +4508 -0
  114. alysis_code/cli_impl/chat/mid_turn_policy.py +125 -0
  115. alysis_code/cli_impl/chat/rendering.py +444 -0
  116. alysis_code/cli_impl/chat/state.py +124 -0
  117. alysis_code/cli_impl/chat_resume.py +830 -0
  118. alysis_code/cli_impl/chat_slash_completer.py +258 -0
  119. alysis_code/cli_impl/commands/__init__.py +11 -0
  120. alysis_code/cli_impl/commands/_shared.py +89 -0
  121. alysis_code/cli_impl/commands/auth.py +623 -0
  122. alysis_code/cli_impl/commands/chat_resume_helpers.py +1531 -0
  123. alysis_code/cli_impl/commands/chat_state.py +158 -0
  124. alysis_code/cli_impl/commands/chat_status.py +1248 -0
  125. alysis_code/cli_impl/commands/chat_terminal.py +942 -0
  126. alysis_code/cli_impl/commands/chat_tui_panels.py +1018 -0
  127. alysis_code/cli_impl/commands/cli_common.py +1223 -0
  128. alysis_code/cli_impl/commands/cli_surface.py +77 -0
  129. alysis_code/cli_impl/commands/config.py +131 -0
  130. alysis_code/cli_impl/commands/conventions.py +85 -0
  131. alysis_code/cli_impl/commands/execution_helpers.py +350 -0
  132. alysis_code/cli_impl/commands/extensions.py +401 -0
  133. alysis_code/cli_impl/commands/forge.py +1282 -0
  134. alysis_code/cli_impl/commands/forge_asset_view.py +121 -0
  135. alysis_code/cli_impl/commands/forge_helpers.py +1215 -0
  136. alysis_code/cli_impl/commands/hooks.py +737 -0
  137. alysis_code/cli_impl/commands/ide_bridge.py +31 -0
  138. alysis_code/cli_impl/commands/mcp.py +700 -0
  139. alysis_code/cli_impl/commands/profile.py +453 -0
  140. alysis_code/cli_impl/commands/prompt_helpers.py +307 -0
  141. alysis_code/cli_impl/commands/report.py +88 -0
  142. alysis_code/cli_impl/commands/root.py +1118 -0
  143. alysis_code/cli_impl/commands/sandbox.py +184 -0
  144. alysis_code/cli_impl/commands/server.py +54 -0
  145. alysis_code/cli_impl/commands/sessions.py +252 -0
  146. alysis_code/cli_impl/commands/skills.py +404 -0
  147. alysis_code/cli_impl/commands/startup.py +946 -0
  148. alysis_code/cli_impl/commands/tools.py +335 -0
  149. alysis_code/cli_impl/commands/update.py +364 -0
  150. alysis_code/cli_impl/commands/welcome.py +972 -0
  151. alysis_code/cli_impl/config_menu.py +3882 -0
  152. alysis_code/cli_impl/forge.py +4509 -0
  153. alysis_code/cli_impl/forge_recovery.py +485 -0
  154. alysis_code/cli_impl/setup_wizard.py +2409 -0
  155. alysis_code/cli_impl/tui/__init__.py +58 -0
  156. alysis_code/cli_impl/tui/app.py +4551 -0
  157. alysis_code/cli_impl/tui/config.py +32 -0
  158. alysis_code/cli_impl/tui/config_flow.py +2754 -0
  159. alysis_code/cli_impl/tui/config_overlay.py +566 -0
  160. alysis_code/cli_impl/tui/content.py +78 -0
  161. alysis_code/cli_impl/tui/footer.py +218 -0
  162. alysis_code/cli_impl/tui/forge_status.py +136 -0
  163. alysis_code/cli_impl/tui/markdown.py +244 -0
  164. alysis_code/cli_impl/tui/owl.py +109 -0
  165. alysis_code/cli_impl/tui/plan_meta.py +477 -0
  166. alysis_code/cli_impl/tui/setup_app.py +519 -0
  167. alysis_code/cli_impl/tui/setup_flow.py +1622 -0
  168. alysis_code/cli_impl/tui/state.py +101 -0
  169. alysis_code/cli_impl/tui/subagent_identity.py +66 -0
  170. alysis_code/cli_impl/tui/subagent_panel.py +186 -0
  171. alysis_code/cli_impl/tui/surface.py +796 -0
  172. alysis_code/cli_impl/tui/transcript.py +514 -0
  173. alysis_code/cli_impl/tui/update_prompt.py +79 -0
  174. alysis_code/cli_impl/tui/workspace_guard.py +384 -0
  175. alysis_code/clipboard.py +172 -0
  176. alysis_code/code_review.py +1211 -0
  177. alysis_code/compaction/__init__.py +28 -0
  178. alysis_code/compaction/conversation_compactor.py +2932 -0
  179. alysis_code/compaction/importance.py +177 -0
  180. alysis_code/compaction/settings.py +297 -0
  181. alysis_code/compaction/tool_output_offload.py +447 -0
  182. alysis_code/config.py +3509 -0
  183. alysis_code/conflict_auto_resolver.py +895 -0
  184. alysis_code/context/__init__.py +1 -0
  185. alysis_code/context/tool_schema_budgeter.py +220 -0
  186. alysis_code/crash_diagnostics.py +282 -0
  187. alysis_code/custom_tools/README.md +34 -0
  188. alysis_code/custom_tools/__init__.py +43 -0
  189. alysis_code/custom_tools/discovery.py +903 -0
  190. alysis_code/custom_tools/runtime.py +1516 -0
  191. alysis_code/custom_tools/session.py +227 -0
  192. alysis_code/custom_tools/trust.py +232 -0
  193. alysis_code/diff_paths.py +113 -0
  194. alysis_code/direction_change.py +293 -0
  195. alysis_code/dispatch_timing.py +306 -0
  196. alysis_code/durable_service_manager.py +1236 -0
  197. alysis_code/edit_discipline.py +659 -0
  198. alysis_code/error_text.py +73 -0
  199. alysis_code/execution_budget.py +411 -0
  200. alysis_code/execution_context.py +915 -0
  201. alysis_code/execution_deadline.py +1065 -0
  202. alysis_code/execution_shared.py +1904 -0
  203. alysis_code/extensions/README.md +30 -0
  204. alysis_code/extensions/__init__.py +93 -0
  205. alysis_code/extensions/activation.py +138 -0
  206. alysis_code/extensions/install.py +1436 -0
  207. alysis_code/extensions/manifest.py +487 -0
  208. alysis_code/extensions/models.py +74 -0
  209. alysis_code/extensions/paths.py +56 -0
  210. alysis_code/extensions/registry.json +4 -0
  211. alysis_code/extensions/registry.py +52 -0
  212. alysis_code/extensions/state.py +83 -0
  213. alysis_code/extensions/workspace_trust.py +101 -0
  214. alysis_code/failed_task_evidence.py +369 -0
  215. alysis_code/failure_category.py +315 -0
  216. alysis_code/feedback_report.py +1647 -0
  217. alysis_code/file_classification.py +485 -0
  218. alysis_code/forge.py +2064 -0
  219. alysis_code/forge_completion.py +362 -0
  220. alysis_code/forge_events.py +475 -0
  221. alysis_code/frontmatter_utils.py +95 -0
  222. alysis_code/git_evidence.py +1181 -0
  223. alysis_code/git_ops.py +560 -0
  224. alysis_code/git_safe.py +62 -0
  225. alysis_code/git_worktrees.py +190 -0
  226. alysis_code/hooks/README.md +33 -0
  227. alysis_code/hooks/__init__.py +67 -0
  228. alysis_code/hooks/audit.py +171 -0
  229. alysis_code/hooks/config.py +225 -0
  230. alysis_code/hooks/dispatcher.py +1110 -0
  231. alysis_code/hooks/models.py +447 -0
  232. alysis_code/hooks/trust.py +202 -0
  233. alysis_code/host_actions.py +543 -0
  234. alysis_code/host_browser.py +103 -0
  235. alysis_code/ide/__init__.py +5 -0
  236. alysis_code/ide/activity_events.py +399 -0
  237. alysis_code/ide/approvals.py +337 -0
  238. alysis_code/ide/artifacts.py +153 -0
  239. alysis_code/ide/browser_egress_proxy.py +1076 -0
  240. alysis_code/ide/cdp_websocket_transport.py +1192 -0
  241. alysis_code/ide/change_ledger.py +1721 -0
  242. alysis_code/ide/context_blocks.py +979 -0
  243. alysis_code/ide/event_stream.py +531 -0
  244. alysis_code/ide/forge_protocol.py +3112 -0
  245. alysis_code/ide/forge_request_ledger.py +737 -0
  246. alysis_code/ide/health.py +965 -0
  247. alysis_code/ide/managed_browser.py +2251 -0
  248. alysis_code/ide/management_protocol.py +3414 -0
  249. alysis_code/ide/mcp_oauth_coordinator.py +744 -0
  250. alysis_code/ide/mcp_oauth_lifecycle.py +1504 -0
  251. alysis_code/ide/prompt_queue.py +1070 -0
  252. alysis_code/ide/protocol.py +191 -0
  253. alysis_code/ide/resumable_swarm.py +1543 -0
  254. alysis_code/ide/session_search.py +295 -0
  255. alysis_code/ide/stdio_bridge.py +9935 -0
  256. alysis_code/ide/structured_state.py +1579 -0
  257. alysis_code/ide/swarm_protocol.py +816 -0
  258. alysis_code/integration_gate.py +506 -0
  259. alysis_code/interactive_input_guard.py +39 -0
  260. alysis_code/interactive_plan_mode.py +26 -0
  261. alysis_code/internal_artifacts.py +179 -0
  262. alysis_code/knowledge_base.py +1409 -0
  263. alysis_code/knowledge_capture.py +1190 -0
  264. alysis_code/knowledge_librarian.py +605 -0
  265. alysis_code/language_policy.py +34 -0
  266. alysis_code/litellm_static_provider.py +535 -0
  267. alysis_code/llm/__init__.py +1 -0
  268. alysis_code/llm/anthropic_messages.py +2288 -0
  269. alysis_code/llm/base.py +71 -0
  270. alysis_code/llm/cache_capabilities.py +985 -0
  271. alysis_code/llm/cache_control_blocks.py +244 -0
  272. alysis_code/llm/cache_policy.py +388 -0
  273. alysis_code/llm/factory.py +373 -0
  274. alysis_code/llm/gemini_generate_content.py +2652 -0
  275. alysis_code/llm/gemini_interactions.py +739 -0
  276. alysis_code/llm/metadata.py +450 -0
  277. alysis_code/llm/openai_compat.py +2947 -0
  278. alysis_code/llm/openai_responses.py +2604 -0
  279. alysis_code/llm/protocols.py +609 -0
  280. alysis_code/llm/provider_limits.py +525 -0
  281. alysis_code/llm/request_plan.py +389 -0
  282. alysis_code/llm/request_shape.py +238 -0
  283. alysis_code/llm/streaming.py +108 -0
  284. alysis_code/llm/temperature_compat.py +78 -0
  285. alysis_code/llm/types.py +195 -0
  286. alysis_code/llm/usage_normalization.py +222 -0
  287. alysis_code/llm_error_display.py +315 -0
  288. alysis_code/logging_redaction.py +326 -0
  289. alysis_code/managed_host_deadline.py +196 -0
  290. alysis_code/mcp/README.md +33 -0
  291. alysis_code/mcp/__init__.py +24 -0
  292. alysis_code/mcp/client.py +1137 -0
  293. alysis_code/mcp/config.py +597 -0
  294. alysis_code/mcp/errors.py +113 -0
  295. alysis_code/mcp/forge_scope.py +154 -0
  296. alysis_code/mcp/jsonrpc.py +214 -0
  297. alysis_code/mcp/manager.py +2308 -0
  298. alysis_code/mcp/models.py +666 -0
  299. alysis_code/mcp/oauth.py +972 -0
  300. alysis_code/mcp/oauth_runtime.py +310 -0
  301. alysis_code/mcp/oauth_store.py +276 -0
  302. alysis_code/mcp/prompts.py +329 -0
  303. alysis_code/mcp/resources.py +295 -0
  304. alysis_code/mcp/roots.py +106 -0
  305. alysis_code/mcp/server_requests.py +75 -0
  306. alysis_code/mcp/token_store.py +859 -0
  307. alysis_code/mcp/transport_http.py +1338 -0
  308. alysis_code/mcp/transport_stdio.py +1267 -0
  309. alysis_code/mcp/untrusted_content.py +119 -0
  310. alysis_code/merge_conflict_reviewer.py +729 -0
  311. alysis_code/model_catalog/__init__.py +1 -0
  312. alysis_code/model_catalog/chatgpt_codex_subscription_snapshot.json +186 -0
  313. alysis_code/model_catalog/litellm_model_prices_snapshot.json +44715 -0
  314. alysis_code/model_catalog/litellm_model_prices_snapshot.meta.json +17 -0
  315. alysis_code/model_metadata_policy.py +223 -0
  316. alysis_code/model_metadata_utils.py +103 -0
  317. alysis_code/model_registry.py +1420 -0
  318. alysis_code/model_router.py +147 -0
  319. alysis_code/permission_policy.py +1016 -0
  320. alysis_code/personas.py +451 -0
  321. alysis_code/pipeline_facts.py +233 -0
  322. alysis_code/plan_assistant.py +4763 -0
  323. alysis_code/plan_mode.py +393 -0
  324. alysis_code/plan_reconciliation.py +1228 -0
  325. alysis_code/plan_repair.py +652 -0
  326. alysis_code/plan_validation.py +1099 -0
  327. alysis_code/planning_constraints.py +904 -0
  328. alysis_code/policy.py +95 -0
  329. alysis_code/preview_server.py +457 -0
  330. alysis_code/process_reaping.py +566 -0
  331. alysis_code/profile_presets.py +1834 -0
  332. alysis_code/profiles.py +666 -0
  333. alysis_code/provider_auth/__init__.py +29 -0
  334. alysis_code/provider_auth/base.py +99 -0
  335. alysis_code/provider_auth/openai_codex.py +951 -0
  336. alysis_code/provider_auth/registry.py +76 -0
  337. alysis_code/provider_auth/store.py +125 -0
  338. alysis_code/provider_diagnostics.py +1209 -0
  339. alysis_code/provider_model_catalog.py +685 -0
  340. alysis_code/provider_telemetry.py +1699 -0
  341. alysis_code/provider_url.py +75 -0
  342. alysis_code/reasoning_contracts.py +911 -0
  343. alysis_code/remote_sync.py +350 -0
  344. alysis_code/replanning.py +1195 -0
  345. alysis_code/repo_scan.py +1152 -0
  346. alysis_code/request_estimation.py +296 -0
  347. alysis_code/review_gate.py +617 -0
  348. alysis_code/run_lock.py +1141 -0
  349. alysis_code/run_outcome.py +58 -0
  350. alysis_code/run_provenance.py +774 -0
  351. alysis_code/run_state.py +445 -0
  352. alysis_code/runtime_artifacts.py +116 -0
  353. alysis_code/runtime_context_features.py +78 -0
  354. alysis_code/runtime_kind.py +52 -0
  355. alysis_code/safety/__init__.py +11 -0
  356. alysis_code/safety/mcp_sanitize.py +29 -0
  357. alysis_code/safety/safe_http.py +297 -0
  358. alysis_code/safety/subagent_report.py +184 -0
  359. alysis_code/sandbox_doctor.py +682 -0
  360. alysis_code/sandbox_runner.py +1025 -0
  361. alysis_code/sandbox_settings.py +423 -0
  362. alysis_code/serialized_paths.py +355 -0
  363. alysis_code/server/__init__.py +3 -0
  364. alysis_code/server/app.py +367 -0
  365. alysis_code/server/auth.py +34 -0
  366. alysis_code/server/job_config.py +30 -0
  367. alysis_code/server/settings.py +215 -0
  368. alysis_code/server/store.py +193 -0
  369. alysis_code/server/worker_runner.py +657 -0
  370. alysis_code/service_persistence.py +355 -0
  371. alysis_code/session_artifacts.py +108 -0
  372. alysis_code/session_metrics.py +331 -0
  373. alysis_code/session_store.py +624 -0
  374. alysis_code/skills/README.md +34 -0
  375. alysis_code/skills/__init__.py +104 -0
  376. alysis_code/skills/conventions.py +84 -0
  377. alysis_code/skills/discovery.py +176 -0
  378. alysis_code/skills/eval_models.py +232 -0
  379. alysis_code/skills/eval_runner.py +372 -0
  380. alysis_code/skills/evals.py +1344 -0
  381. alysis_code/skills/install.py +293 -0
  382. alysis_code/skills/loader.py +118 -0
  383. alysis_code/skills/matching.py +103 -0
  384. alysis_code/skills/models.py +71 -0
  385. alysis_code/skills/paths.py +56 -0
  386. alysis_code/skills/prompting.py +500 -0
  387. alysis_code/skills/scaffold.py +142 -0
  388. alysis_code/skills/state.py +441 -0
  389. alysis_code/skills/transactions.py +125 -0
  390. alysis_code/skills/validation.py +304 -0
  391. alysis_code/step_budget.py +238 -0
  392. alysis_code/subagent_labels.py +49 -0
  393. alysis_code/subagents.py +1072 -0
  394. alysis_code/surface/__init__.py +80 -0
  395. alysis_code/surface/base.py +305 -0
  396. alysis_code/surface/console.py +387 -0
  397. alysis_code/surface/events.py +372 -0
  398. alysis_code/surface/hidden_surface.py +529 -0
  399. alysis_code/surface/noop_surface.py +219 -0
  400. alysis_code/surface/rich_surface.py +1555 -0
  401. alysis_code/surface/styles.py +67 -0
  402. alysis_code/surface/theme.py +455 -0
  403. alysis_code/surface/types.py +100 -0
  404. alysis_code/swarm_backend.py +926 -0
  405. alysis_code/swarm_orchestrator.py +4020 -0
  406. alysis_code/swarm_scheduler.py +441 -0
  407. alysis_code/swarm_trace.py +429 -0
  408. alysis_code/swarm_worker.py +2119 -0
  409. alysis_code/swarm_write_guard.py +348 -0
  410. alysis_code/task_dependencies.py +170 -0
  411. alysis_code/task_readiness.py +992 -0
  412. alysis_code/task_scope.py +2148 -0
  413. alysis_code/terminal_manager.py +762 -0
  414. alysis_code/terminal_ownership.py +460 -0
  415. alysis_code/text_normalization.py +30 -0
  416. alysis_code/token_budget.py +97 -0
  417. alysis_code/tools/README.md +34 -0
  418. alysis_code/tools/__init__.py +1 -0
  419. alysis_code/tools/artifacts.py +127 -0
  420. alysis_code/tools/availability.py +188 -0
  421. alysis_code/tools/fs.py +1456 -0
  422. alysis_code/tools/git.py +461 -0
  423. alysis_code/tools/history.py +229 -0
  424. alysis_code/tools/http_timeout.py +78 -0
  425. alysis_code/tools/image_generation.py +552 -0
  426. alysis_code/tools/registry.py +2936 -0
  427. alysis_code/tools/repo_map.py +476 -0
  428. alysis_code/tools/search.py +563 -0
  429. alysis_code/tools/shell.py +135 -0
  430. alysis_code/tools/symbols.py +1350 -0
  431. alysis_code/tools/test_discovery.py +643 -0
  432. alysis_code/tools/web.py +482 -0
  433. alysis_code/tools/web_search.py +2012 -0
  434. alysis_code/tools/web_search_dashscope.py +557 -0
  435. alysis_code/tools/web_search_ddgs.py +221 -0
  436. alysis_code/tools/web_search_provider_adapters.py +1429 -0
  437. alysis_code/tools/web_search_tavily.py +194 -0
  438. alysis_code/updates.py +933 -0
  439. alysis_code/usage_tracker.py +1990 -0
  440. alysis_code/verification_command_analysis.py +1004 -0
  441. alysis_code/verification_contract.py +574 -0
  442. alysis_code/verification_failure_summary.py +273 -0
  443. alysis_code/verification_repair.py +385 -0
  444. alysis_code/verify_gate.py +3129 -0
  445. alysis_code/web_research.py +1872 -0
  446. alysis_code/web_search_adapters.py +66 -0
  447. alysis_code/web_search_policy.py +27 -0
  448. alysis_code/workspace_binding.py +389 -0
  449. alysis_code/workspace_binding_ui.py +408 -0
  450. alysis_code/workspace_context.py +273 -0
  451. alysis_code/workspace_isolation.py +138 -0
  452. alysis_code/workspace_provisioning.py +455 -0
  453. alysis_code-0.13.0.dist-info/METADATA +507 -0
  454. alysis_code-0.13.0.dist-info/RECORD +458 -0
  455. alysis_code-0.13.0.dist-info/WHEEL +4 -0
  456. alysis_code-0.13.0.dist-info/entry_points.txt +3 -0
  457. alysis_code-0.13.0.dist-info/licenses/LICENSE +176 -0
  458. alysis_code-0.13.0.dist-info/licenses/NOTICE +4 -0
@@ -0,0 +1,816 @@
1
+ """IDE bridge support for Forge swarm jobs: events, result payloads, reconcile.
2
+
3
+ Kept separate from ``forge_protocol.py`` on purpose: that module is pinned to
4
+ never reference swarm or subprocess machinery. Everything here is either a
5
+ pure payload builder, a trace-to-protocol event adapter, or an explicitly
6
+ invoked reconcile action over run artifacts.
7
+
8
+ Reconcile is READ-ONLY by default: it enumerates plan state, run artifacts,
9
+ and preserved task worktrees without touching the backend ensure/reuse
10
+ lifecycle. Mutation happens only through the explicit ``harvest`` (write
11
+ per-task diff artifacts under the run) and ``discard`` (drop preserved
12
+ worktrees) actions, both idempotent.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import os
19
+ import shutil
20
+ import stat
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+ from ..forge import RunPaths, find_task, now_iso, save_plan, set_task_status
25
+ from ..git_ops import (
26
+ GitOpsError,
27
+ _run_git_checked,
28
+ branch_exists,
29
+ current_branch,
30
+ untracked_files,
31
+ )
32
+ from ..git_worktrees import prune_worktrees, remove_task_worktree
33
+ from ..swarm_scheduler import canonical_task_status
34
+ from ..swarm_trace import SwarmTraceEvent
35
+ from .forge_protocol import ForgePlanRecord, forge_artifact_root_name
36
+ from .protocol import ProtocolError, redact_secrets
37
+
38
+ SWARM_EVENT_MESSAGE_MAX_CHARS = 300
39
+ SWARM_TASK_EVENT_STATES = (
40
+ "scheduled",
41
+ "started",
42
+ "progress",
43
+ "approval_pending", # reserved placeholder; emitted once approval routing lands
44
+ "interrupted",
45
+ "failed",
46
+ "merged",
47
+ )
48
+ RECONCILE_ACTIONS = ("report", "harvest", "discard")
49
+ _HARVEST_DIR_NAME = "harvest"
50
+
51
+
52
+ def working_tree_diff_against(root: Path, *, base: str) -> str:
53
+ """Return committed and tracked working-tree changes against ``base``.
54
+
55
+ This IDE-only compatibility helper uses the current hardened git runner;
56
+ untracked files remain a separate, explicit part of the review payload.
57
+ """
58
+ completed = _run_git_checked(
59
+ root,
60
+ ["diff", "--binary", "-M", base, "--"],
61
+ error_message="failed to generate working tree diff",
62
+ timeout_s=5.0,
63
+ )
64
+ return completed.stdout
65
+
66
+
67
+ def apply_patch_file(root: Path, *, patch_path: Path) -> None:
68
+ """Apply one reviewed patch after a fail-closed ``git apply --check``."""
69
+ _run_git_checked(
70
+ root,
71
+ ["apply", "--check", "--whitespace=nowarn", str(patch_path)],
72
+ error_message="patch does not apply cleanly to the working tree",
73
+ )
74
+ _run_git_checked(
75
+ root,
76
+ ["apply", "--whitespace=nowarn", str(patch_path)],
77
+ error_message="failed to apply patch to the working tree",
78
+ )
79
+
80
+
81
+ def _remove_readonly_for_rmtree(function: Any, path: str, _exc_info: Any) -> None:
82
+ """Retry removal after clearing Windows' read-only file attribute."""
83
+ os.chmod(path, stat.S_IWRITE)
84
+ function(path)
85
+
86
+
87
+ def _bounded_redacted(message: str) -> str:
88
+ clean = str(redact_secrets(str(message or "")))
89
+ if len(clean) > SWARM_EVENT_MESSAGE_MAX_CHARS:
90
+ return clean[: SWARM_EVENT_MESSAGE_MAX_CHARS - 3] + "..."
91
+ return clean
92
+
93
+
94
+ class BridgeSwarmTraceSink:
95
+ """Map engine swarm-trace events onto typed protocol surface events.
96
+
97
+ Task-scoped lifecycle lands on the already-registered
98
+ ``swarm_worker_state_changed`` envelope (worker_id = task id, state from
99
+ ``SWARM_TASK_EVENT_STATES``); warnings land on ``warning_emitted``. The
100
+ surface records every event into the session's bounded replay ring, so
101
+ reconnecting IDE clients replay swarm progress. Run-level started/
102
+ completed/cancelled events are emitted by the bridge job runner itself.
103
+
104
+ Thread-safety: ``emit`` is called from worker threads and the
105
+ orchestrator thread; the underlying surface serializes writes.
106
+ """
107
+
108
+ def __init__(self, surface: Any) -> None:
109
+ self._surface = surface
110
+
111
+ def close(self) -> None:
112
+ return None
113
+
114
+ def emit(self, event: SwarmTraceEvent) -> None:
115
+ task_id = str(event.task_id or "").strip()
116
+ phase = str(event.phase or "")
117
+ message = _bounded_redacted(event.message)
118
+ state: str | None = None
119
+ if task_id:
120
+ if phase == "worktree.lifecycle" and message.startswith("Preparing worktree"):
121
+ state = "scheduled"
122
+ elif phase == "worker.lifecycle" and message.startswith("Worker started"):
123
+ state = "started"
124
+ elif phase == "worker.lifecycle" and message.startswith("Worker interrupted"):
125
+ state = "interrupted"
126
+ elif phase == "worker.lifecycle" and message.startswith("Worker finished"):
127
+ state = "progress"
128
+ elif phase == "worker.error":
129
+ state = "failed"
130
+ elif phase == "merge.lifecycle" and (
131
+ "merged" in message or "applied" in message or "already-satisfied" in message
132
+ ):
133
+ state = "merged"
134
+ elif phase in {"scope.violation", "worker.warning"}:
135
+ self._surface.emit_warning(f"[{task_id}] {message}")
136
+ return
137
+ elif phase == "worker.warning":
138
+ self._surface.emit_warning(message)
139
+ return
140
+ if state is not None:
141
+ self._surface.emit_swarm_worker_state_changed(task_id, state, role="forge_swarm")
142
+
143
+
144
+ def swarm_run_summary_payload(paths: RunPaths) -> dict[str, Any]:
145
+ summary_path = paths.execution_dir / "swarm_summary.json"
146
+ if not summary_path.exists():
147
+ return {}
148
+ try:
149
+ payload = json.loads(summary_path.read_text(encoding="utf-8"))
150
+ except (OSError, ValueError):
151
+ return {}
152
+ return payload if isinstance(payload, dict) else {}
153
+
154
+
155
+ def forge_swarm_result_payload(
156
+ record: ForgePlanRecord,
157
+ plan: dict[str, Any],
158
+ *,
159
+ exit_code: int,
160
+ ) -> dict[str, Any]:
161
+ summary = swarm_run_summary_payload(record.paths)
162
+ counts: dict[str, int] = {}
163
+ for task in plan.get("tasks") or []:
164
+ if not isinstance(task, dict):
165
+ continue
166
+ status = canonical_task_status(str(task.get("status") or ""))
167
+ counts[status] = counts.get(status, 0) + 1
168
+ summary_md = record.paths.execution_dir / "swarm_summary.md"
169
+ return {
170
+ "session_id": record.session_id,
171
+ "plan_id": record.plan_id,
172
+ "exit_code": exit_code,
173
+ "run_status": str(summary.get("status") or ("clean" if exit_code == 0 else "unknown")),
174
+ "clean": bool(summary.get("clean", exit_code == 0)),
175
+ "interrupted": bool(summary.get("interrupted", False)),
176
+ "interrupted_task_ids": [str(item) for item in (summary.get("interrupted_task_ids") or [])],
177
+ "verification_status": str(summary.get("verification_status") or "unknown"),
178
+ "reason_codes": [str(item) for item in (summary.get("reason_codes") or [])],
179
+ "task_status_counts": counts,
180
+ "summary_artifact_present": summary_md.exists(),
181
+ "redacted": True,
182
+ "secret_values_included": False,
183
+ }
184
+
185
+
186
+ def _task_worktree_path(paths: RunPaths, task_id: str) -> Path:
187
+ return paths.run_dir / "worktrees" / task_id / "repo"
188
+
189
+
190
+ def _merge_result_payload(paths: RunPaths, task_id: str) -> dict[str, Any] | None:
191
+ merge_path = paths.execution_dir / "merge_results" / f"{task_id}.json"
192
+ if not merge_path.exists():
193
+ return None
194
+ try:
195
+ payload = json.loads(merge_path.read_text(encoding="utf-8"))
196
+ except (OSError, ValueError):
197
+ return None
198
+ return payload if isinstance(payload, dict) else None
199
+
200
+
201
+ def _harvest_dir(paths: RunPaths) -> Path:
202
+ return paths.execution_dir / _HARVEST_DIR_NAME
203
+
204
+
205
+ def _classify_task(
206
+ paths: RunPaths,
207
+ task: dict[str, Any],
208
+ ) -> dict[str, Any]:
209
+ task_id = str(task.get("id") or "").strip()
210
+ status = canonical_task_status(str(task.get("status") or ""))
211
+ worktree = _task_worktree_path(paths, task_id)
212
+ worktree_present = worktree.is_dir()
213
+ merge_payload = _merge_result_payload(paths, task_id)
214
+ merged = status == "done" or bool(merge_payload and merge_payload.get("success"))
215
+ patch_artifact = paths.execution_patches_dir / f"{task_id}.diff"
216
+ harvest_artifact = _harvest_dir(paths) / f"{task_id}.diff"
217
+ if merged:
218
+ state = "merged"
219
+ elif status == "interrupted":
220
+ state = "interrupted"
221
+ elif status in {"planned", "todo"} and not worktree_present:
222
+ state = "unstarted"
223
+ else:
224
+ state = status
225
+ diff_available = worktree_present or patch_artifact.exists() or harvest_artifact.exists()
226
+ return {
227
+ "task_id": task_id,
228
+ "status": status,
229
+ "state": state,
230
+ "worktree_present": worktree_present,
231
+ "worktree_path": (
232
+ os.fspath(worktree.relative_to(paths.root))
233
+ if worktree_present and worktree.is_relative_to(paths.root)
234
+ else (os.fspath(worktree) if worktree_present else None)
235
+ ),
236
+ "diff_available": diff_available,
237
+ "patch_artifact_present": patch_artifact.exists(),
238
+ "harvest_artifact_present": harvest_artifact.exists(),
239
+ "merge_commit_hash": (
240
+ str(merge_payload.get("merge_commit_hash"))
241
+ if merge_payload and merge_payload.get("merge_commit_hash")
242
+ else None
243
+ ),
244
+ "branch": str(task.get("branch") or "") or None,
245
+ }
246
+
247
+
248
+ def _resolve_reconcile_base_branch(paths: RunPaths, base_branch: str | None) -> str:
249
+ if base_branch:
250
+ return base_branch
251
+ try:
252
+ return current_branch(paths.root)
253
+ except GitOpsError:
254
+ return "main"
255
+
256
+
257
+ def _resolve_worktree_base_branch(
258
+ paths: RunPaths,
259
+ worktree: Path,
260
+ base_branch: str | None,
261
+ ) -> str:
262
+ """Pick the diff base that actually exists in the task worktree.
263
+
264
+ Snapshot-backend worktrees carry a synthetic ``snapshot-base`` branch;
265
+ git-worktree backends share branches with the control repo.
266
+ """
267
+ candidates: list[str] = []
268
+ if base_branch:
269
+ candidates.append(base_branch)
270
+ try:
271
+ candidates.append(current_branch(paths.root))
272
+ except GitOpsError:
273
+ pass
274
+ candidates.extend(["snapshot-base", "main"])
275
+ for candidate in candidates:
276
+ clean = str(candidate or "").strip()
277
+ if not clean:
278
+ continue
279
+ try:
280
+ if branch_exists(worktree, clean):
281
+ return clean
282
+ except GitOpsError:
283
+ continue
284
+ return base_branch or "main"
285
+
286
+
287
+ def _harvest_task(
288
+ paths: RunPaths,
289
+ entry: dict[str, Any],
290
+ *,
291
+ base_branch: str,
292
+ ) -> dict[str, Any]:
293
+ task_id = str(entry["task_id"])
294
+ worktree = _task_worktree_path(paths, task_id)
295
+ harvest_dir = _harvest_dir(paths)
296
+ harvest_dir.mkdir(parents=True, exist_ok=True)
297
+ diff_path = harvest_dir / f"{task_id}.diff"
298
+ meta_path = harvest_dir / f"{task_id}.json"
299
+ if not worktree.is_dir():
300
+ return {
301
+ "task_id": task_id,
302
+ "harvested": False,
303
+ "reason": "worktree_absent",
304
+ "diff_artifact": (
305
+ os.fspath(diff_path.relative_to(paths.root)) if diff_path.exists() else None
306
+ ),
307
+ }
308
+ resolved_base = _resolve_worktree_base_branch(paths, worktree, base_branch)
309
+ try:
310
+ diff_text = working_tree_diff_against(worktree, base=resolved_base)
311
+ untracked = untracked_files(worktree)
312
+ except GitOpsError as exc:
313
+ return {
314
+ "task_id": task_id,
315
+ "harvested": False,
316
+ "reason": f"git_error: {redact_secrets(str(exc))}",
317
+ "diff_artifact": None,
318
+ }
319
+ diff_path.write_text(
320
+ diff_text if diff_text else "(empty diff against base)\n", encoding="utf-8"
321
+ )
322
+ meta_path.write_text(
323
+ json.dumps(
324
+ {
325
+ "task_id": task_id,
326
+ "base_branch": resolved_base,
327
+ "diff_bytes": len(diff_text.encode("utf-8", errors="replace")),
328
+ "untracked_files": sorted(untracked),
329
+ "source_worktree": os.fspath(worktree),
330
+ },
331
+ ensure_ascii=True,
332
+ indent=2,
333
+ sort_keys=True,
334
+ )
335
+ + "\n",
336
+ encoding="utf-8",
337
+ )
338
+ return {
339
+ "task_id": task_id,
340
+ "harvested": True,
341
+ "diff_artifact": os.fspath(diff_path.relative_to(paths.root)),
342
+ "diff_bytes": len(diff_text.encode("utf-8", errors="replace")),
343
+ "untracked_file_count": len(untracked),
344
+ }
345
+
346
+
347
+ def _discard_task_worktree(paths: RunPaths, entry: dict[str, Any]) -> dict[str, Any]:
348
+ task_id = str(entry["task_id"])
349
+ worktree = _task_worktree_path(paths, task_id)
350
+ if not worktree.is_dir():
351
+ return {"task_id": task_id, "discarded": False, "reason": "already_absent"}
352
+ try:
353
+ remove_task_worktree(root=paths.root, worktree_repo_path=worktree, force=True)
354
+ except GitOpsError:
355
+ # Snapshot-backend workspaces and synthetic dirs are not registered
356
+ # git worktrees; fall back to a contained recursive delete strictly
357
+ # under the run's worktrees directory.
358
+ resolved = worktree.resolve()
359
+ worktrees_root = (paths.run_dir / "worktrees").resolve()
360
+ if not resolved.is_relative_to(worktrees_root):
361
+ return {
362
+ "task_id": task_id,
363
+ "discarded": False,
364
+ "reason": "refused_outside_run_worktrees_dir",
365
+ }
366
+ try:
367
+ shutil.rmtree(resolved, onerror=_remove_readonly_for_rmtree)
368
+ except OSError as exc:
369
+ return {
370
+ "task_id": task_id,
371
+ "discarded": False,
372
+ "reason": "worktree_delete_failed",
373
+ "error": str(redact_secrets(str(exc))),
374
+ }
375
+ if resolved.exists():
376
+ return {
377
+ "task_id": task_id,
378
+ "discarded": False,
379
+ "reason": "worktree_delete_incomplete",
380
+ }
381
+ try:
382
+ prune_worktrees(paths.root)
383
+ except GitOpsError:
384
+ pass
385
+ parent = worktree.parent
386
+ if parent.is_dir() and not any(parent.iterdir()):
387
+ parent.rmdir()
388
+ return {"task_id": task_id, "discarded": True}
389
+
390
+
391
+ def forge_swarm_reconcile_result(
392
+ record: ForgePlanRecord,
393
+ plan: dict[str, Any],
394
+ *,
395
+ action: str = "report",
396
+ task_ids: list[str] | None = None,
397
+ base_branch: str | None = None,
398
+ ) -> dict[str, Any]:
399
+ clean_action = str(action or "report").strip().lower()
400
+ if clean_action not in RECONCILE_ACTIONS:
401
+ raise ProtocolError(
402
+ "invalid_reconcile_action",
403
+ "forge.swarm.reconcile action must be report, harvest, or discard.",
404
+ )
405
+ paths = record.paths
406
+ wanted = {str(item).strip() for item in (task_ids or []) if str(item).strip()} or None
407
+ if wanted is not None:
408
+ for task_id in sorted(wanted):
409
+ if find_task(plan, task_id) is None:
410
+ raise ProtocolError("task_not_found", f"Unknown task id for reconcile: {task_id}")
411
+ entries = [
412
+ _classify_task(paths, task)
413
+ for task in (plan.get("tasks") or [])
414
+ if isinstance(task, dict)
415
+ and str(task.get("id") or "").strip()
416
+ and (wanted is None or str(task.get("id") or "").strip() in wanted)
417
+ ]
418
+ actions: list[dict[str, Any]] = []
419
+ if clean_action == "harvest":
420
+ resolved_base = _resolve_reconcile_base_branch(paths, base_branch)
421
+ actions = [
422
+ _harvest_task(paths, entry, base_branch=resolved_base)
423
+ for entry in entries
424
+ if entry["worktree_present"]
425
+ ]
426
+ # Refresh classification so harvest artifacts show in the report.
427
+ entries = [
428
+ _classify_task(paths, task)
429
+ for task in (plan.get("tasks") or [])
430
+ if isinstance(task, dict)
431
+ and str(task.get("id") or "").strip()
432
+ and (wanted is None or str(task.get("id") or "").strip() in wanted)
433
+ ]
434
+ elif clean_action == "discard":
435
+ actions = [
436
+ _discard_task_worktree(paths, entry) for entry in entries if entry["worktree_present"]
437
+ ]
438
+ entries = [
439
+ _classify_task(paths, task)
440
+ for task in (plan.get("tasks") or [])
441
+ if isinstance(task, dict)
442
+ and str(task.get("id") or "").strip()
443
+ and (wanted is None or str(task.get("id") or "").strip() in wanted)
444
+ ]
445
+ counts: dict[str, int] = {}
446
+ for entry in entries:
447
+ counts[str(entry["state"])] = counts.get(str(entry["state"]), 0) + 1
448
+ return {
449
+ "session_id": record.session_id,
450
+ "plan_id": record.plan_id,
451
+ "action": clean_action,
452
+ "tasks": entries,
453
+ "state_counts": counts,
454
+ "actions": actions,
455
+ "read_only": clean_action == "report",
456
+ "idempotent": True,
457
+ "redacted": True,
458
+ "secret_values_included": False,
459
+ }
460
+
461
+
462
+ _REVIEW_APPLY_DIR_NAME = "review_apply"
463
+ _REVIEW_DISCARD_DIR_NAME = "review_discard"
464
+ _EMPTY_DIFF_SENTINEL = "(empty diff against base)"
465
+ _REVIEW_RECOVERY_STATES = frozenset(
466
+ {"failed", "verify_failed", "candidate_rejected", "interrupted"}
467
+ )
468
+
469
+
470
+ def _review_marker_path(paths: RunPaths, dir_name: str, task_id: str) -> Path:
471
+ return paths.execution_dir / dir_name / f"{task_id}.json"
472
+
473
+
474
+ def _read_json_marker(path: Path) -> dict[str, Any] | None:
475
+ if not path.exists():
476
+ return None
477
+ try:
478
+ payload = json.loads(path.read_text(encoding="utf-8"))
479
+ except (OSError, ValueError):
480
+ return None
481
+ return payload if isinstance(payload, dict) else None
482
+
483
+
484
+ def _write_json_marker(path: Path, payload: dict[str, Any]) -> None:
485
+ path.parent.mkdir(parents=True, exist_ok=True)
486
+ path.write_text(
487
+ json.dumps(payload, ensure_ascii=True, indent=2, sort_keys=True) + "\n",
488
+ encoding="utf-8",
489
+ )
490
+
491
+
492
+ def _harvest_meta(paths: RunPaths, task_id: str) -> dict[str, Any] | None:
493
+ return _read_json_marker(_harvest_dir(paths) / f"{task_id}.json")
494
+
495
+
496
+ def _untracked_note(untracked: list[str]) -> str | None:
497
+ if not untracked:
498
+ return None
499
+ preview = ", ".join(untracked[:10])
500
+ if len(untracked) > 10:
501
+ preview += ", ..."
502
+ return f"untracked files created: {preview}"
503
+
504
+
505
+ def harvest_ready_review_diffs(
506
+ record: ForgePlanRecord,
507
+ plan: dict[str, Any],
508
+ *,
509
+ base_branch: str | None = None,
510
+ ) -> list[dict[str, Any]]:
511
+ """Produce per-task review diffs for every ready_for_merge task worktree."""
512
+ paths = record.paths
513
+ harvested: list[dict[str, Any]] = []
514
+ for task in plan.get("tasks") or []:
515
+ if not isinstance(task, dict):
516
+ continue
517
+ if canonical_task_status(str(task.get("status") or "")) != "ready_for_merge":
518
+ continue
519
+ entry = _classify_task(paths, task)
520
+ if not entry["worktree_present"]:
521
+ continue
522
+ harvested.append(_harvest_task(paths, entry, base_branch=base_branch or ""))
523
+ return harvested
524
+
525
+
526
+ def _recovery_offer(task: dict[str, Any], state: str) -> dict[str, Any] | None:
527
+ if state not in _REVIEW_RECOVERY_STATES:
528
+ return None
529
+ task_id = str(task.get("id") or "")
530
+ title = str(redact_secrets(str(task.get("title") or ""))).strip()
531
+ scope = [
532
+ str(item).strip()
533
+ for item in (task.get("write_scope") or task.get("estimated_files") or [])
534
+ if str(item).strip()
535
+ ]
536
+ offer: dict[str, Any] = {
537
+ "kind": "regenerate_subtree",
538
+ "start_method": "forge.plan.regenerate.start",
539
+ "suggested_params": {
540
+ "instruction": str(
541
+ redact_secrets(
542
+ f"Regenerate the plan subtree for task {task_id}"
543
+ + (f" ({title})" if title else "")
544
+ + f"; the previous attempt ended {state}."
545
+ )
546
+ ),
547
+ "focus": str(redact_secrets(", ".join(scope[:5]))) or None,
548
+ },
549
+ }
550
+ if state == "interrupted":
551
+ offer["harvest_method"] = "forge.swarm.reconcile"
552
+ return offer
553
+
554
+
555
+ def forge_swarm_review_result(
556
+ record: ForgePlanRecord,
557
+ plan: dict[str, Any],
558
+ ) -> dict[str, Any]:
559
+ """Read-only per-task review listing for the review merge strategy."""
560
+ paths = record.paths
561
+ root_name = forge_artifact_root_name(record.plan_id)
562
+ items: list[dict[str, Any]] = []
563
+ for task in plan.get("tasks") or []:
564
+ if not isinstance(task, dict):
565
+ continue
566
+ task_id = str(task.get("id") or "").strip()
567
+ if not task_id:
568
+ continue
569
+ entry = _classify_task(paths, task)
570
+ applied_marker = _read_json_marker(
571
+ _review_marker_path(paths, _REVIEW_APPLY_DIR_NAME, task_id)
572
+ )
573
+ discarded_marker = _read_json_marker(
574
+ _review_marker_path(paths, _REVIEW_DISCARD_DIR_NAME, task_id)
575
+ )
576
+ state = str(entry["state"])
577
+ if applied_marker is not None:
578
+ state = "applied"
579
+ elif discarded_marker is not None:
580
+ state = "discarded"
581
+ meta = _harvest_meta(paths, task_id)
582
+ untracked = [str(item) for item in ((meta or {}).get("untracked_files") or [])]
583
+ diff_path = _harvest_dir(paths) / f"{task_id}.diff"
584
+ items.append(
585
+ {
586
+ "task_id": task_id,
587
+ "title": str(redact_secrets(str(task.get("title") or ""))),
588
+ "status": str(entry["status"]),
589
+ "state": state,
590
+ "reviewable": state == "ready_for_merge",
591
+ "diff_available": bool(entry["diff_available"]),
592
+ "diff_artifact_id": (
593
+ f"{root_name}:execution/{_HARVEST_DIR_NAME}/{task_id}.diff"
594
+ if diff_path.exists()
595
+ else None
596
+ ),
597
+ "untracked_files": untracked,
598
+ "untracked_files_note": _untracked_note(untracked),
599
+ "worktree_present": bool(entry["worktree_present"]),
600
+ "applied": applied_marker is not None,
601
+ "discarded": discarded_marker is not None,
602
+ "recovery": _recovery_offer(task, state),
603
+ }
604
+ )
605
+ counts: dict[str, int] = {}
606
+ for item in items:
607
+ counts[str(item["state"])] = counts.get(str(item["state"]), 0) + 1
608
+ return {
609
+ "session_id": record.session_id,
610
+ "plan_id": record.plan_id,
611
+ "items": items,
612
+ "state_counts": counts,
613
+ "pending_review_task_ids": [item["task_id"] for item in items if item["reviewable"]],
614
+ "apply_method": "forge.swarm.apply",
615
+ "discard_method": "forge.swarm.discard",
616
+ "working_tree_untouched_until_apply": True,
617
+ "redacted": True,
618
+ "secret_values_included": False,
619
+ }
620
+
621
+
622
+ def _ordered_requested_tasks(
623
+ plan: dict[str, Any],
624
+ task_ids: list[str],
625
+ ) -> list[dict[str, Any]]:
626
+ requested = {str(item).strip() for item in task_ids if str(item).strip()}
627
+ for task_id in sorted(requested):
628
+ if find_task(plan, task_id) is None:
629
+ raise ProtocolError("task_not_found", f"Unknown task id: {task_id}")
630
+ ordered = [
631
+ task
632
+ for task in (plan.get("tasks") or [])
633
+ if isinstance(task, dict) and str(task.get("id") or "").strip() in requested
634
+ ]
635
+ return ordered
636
+
637
+
638
+ def forge_swarm_apply_result(
639
+ record: ForgePlanRecord,
640
+ plan: dict[str, Any],
641
+ *,
642
+ task_ids: list[str],
643
+ base_branch: str | None = None,
644
+ ) -> dict[str, Any]:
645
+ """Apply per-task review diffs to the canonical working tree.
646
+
647
+ Applies are sequential in plan order, per-task atomic (``git apply
648
+ --check`` first), and never stage or commit anything. Untracked files
649
+ created by the worker are NOT applied (no contents in the diff) and are
650
+ surfaced prominently per task so nothing lands silently.
651
+ """
652
+ paths = record.paths
653
+ ordered = _ordered_requested_tasks(plan, task_ids)
654
+ # Pre-validate everything before mutating anything.
655
+ for task in ordered:
656
+ task_id = str(task.get("id") or "").strip()
657
+ if _read_json_marker(_review_marker_path(paths, _REVIEW_APPLY_DIR_NAME, task_id)):
658
+ continue
659
+ status = canonical_task_status(str(task.get("status") or ""))
660
+ if status != "ready_for_merge":
661
+ raise ProtocolError(
662
+ "task_not_reviewable",
663
+ f"Task {task_id} is not awaiting review (status {status}).",
664
+ )
665
+ applied: list[dict[str, Any]] = []
666
+ for task in ordered:
667
+ task_id = str(task.get("id") or "").strip()
668
+ marker_path = _review_marker_path(paths, _REVIEW_APPLY_DIR_NAME, task_id)
669
+ existing_marker = _read_json_marker(marker_path)
670
+ if existing_marker is not None:
671
+ untracked = [
672
+ str(item) for item in (existing_marker.get("untracked_files_not_applied") or [])
673
+ ]
674
+ applied.append(
675
+ {
676
+ "task_id": task_id,
677
+ "applied": True,
678
+ "already_applied": True,
679
+ "untracked_files_not_applied": untracked,
680
+ "untracked_files_note": _untracked_note(untracked),
681
+ }
682
+ )
683
+ continue
684
+ entry = _classify_task(paths, task)
685
+ diff_path = _harvest_dir(paths) / f"{task_id}.diff"
686
+ if not diff_path.exists():
687
+ if entry["worktree_present"]:
688
+ _harvest_task(paths, entry, base_branch=base_branch or "")
689
+ if not diff_path.exists():
690
+ raise ProtocolError(
691
+ "diff_unavailable",
692
+ f"No review diff is available for task {task_id}; "
693
+ "the task worktree is gone and nothing was harvested.",
694
+ )
695
+ diff_text = diff_path.read_text(encoding="utf-8")
696
+ meta = _harvest_meta(paths, task_id) or {}
697
+ untracked = [str(item) for item in (meta.get("untracked_files") or [])]
698
+ nothing_to_apply = diff_text.strip() in {"", _EMPTY_DIFF_SENTINEL}
699
+ if not nothing_to_apply:
700
+ try:
701
+ apply_patch_file(paths.root, patch_path=diff_path)
702
+ except GitOpsError as exc:
703
+ raise ProtocolError(
704
+ "apply_failed",
705
+ str(
706
+ redact_secrets(
707
+ f"Applying task {task_id} diff to the working tree failed: {exc}. "
708
+ f"Already applied this call: "
709
+ f"{', '.join(str(item['task_id']) for item in applied) or '(none)'}."
710
+ )
711
+ ),
712
+ ) from exc
713
+ _write_json_marker(
714
+ marker_path,
715
+ {
716
+ "task_id": task_id,
717
+ "applied_at": now_iso(),
718
+ "nothing_to_apply": nothing_to_apply,
719
+ "diff_artifact": os.fspath(diff_path.relative_to(paths.root)),
720
+ "untracked_files_not_applied": untracked,
721
+ },
722
+ )
723
+ set_task_status(plan, task_id, "done")
724
+ save_plan(paths, plan)
725
+ applied.append(
726
+ {
727
+ "task_id": task_id,
728
+ "applied": True,
729
+ "already_applied": False,
730
+ "nothing_to_apply": nothing_to_apply,
731
+ "diff_artifact": os.fspath(diff_path.relative_to(paths.root)),
732
+ "untracked_files_not_applied": untracked,
733
+ "untracked_files_note": _untracked_note(untracked),
734
+ }
735
+ )
736
+ return {
737
+ "session_id": record.session_id,
738
+ "plan_id": record.plan_id,
739
+ "applied": applied,
740
+ "working_tree_committed": False,
741
+ "redacted": True,
742
+ "secret_values_included": False,
743
+ }
744
+
745
+
746
+ def forge_swarm_discard_result(
747
+ record: ForgePlanRecord,
748
+ plan: dict[str, Any],
749
+ *,
750
+ task_ids: list[str],
751
+ ) -> dict[str, Any]:
752
+ """Discard reviewed task work: drop the preserved worktree, keep artifacts."""
753
+ paths = record.paths
754
+ ordered = _ordered_requested_tasks(plan, task_ids)
755
+ discardable_statuses = {
756
+ "ready_for_merge",
757
+ "interrupted",
758
+ "failed",
759
+ "verify_failed",
760
+ "candidate_rejected",
761
+ }
762
+ for task in ordered:
763
+ task_id = str(task.get("id") or "").strip()
764
+ if _read_json_marker(_review_marker_path(paths, _REVIEW_DISCARD_DIR_NAME, task_id)):
765
+ continue
766
+ if _read_json_marker(_review_marker_path(paths, _REVIEW_APPLY_DIR_NAME, task_id)):
767
+ raise ProtocolError(
768
+ "task_already_applied",
769
+ f"Task {task_id} was already applied to the working tree; "
770
+ "revert it through source control instead of discarding.",
771
+ )
772
+ status = canonical_task_status(str(task.get("status") or ""))
773
+ if status not in discardable_statuses:
774
+ raise ProtocolError(
775
+ "task_not_reviewable",
776
+ f"Task {task_id} cannot be discarded (status {status}).",
777
+ )
778
+ discarded: list[dict[str, Any]] = []
779
+ for task in ordered:
780
+ task_id = str(task.get("id") or "").strip()
781
+ marker_path = _review_marker_path(paths, _REVIEW_DISCARD_DIR_NAME, task_id)
782
+ if _read_json_marker(marker_path) is not None:
783
+ discarded.append({"task_id": task_id, "discarded": True, "already_discarded": True})
784
+ continue
785
+ entry = _classify_task(paths, task)
786
+ worktree_result: dict[str, Any] = {"discarded": False, "reason": "already_absent"}
787
+ if entry["worktree_present"]:
788
+ worktree_result = _discard_task_worktree(paths, entry)
789
+ _write_json_marker(
790
+ marker_path,
791
+ {
792
+ "task_id": task_id,
793
+ "discarded_at": now_iso(),
794
+ "worktree_removed": bool(worktree_result.get("discarded")),
795
+ },
796
+ )
797
+ set_task_status(plan, task_id, "candidate_rejected")
798
+ task_obj = find_task(plan, task_id)
799
+ if task_obj is not None:
800
+ task_obj["last_error"] = "discarded in IDE swarm review"
801
+ save_plan(paths, plan)
802
+ discarded.append(
803
+ {
804
+ "task_id": task_id,
805
+ "discarded": True,
806
+ "already_discarded": False,
807
+ "worktree_removed": bool(worktree_result.get("discarded")),
808
+ }
809
+ )
810
+ return {
811
+ "session_id": record.session_id,
812
+ "plan_id": record.plan_id,
813
+ "discarded": discarded,
814
+ "redacted": True,
815
+ "secret_values_included": False,
816
+ }