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,4509 @@
1
+ # ruff: noqa: F821
2
+ # Dependencies are injected at runtime from alysis_code.cli to preserve monkeypatch surfaces.
3
+ from __future__ import annotations
4
+
5
+ import inspect
6
+ import json
7
+ import os
8
+ import socket
9
+ from dataclasses import dataclass, field
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ import typer
14
+ from click import get_current_context
15
+ from click.core import ParameterSource
16
+
17
+ from ..assets import AssetError
18
+ from ..assets.budget_allocator import (
19
+ TaskAssetAllocation,
20
+ allocate_task_assets,
21
+ write_task_asset_allocation,
22
+ )
23
+ from ..assets.surface import build_asset_surface
24
+ from ..assets.usage_logger import AssetUsageLogger
25
+ from ..assets.worker_mirror import TaskAssetMirror, mirror_task_assets
26
+ from ..assets.worker_section import render_relevant_assets_section
27
+ from ..assets.worker_tools import build_worker_asset_mcp_manager, compose_worker_asset_mcp_manager
28
+ from ..branding import env_get
29
+ from ..error_text import sanitize_error_summary, sanitize_optional_error_summary
30
+ from ..failure_category import FailureCategory
31
+ from ..forge_events import (
32
+ EVENT_PLAN_INVALID,
33
+ EVENT_PLAN_SAVED,
34
+ EVENT_REVIEW_RESULT,
35
+ EVENT_SCOPE_AMENDED,
36
+ EVENT_TASK_COMPLETED,
37
+ EVENT_TASK_FAILED,
38
+ EVENT_TASK_STARTED,
39
+ EVENT_VERIFICATION_RESULT,
40
+ EVENT_VERIFICATION_UNAVAILABLE,
41
+ EXIT_ERROR,
42
+ EXIT_NOT_ACCEPTED,
43
+ EXIT_OK,
44
+ ForgeEventEmitter,
45
+ )
46
+ from ..model_registry import ModelRegistry
47
+ from ..plan_repair import (
48
+ PLAN_STATUS_DRAFT,
49
+ apply_plan_status,
50
+ plan_repair_event_payload,
51
+ )
52
+ from ..plan_validation import PlannerFailedError, raise_for_execution_ready_plan
53
+ from ..replanning import resolve_replanning_mode
54
+ from ..run_state import RUN_STATUS_FAILED, RUN_STATUS_RUNNING
55
+ from ..runtime_kind import RuntimeKind
56
+ from ..swarm_orchestrator import acquire_swarm_mutation_guard
57
+ from ..swarm_scheduler import (
58
+ SUCCESSFUL_TERMINAL_STATUSES,
59
+ TaskCandidate,
60
+ canonical_task_status,
61
+ select_task_candidates,
62
+ )
63
+ from ..task_readiness import is_clearly_non_mutating_task
64
+ from ..task_scope import (
65
+ apply_scope_amendments,
66
+ assess_scope_changes,
67
+ describe_scope_violations,
68
+ is_non_material_untracked_path,
69
+ normalize_scope_patterns,
70
+ relocate_known_scratch_artifacts,
71
+ )
72
+ from ..verification_repair import (
73
+ TASK_STATUS_COMPLETED_UNVERIFIED,
74
+ RepairAttemptExecution,
75
+ build_repair_instruction,
76
+ resolve_repair_attempt_budget,
77
+ run_verification_repair_loop,
78
+ verification_failure_excerpts,
79
+ )
80
+
81
+ _PROTECTED_GLOBAL_NAMES: set[str] = set()
82
+
83
+
84
+ def _events_or_null(events: Any) -> ForgeEventEmitter:
85
+ """Return the caller's emitter, or an inert one so call sites stay unconditional."""
86
+ if isinstance(events, ForgeEventEmitter):
87
+ return events
88
+ return ForgeEventEmitter(command="forge", enabled=False)
89
+
90
+
91
+ @dataclass
92
+ class TaskExecutionOutcome:
93
+ """What one run of :func:`execute_forge_task` produced.
94
+
95
+ ``forge exec`` turns a single outcome into its exit code and terminal event;
96
+ ``forge run`` collects one per task and decides whether the run continues.
97
+ """
98
+
99
+ task_id: str
100
+ title: str
101
+ status: str
102
+ success: bool
103
+ exit_code: int
104
+ summary: str
105
+ report_path: Path
106
+ patch_path: Path
107
+ merge_conflict: bool
108
+ conflict_review_path: Path | None
109
+ payload: dict[str, Any] = field(default_factory=dict)
110
+
111
+
112
+ class ForgeTaskExecutionError(Exception):
113
+ """The task could not be executed at all -- a command error, not a failed task.
114
+
115
+ A task that ran and was rejected comes back as an outcome with
116
+ ``success=False`` (exit 1). This is the other case: bad git state, missing
117
+ branch context, nothing to execute against. Callers report it as exit 2, and
118
+ a sequential run stops rather than moving to the next task.
119
+ """
120
+
121
+ def __init__(
122
+ self,
123
+ message: str,
124
+ *,
125
+ kind: str = "forge_error",
126
+ task_id: str | None = None,
127
+ ) -> None:
128
+ super().__init__(message)
129
+ self.kind = str(kind)
130
+ self.task_id = task_id
131
+
132
+
133
+ def _sequential_conflict_report_lines(
134
+ *,
135
+ root: Path,
136
+ task_id: str,
137
+ base_branch: str | None,
138
+ task_branch: str | None,
139
+ review_path: Path | None,
140
+ ) -> list[str]:
141
+ """Describe an unresolved merge conflict and how to finish it.
142
+
143
+ The sequential path stops at a conflict on purpose instead of starting the
144
+ worktree-based resolver agent, so this report has to carry everything the
145
+ next step needs: where the conflict is, how to land it by hand, and how to
146
+ resume the run.
147
+ """
148
+ base = base_branch or "the base branch"
149
+ branch = task_branch or "the task branch"
150
+ workspace = os.fspath(root)
151
+ lines = [
152
+ f"Merge conflict: {branch} did not merge into {base}. The sequential run "
153
+ "stopped here and started no resolver agent.",
154
+ ]
155
+ if review_path is not None:
156
+ lines.append(f"Conflict review: {os.fspath(review_path)}")
157
+ lines.append(
158
+ f"Finish it by hand: `git -C {workspace} checkout {base}` then "
159
+ f"`git -C {workspace} merge --no-ff {branch}`, resolve the conflicted "
160
+ "files, and commit."
161
+ )
162
+ lines.append(f"Then continue the remaining tasks with `alysis forge run --path {workspace}`.")
163
+ lines.append(
164
+ "To let an agent attempt the resolution instead, re-run this task with "
165
+ f"`alysis forge exec {task_id} --pr --auto-resolve-conflicts "
166
+ f"--path {workspace}`."
167
+ )
168
+ return lines
169
+
170
+
171
+ # --- sequential run scheduling -------------------------------------------------
172
+ #
173
+ # `forge run` reuses the swarm scheduler's readiness rules (dependencies, retry
174
+ # eligibility, attempt limits) but never its batching: the next task is simply the
175
+ # first ready one, recomputed after each task so a task that just finished unblocks
176
+ # its dependents.
177
+
178
+ _NON_EXECUTABLE_TASK_STATUSES = frozenset({"superseded", "invalidated"})
179
+
180
+
181
+ def _parse_only_task_ids(only: str | None) -> set[str] | None:
182
+ if not isinstance(only, str):
183
+ return None
184
+ ids = {part.strip() for part in only.split(",") if part.strip()}
185
+ return ids or None
186
+
187
+
188
+ def _plan_task_dicts(plan: dict[str, Any]) -> list[dict[str, Any]]:
189
+ tasks = plan.get("tasks")
190
+ if not isinstance(tasks, list):
191
+ return []
192
+ return [task for task in tasks if isinstance(task, dict)]
193
+
194
+
195
+ def _sequential_candidates(
196
+ plan: dict[str, Any],
197
+ *,
198
+ retry_failed: bool,
199
+ retry_changes_requested: bool,
200
+ max_attempts: int | None,
201
+ only_ids: set[str] | None,
202
+ exclude_ids: set[str] | None = None,
203
+ ) -> list[TaskCandidate]:
204
+ runnable, _ready_for_merge, _skipped = select_task_candidates(
205
+ tasks=_plan_task_dicts(plan),
206
+ retry_failed=retry_failed,
207
+ retry_changes_requested=retry_changes_requested,
208
+ max_attempts=max_attempts,
209
+ only_ids=only_ids,
210
+ )
211
+ excluded = exclude_ids or set()
212
+ return [candidate for candidate in runnable if candidate.task_id not in excluded]
213
+
214
+
215
+ def _next_sequential_task(
216
+ plan: dict[str, Any],
217
+ *,
218
+ retry_failed: bool,
219
+ retry_changes_requested: bool,
220
+ max_attempts: int | None,
221
+ only_ids: set[str] | None,
222
+ exclude_ids: set[str] | None = None,
223
+ ) -> TaskCandidate | None:
224
+ """First task whose dependencies are satisfied, or None when none are ready.
225
+
226
+ ``exclude_ids`` holds what this run already attempted. Without it a retried
227
+ task that fails again would be re-selected forever, because failing is exactly
228
+ the status ``--retry-failed`` asks the scheduler to accept.
229
+ """
230
+ candidates = _sequential_candidates(
231
+ plan,
232
+ retry_failed=retry_failed,
233
+ retry_changes_requested=retry_changes_requested,
234
+ max_attempts=max_attempts,
235
+ only_ids=only_ids,
236
+ exclude_ids=exclude_ids,
237
+ )
238
+ return candidates[0] if candidates else None
239
+
240
+
241
+ def _projected_sequential_order(
242
+ plan: dict[str, Any],
243
+ *,
244
+ retry_failed: bool,
245
+ retry_changes_requested: bool,
246
+ max_attempts: int | None,
247
+ only_ids: set[str] | None,
248
+ max_tasks: int | None,
249
+ ) -> list[str]:
250
+ """The order tasks would run in, assuming each one succeeds.
251
+
252
+ Dependencies only unblock as tasks finish, so the order has to be simulated
253
+ rather than read off a single scheduling pass. Shallow task copies are enough:
254
+ the simulation only rewrites ``status``.
255
+ """
256
+ tasks = [dict(task) for task in _plan_task_dicts(plan)]
257
+ simulated = {"tasks": tasks}
258
+ order: list[str] = []
259
+ seen: set[str] = set()
260
+ while max_tasks is None or len(order) < max_tasks:
261
+ candidate = _next_sequential_task(
262
+ simulated,
263
+ retry_failed=retry_failed,
264
+ retry_changes_requested=retry_changes_requested,
265
+ max_attempts=max_attempts,
266
+ only_ids=only_ids,
267
+ exclude_ids=seen,
268
+ )
269
+ if candidate is None:
270
+ break
271
+ order.append(candidate.task_id)
272
+ seen.add(candidate.task_id)
273
+ for task in tasks:
274
+ if str(task.get("id") or "").strip() == candidate.task_id:
275
+ task["status"] = "done"
276
+ break
277
+ return order
278
+
279
+
280
+ def _unfinished_task_ids(plan: dict[str, Any]) -> list[str]:
281
+ """Tasks that still represent outstanding work after the run."""
282
+ unfinished: list[str] = []
283
+ for task in _plan_task_dicts(plan):
284
+ task_id = str(task.get("id") or "").strip()
285
+ if not task_id:
286
+ continue
287
+ status = canonical_task_status(str(task.get("status") or ""))
288
+ if status in SUCCESSFUL_TERMINAL_STATUSES or status in _NON_EXECUTABLE_TASK_STATUSES:
289
+ continue
290
+ unfinished.append(task_id)
291
+ return unfinished
292
+
293
+
294
+ def _bump_task_attempt(paths: Any, plan: dict[str, Any], task: dict[str, Any]) -> int:
295
+ """Record that this run is about to try the task, so --max-attempts can bite."""
296
+ raw = task.get("attempts")
297
+ try:
298
+ attempts = int(raw) if raw is not None else 0
299
+ except (TypeError, ValueError):
300
+ attempts = 0
301
+ attempts = max(0, attempts) + 1
302
+ task["attempts"] = attempts
303
+ save_plan(paths, plan)
304
+ return attempts
305
+
306
+
307
+ class _SequentialRunLog:
308
+ """One appended line per lifecycle step of a sequential run.
309
+
310
+ Per-task agent logs stay where they are; this is the run-level narrative, in
311
+ order, in one file -- the thing a swarm run cannot have because its tasks
312
+ interleave.
313
+ """
314
+
315
+ def __init__(self) -> None:
316
+ self._path: Path | None = None
317
+
318
+ def bind(self, paths: Any) -> None:
319
+ ensure_execution_dirs(paths)
320
+ self._path = paths.execution_dir / "sequential_run.jsonl"
321
+
322
+ @property
323
+ def path(self) -> Path | None:
324
+ return self._path
325
+
326
+ def append(self, event: str, **fields: Any) -> None:
327
+ if self._path is None:
328
+ return
329
+ record: dict[str, Any] = {"ts": now_iso(), "event": str(event)}
330
+ record.update(fields)
331
+ try:
332
+ with self._path.open("a", encoding="utf-8") as handle:
333
+ handle.write(json.dumps(record, ensure_ascii=True, default=str) + "\n")
334
+ except OSError:
335
+ # A run log that cannot be written must not take the run down with it.
336
+ return
337
+
338
+
339
+ def _sequential_outcome_payload(outcome: TaskExecutionOutcome) -> dict[str, Any]:
340
+ return {
341
+ "task_id": outcome.task_id,
342
+ "title": outcome.title,
343
+ "status": outcome.status,
344
+ "success": bool(outcome.success),
345
+ "summary": outcome.summary,
346
+ "report": os.fspath(outcome.report_path),
347
+ "patch": os.fspath(outcome.patch_path),
348
+ "merge_conflict": bool(outcome.merge_conflict),
349
+ "verify_summary": outcome.payload.get("verify_summary"),
350
+ "branch": outcome.payload.get("branch"),
351
+ "commit": outcome.payload.get("commit"),
352
+ "merge_result": outcome.payload.get("merge_result"),
353
+ }
354
+
355
+
356
+ def _write_sequential_summary(
357
+ *,
358
+ paths: Any,
359
+ run_id: str,
360
+ started_at: str,
361
+ outcomes: list[TaskExecutionOutcome],
362
+ stopped_reason: str,
363
+ exit_code: int,
364
+ pr: bool,
365
+ verify_mode: str,
366
+ scope_mode: str,
367
+ remaining: list[str] | None = None,
368
+ error: str | None = None,
369
+ ) -> dict[str, Any]:
370
+ """Persist the run outcome next to the swarm's, in the same execution dir."""
371
+ executed = [_sequential_outcome_payload(item) for item in outcomes]
372
+ payload: dict[str, Any] = {
373
+ "schema_version": 1,
374
+ "command": "forge.run",
375
+ "run_id": run_id,
376
+ "started_at": started_at,
377
+ "finished_at": now_iso(),
378
+ "engine": "sequential",
379
+ "worktrees_used": False,
380
+ "parallel": 1,
381
+ "git_flow": bool(pr),
382
+ "verify": verify_mode,
383
+ "scope": scope_mode,
384
+ "stopped_reason": stopped_reason,
385
+ "exit_code": int(exit_code),
386
+ "executed": executed,
387
+ "counts": {
388
+ "executed": len(executed),
389
+ "succeeded": sum(1 for item in executed if item["success"]),
390
+ "failed": sum(1 for item in executed if not item["success"]),
391
+ },
392
+ "remaining": list(remaining or []),
393
+ }
394
+ if error:
395
+ payload["error"] = error
396
+ summary_path = paths.execution_dir / "sequential_summary.json"
397
+ try:
398
+ ensure_execution_dirs(paths)
399
+ summary_path.write_text(
400
+ json.dumps(payload, indent=2, sort_keys=True, default=str) + "\n",
401
+ encoding="utf-8",
402
+ )
403
+ payload["summary_path"] = os.fspath(summary_path)
404
+ except OSError:
405
+ # Reporting the run must not be able to fail the run.
406
+ pass
407
+ return payload
408
+
409
+
410
+ def _print_sequential_order(console: Any, plan: dict[str, Any], order: list[str]) -> None:
411
+ table = _Table(title="Execution order")
412
+ table.add_column("#")
413
+ table.add_column("task")
414
+ table.add_column("title")
415
+ if not order:
416
+ console.print("[yellow]No ready tasks to execute.[/yellow]")
417
+ return
418
+ titles = {
419
+ str(task.get("id") or "").strip(): str(task.get("title") or "").strip()
420
+ for task in _plan_task_dicts(plan)
421
+ }
422
+ for index, task_id in enumerate(order, start=1):
423
+ table.add_row(str(index), task_id, titles.get(task_id, ""))
424
+ console.print(table)
425
+
426
+
427
+ def _print_sequential_summary(console: Any, payload: dict[str, Any]) -> None:
428
+ counts = payload.get("counts") or {}
429
+ table = _Table(title="forge run")
430
+ table.add_column("task")
431
+ table.add_column("status")
432
+ table.add_column("report")
433
+ for item in payload.get("executed") or []:
434
+ table.add_row(
435
+ str(item.get("task_id") or ""),
436
+ str(item.get("status") or ""),
437
+ str(item.get("report") or ""),
438
+ )
439
+ console.print(table)
440
+ console.print(
441
+ f"Executed: {counts.get('executed', 0)} | "
442
+ f"succeeded: {counts.get('succeeded', 0)} | "
443
+ f"failed: {counts.get('failed', 0)} | "
444
+ f"stopped: {payload.get('stopped_reason')}"
445
+ )
446
+ remaining = payload.get("remaining") or []
447
+ if remaining:
448
+ preview = ", ".join(str(item) for item in remaining[:10])
449
+ if len(remaining) > 10:
450
+ preview += ", ..."
451
+ console.print(f"[yellow]Unfinished tasks:[/yellow] {preview}")
452
+ # A stopped run leaves the checkout wherever the failing task left it, which is
453
+ # the first thing you need to know before touching the repository again.
454
+ if payload.get("stopped_reason") == "task_failed":
455
+ failed = next(
456
+ (item for item in reversed(payload.get("executed") or []) if not item.get("success")),
457
+ None,
458
+ )
459
+ if failed is not None:
460
+ branch = str(failed.get("branch") or "").strip()
461
+ location = f" on branch {branch}" if branch else ""
462
+ console.print(
463
+ f"[yellow]Run stopped at {failed.get('task_id')}[/yellow]"
464
+ f"{location}; nothing after it was started. "
465
+ "Fix it and re-run `forge run` to continue, or pass --keep-going to skip "
466
+ "past failures next time."
467
+ )
468
+ summary_path = payload.get("summary_path")
469
+ if summary_path:
470
+ console.print(f"Summary: {summary_path}")
471
+
472
+
473
+ def _exec_error_exit(
474
+ console: Any,
475
+ events: ForgeEventEmitter,
476
+ message: str,
477
+ *,
478
+ task_id: str | None = None,
479
+ kind: str = "forge_error",
480
+ ) -> typer.Exit:
481
+ """Report a `forge exec` command error on both surfaces and build the Exit."""
482
+ console.print(f"[red]Forge error:[/red] {message}")
483
+ events.error(
484
+ message=str(message),
485
+ kind=kind,
486
+ exit_code=EXIT_ERROR,
487
+ data={"task_id": task_id} if task_id else None,
488
+ )
489
+ return typer.Exit(code=EXIT_ERROR)
490
+
491
+
492
+ def _emit_verification_unavailable(
493
+ events: ForgeEventEmitter,
494
+ *,
495
+ task_id: str,
496
+ policy: str,
497
+ reason: str,
498
+ blocking: bool,
499
+ outcome: str | None = None,
500
+ ) -> None:
501
+ """Report that verification did not run, and whether that blocked the task.
502
+
503
+ ``outcome`` names the task status this produced, so a machine consumer can tell
504
+ "kept, but nothing checked it" (``completed_unverified``) apart from the merely
505
+ informational unavailable notices that leave the outcome untouched.
506
+ """
507
+ payload: dict[str, Any] = {
508
+ "scope": "task",
509
+ "task_id": task_id,
510
+ "policy": policy,
511
+ "reason": reason,
512
+ "blocking": bool(blocking),
513
+ }
514
+ if outcome:
515
+ payload["outcome"] = str(outcome)
516
+ events.emit(EVENT_VERIFICATION_UNAVAILABLE, payload)
517
+
518
+
519
+ def _attempt_verification_repair(
520
+ *,
521
+ attempt: int,
522
+ failing_result: Any,
523
+ max_attempts: int,
524
+ task_id: str,
525
+ base_instruction: str,
526
+ verify_artifact_path: Path,
527
+ run_repair_agent: Any,
528
+ run_verification: Any,
529
+ root: Path,
530
+ commit_message: str,
531
+ ) -> RepairAttemptExecution:
532
+ """Run one repair attempt: prompt with the failure, fix, commit, re-verify.
533
+
534
+ Nothing here decides the task's outcome -- it reports what happened and lets
535
+ :func:`run_verification_repair_loop` decide whether to keep going.
536
+ """
537
+ instruction = build_repair_instruction(
538
+ base_instruction=base_instruction,
539
+ task_id=task_id,
540
+ attempt=attempt,
541
+ max_attempts=max_attempts,
542
+ verify_summary=str(getattr(failing_result, "summary", "")),
543
+ excerpts=verification_failure_excerpts(failing_result),
544
+ artifact_path=os.fspath(verify_artifact_path),
545
+ )
546
+ try:
547
+ exit_code = int(run_repair_agent(instruction, attempt))
548
+ except Exception as e: # noqa: BLE001
549
+ return RepairAttemptExecution(
550
+ agent_exit_code=1,
551
+ verify_result=None,
552
+ error=f"repair agent raised: {sanitize_error_summary(str(e))}",
553
+ )
554
+ if exit_code != 0:
555
+ # A crashed repair agent leaves the tree in an unknown state; re-verifying it
556
+ # would attribute its mess to the task's own work.
557
+ return RepairAttemptExecution(
558
+ agent_exit_code=exit_code,
559
+ verify_result=None,
560
+ error=f"repair agent exited non-zero ({exit_code})",
561
+ )
562
+
563
+ committed = False
564
+ try:
565
+ commit_hash = _stage_and_commit_task_changes(root, message=commit_message)
566
+ committed = commit_hash is not None
567
+ except GitOpsError as e:
568
+ return RepairAttemptExecution(
569
+ agent_exit_code=exit_code,
570
+ verify_result=None,
571
+ error=f"could not commit the repair attempt: {e}",
572
+ )
573
+ if not committed:
574
+ # The agent changed nothing, so re-running the same commands would produce
575
+ # the same failure. Stop rather than burn the rest of the budget.
576
+ return RepairAttemptExecution(
577
+ agent_exit_code=exit_code,
578
+ verify_result=None,
579
+ error="the repair attempt made no repository changes",
580
+ )
581
+
582
+ changed_files: tuple[str, ...] = ()
583
+ try:
584
+ changed_files = tuple(changed_files_between(root, revspec="HEAD~1..HEAD"))
585
+ except GitOpsError:
586
+ changed_files = ()
587
+
588
+ return RepairAttemptExecution(
589
+ agent_exit_code=exit_code,
590
+ verify_result=run_verification(),
591
+ committed=True,
592
+ changed_files=changed_files,
593
+ )
594
+
595
+
596
+ def _stage_and_commit_task_changes(root: Path, *, message: str) -> str | None:
597
+ """Stage the workspace and commit it, skipping protected and non-material paths.
598
+
599
+ Returns the new commit hash, or ``None`` when there was nothing to commit. Used
600
+ by verification repair attempts, which run after the task's own commit and must
601
+ land their fix on the same branch before verification is re-run.
602
+ """
603
+ non_material_untracked_paths = list_untracked_packaging_metadata_paths(root)
604
+ stage_all(root)
605
+ unstage_staged_prefixes(root, [".alysis", ".alysis_images", "alysis-feedback"])
606
+ ensure_not_staged_prefixes(root, [".alysis", ".alysis_images", "alysis-feedback"])
607
+ if non_material_untracked_paths:
608
+ unstage_staged_paths(root, non_material_untracked_paths)
609
+ ensure_not_staged_paths(root, non_material_untracked_paths)
610
+ staged_now = staged_files(root)
611
+ if staged_now and has_grounded_rust_target_runtime_artifacts(root):
612
+ unstage_staged_runtime_artifacts(root, current_paths=staged_now)
613
+ staged_now = staged_files(root)
614
+ ensure_not_staged_runtime_artifacts(root, current_paths=staged_now)
615
+ if not staged_now:
616
+ return None
617
+ return commit_all(root, message=message)
618
+
619
+
620
+ def _plan_execution_readiness(plan: dict[str, Any]) -> str | None:
621
+ """Return why the plan cannot be executed yet, or None when it is ready."""
622
+ try:
623
+ return _forge_no_execution_ready_tasks_message(plan)
624
+ except Exception: # noqa: BLE001
625
+ # Readiness is reporting metadata; never let it break the command it describes.
626
+ return None
627
+
628
+
629
+ def _sync_cli_globals(cli_mod: Any) -> None:
630
+ module_globals = globals()
631
+ if not _PROTECTED_GLOBAL_NAMES:
632
+ for local_name, local_value in module_globals.items():
633
+ if callable(local_value):
634
+ _PROTECTED_GLOBAL_NAMES.add(local_name)
635
+ for name, value in cli_mod.__dict__.items():
636
+ if name.startswith("__") or name in _PROTECTED_GLOBAL_NAMES:
637
+ continue
638
+ module_globals[name] = value
639
+
640
+
641
+ def _path_binding_source(ctx: Any = None, path: Path | None = None) -> str:
642
+ current_ctx = ctx if ctx is not None else get_current_context(silent=True)
643
+ path_source = current_ctx.get_parameter_source("path") if current_ctx is not None else None
644
+ if path_source is not None and path_source is not ParameterSource.DEFAULT:
645
+ return "explicit_path"
646
+ if path is None and current_ctx is not None:
647
+ raw_path = current_ctx.params.get("path")
648
+ path = Path(raw_path) if raw_path is not None else None
649
+ if path is None:
650
+ frame = inspect.currentframe()
651
+ caller = frame.f_back if frame is not None else None
652
+ raw_path = caller.f_locals.get("path") if caller is not None else None
653
+ path = Path(raw_path) if raw_path is not None else None
654
+ if path is not None and Path(path) != Path("."):
655
+ return "explicit_path"
656
+
657
+ if path_source is None or path_source is ParameterSource.DEFAULT:
658
+ return "cwd"
659
+ return "explicit_path"
660
+
661
+
662
+ def _mark_run_status(
663
+ paths: Any,
664
+ status: str,
665
+ *,
666
+ reason: str = "",
667
+ plan: dict[str, Any] | None = None,
668
+ mode: str | None = None,
669
+ ) -> None:
670
+ """Record a run lifecycle transition on the current-run pointer.
671
+
672
+ Best-effort by design: a pointer that cannot be written is a stale status line
673
+ in a UI, never a reason to fail the execution it describes.
674
+ """
675
+ from ..forge import set_current_run_status
676
+ from ..run_state import RUN_STATUS_RUNNING, build_run_owner
677
+
678
+ owner = None
679
+ if status == RUN_STATUS_RUNNING:
680
+ owner = build_run_owner(
681
+ pid=os.getpid(),
682
+ hostname=socket.gethostname(),
683
+ mode=mode or "forge",
684
+ )
685
+ try:
686
+ set_current_run_status(paths, status, reason=reason, owner=owner, plan=plan)
687
+ except Exception: # noqa: BLE001 - see docstring
688
+ return
689
+
690
+
691
+ def _mark_run_interrupted_if_still_running(paths: Any, *, reason: str) -> None:
692
+ """Close out a run whose process is leaving without having recorded an outcome.
693
+
694
+ Every normal exit path stamps a terminal status first, so reaching here means
695
+ an exception, a ``KeyboardInterrupt``, or a code path that forgot -- all of
696
+ which leave resumable work behind, which is what ``interrupted`` means.
697
+ """
698
+ from ..forge import current_run_status
699
+ from ..run_state import RUN_STATUS_INTERRUPTED, RUN_STATUS_RUNNING
700
+
701
+ try:
702
+ if current_run_status(paths.root) != RUN_STATUS_RUNNING:
703
+ return
704
+ except Exception: # noqa: BLE001
705
+ return
706
+ _mark_run_status(paths, RUN_STATUS_INTERRUPTED, reason=reason)
707
+
708
+
709
+ def _terminal_run_status_for(*, plan: dict[str, Any], any_failure: bool) -> tuple[str, str]:
710
+ """Map an execution's result onto the run lifecycle enum.
711
+
712
+ Unfinished tasks left by ``--only`` or ``--max-tasks`` are a scope the caller
713
+ chose, not lost work, so they land back on ``approved`` (executable, idle)
714
+ rather than ``interrupted`` (something went wrong).
715
+ """
716
+ from ..run_state import RUN_STATUS_APPROVED, RUN_STATUS_COMPLETED
717
+
718
+ if any_failure:
719
+ return RUN_STATUS_FAILED, "execution finished with at least one task not accepted"
720
+ remaining = _unfinished_task_ids(plan)
721
+ if remaining:
722
+ return (
723
+ RUN_STATUS_APPROVED,
724
+ f"execution finished; {len(remaining)} task(s) still unexecuted",
725
+ )
726
+ return RUN_STATUS_COMPLETED, "every plan task reached a successful terminal status"
727
+
728
+
729
+ def _missing_swarm_run_error(*, binding: WorkspaceBinding) -> ForgeError:
730
+ requested = os.fspath(binding.requested_path)
731
+ return ForgeError(
732
+ "No current forge run was found for this workspace. "
733
+ f"Start with `alysis forge plan --path {requested}` or enter Forge "
734
+ "from chat after starting alysis inside a project folder."
735
+ )
736
+
737
+
738
+ def _print_forge_lock_wait_notice(console: Any, info: dict[str, Any]) -> None:
739
+ diagnostic = str(info.get("diagnostic") or "").strip()
740
+ console.print(
741
+ "[yellow]Forge execution queued:[/yellow] another execution is mutating this workspace; waiting for it to finish."
742
+ )
743
+ if diagnostic:
744
+ console.print(f"[dim]{diagnostic}[/dim]")
745
+
746
+
747
+ def _render_planner_reply(
748
+ *, console: Any, message: str, questions: list[str] | None = None
749
+ ) -> None:
750
+ console.print("[bold]Planner:[/bold]")
751
+ console.print(message)
752
+ if questions:
753
+ console.print("[dim]Planner questions[/dim]")
754
+ for question in questions:
755
+ console.print(f"- {question}")
756
+
757
+
758
+ def _merge_changed_files(*path_groups: list[str]) -> list[str]:
759
+ seen: set[str] = set()
760
+ merged: list[str] = []
761
+ for group in path_groups:
762
+ for raw in group:
763
+ value = str(raw).strip().replace("\\", "/")
764
+ while value.startswith("./"):
765
+ value = value[2:]
766
+ if not value or value in seen:
767
+ continue
768
+ seen.add(value)
769
+ merged.append(value)
770
+ return merged
771
+
772
+
773
+ def _runtime_snapshot_changed_files(
774
+ before_snapshot: dict[str, str],
775
+ after_snapshot: dict[str, str],
776
+ ) -> list[str]:
777
+ changed: list[str] = []
778
+ for path in sorted(set(before_snapshot) | set(after_snapshot)):
779
+ if before_snapshot.get(path) != after_snapshot.get(path):
780
+ changed.append(_normalize_changed_file_path(path))
781
+ return [path for path in changed if path]
782
+
783
+
784
+ def _normalize_changed_file_path(path: str) -> str:
785
+ value = str(path).strip().replace("\\", "/")
786
+ while value.startswith("./"):
787
+ value = value[2:]
788
+ return value.strip("/")
789
+
790
+
791
+ def _authorized_custom_tool_runtime_side_effects(
792
+ *,
793
+ sessions_dir: Path,
794
+ session_id: str,
795
+ ) -> set[str]:
796
+ session_log = sessions_dir / f"{session_id}.jsonl"
797
+ if not session_log.exists():
798
+ return set()
799
+ authorized: set[str] = set()
800
+ try:
801
+ lines = session_log.read_text(encoding="utf-8", errors="replace").splitlines()
802
+ except OSError:
803
+ return set()
804
+ for line in lines:
805
+ if not line.strip():
806
+ continue
807
+ try:
808
+ event = json.loads(line)
809
+ except json.JSONDecodeError:
810
+ continue
811
+ if not isinstance(event, dict) or event.get("type") != "tool_result":
812
+ continue
813
+ payload = event.get("payload")
814
+ result = payload.get("result") if isinstance(payload, dict) else None
815
+ if not isinstance(result, dict) or result.get("success") is not True:
816
+ continue
817
+ side_effects = result.get("side_effects")
818
+ if not isinstance(side_effects, dict):
819
+ continue
820
+ writes = side_effects.get("workspace_writes")
821
+ if not isinstance(writes, list):
822
+ continue
823
+ for item in writes:
824
+ if not isinstance(item, dict):
825
+ continue
826
+ if str(item.get("scope") or "") != "tool_dir":
827
+ continue
828
+ rel_path = _normalize_changed_file_path(str(item.get("path") or ""))
829
+ if rel_path == ".alysis/tools" or rel_path.startswith(".alysis/tools/"):
830
+ authorized.add(rel_path)
831
+ return authorized
832
+
833
+
834
+ def _path_matches_authorized_runtime_prefix(path: str, prefixes: set[str]) -> bool:
835
+ normalized = _normalize_changed_file_path(path)
836
+ return any(
837
+ normalized == prefix or normalized.startswith(prefix.rstrip("/") + "/")
838
+ for prefix in prefixes
839
+ )
840
+
841
+
842
+ def _drop_parent_directory_placeholders(paths: list[str]) -> list[str]:
843
+ concrete_paths = [path for path in paths if path and not path.endswith("/")]
844
+ filtered: list[str] = []
845
+ for path in paths:
846
+ if path.endswith("/") and any(other.startswith(path) for other in concrete_paths):
847
+ continue
848
+ filtered.append(path)
849
+ return filtered
850
+
851
+
852
+ def _task_declares_explicit_write_scope(task: dict[str, Any]) -> bool:
853
+ raw = task.get("write_scope")
854
+ if isinstance(raw, list):
855
+ return any(str(item or "").strip() for item in raw)
856
+ if isinstance(raw, str):
857
+ return bool(raw.strip())
858
+ return False
859
+
860
+
861
+ def _task_is_analysis_only(task: dict[str, Any]) -> bool:
862
+ raw_flag = task.get("analysis_only")
863
+ if isinstance(raw_flag, bool):
864
+ return raw_flag
865
+ return is_clearly_non_mutating_task(
866
+ title=str(task.get("title") or "").strip(),
867
+ description=str(task.get("description") or "").strip(),
868
+ acceptance_criteria=[
869
+ str(item or "")
870
+ for item in (task.get("acceptance_criteria") or [])
871
+ if str(item or "").strip()
872
+ ],
873
+ )
874
+
875
+
876
+ def _task_string_list(task: dict[str, Any], key: str) -> list[str]:
877
+ raw = task.get(key)
878
+ if raw is None:
879
+ return []
880
+ if isinstance(raw, str):
881
+ return [raw.strip()] if raw.strip() else []
882
+ if not isinstance(raw, (list, tuple)):
883
+ text = str(raw).strip()
884
+ return [text] if text else []
885
+ return [str(item).strip() for item in raw if str(item).strip()]
886
+
887
+
888
+ def _empty_forge_exec_task_asset_mirror(
889
+ workspace_path: Path,
890
+ *,
891
+ task_id: str = "",
892
+ ) -> TaskAssetMirror:
893
+ workspace = workspace_path.resolve()
894
+ return TaskAssetMirror(
895
+ workspace_path=workspace,
896
+ manifest_path=workspace / ".alysis" / "task_assets" / "manifest.json",
897
+ primary=[],
898
+ may_need=[],
899
+ pinned=[],
900
+ task_id=task_id,
901
+ )
902
+
903
+
904
+ def _combined_forge_exec_image_paths(
905
+ *,
906
+ legacy_paths: list[str],
907
+ mirror: TaskAssetMirror,
908
+ allocation: TaskAssetAllocation | None,
909
+ cfg: Any,
910
+ role_model: str,
911
+ model_registry: ModelRegistry,
912
+ usage_logger: AssetUsageLogger,
913
+ ) -> list[str] | None:
914
+ combined: list[str] = []
915
+ seen: set[str] = set()
916
+
917
+ def _append(path: str) -> None:
918
+ try:
919
+ normalized = os.fspath(Path(path).resolve())
920
+ except OSError:
921
+ return
922
+ if normalized in seen:
923
+ return
924
+ seen.add(normalized)
925
+ combined.append(normalized)
926
+
927
+ for path in legacy_paths:
928
+ _append(path)
929
+ if cfg.assets.worker.inline_images and model_registry.get(role_model).supports_vision:
930
+ decision_by_id = {
931
+ decision.asset_id: decision.mode
932
+ for decision in (allocation.decisions if allocation else [])
933
+ }
934
+ max_new = max(0, int(cfg.assets.worker.max_inline_images))
935
+ added_new = 0
936
+ for entry in mirror.primary:
937
+ if entry.kind != "image" or entry.status != "mirrored":
938
+ continue
939
+ if decision_by_id.get(entry.asset_id) not in {"full_inline", "focused_extract"}:
940
+ continue
941
+ if entry.raw_workspace_path is None or not entry.raw_workspace_path.exists():
942
+ continue
943
+ before = len(combined)
944
+ _append(os.fspath(entry.raw_workspace_path))
945
+ if len(combined) > before:
946
+ usage_logger.inline_injection(asset_id=entry.asset_id, kind=entry.kind)
947
+ added_new += 1
948
+ if added_new >= max_new:
949
+ break
950
+ max_total = max(0, int(cfg.assets.worker.max_inline_images))
951
+ if max_total > 0:
952
+ combined = combined[:max_total]
953
+ return combined or None
954
+
955
+
956
+ def _append_patch_debug_section(patch_path: Path, *, title: str, patch_text: str) -> None:
957
+ existing = patch_path.read_text(encoding="utf-8") if patch_path.exists() else ""
958
+ parts: list[str] = []
959
+ if existing:
960
+ parts.append(existing if existing.endswith("\n") else existing + "\n")
961
+ parts.append(f"# {title}\n")
962
+ if patch_text:
963
+ parts.append(patch_text if patch_text.endswith("\n") else patch_text + "\n")
964
+ patch_path.write_text("\n".join(part.rstrip("\n") for part in parts if part), encoding="utf-8")
965
+
966
+
967
+ def forge_plan(
968
+ path: Path = typer.Option(
969
+ Path("."),
970
+ "--path",
971
+ help="Workspace path or repository subdirectory.",
972
+ ),
973
+ create_path: bool = typer.Option(
974
+ False,
975
+ "--create-path",
976
+ help="Create --path if it does not exist before binding the workspace.",
977
+ ),
978
+ allow_broad_workspace: bool = typer.Option(
979
+ False,
980
+ "--allow-broad-workspace",
981
+ help="Allow guarded broad workspaces instead of choosing a narrower project folder.",
982
+ ),
983
+ cli_ctx: Any = None,
984
+ events: Any = None,
985
+ ) -> None:
986
+ events = _events_or_null(events)
987
+ console = _console()
988
+ try:
989
+ binding = _resolve_startup_workspace_binding(
990
+ requested_path=path,
991
+ console=console,
992
+ interactive=not _is_non_interactive_terminal(),
993
+ create_if_missing=create_path,
994
+ allow_broad_workspace=allow_broad_workspace,
995
+ source=_path_binding_source(cli_ctx),
996
+ action=WorkspaceAction.FORGE_PLAN,
997
+ )
998
+ paths = create_plan_run(
999
+ path,
1000
+ create_if_missing=create_path,
1001
+ allow_broad_workspace=allow_broad_workspace,
1002
+ workspace_binding=binding,
1003
+ )
1004
+ events.set_run_id(paths.run_id)
1005
+ plan = load_plan(paths)
1006
+ workspace_scan = ensure_workspace_context_artifacts(paths)
1007
+ except (ForgeError, WorkspaceBindingError) as e:
1008
+ console.print(f"[red]Forge error:[/red] {e}")
1009
+ events.error(message=str(e), kind="forge_error", exit_code=EXIT_ERROR)
1010
+ raise typer.Exit(code=EXIT_ERROR) from e
1011
+
1012
+ console.rule("[bold cyan]forge plan[/bold cyan]")
1013
+ console.print(f"Run ID: {paths.run_id}")
1014
+ console.print(f"Plan directory: {paths.plan_dir}")
1015
+ for line in format_workspace_context_summary_lines(workspace_scan):
1016
+ console.print(line)
1017
+ console.print("Planning loop started. Type /help for commands. Type /done to finish.")
1018
+ assistant_enabled = False
1019
+ planning_suggested: set[str] = set()
1020
+ planner_state = _ForgePlannerSessionState(
1021
+ workspace_context=(
1022
+ _workspace_context_payload_for_paths(paths=paths)
1023
+ or {
1024
+ **workspace_scan.to_dict(),
1025
+ "greenfield": bool(getattr(paths, "greenfield", False)),
1026
+ }
1027
+ )
1028
+ )
1029
+
1030
+ def _emit_planner_meta(message: str) -> None:
1031
+ console.print(message)
1032
+
1033
+ def _emit_planner_warning_group(label: str, warnings: list[str]) -> None:
1034
+ for warning in warnings:
1035
+ console.print(f"[yellow]{label}:[/yellow] {warning}")
1036
+
1037
+ while True:
1038
+ try:
1039
+ # In machine mode the prompt goes to stderr so stdout stays pure NDJSON;
1040
+ # planning input is still read from stdin exactly as before.
1041
+ line = typer.prompt("plan", err=events.enabled)
1042
+ except (EOFError, KeyboardInterrupt):
1043
+ console.print("")
1044
+ break
1045
+ text = line.strip()
1046
+ if not text:
1047
+ continue
1048
+
1049
+ append_transcript_note(paths, role="user", message=text)
1050
+ parts = text.split(maxsplit=1)
1051
+ cmd = parts[0].lower()
1052
+ arg = parts[1].strip() if len(parts) > 1 else ""
1053
+
1054
+ if cmd in {"/done", "done", "/exit", "exit", "/quit", "quit"}:
1055
+ append_transcript_note(paths, role="system", message="Planning loop finished.")
1056
+ break
1057
+ if cmd in {"/help", "help"}:
1058
+ console.print(_planning_help_panel())
1059
+ append_transcript_note(paths, role="system", message="Displayed planning help.")
1060
+ continue
1061
+ if cmd == "/assistant":
1062
+ assistant_cmd = arg.lower()
1063
+ if not assistant_cmd:
1064
+ assistant_cmd, picker_available = _select_forge_assistant_interactive(
1065
+ enabled=assistant_enabled,
1066
+ console=console,
1067
+ )
1068
+ if not picker_available:
1069
+ console.print("[yellow]Usage:[/yellow] /assistant on|off|status")
1070
+ append_transcript_note(
1071
+ paths,
1072
+ role="system",
1073
+ message="Rejected invalid /assistant usage.",
1074
+ )
1075
+ continue
1076
+ if assistant_cmd is None:
1077
+ continue
1078
+ if assistant_cmd == "on":
1079
+ assistant_enabled = True
1080
+ console.print("Planner assistant: ON")
1081
+ append_transcript_note(paths, role="system", message="Planner assistant enabled.")
1082
+ continue
1083
+ if assistant_cmd == "off":
1084
+ assistant_enabled = False
1085
+ _set_forge_planner_follow_up_state(
1086
+ planner_state=planner_state,
1087
+ questions=[],
1088
+ awaiting_clarification=False,
1089
+ )
1090
+ console.print("Planner assistant: OFF")
1091
+ append_transcript_note(paths, role="system", message="Planner assistant disabled.")
1092
+ continue
1093
+ if assistant_cmd == "status":
1094
+ state = "ON" if assistant_enabled else "OFF"
1095
+ console.print(f"Planner assistant: {state}")
1096
+ append_transcript_note(
1097
+ paths,
1098
+ role="system",
1099
+ message=f"Planner assistant status requested ({state}).",
1100
+ )
1101
+ continue
1102
+
1103
+ console.print("[yellow]Usage:[/yellow] /assistant on|off|status")
1104
+ append_transcript_note(
1105
+ paths,
1106
+ role="system",
1107
+ message="Rejected invalid /assistant usage.",
1108
+ )
1109
+ continue
1110
+
1111
+ if assistant_enabled:
1112
+ _run_forge_planner_turn_controller(
1113
+ console=console,
1114
+ paths=paths,
1115
+ plan=plan,
1116
+ planner_state=planner_state,
1117
+ user_text=text,
1118
+ cfg_loader=load_config,
1119
+ unavailable_message_builder=(
1120
+ lambda error: (
1121
+ "Planner assistant is unavailable because config could not be loaded: "
1122
+ f"{error}"
1123
+ )
1124
+ ),
1125
+ emit_meta=_emit_planner_meta,
1126
+ emit_warning_group=_emit_planner_warning_group,
1127
+ api_key_override=None,
1128
+ render_reply=lambda message, questions: _render_planner_reply(
1129
+ console=console,
1130
+ message=message,
1131
+ questions=questions,
1132
+ ),
1133
+ selection_label="planner",
1134
+ planning_relevant=True,
1135
+ )
1136
+ continue
1137
+
1138
+ if cmd == "/goal":
1139
+ if not arg:
1140
+ console.print("[yellow]Usage:[/yellow] /goal <text>")
1141
+ append_transcript_note(paths, role="system", message="Rejected empty /goal.")
1142
+ continue
1143
+ goal = arg
1144
+ plan["project_goal"] = goal
1145
+ if not str(plan.get("summary") or "").strip():
1146
+ plan["summary"] = goal
1147
+ save_plan(paths, plan)
1148
+ console.print("Project goal updated.")
1149
+ if "goal" not in planning_suggested:
1150
+ planning_suggested.add("goal")
1151
+ console.print(
1152
+ "[dim]Next: add tasks with /task <title>, or describe the work and "
1153
+ "let the planner draft them.[/dim]"
1154
+ )
1155
+ append_transcript_note(paths, role="system", message="Updated project goal.")
1156
+ continue
1157
+ if cmd == "/task":
1158
+ if not arg:
1159
+ console.print("[yellow]Usage:[/yellow] /task <title>")
1160
+ append_transcript_note(paths, role="system", message="Rejected empty /task.")
1161
+ continue
1162
+ title = arg
1163
+ try:
1164
+ task = add_task(
1165
+ plan,
1166
+ title=title,
1167
+ description=f"Manual planning chat task: {title}",
1168
+ )
1169
+ except ForgeError as e:
1170
+ console.print(f"[yellow]Task rejected:[/yellow] {e}")
1171
+ append_transcript_note(
1172
+ paths,
1173
+ role="system",
1174
+ message=f"Rejected /task because it lacked runnable file scope: {e}",
1175
+ )
1176
+ continue
1177
+ save_plan(paths, plan)
1178
+ console.print(f"Added task: {task['id']} - {task['title']}")
1179
+ if "task" not in planning_suggested:
1180
+ planning_suggested.add("task")
1181
+ console.print(
1182
+ "[dim]Next: add more tasks, or /done to save and validate the plan.[/dim]"
1183
+ )
1184
+ append_transcript_note(paths, role="system", message=f"Added task {task['id']}.")
1185
+ continue
1186
+
1187
+ add_requirement(plan, text)
1188
+ save_plan(paths, plan)
1189
+ console.print("Captured requirement note.")
1190
+ append_transcript_note(paths, role="system", message="Captured requirement note.")
1191
+
1192
+ finalize_plan(plan)
1193
+ reconciliation_result, _ = _reconcile_plan_for_paths(
1194
+ paths=paths,
1195
+ plan=plan,
1196
+ refresh_if_stale=True,
1197
+ transcript_tail=planner_state.transcript,
1198
+ )
1199
+ save_plan(paths, plan)
1200
+ validation_warnings = _validate_forge_plan_for_paths(paths, plan)
1201
+ if reconciliation_result.warnings:
1202
+ console.print("[yellow]Plan reconciliation warnings:[/yellow]")
1203
+ for warning in reconciliation_result.warnings:
1204
+ console.print(f"- {warning}")
1205
+ append_transcript_note(
1206
+ paths,
1207
+ role="system",
1208
+ message=f"Plan reconciliation warning: {warning}",
1209
+ )
1210
+ _write_plan_validation_artifact(
1211
+ paths=paths,
1212
+ reconciliation_result=reconciliation_result,
1213
+ validation_warnings=validation_warnings,
1214
+ )
1215
+ if validation_warnings:
1216
+ console.print("[yellow]Plan validation warnings:[/yellow]")
1217
+ for warning in validation_warnings:
1218
+ console.print(f"- {warning}")
1219
+ append_transcript_note(
1220
+ paths,
1221
+ role="system",
1222
+ message=f"Plan validation warning: {warning}",
1223
+ )
1224
+
1225
+ # Stamp the plan with an explicit status so a plan that only earned warnings
1226
+ # is stored as a draft rather than looking ready until exec rejects it.
1227
+ status_assessment = apply_plan_status(plan, validation_warnings=validation_warnings)
1228
+ save_plan(paths, plan)
1229
+ if status_assessment.status == PLAN_STATUS_DRAFT:
1230
+ console.print("[yellow]Plan status:[/yellow] draft (not execution-ready)")
1231
+ for reason in status_assessment.blocking_reasons[:5]:
1232
+ console.print(f"- {reason}")
1233
+ append_transcript_note(
1234
+ paths,
1235
+ role="system",
1236
+ message="Plan saved as draft: " + "; ".join(status_assessment.blocking_reasons[:5]),
1237
+ )
1238
+ else:
1239
+ console.print("Plan status: execution_ready")
1240
+
1241
+ console.print(f"Plan saved: {paths.plan_md_path}")
1242
+ console.print(f"Structured plan: {paths.plan_json_path}")
1243
+
1244
+ tasks = plan.get("tasks") or []
1245
+ not_ready_reason = _plan_execution_readiness(plan)
1246
+ repair_payload = plan_repair_event_payload(plan)
1247
+ plan_saved_payload = {
1248
+ "run_id": paths.run_id,
1249
+ "plan_md": os.fspath(paths.plan_md_path),
1250
+ "plan_json": os.fspath(paths.plan_json_path),
1251
+ "run_dir": os.fspath(paths.run_dir),
1252
+ "project_goal": str(plan.get("project_goal") or "").strip() or None,
1253
+ "task_count": len(tasks),
1254
+ "task_ids": [str(task.get("id", "")) for task in tasks],
1255
+ "execution_ready": not_ready_reason is None,
1256
+ "warnings": list(validation_warnings),
1257
+ "reconciliation_warnings": list(reconciliation_result.warnings),
1258
+ **repair_payload,
1259
+ }
1260
+ events.emit(EVENT_PLAN_SAVED, plan_saved_payload)
1261
+ if not_ready_reason is not None:
1262
+ events.emit(
1263
+ EVENT_PLAN_INVALID,
1264
+ {
1265
+ "reason": not_ready_reason,
1266
+ "source": "execution_readiness",
1267
+ "warnings": list(validation_warnings),
1268
+ **repair_payload,
1269
+ },
1270
+ )
1271
+ events.run_completed(ok=True, exit_code=EXIT_OK, data={"plan": plan_saved_payload})
1272
+
1273
+
1274
+ _SEQUENTIAL_DELEGATE_ENV = "ALYSIS_SWARM_SEQUENTIAL_DELEGATE"
1275
+
1276
+
1277
+ def _swarm_sequential_delegation_blocker(
1278
+ *,
1279
+ cfg: Any,
1280
+ parallel: int,
1281
+ dry_run: Any,
1282
+ keep_worktrees: Any,
1283
+ retry_merge_conflicts: Any,
1284
+ replan: str | None,
1285
+ integration_verify: str | None,
1286
+ integration_verify_cmd: list[str] | None,
1287
+ ) -> str | None:
1288
+ """Why ``--parallel 1`` must stay on the swarm engine, or None to delegate.
1289
+
1290
+ Delegation is only safe when nothing swarm-specific was requested. Every
1291
+ blocker below names a capability the sequential engine genuinely does not
1292
+ have, so the answer is always "keep the swarm", never "quietly drop it".
1293
+ """
1294
+ if not isinstance(parallel, int) or parallel != 1:
1295
+ return "parallel > 1"
1296
+ if str(env_get(_SEQUENTIAL_DELEGATE_ENV, "")).strip().lower() in {
1297
+ "0",
1298
+ "false",
1299
+ "no",
1300
+ "off",
1301
+ }:
1302
+ return f"{_SEQUENTIAL_DELEGATE_ENV} disables delegation"
1303
+ if dry_run is True:
1304
+ return "--dry-run reports the swarm schedule"
1305
+ if keep_worktrees is True:
1306
+ return "--keep-worktrees is worktree-specific"
1307
+ if retry_merge_conflicts is True:
1308
+ return "--retry-merge-conflicts re-merges existing task branches"
1309
+ if integration_verify is not None or integration_verify_cmd:
1310
+ return "an explicit integration verification gate was requested"
1311
+ try:
1312
+ replanning_mode = resolve_replanning_mode(cfg=cfg, replanning_mode=replan)
1313
+ except Exception: # noqa: BLE001
1314
+ # Failing to classify replanning must not decide the engine; keep the
1315
+ # swarm, which is what the caller literally typed.
1316
+ return "between-batch replanning mode could not be resolved"
1317
+ if str(replanning_mode) != "off":
1318
+ return f"between-batch replanning is {replanning_mode}"
1319
+ return None
1320
+
1321
+
1322
+ def _load_swarm_summary_payload(paths: Any) -> dict[str, Any] | None:
1323
+ """Read the structured swarm outcome the orchestrator already writes."""
1324
+ summary_path = paths.execution_dir / "swarm_summary.json"
1325
+ try:
1326
+ payload = json.loads(summary_path.read_text(encoding="utf-8"))
1327
+ except (OSError, ValueError):
1328
+ return None
1329
+ return payload if isinstance(payload, dict) else None
1330
+
1331
+
1332
+ def _emit_swarm_outcome_events(
1333
+ *,
1334
+ events: ForgeEventEmitter,
1335
+ paths: Any,
1336
+ exit_code: int,
1337
+ dry_run: bool,
1338
+ ) -> None:
1339
+ """Translate the swarm's own outcome artifact into protocol events."""
1340
+ if not events.enabled:
1341
+ return
1342
+
1343
+ summary = _load_swarm_summary_payload(paths)
1344
+ data: dict[str, Any] = {"dry_run": bool(dry_run)}
1345
+ if summary is not None:
1346
+ verification_status = str(summary.get("verification_status") or "")
1347
+ integration = summary.get("integration")
1348
+ integration_payload = integration if isinstance(integration, dict) else {}
1349
+ if verification_status == "not_run":
1350
+ events.emit(
1351
+ EVENT_VERIFICATION_UNAVAILABLE,
1352
+ {
1353
+ "scope": "swarm",
1354
+ "status": verification_status,
1355
+ "reason": "no integration verification ran for this swarm run",
1356
+ },
1357
+ )
1358
+ elif verification_status:
1359
+ events.emit(
1360
+ EVENT_VERIFICATION_RESULT,
1361
+ {
1362
+ "scope": "swarm",
1363
+ "status": verification_status,
1364
+ "passed": verification_status == "passed",
1365
+ "integration": integration_payload,
1366
+ },
1367
+ )
1368
+ data["outcome"] = summary
1369
+ elif not dry_run:
1370
+ events.emit(
1371
+ EVENT_VERIFICATION_UNAVAILABLE,
1372
+ {
1373
+ "scope": "swarm",
1374
+ "status": "unknown",
1375
+ "reason": "swarm summary artifact was not written",
1376
+ },
1377
+ )
1378
+
1379
+ events.run_completed(ok=exit_code == EXIT_OK, exit_code=exit_code, data=data)
1380
+
1381
+
1382
+ def forge_swarm(
1383
+ path: Path = typer.Option(
1384
+ Path("."),
1385
+ "--path",
1386
+ help="Workspace path or repository subdirectory.",
1387
+ ),
1388
+ allow_broad_workspace: bool = typer.Option(
1389
+ False,
1390
+ "--allow-broad-workspace",
1391
+ help="Allow guarded broad workspaces instead of requiring a narrower project path.",
1392
+ ),
1393
+ parallel: int = typer.Option(2, "--parallel", min=1, help="Parallel workers per batch."),
1394
+ base_branch: str | None = typer.Option(
1395
+ None,
1396
+ "--base-branch",
1397
+ help="Base branch (defaults to current checked out branch).",
1398
+ ),
1399
+ max_tasks: int | None = typer.Option(
1400
+ None,
1401
+ "--max-tasks",
1402
+ min=1,
1403
+ help="Maximum number of tasks to execute in this swarm run.",
1404
+ ),
1405
+ max_attempts: int | None = typer.Option(
1406
+ None,
1407
+ "--max-attempts",
1408
+ min=1,
1409
+ help="Maximum attempts allowed per task before scheduler skips it.",
1410
+ ),
1411
+ dry_run: bool = typer.Option(False, "--dry-run", help="Print schedule and exit."),
1412
+ keep_worktrees: bool = typer.Option(
1413
+ False,
1414
+ "--keep-worktrees",
1415
+ help=(
1416
+ "Keep failed task worktrees and branches for debugging; default cleanup removes "
1417
+ "successful worktrees and rejected failed branch state."
1418
+ ),
1419
+ ),
1420
+ retry_failed: bool = typer.Option(
1421
+ False,
1422
+ "--retry-failed",
1423
+ help="Include tasks currently marked failed.",
1424
+ ),
1425
+ retry_changes_requested: bool = typer.Option(
1426
+ False,
1427
+ "--retry-changes-requested",
1428
+ help="Include tasks currently marked changes_requested.",
1429
+ ),
1430
+ only: str | None = typer.Option(
1431
+ None,
1432
+ "--only",
1433
+ help="Comma-separated task ids to execute (still enforces dependencies).",
1434
+ ),
1435
+ retry_merge_conflicts: bool = typer.Option(
1436
+ False,
1437
+ "--retry-merge-conflicts",
1438
+ help="Retry tasks marked merge_conflict during merge phase.",
1439
+ ),
1440
+ scope: str = typer.Option(
1441
+ "strict",
1442
+ "--scope",
1443
+ help="Write-scope enforcement: strict by default; use warn or off to opt out.",
1444
+ ),
1445
+ verify: str = typer.Option(
1446
+ "warn",
1447
+ "--verify",
1448
+ help="Verification policy: off, warn, or strict.",
1449
+ ),
1450
+ verify_cmd: list[str] | None = typer.Option(
1451
+ None,
1452
+ "--verify-cmd",
1453
+ help="Override verify command for this run (repeatable).",
1454
+ ),
1455
+ integration_verify: str | None = typer.Option(
1456
+ None,
1457
+ "--integration-verify",
1458
+ help="Batch integration verification policy: off, warn, or strict (defaults to config: warn).",
1459
+ ),
1460
+ integration_verify_cmd: list[str] | None = typer.Option(
1461
+ None,
1462
+ "--integration-verify-cmd",
1463
+ help="Override integration verify command for this swarm run (repeatable).",
1464
+ ),
1465
+ replan: str | None = typer.Option(
1466
+ None,
1467
+ "--replan",
1468
+ help="Between-batch replanning mode: off, suggest, or apply.",
1469
+ ),
1470
+ review: bool = typer.Option(
1471
+ False,
1472
+ "--review",
1473
+ help="Run automated PR review gate before merging task branches.",
1474
+ ),
1475
+ mode: Mode | None = typer.Option(None, "--mode", help="Mode override."),
1476
+ model: str | None = typer.Option(None, "--model", help="Model override."),
1477
+ base_url: str | None = typer.Option(None, "--base-url", help="Base URL override."),
1478
+ temperature: float | None = typer.Option(None, "--temperature", help="Sampling temperature."),
1479
+ stream: bool | None = typer.Option(
1480
+ None,
1481
+ "--stream/--no-stream",
1482
+ help="Enable streamed assistant output.",
1483
+ ),
1484
+ max_steps: int | None = typer.Option(
1485
+ None,
1486
+ "--max-steps",
1487
+ help="Optional safety limit on each managed agent task.",
1488
+ ),
1489
+ no_log: bool = typer.Option(False, "--no-log", help="Disable JSONL session logging."),
1490
+ api_key_env: str | None = typer.Option(
1491
+ None,
1492
+ "--api-key-env",
1493
+ help=(
1494
+ "Read API key from this environment variable (overrides ALYSIS_API_KEY/OPENAI_API_KEY)."
1495
+ ),
1496
+ ),
1497
+ api_key_stdin: bool = typer.Option(
1498
+ False,
1499
+ "--api-key-stdin",
1500
+ help="Prompt for API key (hidden input). Key is kept in memory for this run only.",
1501
+ ),
1502
+ api_key: str | None = typer.Option(
1503
+ None,
1504
+ "--api-key",
1505
+ help=(
1506
+ "UNSAFE: Provide API key via CLI argument (may leak via shell history / process list). "
1507
+ "Prefer --api-key-stdin or --api-key-env."
1508
+ ),
1509
+ ),
1510
+ yes: bool = typer.Option(
1511
+ False,
1512
+ "--yes",
1513
+ help="In auto mode, skip confirmations for sensitive commands (hard blocks still apply).",
1514
+ ),
1515
+ cli_ctx: Any = None,
1516
+ events: Any = None,
1517
+ ) -> None:
1518
+ events = _events_or_null(events)
1519
+ console = _console()
1520
+ cfg = load_config()
1521
+
1522
+ # A swarm of one is a sequential run wearing worktrees. Hand it to the sequential
1523
+ # engine so the blessed path is what actually executes, unless the invocation asked
1524
+ # for machinery only the swarm has.
1525
+ delegation_blocker = _swarm_sequential_delegation_blocker(
1526
+ cfg=cfg,
1527
+ parallel=parallel,
1528
+ dry_run=dry_run,
1529
+ keep_worktrees=keep_worktrees,
1530
+ retry_merge_conflicts=retry_merge_conflicts,
1531
+ replan=replan,
1532
+ integration_verify=integration_verify,
1533
+ integration_verify_cmd=integration_verify_cmd,
1534
+ )
1535
+ if isinstance(parallel, int) and parallel == 1:
1536
+ if delegation_blocker is None:
1537
+ console.print(
1538
+ "[dim]forge swarm --parallel 1 has no parallelism to exploit; running the "
1539
+ "sequential engine (`forge run`) in this checkout instead of provisioning "
1540
+ "worktrees.[/dim]"
1541
+ )
1542
+ return forge_run(
1543
+ path=path,
1544
+ allow_broad_workspace=allow_broad_workspace,
1545
+ only=only,
1546
+ max_tasks=max_tasks,
1547
+ max_attempts=max_attempts,
1548
+ retry_failed=retry_failed,
1549
+ retry_changes_requested=retry_changes_requested,
1550
+ keep_going=False,
1551
+ dry_run=False,
1552
+ scope=scope,
1553
+ verify=verify,
1554
+ verify_cmd=verify_cmd,
1555
+ verify_repair_attempts=None,
1556
+ pr=True,
1557
+ review=review,
1558
+ base_branch=base_branch,
1559
+ keep_branch=False,
1560
+ auto_resolve_conflicts=False,
1561
+ mode=mode,
1562
+ model=model,
1563
+ base_url=base_url,
1564
+ temperature=temperature,
1565
+ stream=stream,
1566
+ max_steps=max_steps,
1567
+ no_log=no_log,
1568
+ api_key_env=api_key_env,
1569
+ api_key_stdin=api_key_stdin,
1570
+ api_key=api_key,
1571
+ yes=yes,
1572
+ cli_ctx=cli_ctx,
1573
+ events=events,
1574
+ )
1575
+ console.print(
1576
+ f"[dim]forge swarm --parallel 1 kept the swarm engine: {delegation_blocker}. "
1577
+ "Use `forge run` for the sequential path.[/dim]"
1578
+ )
1579
+
1580
+ effective = clone_cfg(cfg)
1581
+ current_ctx = get_current_context(silent=True)
1582
+ max_steps_source = (
1583
+ current_ctx.get_parameter_source("max_steps") if current_ctx is not None else None
1584
+ )
1585
+ max_steps_provided = max_steps is not None
1586
+ if current_ctx is not None:
1587
+ max_steps_provided = (
1588
+ max_steps_source is not None and max_steps_source is not ParameterSource.DEFAULT
1589
+ )
1590
+ if base_url is not None:
1591
+ effective.base_url = base_url
1592
+ if model is not None:
1593
+ effective.model = model
1594
+ if temperature is not None:
1595
+ _apply_temperature_override(effective, temperature)
1596
+ if stream is not None:
1597
+ effective.stream = stream
1598
+ if max_steps is not None:
1599
+ effective.max_steps = max_steps
1600
+ swarm_max_steps = effective.max_steps if max_steps_provided else None
1601
+
1602
+ effective_mode = (mode.value if mode else effective.default_mode) or "review"
1603
+ scope_mode = "strict"
1604
+ verify_mode = "warn"
1605
+ integration_verify_mode = None
1606
+ replanning_mode = None
1607
+
1608
+ try:
1609
+ scope_mode = _normalize_scope_mode(scope)
1610
+ verify_mode = _normalize_verify_mode(verify)
1611
+ integration_verify_mode = integration_verify
1612
+ replanning_mode = replan
1613
+ api_key_override = _resolve_api_key_override(
1614
+ api_key=api_key,
1615
+ api_key_env=api_key_env,
1616
+ api_key_stdin=api_key_stdin,
1617
+ )
1618
+ binding = resolve_workspace_binding(
1619
+ path,
1620
+ create_if_missing=False,
1621
+ allow_broad_workspace=allow_broad_workspace,
1622
+ source=_path_binding_source(cli_ctx),
1623
+ )
1624
+ ensure_workspace_policy(
1625
+ binding,
1626
+ action=WorkspaceAction.SWARM,
1627
+ allow_broad_workspace=allow_broad_workspace,
1628
+ )
1629
+ try:
1630
+ paths = load_current_run_paths(binding.workspace_context.focus_path)
1631
+ except ForgeError as e:
1632
+ if "current_run.json" in str(e):
1633
+ raise _missing_swarm_run_error(binding=binding) from e
1634
+ raise
1635
+ events.set_run_id(paths.run_id)
1636
+ plan = load_plan(paths)
1637
+ run_mutation_guard = acquire_swarm_mutation_guard(
1638
+ paths,
1639
+ mode="forge_swarm:cli",
1640
+ on_wait=lambda info: _print_forge_lock_wait_notice(console, info),
1641
+ )
1642
+ try:
1643
+ if bool(getattr(run_mutation_guard, "acquired_after_wait", False)):
1644
+ plan = load_plan(paths)
1645
+ reconciliation_result, _ = _reconcile_plan_for_paths(
1646
+ paths=paths,
1647
+ plan=plan,
1648
+ refresh_if_stale=True,
1649
+ )
1650
+ if reconciliation_result.changed:
1651
+ save_plan(paths, plan)
1652
+ if reconciliation_result.warnings:
1653
+ console.print("[yellow]Plan reconciliation warnings:[/yellow]")
1654
+ for warning in reconciliation_result.warnings:
1655
+ console.print(f"- {warning}")
1656
+ validation_warnings = _validate_forge_plan_for_paths(paths, plan)
1657
+ _write_plan_validation_artifact(
1658
+ paths=paths,
1659
+ reconciliation_result=reconciliation_result,
1660
+ validation_warnings=validation_warnings,
1661
+ )
1662
+ repair_payload = plan_repair_event_payload(plan)
1663
+ no_execution_ready_tasks_message = _forge_no_execution_ready_tasks_message(plan)
1664
+ if no_execution_ready_tasks_message is not None:
1665
+ events.emit(
1666
+ EVENT_PLAN_INVALID,
1667
+ {
1668
+ "reason": no_execution_ready_tasks_message,
1669
+ "source": "execution_readiness",
1670
+ "warnings": list(validation_warnings),
1671
+ **repair_payload,
1672
+ },
1673
+ )
1674
+ raise ForgeError(no_execution_ready_tasks_message)
1675
+ try:
1676
+ raise_for_execution_ready_plan(
1677
+ plan,
1678
+ retry_failed=retry_failed,
1679
+ retry_changes_requested=retry_changes_requested,
1680
+ retry_merge_conflicts=retry_merge_conflicts,
1681
+ only=only,
1682
+ )
1683
+ except PlannerFailedError as e:
1684
+ events.emit(
1685
+ EVENT_PLAN_INVALID,
1686
+ {
1687
+ "reason": str(e),
1688
+ "source": "plan_validation",
1689
+ "failure_category": getattr(e, "failure_category", None),
1690
+ **repair_payload,
1691
+ },
1692
+ )
1693
+ err = ForgeError(str(e))
1694
+ err.failure_category = e.failure_category # type: ignore[attr-defined]
1695
+ raise err from e
1696
+ resolve_model_for_role(
1697
+ cfg=effective,
1698
+ role=ROLE_CODING,
1699
+ plan=plan,
1700
+ prefer_context="forge",
1701
+ )
1702
+ if not dry_run:
1703
+ _mark_run_status(
1704
+ paths,
1705
+ RUN_STATUS_RUNNING,
1706
+ reason=f"forge swarm started (parallel={parallel})",
1707
+ plan=plan,
1708
+ mode="forge_swarm:cli",
1709
+ )
1710
+ code = run_swarm(
1711
+ paths=paths,
1712
+ plan=plan,
1713
+ cfg=effective,
1714
+ mode=effective_mode,
1715
+ yes=yes,
1716
+ max_steps=swarm_max_steps,
1717
+ api_key_override=api_key_override,
1718
+ no_log=no_log,
1719
+ parallel=parallel,
1720
+ base_branch=base_branch,
1721
+ max_tasks=max_tasks,
1722
+ max_attempts=max_attempts,
1723
+ dry_run=dry_run,
1724
+ keep_worktrees=keep_worktrees,
1725
+ retry_failed=retry_failed,
1726
+ retry_changes_requested=retry_changes_requested,
1727
+ only=only,
1728
+ retry_merge_conflicts=retry_merge_conflicts,
1729
+ scope_mode=scope_mode,
1730
+ verify_mode=verify_mode,
1731
+ verify_cmd=verify_cmd,
1732
+ integration_mode=integration_verify_mode,
1733
+ integration_verify_cmd=integration_verify_cmd,
1734
+ replanning_mode=replanning_mode,
1735
+ review=review,
1736
+ console=console,
1737
+ workspace_binding=binding,
1738
+ run_mutation_guard=run_mutation_guard,
1739
+ )
1740
+ if not dry_run:
1741
+ # The swarm reloads and rewrites the plan as it merges batches, so the
1742
+ # in-memory copy is stale by now; the terminal status has to be decided
1743
+ # from what actually landed on disk.
1744
+ terminal_status, terminal_reason = _terminal_run_status_for(
1745
+ plan=load_plan(paths),
1746
+ any_failure=code != EXIT_OK,
1747
+ )
1748
+ _mark_run_status(paths, terminal_status, reason=terminal_reason)
1749
+ finally:
1750
+ _mark_run_interrupted_if_still_running(
1751
+ paths,
1752
+ reason="forge swarm exited without recording an outcome",
1753
+ )
1754
+ run_mutation_guard.release()
1755
+ except (ConfigError, ForgeError, GitOpsError, WorkspaceBindingError) as e:
1756
+ console.print(f"[red]Forge error:[/red] {e}")
1757
+ events.error(
1758
+ message=str(e),
1759
+ kind="forge_error",
1760
+ exit_code=EXIT_ERROR,
1761
+ data={"failure_category": getattr(e, "failure_category", None)},
1762
+ )
1763
+ raise typer.Exit(code=EXIT_ERROR) from e
1764
+ except Exception as e: # noqa: BLE001
1765
+ # An unexpected exception is a command error, not "the swarm ran and some tasks
1766
+ # failed" -- exit 2 keeps exit 1 meaning only the latter.
1767
+ console.print(f"[red]Forge error:[/red] {e}")
1768
+ events.error(
1769
+ message=str(e) or e.__class__.__name__,
1770
+ kind="exception",
1771
+ exit_code=EXIT_ERROR,
1772
+ data={"exception_type": e.__class__.__name__},
1773
+ )
1774
+ raise typer.Exit(code=EXIT_ERROR) from e
1775
+
1776
+ log_paths = sorted(paths.execution_logs_dir.glob("*.jsonl"))
1777
+ _print_usage_summary_from_logs(
1778
+ console=console,
1779
+ title="Swarm Usage Summary",
1780
+ log_paths=log_paths,
1781
+ )
1782
+ _emit_swarm_outcome_events(events=events, paths=paths, exit_code=code, dry_run=dry_run)
1783
+ raise typer.Exit(code=code)
1784
+
1785
+
1786
+ def execute_forge_task(
1787
+ *,
1788
+ console: Any,
1789
+ events: ForgeEventEmitter,
1790
+ paths: Any,
1791
+ plan: dict[str, Any],
1792
+ task: dict[str, Any],
1793
+ task_id: str,
1794
+ effective: Any,
1795
+ run_cfg: Any,
1796
+ effective_mode: str,
1797
+ scope_mode: str,
1798
+ verify_mode: str,
1799
+ verify_commands: list[str],
1800
+ verify_command_source: str | None,
1801
+ verify_repair_budget: int,
1802
+ api_key_override: str | None,
1803
+ yes: bool,
1804
+ no_log: bool,
1805
+ max_steps_provided: bool,
1806
+ pr: bool,
1807
+ review: bool,
1808
+ base_branch: str | None,
1809
+ keep_branch: bool,
1810
+ auto_resolve_conflicts: bool = False,
1811
+ ) -> TaskExecutionOutcome:
1812
+ """Execute exactly one plan task in the main checkout, with no worktrees.
1813
+
1814
+ This is the sequential execution core. ``forge exec`` calls it once and turns
1815
+ the outcome into an exit code; ``forge run`` calls it per ready task in
1816
+ dependency order. Sharing it is the point: scope triage, verification, the
1817
+ repair loop, the review gate and the PR flow behave identically either way,
1818
+ so a fix to one is a fix to both.
1819
+
1820
+ The caller owns the run mutation guard, the plan reload and the terminal
1821
+ event. Raising :class:`ForgeTaskExecutionError` means the task could not be
1822
+ executed at all (a command error); returning an outcome with
1823
+ ``success=False`` means it ran and was not accepted.
1824
+ """
1825
+ pr_base_branch: str | None = None
1826
+ pr_task_branch: str | None = None
1827
+ commit_hash: str | None = None
1828
+ merge_commit_hash: str | None = None
1829
+ merge_result: str | None = None
1830
+ allowed_scope = normalize_scope_patterns(task, root=paths.root) if scope_mode != "off" else []
1831
+ scope_warnings: list[str] = []
1832
+ review_blocked = False
1833
+ verify_blocked = False
1834
+ # Set when the work landed but strict verification had no authoritative
1835
+ # command to hold it against: a completion, not a failure, and never merged.
1836
+ verification_unavailable_completion = False
1837
+ verification_repair_payload: dict[str, Any] | None = None
1838
+ verify_summary: str | None = None
1839
+ verify_payload: dict[str, Any] | None = None
1840
+ merge_conflict_detected = False
1841
+ conflict_review_path: Path | None = None
1842
+ verify_path = paths.execution_verify_dir / f"{_safe_task_file_component(task_id)}.txt"
1843
+ remote_settings = load_remote_settings_from_env()
1844
+ remote_record: dict[str, Any] | None = None
1845
+ conflict_auto_settings = load_conflict_auto_resolve_settings(cfg=effective)
1846
+
1847
+ if pr:
1848
+ try:
1849
+ ensure_git_available()
1850
+ ensure_git_repo(paths.root)
1851
+ ensure_clean_for_pr(paths.root)
1852
+ pr_base_branch = base_branch.strip() if base_branch else current_branch(paths.root)
1853
+ if not pr_base_branch:
1854
+ raise GitOpsError("base branch is empty")
1855
+ pr_task_branch = str(task.get("branch") or "").strip()
1856
+ if not pr_task_branch:
1857
+ pr_task_branch = generate_task_branch_name(
1858
+ task_id,
1859
+ str(task.get("title") or ""),
1860
+ )
1861
+ task["branch"] = pr_task_branch
1862
+ save_plan(paths, plan)
1863
+ checkout_branch(paths.root, pr_task_branch, base_branch=pr_base_branch)
1864
+ merge_result = "not merged"
1865
+ except GitOpsError as e:
1866
+ raise ForgeTaskExecutionError(
1867
+ str(e),
1868
+ kind="git_error",
1869
+ task_id=task_id,
1870
+ ) from e
1871
+
1872
+ ensure_execution_dirs(paths)
1873
+ set_task_status(plan, task_id, "in_progress")
1874
+ save_plan(paths, plan)
1875
+ events.emit(
1876
+ EVENT_TASK_STARTED,
1877
+ {
1878
+ "task_id": task_id,
1879
+ "title": str(task.get("title", "")),
1880
+ "status": "in_progress",
1881
+ "source": "exec",
1882
+ "pr": bool(pr),
1883
+ "branch": pr_task_branch,
1884
+ },
1885
+ )
1886
+
1887
+ started_at = now_iso()
1888
+ run_non_interactive = _is_non_interactive_terminal()
1889
+ prompt_verification_enabled = verify_mode != "off" and bool(verify_commands)
1890
+ prepared_knowledge = _prepare_task_execution_knowledge(
1891
+ run_paths=paths,
1892
+ task=task,
1893
+ selection_label="execution",
1894
+ )
1895
+ runtime_session_id = _safe_task_file_component(task_id)
1896
+ task_mcp_scope, task_mcp_scope_warnings = normalize_task_mcp_scope(
1897
+ task.get("mcp_scope"),
1898
+ warning_prefix=f"Task {task_id}",
1899
+ )
1900
+ scope_warnings.extend(task_mcp_scope_warnings)
1901
+ instruction = ""
1902
+ task_image_paths: list[str] | None = None
1903
+ budget_artifact_path = paths.execution_budgets_dir / f"{runtime_session_id}.json"
1904
+ runtime_sessions_dir = _execution_private_sessions_dir(
1905
+ cfg=run_cfg,
1906
+ run_id=paths.run_id,
1907
+ task_id=task_id,
1908
+ workspace_root=paths.root,
1909
+ )
1910
+ _cleanup_execution_private_sessions_dir(runtime_sessions_dir)
1911
+ task_attempts_raw = task.get("attempts")
1912
+ try:
1913
+ task_attempt_count = (
1914
+ max(0, int(task_attempts_raw if task_attempts_raw is not None else 0)) + 1
1915
+ )
1916
+ except (TypeError, ValueError):
1917
+ task_attempt_count = 1
1918
+ task_step_budget = _resolve_managed_task_step_budget(
1919
+ cfg=run_cfg,
1920
+ plan=plan,
1921
+ task=task,
1922
+ kind="managed_task",
1923
+ mode=effective_mode,
1924
+ verification_enabled=verify_mode != "off",
1925
+ max_steps_override=(effective.max_steps if max_steps_provided else None),
1926
+ attempt_count=task_attempt_count,
1927
+ image_count=0,
1928
+ )
1929
+ head_before_run = head_commit(paths.root) if paths.has_head_commit else None
1930
+ before_runtime_snapshot: dict[str, str] | None = None
1931
+ reporting_baseline: Any | None = None
1932
+ recording_surface = RecordingSurface(_make_rich_surface(console=console))
1933
+ asset_setup_error: str | None = None
1934
+ asset_setup_warnings: list[str] = []
1935
+ asset_usage_logger = AssetUsageLogger(run_paths=paths, task_id=task_id)
1936
+ asset_model_registry = ModelRegistry(cfg=run_cfg)
1937
+ asset_surface = (
1938
+ build_asset_surface(
1939
+ cfg=run_cfg,
1940
+ run_paths=paths,
1941
+ model_registry=asset_model_registry,
1942
+ )
1943
+ if run_cfg.assets.enabled
1944
+ else None
1945
+ )
1946
+ task_asset_mirror = _empty_forge_exec_task_asset_mirror(paths.root, task_id=task_id)
1947
+ if asset_surface is not None:
1948
+ try:
1949
+ task_asset_mirror = mirror_task_assets(
1950
+ task=task,
1951
+ plan=plan,
1952
+ surface=asset_surface,
1953
+ workspace_path=paths.root,
1954
+ )
1955
+ except AssetError as exc:
1956
+ if run_cfg.assets.worker.fail_on_mirror_error:
1957
+ asset_setup_error = (
1958
+ f"forge exec asset mirror failed: {sanitize_error_summary(str(exc))}"
1959
+ )
1960
+ else:
1961
+ asset_setup_warnings.append(
1962
+ f"forge exec asset mirror skipped: {sanitize_optional_error_summary(str(exc))}"
1963
+ )
1964
+ scope_warnings.extend(asset_setup_warnings)
1965
+ for entry in [
1966
+ *task_asset_mirror.primary,
1967
+ *task_asset_mirror.may_need,
1968
+ *task_asset_mirror.pinned,
1969
+ ]:
1970
+ asset_usage_logger.mirror(
1971
+ asset_id=entry.asset_id,
1972
+ kind=entry.kind,
1973
+ status=entry.status,
1974
+ )
1975
+ has_mirrored_task_assets = bool(
1976
+ task_asset_mirror.primary or task_asset_mirror.may_need or task_asset_mirror.pinned
1977
+ )
1978
+
1979
+ run_code = 1
1980
+ run_err: str | None = asset_setup_error
1981
+ task_mcp_manager: Any | None = None
1982
+ asset_allocation: TaskAssetAllocation | None = None
1983
+ try:
1984
+ task_mcp_manager = _build_forge_task_scoped_mcp_manager(
1985
+ workspace_root=paths.root,
1986
+ session_id=runtime_session_id,
1987
+ task_scope=task_mcp_scope,
1988
+ )
1989
+ mcp_context_section = _build_forge_mcp_execution_context_section(
1990
+ task_scope=task_mcp_scope,
1991
+ mcp_manager=task_mcp_manager,
1992
+ )
1993
+ instruction_bundle = _build_forge_exec_instruction_bundle(
1994
+ plan=plan,
1995
+ task=task,
1996
+ root=paths.root,
1997
+ cfg=run_cfg,
1998
+ role_model=run_cfg.model,
1999
+ mode=effective_mode,
2000
+ yes=yes,
2001
+ deny_write_prefixes=[".alysis"],
2002
+ allow_write_globs=allowed_scope if scope_mode == "strict" else None,
2003
+ non_interactive=run_non_interactive,
2004
+ verification_enabled=prompt_verification_enabled,
2005
+ authoritative_verification_commands=(
2006
+ verify_commands if prompt_verification_enabled else None
2007
+ ),
2008
+ api_key=api_key_override,
2009
+ subagents_enabled=False,
2010
+ leading_sections=[prepared_knowledge.prompt_section, mcp_context_section],
2011
+ )
2012
+ relevant_assets_section = ""
2013
+ if asset_surface is not None and task_asset_mirror.primary:
2014
+ asset_allocation = allocate_task_assets(
2015
+ task=task,
2016
+ plan=plan,
2017
+ mirror=task_asset_mirror,
2018
+ cfg=run_cfg,
2019
+ model_registry=asset_model_registry,
2020
+ instruction_token_budget=instruction_bundle.budget.final_instruction_budget,
2021
+ api_key=api_key_override,
2022
+ )
2023
+ for decision in asset_allocation.decisions:
2024
+ asset_usage_logger.allocation_decision(
2025
+ asset_id=decision.asset_id,
2026
+ mode=decision.mode,
2027
+ )
2028
+ relevant_assets_section = render_relevant_assets_section(
2029
+ mirror=task_asset_mirror,
2030
+ allocation=asset_allocation,
2031
+ cfg=run_cfg,
2032
+ surface=asset_surface,
2033
+ model_registry=asset_model_registry,
2034
+ api_key=api_key_override,
2035
+ )
2036
+ elif asset_surface is not None and (task_asset_mirror.may_need or task_asset_mirror.pinned):
2037
+ asset_allocation = TaskAssetAllocation(
2038
+ task_id=task_id,
2039
+ decisions=[],
2040
+ elapsed_ms=0,
2041
+ model=None,
2042
+ tokens_used={},
2043
+ fallback_used=False,
2044
+ fallback_reason=None,
2045
+ )
2046
+ relevant_assets_section = render_relevant_assets_section(
2047
+ mirror=task_asset_mirror,
2048
+ allocation=asset_allocation,
2049
+ cfg=run_cfg,
2050
+ surface=asset_surface,
2051
+ model_registry=asset_model_registry,
2052
+ api_key=api_key_override,
2053
+ )
2054
+ if relevant_assets_section:
2055
+ instruction_bundle = _build_forge_exec_instruction_bundle(
2056
+ plan=plan,
2057
+ task=task,
2058
+ root=paths.root,
2059
+ cfg=run_cfg,
2060
+ role_model=run_cfg.model,
2061
+ mode=effective_mode,
2062
+ yes=yes,
2063
+ deny_write_prefixes=[".alysis"],
2064
+ allow_write_globs=allowed_scope if scope_mode == "strict" else None,
2065
+ non_interactive=run_non_interactive,
2066
+ verification_enabled=prompt_verification_enabled,
2067
+ authoritative_verification_commands=(
2068
+ verify_commands if prompt_verification_enabled else None
2069
+ ),
2070
+ api_key=api_key_override,
2071
+ subagents_enabled=False,
2072
+ leading_sections=[prepared_knowledge.prompt_section, mcp_context_section],
2073
+ relevant_assets_section=relevant_assets_section,
2074
+ )
2075
+ instruction = instruction_bundle.instruction
2076
+ _write_execution_context_artifact(
2077
+ paths=paths,
2078
+ task_id=task_id,
2079
+ context_text=instruction_bundle.artifact_text,
2080
+ )
2081
+ task_image_paths = _combined_forge_exec_image_paths(
2082
+ legacy_paths=list(instruction_bundle.image_paths),
2083
+ mirror=task_asset_mirror,
2084
+ allocation=asset_allocation,
2085
+ cfg=run_cfg,
2086
+ role_model=run_cfg.model,
2087
+ model_registry=asset_model_registry,
2088
+ usage_logger=asset_usage_logger,
2089
+ )
2090
+ task_step_budget = _resolve_managed_task_step_budget(
2091
+ cfg=run_cfg,
2092
+ plan=plan,
2093
+ task=task,
2094
+ kind="managed_task",
2095
+ mode=effective_mode,
2096
+ verification_enabled=verify_mode != "off",
2097
+ max_steps_override=(effective.max_steps if max_steps_provided else None),
2098
+ attempt_count=task_attempt_count,
2099
+ image_count=len(task_image_paths or []),
2100
+ )
2101
+ budget_artifact_payload = instruction_bundle.to_budget_artifact_payload()
2102
+ budget_artifact_payload["step_budget"] = task_step_budget.to_payload()
2103
+ budget_artifact_path = _write_execution_budget_artifact(
2104
+ paths=paths,
2105
+ task_id=task_id,
2106
+ payload=budget_artifact_payload,
2107
+ )
2108
+ before_runtime_snapshot = _snapshot_runtime_tree(paths.root)
2109
+ reporting_baseline = _capture_task_local_workspace_baseline(
2110
+ paths.root,
2111
+ before_commit=head_before_run,
2112
+ )
2113
+ if run_err is None:
2114
+ if asset_surface is not None and has_mirrored_task_assets:
2115
+ task_mcp_manager = compose_worker_asset_mcp_manager(
2116
+ base_manager=task_mcp_manager,
2117
+ asset_manager=build_worker_asset_mcp_manager(
2118
+ cfg=run_cfg,
2119
+ surface=asset_surface,
2120
+ model_registry=asset_model_registry,
2121
+ mirror=task_asset_mirror,
2122
+ usage_logger=asset_usage_logger,
2123
+ api_key=api_key_override,
2124
+ ),
2125
+ )
2126
+ run_code = run_agent(
2127
+ cfg=run_cfg,
2128
+ root=paths.root,
2129
+ instruction=instruction,
2130
+ mode=effective_mode,
2131
+ runtime_kind=RuntimeKind.FORGE_EXEC,
2132
+ yes=yes,
2133
+ max_steps=task_step_budget.resolved_max_steps,
2134
+ no_log=no_log,
2135
+ api_key_override=api_key_override,
2136
+ console=console,
2137
+ surface=recording_surface,
2138
+ image_paths=task_image_paths,
2139
+ deny_write_prefixes=[".alysis"],
2140
+ allow_write_globs=allowed_scope if scope_mode == "strict" else None,
2141
+ non_interactive=run_non_interactive,
2142
+ session_log_dir_override=runtime_sessions_dir,
2143
+ session_id_override=runtime_session_id,
2144
+ usage_role=f"forge_exec:{task_id}",
2145
+ enable_compaction=False,
2146
+ enable_tool_output_offload=True,
2147
+ enable_conversation_summarization=True,
2148
+ compaction_profile="execution",
2149
+ enable_chat_turn_step_budget=False,
2150
+ one_shot_execution=True,
2151
+ verification_enabled=prompt_verification_enabled,
2152
+ authoritative_verification_commands=(
2153
+ verify_commands if prompt_verification_enabled else None
2154
+ ),
2155
+ subagents_enabled=False,
2156
+ enforce_explicit_subagent_requests=False,
2157
+ mcp_manager=task_mcp_manager,
2158
+ session_source_metadata={
2159
+ "surface": "forge_exec",
2160
+ "run_id": paths.run_id,
2161
+ "task_id": str(task_id),
2162
+ },
2163
+ )
2164
+ except Exception as e: # noqa: BLE001
2165
+ run_code = 1
2166
+ run_err = str(e)
2167
+ if not instruction:
2168
+ _write_execution_context_artifact(
2169
+ paths=paths,
2170
+ task_id=task_id,
2171
+ context_text=(
2172
+ "# Task Context Pack\n\n"
2173
+ "Task execution setup failed before the agent started.\n\n"
2174
+ f"- Error: {run_err}\n"
2175
+ ),
2176
+ )
2177
+ if not budget_artifact_path.exists():
2178
+ _write_execution_budget_artifact(
2179
+ paths=paths,
2180
+ task_id=task_id,
2181
+ payload={
2182
+ "error": run_err,
2183
+ "step_budget": task_step_budget.to_payload(),
2184
+ },
2185
+ )
2186
+ if before_runtime_snapshot is None:
2187
+ before_runtime_snapshot = _snapshot_runtime_tree(paths.root)
2188
+ if reporting_baseline is None:
2189
+ reporting_baseline = _capture_task_local_workspace_baseline(
2190
+ paths.root,
2191
+ before_commit=head_before_run,
2192
+ )
2193
+ finally:
2194
+ if task_mcp_manager is not None:
2195
+ task_mcp_manager.close()
2196
+
2197
+ assert before_runtime_snapshot is not None
2198
+ assert reporting_baseline is not None
2199
+ after_runtime_snapshot = _snapshot_runtime_tree(paths.root)
2200
+ runtime_artifact_changes = _runtime_snapshot_changed_files(
2201
+ before_runtime_snapshot,
2202
+ after_runtime_snapshot,
2203
+ )
2204
+ authorized_runtime_side_effects = _authorized_custom_tool_runtime_side_effects(
2205
+ sessions_dir=runtime_sessions_dir,
2206
+ session_id=runtime_session_id,
2207
+ )
2208
+ runtime_artifact_changes = [
2209
+ path for path in runtime_artifact_changes if path not in authorized_runtime_side_effects
2210
+ ]
2211
+ runtime_artifacts_changed = bool(runtime_artifact_changes)
2212
+ if asset_allocation is not None:
2213
+ write_task_asset_allocation(
2214
+ run_paths=paths,
2215
+ allocation=asset_allocation,
2216
+ started_at=started_at,
2217
+ )
2218
+ asset_usage_logger.summary(
2219
+ primary_count=len(task_asset_mirror.primary),
2220
+ may_need_count=len(task_asset_mirror.may_need),
2221
+ pinned_count=len(task_asset_mirror.pinned),
2222
+ )
2223
+ try:
2224
+ exec_artifacts = _write_exec_log_artifacts(
2225
+ paths=paths,
2226
+ task_id=task_id,
2227
+ cfg=run_cfg,
2228
+ no_log=no_log,
2229
+ before_logs=None,
2230
+ sessions_dir=runtime_sessions_dir,
2231
+ expected_session_id=runtime_session_id,
2232
+ )
2233
+ finally:
2234
+ _cleanup_execution_private_sessions_dir(runtime_sessions_dir)
2235
+
2236
+ safe_task_component = _safe_task_file_component(task_id)
2237
+ patch_path = paths.execution_patches_dir / f"{safe_task_component}.diff"
2238
+ scratch_artifact_dir = paths.execution_dir / "scratch" / safe_task_component
2239
+ scratch_artifact_dir.mkdir(parents=True, exist_ok=True)
2240
+ success = run_code == 0 and not runtime_artifacts_changed
2241
+ pr_report_state_upgraded = False
2242
+ head_after_run = head_commit(paths.root) if paths.has_head_commit else None
2243
+ # Captured before cleanup so scope triage can tell a file the task created from an
2244
+ # existing neighbour it merely edited.
2245
+ baseline_workspace_paths = set(reporting_baseline.before_snapshot)
2246
+ try:
2247
+ report_diff = _build_task_local_workspace_reporting_diff(
2248
+ paths.root,
2249
+ baseline=reporting_baseline,
2250
+ after_commit=head_after_run,
2251
+ )
2252
+ finally:
2253
+ _cleanup_task_local_workspace_baseline(reporting_baseline)
2254
+ patch_path.write_text(report_diff.patch_text, encoding="utf-8")
2255
+ scratch_scope_diagnostics = relocate_known_scratch_artifacts(
2256
+ root=paths.root,
2257
+ artifact_dir=scratch_artifact_dir,
2258
+ )
2259
+ relocated_scratch_paths = {item.path for item in scratch_scope_diagnostics}
2260
+ changed_files = list(report_diff.changed_files)
2261
+ if relocated_scratch_paths:
2262
+ changed_files = [path for path in changed_files if path not in relocated_scratch_paths]
2263
+ agent_added_non_material_paths: list[str] = []
2264
+ if head_before_run and head_after_run and head_after_run != head_before_run:
2265
+ agent_added_non_material_paths = [
2266
+ path
2267
+ for path in added_files_since(
2268
+ paths.root,
2269
+ before_commit=head_before_run,
2270
+ after_commit=head_after_run,
2271
+ )
2272
+ if is_non_material_untracked_path(path)
2273
+ ]
2274
+ if agent_added_non_material_paths:
2275
+ changed_files = [
2276
+ path for path in changed_files if path not in agent_added_non_material_paths
2277
+ ]
2278
+ pr_material_changed_files = (
2279
+ _drop_parent_directory_placeholders(
2280
+ _merge_changed_files(
2281
+ list(changed_files),
2282
+ list_changed_files_including_untracked(paths.root),
2283
+ )
2284
+ )
2285
+ if pr
2286
+ else list(changed_files)
2287
+ )
2288
+ scope_changed_files = pr_material_changed_files if pr else changed_files
2289
+ scope_inspection_error = report_diff.inspection_error
2290
+ scope_violation_files: list[str] = []
2291
+ scope_diagnostics = [item.to_payload() for item in scratch_scope_diagnostics]
2292
+ for diagnostic in scratch_scope_diagnostics:
2293
+ scope_warnings.append(
2294
+ "Scope recovery: "
2295
+ f"{diagnostic.classification} for {diagnostic.path} "
2296
+ f"({diagnostic.reason_code}; action={diagnostic.recommended_action})."
2297
+ )
2298
+ material_changes_detected = bool(pr_material_changed_files)
2299
+ nonzero_agent_exit = run_code != 0 and run_err is None
2300
+ scope_amendment_payloads: list[dict[str, Any]] = []
2301
+ scope_amended_patterns: list[str] = []
2302
+ scope_in_scope_changes = False
2303
+ adjacent_only_changes = False
2304
+ strict_scope_blocked = False
2305
+ can_attempt_pr_flow = False
2306
+ pr_nonzero_salvage_allowed = False
2307
+ pr_nonzero_salvage_attempted = False
2308
+ no_material_changes_blocked = False
2309
+ result_kind: str | None = None
2310
+ noop_reason: str | None = None
2311
+ analysis_only_noop_accepted = False
2312
+ if scope_mode in {"warn", "strict"}:
2313
+ if scope_inspection_error:
2314
+ if scope_mode == "strict":
2315
+ strict_scope_blocked = True
2316
+ success = False
2317
+ run_err = (run_err + "; " if run_err else "") + scope_inspection_error
2318
+ else:
2319
+ scope_warnings.append(scope_inspection_error)
2320
+ # Adjacent changes are triaged into scope amendments only in strict mode: warn
2321
+ # mode has nothing to unblock, so amending there would silently rewrite the plan
2322
+ # for a mode whose whole contract is "report, change nothing".
2323
+ scope_assessment = assess_scope_changes(
2324
+ scope_changed_files,
2325
+ allowed_scope,
2326
+ task=task,
2327
+ root=paths.root,
2328
+ extra_diagnostics=scratch_scope_diagnostics,
2329
+ amend_adjacent=scope_mode == "strict",
2330
+ new_paths=[
2331
+ path
2332
+ for path in scope_changed_files
2333
+ if _normalize_changed_file_path(path) not in baseline_workspace_paths
2334
+ ],
2335
+ )
2336
+ scope_changed_files = list(scope_assessment.effective_changed_files)
2337
+ scope_diagnostics = [item.to_payload() for item in scope_assessment.diagnostics]
2338
+ scope_in_scope_changes = bool(scope_assessment.in_scope_paths)
2339
+ for diagnostic in scope_assessment.diagnostics:
2340
+ if diagnostic.allowed:
2341
+ warning = (
2342
+ "Scope recovery: "
2343
+ f"{diagnostic.classification} for {diagnostic.path} "
2344
+ f"({diagnostic.reason_code}; action={diagnostic.recommended_action})."
2345
+ )
2346
+ if warning not in scope_warnings:
2347
+ scope_warnings.append(warning)
2348
+ if scope_assessment.amendments:
2349
+ scope_amendment_payloads = [item.to_payload() for item in scope_assessment.amendments]
2350
+ scope_amended_patterns = apply_scope_amendments(
2351
+ task,
2352
+ scope_assessment.amendments,
2353
+ )
2354
+ adjacent_only_changes = not scope_in_scope_changes
2355
+ amendment_preview = ", ".join(
2356
+ f"{item.path} ({item.reason_code})" for item in scope_assessment.amendments[:20]
2357
+ )
2358
+ if len(scope_assessment.amendments) > 20:
2359
+ amendment_preview += ", ..."
2360
+ scope_warnings.append(
2361
+ f"Scope amended: {len(scope_assessment.amendments)} adjacent change(s) "
2362
+ f"accepted and added to write_scope: {amendment_preview}."
2363
+ )
2364
+ events.emit(
2365
+ EVENT_SCOPE_AMENDED,
2366
+ {
2367
+ "task_id": task_id,
2368
+ "scope_mode": scope_mode,
2369
+ "allowed_scope": list(allowed_scope),
2370
+ "added_patterns": list(scope_amended_patterns),
2371
+ "amendments": scope_amendment_payloads,
2372
+ "adjacent_paths": list(scope_assessment.adjacent_paths),
2373
+ "adjacent_only": bool(adjacent_only_changes),
2374
+ },
2375
+ )
2376
+ if not scope_assessment.ok:
2377
+ violations = scope_assessment.blocking_paths
2378
+ scope_violation_files = list(violations)
2379
+ preview = ", ".join(violations[:20])
2380
+ if len(violations) > 20:
2381
+ preview += ", ..."
2382
+ classes = sorted(
2383
+ {
2384
+ str(item.get("classification") or "unknown")
2385
+ for item in scope_diagnostics
2386
+ if not bool(item.get("allowed"))
2387
+ }
2388
+ )
2389
+ scope_msg = (
2390
+ f"Out-of-scope file changes detected ({len(violations)}): {preview}. "
2391
+ f"Allowed scope: {allowed_scope or ['(none)']}."
2392
+ )
2393
+ if classes:
2394
+ scope_msg += f" Scope classifications: {', '.join(classes)}."
2395
+ # The full triage plus a ready-to-paste scope patch, so a human or the
2396
+ # replanner can fix the plan in one step instead of re-deriving it.
2397
+ violation_lines = describe_scope_violations(scope_assessment.diagnostics)
2398
+ if violation_lines:
2399
+ scope_msg += " Classified changes: " + " | ".join(violation_lines) + "."
2400
+ if scope_assessment.protected_paths:
2401
+ scope_msg += (
2402
+ " Protected paths (never amendable): "
2403
+ + ", ".join(scope_assessment.protected_paths)
2404
+ + "."
2405
+ )
2406
+ if scope_assessment.suggested_scope_patterns:
2407
+ scope_msg += (
2408
+ " Suggested write_scope additions: "
2409
+ + ", ".join(scope_assessment.suggested_scope_patterns)
2410
+ + "."
2411
+ )
2412
+ if scope_mode == "strict":
2413
+ strict_scope_blocked = True
2414
+ success = False
2415
+ run_err = (
2416
+ (run_err + "; " if run_err else "")
2417
+ + scope_msg
2418
+ + " Task was blocked due to strict scope isolation."
2419
+ )
2420
+ else:
2421
+ scope_warnings.append(scope_msg)
2422
+
2423
+ if pr and not runtime_artifacts_changed and not material_changes_detected:
2424
+ success = False
2425
+
2426
+ if run_code == 0 and not runtime_artifacts_changed and not material_changes_detected:
2427
+ if _task_is_analysis_only(task):
2428
+ if pr:
2429
+ if not pr_base_branch:
2430
+ success = False
2431
+ run_err = (run_err + "; " if run_err else "") + (
2432
+ "missing PR base branch context for analysis-only no-op cleanup"
2433
+ )
2434
+ merge_result = "not merged: analysis-only no-op cleanup failed"
2435
+ else:
2436
+ try:
2437
+ checkout_branch(
2438
+ paths.root,
2439
+ pr_base_branch,
2440
+ base_branch=pr_base_branch,
2441
+ )
2442
+ if keep_branch or not pr_task_branch or pr_task_branch == pr_base_branch:
2443
+ merge_result = "no merge required (analysis-only no-op; branch kept)"
2444
+ else:
2445
+ try:
2446
+ delete_branch(paths.root, pr_task_branch)
2447
+ merge_result = (
2448
+ "no merge required (analysis-only no-op; branch deleted)"
2449
+ )
2450
+ except GitOpsError as cleanup_err:
2451
+ scope_warnings.append(
2452
+ "Branch cleanup warning: "
2453
+ f"failed to delete {pr_task_branch}: {cleanup_err}"
2454
+ )
2455
+ merge_result = (
2456
+ "no merge required (analysis-only no-op; "
2457
+ f"branch delete warning: {cleanup_err})"
2458
+ )
2459
+ success = True
2460
+ except GitOpsError as e:
2461
+ success = False
2462
+ run_err = (run_err + "; " if run_err else "") + (
2463
+ f"PR no-op cleanup failed: {e}"
2464
+ )
2465
+ merge_result = f"not merged: analysis-only no-op cleanup failed: {e}"
2466
+ else:
2467
+ success = True
2468
+ if success:
2469
+ result_kind = "success_noop"
2470
+ noop_reason = "analysis_only"
2471
+ analysis_only_noop_accepted = True
2472
+ verify_summary = "verification skipped: analysis-only task made no changes"
2473
+ _emit_verification_unavailable(
2474
+ events,
2475
+ task_id=task_id,
2476
+ policy=verify_mode,
2477
+ reason=verify_summary,
2478
+ blocking=False,
2479
+ )
2480
+ elif _task_declares_explicit_write_scope(task):
2481
+ no_material_changes_blocked = True
2482
+ success = False
2483
+ run_err = (run_err + "; " if run_err else "") + (
2484
+ "No material file changes were detected for a task with explicit write_scope. "
2485
+ "The task produced no file changes at all -- not in the declared scope and "
2486
+ "not adjacent to it -- so its expected local file update was not produced."
2487
+ )
2488
+ elif (
2489
+ run_code == 0
2490
+ and not runtime_artifacts_changed
2491
+ and adjacent_only_changes
2492
+ and not strict_scope_blocked
2493
+ ):
2494
+ # No declared scope path changed, but the task did do work: every change was
2495
+ # adjacent and has been amended into write_scope. That is a pass with an
2496
+ # amendment, not the "task did nothing" rejection above.
2497
+ result_kind = result_kind or "success_scope_amended"
2498
+ scope_warnings.append(
2499
+ "No declared write_scope path changed; every change was adjacent to the "
2500
+ "declared scope and write_scope was amended to cover it."
2501
+ )
2502
+
2503
+ pr_nonzero_salvage_allowed = (
2504
+ pr
2505
+ and nonzero_agent_exit
2506
+ and verify_mode == "strict"
2507
+ and bool(verify_commands)
2508
+ and not runtime_artifacts_changed
2509
+ and material_changes_detected
2510
+ and not strict_scope_blocked
2511
+ )
2512
+
2513
+ if nonzero_agent_exit and pr_nonzero_salvage_allowed:
2514
+ pr_nonzero_salvage_attempted = True
2515
+ scope_warnings.append(
2516
+ f"PR flow attempted to salvage a non-zero agent exit ({run_code}); "
2517
+ "acceptance requires strict verification and PR gates."
2518
+ )
2519
+ elif nonzero_agent_exit:
2520
+ success = False
2521
+ run_err = (run_err + "; " if run_err else "") + (
2522
+ f"agent exited non-zero ({run_code}); refusing to accept partial task result"
2523
+ )
2524
+
2525
+ can_attempt_pr_flow = (
2526
+ pr
2527
+ and not runtime_artifacts_changed
2528
+ and material_changes_detected
2529
+ and not strict_scope_blocked
2530
+ and (run_code == 0 or pr_nonzero_salvage_allowed)
2531
+ )
2532
+
2533
+ if can_attempt_pr_flow:
2534
+ success = True
2535
+ if not pr_base_branch or not pr_task_branch:
2536
+ success = False
2537
+ run_err = (run_err + "; " if run_err else "") + "missing PR branch context"
2538
+ merge_result = "not merged"
2539
+ else:
2540
+ try:
2541
+ non_material_untracked_paths = list_untracked_packaging_metadata_paths(paths.root)
2542
+ stage_all(paths.root)
2543
+ unstage_staged_prefixes(
2544
+ paths.root,
2545
+ [".alysis", ".alysis_images", "alysis-feedback"],
2546
+ )
2547
+ ensure_not_staged_prefixes(
2548
+ paths.root,
2549
+ [".alysis", ".alysis_images", "alysis-feedback"],
2550
+ )
2551
+ if non_material_untracked_paths:
2552
+ unstage_staged_paths(paths.root, non_material_untracked_paths)
2553
+ ensure_not_staged_paths(paths.root, non_material_untracked_paths)
2554
+ if has_grounded_rust_target_runtime_artifacts(paths.root):
2555
+ staged_now = staged_files(paths.root)
2556
+ unstage_staged_runtime_artifacts(
2557
+ paths.root,
2558
+ current_paths=staged_now,
2559
+ )
2560
+ staged_now = staged_files(paths.root)
2561
+ ensure_not_staged_runtime_artifacts(
2562
+ paths.root,
2563
+ current_paths=staged_now,
2564
+ )
2565
+ commit_title = str(task.get("title") or "").strip() or "task update"
2566
+ commit_hash = commit_all(
2567
+ paths.root,
2568
+ message=f"{task_id}: {commit_title}",
2569
+ )
2570
+ patch_text = format_patch_stdout(paths.root, base_branch=pr_base_branch)
2571
+ patch_path.write_text(
2572
+ patch_text if patch_text else "(empty format-patch output)\n",
2573
+ encoding="utf-8",
2574
+ )
2575
+ changed_files = changed_files_between(
2576
+ paths.root,
2577
+ revspec=f"{pr_base_branch}..HEAD",
2578
+ )
2579
+ pr_report_state_upgraded = True
2580
+ except GitOpsError as e:
2581
+ success = False
2582
+ run_err = (run_err + "; " if run_err else "") + f"PR flow failed: {e}"
2583
+ merge_result = f"not merged: {e}"
2584
+
2585
+ if success and remote_settings.enabled:
2586
+ if not pr_task_branch:
2587
+ success = False
2588
+ run_err = (
2589
+ run_err + "; " if run_err else ""
2590
+ ) + "missing task branch for remote sync"
2591
+ merge_result = "not merged: remote sync branch context missing"
2592
+ else:
2593
+ remote_name = remote_settings.remote_name
2594
+ provider = "unknown"
2595
+ remote_record = init_remote_record(
2596
+ task_id=task_id,
2597
+ remote=remote_name,
2598
+ provider=provider,
2599
+ )
2600
+ remote_errors = remote_record["errors"]
2601
+ assert isinstance(remote_errors, list)
2602
+ try:
2603
+ remote_url = get_remote_url(paths.root, remote_name)
2604
+ provider = resolve_provider(
2605
+ settings_provider=remote_settings.provider,
2606
+ remote_url=remote_url,
2607
+ )
2608
+ remote_record["provider"] = provider
2609
+ except RemoteSyncError as e:
2610
+ msg = f"remote discovery failed: {e}"
2611
+ remote_errors.append(msg)
2612
+ if remote_settings.strict:
2613
+ success = False
2614
+ run_err = (run_err + "; " if run_err else "") + msg
2615
+ merge_result = f"not merged: {msg}"
2616
+ else:
2617
+ scope_warnings.append(msg)
2618
+
2619
+ if success:
2620
+ pushed_branch, branch_output = push_branch(
2621
+ paths.root,
2622
+ remote=remote_name,
2623
+ branch=pr_task_branch,
2624
+ )
2625
+ remote_record["pushed_branch"] = pushed_branch
2626
+ remote_record["branch_push_output"] = truncate_output(branch_output)
2627
+ if not pushed_branch:
2628
+ msg = f"remote branch push failed: {branch_output or 'unknown error'}"
2629
+ remote_errors.append(msg)
2630
+ if remote_settings.strict:
2631
+ success = False
2632
+ run_err = (run_err + "; " if run_err else "") + msg
2633
+ merge_result = f"not merged: {msg}"
2634
+ else:
2635
+ scope_warnings.append(msg)
2636
+
2637
+ if success and remote_settings.create_pr and remote_record is not None:
2638
+ created_pr, pr_url, pr_id, pr_output = ensure_pr_or_mr(
2639
+ paths.root,
2640
+ provider=str(remote_record.get("provider") or "unknown"),
2641
+ base_branch=pr_base_branch,
2642
+ head_branch=pr_task_branch,
2643
+ title=(
2644
+ f"{task_id}: "
2645
+ f"{str(task.get('title') or '').strip() or 'task update'}"
2646
+ ),
2647
+ body=instruction[:4000],
2648
+ )
2649
+ remote_record["created_pr"] = created_pr
2650
+ remote_record["pr_url"] = pr_url
2651
+ remote_record["pr_number_or_iid"] = pr_id
2652
+ remote_record["pr_output"] = truncate_output(pr_output)
2653
+ if created_pr and pr_url:
2654
+ task["remote_pr_url"] = pr_url
2655
+ task["remote_provider"] = str(
2656
+ remote_record.get("provider") or "unknown"
2657
+ )
2658
+ save_plan(paths, plan)
2659
+ if not created_pr:
2660
+ msg = f"remote PR/MR creation failed: {pr_output or 'unknown error'}"
2661
+ remote_errors.append(msg)
2662
+ if remote_settings.strict:
2663
+ success = False
2664
+ run_err = (run_err + "; " if run_err else "") + msg
2665
+ merge_result = f"not merged: {msg}"
2666
+ else:
2667
+ scope_warnings.append(msg)
2668
+
2669
+ if remote_record is not None:
2670
+ write_remote_record(
2671
+ execution_dir=paths.execution_dir,
2672
+ task_id=task_id,
2673
+ record=remote_record,
2674
+ )
2675
+
2676
+ def _run_repair_agent(repair_instruction: str, attempt: int) -> int:
2677
+ """Re-run the executing agent on this task with a repair prompt.
2678
+
2679
+ Same machinery as the original invocation -- same config, mode,
2680
+ write guard, budget and MCP scope -- only the instruction and the
2681
+ session id differ, so a repair is not a weaker kind of run.
2682
+ """
2683
+ repair_session_id = f"{runtime_session_id}-repair{attempt}"
2684
+ repair_sessions_dir = _execution_private_sessions_dir(
2685
+ cfg=run_cfg,
2686
+ run_id=paths.run_id,
2687
+ task_id=f"{task_id}-repair{attempt}",
2688
+ workspace_root=paths.root,
2689
+ )
2690
+ _cleanup_execution_private_sessions_dir(repair_sessions_dir)
2691
+ repair_mcp_manager: Any | None = None
2692
+ try:
2693
+ repair_mcp_manager = _build_forge_task_scoped_mcp_manager(
2694
+ workspace_root=paths.root,
2695
+ session_id=repair_session_id,
2696
+ task_scope=task_mcp_scope,
2697
+ )
2698
+ if asset_surface is not None and has_mirrored_task_assets:
2699
+ repair_mcp_manager = compose_worker_asset_mcp_manager(
2700
+ base_manager=repair_mcp_manager,
2701
+ asset_manager=build_worker_asset_mcp_manager(
2702
+ cfg=run_cfg,
2703
+ surface=asset_surface,
2704
+ model_registry=asset_model_registry,
2705
+ mirror=task_asset_mirror,
2706
+ usage_logger=asset_usage_logger,
2707
+ api_key=api_key_override,
2708
+ ),
2709
+ )
2710
+ return run_agent(
2711
+ cfg=run_cfg,
2712
+ root=paths.root,
2713
+ instruction=repair_instruction,
2714
+ mode=effective_mode,
2715
+ runtime_kind=RuntimeKind.FORGE_EXEC,
2716
+ yes=yes,
2717
+ max_steps=task_step_budget.resolved_max_steps,
2718
+ no_log=no_log,
2719
+ api_key_override=api_key_override,
2720
+ console=console,
2721
+ surface=recording_surface,
2722
+ image_paths=task_image_paths,
2723
+ deny_write_prefixes=[".alysis"],
2724
+ allow_write_globs=allowed_scope if scope_mode == "strict" else None,
2725
+ non_interactive=run_non_interactive,
2726
+ session_log_dir_override=repair_sessions_dir,
2727
+ session_id_override=repair_session_id,
2728
+ usage_role=f"forge_exec:{task_id}:repair{attempt}",
2729
+ enable_compaction=False,
2730
+ enable_tool_output_offload=True,
2731
+ enable_conversation_summarization=True,
2732
+ compaction_profile="execution",
2733
+ enable_chat_turn_step_budget=False,
2734
+ one_shot_execution=True,
2735
+ verification_enabled=prompt_verification_enabled,
2736
+ authoritative_verification_commands=(
2737
+ verify_commands if prompt_verification_enabled else None
2738
+ ),
2739
+ subagents_enabled=False,
2740
+ enforce_explicit_subagent_requests=False,
2741
+ mcp_manager=repair_mcp_manager,
2742
+ session_source_metadata={
2743
+ "surface": "forge_exec",
2744
+ "run_id": paths.run_id,
2745
+ "task_id": str(task_id),
2746
+ "verification_repair_attempt": int(attempt),
2747
+ },
2748
+ )
2749
+ finally:
2750
+ if repair_mcp_manager is not None:
2751
+ repair_mcp_manager.close()
2752
+ _cleanup_execution_private_sessions_dir(repair_sessions_dir)
2753
+
2754
+ if success and verify_mode != "off" and verify_commands:
2755
+ verify_mutation_paths: list[str] = []
2756
+ verify_mutation_patch_sections: list[str] = []
2757
+
2758
+ def _verification_pass(pass_label: str) -> Any:
2759
+ """Run the gate once, recording anything it changed on disk.
2760
+
2761
+ Each pass is snapshotted separately so a repair attempt's own
2762
+ (committed) edits between passes are never mistaken for a
2763
+ verification command mutating the repository.
2764
+ """
2765
+ before_verify_snapshot = _snapshot_workspace_tree(paths.root)
2766
+ result = run_task_verification(
2767
+ root=paths.root,
2768
+ commands=verify_commands,
2769
+ artifact_path=verify_path,
2770
+ cfg=effective,
2771
+ )
2772
+ events.emit(
2773
+ EVENT_VERIFICATION_RESULT,
2774
+ {
2775
+ "scope": "task",
2776
+ "task_id": task_id,
2777
+ "passed": bool(result.all_passed),
2778
+ "policy": verify_mode,
2779
+ "pass": pass_label,
2780
+ "summary": result.summary,
2781
+ "failure_category": result.failure_category_value,
2782
+ "commands": list(verify_commands),
2783
+ "command_source": verify_command_source,
2784
+ "artifact": os.fspath(verify_path),
2785
+ "result": verify_run_result_to_payload(
2786
+ root=paths.root,
2787
+ result=result,
2788
+ ),
2789
+ },
2790
+ )
2791
+ after_verify_snapshot = _snapshot_workspace_tree(paths.root)
2792
+ pass_diff = _build_workspace_snapshot_reporting_diff(
2793
+ paths.root,
2794
+ before_snapshot=before_verify_snapshot,
2795
+ after_snapshot=after_verify_snapshot,
2796
+ )
2797
+ for path in pass_diff.changed_files:
2798
+ if path not in verify_mutation_paths:
2799
+ verify_mutation_paths.append(path)
2800
+ if pass_diff.patch_text:
2801
+ verify_mutation_patch_sections.append(pass_diff.patch_text)
2802
+ return result
2803
+
2804
+ verify_result = _verification_pass("initial")
2805
+
2806
+ # A failing gate is a signal about the work, not a verdict on it:
2807
+ # hand the failing output back to the same agent and let it fix
2808
+ # what it broke before the task is called failed. Only in strict
2809
+ # mode, because that is the only mode where the failure blocks.
2810
+ repair_outcome = None
2811
+ if (
2812
+ not verify_result.all_passed
2813
+ and verify_mode == "strict"
2814
+ and verify_repair_budget > 0
2815
+ ):
2816
+ repair_outcome = run_verification_repair_loop(
2817
+ initial_result=verify_result,
2818
+ max_attempts=verify_repair_budget,
2819
+ repairable=lambda result: (
2820
+ result.failure_category_value != FailureCategory.INFRA_UNAVAILABLE.value
2821
+ ),
2822
+ attempt_repair=lambda attempt, failing: _attempt_verification_repair(
2823
+ attempt=attempt,
2824
+ failing_result=failing,
2825
+ max_attempts=verify_repair_budget,
2826
+ task_id=task_id,
2827
+ base_instruction=instruction,
2828
+ verify_artifact_path=verify_path,
2829
+ run_verification=lambda: _verification_pass(f"repair.{attempt}"),
2830
+ run_repair_agent=_run_repair_agent,
2831
+ root=paths.root,
2832
+ commit_message=(f"{task_id}: verification repair attempt {attempt}"),
2833
+ ),
2834
+ )
2835
+ verify_result = repair_outcome.final_result
2836
+ verification_repair_payload = repair_outcome.to_payload()
2837
+ for line in repair_outcome.report_lines():
2838
+ scope_warnings.append(line)
2839
+ if repair_outcome.attempts:
2840
+ try:
2841
+ patch_text = format_patch_stdout(
2842
+ paths.root,
2843
+ base_branch=pr_base_branch,
2844
+ )
2845
+ patch_path.write_text(
2846
+ patch_text if patch_text else "(empty format-patch output)\n",
2847
+ encoding="utf-8",
2848
+ )
2849
+ changed_files = changed_files_between(
2850
+ paths.root,
2851
+ revspec=f"{pr_base_branch}..HEAD",
2852
+ )
2853
+ except GitOpsError as e:
2854
+ scope_warnings.append(
2855
+ f"Could not refresh the patch after verification repair: {e}"
2856
+ )
2857
+
2858
+ verify_summary = verify_result.summary
2859
+ verify_payload = verify_run_result_to_payload(
2860
+ root=paths.root,
2861
+ result=verify_result,
2862
+ )
2863
+ if repair_outcome is not None and repair_outcome.attempts:
2864
+ verify_summary = (
2865
+ f"{verify_summary} "
2866
+ f"(after {repair_outcome.attempts_used} repair attempt"
2867
+ f"{'s' if repair_outcome.attempts_used != 1 else ''})"
2868
+ )
2869
+ verify_mutation_diff_text = "\n".join(verify_mutation_patch_sections)
2870
+ if verify_mutation_paths:
2871
+ preview = ", ".join(verify_mutation_paths[:20])
2872
+ if len(verify_mutation_paths) > 20:
2873
+ preview += ", ..."
2874
+ verify_mutation_msg = (
2875
+ "Verification commands modified repository state after the task commit "
2876
+ f"({len(verify_mutation_paths)}): {preview}."
2877
+ )
2878
+ if scope_mode in {"warn", "strict"}:
2879
+ scope_assessment = assess_scope_changes(
2880
+ verify_mutation_paths,
2881
+ allowed_scope,
2882
+ task=task,
2883
+ root=paths.root,
2884
+ )
2885
+ scope_diagnostics.extend(
2886
+ item.to_payload() for item in scope_assessment.diagnostics
2887
+ )
2888
+ if not scope_assessment.ok:
2889
+ verify_scope_violations = scope_assessment.blocking_paths
2890
+ scope_violation_files = _merge_changed_files(
2891
+ scope_violation_files,
2892
+ verify_scope_violations,
2893
+ )
2894
+ classes = sorted(
2895
+ {
2896
+ item.classification
2897
+ for item in scope_assessment.diagnostics
2898
+ if not item.allowed
2899
+ }
2900
+ )
2901
+ scope_msg = (
2902
+ f"Out-of-scope file changes detected ({len(verify_scope_violations)}): "
2903
+ f"{', '.join(verify_scope_violations[:20])}"
2904
+ )
2905
+ if len(verify_scope_violations) > 20:
2906
+ scope_msg += ", ..."
2907
+ scope_msg += (
2908
+ f". Allowed scope: {allowed_scope or ['(none)']}."
2909
+ " Task was blocked due to strict scope isolation."
2910
+ " Verification commands modified repository state after the task commit."
2911
+ )
2912
+ if classes:
2913
+ scope_msg += f" Scope classifications: {', '.join(classes)}."
2914
+ if scope_mode == "strict":
2915
+ success = False
2916
+ commit_hash = None
2917
+ merge_result = "not merged: strict scope isolation blocked verification-time writes"
2918
+ run_err = (run_err + "; " if run_err else "") + scope_msg
2919
+ changed_files = _merge_changed_files(
2920
+ changed_files,
2921
+ verify_mutation_paths,
2922
+ )
2923
+ _append_patch_debug_section(
2924
+ patch_path,
2925
+ title="Post-verification workspace diff",
2926
+ patch_text=verify_mutation_diff_text,
2927
+ )
2928
+ else:
2929
+ scope_warnings.append(scope_msg)
2930
+ elif scope_mode == "strict":
2931
+ success = False
2932
+ commit_hash = None
2933
+ merge_result = (
2934
+ "not merged: verification commands modified repository state"
2935
+ )
2936
+ run_err = (run_err + "; " if run_err else "") + verify_mutation_msg
2937
+ changed_files = _merge_changed_files(
2938
+ changed_files, verify_mutation_paths
2939
+ )
2940
+ _append_patch_debug_section(
2941
+ patch_path,
2942
+ title="Post-verification workspace diff",
2943
+ patch_text=verify_mutation_diff_text,
2944
+ )
2945
+ else:
2946
+ scope_warnings.append(verify_mutation_msg)
2947
+ if not verify_result.all_passed and verify_mode == "strict":
2948
+ success = False
2949
+ verify_blocked = True
2950
+ merge_result = "not merged: strict verification failed"
2951
+ run_err = (run_err + "; " if run_err else "") + (
2952
+ f"verification failed: {verify_result.summary}"
2953
+ )
2954
+ elif not verify_result.all_passed:
2955
+ warning_prefix = (
2956
+ "Verification infrastructure warning"
2957
+ if verify_result.failure_category_value
2958
+ == FailureCategory.INFRA_UNAVAILABLE.value
2959
+ else "Verification warning"
2960
+ )
2961
+ scope_warnings.append(f"{warning_prefix}: {verify_result.summary}")
2962
+ elif success and verify_mode == "strict":
2963
+ # The work is committed on the task branch and no authoritative
2964
+ # command exists to check it -- a property of the workspace, not
2965
+ # a defect in the work. Failing here used to discard a completed
2966
+ # task over missing tooling, so the task now completes unverified:
2967
+ # nothing is merged, the branch stays intact for review, and the
2968
+ # honest outcome is recorded instead of a fabricated failure.
2969
+ verification_unavailable_completion = True
2970
+ verify_summary = (
2971
+ "verification skipped: no authoritative commands available; "
2972
+ "task kept as completed_unverified"
2973
+ )
2974
+ merge_result = (
2975
+ "not merged: verification unavailable "
2976
+ f"(branch {pr_task_branch} kept for review)"
2977
+ )
2978
+ scope_warnings.append(
2979
+ "Strict verification found no authoritative command for this task. "
2980
+ "The work was kept and committed, but nothing checked it -- review "
2981
+ "the branch before merging, or provide --verify-cmd."
2982
+ )
2983
+ _emit_verification_unavailable(
2984
+ events,
2985
+ task_id=task_id,
2986
+ policy=verify_mode,
2987
+ reason=verify_summary,
2988
+ blocking=False,
2989
+ outcome=TASK_STATUS_COMPLETED_UNVERIFIED,
2990
+ )
2991
+ elif verify_mode == "strict":
2992
+ # Reached only when the task already failed for another reason;
2993
+ # strict verification simply had nothing to add.
2994
+ success = False
2995
+ verify_blocked = True
2996
+ merge_result = "not merged: strict verification unavailable"
2997
+ run_err = (run_err + "; " if run_err else "") + (
2998
+ "strict verification requires authoritative commands, but none were available"
2999
+ )
3000
+ verify_summary = "verification skipped: no authoritative commands available"
3001
+ _emit_verification_unavailable(
3002
+ events,
3003
+ task_id=task_id,
3004
+ policy=verify_mode,
3005
+ reason=verify_summary,
3006
+ blocking=True,
3007
+ )
3008
+ elif verify_mode != "off":
3009
+ verify_summary = "verification skipped: no authoritative commands available"
3010
+ _emit_verification_unavailable(
3011
+ events,
3012
+ task_id=task_id,
3013
+ policy=verify_mode,
3014
+ reason=verify_summary,
3015
+ blocking=False,
3016
+ )
3017
+ elif verify_mode == "off":
3018
+ verify_summary = "verification disabled (--verify off)"
3019
+ _emit_verification_unavailable(
3020
+ events,
3021
+ task_id=task_id,
3022
+ policy=verify_mode,
3023
+ reason=verify_summary,
3024
+ blocking=False,
3025
+ )
3026
+
3027
+ if success and review:
3028
+ try:
3029
+ review_outcome = review_task(
3030
+ paths=paths,
3031
+ plan=plan,
3032
+ task=task,
3033
+ cfg=effective,
3034
+ api_key_override=api_key_override,
3035
+ verification_payload_override=verify_payload,
3036
+ )
3037
+ events.emit(
3038
+ EVENT_REVIEW_RESULT,
3039
+ {
3040
+ "task_id": task_id,
3041
+ "approved": bool(review_outcome.approved),
3042
+ "confidence": review_outcome.confidence,
3043
+ "summary": review_outcome.summary,
3044
+ "blocking_issues": review_outcome.blocking_issues_count,
3045
+ "non_blocking_issues": review_outcome.non_blocking_issues_count,
3046
+ "review_json": os.fspath(review_outcome.json_path),
3047
+ "review_markdown": os.fspath(review_outcome.markdown_path),
3048
+ },
3049
+ )
3050
+ if not review_outcome.approved:
3051
+ success = False
3052
+ review_blocked = True
3053
+ merge_result = "not merged: review requested changes"
3054
+ except ReviewError as e:
3055
+ success = False
3056
+ run_err = (run_err + "; " if run_err else "") + f"review failed: {e}"
3057
+ merge_result = f"not merged: review failed: {e}"
3058
+ events.emit(
3059
+ EVENT_REVIEW_RESULT,
3060
+ {
3061
+ "task_id": task_id,
3062
+ "approved": False,
3063
+ "available": False,
3064
+ "error": str(e),
3065
+ },
3066
+ )
3067
+
3068
+ if success and verification_unavailable_completion:
3069
+ # Nothing merges without a check behind it. Return the worktree to
3070
+ # the base branch so the run leaves the repository where it found
3071
+ # it, and leave the task branch in place for a human to review.
3072
+ try:
3073
+ checkout_branch(
3074
+ paths.root,
3075
+ pr_base_branch,
3076
+ base_branch=pr_base_branch,
3077
+ )
3078
+ except GitOpsError as e:
3079
+ scope_warnings.append(
3080
+ "Could not return to "
3081
+ f"{pr_base_branch} after an unverified completion: {e}. "
3082
+ f"The work is committed on {pr_task_branch}."
3083
+ )
3084
+ elif success:
3085
+ try:
3086
+ merge_title = str(task.get("title") or "").strip()
3087
+ merge_message = (
3088
+ f"Merge {task_id}: {merge_title}" if merge_title else f"Merge {task_id}"
3089
+ )
3090
+ merge_commit_hash = merge_no_ff(
3091
+ paths.root,
3092
+ base_branch=pr_base_branch,
3093
+ task_branch=pr_task_branch,
3094
+ message=merge_message,
3095
+ )
3096
+ if keep_branch:
3097
+ merge_result = f"merged into {pr_base_branch} (branch kept)"
3098
+ else:
3099
+ try:
3100
+ delete_branch(paths.root, pr_task_branch)
3101
+ merge_result = f"merged into {pr_base_branch} (branch deleted)"
3102
+ except GitOpsError as cleanup_err:
3103
+ scope_warnings.append(
3104
+ "Branch cleanup warning: "
3105
+ f"failed to delete {pr_task_branch}: {cleanup_err}"
3106
+ )
3107
+ merge_result = (
3108
+ f"merged into {pr_base_branch} "
3109
+ f"(branch delete warning: {cleanup_err})"
3110
+ )
3111
+ except GitOpsError as e:
3112
+ success = False
3113
+ run_err = (run_err + "; " if run_err else "") + f"PR flow failed: {e}"
3114
+ unmerged = list_unmerged_files(paths.root)
3115
+ if unmerged and pr_base_branch and pr_task_branch:
3116
+ merge_conflict_detected = True
3117
+ context = capture_merge_conflict_context(
3118
+ paths.root,
3119
+ base_branch=pr_base_branch,
3120
+ task_branch=pr_task_branch,
3121
+ merge_error=str(e),
3122
+ )
3123
+ review_outcome = review_merge_conflict(
3124
+ paths=paths,
3125
+ task=task,
3126
+ cfg=effective,
3127
+ api_key_override=api_key_override,
3128
+ context=context,
3129
+ plan=plan,
3130
+ )
3131
+ cleanup_ok, cleanup_log = try_abort_merge(
3132
+ paths.root,
3133
+ base_branch=pr_base_branch,
3134
+ )
3135
+ conflict_artifacts = write_conflict_artifacts(
3136
+ paths=paths,
3137
+ task_id=task_id,
3138
+ context=context,
3139
+ review_json=review_outcome.review_json,
3140
+ review_md=review_outcome.review_markdown,
3141
+ cleanup_log=cleanup_log,
3142
+ )
3143
+ conflict_review_path = conflict_artifacts.review_md_path
3144
+ merge_result = (
3145
+ f"not merged: conflict while merging {pr_task_branch} into "
3146
+ f"{pr_base_branch}"
3147
+ )
3148
+ if review_outcome.skipped_reason:
3149
+ scope_warnings.append(
3150
+ f"Conflict review note: {review_outcome.skipped_reason}"
3151
+ )
3152
+ if not cleanup_ok:
3153
+ scope_warnings.append(
3154
+ "Merge cleanup warning: repository state may need manual recovery. "
3155
+ f"See {conflict_artifacts.cleanup_log_path}"
3156
+ )
3157
+ if auto_resolve_conflicts and can_attempt_conflict_auto_resolve(
3158
+ task=task,
3159
+ settings=conflict_auto_settings,
3160
+ ):
3161
+ bump_conflict_attempt(task)
3162
+ save_plan(paths, plan)
3163
+ auto_outcome = attempt_auto_resolve_conflict(
3164
+ paths=paths,
3165
+ plan=plan,
3166
+ task=task,
3167
+ cfg=effective,
3168
+ api_key_override=api_key_override,
3169
+ base_branch=pr_base_branch,
3170
+ task_branch=pr_task_branch,
3171
+ keep_worktrees=False,
3172
+ settings=conflict_auto_settings,
3173
+ verify_commands=(verify_commands if verify_mode != "off" else []),
3174
+ )
3175
+ if auto_outcome.success:
3176
+ success = True
3177
+ run_err = None
3178
+ merge_conflict_detected = False
3179
+ merge_commit_hash = auto_outcome.merge_commit_hash
3180
+ merge_result = f"auto-resolved and merged into {pr_base_branch}"
3181
+ if auto_outcome.warnings:
3182
+ scope_warnings.extend(auto_outcome.warnings)
3183
+ conflict_review_path = auto_outcome.report_path
3184
+ else:
3185
+ scope_warnings.append(
3186
+ "Conflict auto-resolve failed: "
3187
+ f"{auto_outcome.error or 'unknown error'}"
3188
+ )
3189
+ else:
3190
+ # The sequential path stops at a conflict instead of
3191
+ # starting a resolver agent in its own worktree. That
3192
+ # is swarm machinery, and reaching for it here turned
3193
+ # "one task did not merge" into a second opaque agent
3194
+ # run. Report the conflict and how to finish it.
3195
+ scope_warnings.extend(
3196
+ _sequential_conflict_report_lines(
3197
+ root=paths.root,
3198
+ task_id=task_id,
3199
+ base_branch=pr_base_branch,
3200
+ task_branch=pr_task_branch,
3201
+ review_path=conflict_review_path,
3202
+ )
3203
+ )
3204
+ else:
3205
+ merge_result = f"not merged: {e}"
3206
+
3207
+ if success and remote_settings.enabled and remote_record is not None and pr_base_branch:
3208
+ pushed_base, base_output = push_base(
3209
+ paths.root,
3210
+ remote=str(remote_record.get("remote") or remote_settings.remote_name),
3211
+ base_branch=pr_base_branch,
3212
+ )
3213
+ remote_record["pushed_base"] = pushed_base
3214
+ remote_record["base_push_output"] = truncate_output(base_output)
3215
+ if not pushed_base:
3216
+ msg = f"remote base push failed: {base_output or 'unknown error'}"
3217
+ raw_errors = remote_record.get("errors")
3218
+ if isinstance(raw_errors, list):
3219
+ raw_errors.append(msg)
3220
+ # Local merge already happened; keep success and record warning.
3221
+ scope_warnings.append(msg)
3222
+ write_remote_record(
3223
+ execution_dir=paths.execution_dir,
3224
+ task_id=task_id,
3225
+ record=remote_record,
3226
+ )
3227
+
3228
+ if pr and merge_result is None:
3229
+ merge_result = "not merged"
3230
+
3231
+ if pr and not pr_report_state_upgraded:
3232
+ recovered_pr_report_state = False
3233
+ if commit_hash is not None and pr_base_branch:
3234
+ try:
3235
+ patch_text = format_patch_stdout(paths.root, base_branch=pr_base_branch)
3236
+ patch_path.write_text(
3237
+ patch_text if patch_text else "(empty format-patch output)\n",
3238
+ encoding="utf-8",
3239
+ )
3240
+ changed_files = changed_files_between(
3241
+ paths.root,
3242
+ revspec=f"{pr_base_branch}..HEAD",
3243
+ )
3244
+ recovered_pr_report_state = True
3245
+ except GitOpsError:
3246
+ recovered_pr_report_state = False
3247
+ if not recovered_pr_report_state:
3248
+ patch_path.write_text(report_diff.patch_text, encoding="utf-8")
3249
+ changed_files = list(report_diff.changed_files)
3250
+
3251
+ if runtime_artifacts_changed:
3252
+ summary = "Task failed: agent modified files under .alysis/ which is not allowed."
3253
+ elif scope_violation_files:
3254
+ summary = "Task blocked due to strict scope isolation."
3255
+ elif no_material_changes_blocked:
3256
+ summary = "Task failed: no material file changes were detected."
3257
+ elif verify_blocked:
3258
+ summary = "Task blocked by strict verification gate."
3259
+ elif review_blocked:
3260
+ summary = "Task blocked by review gate (changes requested)."
3261
+ elif analysis_only_noop_accepted:
3262
+ summary = "Analysis-only task completed successfully with no repository changes."
3263
+ elif success and verification_unavailable_completion:
3264
+ summary = (
3265
+ "Task completed, but nothing verified it: no authoritative verification "
3266
+ f"command exists for this workspace. The work is committed on "
3267
+ f"{pr_task_branch or 'the task branch'} and was deliberately not merged."
3268
+ )
3269
+ elif run_code == 0 and success:
3270
+ summary = "Task execution completed successfully."
3271
+ elif pr and can_attempt_pr_flow and run_code == 0:
3272
+ summary = "Task execution failed during PR flow."
3273
+ else:
3274
+ summary = "Task execution failed."
3275
+ if run_err:
3276
+ summary += f" Error: {run_err}"
3277
+ if scope_warnings:
3278
+ summary += " Warnings: " + " | ".join(scope_warnings)
3279
+ if conflict_review_path is not None:
3280
+ summary += f" Conflict review: {conflict_review_path}"
3281
+
3282
+ finished_at = now_iso()
3283
+ report_verify_commands = verify_commands if pr and verify_mode != "off" else []
3284
+ report_path = write_task_report(
3285
+ paths=paths,
3286
+ task=task,
3287
+ result="success" if success else "failure",
3288
+ result_kind=result_kind,
3289
+ summary=summary,
3290
+ started_at=started_at,
3291
+ finished_at=finished_at,
3292
+ changed_files=changed_files,
3293
+ verify_commands=report_verify_commands,
3294
+ patch_path=patch_path,
3295
+ budget_artifact_path=budget_artifact_path,
3296
+ execution_log_artifacts=exec_artifacts,
3297
+ verify_artifact_path=verify_path if verify_path.exists() else None,
3298
+ verify_summary=verify_summary,
3299
+ verify_payload=verify_payload,
3300
+ verify_command_source=verify_command_source,
3301
+ base_branch=pr_base_branch,
3302
+ task_branch=pr_task_branch,
3303
+ commit_hash=commit_hash,
3304
+ merge_commit_hash=merge_commit_hash,
3305
+ merge_result=merge_result,
3306
+ salvaged_nonzero_exit=bool(pr_nonzero_salvage_attempted and success),
3307
+ noop_reason=noop_reason,
3308
+ remote_lines=_remote_report_lines(remote_record),
3309
+ scope_amendments=scope_amendment_payloads,
3310
+ scope_amended_patterns=scope_amended_patterns,
3311
+ )
3312
+ persisted_capture = persist_execution_knowledge_capture(
3313
+ paths=paths,
3314
+ task=task,
3315
+ source="forge_exec",
3316
+ assistant_message=recording_surface.final_assistant_message,
3317
+ artifact_dir=(
3318
+ paths.execution_knowledge_capture_dir
3319
+ / _safe_task_file_component(task_id)
3320
+ / _safe_task_file_component(started_at)
3321
+ ),
3322
+ report_path=report_path,
3323
+ patch_path=patch_path,
3324
+ verify_artifact_path=verify_path if verify_path.exists() else None,
3325
+ budget_artifact_path=budget_artifact_path,
3326
+ session_artifact_dir=exec_artifacts.session_artifact_dir,
3327
+ )
3328
+ if success and verification_unavailable_completion:
3329
+ # The work is kept, but "validated" would be a lie: nothing ran against it.
3330
+ mark_knowledge_capture_promotion_skipped(
3331
+ artifact_dir=persisted_capture.artifact_dir,
3332
+ reason="task completed without any authoritative verification",
3333
+ )
3334
+ elif success:
3335
+ promote_validated_knowledge_capture(
3336
+ paths=paths,
3337
+ task=task,
3338
+ artifact_dir=persisted_capture.artifact_dir,
3339
+ )
3340
+ else:
3341
+ mark_knowledge_capture_promotion_skipped(
3342
+ artifact_dir=persisted_capture.artifact_dir,
3343
+ reason="task execution outcome was not accepted",
3344
+ )
3345
+
3346
+ write_task_attempt_entry(
3347
+ paths=paths,
3348
+ task=task,
3349
+ source="forge_exec",
3350
+ result="success" if success else "failure",
3351
+ summary=summary,
3352
+ changed_files=changed_files,
3353
+ verify_summary=verify_summary,
3354
+ report_path=report_path,
3355
+ patch_path=patch_path,
3356
+ verify_artifact_path=verify_path if verify_path.exists() else None,
3357
+ budget_artifact_path=budget_artifact_path,
3358
+ session_artifact_dir=exec_artifacts.session_artifact_dir,
3359
+ acceptance_state="accepted" if success else "rejected",
3360
+ extra_tags=[
3361
+ "execution",
3362
+ "sequential",
3363
+ ],
3364
+ )
3365
+ issue_paths = changed_files or list(allowed_scope)
3366
+ if runtime_artifacts_changed:
3367
+ write_issue_entry(
3368
+ paths=paths,
3369
+ task=task,
3370
+ source="forge_exec",
3371
+ title=f"{task_id}: protected .alysis mutation attempt",
3372
+ summary="Engineer execution attempted to modify protected .alysis runtime state.",
3373
+ paths_in_scope=issue_paths,
3374
+ report_path=report_path,
3375
+ patch_path=patch_path,
3376
+ verify_artifact_path=verify_path if verify_path.exists() else None,
3377
+ budget_artifact_path=budget_artifact_path,
3378
+ session_artifact_dir=exec_artifacts.session_artifact_dir,
3379
+ tags=["protected_runtime_mutation"],
3380
+ )
3381
+ elif verify_blocked:
3382
+ write_issue_entry(
3383
+ paths=paths,
3384
+ task=task,
3385
+ source="forge_exec",
3386
+ title=f"{task_id}: verification failed",
3387
+ summary=verify_summary or "Verification blocked task completion.",
3388
+ paths_in_scope=issue_paths,
3389
+ report_path=report_path,
3390
+ patch_path=patch_path,
3391
+ verify_artifact_path=verify_path if verify_path.exists() else None,
3392
+ budget_artifact_path=budget_artifact_path,
3393
+ session_artifact_dir=exec_artifacts.session_artifact_dir,
3394
+ tags=["verification_failure"],
3395
+ )
3396
+ elif review_blocked:
3397
+ write_issue_entry(
3398
+ paths=paths,
3399
+ task=task,
3400
+ source="forge_exec",
3401
+ title=f"{task_id}: review requested changes",
3402
+ summary=summary,
3403
+ paths_in_scope=issue_paths,
3404
+ report_path=report_path,
3405
+ patch_path=patch_path,
3406
+ verify_artifact_path=verify_path if verify_path.exists() else None,
3407
+ budget_artifact_path=budget_artifact_path,
3408
+ session_artifact_dir=exec_artifacts.session_artifact_dir,
3409
+ tags=["review_blocked"],
3410
+ )
3411
+ elif merge_conflict_detected:
3412
+ write_issue_entry(
3413
+ paths=paths,
3414
+ task=task,
3415
+ source="forge_exec",
3416
+ title=f"{task_id}: merge conflict remains unresolved",
3417
+ summary=summary,
3418
+ paths_in_scope=issue_paths,
3419
+ report_path=report_path,
3420
+ patch_path=patch_path,
3421
+ verify_artifact_path=verify_path if verify_path.exists() else None,
3422
+ budget_artifact_path=budget_artifact_path,
3423
+ session_artifact_dir=exec_artifacts.session_artifact_dir,
3424
+ tags=["merge_conflict"],
3425
+ )
3426
+ elif not success:
3427
+ write_issue_entry(
3428
+ paths=paths,
3429
+ task=task,
3430
+ source="forge_exec",
3431
+ title=f"{task_id}: task execution failed",
3432
+ summary=summary,
3433
+ paths_in_scope=issue_paths,
3434
+ report_path=report_path,
3435
+ patch_path=patch_path,
3436
+ verify_artifact_path=verify_path if verify_path.exists() else None,
3437
+ budget_artifact_path=budget_artifact_path,
3438
+ session_artifact_dir=exec_artifacts.session_artifact_dir,
3439
+ tags=(
3440
+ ["execution_failure", "scope_violation"]
3441
+ if scope_violation_files
3442
+ else ["execution_failure"]
3443
+ ),
3444
+ )
3445
+ rebuild_knowledge_index(paths)
3446
+
3447
+ if success and verification_unavailable_completion:
3448
+ status = TASK_STATUS_COMPLETED_UNVERIFIED
3449
+ elif success:
3450
+ status = "done"
3451
+ elif merge_conflict_detected:
3452
+ status = "merge_conflict"
3453
+ elif verify_blocked:
3454
+ status = "verify_failed"
3455
+ elif review_blocked:
3456
+ status = "changes_requested"
3457
+ else:
3458
+ status = "failed"
3459
+ set_task_status(plan, task_id, status)
3460
+ save_plan(paths, plan)
3461
+
3462
+ console.print(f"Task: {task_id} ({task.get('title', '')})")
3463
+ if success and verification_unavailable_completion:
3464
+ console.print("Result: completed_unverified (kept, but nothing verified it)")
3465
+ else:
3466
+ console.print(f"Result: {'success' if success else 'failure'}")
3467
+ console.print(f"Report: {report_path}")
3468
+ console.print(f"Patch: {patch_path}")
3469
+ _print_usage_summary_from_logs(
3470
+ console=console,
3471
+ title=f"Usage Summary ({task_id})",
3472
+ log_paths=([exec_artifacts.log_copy_path] if exec_artifacts.log_retained else []),
3473
+ )
3474
+ if conflict_review_path is not None:
3475
+ console.print(f"Conflict Review: {conflict_review_path}")
3476
+
3477
+ task_payload: dict[str, Any] = {
3478
+ "task_id": task_id,
3479
+ "title": str(task.get("title", "")),
3480
+ "status": status,
3481
+ "source": "exec",
3482
+ "success": bool(success),
3483
+ "report": os.fspath(report_path),
3484
+ "patch": os.fspath(patch_path),
3485
+ "verify_summary": verify_summary,
3486
+ "verify_blocked": bool(verify_blocked),
3487
+ "verification_unavailable": bool(verification_unavailable_completion),
3488
+ "verification_repair": (
3489
+ verification_repair_payload if verification_repair_payload else None
3490
+ ),
3491
+ "review_blocked": bool(review_blocked),
3492
+ "merge_conflict": bool(merge_conflict_detected),
3493
+ "merge_result": merge_result,
3494
+ "branch": pr_task_branch,
3495
+ "base_branch": pr_base_branch,
3496
+ "commit": commit_hash,
3497
+ "merge_commit": merge_commit_hash,
3498
+ "scope_warnings": list(scope_warnings),
3499
+ "scope_amendments": list(scope_amendment_payloads),
3500
+ "scope_amended_patterns": list(scope_amended_patterns),
3501
+ }
3502
+ if conflict_review_path is not None:
3503
+ task_payload["conflict_review"] = os.fspath(conflict_review_path)
3504
+ events.emit(
3505
+ EVENT_TASK_COMPLETED if success else EVENT_TASK_FAILED,
3506
+ task_payload,
3507
+ )
3508
+ # A task that ran and was not accepted is a genuine execution failure, so exit 1
3509
+ # stays. Exit 2 is reserved for the command itself failing, which leaves this
3510
+ # function as ForgeTaskExecutionError rather than as an outcome.
3511
+ return TaskExecutionOutcome(
3512
+ task_id=task_id,
3513
+ title=str(task.get("title", "")),
3514
+ status=status,
3515
+ success=bool(success),
3516
+ exit_code=EXIT_OK if success else EXIT_NOT_ACCEPTED,
3517
+ summary=summary,
3518
+ report_path=report_path,
3519
+ patch_path=patch_path,
3520
+ merge_conflict=bool(merge_conflict_detected),
3521
+ conflict_review_path=conflict_review_path,
3522
+ payload=task_payload,
3523
+ )
3524
+
3525
+
3526
+ def forge_exec(
3527
+ task_id: str = typer.Argument(..., help="Task id from plan.json (for example T01)."),
3528
+ path: Path = typer.Option(
3529
+ Path("."),
3530
+ "--path",
3531
+ help="Workspace path or repository subdirectory.",
3532
+ ),
3533
+ mode: Mode | None = typer.Option(None, "--mode", help="Mode override."),
3534
+ model: str | None = typer.Option(None, "--model", help="Model override."),
3535
+ base_url: str | None = typer.Option(None, "--base-url", help="Base URL override."),
3536
+ temperature: float | None = typer.Option(None, "--temperature", help="Sampling temperature."),
3537
+ stream: bool | None = typer.Option(
3538
+ None,
3539
+ "--stream/--no-stream",
3540
+ help="Enable streamed assistant output.",
3541
+ ),
3542
+ max_steps: int | None = typer.Option(
3543
+ None,
3544
+ "--max-steps",
3545
+ help="Optional safety limit on each managed agent task.",
3546
+ ),
3547
+ no_log: bool = typer.Option(False, "--no-log", help="Disable JSONL session logging."),
3548
+ api_key_env: str | None = typer.Option(
3549
+ None,
3550
+ "--api-key-env",
3551
+ help=(
3552
+ "Read API key from this environment variable (overrides ALYSIS_API_KEY/OPENAI_API_KEY)."
3553
+ ),
3554
+ ),
3555
+ api_key_stdin: bool = typer.Option(
3556
+ False,
3557
+ "--api-key-stdin",
3558
+ help="Prompt for API key (hidden input). Key is kept in memory for this run only.",
3559
+ ),
3560
+ api_key: str | None = typer.Option(
3561
+ None,
3562
+ "--api-key",
3563
+ help=(
3564
+ "UNSAFE: Provide API key via CLI argument (may leak via shell history / process list). "
3565
+ "Prefer --api-key-stdin or --api-key-env."
3566
+ ),
3567
+ ),
3568
+ pr: bool = typer.Option(
3569
+ False,
3570
+ "--pr/--no-pr",
3571
+ help="Run task in PR-like git flow (branch, commit, patch, merge).",
3572
+ ),
3573
+ review: bool = typer.Option(
3574
+ False,
3575
+ "--review",
3576
+ help="Run automated PR review gate before merge (requires --pr).",
3577
+ ),
3578
+ base_branch: str | None = typer.Option(
3579
+ None,
3580
+ "--base-branch",
3581
+ help="Base branch for --pr mode (defaults to current branch).",
3582
+ ),
3583
+ keep_branch: bool = typer.Option(
3584
+ False,
3585
+ "--keep-branch",
3586
+ help="Keep task branch after successful merge in --pr mode.",
3587
+ ),
3588
+ scope: str = typer.Option(
3589
+ "strict",
3590
+ "--scope",
3591
+ help="Write-scope enforcement: strict by default; use warn or off to opt out.",
3592
+ ),
3593
+ verify: str = typer.Option(
3594
+ "warn",
3595
+ "--verify",
3596
+ help="Verification policy for PR flow: off, warn, or strict.",
3597
+ ),
3598
+ verify_cmd: list[str] | None = typer.Option(
3599
+ None,
3600
+ "--verify-cmd",
3601
+ help="Override verify command for this run (repeatable).",
3602
+ ),
3603
+ yes: bool = typer.Option(
3604
+ False,
3605
+ "--yes",
3606
+ help="In auto mode, skip confirmations for sensitive commands (hard blocks still apply).",
3607
+ ),
3608
+ # Appended after ``yes`` on purpose: `forge_exec_impl` forwards these positionally,
3609
+ # so a new parameter inserted mid-list would silently shift every later argument.
3610
+ verify_repair_attempts: int | None = typer.Option(
3611
+ None,
3612
+ "--verify-repair-attempts",
3613
+ help=(
3614
+ "How many times a failing strict verification is fed back to the agent "
3615
+ "to repair before the task fails (default 2; 0 disables)."
3616
+ ),
3617
+ ),
3618
+ auto_resolve_conflicts: bool = typer.Option(
3619
+ False,
3620
+ "--auto-resolve-conflicts",
3621
+ help=(
3622
+ "On merge conflict, start a resolver agent in a dedicated worktree instead "
3623
+ "of stopping with a conflict report. Off by default: the sequential path "
3624
+ "reports the conflict and tells you how to land it."
3625
+ ),
3626
+ ),
3627
+ events: Any = None,
3628
+ ) -> None:
3629
+ events = _events_or_null(events)
3630
+ console = _console()
3631
+ cfg = load_config()
3632
+ effective = clone_cfg(cfg)
3633
+ current_ctx = get_current_context(silent=True)
3634
+ max_steps_source = (
3635
+ current_ctx.get_parameter_source("max_steps") if current_ctx is not None else None
3636
+ )
3637
+ max_steps_provided = max_steps is not None
3638
+ if current_ctx is not None:
3639
+ max_steps_provided = (
3640
+ max_steps_source is not None and max_steps_source is not ParameterSource.DEFAULT
3641
+ )
3642
+ if base_url is not None:
3643
+ effective.base_url = base_url
3644
+ if model is not None:
3645
+ effective.model = model
3646
+ if temperature is not None:
3647
+ _apply_temperature_override(effective, temperature)
3648
+ if stream is not None:
3649
+ effective.stream = stream
3650
+ if max_steps is not None:
3651
+ effective.max_steps = max_steps
3652
+ effective_mode = (mode.value if mode else effective.default_mode) or "review"
3653
+ scope_mode = "strict"
3654
+ verify_mode = "warn"
3655
+ verify_commands: list[str] = []
3656
+ verify_command_source: str | None = None
3657
+ run_cfg = clone_cfg(effective)
3658
+ # Called as a plain function (not through Typer) this arrives as an OptionInfo,
3659
+ # which is neither an int nor None -- normalize before it reaches the budget.
3660
+ repair_attempts_override = (
3661
+ verify_repair_attempts
3662
+ if isinstance(verify_repair_attempts, int) and not isinstance(verify_repair_attempts, bool)
3663
+ else None
3664
+ )
3665
+ verify_repair_budget = resolve_repair_attempt_budget(repair_attempts_override)
3666
+
3667
+ try:
3668
+ scope_mode = _normalize_scope_mode(scope)
3669
+ verify_mode = _normalize_verify_mode(verify)
3670
+ api_key_override = _resolve_api_key_override(
3671
+ api_key=api_key,
3672
+ api_key_env=api_key_env,
3673
+ api_key_stdin=api_key_stdin,
3674
+ )
3675
+ paths = load_current_run_paths(path)
3676
+ events.set_run_id(paths.run_id)
3677
+ plan = load_plan(paths)
3678
+ run_cfg.model = resolve_model_for_role(
3679
+ cfg=effective,
3680
+ role=ROLE_CODING,
3681
+ plan=plan,
3682
+ prefer_context="forge",
3683
+ )
3684
+ except (ConfigError, ForgeError) as e:
3685
+ raise _exec_error_exit(console, events, str(e), task_id=task_id) from e
3686
+
3687
+ task = find_task(plan, task_id)
3688
+ if task is None:
3689
+ message = f"Task not found: {task_id}"
3690
+ events.emit(
3691
+ EVENT_PLAN_INVALID,
3692
+ {"reason": message, "source": "task_lookup", "task_id": task_id},
3693
+ )
3694
+ raise _exec_error_exit(console, events, message, task_id=task_id)
3695
+ task_status = canonical_task_status(str(task.get("status") or ""))
3696
+ if task_status in {"superseded", "invalidated"}:
3697
+ message = (
3698
+ f"Task is non-executable obsolete work ({task_status}): {task_id}. "
3699
+ "Use an active planned replacement task instead."
3700
+ )
3701
+ events.emit(
3702
+ EVENT_PLAN_INVALID,
3703
+ {
3704
+ "reason": message,
3705
+ "source": "task_status",
3706
+ "task_id": task_id,
3707
+ "status": task_status,
3708
+ },
3709
+ )
3710
+ raise _exec_error_exit(console, events, message, task_id=task_id)
3711
+ if verify_mode != "off":
3712
+ verify_selection = resolve_authoritative_task_verify_command_selection(
3713
+ cfg=effective,
3714
+ verify_cmd=verify_cmd,
3715
+ task=task,
3716
+ root=paths.root,
3717
+ plan_requirements=[
3718
+ str(item).strip() for item in (plan.get("requirements") or []) if str(item).strip()
3719
+ ],
3720
+ )
3721
+ verify_commands = list(verify_selection.commands)
3722
+ verify_command_source = verify_selection.source
3723
+ run_cfg.verify_commands = list(verify_commands)
3724
+
3725
+ blockers = _task_dependency_blockers(plan, task)
3726
+ if blockers:
3727
+ message = "Dependencies are not done: " + ", ".join(blockers)
3728
+ events.emit(
3729
+ EVENT_PLAN_INVALID,
3730
+ {
3731
+ "reason": message,
3732
+ "source": "dependencies",
3733
+ "task_id": task_id,
3734
+ "blockers": list(blockers),
3735
+ },
3736
+ )
3737
+ raise _exec_error_exit(console, events, message, task_id=task_id)
3738
+ if review and not pr:
3739
+ raise _exec_error_exit(
3740
+ console,
3741
+ events,
3742
+ "--review requires --pr.",
3743
+ task_id=task_id,
3744
+ kind="usage_error",
3745
+ )
3746
+
3747
+ try:
3748
+ run_mutation_guard = acquire_swarm_mutation_guard(
3749
+ paths,
3750
+ mode=f"forge_exec:{task_id}",
3751
+ on_wait=lambda info: _print_forge_lock_wait_notice(console, info),
3752
+ )
3753
+ except ForgeError as e:
3754
+ raise _exec_error_exit(console, events, str(e), task_id=task_id) from e
3755
+
3756
+ try:
3757
+ if bool(getattr(run_mutation_guard, "acquired_after_wait", False)):
3758
+ plan = load_plan(paths)
3759
+ task = find_task(plan, task_id)
3760
+ if task is None:
3761
+ message = (
3762
+ "Queued Forge exec revalidated the current plan "
3763
+ f"and task no longer exists: {task_id}"
3764
+ )
3765
+ events.emit(
3766
+ EVENT_PLAN_INVALID,
3767
+ {"reason": message, "source": "task_lookup", "task_id": task_id},
3768
+ )
3769
+ raise _exec_error_exit(console, events, message, task_id=task_id)
3770
+ task_status = canonical_task_status(str(task.get("status") or ""))
3771
+ if task_status in {"done", "superseded", "invalidated"}:
3772
+ message = (
3773
+ "Queued Forge exec revalidated the current plan "
3774
+ f"and task is no longer executable ({task_status}): {task_id}."
3775
+ )
3776
+ events.emit(
3777
+ EVENT_PLAN_INVALID,
3778
+ {
3779
+ "reason": message,
3780
+ "source": "task_status",
3781
+ "task_id": task_id,
3782
+ "status": task_status,
3783
+ },
3784
+ )
3785
+ raise _exec_error_exit(console, events, message, task_id=task_id)
3786
+ run_cfg.model = resolve_model_for_role(
3787
+ cfg=effective,
3788
+ role=ROLE_CODING,
3789
+ plan=plan,
3790
+ prefer_context="forge",
3791
+ )
3792
+ verify_commands = []
3793
+ verify_command_source = None
3794
+ if verify_mode != "off":
3795
+ verify_selection = resolve_authoritative_task_verify_command_selection(
3796
+ cfg=effective,
3797
+ verify_cmd=verify_cmd,
3798
+ task=task,
3799
+ root=paths.root,
3800
+ plan_requirements=[
3801
+ str(item).strip()
3802
+ for item in (plan.get("requirements") or [])
3803
+ if str(item).strip()
3804
+ ],
3805
+ )
3806
+ verify_commands = list(verify_selection.commands)
3807
+ verify_command_source = verify_selection.source
3808
+ run_cfg.verify_commands = list(verify_commands)
3809
+ blockers = _task_dependency_blockers(plan, task)
3810
+ if blockers:
3811
+ message = "Dependencies are not done: " + ", ".join(blockers)
3812
+ events.emit(
3813
+ EVENT_PLAN_INVALID,
3814
+ {
3815
+ "reason": message,
3816
+ "source": "dependencies",
3817
+ "task_id": task_id,
3818
+ "blockers": list(blockers),
3819
+ },
3820
+ )
3821
+ raise _exec_error_exit(console, events, message, task_id=task_id)
3822
+
3823
+ _mark_run_status(
3824
+ paths,
3825
+ RUN_STATUS_RUNNING,
3826
+ reason=f"forge exec started for {task_id}",
3827
+ plan=plan,
3828
+ mode=f"forge_exec:{task_id}",
3829
+ )
3830
+ outcome = execute_forge_task(
3831
+ console=console,
3832
+ events=events,
3833
+ paths=paths,
3834
+ plan=plan,
3835
+ task=task,
3836
+ task_id=task_id,
3837
+ effective=effective,
3838
+ run_cfg=run_cfg,
3839
+ effective_mode=effective_mode,
3840
+ scope_mode=scope_mode,
3841
+ verify_mode=verify_mode,
3842
+ verify_commands=verify_commands,
3843
+ verify_command_source=verify_command_source,
3844
+ verify_repair_budget=verify_repair_budget,
3845
+ api_key_override=api_key_override,
3846
+ yes=yes,
3847
+ no_log=no_log,
3848
+ max_steps_provided=max_steps_provided,
3849
+ pr=pr,
3850
+ review=review,
3851
+ base_branch=base_branch,
3852
+ keep_branch=keep_branch,
3853
+ auto_resolve_conflicts=auto_resolve_conflicts is True,
3854
+ )
3855
+ terminal_status, terminal_reason = _terminal_run_status_for(
3856
+ plan=plan,
3857
+ any_failure=not outcome.success,
3858
+ )
3859
+ _mark_run_status(paths, terminal_status, reason=terminal_reason)
3860
+ except ForgeTaskExecutionError as e:
3861
+ raise _exec_error_exit(
3862
+ console,
3863
+ events,
3864
+ str(e),
3865
+ task_id=e.task_id or task_id,
3866
+ kind=e.kind,
3867
+ ) from e
3868
+ finally:
3869
+ _mark_run_interrupted_if_still_running(
3870
+ paths,
3871
+ reason=f"forge exec for {task_id} exited without recording an outcome",
3872
+ )
3873
+ run_mutation_guard.release()
3874
+
3875
+ events.run_completed(
3876
+ ok=outcome.success,
3877
+ exit_code=outcome.exit_code,
3878
+ data={"task": outcome.payload},
3879
+ )
3880
+ raise typer.Exit(code=outcome.exit_code)
3881
+
3882
+
3883
+ def forge_run(
3884
+ path: Path = typer.Option(
3885
+ Path("."),
3886
+ "--path",
3887
+ help="Workspace path or repository subdirectory.",
3888
+ ),
3889
+ allow_broad_workspace: bool = typer.Option(
3890
+ False,
3891
+ "--allow-broad-workspace",
3892
+ help="Allow guarded broad workspaces instead of requiring a narrower project path.",
3893
+ ),
3894
+ only: str | None = typer.Option(
3895
+ None,
3896
+ "--only",
3897
+ help="Comma-separated task ids to execute (dependencies are still enforced).",
3898
+ ),
3899
+ max_tasks: int | None = typer.Option(
3900
+ None,
3901
+ "--max-tasks",
3902
+ min=1,
3903
+ help="Stop after this many tasks have been executed.",
3904
+ ),
3905
+ max_attempts: int | None = typer.Option(
3906
+ None,
3907
+ "--max-attempts",
3908
+ min=1,
3909
+ help="Skip tasks that already reached this many recorded attempts.",
3910
+ ),
3911
+ retry_failed: bool = typer.Option(
3912
+ False,
3913
+ "--retry-failed",
3914
+ help="Include tasks currently marked failed.",
3915
+ ),
3916
+ retry_changes_requested: bool = typer.Option(
3917
+ False,
3918
+ "--retry-changes-requested",
3919
+ help="Include tasks currently marked changes_requested.",
3920
+ ),
3921
+ keep_going: bool = typer.Option(
3922
+ False,
3923
+ "--keep-going",
3924
+ help=(
3925
+ "Continue with the next independent task after a failure instead of stopping. "
3926
+ "Default is to stop, because a failed task usually leaves later tasks unsound."
3927
+ ),
3928
+ ),
3929
+ dry_run: bool = typer.Option(
3930
+ False,
3931
+ "--dry-run",
3932
+ help="Print the execution order and exit without running anything.",
3933
+ ),
3934
+ scope: str = typer.Option(
3935
+ "strict",
3936
+ "--scope",
3937
+ help="Write-scope enforcement: strict by default; use warn or off to opt out.",
3938
+ ),
3939
+ verify: str = typer.Option(
3940
+ "warn",
3941
+ "--verify",
3942
+ help="Per-task verification policy: off, warn, or strict.",
3943
+ ),
3944
+ verify_cmd: list[str] | None = typer.Option(
3945
+ None,
3946
+ "--verify-cmd",
3947
+ help="Override verify command for this run (repeatable).",
3948
+ ),
3949
+ verify_repair_attempts: int | None = typer.Option(
3950
+ None,
3951
+ "--verify-repair-attempts",
3952
+ help=(
3953
+ "How many times a failing strict verification is fed back to the agent "
3954
+ "to repair before the task fails (default 2; 0 disables)."
3955
+ ),
3956
+ ),
3957
+ pr: bool = typer.Option(
3958
+ True,
3959
+ "--pr/--no-pr",
3960
+ help=(
3961
+ "Run each task as a branch/commit/verify/merge cycle in the main checkout "
3962
+ "(default). --no-pr leaves the changes uncommitted and runs no verification "
3963
+ "gate, so use it only where git flow is unavailable."
3964
+ ),
3965
+ ),
3966
+ review: bool = typer.Option(
3967
+ False,
3968
+ "--review",
3969
+ help="Run the automated review gate before merging each task (requires --pr).",
3970
+ ),
3971
+ base_branch: str | None = typer.Option(
3972
+ None,
3973
+ "--base-branch",
3974
+ help="Base branch for --pr mode (defaults to the current branch).",
3975
+ ),
3976
+ keep_branch: bool = typer.Option(
3977
+ False,
3978
+ "--keep-branch",
3979
+ help="Keep each task branch after a successful merge.",
3980
+ ),
3981
+ auto_resolve_conflicts: bool = typer.Option(
3982
+ False,
3983
+ "--auto-resolve-conflicts",
3984
+ help=(
3985
+ "On merge conflict, start a resolver agent in a dedicated worktree instead of "
3986
+ "stopping with a conflict report. Off by default."
3987
+ ),
3988
+ ),
3989
+ mode: Mode | None = typer.Option(None, "--mode", help="Mode override."),
3990
+ model: str | None = typer.Option(None, "--model", help="Model override."),
3991
+ base_url: str | None = typer.Option(None, "--base-url", help="Base URL override."),
3992
+ temperature: float | None = typer.Option(None, "--temperature", help="Sampling temperature."),
3993
+ stream: bool | None = typer.Option(
3994
+ None,
3995
+ "--stream/--no-stream",
3996
+ help="Enable streamed assistant output.",
3997
+ ),
3998
+ max_steps: int | None = typer.Option(
3999
+ None,
4000
+ "--max-steps",
4001
+ help="Optional safety limit on each managed agent task.",
4002
+ ),
4003
+ no_log: bool = typer.Option(False, "--no-log", help="Disable JSONL session logging."),
4004
+ api_key_env: str | None = typer.Option(
4005
+ None,
4006
+ "--api-key-env",
4007
+ help=(
4008
+ "Read API key from this environment variable (overrides ALYSIS_API_KEY/OPENAI_API_KEY)."
4009
+ ),
4010
+ ),
4011
+ api_key_stdin: bool = typer.Option(
4012
+ False,
4013
+ "--api-key-stdin",
4014
+ help="Prompt for API key (hidden input). Key is kept in memory for this run only.",
4015
+ ),
4016
+ api_key: str | None = typer.Option(
4017
+ None,
4018
+ "--api-key",
4019
+ help=(
4020
+ "UNSAFE: Provide API key via CLI argument (may leak via shell history / process list). "
4021
+ "Prefer --api-key-stdin or --api-key-env."
4022
+ ),
4023
+ ),
4024
+ yes: bool = typer.Option(
4025
+ False,
4026
+ "--yes",
4027
+ help="In auto mode, skip confirmations for sensitive commands (hard blocks still apply).",
4028
+ ),
4029
+ cli_ctx: Any = None,
4030
+ events: Any = None,
4031
+ ) -> None:
4032
+ """Execute every ready task sequentially, in dependency order, in this checkout.
4033
+
4034
+ No worktrees, no parallelism, no batch integration gate: one task at a time
4035
+ through :func:`execute_forge_task`, the same core `forge exec` uses. The run
4036
+ stops at the first failure unless ``--keep-going`` says otherwise.
4037
+ """
4038
+ events = _events_or_null(events)
4039
+ console = _console()
4040
+ cfg = load_config()
4041
+ effective = clone_cfg(cfg)
4042
+ # The public Typer command forwards its own context explicitly. Prefer it
4043
+ # over Click's ambient lookup: this implementation is called through a
4044
+ # delegation layer, where the ambient context may be the parent ``forge``
4045
+ # group and cannot report whether run-specific options were supplied.
4046
+ current_ctx = cli_ctx if cli_ctx is not None else get_current_context(silent=True)
4047
+ max_steps_provided = max_steps is not None
4048
+ if current_ctx is not None:
4049
+ source = current_ctx.get_parameter_source("max_steps")
4050
+ # Typer may provide its vendored Click enum here, so compare the stable
4051
+ # semantic name rather than enum identity across package boundaries.
4052
+ max_steps_provided = (
4053
+ source is not None and getattr(source, "name", None) != ParameterSource.DEFAULT.name
4054
+ )
4055
+ pr_explicit = False
4056
+ if current_ctx is not None:
4057
+ pr_source = current_ctx.get_parameter_source("pr")
4058
+ pr_explicit = (
4059
+ pr_source is not None
4060
+ and getattr(pr_source, "name", None) != ParameterSource.DEFAULT.name
4061
+ )
4062
+
4063
+ if base_url is not None:
4064
+ effective.base_url = base_url
4065
+ if model is not None:
4066
+ effective.model = model
4067
+ if temperature is not None:
4068
+ _apply_temperature_override(effective, temperature)
4069
+ if stream is not None:
4070
+ effective.stream = stream
4071
+ if max_steps is not None:
4072
+ effective.max_steps = max_steps
4073
+ effective_mode = (mode.value if mode else effective.default_mode) or "review"
4074
+
4075
+ # Called as a plain function these arrive as truthy typer OptionInfo objects,
4076
+ # so every boolean is normalized before it can silently flip a policy.
4077
+ pr_requested = pr is True
4078
+ review_requested = review is True
4079
+ keep_branch_requested = keep_branch is True
4080
+ keep_going_requested = keep_going is True
4081
+ dry_run_requested = dry_run is True
4082
+ retry_failed_requested = retry_failed is True
4083
+ retry_changes_requested_requested = retry_changes_requested is True
4084
+ auto_resolve_requested = auto_resolve_conflicts is True
4085
+ repair_attempts_override = (
4086
+ verify_repair_attempts
4087
+ if isinstance(verify_repair_attempts, int) and not isinstance(verify_repair_attempts, bool)
4088
+ else None
4089
+ )
4090
+ verify_repair_budget = resolve_repair_attempt_budget(repair_attempts_override)
4091
+ only_ids = _parse_only_task_ids(only)
4092
+ task_limit = (
4093
+ max_tasks if isinstance(max_tasks, int) and not isinstance(max_tasks, bool) else None
4094
+ )
4095
+ attempt_limit = (
4096
+ max_attempts
4097
+ if isinstance(max_attempts, int) and not isinstance(max_attempts, bool)
4098
+ else None
4099
+ )
4100
+
4101
+ started_at = now_iso()
4102
+ outcomes: list[TaskExecutionOutcome] = []
4103
+ run_log = _SequentialRunLog()
4104
+ paths: Any = None
4105
+
4106
+ try:
4107
+ scope_mode = _normalize_scope_mode(scope)
4108
+ verify_mode = _normalize_verify_mode(verify)
4109
+ if review_requested and not pr_requested:
4110
+ raise ForgeError("--review requires --pr.")
4111
+ api_key_override = _resolve_api_key_override(
4112
+ api_key=api_key,
4113
+ api_key_env=api_key_env,
4114
+ api_key_stdin=api_key_stdin,
4115
+ )
4116
+ binding = resolve_workspace_binding(
4117
+ path,
4118
+ create_if_missing=False,
4119
+ allow_broad_workspace=allow_broad_workspace,
4120
+ source=_path_binding_source(cli_ctx, path),
4121
+ )
4122
+ ensure_workspace_policy(
4123
+ binding,
4124
+ action=WorkspaceAction.FORGE_RUN,
4125
+ allow_broad_workspace=allow_broad_workspace,
4126
+ )
4127
+ try:
4128
+ paths = load_current_run_paths(binding.workspace_context.focus_path)
4129
+ except ForgeError as e:
4130
+ if "current_run.json" in str(e):
4131
+ raise _missing_swarm_run_error(binding=binding) from e
4132
+ raise
4133
+ events.set_run_id(paths.run_id)
4134
+ run_log.bind(paths)
4135
+ plan = load_plan(paths)
4136
+
4137
+ # PR flow is the default because it is what makes per-task verification and a
4138
+ # per-task resume point possible. A workspace without a git HEAD cannot do it,
4139
+ # so an unstated default degrades loudly instead of failing; an explicit --pr
4140
+ # still fails, because silently ignoring a flag the user typed is worse.
4141
+ git_flow_available = bool(paths.has_head_commit)
4142
+ if pr_requested and not git_flow_available:
4143
+ # --review only exists inside git flow, so degrading would drop it too.
4144
+ # Both count as "the user typed it", and neither gets dropped silently.
4145
+ explicit_flag = "--pr" if pr_explicit else ("--review" if review_requested else None)
4146
+ if explicit_flag is not None:
4147
+ raise ForgeError(
4148
+ f"{explicit_flag} needs a git repository with at least one commit; this "
4149
+ f"workspace ({os.fspath(paths.root)}) has none. Commit once, or drop "
4150
+ f"{explicit_flag}."
4151
+ )
4152
+ pr_requested = False
4153
+ review_requested = False
4154
+ console.print(
4155
+ "[yellow]No git HEAD in this workspace:[/yellow] running without git flow. "
4156
+ "Tasks execute in place, nothing is committed, and per-task verification "
4157
+ "does not run."
4158
+ )
4159
+ events.emit(
4160
+ EVENT_VERIFICATION_UNAVAILABLE,
4161
+ {
4162
+ "scope": "run",
4163
+ "policy": verify_mode,
4164
+ "reason": (
4165
+ "sequential run degraded to --no-pr: workspace has no git HEAD, so no "
4166
+ "per-task verification gate runs"
4167
+ ),
4168
+ "blocking": False,
4169
+ },
4170
+ )
4171
+
4172
+ # The base branch is pinned once for the whole run rather than re-derived per
4173
+ # task from the current branch. A failed task leaves the checkout on its own
4174
+ # branch, so re-deriving would make the next task branch off rejected work --
4175
+ # which is exactly what --keep-going would otherwise do.
4176
+ resolved_base_branch: str | None = (
4177
+ base_branch.strip() if isinstance(base_branch, str) and base_branch.strip() else None
4178
+ )
4179
+ if pr_requested and resolved_base_branch is None:
4180
+ try:
4181
+ resolved_base_branch = (current_branch(paths.root) or "").strip() or None
4182
+ except GitOpsError as e:
4183
+ raise ForgeError(f"could not determine the base branch for git flow: {e}") from e
4184
+ if resolved_base_branch is None:
4185
+ raise ForgeError(
4186
+ "could not determine the base branch for git flow; pass --base-branch."
4187
+ )
4188
+
4189
+ run_mutation_guard = acquire_swarm_mutation_guard(
4190
+ paths,
4191
+ mode="forge_run:cli",
4192
+ on_wait=lambda info: _print_forge_lock_wait_notice(console, info),
4193
+ )
4194
+ try:
4195
+ if bool(getattr(run_mutation_guard, "acquired_after_wait", False)):
4196
+ plan = load_plan(paths)
4197
+ reconciliation_result, _ = _reconcile_plan_for_paths(
4198
+ paths=paths,
4199
+ plan=plan,
4200
+ refresh_if_stale=True,
4201
+ )
4202
+ if reconciliation_result.changed:
4203
+ save_plan(paths, plan)
4204
+ if reconciliation_result.warnings:
4205
+ console.print("[yellow]Plan reconciliation warnings:[/yellow]")
4206
+ for warning in reconciliation_result.warnings:
4207
+ console.print(f"- {warning}")
4208
+ validation_warnings = _validate_forge_plan_for_paths(paths, plan)
4209
+ _write_plan_validation_artifact(
4210
+ paths=paths,
4211
+ reconciliation_result=reconciliation_result,
4212
+ validation_warnings=validation_warnings,
4213
+ )
4214
+ repair_payload = plan_repair_event_payload(plan)
4215
+ readiness_message = _forge_no_execution_ready_tasks_message(plan)
4216
+ if readiness_message is not None:
4217
+ events.emit(
4218
+ EVENT_PLAN_INVALID,
4219
+ {
4220
+ "reason": readiness_message,
4221
+ "source": "execution_readiness",
4222
+ "warnings": list(validation_warnings),
4223
+ **repair_payload,
4224
+ },
4225
+ )
4226
+ raise ForgeError(readiness_message)
4227
+ try:
4228
+ raise_for_execution_ready_plan(
4229
+ plan,
4230
+ retry_failed=retry_failed_requested,
4231
+ retry_changes_requested=retry_changes_requested_requested,
4232
+ retry_merge_conflicts=False,
4233
+ only=only,
4234
+ )
4235
+ except PlannerFailedError as e:
4236
+ events.emit(
4237
+ EVENT_PLAN_INVALID,
4238
+ {
4239
+ "reason": str(e),
4240
+ "source": "plan_validation",
4241
+ "failure_category": getattr(e, "failure_category", None),
4242
+ **repair_payload,
4243
+ },
4244
+ )
4245
+ err = ForgeError(str(e))
4246
+ err.failure_category = e.failure_category # type: ignore[attr-defined]
4247
+ raise err from e
4248
+
4249
+ projected = _projected_sequential_order(
4250
+ plan,
4251
+ retry_failed=retry_failed_requested,
4252
+ retry_changes_requested=retry_changes_requested_requested,
4253
+ max_attempts=attempt_limit,
4254
+ only_ids=only_ids,
4255
+ max_tasks=task_limit,
4256
+ )
4257
+ # A run boundary in the append-only log, so a second `forge run` against the
4258
+ # same plan reads as a second run rather than as more of the first.
4259
+ run_log.append(
4260
+ "run_started",
4261
+ run_id=paths.run_id,
4262
+ order=list(projected),
4263
+ git_flow=pr_requested,
4264
+ verify=verify_mode,
4265
+ scope=scope_mode,
4266
+ dry_run=dry_run_requested,
4267
+ )
4268
+ console.rule("[bold]forge run[/bold]")
4269
+ console.print(f"Run ID: {paths.run_id}")
4270
+ console.print(f"Workspace: {os.fspath(paths.root)}")
4271
+ console.print(
4272
+ f"Mode: sequential, in-checkout (no worktrees) | "
4273
+ f"git flow: {'on' if pr_requested else 'off'} | verify: {verify_mode} | "
4274
+ f"scope: {scope_mode}"
4275
+ )
4276
+ if pr_requested and resolved_base_branch:
4277
+ console.print(f"Base branch: {resolved_base_branch}")
4278
+ _print_sequential_order(console, plan, projected)
4279
+
4280
+ if dry_run_requested:
4281
+ data = {
4282
+ "dry_run": True,
4283
+ "order": list(projected),
4284
+ "task_count": len(projected),
4285
+ }
4286
+ events.run_completed(ok=True, exit_code=EXIT_OK, data=data)
4287
+ raise typer.Exit(code=EXIT_OK)
4288
+
4289
+ # From here on the workspace has an owner on record. Anything that stops
4290
+ # this process without reaching the terminal transition below leaves the
4291
+ # pointer saying `running` with a dead owner, which the next command
4292
+ # reconciles to `interrupted`.
4293
+ _mark_run_status(
4294
+ paths,
4295
+ RUN_STATUS_RUNNING,
4296
+ reason=f"forge run started ({len(projected)} task(s) projected)",
4297
+ plan=plan,
4298
+ mode="forge_run:cli",
4299
+ )
4300
+
4301
+ stopped_reason = "completed"
4302
+ attempted: set[str] = set()
4303
+ while True:
4304
+ if task_limit is not None and len(outcomes) >= task_limit:
4305
+ stopped_reason = "max_tasks"
4306
+ break
4307
+ candidate = _next_sequential_task(
4308
+ plan,
4309
+ retry_failed=retry_failed_requested,
4310
+ retry_changes_requested=retry_changes_requested_requested,
4311
+ max_attempts=attempt_limit,
4312
+ only_ids=only_ids,
4313
+ exclude_ids=attempted,
4314
+ )
4315
+ if candidate is None:
4316
+ break
4317
+ task = candidate.task
4318
+ task_id = candidate.task_id
4319
+ attempted.add(task_id)
4320
+
4321
+ run_cfg = clone_cfg(effective)
4322
+ run_cfg.model = resolve_model_for_role(
4323
+ cfg=effective,
4324
+ role=ROLE_CODING,
4325
+ plan=plan,
4326
+ prefer_context="forge",
4327
+ )
4328
+ verify_commands: list[str] = []
4329
+ verify_command_source: str | None = None
4330
+ if verify_mode != "off":
4331
+ selection = resolve_authoritative_task_verify_command_selection(
4332
+ cfg=effective,
4333
+ verify_cmd=verify_cmd,
4334
+ task=task,
4335
+ root=paths.root,
4336
+ plan_requirements=[
4337
+ str(item).strip()
4338
+ for item in (plan.get("requirements") or [])
4339
+ if str(item).strip()
4340
+ ],
4341
+ )
4342
+ verify_commands = list(selection.commands)
4343
+ verify_command_source = selection.source
4344
+ run_cfg.verify_commands = list(verify_commands)
4345
+
4346
+ position = len(outcomes) + 1
4347
+ console.rule(
4348
+ f"[bold]{position}. {task_id}[/bold] {str(task.get('title') or '').strip()}"
4349
+ )
4350
+ run_log.append(
4351
+ "task_started",
4352
+ task_id=task_id,
4353
+ title=str(task.get("title") or ""),
4354
+ position=position,
4355
+ )
4356
+ try:
4357
+ outcome = execute_forge_task(
4358
+ console=console,
4359
+ events=events,
4360
+ paths=paths,
4361
+ plan=plan,
4362
+ task=task,
4363
+ task_id=task_id,
4364
+ effective=effective,
4365
+ run_cfg=run_cfg,
4366
+ effective_mode=effective_mode,
4367
+ scope_mode=scope_mode,
4368
+ verify_mode=verify_mode,
4369
+ verify_commands=verify_commands,
4370
+ verify_command_source=verify_command_source,
4371
+ verify_repair_budget=verify_repair_budget,
4372
+ api_key_override=api_key_override,
4373
+ yes=yes is True,
4374
+ no_log=no_log is True,
4375
+ max_steps_provided=max_steps_provided,
4376
+ pr=pr_requested,
4377
+ review=review_requested,
4378
+ base_branch=resolved_base_branch,
4379
+ keep_branch=keep_branch_requested,
4380
+ auto_resolve_conflicts=auto_resolve_requested,
4381
+ )
4382
+ except ForgeTaskExecutionError as e:
4383
+ # The task could not be executed at all. That is a command error,
4384
+ # and continuing would run later tasks on top of an unknown repo
4385
+ # state, so the run ends here.
4386
+ run_log.append(
4387
+ "task_error",
4388
+ task_id=task_id,
4389
+ error=str(e),
4390
+ kind=e.kind,
4391
+ )
4392
+ _write_sequential_summary(
4393
+ paths=paths,
4394
+ run_id=paths.run_id,
4395
+ started_at=started_at,
4396
+ outcomes=outcomes,
4397
+ stopped_reason="task_error",
4398
+ exit_code=EXIT_ERROR,
4399
+ pr=pr_requested,
4400
+ verify_mode=verify_mode,
4401
+ scope_mode=scope_mode,
4402
+ error=str(e),
4403
+ )
4404
+ err = ForgeError(str(e))
4405
+ err.failure_category = e.kind # type: ignore[attr-defined]
4406
+ raise err from e
4407
+
4408
+ outcomes.append(outcome)
4409
+ # Recorded after the fact, not before: `execute_forge_task` derives the
4410
+ # task's step budget from the count of *previous* attempts, so bumping
4411
+ # first would make every first attempt look like a retry -- and the whole
4412
+ # point of sharing the core is that `forge run` and `forge exec` give a
4413
+ # task the same budget.
4414
+ attempts = _bump_task_attempt(paths, plan, task)
4415
+ run_log.append(
4416
+ "task_finished",
4417
+ task_id=task_id,
4418
+ status=outcome.status,
4419
+ success=outcome.success,
4420
+ summary=outcome.summary,
4421
+ report=os.fspath(outcome.report_path),
4422
+ attempts=attempts,
4423
+ )
4424
+ if not outcome.success and not keep_going_requested:
4425
+ stopped_reason = "task_failed"
4426
+ break
4427
+
4428
+ remaining = _unfinished_task_ids(plan)
4429
+ terminal_status, terminal_reason = _terminal_run_status_for(
4430
+ plan=plan,
4431
+ any_failure=any(not item.success for item in outcomes),
4432
+ )
4433
+ _mark_run_status(paths, terminal_status, reason=terminal_reason)
4434
+ finally:
4435
+ _mark_run_interrupted_if_still_running(
4436
+ paths,
4437
+ reason="forge run exited without recording an outcome",
4438
+ )
4439
+ run_mutation_guard.release()
4440
+ except (ConfigError, ForgeError, GitOpsError, WorkspaceBindingError) as e:
4441
+ console.print(f"[red]Forge error:[/red] {e}")
4442
+ events.error(
4443
+ message=str(e),
4444
+ kind="forge_error",
4445
+ exit_code=EXIT_ERROR,
4446
+ data={"failure_category": getattr(e, "failure_category", None)},
4447
+ )
4448
+ raise typer.Exit(code=EXIT_ERROR) from e
4449
+ except typer.Exit:
4450
+ raise
4451
+ except Exception as e: # noqa: BLE001
4452
+ # An unexpected exception is the command failing, not "tasks were rejected".
4453
+ console.print(f"[red]Forge error:[/red] {e}")
4454
+ events.error(
4455
+ message=str(e) or e.__class__.__name__,
4456
+ kind="exception",
4457
+ exit_code=EXIT_ERROR,
4458
+ data={"exception_type": e.__class__.__name__},
4459
+ )
4460
+ raise typer.Exit(code=EXIT_ERROR) from e
4461
+
4462
+ # Exit 1 means "ran to completion, work not accepted". Tasks left unexecuted
4463
+ # because of --only or --max-tasks are a scope the caller chose, not rejected
4464
+ # work, so only an actual task failure moves the exit code.
4465
+ failed = [item for item in outcomes if not item.success]
4466
+ exit_code = EXIT_NOT_ACCEPTED if failed else EXIT_OK
4467
+
4468
+ summary_payload = _write_sequential_summary(
4469
+ paths=paths,
4470
+ run_id=paths.run_id,
4471
+ started_at=started_at,
4472
+ outcomes=outcomes,
4473
+ stopped_reason=stopped_reason,
4474
+ exit_code=exit_code,
4475
+ pr=pr_requested,
4476
+ verify_mode=verify_mode,
4477
+ scope_mode=scope_mode,
4478
+ remaining=remaining,
4479
+ )
4480
+ _print_sequential_summary(console, summary_payload)
4481
+ _print_usage_summary_from_logs(
4482
+ console=console,
4483
+ title="Sequential Run Usage Summary",
4484
+ log_paths=sorted(paths.execution_logs_dir.glob("*.jsonl")),
4485
+ )
4486
+ events.run_completed(
4487
+ ok=exit_code == EXIT_OK, exit_code=exit_code, data={"outcome": summary_payload}
4488
+ )
4489
+ raise typer.Exit(code=exit_code)
4490
+
4491
+
4492
+ def forge_plan_impl(cli_mod: Any, *args: Any, **kwargs: Any) -> Any:
4493
+ _sync_cli_globals(cli_mod)
4494
+ return forge_plan(*args, **kwargs)
4495
+
4496
+
4497
+ def forge_swarm_impl(cli_mod: Any, *args: Any, **kwargs: Any) -> Any:
4498
+ _sync_cli_globals(cli_mod)
4499
+ return forge_swarm(*args, **kwargs)
4500
+
4501
+
4502
+ def forge_exec_impl(cli_mod: Any, *args: Any, **kwargs: Any) -> Any:
4503
+ _sync_cli_globals(cli_mod)
4504
+ return forge_exec(*args, **kwargs)
4505
+
4506
+
4507
+ def forge_run_impl(cli_mod: Any, *args: Any, **kwargs: Any) -> Any:
4508
+ _sync_cli_globals(cli_mod)
4509
+ return forge_run(*args, **kwargs)