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,3112 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import inspect
5
+ import json
6
+ import re
7
+ from collections.abc import Callable
8
+ from contextlib import suppress
9
+ from dataclasses import dataclass
10
+ from pathlib import Path, PurePosixPath
11
+ from typing import Any
12
+
13
+ from ..approval_scope import exact_file_set_scope, exact_verify_command_set_scope
14
+ from ..assets import AssetAlreadyExistsError, AssetError, asset_reference_check
15
+ from ..assets.plan_binding import bind_asset_to_matching_tasks
16
+ from ..assets.surface import (
17
+ AssetSurfaceDetail,
18
+ AssetSurfaceEntry,
19
+ build_asset_surface,
20
+ )
21
+ from ..diff_paths import parse_patch_changed_files
22
+ from ..execution_shared import (
23
+ build_task_execution_instruction_bundle,
24
+ build_task_local_workspace_reporting_diff,
25
+ capture_task_local_workspace_baseline,
26
+ cleanup_task_local_workspace_baseline,
27
+ resolve_managed_task_step_budget,
28
+ safe_task_file_component,
29
+ write_execution_budget_artifact,
30
+ write_execution_context_artifact,
31
+ )
32
+ from ..forge import (
33
+ ForgeError,
34
+ RunPaths,
35
+ append_planner_chat,
36
+ append_planner_summary,
37
+ append_transcript_note,
38
+ create_plan_run,
39
+ ensure_execution_dirs,
40
+ ensure_workspace_context_artifacts,
41
+ finalize_plan,
42
+ find_task,
43
+ load_plan,
44
+ make_run_paths,
45
+ now_iso,
46
+ save_plan,
47
+ set_task_status,
48
+ write_task_report,
49
+ )
50
+ from ..git_ops import head_commit
51
+ from ..knowledge_librarian import prepare_planner_knowledge, resolve_knowledge_workspace_root
52
+ from ..plan_assistant import apply_guarded_planner_plan_update, run_planner_turn
53
+ from ..plan_reconciliation import reconcile_plan_with_workspace
54
+ from ..plan_repair import PlannerRepairReport, apply_plan_status, record_plan_repair
55
+ from ..plan_validation import validate_plan
56
+ from ..review_gate import ReviewError, review_task
57
+ from ..runtime_kind import RuntimeKind
58
+ from ..sandbox_doctor import diagnose_sandbox
59
+ from ..surface.types import ApprovalRequest
60
+ from ..task_scope import check_scope
61
+ from ..verify_gate import (
62
+ resolve_verify_commands,
63
+ run_task_verification,
64
+ verify_run_result_to_payload,
65
+ )
66
+ from .protocol import ProtocolError, redact_secrets
67
+
68
+ DEFAULT_MAX_DIFF_BYTES = 64 * 1024
69
+ MAX_DIFF_BYTES = 1024 * 1024
70
+ MAX_LISTED_PLANS = 100
71
+ MAX_FORGE_ASSET_BYTES = 25 * 1024 * 1024
72
+ PLAN_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$")
73
+ ASSET_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$")
74
+ REAL_EXECUTE_AUTO_UNSUPPORTED_REASON = (
75
+ "Forge Execute v1 is review-mode only. Auto and fullaccess execution remain disabled "
76
+ "until they have separate approval, sandbox, and cancellation coverage."
77
+ )
78
+ SUPPORTED_SANDBOX_PROFILES = frozenset({"default", "strict", "warn", "off"})
79
+ REAL_EXECUTE_SUPPORTED_MODES = frozenset({"review"})
80
+
81
+
82
+ def append_planner_router_event(paths: RunPaths, payload: dict[str, Any]) -> None:
83
+ """Persist legacy planner-router telemetry when an older planner supplies it.
84
+
85
+ The deterministic planner router was removed from the current runtime, so
86
+ production planner results no longer include this payload. Keeping the
87
+ writer local to the compatibility bridge lets older IDE requests and test
88
+ doubles remain readable without restoring the removed router to Forge.
89
+ """
90
+ paths.notes_dir.mkdir(parents=True, exist_ok=True)
91
+ event_type = (
92
+ "planner_router_failure"
93
+ if str(payload.get("fallback_reason") or "").strip()
94
+ else "planner_router_decision"
95
+ )
96
+ event = {"type": event_type, "ts": now_iso(), "payload": payload}
97
+ event_path = paths.notes_dir / "planner_router_events.jsonl"
98
+ with event_path.open("a", encoding="utf-8") as fh:
99
+ fh.write(
100
+ json.dumps(event, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + "\n"
101
+ )
102
+
103
+
104
+ def _throw_if_cancelled(cancellation_token: Any | None) -> None:
105
+ if cancellation_token is None:
106
+ return
107
+ throw_if_cancelled = getattr(cancellation_token, "throw_if_cancelled", None)
108
+ if callable(throw_if_cancelled):
109
+ throw_if_cancelled("cancelled_by_user")
110
+ return
111
+ if bool(getattr(cancellation_token, "is_cancelled", False)):
112
+ raise RuntimeError("cancelled_by_user")
113
+
114
+
115
+ def _call_accepts_keyword(callable_obj: Callable[..., Any], keyword: str) -> bool:
116
+ try:
117
+ sig = inspect.signature(callable_obj)
118
+ except (TypeError, ValueError):
119
+ return False
120
+ for parameter in sig.parameters.values():
121
+ if parameter.kind is inspect.Parameter.VAR_KEYWORD:
122
+ return True
123
+ if (
124
+ parameter.kind
125
+ in {
126
+ inspect.Parameter.POSITIONAL_OR_KEYWORD,
127
+ inspect.Parameter.KEYWORD_ONLY,
128
+ }
129
+ and parameter.name == keyword
130
+ ):
131
+ return True
132
+ return False
133
+
134
+
135
+ FORGE_REGISTRY_REJECTED_MESSAGE = "Forge persisted plan registry path was rejected."
136
+ SAFE_IDE_TASK_STATUSES = frozenset(
137
+ {
138
+ "planned",
139
+ "in_progress",
140
+ "blocked",
141
+ "done",
142
+ "failed",
143
+ "changes_requested",
144
+ "superseded",
145
+ }
146
+ )
147
+ MAX_PLAN_EDIT_TEXT_CHARS = 8_000
148
+
149
+
150
+ @dataclass(frozen=True, slots=True)
151
+ class ForgePlanRecord:
152
+ session_id: str
153
+ plan_id: str
154
+ paths: RunPaths
155
+ status: str = "planned"
156
+ job_id: str | None = None
157
+ source: str = "active_memory"
158
+ warnings: tuple[str, ...] = ()
159
+ verification_commands: tuple[str, ...] = ()
160
+
161
+
162
+ @dataclass(frozen=True, slots=True)
163
+ class DiffRecord:
164
+ diff_id: str
165
+ session_id: str
166
+ plan_id: str
167
+ job_id: str | None
168
+ path: Path
169
+ rel_path: str
170
+ file_path: str
171
+ status: str
172
+ old_label: str
173
+ new_label: str
174
+ size_bytes: int
175
+
176
+
177
+ def create_ide_forge_plan(
178
+ *,
179
+ session_id: str,
180
+ workspace_root: Path,
181
+ instruction: str,
182
+ cfg: Any,
183
+ planner_runner: Callable[..., Any] | None = None,
184
+ cancellation_token: Any | None = None,
185
+ ) -> tuple[ForgePlanRecord, dict[str, Any]]:
186
+ clean_instruction = instruction.strip()
187
+ if not clean_instruction:
188
+ raise ProtocolError("missing_field", "instruction is required.")
189
+ _throw_if_cancelled(cancellation_token)
190
+ quality: dict[str, Any]
191
+ try:
192
+ paths = create_plan_run(workspace_root)
193
+ plan = load_plan(paths)
194
+ append_transcript_note(paths, role="user", message=clean_instruction)
195
+ append_planner_chat(paths, role="user", message=clean_instruction)
196
+ workspace_context = _workspace_context_payload(paths)
197
+ relevant_knowledge_section = _planner_knowledge_section(
198
+ paths=paths,
199
+ plan=plan,
200
+ instruction=clean_instruction,
201
+ )
202
+ planner = planner_runner or run_planner_turn
203
+ planner_kwargs = {
204
+ "cfg": cfg,
205
+ "api_key_override": None,
206
+ "plan": plan,
207
+ "transcript_tail": [{"role": "user", "content": clean_instruction}],
208
+ "workspace_context": workspace_context,
209
+ "user_text": clean_instruction,
210
+ "stream": False,
211
+ "relevant_knowledge_section": relevant_knowledge_section,
212
+ "prefer_context": "forge",
213
+ "awaiting_clarification": False,
214
+ "pending_questions": [],
215
+ "run_paths": paths,
216
+ }
217
+ if _call_accepts_keyword(planner, "cancellation_token"):
218
+ planner_kwargs["cancellation_token"] = cancellation_token
219
+ planner_result = planner(**planner_kwargs)
220
+ _throw_if_cancelled(cancellation_token)
221
+ assistant_message = str(getattr(planner_result, "assistant_message", "") or "")
222
+ if assistant_message:
223
+ append_transcript_note(paths, role="assistant", message=assistant_message)
224
+ append_planner_chat(paths, role="assistant", message=assistant_message)
225
+ router_event = getattr(planner_result, "planner_router_event", None)
226
+ if isinstance(router_event, dict):
227
+ append_planner_router_event(paths, router_event)
228
+
229
+ planner_error = str(getattr(planner_result, "error", "") or "").strip()
230
+ if planner_error:
231
+ append_planner_summary(paths, f"planner error: {planner_error}")
232
+ save_plan(paths, plan)
233
+ raise ProtocolError(
234
+ "forge_plan_failed",
235
+ str(redact_secrets(f"Forge planner failed: {planner_error}")),
236
+ )
237
+
238
+ raw_plan_update = getattr(planner_result, "plan_update", None)
239
+ if not isinstance(raw_plan_update, dict) or not raw_plan_update:
240
+ questions = [
241
+ str(question).strip()
242
+ for question in getattr(planner_result, "questions", []) or []
243
+ if str(question).strip()
244
+ ]
245
+ if questions:
246
+ append_transcript_note(
247
+ paths,
248
+ role="system",
249
+ message=f"Planner questions: {'; '.join(questions)}",
250
+ )
251
+ append_planner_summary(paths, "planner produced no structured task update")
252
+ save_plan(paths, plan)
253
+ raise ProtocolError(
254
+ "forge_plan_incomplete",
255
+ "Forge planner did not produce a structured execution plan. No shallow fallback plan was created.",
256
+ )
257
+
258
+ apply_result = apply_guarded_planner_plan_update(
259
+ plan,
260
+ raw_plan_update,
261
+ latest_user_text=clean_instruction,
262
+ workspace_context=workspace_context,
263
+ )
264
+ reconciliation_warnings: list[str] = []
265
+ if apply_result.changed:
266
+ target_ids = set(apply_result.added_task_ids + apply_result.updated_task_ids) or None
267
+ reconciliation = reconcile_plan_with_workspace(
268
+ plan,
269
+ workspace_root=paths.root,
270
+ workspace_context=workspace_context,
271
+ user_text=clean_instruction,
272
+ transcript_tail=[
273
+ {"role": "user", "content": clean_instruction},
274
+ {"role": "assistant", "content": assistant_message},
275
+ ],
276
+ target_task_ids=target_ids,
277
+ )
278
+ reconciliation_warnings = list(getattr(reconciliation, "warnings", []) or [])
279
+ resolved_commands = _resolved_verification_commands(cfg, paths.root)
280
+ if resolved_commands:
281
+ plan["ide_verification_commands"] = list(resolved_commands)
282
+ if _plan_tasks(plan):
283
+ finalize_plan(plan)
284
+ record_plan_repair(plan, getattr(planner_result, "repair", None) or PlannerRepairReport())
285
+ apply_plan_status(plan, validation_warnings=validate_plan(plan))
286
+ save_plan(paths, plan)
287
+ quality = validate_ide_plan_quality(
288
+ plan,
289
+ verification_commands=resolved_commands,
290
+ extra_warnings=[
291
+ *list(getattr(apply_result, "warnings", []) or []),
292
+ *reconciliation_warnings,
293
+ ],
294
+ )
295
+ _write_plan_validation_artifact(paths=paths, warnings=quality["warnings"])
296
+ except ProtocolError:
297
+ raise
298
+ except ForgeError as e:
299
+ raise ProtocolError("forge_plan_failed", str(e)) from e
300
+ except Exception as e:
301
+ _throw_if_cancelled(cancellation_token)
302
+ raise ProtocolError(
303
+ "forge_plan_failed",
304
+ str(redact_secrets(f"Forge planner adapter failed: {e}")),
305
+ ) from e
306
+
307
+ if not _plan_tasks(plan):
308
+ raise ProtocolError(
309
+ "forge_plan_incomplete",
310
+ "Forge planner did not produce executable tasks. No shallow fallback plan was returned.",
311
+ )
312
+
313
+ record = ForgePlanRecord(
314
+ session_id=session_id,
315
+ plan_id=paths.run_id,
316
+ paths=paths,
317
+ source="active_memory",
318
+ warnings=tuple(quality["warnings"]),
319
+ verification_commands=tuple(resolved_commands),
320
+ )
321
+ return record, plan
322
+
323
+
324
+ def load_recorded_plan(record: ForgePlanRecord, *, migrate_legacy: bool = True) -> dict[str, Any]:
325
+ try:
326
+ validate_forge_run_artifact_root(record)
327
+ return load_plan(record.paths, migrate_legacy=migrate_legacy)
328
+ except ForgeError as e:
329
+ raise ProtocolError("forge_plan_not_found", str(e)) from e
330
+
331
+
332
+ def forge_plan_result(
333
+ record: ForgePlanRecord,
334
+ plan: dict[str, Any],
335
+ *,
336
+ created_session: bool,
337
+ verification_commands: list[str] | None = None,
338
+ ) -> dict[str, Any]:
339
+ root_name = forge_artifact_root_name(record.plan_id)
340
+ commands = (
341
+ verification_commands
342
+ if verification_commands is not None
343
+ else list(record.verification_commands)
344
+ or _string_list(plan.get("ide_verification_commands"))
345
+ )
346
+ quality = validate_ide_plan_quality(
347
+ plan,
348
+ verification_commands=commands,
349
+ extra_warnings=list(record.warnings),
350
+ )
351
+ return {
352
+ "plan_id": record.plan_id,
353
+ "session_id": record.session_id,
354
+ "job_id": record.job_id,
355
+ "status": record.status,
356
+ "source": record.source,
357
+ "created_session": created_session,
358
+ "project_goal": str(redact_secrets(str(plan.get("project_goal") or ""))),
359
+ "summary": str(redact_secrets(str(plan.get("summary") or ""))),
360
+ "warnings": quality["warnings"],
361
+ "incomplete": quality["incomplete"],
362
+ "tasks": [
363
+ _task_to_ide_schema(
364
+ task,
365
+ verification_commands=commands,
366
+ warnings=quality["task_warnings"].get(str(task.get("id") or ""), []),
367
+ )
368
+ for task in _plan_tasks(plan)
369
+ ],
370
+ "artifacts": [
371
+ {
372
+ "kind": "forge_plan_json",
373
+ "artifact_id": f"{root_name}:plan/plan.json",
374
+ "path": "plan/plan.json",
375
+ },
376
+ {
377
+ "kind": "forge_plan_markdown",
378
+ "artifact_id": f"{root_name}:plan/PLAN.md",
379
+ "path": "plan/PLAN.md",
380
+ },
381
+ ],
382
+ "plan_artifact_id": f"{root_name}:plan/plan.json",
383
+ "plan_markdown_artifact_id": f"{root_name}:plan/PLAN.md",
384
+ }
385
+
386
+
387
+ def forge_status_result(record: ForgePlanRecord, plan: dict[str, Any]) -> dict[str, Any]:
388
+ payload = forge_plan_result(record, plan, created_session=False)
389
+ payload["diff_count"] = len(list_diff_records([record]))
390
+ return payload
391
+
392
+
393
+ def forge_show_result(record: ForgePlanRecord, plan: dict[str, Any], *, cfg: Any) -> dict[str, Any]:
394
+ payload = forge_status_result(record, plan)
395
+ payload["assets"] = _asset_entries_payload(record, cfg=cfg, include_deleted=True)
396
+ payload["legacy_assets"] = _legacy_plan_assets_payload(plan)
397
+ payload["artifact_count"] = len(payload.get("artifacts") or [])
398
+ return payload
399
+
400
+
401
+ def forge_plan_state_result(
402
+ record: ForgePlanRecord,
403
+ plan: dict[str, Any],
404
+ *,
405
+ cfg: Any,
406
+ ) -> dict[str, Any]:
407
+ payload = forge_show_result(record, plan, cfg=cfg)
408
+ payload["ide_revision"] = _plan_ide_revision(plan)
409
+ payload["assistant"] = _plan_assistant_payload(plan)
410
+ payload["goal"] = str(redact_secrets(str(plan.get("project_goal") or "")))
411
+ payload["validation"] = forge_plan_validate_result(record, plan)
412
+ return payload
413
+
414
+
415
+ @dataclass(frozen=True, slots=True)
416
+ class ForgePlanEditOutcome:
417
+ """A committed plan edit, separated from the response payload rendering.
418
+
419
+ Apply functions mutate and persist the plan under the caller's lock;
420
+ forge_plan_edit_render builds the (filesystem-heavy) response payload and
421
+ must run outside bridge-wide locks.
422
+ """
423
+
424
+ changed: bool
425
+ previous_revision: int
426
+ changed_fields: tuple[str, ...]
427
+ task: dict[str, Any] | None = None
428
+
429
+
430
+ def forge_plan_set_assistant_apply(
431
+ record: ForgePlanRecord,
432
+ plan: dict[str, Any],
433
+ *,
434
+ instruction: str,
435
+ expected_revision: Any = None,
436
+ ) -> ForgePlanEditOutcome:
437
+ previous_revision = _check_expected_revision(plan, expected_revision)
438
+ clean = _safe_plan_edit_text(instruction, field="instruction")
439
+ previous = _plan_assistant_payload(plan).get("instruction", "")
440
+ changed = previous != clean
441
+ if changed:
442
+ plan["ide_assistant"] = {
443
+ "instruction": clean,
444
+ "updated_at": now_iso(),
445
+ "source": "ide_protocol",
446
+ }
447
+ plan["ide_revision"] = previous_revision + 1
448
+ save_plan(record.paths, plan)
449
+ return ForgePlanEditOutcome(
450
+ changed=changed,
451
+ previous_revision=previous_revision,
452
+ changed_fields=("assistant.instruction",) if changed else (),
453
+ )
454
+
455
+
456
+ def forge_plan_set_goal_apply(
457
+ record: ForgePlanRecord,
458
+ plan: dict[str, Any],
459
+ *,
460
+ goal: str,
461
+ expected_revision: Any = None,
462
+ ) -> ForgePlanEditOutcome:
463
+ previous_revision = _check_expected_revision(plan, expected_revision)
464
+ clean = _safe_plan_edit_text(goal, field="goal")
465
+ if not clean:
466
+ raise ProtocolError("missing_field", "goal must be a non-empty string.")
467
+ previous = str(plan.get("project_goal") or "")
468
+ changed = previous != clean
469
+ if changed:
470
+ plan["project_goal"] = clean
471
+ plan["ide_revision"] = previous_revision + 1
472
+ plan["ide_stale_reason"] = "goal_changed"
473
+ save_plan(record.paths, plan)
474
+ return ForgePlanEditOutcome(
475
+ changed=changed,
476
+ previous_revision=previous_revision,
477
+ changed_fields=("project_goal",) if changed else (),
478
+ )
479
+
480
+
481
+ def forge_plan_update_task_apply(
482
+ record: ForgePlanRecord,
483
+ plan: dict[str, Any],
484
+ *,
485
+ task_id: str,
486
+ title: str | None = None,
487
+ body: str | None = None,
488
+ status: str | None = None,
489
+ expected_revision: Any = None,
490
+ ) -> ForgePlanEditOutcome:
491
+ clean_task_id = _required_task_id(task_id)
492
+ previous_revision = _check_expected_revision(plan, expected_revision)
493
+ try:
494
+ task = find_task(plan, clean_task_id)
495
+ except ForgeError as e:
496
+ raise ProtocolError("task_not_found", str(redact_secrets(str(e)))) from e
497
+
498
+ changed_fields: list[str] = []
499
+ if title is not None:
500
+ clean_title = _safe_plan_edit_text(title, field="title")
501
+ if not clean_title:
502
+ raise ProtocolError("missing_field", "task title must be a non-empty string.")
503
+ if str(task.get("title") or "") != clean_title:
504
+ task["title"] = clean_title
505
+ changed_fields.append("title")
506
+ if body is not None:
507
+ clean_body = _safe_plan_edit_text(body, field="body")
508
+ if str(task.get("description") or "") != clean_body:
509
+ task["description"] = clean_body
510
+ changed_fields.append("description")
511
+ if status is not None:
512
+ clean_status = str(status or "").strip().lower()
513
+ if clean_status not in SAFE_IDE_TASK_STATUSES:
514
+ raise ProtocolError(
515
+ "invalid_task_status",
516
+ "task status must be one of: " + ", ".join(sorted(SAFE_IDE_TASK_STATUSES)),
517
+ )
518
+ if str(task.get("status") or "") != clean_status:
519
+ set_task_status(plan, clean_task_id, clean_status)
520
+ changed_fields.append("status")
521
+
522
+ if changed_fields:
523
+ plan["ide_revision"] = previous_revision + 1
524
+ plan["ide_stale_reason"] = "task_updated"
525
+ save_plan(record.paths, plan)
526
+ task_payload = _task_to_ide_schema(
527
+ task,
528
+ verification_commands=_string_list(plan.get("ide_verification_commands")),
529
+ warnings=[],
530
+ )
531
+ return ForgePlanEditOutcome(
532
+ changed=bool(changed_fields),
533
+ previous_revision=previous_revision,
534
+ changed_fields=tuple(changed_fields),
535
+ task=task_payload,
536
+ )
537
+
538
+
539
+ def forge_plan_edit_render(
540
+ record: ForgePlanRecord,
541
+ plan: dict[str, Any],
542
+ *,
543
+ cfg: Any,
544
+ outcome: ForgePlanEditOutcome,
545
+ ) -> dict[str, Any]:
546
+ result = _plan_edit_result(
547
+ record,
548
+ plan,
549
+ cfg=cfg,
550
+ changed=outcome.changed,
551
+ previous_revision=outcome.previous_revision,
552
+ changed_fields=list(outcome.changed_fields),
553
+ )
554
+ if outcome.task is not None:
555
+ result["task"] = outcome.task
556
+ return result
557
+
558
+
559
+ def forge_plan_set_assistant_result(
560
+ record: ForgePlanRecord,
561
+ plan: dict[str, Any],
562
+ *,
563
+ instruction: str,
564
+ expected_revision: Any = None,
565
+ cfg: Any,
566
+ ) -> dict[str, Any]:
567
+ outcome = forge_plan_set_assistant_apply(
568
+ record, plan, instruction=instruction, expected_revision=expected_revision
569
+ )
570
+ return forge_plan_edit_render(record, plan, cfg=cfg, outcome=outcome)
571
+
572
+
573
+ def forge_plan_set_goal_result(
574
+ record: ForgePlanRecord,
575
+ plan: dict[str, Any],
576
+ *,
577
+ goal: str,
578
+ expected_revision: Any = None,
579
+ cfg: Any,
580
+ ) -> dict[str, Any]:
581
+ outcome = forge_plan_set_goal_apply(
582
+ record, plan, goal=goal, expected_revision=expected_revision
583
+ )
584
+ return forge_plan_edit_render(record, plan, cfg=cfg, outcome=outcome)
585
+
586
+
587
+ def forge_plan_update_task_result(
588
+ record: ForgePlanRecord,
589
+ plan: dict[str, Any],
590
+ *,
591
+ task_id: str,
592
+ title: str | None = None,
593
+ body: str | None = None,
594
+ status: str | None = None,
595
+ expected_revision: Any = None,
596
+ cfg: Any,
597
+ ) -> dict[str, Any]:
598
+ outcome = forge_plan_update_task_apply(
599
+ record,
600
+ plan,
601
+ task_id=task_id,
602
+ title=title,
603
+ body=body,
604
+ status=status,
605
+ expected_revision=expected_revision,
606
+ )
607
+ return forge_plan_edit_render(record, plan, cfg=cfg, outcome=outcome)
608
+
609
+
610
+ def forge_plan_validate_result(record: ForgePlanRecord, plan: dict[str, Any]) -> dict[str, Any]:
611
+ commands = list(record.verification_commands) or _string_list(
612
+ plan.get("ide_verification_commands")
613
+ )
614
+ quality = validate_ide_plan_quality(plan, verification_commands=commands)
615
+ return {
616
+ "ok": not bool(quality["warnings"]) and not bool(quality["incomplete"]),
617
+ "warnings": quality["warnings"],
618
+ "incomplete": quality["incomplete"],
619
+ "task_warnings": quality["task_warnings"],
620
+ "ide_revision": _plan_ide_revision(plan),
621
+ "stale_reason": str(redact_secrets(str(plan.get("ide_stale_reason") or ""))) or None,
622
+ }
623
+
624
+
625
+ @dataclass(slots=True)
626
+ class ForgePlanRegenerateComputation:
627
+ """Planner output computed from a plan snapshot, awaiting a revision-checked commit."""
628
+
629
+ plan: dict[str, Any]
630
+ base_revision: int
631
+ changed: bool
632
+ quality: dict[str, Any]
633
+
634
+
635
+ def ensure_expected_plan_revision(plan: dict[str, Any], expected_revision: Any) -> int:
636
+ return _check_expected_revision(plan, expected_revision)
637
+
638
+
639
+ def forge_plan_regenerate_compute(
640
+ record: ForgePlanRecord,
641
+ plan: dict[str, Any],
642
+ *,
643
+ instruction: str | None = None,
644
+ focus: str | None = None,
645
+ expected_revision: Any = None,
646
+ cfg: Any,
647
+ planner_runner: Callable[..., Any] | None = None,
648
+ cancellation_token: Any | None = None,
649
+ ) -> ForgePlanRegenerateComputation:
650
+ """Run the planner against a plan snapshot without persisting anything.
651
+
652
+ Callers must not hold bridge-wide locks across this call: the planner is a
653
+ provider round-trip. The snapshot is mutated in place and only becomes
654
+ durable through forge_plan_regenerate_commit, which re-checks the revision.
655
+ """
656
+ old_revision = _check_expected_revision(plan, expected_revision)
657
+ clean_instruction = (
658
+ _safe_plan_edit_text(instruction, field="instruction") if instruction is not None else ""
659
+ )
660
+ clean_focus = _safe_plan_edit_text(focus, field="focus") if focus is not None else ""
661
+ user_text = _regeneration_instruction(plan, instruction=clean_instruction, focus=clean_focus)
662
+ before_fingerprint = _plan_regeneration_fingerprint(plan)
663
+ _throw_if_cancelled(cancellation_token)
664
+ try:
665
+ append_transcript_note(record.paths, role="user", message=user_text)
666
+ append_planner_chat(record.paths, role="user", message=user_text)
667
+ workspace_context = _workspace_context_payload(record.paths)
668
+ relevant_knowledge_section = _planner_knowledge_section(
669
+ paths=record.paths,
670
+ plan=plan,
671
+ instruction=user_text,
672
+ )
673
+ planner = planner_runner or run_planner_turn
674
+ planner_kwargs: dict[str, Any] = {
675
+ "cfg": cfg,
676
+ "api_key_override": None,
677
+ "plan": plan,
678
+ "transcript_tail": [{"role": "user", "content": user_text}],
679
+ "workspace_context": workspace_context,
680
+ "user_text": user_text,
681
+ "stream": False,
682
+ "relevant_knowledge_section": relevant_knowledge_section,
683
+ "prefer_context": "forge",
684
+ "awaiting_clarification": False,
685
+ "pending_questions": [],
686
+ "run_paths": record.paths,
687
+ }
688
+ if _call_accepts_keyword(planner, "cancellation_token"):
689
+ planner_kwargs["cancellation_token"] = cancellation_token
690
+ planner_result = planner(**planner_kwargs)
691
+ _throw_if_cancelled(cancellation_token)
692
+ assistant_message = str(getattr(planner_result, "assistant_message", "") or "")
693
+ if assistant_message:
694
+ append_transcript_note(record.paths, role="assistant", message=assistant_message)
695
+ append_planner_chat(record.paths, role="assistant", message=assistant_message)
696
+ router_event = getattr(planner_result, "planner_router_event", None)
697
+ if isinstance(router_event, dict):
698
+ append_planner_router_event(record.paths, router_event)
699
+
700
+ planner_error = str(getattr(planner_result, "error", "") or "").strip()
701
+ if planner_error:
702
+ append_planner_summary(record.paths, f"planner regenerate error: {planner_error}")
703
+ raise ProtocolError(
704
+ "forge_plan_failed",
705
+ str(redact_secrets(f"Forge planner regeneration failed: {planner_error}")),
706
+ )
707
+ raw_plan_update = getattr(planner_result, "plan_update", None)
708
+ if not isinstance(raw_plan_update, dict) or not raw_plan_update:
709
+ append_planner_summary(
710
+ record.paths, "planner regeneration produced no structured update"
711
+ )
712
+ raise ProtocolError(
713
+ "forge_plan_incomplete",
714
+ "Forge planner regeneration did not produce a structured plan update.",
715
+ )
716
+
717
+ apply_result = apply_guarded_planner_plan_update(
718
+ plan,
719
+ raw_plan_update,
720
+ latest_user_text=user_text,
721
+ workspace_context=workspace_context,
722
+ )
723
+ target_ids = set(apply_result.added_task_ids + apply_result.updated_task_ids) or None
724
+ reconciliation = reconcile_plan_with_workspace(
725
+ plan,
726
+ workspace_root=record.paths.root,
727
+ workspace_context=workspace_context,
728
+ user_text=user_text,
729
+ transcript_tail=[
730
+ {"role": "user", "content": user_text},
731
+ {"role": "assistant", "content": assistant_message},
732
+ ],
733
+ target_task_ids=target_ids,
734
+ )
735
+ _throw_if_cancelled(cancellation_token)
736
+ resolved_commands = _resolved_verification_commands(cfg, record.paths.root)
737
+ if resolved_commands:
738
+ plan["ide_verification_commands"] = list(resolved_commands)
739
+ if _plan_tasks(plan):
740
+ finalize_plan(plan)
741
+ after_fingerprint = _plan_regeneration_fingerprint(plan)
742
+ changed = before_fingerprint != after_fingerprint
743
+ quality = validate_ide_plan_quality(
744
+ plan,
745
+ verification_commands=resolved_commands
746
+ or list(record.verification_commands)
747
+ or _string_list(plan.get("ide_verification_commands")),
748
+ extra_warnings=[
749
+ *list(getattr(apply_result, "warnings", []) or []),
750
+ *list(getattr(reconciliation, "warnings", []) or []),
751
+ ],
752
+ )
753
+ except ProtocolError:
754
+ raise
755
+ except ForgeError as e:
756
+ raise ProtocolError("forge_plan_failed", str(redact_secrets(str(e)))) from e
757
+ except Exception as e:
758
+ _throw_if_cancelled(cancellation_token)
759
+ raise ProtocolError(
760
+ "forge_plan_failed",
761
+ str(redact_secrets(f"Forge planner regeneration adapter failed: {e}")),
762
+ ) from e
763
+ return ForgePlanRegenerateComputation(
764
+ plan=plan,
765
+ base_revision=old_revision,
766
+ changed=changed,
767
+ quality=quality,
768
+ )
769
+
770
+
771
+ def forge_plan_regenerate_commit(
772
+ record: ForgePlanRecord,
773
+ computation: ForgePlanRegenerateComputation,
774
+ ) -> None:
775
+ """Persist a computed regeneration after re-checking the plan revision.
776
+
777
+ Callers must serialize this against other plan writers (the bridge holds
778
+ its state lock). A revision moved by a concurrent writer during compute
779
+ fails closed with stale_plan_revision and persists nothing.
780
+ """
781
+ current = load_recorded_plan(record, migrate_legacy=False)
782
+ current_revision = _plan_ide_revision(current)
783
+ if current_revision != computation.base_revision:
784
+ raise ProtocolError(
785
+ "stale_plan_revision",
786
+ f"Plan revision is {current_revision}; regeneration was computed from "
787
+ f"revision {computation.base_revision}. Refresh plan state and retry.",
788
+ )
789
+ plan = computation.plan
790
+ if computation.changed:
791
+ plan["ide_revision"] = computation.base_revision + 1
792
+ plan["ide_stale_reason"] = "regenerated"
793
+ plan["ide_regenerated_at"] = now_iso()
794
+ apply_plan_status(plan, validation_warnings=computation.quality["warnings"])
795
+ try:
796
+ save_plan(record.paths, plan)
797
+ _write_plan_validation_artifact(
798
+ paths=record.paths, warnings=computation.quality["warnings"]
799
+ )
800
+ except ForgeError as e:
801
+ raise ProtocolError("forge_plan_failed", str(redact_secrets(str(e)))) from e
802
+
803
+
804
+ def forge_plan_regenerate_render(
805
+ record: ForgePlanRecord,
806
+ computation: ForgePlanRegenerateComputation,
807
+ *,
808
+ cfg: Any,
809
+ ) -> dict[str, Any]:
810
+ plan = computation.plan
811
+ result = forge_plan_state_result(record, plan, cfg=cfg)
812
+ result.update(
813
+ {
814
+ "old_revision": computation.base_revision,
815
+ "new_revision": _plan_ide_revision(plan),
816
+ "changed": computation.changed,
817
+ "summary": str(redact_secrets(str(plan.get("summary") or ""))),
818
+ "warnings": computation.quality["warnings"],
819
+ "validation": forge_plan_validate_result(record, plan),
820
+ "redacted": True,
821
+ "secret_values_included": False,
822
+ "audit": {
823
+ "changed_fields": ["plan"] if computation.changed else [],
824
+ "previous_revision": computation.base_revision,
825
+ "revision": _plan_ide_revision(plan),
826
+ "source": "ide_protocol",
827
+ "operation": "forge.plan.regenerate",
828
+ "secret_values_included": False,
829
+ },
830
+ }
831
+ )
832
+ return result
833
+
834
+
835
+ def forge_plan_regenerate_result(
836
+ record: ForgePlanRecord,
837
+ plan: dict[str, Any],
838
+ *,
839
+ instruction: str | None = None,
840
+ focus: str | None = None,
841
+ expected_revision: Any = None,
842
+ cfg: Any,
843
+ planner_runner: Callable[..., Any] | None = None,
844
+ cancellation_token: Any | None = None,
845
+ ) -> dict[str, Any]:
846
+ computation = forge_plan_regenerate_compute(
847
+ record,
848
+ plan,
849
+ instruction=instruction,
850
+ focus=focus,
851
+ expected_revision=expected_revision,
852
+ cfg=cfg,
853
+ planner_runner=planner_runner,
854
+ cancellation_token=cancellation_token,
855
+ )
856
+ forge_plan_regenerate_commit(record, computation)
857
+ return forge_plan_regenerate_render(record, computation, cfg=cfg)
858
+
859
+
860
+ def _plan_edit_result(
861
+ record: ForgePlanRecord,
862
+ plan: dict[str, Any],
863
+ *,
864
+ cfg: Any,
865
+ changed: bool,
866
+ previous_revision: int,
867
+ changed_fields: list[str],
868
+ ) -> dict[str, Any]:
869
+ payload = forge_plan_state_result(record, plan, cfg=cfg)
870
+ payload["changed"] = bool(changed)
871
+ payload["audit"] = {
872
+ "changed_fields": _redacted_strings(changed_fields),
873
+ "previous_revision": previous_revision,
874
+ "revision": _plan_ide_revision(plan),
875
+ "source": "ide_protocol",
876
+ "secret_values_included": False,
877
+ }
878
+ return payload
879
+
880
+
881
+ def forge_review_result(
882
+ record: ForgePlanRecord,
883
+ plan: dict[str, Any],
884
+ *,
885
+ task_id: str,
886
+ cfg: Any,
887
+ reviewer: Callable[..., Any] | None = None,
888
+ ) -> dict[str, Any]:
889
+ clean_task_id = _required_task_id(task_id)
890
+ validate_forge_run_artifact_root(record)
891
+ try:
892
+ task = find_task(plan, clean_task_id)
893
+ except ForgeError as e:
894
+ raise ProtocolError("task_not_found", str(redact_secrets(str(e)))) from e
895
+ try:
896
+ outcome = (reviewer or review_task)(
897
+ paths=record.paths,
898
+ plan=plan,
899
+ task=task,
900
+ cfg=cfg,
901
+ api_key_override=None,
902
+ )
903
+ except ReviewError as e:
904
+ raise ProtocolError("forge_review_failed", str(redact_secrets(str(e)))) from e
905
+ except Exception as e: # noqa: BLE001
906
+ raise ProtocolError(
907
+ "forge_review_failed",
908
+ str(redact_secrets(f"Forge review failed: {e}")),
909
+ ) from e
910
+ review_json = _read_json_artifact(outcome.json_path, root=record.paths.root)
911
+ review_markdown = _read_text_artifact(outcome.markdown_path, root=record.paths.root)
912
+ return {
913
+ "session_id": record.session_id,
914
+ "plan_id": record.plan_id,
915
+ "task_id": str(redact_secrets(outcome.task_id)),
916
+ "approved": bool(outcome.approved),
917
+ "confidence": str(redact_secrets(outcome.confidence)),
918
+ "summary": str(redact_secrets(outcome.summary)),
919
+ "blocking_issues_count": int(outcome.blocking_issues_count),
920
+ "non_blocking_issues_count": int(outcome.non_blocking_issues_count),
921
+ "review_json": redact_secrets(review_json),
922
+ "review_markdown": str(redact_secrets(review_markdown)),
923
+ "json_artifact_id": f"{forge_artifact_root_name(record.plan_id)}:{_repo_rel(record.paths.root, outcome.json_path)}",
924
+ "markdown_artifact_id": f"{forge_artifact_root_name(record.plan_id)}:{_repo_rel(record.paths.root, outcome.markdown_path)}",
925
+ "requires_human_approval": not bool(outcome.approved),
926
+ "action": (
927
+ None
928
+ if outcome.approved
929
+ else {
930
+ "kind": "review_needed",
931
+ "message": "Forge review returned blocking issues; inspect the structured review before continuing.",
932
+ }
933
+ ),
934
+ }
935
+
936
+
937
+ def forge_attach_result(
938
+ record: ForgePlanRecord,
939
+ *,
940
+ source_path: Path,
941
+ cfg: Any,
942
+ title: str | None = None,
943
+ description: str = "",
944
+ ) -> dict[str, Any]:
945
+ return forge_assets_add_result(
946
+ record,
947
+ source_path=source_path,
948
+ title=title,
949
+ description=description,
950
+ pinned=False,
951
+ wait=False,
952
+ link=True,
953
+ cfg=cfg,
954
+ command="forge.attach",
955
+ )
956
+
957
+
958
+ def forge_assets_list_result(
959
+ record: ForgePlanRecord,
960
+ *,
961
+ cfg: Any,
962
+ include_deleted: bool = False,
963
+ ) -> dict[str, Any]:
964
+ assets = _asset_entries_payload(record, cfg=cfg, include_deleted=include_deleted)
965
+ return {
966
+ "session_id": record.session_id,
967
+ "plan_id": record.plan_id,
968
+ "run_id": record.paths.run_id,
969
+ "assets": assets,
970
+ "count": len(assets),
971
+ "include_deleted": bool(include_deleted),
972
+ }
973
+
974
+
975
+ def forge_assets_show_result(record: ForgePlanRecord, *, asset_id: str, cfg: Any) -> dict[str, Any]:
976
+ surface = _asset_surface(record, cfg=cfg)
977
+ try:
978
+ detail = surface.show_asset(_required_asset_id(asset_id))
979
+ except AssetError as e:
980
+ raise ProtocolError("asset_not_found", str(redact_secrets(str(e)))) from e
981
+ return {
982
+ "session_id": record.session_id,
983
+ "plan_id": record.plan_id,
984
+ "asset": _asset_detail_payload(detail),
985
+ }
986
+
987
+
988
+ def forge_assets_add_result(
989
+ record: ForgePlanRecord,
990
+ *,
991
+ source_path: Path,
992
+ title: str | None,
993
+ description: str = "",
994
+ pinned: bool = False,
995
+ wait: bool = False,
996
+ link: bool = False,
997
+ cfg: Any,
998
+ command: str = "forge.assets.add",
999
+ ) -> dict[str, Any]:
1000
+ source = _resolve_workspace_asset_source(record, source_path)
1001
+ clean_title = str(title or "").strip() or source.name
1002
+ if not clean_title:
1003
+ raise ProtocolError("missing_field", "title is required.")
1004
+ surface = _asset_surface(record, cfg=cfg)
1005
+ try:
1006
+ result = surface.add_asset(
1007
+ source,
1008
+ title=clean_title,
1009
+ description=str(description or ""),
1010
+ pinned=bool(pinned),
1011
+ added_by={"phase": "ide_protocol", "command": command},
1012
+ comprehend="sync" if wait else "skip",
1013
+ dedupe_policy="link" if link else "reject",
1014
+ )
1015
+ bound_task_ids = _bind_asset_to_plan(record, surface=surface, asset_record=result.record)
1016
+ detail = surface.show_asset(result.record.id)
1017
+ except AssetAlreadyExistsError as e:
1018
+ raise ProtocolError(
1019
+ "asset_already_exists",
1020
+ str(redact_secrets(f"{e} existing_id={e.existing_id}")),
1021
+ ) from e
1022
+ except AssetError as e:
1023
+ raise ProtocolError("asset_error", str(redact_secrets(str(e)))) from e
1024
+ return {
1025
+ "session_id": record.session_id,
1026
+ "plan_id": record.plan_id,
1027
+ "asset": _asset_detail_payload(detail),
1028
+ "bound_task_ids": _redacted_strings(bound_task_ids),
1029
+ "comprehension_record": (
1030
+ redact_secrets(result.comprehension_record.to_dict())
1031
+ if result.comprehension_record is not None
1032
+ else None
1033
+ ),
1034
+ "status": "added",
1035
+ }
1036
+
1037
+
1038
+ def forge_assets_delete_result(
1039
+ record: ForgePlanRecord, *, asset_id: str, cfg: Any
1040
+ ) -> dict[str, Any]:
1041
+ clean_asset_id = _required_asset_id(asset_id)
1042
+ surface = _asset_surface(record, cfg=cfg)
1043
+ try:
1044
+ deleted = surface.delete_asset(clean_asset_id)
1045
+ except AssetError as e:
1046
+ raise ProtocolError("asset_error", str(redact_secrets(str(e)))) from e
1047
+ return {
1048
+ "session_id": record.session_id,
1049
+ "plan_id": record.plan_id,
1050
+ "asset": redact_secrets(deleted.to_dict()),
1051
+ "status": "deleted",
1052
+ }
1053
+
1054
+
1055
+ def forge_assets_edit_result(
1056
+ record: ForgePlanRecord,
1057
+ *,
1058
+ asset_id: str,
1059
+ cfg: Any,
1060
+ title: str | None = None,
1061
+ description: str | None = None,
1062
+ pinned: bool | None = None,
1063
+ refresh: bool = False,
1064
+ ) -> dict[str, Any]:
1065
+ clean_asset_id = _required_asset_id(asset_id)
1066
+ if title is None and description is None and pinned is None and not refresh:
1067
+ raise ProtocolError(
1068
+ "missing_field",
1069
+ "forge.assets.edit requires title, description, pinned, or refresh=true.",
1070
+ )
1071
+ surface = _asset_surface(record, cfg=cfg)
1072
+ try:
1073
+ detail = surface.edit_asset(
1074
+ clean_asset_id,
1075
+ title=title,
1076
+ description=description,
1077
+ pinned=pinned,
1078
+ retrigger_comprehension=False,
1079
+ )
1080
+ comprehension = (
1081
+ surface.refresh_comprehension(clean_asset_id, mode="sync").join() if refresh else None
1082
+ )
1083
+ if refresh:
1084
+ detail = surface.show_asset(clean_asset_id)
1085
+ except AssetError as e:
1086
+ raise ProtocolError("asset_error", str(redact_secrets(str(e)))) from e
1087
+ return {
1088
+ "session_id": record.session_id,
1089
+ "plan_id": record.plan_id,
1090
+ "asset": _asset_detail_payload(detail),
1091
+ "comprehension_record": (
1092
+ redact_secrets(comprehension.to_dict()) if comprehension is not None else None
1093
+ ),
1094
+ "status": "updated",
1095
+ }
1096
+
1097
+
1098
+ def forge_assets_refresh_result(
1099
+ record: ForgePlanRecord,
1100
+ *,
1101
+ asset_id: str,
1102
+ cfg: Any,
1103
+ ) -> dict[str, Any]:
1104
+ clean_asset_id = _required_asset_id(asset_id)
1105
+ surface = _asset_surface(record, cfg=cfg)
1106
+ try:
1107
+ handle = surface.refresh_comprehension(clean_asset_id, mode="sync")
1108
+ comprehension = handle.join()
1109
+ detail = surface.show_asset(clean_asset_id)
1110
+ except AssetError as e:
1111
+ raise ProtocolError("asset_error", str(redact_secrets(str(e)))) from e
1112
+ return {
1113
+ "session_id": record.session_id,
1114
+ "plan_id": record.plan_id,
1115
+ "asset": _asset_detail_payload(detail),
1116
+ "comprehension_record": (
1117
+ redact_secrets(comprehension.to_dict()) if comprehension is not None else None
1118
+ ),
1119
+ "status": detail.comprehension_status,
1120
+ "async_background": False,
1121
+ }
1122
+
1123
+
1124
+ def forge_assets_cancel_pending_result(record: ForgePlanRecord, *, cfg: Any) -> dict[str, Any]:
1125
+ surface = _asset_surface(record, cfg=cfg)
1126
+ cancelled = surface.cancel_pending_comprehensions()
1127
+ return {
1128
+ "session_id": record.session_id,
1129
+ "plan_id": record.plan_id,
1130
+ "cancelled_count": int(cancelled),
1131
+ "status": "cancelled" if cancelled else "idle",
1132
+ "message": "No persistent IDE background comprehensions are running."
1133
+ if not cancelled
1134
+ else "Pending in-process comprehensions were asked to stop.",
1135
+ }
1136
+
1137
+
1138
+ def forge_assets_check_plan_result(
1139
+ record: ForgePlanRecord,
1140
+ plan: dict[str, Any],
1141
+ *,
1142
+ cfg: Any,
1143
+ ) -> dict[str, Any]:
1144
+ surface = _asset_surface(record, cfg=cfg)
1145
+ try:
1146
+ report = asset_reference_check(plan, surface)
1147
+ except AssetError as e:
1148
+ raise ProtocolError("asset_error", str(redact_secrets(str(e)))) from e
1149
+ return {
1150
+ "session_id": record.session_id,
1151
+ "plan_id": record.plan_id,
1152
+ "deleted_referenced": [
1153
+ {"task_id": str(redact_secrets(task_id)), "asset_id": str(redact_secrets(asset_id))}
1154
+ for task_id, asset_id in report.deleted_referenced
1155
+ ],
1156
+ "missing_referenced": [
1157
+ {"task_id": str(redact_secrets(task_id)), "asset_id": str(redact_secrets(asset_id))}
1158
+ for task_id, asset_id in report.missing_referenced
1159
+ ],
1160
+ "pinned_added": _redacted_strings(list(report.pinned_added)),
1161
+ "ok": not report.deleted_referenced and not report.missing_referenced,
1162
+ }
1163
+
1164
+
1165
+ def forge_assets_prune_legacy_result(
1166
+ record: ForgePlanRecord,
1167
+ plan: dict[str, Any],
1168
+ *,
1169
+ cfg: Any,
1170
+ yes: bool,
1171
+ ) -> dict[str, Any]:
1172
+ if _plan_schema_version(plan) < 2:
1173
+ raise ProtocolError(
1174
+ "legacy_prune_unavailable",
1175
+ "Legacy asset pruning requires plan schema_version=2 after migration.",
1176
+ )
1177
+ surface = _asset_surface(record, cfg=cfg)
1178
+ legacy_files = _legacy_asset_files(record.paths)
1179
+ known_hashes = {
1180
+ str(asset.sha256)
1181
+ for asset in surface.index.records(include_deleted=True)
1182
+ if str(asset.sha256 or "").strip()
1183
+ }
1184
+ verified: list[str] = []
1185
+ unverified: list[str] = []
1186
+ for file_path in legacy_files:
1187
+ resolved = _resolve_existing_file(
1188
+ file_path,
1189
+ workspace_root=record.paths.root.resolve(),
1190
+ run_root=record.paths.run_dir.resolve(),
1191
+ not_found_code="legacy_asset_not_found",
1192
+ )
1193
+ digest = _sha256_file(resolved)
1194
+ rel = _repo_rel(record.paths.root, resolved)
1195
+ if digest in known_hashes:
1196
+ verified.append(rel)
1197
+ else:
1198
+ unverified.append(rel)
1199
+ payload: dict[str, Any] = {
1200
+ "session_id": record.session_id,
1201
+ "plan_id": record.plan_id,
1202
+ "verified": _redacted_strings(verified),
1203
+ "unverified": _redacted_strings(unverified),
1204
+ "deleted": [],
1205
+ "requires_confirmation": bool(verified) and not yes,
1206
+ "blocked": bool(unverified),
1207
+ }
1208
+ if unverified or not verified or not yes:
1209
+ return payload
1210
+ deleted: list[str] = []
1211
+ verified_set = set(verified)
1212
+ for file_path in legacy_files:
1213
+ rel = _repo_rel(record.paths.root, file_path)
1214
+ if rel not in verified_set:
1215
+ continue
1216
+ try:
1217
+ file_path.unlink()
1218
+ except OSError as e:
1219
+ raise ProtocolError(
1220
+ "legacy_prune_failed",
1221
+ str(redact_secrets(f"Failed to delete {rel}: {e}")),
1222
+ ) from e
1223
+ deleted.append(rel)
1224
+ _remove_empty_legacy_dirs(record.paths)
1225
+ payload["deleted"] = _redacted_strings(deleted)
1226
+ payload["requires_confirmation"] = False
1227
+ payload["blocked"] = False
1228
+ return payload
1229
+
1230
+
1231
+ def forge_execute_preview_result(
1232
+ record: ForgePlanRecord,
1233
+ plan: dict[str, Any],
1234
+ *,
1235
+ task_ids: list[str],
1236
+ execution_mode: str,
1237
+ workspace_trusted: bool | None,
1238
+ sandbox_profile: str,
1239
+ max_steps: Any = None,
1240
+ no_log: bool = False,
1241
+ cfg: Any | None = None,
1242
+ ) -> dict[str, Any]:
1243
+ selected_tasks = _select_preview_tasks(plan, task_ids)
1244
+ commands = list(record.verification_commands) or _string_list(
1245
+ plan.get("ide_verification_commands")
1246
+ )
1247
+ mode = execution_mode.strip().lower() or "review"
1248
+ if mode not in {"readonly", "review", "auto"}:
1249
+ raise ProtocolError(
1250
+ "invalid_mode", "Forge execute preview mode must be readonly, review, or auto."
1251
+ )
1252
+
1253
+ sandbox = _sandbox_preview(sandbox_profile, cfg=cfg)
1254
+ workspace_trust_required = mode in {"review", "auto"}
1255
+ file_scopes = [_preview_file_scope(task) for task in selected_tasks]
1256
+ verification = [_preview_verification(task, commands) for task in selected_tasks]
1257
+ real_supported = mode in REAL_EXECUTE_SUPPORTED_MODES
1258
+ known_risks = _preview_known_risks(selected_tasks, real_execution_supported=real_supported)
1259
+ missing = _preview_missing_prerequisites(
1260
+ selected_tasks=selected_tasks,
1261
+ verification=verification,
1262
+ workspace_trust_required=workspace_trust_required,
1263
+ workspace_trusted=workspace_trusted,
1264
+ sandbox=sandbox,
1265
+ sandbox_required=mode in {"review", "auto"},
1266
+ )
1267
+ required_approvals = _preview_required_approvals(
1268
+ mode=mode,
1269
+ selected_tasks=selected_tasks,
1270
+ verification=verification,
1271
+ )
1272
+ preview_ready = not missing
1273
+ unsupported_reason = "" if real_supported else REAL_EXECUTE_AUTO_UNSUPPORTED_REASON
1274
+ return {
1275
+ "session_id": record.session_id,
1276
+ "plan_id": record.plan_id,
1277
+ "selected_task_ids": [str(task.get("id") or "") for task in selected_tasks],
1278
+ "execution_mode_requested": mode,
1279
+ "workspace_trust_required": workspace_trust_required,
1280
+ "workspace_trusted": workspace_trusted,
1281
+ "estimated_file_scopes": file_scopes,
1282
+ "verification_commands": verification,
1283
+ "required_approvals": required_approvals,
1284
+ "runtime_approval_requirements": _preview_runtime_approval_requirements(mode),
1285
+ "approval_scopes_safe": _approval_scopes_are_safe(required_approvals),
1286
+ "sandbox_profile": sandbox,
1287
+ "known_risks": _redacted_strings(known_risks),
1288
+ "missing_prerequisites": _redacted_strings(missing),
1289
+ "preview_ready": preview_ready,
1290
+ "real_execution_supported": real_supported,
1291
+ "unsupported_reason": str(redact_secrets(unsupported_reason)),
1292
+ "active_cancellation_supported": True,
1293
+ "cancellation": {
1294
+ "supported": True,
1295
+ "kind": "cooperative_checkpoint",
1296
+ "hard_interrupt": False,
1297
+ },
1298
+ "max_steps": _optional_positive_int(max_steps),
1299
+ "no_log": bool(no_log),
1300
+ "subagents_supported": False,
1301
+ "subagents_enabled": False,
1302
+ "subagents_policy": "disabled_for_ide_forge_execute_v1",
1303
+ "next_recommended_action": _preview_next_action(
1304
+ preview_ready=preview_ready,
1305
+ missing_prerequisites=missing,
1306
+ real_execution_supported=real_supported,
1307
+ ),
1308
+ "status": "preview",
1309
+ }
1310
+
1311
+
1312
+ def list_persisted_forge_plans(
1313
+ *,
1314
+ workspace_root: Path,
1315
+ session_id: str | None = None,
1316
+ active_records: list[ForgePlanRecord] | None = None,
1317
+ max_items: Any = MAX_LISTED_PLANS,
1318
+ ) -> dict[str, Any]:
1319
+ limit = _bounded_plan_count(max_items)
1320
+ active_by_plan = {record.plan_id: record for record in active_records or []}
1321
+ plans: list[dict[str, Any]] = []
1322
+ for paths, plan in _iter_persisted_plans(workspace_root, limit=limit * 2):
1323
+ active = active_by_plan.get(paths.run_id)
1324
+ source = "active_memory" if active is not None else "persisted"
1325
+ plans.append(_plan_summary(paths=paths, plan=plan, session_id=session_id, source=source))
1326
+ plans.sort(key=lambda item: str(item.get("updated_at") or ""), reverse=True)
1327
+ return {
1328
+ "workspace_root": str(workspace_root),
1329
+ "plans": plans[:limit],
1330
+ "truncated": len(plans) > limit,
1331
+ "max_items": limit,
1332
+ }
1333
+
1334
+
1335
+ def open_persisted_forge_plan(
1336
+ *,
1337
+ workspace_root: Path,
1338
+ session_id: str,
1339
+ plan_id: str,
1340
+ source: str = "loaded_persisted",
1341
+ ) -> tuple[ForgePlanRecord, dict[str, Any]]:
1342
+ paths = _paths_for_persisted_plan(workspace_root=workspace_root, plan_id=plan_id)
1343
+ try:
1344
+ _validate_forge_run_paths(paths, require_plan_json=True)
1345
+ plan = load_plan(paths)
1346
+ except ForgeError as e:
1347
+ raise ProtocolError("forge_plan_not_found", str(e)) from e
1348
+ return (
1349
+ ForgePlanRecord(
1350
+ session_id=session_id,
1351
+ plan_id=paths.run_id,
1352
+ paths=paths,
1353
+ source=source,
1354
+ ),
1355
+ plan,
1356
+ )
1357
+
1358
+
1359
+ def forge_artifact_root_name(plan_id: str) -> str:
1360
+ safe = "".join(ch if ch.isalnum() or ch == "_" else "_" for ch in plan_id)
1361
+ return f"forge_{safe}"
1362
+
1363
+
1364
+ def validate_forge_run_artifact_root(record: ForgePlanRecord) -> Path:
1365
+ _validate_forge_run_paths(record.paths, require_plan_json=True)
1366
+ return record.paths.run_dir
1367
+
1368
+
1369
+ def list_diff_records(records: list[ForgePlanRecord]) -> list[DiffRecord]:
1370
+ diffs: list[DiffRecord] = []
1371
+ for record in records:
1372
+ patches_dir = _validated_forge_patches_dir(record)
1373
+ if patches_dir is None:
1374
+ continue
1375
+ run_root = _resolve_existing_directory(record.paths.run_dir, code="forge_registry_rejected")
1376
+ workspace_root = record.paths.root.expanduser().resolve()
1377
+ for path in sorted(patches_dir.glob("*.diff"), key=lambda item: item.name):
1378
+ if path.is_symlink() or not path.is_file():
1379
+ continue
1380
+ try:
1381
+ resolved = path.resolve(strict=True)
1382
+ resolved.relative_to(workspace_root)
1383
+ resolved.relative_to(run_root)
1384
+ resolved.relative_to(patches_dir)
1385
+ size = path.stat().st_size
1386
+ except (OSError, ValueError):
1387
+ continue
1388
+ rel_path = resolved.relative_to(run_root).as_posix()
1389
+ try:
1390
+ preview = _read_limited_text(path, max_bytes=DEFAULT_MAX_DIFF_BYTES).text
1391
+ except ProtocolError:
1392
+ continue
1393
+ changed_files = parse_patch_changed_files(preview)
1394
+ file_path = _diff_file_label(changed_files)
1395
+ status = "truncated" if size > DEFAULT_MAX_DIFF_BYTES else "available"
1396
+ diffs.append(
1397
+ DiffRecord(
1398
+ diff_id=_diff_id(record, rel_path),
1399
+ session_id=record.session_id,
1400
+ plan_id=record.plan_id,
1401
+ job_id=record.job_id,
1402
+ path=path,
1403
+ rel_path=rel_path,
1404
+ file_path=str(redact_secrets(file_path)),
1405
+ status=status,
1406
+ old_label="before",
1407
+ new_label="after",
1408
+ size_bytes=size,
1409
+ )
1410
+ )
1411
+ return diffs
1412
+
1413
+
1414
+ def diff_list_result(records: list[ForgePlanRecord]) -> dict[str, Any]:
1415
+ diffs = list_diff_records(records)
1416
+ return {
1417
+ "diffs": [
1418
+ {
1419
+ "diff_id": record.diff_id,
1420
+ "session_id": record.session_id,
1421
+ "plan_id": record.plan_id,
1422
+ "job_id": record.job_id,
1423
+ "file_path": record.file_path,
1424
+ "status": record.status,
1425
+ "old_label": record.old_label,
1426
+ "new_label": record.new_label,
1427
+ "size_bytes": record.size_bytes,
1428
+ }
1429
+ for record in diffs
1430
+ ],
1431
+ "empty_reason": (
1432
+ "No Forge diff artifacts are available for this scoped plan."
1433
+ if records and not diffs
1434
+ else None
1435
+ ),
1436
+ }
1437
+
1438
+
1439
+ def diff_get_result(
1440
+ records: list[ForgePlanRecord],
1441
+ *,
1442
+ diff_id: str,
1443
+ max_bytes: Any = DEFAULT_MAX_DIFF_BYTES,
1444
+ ) -> dict[str, Any]:
1445
+ clean = diff_id.strip()
1446
+ if not clean:
1447
+ raise ProtocolError("missing_field", "diff_id is required.")
1448
+ max_bytes_int = _bounded_diff_bytes(max_bytes)
1449
+ matching = [record for record in list_diff_records(records) if record.diff_id == clean]
1450
+ if not matching:
1451
+ raise ProtocolError("diff_not_found", "Diff was not found.")
1452
+ record = matching[0]
1453
+ limited = _read_limited_text(record.path, max_bytes=max_bytes_int)
1454
+ return {
1455
+ "diff_id": record.diff_id,
1456
+ "session_id": record.session_id,
1457
+ "plan_id": record.plan_id,
1458
+ "job_id": record.job_id,
1459
+ "file_path": record.file_path,
1460
+ "old_text": None,
1461
+ "new_text": None,
1462
+ "old_artifact_id": None,
1463
+ "new_artifact_id": None,
1464
+ "unified_diff": redact_secrets(limited.text),
1465
+ "truncated": limited.truncated,
1466
+ "size_bytes": record.size_bytes,
1467
+ "max_bytes": max_bytes_int,
1468
+ "redaction": "protocol_preview",
1469
+ }
1470
+
1471
+
1472
+ def forge_execute_review_job_result(
1473
+ record: ForgePlanRecord,
1474
+ plan: dict[str, Any],
1475
+ *,
1476
+ task_ids: list[str],
1477
+ workspace_trusted: bool | None,
1478
+ sandbox_profile: str,
1479
+ cfg: Any,
1480
+ surface: Any,
1481
+ job_id: str,
1482
+ max_steps: Any = None,
1483
+ no_log: bool = False,
1484
+ agent_runner: Callable[..., int] | None = None,
1485
+ cancellation_token: Any | None = None,
1486
+ ) -> dict[str, Any]:
1487
+ _throw_if_cancelled(cancellation_token)
1488
+ if not task_ids:
1489
+ raise ProtocolError("missing_field", "forge.execute requires explicit task_ids.")
1490
+ if not str(getattr(cfg, "model", "") or "").strip():
1491
+ raise ProtocolError("config_error", "Model is not set.")
1492
+ preview = forge_execute_preview_result(
1493
+ record,
1494
+ plan,
1495
+ task_ids=task_ids,
1496
+ execution_mode="review",
1497
+ workspace_trusted=workspace_trusted,
1498
+ sandbox_profile=sandbox_profile,
1499
+ max_steps=max_steps,
1500
+ no_log=no_log,
1501
+ cfg=cfg,
1502
+ )
1503
+ if not preview["real_execution_supported"]:
1504
+ raise ProtocolError(
1505
+ "forge_execute_unsupported",
1506
+ preview["unsupported_reason"] or REAL_EXECUTE_AUTO_UNSUPPORTED_REASON,
1507
+ )
1508
+ if not preview["approval_scopes_safe"]:
1509
+ raise ProtocolError(
1510
+ "forge_execute_prerequisites_failed",
1511
+ "Forge Execute requires exact safe approval scopes.",
1512
+ )
1513
+ if not preview["preview_ready"]:
1514
+ blockers = "; ".join(str(item) for item in preview["missing_prerequisites"])
1515
+ raise ProtocolError(
1516
+ "forge_execute_prerequisites_failed",
1517
+ str(redact_secrets(blockers or "Forge Execute prerequisites are not satisfied.")),
1518
+ )
1519
+
1520
+ selected_tasks = _select_preview_tasks(plan, task_ids)
1521
+ dependency_blockers = _selection_dependency_blockers(plan, selected_tasks)
1522
+ if dependency_blockers:
1523
+ raise ProtocolError(
1524
+ "forge_execute_prerequisites_failed",
1525
+ str(redact_secrets("; ".join(dependency_blockers))),
1526
+ )
1527
+
1528
+ validate_forge_run_artifact_root(record)
1529
+ ensure_execution_dirs(record.paths)
1530
+ sandbox_cfg = _cfg_for_sandbox_profile(cfg, sandbox_profile.strip().lower() or "default")
1531
+ fallback_commands = list(record.verification_commands) or _string_list(
1532
+ plan.get("ide_verification_commands")
1533
+ )
1534
+ results: list[dict[str, Any]] = []
1535
+ all_success = True
1536
+ completed_task_ids = _initial_completed_task_ids(plan)
1537
+ surface.emit_status_update(mode="review", model=getattr(cfg, "model", None))
1538
+ surface.emit_info(f"forge_execute_started {record.plan_id}")
1539
+ for task in selected_tasks:
1540
+ _throw_if_cancelled(cancellation_token)
1541
+ dependency_blockers = _runtime_dependency_blockers(
1542
+ plan=plan,
1543
+ task=task,
1544
+ completed_task_ids=completed_task_ids,
1545
+ )
1546
+ if dependency_blockers:
1547
+ result = _block_review_task(
1548
+ record=record,
1549
+ plan=plan,
1550
+ task=task,
1551
+ summary="; ".join(dependency_blockers),
1552
+ surface=surface,
1553
+ )
1554
+ else:
1555
+ result = _run_single_review_task(
1556
+ record=record,
1557
+ plan=plan,
1558
+ task=task,
1559
+ cfg=sandbox_cfg,
1560
+ surface=surface,
1561
+ fallback_verify_commands=fallback_commands,
1562
+ max_steps=max_steps,
1563
+ no_log=no_log,
1564
+ agent_runner=agent_runner,
1565
+ cancellation_token=cancellation_token,
1566
+ )
1567
+ _throw_if_cancelled(cancellation_token)
1568
+ results.append(result)
1569
+ if result["status"] == "done":
1570
+ completed_task_ids.add(str(task.get("id") or "").strip())
1571
+ else:
1572
+ all_success = False
1573
+ save_plan(record.paths, plan)
1574
+ diff_count = len(list_diff_records([record]))
1575
+ status = "completed" if all_success else "failed"
1576
+ return {
1577
+ "session_id": record.session_id,
1578
+ "plan_id": record.plan_id,
1579
+ "job_id": job_id,
1580
+ "status": status,
1581
+ "selected_task_ids": [str(task.get("id") or "") for task in selected_tasks],
1582
+ "task_results": results,
1583
+ "diff_count": diff_count,
1584
+ "max_steps": _optional_positive_int(max_steps),
1585
+ "no_log": bool(no_log),
1586
+ "subagents_supported": False,
1587
+ "subagents_enabled": False,
1588
+ "subagents_policy": "disabled_for_ide_forge_execute_v1",
1589
+ "active_cancellation_supported": True,
1590
+ }
1591
+
1592
+
1593
+ def _run_single_review_task(
1594
+ *,
1595
+ record: ForgePlanRecord,
1596
+ plan: dict[str, Any],
1597
+ task: dict[str, Any],
1598
+ cfg: Any,
1599
+ surface: Any,
1600
+ fallback_verify_commands: list[str],
1601
+ max_steps: Any,
1602
+ no_log: bool,
1603
+ agent_runner: Callable[..., int] | None,
1604
+ cancellation_token: Any | None = None,
1605
+ ) -> dict[str, Any]:
1606
+ _throw_if_cancelled(cancellation_token)
1607
+ task_id = str(task.get("id") or "").strip()
1608
+ title = str(task.get("title") or "").strip() or task_id
1609
+ paths = record.paths
1610
+ safe_task = safe_task_file_component(task_id)
1611
+ verify_commands = _verification_commands_for_task(task, fallback_verify_commands)
1612
+ allowed_scope = _task_write_scope_for_execution(task)
1613
+ started_at = now_iso()
1614
+ patch_path = paths.execution_patches_dir / f"{safe_task}.diff"
1615
+ verify_path = paths.execution_verify_dir / f"{safe_task}.txt"
1616
+ surface.emit_plan_node_updated(task_id, "in_progress", title)
1617
+ surface.emit_swarm_worker_state_changed(task_id, "running", role="forge_execute_review")
1618
+
1619
+ task_step_budget = resolve_managed_task_step_budget(
1620
+ cfg=cfg,
1621
+ plan=plan,
1622
+ task=task,
1623
+ kind="ide_forge_execute",
1624
+ mode="review",
1625
+ verification_enabled=bool(verify_commands),
1626
+ max_steps_override=_optional_positive_int(max_steps),
1627
+ attempt_count=_task_attempt_count(task),
1628
+ image_count=0,
1629
+ )
1630
+ instruction_bundle = build_task_execution_instruction_bundle(
1631
+ plan=plan,
1632
+ task=task,
1633
+ root=paths.root,
1634
+ cfg=cfg,
1635
+ role_model=str(getattr(cfg, "model", "") or ""),
1636
+ mode="review",
1637
+ yes=False,
1638
+ deny_write_prefixes=[".alysis"],
1639
+ allow_write_globs=allowed_scope,
1640
+ non_interactive=True,
1641
+ verification_enabled=bool(verify_commands),
1642
+ authoritative_verification_commands=verify_commands or None,
1643
+ api_key=None,
1644
+ subagents_enabled=False,
1645
+ )
1646
+ context_path = write_execution_context_artifact(
1647
+ run_paths=paths,
1648
+ task_id=task_id,
1649
+ context_text=instruction_bundle.artifact_text,
1650
+ )
1651
+ budget_payload = instruction_bundle.to_budget_artifact_payload()
1652
+ budget_payload["step_budget"] = task_step_budget.to_payload()
1653
+ budget_path = write_execution_budget_artifact(
1654
+ run_paths=paths,
1655
+ task_id=task_id,
1656
+ payload=budget_payload,
1657
+ )
1658
+ baseline = capture_task_local_workspace_baseline(
1659
+ paths.root, before_commit=head_commit(paths.root)
1660
+ )
1661
+ run_code = 1
1662
+ run_error: str | None = None
1663
+ verify_summary: str | None = None
1664
+ verify_payload: dict[str, Any] | None = None
1665
+ try:
1666
+ runner = agent_runner
1667
+ if runner is None:
1668
+ from ..agent_loop import run_agent as runner
1669
+ try:
1670
+ runner_kwargs = {
1671
+ "cfg": cfg,
1672
+ "root": paths.root,
1673
+ "instruction": instruction_bundle.instruction,
1674
+ "mode": "review",
1675
+ "runtime_kind": RuntimeKind.FORGE_EXEC,
1676
+ "yes": False,
1677
+ "max_steps": task_step_budget.resolved_max_steps,
1678
+ "no_log": no_log,
1679
+ "api_key_override": None,
1680
+ "console": None,
1681
+ "surface": surface,
1682
+ "image_paths": list(instruction_bundle.image_paths) or None,
1683
+ "deny_write_prefixes": [".alysis"],
1684
+ "allow_write_globs": allowed_scope,
1685
+ "non_interactive": True,
1686
+ "session_log_dir_override": paths.execution_sessions_dir,
1687
+ "session_id_override": safe_task,
1688
+ "usage_role": f"ide_forge_execute:{task_id}",
1689
+ "enable_compaction": False,
1690
+ "enable_tool_output_offload": True,
1691
+ "enable_conversation_summarization": True,
1692
+ "compaction_profile": "execution",
1693
+ "enable_chat_turn_step_budget": False,
1694
+ "one_shot_execution": True,
1695
+ "verification_enabled": bool(verify_commands),
1696
+ "authoritative_verification_commands": verify_commands or None,
1697
+ "subagents_enabled": False,
1698
+ }
1699
+ if _call_accepts_keyword(runner, "cancellation_token"):
1700
+ runner_kwargs["cancellation_token"] = cancellation_token
1701
+ run_code = int(runner(**runner_kwargs) or 0)
1702
+ _throw_if_cancelled(cancellation_token)
1703
+ except Exception as exc: # noqa: BLE001
1704
+ _throw_if_cancelled(cancellation_token)
1705
+ run_error = str(redact_secrets(f"agent failed: {exc}"))
1706
+ run_code = 1
1707
+ after_commit = head_commit(paths.root)
1708
+ diff = build_task_local_workspace_reporting_diff(
1709
+ paths.root,
1710
+ baseline=baseline,
1711
+ after_commit=after_commit,
1712
+ )
1713
+ finally:
1714
+ cleanup_task_local_workspace_baseline(baseline)
1715
+
1716
+ patch_path.write_text(diff.patch_text, encoding="utf-8")
1717
+ changed_files = list(diff.changed_files)
1718
+ success = run_code == 0
1719
+ status = "done"
1720
+ summary_parts: list[str] = []
1721
+ if run_error:
1722
+ success = False
1723
+ summary_parts.append(run_error)
1724
+ elif run_code != 0:
1725
+ success = False
1726
+ summary_parts.append(f"agent exited non-zero ({run_code})")
1727
+ if diff.inspection_error:
1728
+ success = False
1729
+ summary_parts.append(str(redact_secrets(diff.inspection_error)))
1730
+ scope_ok, scope_violations = check_scope(changed_files, allowed_scope, root=paths.root)
1731
+ if not scope_ok:
1732
+ success = False
1733
+ preview = ", ".join(scope_violations[:20])
1734
+ if len(scope_violations) > 20:
1735
+ preview += ", ..."
1736
+ summary_parts.append(
1737
+ f"out-of-scope file changes detected ({len(scope_violations)}): {preview}"
1738
+ )
1739
+ if success:
1740
+ verify_allowed = _request_verify_approval(surface, verify_commands)
1741
+ if not verify_allowed:
1742
+ success = False
1743
+ status = "verify_failed"
1744
+ verify_summary = "verification approval denied"
1745
+ summary_parts.append(verify_summary)
1746
+ surface.emit_verify_gate_result(
1747
+ _verification_command_label(verify_commands),
1748
+ False,
1749
+ verify_summary,
1750
+ worker_id=task_id,
1751
+ role="forge_execute_review",
1752
+ )
1753
+ else:
1754
+ verify_result = run_task_verification(
1755
+ root=paths.root,
1756
+ commands=verify_commands,
1757
+ artifact_path=verify_path,
1758
+ cfg=cfg,
1759
+ )
1760
+ verify_summary = str(redact_secrets(verify_result.summary))
1761
+ verify_payload = redact_secrets(
1762
+ verify_run_result_to_payload(root=paths.root, result=verify_result)
1763
+ )
1764
+ surface.emit_verify_gate_result(
1765
+ _verification_command_label(verify_commands),
1766
+ bool(verify_result.all_passed),
1767
+ verify_summary,
1768
+ worker_id=task_id,
1769
+ role="forge_execute_review",
1770
+ )
1771
+ if not verify_result.all_passed:
1772
+ success = False
1773
+ status = "verify_failed"
1774
+ summary_parts.append(f"verification failed: {verify_summary}")
1775
+ if not success and status == "done":
1776
+ status = "failed"
1777
+ if success:
1778
+ summary = "IDE Forge Execute review task completed with scoped artifacts and passing verification."
1779
+ surface.emit_review_gate_decision(
1780
+ "accepted",
1781
+ summary,
1782
+ worker_id=task_id,
1783
+ role="forge_execute_review",
1784
+ )
1785
+ else:
1786
+ summary = "; ".join(summary_parts) or "IDE Forge Execute review task failed."
1787
+ surface.emit_review_gate_decision(
1788
+ "blocked",
1789
+ summary,
1790
+ worker_id=task_id,
1791
+ role="forge_execute_review",
1792
+ )
1793
+ finished_at = now_iso()
1794
+ report_path = write_task_report(
1795
+ paths=paths,
1796
+ task=task,
1797
+ result="success" if success else "failure",
1798
+ result_kind="ide_review_execute",
1799
+ summary=str(redact_secrets(summary)),
1800
+ started_at=started_at,
1801
+ finished_at=finished_at,
1802
+ changed_files=changed_files,
1803
+ verify_commands=verify_commands,
1804
+ patch_path=patch_path,
1805
+ budget_artifact_path=budget_path,
1806
+ execution_log_artifacts=None,
1807
+ verify_artifact_path=verify_path if verify_path.exists() else None,
1808
+ verify_summary=verify_summary,
1809
+ verify_payload=verify_payload,
1810
+ verify_command_source="ide_forge_execute",
1811
+ merge_result="not merged: IDE review-mode execution leaves changes in the workspace",
1812
+ )
1813
+ set_task_status(plan, task_id, status)
1814
+ save_plan(paths, plan)
1815
+ surface.emit_plan_node_updated(task_id, status, title)
1816
+ surface.emit_swarm_worker_state_changed(
1817
+ task_id, "completed" if success else "failed", role="forge_execute_review"
1818
+ )
1819
+ if not success:
1820
+ surface.emit_error(
1821
+ "forge_execute_task_failed",
1822
+ summary,
1823
+ False,
1824
+ worker_id=task_id,
1825
+ role="forge_execute_review",
1826
+ )
1827
+ return {
1828
+ "task_id": str(redact_secrets(task_id)),
1829
+ "status": status,
1830
+ "success": success,
1831
+ "summary": str(redact_secrets(summary)),
1832
+ "changed_files": _redacted_strings(changed_files),
1833
+ "report_artifact_id": f"{forge_artifact_root_name(record.plan_id)}:{_repo_rel(paths.root, report_path)}",
1834
+ "patch_artifact_id": f"{forge_artifact_root_name(record.plan_id)}:{_repo_rel(paths.root, patch_path)}",
1835
+ "context_artifact_id": f"{forge_artifact_root_name(record.plan_id)}:{_repo_rel(paths.root, context_path)}",
1836
+ "verify_summary": verify_summary,
1837
+ }
1838
+
1839
+
1840
+ def _block_review_task(
1841
+ *,
1842
+ record: ForgePlanRecord,
1843
+ plan: dict[str, Any],
1844
+ task: dict[str, Any],
1845
+ summary: str,
1846
+ surface: Any,
1847
+ ) -> dict[str, Any]:
1848
+ task_id = str(task.get("id") or "").strip()
1849
+ title = str(task.get("title") or "").strip() or task_id
1850
+ clean_summary = str(redact_secrets(summary or "Task dependencies are not satisfied."))
1851
+ started_at = now_iso()
1852
+ finished_at = now_iso()
1853
+ paths = record.paths
1854
+ ensure_execution_dirs(paths)
1855
+ report_path = paths.execution_reports_dir / f"{safe_task_file_component(task_id)}.md"
1856
+ report_path.write_text(
1857
+ "\n".join(
1858
+ [
1859
+ f"# Task Execution Report: {task_id}",
1860
+ "",
1861
+ f"- Task Title: {str(redact_secrets(title))}",
1862
+ f"- Started At: {started_at}",
1863
+ f"- Finished At: {finished_at}",
1864
+ "- Result: blocked",
1865
+ "- Result Kind: ide_review_execute",
1866
+ "- Patch: (none)",
1867
+ "- Verify Artifact: (none)",
1868
+ "- Merge Result: blocked before execution",
1869
+ "",
1870
+ "## Summary",
1871
+ "",
1872
+ clean_summary,
1873
+ "",
1874
+ ]
1875
+ ),
1876
+ encoding="utf-8",
1877
+ )
1878
+ set_task_status(plan, task_id, "blocked")
1879
+ save_plan(paths, plan)
1880
+ surface.emit_plan_node_updated(task_id, "blocked", title)
1881
+ surface.emit_swarm_worker_state_changed(task_id, "blocked", role="forge_execute_review")
1882
+ surface.emit_warning(clean_summary, worker_id=task_id, role="forge_execute_review")
1883
+ surface.emit_review_gate_decision(
1884
+ "blocked",
1885
+ clean_summary,
1886
+ worker_id=task_id,
1887
+ role="forge_execute_review",
1888
+ )
1889
+ return {
1890
+ "task_id": str(redact_secrets(task_id)),
1891
+ "status": "blocked",
1892
+ "success": False,
1893
+ "summary": clean_summary,
1894
+ "changed_files": [],
1895
+ "report_artifact_id": f"{forge_artifact_root_name(record.plan_id)}:{_repo_rel(paths.root, report_path)}",
1896
+ "patch_artifact_id": None,
1897
+ "context_artifact_id": None,
1898
+ "verify_summary": None,
1899
+ }
1900
+
1901
+
1902
+ def _repo_rel(root: Path, path: Path) -> str:
1903
+ try:
1904
+ return path.resolve().relative_to(root.resolve()).as_posix()
1905
+ except (OSError, ValueError):
1906
+ return path.name
1907
+
1908
+
1909
+ def _asset_surface(record: ForgePlanRecord, *, cfg: Any) -> Any:
1910
+ validate_forge_run_artifact_root(record)
1911
+ try:
1912
+ return build_asset_surface(cfg=cfg, run_paths=record.paths)
1913
+ except AssetError as e:
1914
+ raise ProtocolError("asset_error", str(redact_secrets(str(e)))) from e
1915
+
1916
+
1917
+ def _asset_entries_payload(
1918
+ record: ForgePlanRecord,
1919
+ *,
1920
+ cfg: Any,
1921
+ include_deleted: bool,
1922
+ ) -> list[dict[str, Any]]:
1923
+ surface = _asset_surface(record, cfg=cfg)
1924
+ try:
1925
+ entries = surface.list_assets(include_deleted=include_deleted)
1926
+ except AssetError as e:
1927
+ raise ProtocolError("asset_error", str(redact_secrets(str(e)))) from e
1928
+ return [_asset_entry_payload(entry) for entry in entries]
1929
+
1930
+
1931
+ def _asset_entry_payload(entry: AssetSurfaceEntry) -> dict[str, Any]:
1932
+ return redact_secrets(
1933
+ {
1934
+ "record": entry.record.to_dict(),
1935
+ "comprehension_status": entry.comprehension_status,
1936
+ "comprehension_source": entry.comprehension_source,
1937
+ "comprehension_summary_preview": entry.comprehension_summary_preview,
1938
+ "detected_language": entry.detected_language,
1939
+ }
1940
+ )
1941
+
1942
+
1943
+ def _asset_detail_payload(detail: AssetSurfaceDetail) -> dict[str, Any]:
1944
+ return redact_secrets(
1945
+ {
1946
+ "record": detail.record.to_dict(),
1947
+ "comprehension_status": detail.comprehension_status,
1948
+ "comprehension": (
1949
+ detail.comprehension.to_dict() if detail.comprehension is not None else None
1950
+ ),
1951
+ "versions": detail.versions,
1952
+ "extracted_text_preview": detail.extracted_text_preview,
1953
+ }
1954
+ )
1955
+
1956
+
1957
+ def _legacy_plan_assets_payload(plan: dict[str, Any]) -> list[dict[str, Any]]:
1958
+ assets = plan.get("assets")
1959
+ if not isinstance(assets, list):
1960
+ return []
1961
+ return [redact_secrets(dict(asset)) for asset in assets if isinstance(asset, dict)]
1962
+
1963
+
1964
+ def _bind_asset_to_plan(record: ForgePlanRecord, *, surface: Any, asset_record: Any) -> list[str]:
1965
+ try:
1966
+ plan = load_plan(record.paths)
1967
+ active_records = surface.index.records(include_deleted=False)
1968
+ bound_task_ids = bind_asset_to_matching_tasks(
1969
+ plan=plan,
1970
+ record=asset_record,
1971
+ active_records=active_records,
1972
+ )
1973
+ if bound_task_ids:
1974
+ save_plan(record.paths, plan)
1975
+ return [str(task_id) for task_id in bound_task_ids]
1976
+ except (ForgeError, AssetError):
1977
+ return []
1978
+
1979
+
1980
+ def _required_task_id(value: str) -> str:
1981
+ clean = str(value or "").strip()
1982
+ if not clean:
1983
+ raise ProtocolError("missing_field", "task_id is required.")
1984
+ if len(clean) > 128 or "/" in clean or "\\" in clean:
1985
+ raise ProtocolError("invalid_task_id", "task_id must be an opaque Forge task id.")
1986
+ return clean
1987
+
1988
+
1989
+ def _plan_ide_revision(plan: dict[str, Any]) -> int:
1990
+ try:
1991
+ revision = int(plan.get("ide_revision", 0) or 0)
1992
+ except (TypeError, ValueError):
1993
+ revision = 0
1994
+ return max(0, revision)
1995
+
1996
+
1997
+ def _check_expected_revision(plan: dict[str, Any], expected_revision: Any) -> int:
1998
+ current = _plan_ide_revision(plan)
1999
+ if expected_revision is None:
2000
+ return current
2001
+ try:
2002
+ expected = int(expected_revision)
2003
+ except (TypeError, ValueError) as e:
2004
+ raise ProtocolError(
2005
+ "invalid_revision",
2006
+ "expected_revision must be an integer when provided.",
2007
+ ) from e
2008
+ if expected != current:
2009
+ raise ProtocolError(
2010
+ "stale_plan_revision",
2011
+ f"Plan revision is {current}; caller expected {expected}. Refresh plan state and retry.",
2012
+ )
2013
+ return current
2014
+
2015
+
2016
+ def _safe_plan_edit_text(value: str, *, field: str) -> str:
2017
+ text = str(value or "").strip()
2018
+ if "\x00" in text:
2019
+ raise ProtocolError("invalid_field", f"{field} must not contain NUL bytes.")
2020
+ if len(text) > MAX_PLAN_EDIT_TEXT_CHARS:
2021
+ raise ProtocolError(
2022
+ "invalid_field",
2023
+ f"{field} is too large for an IDE plan edit.",
2024
+ )
2025
+ return str(redact_secrets(text))
2026
+
2027
+
2028
+ def _regeneration_instruction(
2029
+ plan: dict[str, Any],
2030
+ *,
2031
+ instruction: str,
2032
+ focus: str,
2033
+ ) -> str:
2034
+ if instruction:
2035
+ base = instruction
2036
+ else:
2037
+ goal = str(redact_secrets(str(plan.get("project_goal") or ""))).strip()
2038
+ summary = str(redact_secrets(str(plan.get("summary") or ""))).strip()
2039
+ base = "Regenerate the active Forge plan using the current workspace context."
2040
+ if goal:
2041
+ base += f" Goal: {goal}."
2042
+ elif summary:
2043
+ base += f" Current summary: {summary}."
2044
+ if focus:
2045
+ return f"{base}\n\nFocus: {focus}"
2046
+ return base
2047
+
2048
+
2049
+ def _plan_regeneration_fingerprint(plan: dict[str, Any]) -> str:
2050
+ comparable = dict(plan)
2051
+ for key in ("updated_at", "ide_revision", "ide_regenerated_at"):
2052
+ comparable.pop(key, None)
2053
+ return json.dumps(
2054
+ comparable,
2055
+ sort_keys=True,
2056
+ ensure_ascii=False,
2057
+ separators=(",", ":"),
2058
+ default=str,
2059
+ )
2060
+
2061
+
2062
+ def _plan_assistant_payload(plan: dict[str, Any]) -> dict[str, Any]:
2063
+ raw = plan.get("ide_assistant")
2064
+ if not isinstance(raw, dict):
2065
+ return {"instruction": "", "updated_at": None, "source": "default"}
2066
+ return {
2067
+ "instruction": str(redact_secrets(str(raw.get("instruction") or ""))),
2068
+ "updated_at": str(raw.get("updated_at") or "") or None,
2069
+ "source": str(raw.get("source") or "ide_protocol"),
2070
+ }
2071
+
2072
+
2073
+ def _required_asset_id(value: str) -> str:
2074
+ clean = str(value or "").strip()
2075
+ if not clean:
2076
+ raise ProtocolError("missing_field", "asset_id is required.")
2077
+ if ASSET_ID_PATTERN.fullmatch(clean) is None or "/" in clean or "\\" in clean:
2078
+ raise ProtocolError("invalid_asset_id", "asset_id must be an opaque Forge asset id.")
2079
+ return clean
2080
+
2081
+
2082
+ def _resolve_workspace_asset_source(record: ForgePlanRecord, source_path: Path) -> Path:
2083
+ validate_forge_run_artifact_root(record)
2084
+ root = record.paths.root.expanduser().resolve()
2085
+ raw = Path(source_path).expanduser()
2086
+ candidate = raw if raw.is_absolute() else root / raw
2087
+ try:
2088
+ candidate.relative_to(root)
2089
+ except ValueError as e:
2090
+ raise ProtocolError(
2091
+ "asset_path_outside_workspace",
2092
+ "Asset source paths must stay inside the resolved workspace.",
2093
+ ) from e
2094
+ if _path_or_parent_is_symlink(candidate, root=root):
2095
+ raise ProtocolError("asset_symlink_rejected", "Asset source paths may not be symlinks.")
2096
+ try:
2097
+ resolved = candidate.resolve(strict=False)
2098
+ resolved.relative_to(root)
2099
+ except (OSError, ValueError) as e:
2100
+ raise ProtocolError(
2101
+ "asset_path_outside_workspace",
2102
+ "Asset source paths must stay inside the resolved workspace.",
2103
+ ) from e
2104
+ if not resolved.exists():
2105
+ raise ProtocolError("asset_not_found", "Asset source path does not exist.")
2106
+ if not resolved.is_file():
2107
+ raise ProtocolError("asset_not_file", "Asset source path must be a regular file.")
2108
+ try:
2109
+ size = resolved.stat().st_size
2110
+ except OSError as e:
2111
+ raise ProtocolError("asset_not_found", "Asset source path could not be inspected.") from e
2112
+ if size > MAX_FORGE_ASSET_BYTES:
2113
+ raise ProtocolError(
2114
+ "asset_too_large",
2115
+ f"Asset source exceeds the maximum supported size ({MAX_FORGE_ASSET_BYTES} bytes).",
2116
+ )
2117
+ return resolved
2118
+
2119
+
2120
+ def _path_or_parent_is_symlink(path: Path, *, root: Path) -> bool:
2121
+ try:
2122
+ relative = path.relative_to(root)
2123
+ except ValueError:
2124
+ return True
2125
+ probe = root
2126
+ for part in relative.parts:
2127
+ probe = probe / part
2128
+ if _path_is_symlink(probe):
2129
+ return True
2130
+ if probe == path:
2131
+ break
2132
+ return False
2133
+
2134
+
2135
+ def _read_json_artifact(path: Path, *, root: Path) -> dict[str, Any] | None:
2136
+ resolved = _resolve_artifact_file_for_read(path, root=root)
2137
+ if resolved is None:
2138
+ return None
2139
+ try:
2140
+ payload = json.loads(resolved.read_text(encoding="utf-8"))
2141
+ except (OSError, json.JSONDecodeError):
2142
+ return None
2143
+ return payload if isinstance(payload, dict) else None
2144
+
2145
+
2146
+ def _read_text_artifact(path: Path, *, root: Path, max_bytes: int = DEFAULT_MAX_DIFF_BYTES) -> str:
2147
+ resolved = _resolve_artifact_file_for_read(path, root=root)
2148
+ if resolved is None:
2149
+ return ""
2150
+ return _read_limited_text(resolved, max_bytes=max_bytes).text
2151
+
2152
+
2153
+ def _resolve_artifact_file_for_read(path: Path, *, root: Path) -> Path | None:
2154
+ if _path_is_symlink(path):
2155
+ return None
2156
+ try:
2157
+ resolved = path.resolve(strict=True)
2158
+ resolved.relative_to(root.resolve())
2159
+ except (OSError, ValueError):
2160
+ return None
2161
+ return resolved if resolved.is_file() else None
2162
+
2163
+
2164
+ def _plan_schema_version(plan: dict[str, Any]) -> int:
2165
+ try:
2166
+ return int(plan.get("schema_version", 1) or 1)
2167
+ except (TypeError, ValueError):
2168
+ return 1
2169
+
2170
+
2171
+ def _legacy_asset_files(run_paths: RunPaths) -> list[Path]:
2172
+ files: list[Path] = []
2173
+ for root in (run_paths.assets_dir, run_paths.assets_text_dir):
2174
+ if _path_is_symlink(root):
2175
+ _raise_forge_registry_rejected()
2176
+ if not root.exists():
2177
+ continue
2178
+ root_resolved = _resolve_existing_directory(root, code="forge_registry_rejected")
2179
+ _ensure_resolved_under(root_resolved, run_paths.root.resolve())
2180
+ for path in sorted(root.rglob("*")):
2181
+ if _path_is_symlink(path):
2182
+ _raise_forge_registry_rejected()
2183
+ if path.is_file():
2184
+ files.append(path)
2185
+ return files
2186
+
2187
+
2188
+ def _sha256_file(path: Path) -> str:
2189
+ digest = hashlib.sha256()
2190
+ with path.open("rb") as handle:
2191
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
2192
+ digest.update(chunk)
2193
+ return digest.hexdigest()
2194
+
2195
+
2196
+ def _remove_empty_legacy_dirs(run_paths: RunPaths) -> None:
2197
+ for root in (run_paths.assets_text_dir, run_paths.assets_dir):
2198
+ if not root.exists() or _path_is_symlink(root):
2199
+ continue
2200
+ for directory in sorted(
2201
+ [path for path in root.rglob("*") if path.is_dir() and not path.is_symlink()],
2202
+ key=lambda item: len(item.parts),
2203
+ reverse=True,
2204
+ ):
2205
+ with suppress(OSError):
2206
+ directory.rmdir()
2207
+ with suppress(OSError):
2208
+ root.rmdir()
2209
+
2210
+
2211
+ def _verification_commands_for_task(task: dict[str, Any], fallback: list[str]) -> list[str]:
2212
+ explicit = _string_list(task.get("verification_commands") or task.get("verify_commands"))
2213
+ return explicit or list(fallback)
2214
+
2215
+
2216
+ def _task_write_scope_for_execution(task: dict[str, Any]) -> list[str]:
2217
+ return _string_list(task.get("write_scope")) or _string_list(task.get("estimated_files"))
2218
+
2219
+
2220
+ def _request_verify_approval(surface: Any, commands: list[str]) -> bool:
2221
+ decision = surface.request_approval(
2222
+ ApprovalRequest(
2223
+ kind="verify_run",
2224
+ reason="review mode requires confirmation for exact verification commands",
2225
+ preview="\n".join(f"$ {command}" for command in commands),
2226
+ command=_verification_command_label(commands),
2227
+ allow_for_session_scope=exact_verify_command_set_scope(commands),
2228
+ )
2229
+ )
2230
+ return bool(getattr(decision, "allow", False))
2231
+
2232
+
2233
+ def _verification_command_label(commands: list[str]) -> str:
2234
+ return commands[0] if len(commands) == 1 else f"{len(commands)} verification commands"
2235
+
2236
+
2237
+ def _task_attempt_count(task: dict[str, Any]) -> int:
2238
+ try:
2239
+ return max(1, int(task.get("attempts") or 0) + 1)
2240
+ except (TypeError, ValueError):
2241
+ return 1
2242
+
2243
+
2244
+ def _optional_positive_int(value: Any) -> int | None:
2245
+ if value is None or value == "":
2246
+ return None
2247
+ try:
2248
+ parsed = int(value)
2249
+ except (TypeError, ValueError):
2250
+ return None
2251
+ return parsed if parsed > 0 else None
2252
+
2253
+
2254
+ def _selection_dependency_blockers(
2255
+ plan: dict[str, Any],
2256
+ selected_tasks: list[dict[str, Any]],
2257
+ ) -> list[str]:
2258
+ completed = {
2259
+ str(task.get("id") or "").strip()
2260
+ for task in _plan_tasks(plan)
2261
+ if str(task.get("status") or "").strip().lower() in {"done", "completed"}
2262
+ }
2263
+ blockers: list[str] = []
2264
+ for task in selected_tasks:
2265
+ task_id = str(task.get("id") or "").strip() or "task"
2266
+ for dep_id in _string_list(task.get("dependencies")):
2267
+ if dep_id not in completed:
2268
+ dep_task = find_task(plan, dep_id)
2269
+ dep_status = (
2270
+ str(dep_task.get("status") or "unknown").strip()
2271
+ if isinstance(dep_task, dict)
2272
+ else "missing"
2273
+ )
2274
+ blockers.append(f"{task_id}: dependency {dep_id} is not done ({dep_status}).")
2275
+ completed.add(task_id)
2276
+ return blockers
2277
+
2278
+
2279
+ def _initial_completed_task_ids(plan: dict[str, Any]) -> set[str]:
2280
+ return {
2281
+ str(task.get("id") or "").strip()
2282
+ for task in _plan_tasks(plan)
2283
+ if str(task.get("id") or "").strip()
2284
+ and str(task.get("status") or "").strip().lower() in {"done", "completed"}
2285
+ }
2286
+
2287
+
2288
+ def _runtime_dependency_blockers(
2289
+ *,
2290
+ plan: dict[str, Any],
2291
+ task: dict[str, Any],
2292
+ completed_task_ids: set[str],
2293
+ ) -> list[str]:
2294
+ task_id = str(task.get("id") or "").strip() or "task"
2295
+ blockers: list[str] = []
2296
+ for dep_id in _string_list(task.get("dependencies")):
2297
+ if dep_id in completed_task_ids:
2298
+ continue
2299
+ dep_task = find_task(plan, dep_id)
2300
+ dep_status = (
2301
+ str(dep_task.get("status") or "unknown").strip()
2302
+ if isinstance(dep_task, dict)
2303
+ else "missing"
2304
+ )
2305
+ blockers.append(
2306
+ f"{task_id}: dependency {dep_id} is not completed; current status is {dep_status}."
2307
+ )
2308
+ return blockers
2309
+
2310
+
2311
+ def _plan_tasks(plan: dict[str, Any]) -> list[dict[str, Any]]:
2312
+ tasks = plan.get("tasks")
2313
+ if not isinstance(tasks, list):
2314
+ return []
2315
+ return [task for task in tasks if isinstance(task, dict)]
2316
+
2317
+
2318
+ def _task_to_ide_schema(
2319
+ task: dict[str, Any],
2320
+ *,
2321
+ verification_commands: list[str],
2322
+ warnings: list[str],
2323
+ ) -> dict[str, Any]:
2324
+ task_id = str(task.get("id") or "")
2325
+ title = str(task.get("title") or "").strip() or task_id or "Untitled task"
2326
+ objective = str(task.get("description") or "").strip() or title
2327
+ explicit_verify = _string_list(task.get("verification_commands") or task.get("verify_commands"))
2328
+ return {
2329
+ "task_id": str(redact_secrets(task_id)),
2330
+ "title": str(redact_secrets(title)),
2331
+ "objective": str(redact_secrets(objective)),
2332
+ "file_scope": {
2333
+ "estimated_files": _redacted_string_list(task.get("estimated_files")),
2334
+ "write_scope": _redacted_string_list(task.get("write_scope")),
2335
+ },
2336
+ "acceptance_criteria": _redacted_string_list(task.get("acceptance_criteria")),
2337
+ "verification_commands": _redacted_strings(explicit_verify or verification_commands),
2338
+ "risk_notes": _redacted_string_list(
2339
+ task.get("risk_notes") or task.get("risks") or task.get("risk")
2340
+ ),
2341
+ "dependencies": _redacted_string_list(task.get("dependencies")),
2342
+ "order": _optional_int(task.get("order") or task.get("sequence")),
2343
+ "scope_unknown_reason": str(
2344
+ redact_secrets(str(task.get("scope_unknown_reason") or "").strip())
2345
+ ),
2346
+ "warnings": _redacted_strings(warnings),
2347
+ "status": str(redact_secrets(str(task.get("status") or "planned"))),
2348
+ }
2349
+
2350
+
2351
+ def _string_list(value: Any) -> list[str]:
2352
+ if not isinstance(value, list | tuple):
2353
+ return []
2354
+ return [str(item).strip() for item in value if str(item).strip()]
2355
+
2356
+
2357
+ def _redacted_string_list(value: Any) -> list[str]:
2358
+ if isinstance(value, str):
2359
+ return _redacted_strings([value])
2360
+ return _redacted_strings(_string_list(value))
2361
+
2362
+
2363
+ def _redacted_strings(values: list[str]) -> list[str]:
2364
+ return [str(redact_secrets(value)) for value in values if str(value).strip()]
2365
+
2366
+
2367
+ def _optional_int(value: Any) -> int | None:
2368
+ if isinstance(value, bool):
2369
+ return None
2370
+ try:
2371
+ return int(value)
2372
+ except (TypeError, ValueError):
2373
+ return None
2374
+
2375
+
2376
+ def _workspace_context_payload(paths: RunPaths) -> dict[str, Any] | None:
2377
+ try:
2378
+ return ensure_workspace_context_artifacts(paths).to_dict()
2379
+ except Exception:
2380
+ return None
2381
+
2382
+
2383
+ def _planner_knowledge_section(
2384
+ *,
2385
+ paths: RunPaths,
2386
+ plan: dict[str, Any],
2387
+ instruction: str,
2388
+ ) -> str | None:
2389
+ try:
2390
+ selected = prepare_planner_knowledge(
2391
+ paths=paths,
2392
+ plan=plan,
2393
+ user_text=instruction,
2394
+ selection_label="ide_forge_plan",
2395
+ )
2396
+ return selected.render_prompt_section(
2397
+ workspace_root=resolve_knowledge_workspace_root(paths)
2398
+ )
2399
+ except Exception:
2400
+ return None
2401
+
2402
+
2403
+ def _resolved_verification_commands(cfg: Any, workspace_root: Path) -> list[str]:
2404
+ try:
2405
+ return _redacted_strings(
2406
+ resolve_verify_commands(cfg=cfg, verify_cmd=None, root=workspace_root)
2407
+ )
2408
+ except Exception:
2409
+ return []
2410
+
2411
+
2412
+ def validate_ide_plan_quality(
2413
+ plan: dict[str, Any],
2414
+ *,
2415
+ verification_commands: list[str],
2416
+ extra_warnings: list[str] | tuple[str, ...] = (),
2417
+ ) -> dict[str, Any]:
2418
+ warnings = _sanitize_warnings([*list(extra_warnings), *validate_plan(plan)])
2419
+ task_warnings: dict[str, list[str]] = {}
2420
+ tasks = _plan_tasks(plan)
2421
+ if not tasks:
2422
+ warnings.append("Forge plan has no structured tasks.")
2423
+ for task in tasks:
2424
+ task_id = str(task.get("id") or "").strip() or "task"
2425
+ current: list[str] = []
2426
+ acceptance = _string_list(task.get("acceptance_criteria"))
2427
+ explicit_verify = _string_list(
2428
+ task.get("verification_commands") or task.get("verify_commands")
2429
+ )
2430
+ estimated = _string_list(task.get("estimated_files"))
2431
+ write_scope = _string_list(task.get("write_scope"))
2432
+ scope_unknown_reason = str(task.get("scope_unknown_reason") or "").strip()
2433
+ if _task_is_execution_ready(task):
2434
+ if not acceptance:
2435
+ current.append(f"{task_id}: missing acceptance criteria.")
2436
+ if not explicit_verify and not verification_commands:
2437
+ current.append(f"{task_id}: missing verification commands.")
2438
+ if not estimated and not write_scope and not scope_unknown_reason:
2439
+ current.append(
2440
+ f"{task_id}: missing file scope and no scope_unknown_reason was recorded."
2441
+ )
2442
+ if current:
2443
+ task_warnings[task_id] = _sanitize_warnings(current)
2444
+ warnings.extend(task_warnings[task_id])
2445
+ sanitized = _sanitize_warnings(warnings)
2446
+ return {
2447
+ "warnings": sanitized,
2448
+ "task_warnings": task_warnings,
2449
+ "incomplete": bool(sanitized),
2450
+ }
2451
+
2452
+
2453
+ def _task_is_execution_ready(task: dict[str, Any]) -> bool:
2454
+ if bool(task.get("analysis_only")):
2455
+ return False
2456
+ status = str(task.get("status") or "planned").strip().lower()
2457
+ return status not in {"blocked", "superseded", "invalidated", "done", "completed"}
2458
+
2459
+
2460
+ def _select_preview_tasks(plan: dict[str, Any], task_ids: list[str]) -> list[dict[str, Any]]:
2461
+ tasks = _plan_tasks(plan)
2462
+ by_id = {
2463
+ str(task.get("id") or "").strip(): task
2464
+ for task in tasks
2465
+ if str(task.get("id") or "").strip()
2466
+ }
2467
+ if task_ids:
2468
+ selected: list[dict[str, Any]] = []
2469
+ for task_id in task_ids:
2470
+ task = by_id.get(task_id)
2471
+ if task is None:
2472
+ raise ProtocolError("forge_task_not_found", f"Forge task was not found: {task_id}")
2473
+ selected.append(task)
2474
+ return selected
2475
+ return [task for task in tasks if _task_is_execution_ready(task)]
2476
+
2477
+
2478
+ def _preview_file_scope(task: dict[str, Any]) -> dict[str, Any]:
2479
+ return {
2480
+ "task_id": str(redact_secrets(str(task.get("id") or ""))),
2481
+ "title": str(redact_secrets(str(task.get("title") or ""))),
2482
+ "estimated_files": _redacted_string_list(task.get("estimated_files")),
2483
+ "write_scope": _redacted_string_list(task.get("write_scope")),
2484
+ "scope_unknown_reason": str(
2485
+ redact_secrets(str(task.get("scope_unknown_reason") or "").strip())
2486
+ ),
2487
+ }
2488
+
2489
+
2490
+ def _preview_verification(task: dict[str, Any], fallback_commands: list[str]) -> dict[str, Any]:
2491
+ explicit = _string_list(task.get("verification_commands") or task.get("verify_commands"))
2492
+ commands = explicit or fallback_commands
2493
+ return {
2494
+ "task_id": str(redact_secrets(str(task.get("id") or ""))),
2495
+ "commands": _redacted_strings(commands),
2496
+ "source": "task" if explicit else ("plan_or_config" if commands else "missing"),
2497
+ "missing_reason": (
2498
+ ""
2499
+ if commands
2500
+ else "No task verification commands or plan-level verification commands are available."
2501
+ ),
2502
+ }
2503
+
2504
+
2505
+ def _preview_known_risks(
2506
+ selected_tasks: list[dict[str, Any]],
2507
+ *,
2508
+ real_execution_supported: bool,
2509
+ ) -> list[str]:
2510
+ risks = [
2511
+ "Active Forge runtime cancellation is cooperative and may stop at the next backend checkpoint.",
2512
+ ]
2513
+ if not real_execution_supported:
2514
+ risks.insert(0, REAL_EXECUTE_AUTO_UNSUPPORTED_REASON)
2515
+ for task in selected_tasks:
2516
+ task_id = str(task.get("id") or "task").strip() or "task"
2517
+ for risk in _string_list(task.get("risk_notes") or task.get("risks") or task.get("risk")):
2518
+ risks.append(f"{task_id}: {risk}")
2519
+ for warning in _string_list(task.get("warnings")):
2520
+ risks.append(f"{task_id}: {warning}")
2521
+ return list(dict.fromkeys(risks))
2522
+
2523
+
2524
+ def _preview_missing_prerequisites(
2525
+ *,
2526
+ selected_tasks: list[dict[str, Any]],
2527
+ verification: list[dict[str, Any]],
2528
+ workspace_trust_required: bool,
2529
+ workspace_trusted: bool | None,
2530
+ sandbox: dict[str, Any],
2531
+ sandbox_required: bool,
2532
+ ) -> list[str]:
2533
+ missing: list[str] = []
2534
+ if not selected_tasks:
2535
+ missing.append("No executable Forge tasks were selected.")
2536
+ if workspace_trust_required:
2537
+ if workspace_trusted is False:
2538
+ missing.append("Workspace Trust is required before mutating Forge execution can run.")
2539
+ elif workspace_trusted is None:
2540
+ missing.append(
2541
+ "Workspace Trust status is required before mutating Forge execution can run."
2542
+ )
2543
+ if sandbox.get("supported") is False:
2544
+ missing.append(str(sandbox.get("diagnostic") or "Sandbox profile is unsupported."))
2545
+ elif sandbox_required and sandbox.get("available") is not True:
2546
+ missing.append(str(sandbox.get("diagnostic") or "Sandbox profile is not available."))
2547
+ verification_by_id = {
2548
+ str(item.get("task_id") or ""): item for item in verification if isinstance(item, dict)
2549
+ }
2550
+ for task in selected_tasks:
2551
+ task_id = str(task.get("id") or "task").strip() or "task"
2552
+ if not _task_is_execution_ready(task):
2553
+ status = str(task.get("status") or "unknown").strip().lower() or "unknown"
2554
+ missing.append(f"{task_id}: task status '{status}' is not executable.")
2555
+ continue
2556
+ if not _string_list(task.get("acceptance_criteria")):
2557
+ missing.append(f"{task_id}: acceptance criteria are required before execution.")
2558
+ verify_item = verification_by_id.get(task_id)
2559
+ if not verify_item or not _string_list(verify_item.get("commands")):
2560
+ missing.append(f"{task_id}: verification commands are required before execution.")
2561
+ if _task_is_execution_ready(task):
2562
+ estimated = _string_list(task.get("estimated_files"))
2563
+ write_scope = _string_list(task.get("write_scope"))
2564
+ scope_unknown_reason = str(task.get("scope_unknown_reason") or "").strip()
2565
+ if not estimated and not write_scope and not scope_unknown_reason:
2566
+ missing.append(
2567
+ f"{task_id}: file scope is required, or scope_unknown_reason must explain why it is unknown."
2568
+ )
2569
+ return list(dict.fromkeys(missing))
2570
+
2571
+
2572
+ def _preview_required_approvals(
2573
+ *,
2574
+ mode: str,
2575
+ selected_tasks: list[dict[str, Any]],
2576
+ verification: list[dict[str, Any]],
2577
+ ) -> list[dict[str, Any]]:
2578
+ if mode == "readonly":
2579
+ return []
2580
+ approvals: list[dict[str, Any]] = []
2581
+ executable_task_ids = {
2582
+ str(task.get("id") or "").strip()
2583
+ for task in selected_tasks
2584
+ if _task_is_execution_ready(task)
2585
+ }
2586
+ for task in selected_tasks:
2587
+ if not _task_is_execution_ready(task):
2588
+ continue
2589
+ task_id = str(task.get("id") or "").strip()
2590
+ files = _string_list(task.get("write_scope")) or _string_list(task.get("estimated_files"))
2591
+ if files:
2592
+ scope = exact_file_set_scope(files, operation="fs_write")
2593
+ approvals.append(
2594
+ {
2595
+ "kind": "fs_write",
2596
+ "task_id": str(redact_secrets(task_id)),
2597
+ "reason": "Forge execution may write the scoped task files.",
2598
+ "scope": scope,
2599
+ "allow_for_session_scope": scope,
2600
+ "allow_for_session_supported": True,
2601
+ }
2602
+ )
2603
+ for item in verification:
2604
+ task_id = str(item.get("task_id") or "").strip() if isinstance(item, dict) else ""
2605
+ if task_id not in executable_task_ids:
2606
+ continue
2607
+ commands = _string_list(item.get("commands")) if isinstance(item, dict) else []
2608
+ if commands:
2609
+ scope = exact_verify_command_set_scope(commands)
2610
+ approvals.append(
2611
+ {
2612
+ "kind": "verify_run",
2613
+ "task_id": str(redact_secrets(task_id)),
2614
+ "reason": "Forge execution may run the exact verification command set.",
2615
+ "scope": scope,
2616
+ "allow_for_session_scope": scope,
2617
+ "allow_for_session_supported": True,
2618
+ }
2619
+ )
2620
+ return redact_secrets(approvals)
2621
+
2622
+
2623
+ def _preview_runtime_approval_requirements(mode: str) -> list[dict[str, Any]]:
2624
+ if mode == "readonly":
2625
+ return []
2626
+ return redact_secrets(
2627
+ [
2628
+ {
2629
+ "kind": "shell_run",
2630
+ "reason": (
2631
+ "If review execution runs shell commands, each command is evaluated before "
2632
+ "execution and requires a host-managed exact command-hash approval."
2633
+ ),
2634
+ "scope_requirement": {
2635
+ "type": "exact_command_hash",
2636
+ "deferred_until_command_known": True,
2637
+ },
2638
+ "allow_for_session_supported": True,
2639
+ "warning": None,
2640
+ },
2641
+ {
2642
+ "kind": "custom_tool_run",
2643
+ "reason": (
2644
+ "If review execution invokes a custom tool, the tool invocation is host-managed. "
2645
+ "Unscoped custom-tool approvals cannot be approved broadly for the session."
2646
+ ),
2647
+ "scope_requirement": None,
2648
+ "allow_for_session_supported": False,
2649
+ "warning": "Allow for session is disabled unless the runtime provides an explicit safe scope.",
2650
+ },
2651
+ {
2652
+ "kind": "mcp_tool_run",
2653
+ "reason": (
2654
+ "If review execution invokes MCP-like tools, the invocation remains host-managed "
2655
+ "and must not receive broad session approval by tool kind."
2656
+ ),
2657
+ "scope_requirement": None,
2658
+ "allow_for_session_supported": False,
2659
+ "warning": "Allow for session is disabled unless the runtime provides an explicit safe scope.",
2660
+ },
2661
+ ]
2662
+ )
2663
+
2664
+
2665
+ def _approval_scopes_are_safe(approvals: list[dict[str, Any]]) -> bool:
2666
+ allowed = {"exact_file_set", "exact_verify_command_set"}
2667
+ for approval in approvals:
2668
+ scope = approval.get("allow_for_session_scope") or approval.get("scope")
2669
+ if not isinstance(scope, dict) or str(scope.get("type") or "") not in allowed:
2670
+ return False
2671
+ return True
2672
+
2673
+
2674
+ def _sandbox_preview(raw_profile: str, *, cfg: Any | None = None) -> dict[str, Any]:
2675
+ profile = raw_profile.strip().lower() or "default"
2676
+ supported = profile in SUPPORTED_SANDBOX_PROFILES
2677
+ if not supported:
2678
+ diagnostic = f"Unsupported sandbox profile for IDE preview: {profile}"
2679
+ return {
2680
+ "requested": str(redact_secrets(profile)),
2681
+ "supported": False,
2682
+ "available": False,
2683
+ "diagnostic": diagnostic,
2684
+ }
2685
+ if profile == "off":
2686
+ diagnostic = "Sandbox profile 'off' disables sandboxing; mutating Forge execution requires an available sandbox profile."
2687
+ return {
2688
+ "requested": str(redact_secrets(profile)),
2689
+ "supported": True,
2690
+ "available": False,
2691
+ "diagnostic": diagnostic,
2692
+ }
2693
+ if profile == "warn":
2694
+ diagnostic = (
2695
+ "Sandbox profile 'warn' can fall back to host execution; mutating IDE Forge Execute "
2696
+ "requires strict sandboxing."
2697
+ )
2698
+ return {
2699
+ "requested": str(redact_secrets(profile)),
2700
+ "supported": True,
2701
+ "available": False,
2702
+ "diagnostic": diagnostic,
2703
+ }
2704
+ if cfg is None:
2705
+ diagnostic = (
2706
+ "Sandbox availability is not verified by this bridge call. Run the sandbox doctor "
2707
+ "or start the IDE bridge with a model-backed execution request before mutating execution."
2708
+ )
2709
+ return {
2710
+ "requested": str(redact_secrets(profile)),
2711
+ "supported": True,
2712
+ "available": False,
2713
+ "diagnostic": diagnostic,
2714
+ }
2715
+
2716
+ sandbox_cfg = _cfg_for_sandbox_profile(cfg, profile)
2717
+ try:
2718
+ diagnostic_result = diagnose_sandbox(
2719
+ sandbox_cfg,
2720
+ include_smoke=False,
2721
+ include_server_image=False,
2722
+ )
2723
+ except Exception as exc: # noqa: BLE001 - diagnostics are environment-dependent
2724
+ return {
2725
+ "requested": str(redact_secrets(profile)),
2726
+ "supported": True,
2727
+ "available": False,
2728
+ "diagnostic": str(redact_secrets(f"Sandbox diagnostic failed: {exc}")),
2729
+ }
2730
+
2731
+ configured_mode = str(getattr(diagnostic_result, "configured_mode", "") or "").strip().lower()
2732
+ available = bool(getattr(diagnostic_result, "ready", False)) and configured_mode == "strict"
2733
+ if available:
2734
+ diagnostic = (
2735
+ f"Sandbox ready using {diagnostic_result.selected_backend or configured_mode} "
2736
+ f"({configured_mode})."
2737
+ )
2738
+ elif configured_mode != "strict":
2739
+ diagnostic = (
2740
+ f"Sandbox mode is {configured_mode or 'unknown'}; mutating IDE Forge Execute "
2741
+ "requires strict sandboxing."
2742
+ )
2743
+ else:
2744
+ next_steps = "; ".join(
2745
+ str(item) for item in getattr(diagnostic_result, "next_steps", ()) if str(item)
2746
+ )
2747
+ status = str(getattr(diagnostic_result, "status", "") or "unavailable")
2748
+ diagnostic = next_steps or f"Sandbox is not available ({status})."
2749
+ return {
2750
+ "requested": str(redact_secrets(profile)),
2751
+ "supported": supported,
2752
+ "available": available,
2753
+ "diagnostic": str(redact_secrets(diagnostic)),
2754
+ }
2755
+
2756
+
2757
+ def _cfg_for_sandbox_profile(cfg: Any, profile: str) -> Any:
2758
+ if profile == "default":
2759
+ return cfg
2760
+ try:
2761
+ effective = cfg.model_copy(deep=True)
2762
+ except AttributeError:
2763
+ effective = cfg
2764
+ extra_fields = dict(getattr(effective, "extra_fields", {}) or {})
2765
+ shell_sandbox = dict(extra_fields.get("shell_sandbox") or {})
2766
+ shell_sandbox["mode"] = profile
2767
+ extra_fields["shell_sandbox"] = shell_sandbox
2768
+ verify_sandbox = dict(extra_fields.get("verify_sandbox") or {})
2769
+ verify_sandbox["mode"] = profile
2770
+ extra_fields["verify_sandbox"] = verify_sandbox
2771
+ try:
2772
+ effective.extra_fields = extra_fields
2773
+ except Exception:
2774
+ pass
2775
+ return effective
2776
+
2777
+
2778
+ def _preview_next_action(
2779
+ *,
2780
+ preview_ready: bool,
2781
+ missing_prerequisites: list[str],
2782
+ real_execution_supported: bool,
2783
+ ) -> str:
2784
+ if missing_prerequisites:
2785
+ return "Resolve the missing prerequisites, then run Forge Execute Preview again."
2786
+ if not real_execution_supported:
2787
+ return "Review the preview result. Real execution is unavailable for the requested mode."
2788
+ if preview_ready:
2789
+ return "Review the preview, then explicitly confirm real review-mode execution."
2790
+ return "Review the preview result before attempting execution."
2791
+
2792
+
2793
+ def _sanitize_warnings(values: list[str] | tuple[str, ...]) -> list[str]:
2794
+ return list(
2795
+ dict.fromkeys(str(redact_secrets(value)).strip() for value in values if str(value).strip())
2796
+ )
2797
+
2798
+
2799
+ def _write_plan_validation_artifact(*, paths: RunPaths, warnings: list[str]) -> None:
2800
+ lines = ["# Plan Validation", ""]
2801
+ if warnings:
2802
+ lines.extend(["## Warnings", ""])
2803
+ lines.extend(f"- {warning}" for warning in warnings)
2804
+ else:
2805
+ lines.append("No IDE plan quality warnings.")
2806
+ try:
2807
+ paths.notes_dir.mkdir(parents=True, exist_ok=True)
2808
+ (paths.notes_dir / "plan_validation.md").write_text(
2809
+ "\n".join(lines).rstrip() + "\n",
2810
+ encoding="utf-8",
2811
+ )
2812
+ except OSError:
2813
+ return
2814
+
2815
+
2816
+ def _bounded_plan_count(value: Any) -> int:
2817
+ if value is None or value == "":
2818
+ return MAX_LISTED_PLANS
2819
+ try:
2820
+ parsed = int(value)
2821
+ except (TypeError, ValueError):
2822
+ return MAX_LISTED_PLANS
2823
+ return max(1, min(parsed, MAX_LISTED_PLANS))
2824
+
2825
+
2826
+ def _iter_persisted_plans(
2827
+ workspace_root: Path,
2828
+ *,
2829
+ limit: int,
2830
+ ) -> list[tuple[RunPaths, dict[str, Any]]]:
2831
+ registry = _validated_registry_base(workspace_root, missing_ok=True)
2832
+ if registry is None:
2833
+ return []
2834
+ root, _runtime_dir, runs_dir = registry
2835
+ out: list[tuple[RunPaths, dict[str, Any]]] = []
2836
+ for run_dir in sorted(runs_dir.iterdir(), key=lambda path: path.name, reverse=True):
2837
+ if len(out) >= limit:
2838
+ break
2839
+ if run_dir.is_symlink() or not run_dir.is_dir() or not _valid_plan_id(run_dir.name):
2840
+ continue
2841
+ try:
2842
+ paths = _paths_for_persisted_plan(workspace_root=root, plan_id=run_dir.name)
2843
+ plan = load_plan(paths)
2844
+ except (ForgeError, ProtocolError, OSError):
2845
+ continue
2846
+ out.append((paths, plan))
2847
+ return out
2848
+
2849
+
2850
+ def _paths_for_persisted_plan(*, workspace_root: Path, plan_id: str) -> RunPaths:
2851
+ clean = plan_id.strip()
2852
+ if not _valid_plan_id(clean):
2853
+ raise ProtocolError("invalid_plan_id", "plan_id must be an opaque Forge plan id.")
2854
+ registry = _validated_registry_base(workspace_root, missing_ok=False)
2855
+ if registry is None:
2856
+ raise ProtocolError("forge_plan_not_found", "Forge plan was not found.")
2857
+ root, _runtime_dir, runs_dir = registry
2858
+ paths = make_run_paths(root=root, run_id=clean)
2859
+ try:
2860
+ paths.run_dir.resolve(strict=False).relative_to(runs_dir)
2861
+ except (OSError, ValueError) as e:
2862
+ raise ProtocolError("invalid_plan_id", "plan_id escapes the Forge run registry.") from e
2863
+ if not paths.run_dir.exists():
2864
+ raise ProtocolError("forge_plan_not_found", "Forge plan was not found.")
2865
+ _validate_forge_run_paths(paths, require_plan_json=True)
2866
+ return paths
2867
+
2868
+
2869
+ def _validated_registry_base(
2870
+ workspace_root: Path,
2871
+ *,
2872
+ missing_ok: bool,
2873
+ ) -> tuple[Path, Path, Path] | None:
2874
+ root = workspace_root.expanduser().resolve()
2875
+ runtime_dir = root / ".alysis"
2876
+ if _path_is_symlink(runtime_dir):
2877
+ _raise_forge_registry_rejected()
2878
+ if not runtime_dir.exists():
2879
+ if missing_ok:
2880
+ return None
2881
+ raise ProtocolError("forge_plan_not_found", "Forge plan was not found.")
2882
+ runtime_resolved = _resolve_existing_directory(runtime_dir, code="forge_registry_rejected")
2883
+ _ensure_resolved_under(runtime_resolved, root)
2884
+ runs_dir = runtime_dir / "runs"
2885
+ if _path_is_symlink(runs_dir):
2886
+ _raise_forge_registry_rejected()
2887
+ if not runs_dir.exists():
2888
+ if missing_ok:
2889
+ return None
2890
+ raise ProtocolError("forge_plan_not_found", "Forge plan was not found.")
2891
+ runs_resolved = _resolve_existing_directory(runs_dir, code="forge_registry_rejected")
2892
+ _ensure_resolved_under(runs_resolved, root)
2893
+ _ensure_resolved_under(runs_resolved, runtime_resolved)
2894
+ return root, runtime_resolved, runs_resolved
2895
+
2896
+
2897
+ def _validate_forge_run_paths(paths: RunPaths, *, require_plan_json: bool) -> None:
2898
+ registry = _validated_registry_base(paths.root, missing_ok=False)
2899
+ if registry is None:
2900
+ raise ProtocolError("forge_plan_not_found", "Forge plan was not found.")
2901
+ root, _runtime_dir, runs_dir = registry
2902
+ run_dir = paths.run_dir
2903
+ if _path_is_symlink(run_dir):
2904
+ _raise_forge_registry_rejected()
2905
+ if not run_dir.exists():
2906
+ raise ProtocolError("forge_plan_not_found", "Forge plan was not found.")
2907
+ run_resolved = _resolve_existing_directory(run_dir, code="forge_registry_rejected")
2908
+ _ensure_resolved_under(run_resolved, root)
2909
+ _ensure_resolved_under(run_resolved, runs_dir)
2910
+
2911
+ plan_dir = paths.plan_dir
2912
+ if _path_is_symlink(plan_dir):
2913
+ _raise_forge_registry_rejected()
2914
+ if require_plan_json and not plan_dir.exists():
2915
+ raise ProtocolError("forge_plan_not_found", "Forge plan was not found.")
2916
+ if plan_dir.exists():
2917
+ plan_resolved = _resolve_existing_directory(plan_dir, code="forge_registry_rejected")
2918
+ _ensure_resolved_under(plan_resolved, root)
2919
+ _ensure_resolved_under(plan_resolved, run_resolved)
2920
+
2921
+ if require_plan_json:
2922
+ _resolve_existing_file(
2923
+ paths.plan_json_path,
2924
+ workspace_root=root,
2925
+ run_root=run_resolved,
2926
+ not_found_code="forge_plan_not_found",
2927
+ )
2928
+
2929
+ _validate_optional_directory(paths.execution_dir, workspace_root=root, run_root=run_resolved)
2930
+ _validate_optional_directory(
2931
+ paths.execution_patches_dir,
2932
+ workspace_root=root,
2933
+ run_root=run_resolved,
2934
+ )
2935
+
2936
+
2937
+ def _validated_forge_patches_dir(record: ForgePlanRecord) -> Path | None:
2938
+ _validate_forge_run_paths(record.paths, require_plan_json=True)
2939
+ execution_dir = record.paths.execution_dir
2940
+ if not execution_dir.exists():
2941
+ return None
2942
+ execution_resolved = _resolve_existing_directory(
2943
+ execution_dir,
2944
+ code="forge_registry_rejected",
2945
+ )
2946
+ patches_dir = record.paths.execution_patches_dir
2947
+ if _path_is_symlink(patches_dir):
2948
+ _raise_forge_registry_rejected()
2949
+ if not patches_dir.exists():
2950
+ return None
2951
+ patches_resolved = _resolve_existing_directory(patches_dir, code="forge_registry_rejected")
2952
+ _ensure_resolved_under(patches_resolved, execution_resolved)
2953
+ return patches_resolved
2954
+
2955
+
2956
+ def _validate_optional_directory(path: Path, *, workspace_root: Path, run_root: Path) -> None:
2957
+ if _path_is_symlink(path):
2958
+ _raise_forge_registry_rejected()
2959
+ if not path.exists():
2960
+ return
2961
+ resolved = _resolve_existing_directory(path, code="forge_registry_rejected")
2962
+ _ensure_resolved_under(resolved, workspace_root)
2963
+ _ensure_resolved_under(resolved, run_root)
2964
+
2965
+
2966
+ def _resolve_existing_directory(path: Path, *, code: str) -> Path:
2967
+ try:
2968
+ if not path.is_dir():
2969
+ if code == "forge_plan_not_found":
2970
+ raise ProtocolError("forge_plan_not_found", "Forge plan was not found.")
2971
+ _raise_forge_registry_rejected()
2972
+ return path.resolve(strict=True)
2973
+ except OSError as e:
2974
+ if code == "forge_plan_not_found":
2975
+ raise ProtocolError("forge_plan_not_found", "Forge plan was not found.") from e
2976
+ _raise_forge_registry_rejected(e)
2977
+
2978
+
2979
+ def _resolve_existing_file(
2980
+ path: Path,
2981
+ *,
2982
+ workspace_root: Path,
2983
+ run_root: Path,
2984
+ not_found_code: str,
2985
+ ) -> Path:
2986
+ if _path_is_symlink(path):
2987
+ _raise_forge_registry_rejected()
2988
+ try:
2989
+ if not path.is_file():
2990
+ raise ProtocolError(not_found_code, "Forge plan was not found.")
2991
+ resolved = path.resolve(strict=True)
2992
+ except OSError as e:
2993
+ raise ProtocolError(not_found_code, "Forge plan was not found.") from e
2994
+ try:
2995
+ resolved.relative_to(workspace_root)
2996
+ resolved.relative_to(run_root)
2997
+ except ValueError as e:
2998
+ _raise_forge_registry_rejected(e)
2999
+ return resolved
3000
+
3001
+
3002
+ def _ensure_resolved_under(path: Path, root: Path) -> None:
3003
+ try:
3004
+ path.relative_to(root)
3005
+ except ValueError as e:
3006
+ _raise_forge_registry_rejected(e)
3007
+
3008
+
3009
+ def _path_is_symlink(path: Path) -> bool:
3010
+ try:
3011
+ return path.is_symlink()
3012
+ except OSError:
3013
+ _raise_forge_registry_rejected()
3014
+
3015
+
3016
+ def _raise_forge_registry_rejected(exc: BaseException | None = None) -> None:
3017
+ error = ProtocolError("forge_registry_rejected", FORGE_REGISTRY_REJECTED_MESSAGE)
3018
+ if exc is None:
3019
+ raise error
3020
+ raise error from exc
3021
+
3022
+
3023
+ def _valid_plan_id(value: str) -> bool:
3024
+ return bool(PLAN_ID_PATTERN.fullmatch(value)) and "/" not in value and "\\" not in value
3025
+
3026
+
3027
+ def _plan_summary(
3028
+ *,
3029
+ paths: RunPaths,
3030
+ plan: dict[str, Any],
3031
+ session_id: str | None,
3032
+ source: str,
3033
+ ) -> dict[str, Any]:
3034
+ tasks = _plan_tasks(plan)
3035
+ root_name = forge_artifact_root_name(paths.run_id)
3036
+ return {
3037
+ "plan_id": paths.run_id,
3038
+ "session_id": session_id,
3039
+ "workspace_root": str(paths.root),
3040
+ "status": _plan_status(tasks),
3041
+ "source": source,
3042
+ "project_goal": str(redact_secrets(str(plan.get("project_goal") or ""))),
3043
+ "summary": str(redact_secrets(str(plan.get("summary") or ""))),
3044
+ "task_count": len(tasks),
3045
+ "created_at": str(plan.get("created_at") or ""),
3046
+ "updated_at": str(plan.get("updated_at") or ""),
3047
+ "plan_artifact_id": f"{root_name}:plan/plan.json",
3048
+ "plan_markdown_artifact_id": f"{root_name}:plan/PLAN.md",
3049
+ }
3050
+
3051
+
3052
+ def _plan_status(tasks: list[dict[str, Any]]) -> str:
3053
+ if not tasks:
3054
+ return "incomplete"
3055
+ statuses = {str(task.get("status") or "planned").strip().lower() for task in tasks}
3056
+ if statuses <= {"done", "completed"}:
3057
+ return "completed"
3058
+ if "blocked" in statuses:
3059
+ return "blocked"
3060
+ return "planned"
3061
+
3062
+
3063
+ def _diff_id(record: ForgePlanRecord, rel_path: str) -> str:
3064
+ digest = hashlib.sha256(
3065
+ f"{record.session_id}\0{record.plan_id}\0{rel_path}".encode("utf-8", errors="replace")
3066
+ ).hexdigest()
3067
+ return f"diff_{digest[:32]}"
3068
+
3069
+
3070
+ def _diff_file_label(changed_files: list[str]) -> str:
3071
+ if not changed_files:
3072
+ return "(unknown)"
3073
+ if len(changed_files) == 1:
3074
+ return _safe_rel_label(changed_files[0])
3075
+ return f"{_safe_rel_label(changed_files[0])} (+{len(changed_files) - 1} more)"
3076
+
3077
+
3078
+ def _safe_rel_label(path: str) -> str:
3079
+ pure = PurePosixPath(path.replace("\\", "/"))
3080
+ if pure.is_absolute() or any(part == ".." for part in pure.parts):
3081
+ return Path(path).name or "(unknown)"
3082
+ return pure.as_posix()
3083
+
3084
+
3085
+ @dataclass(frozen=True, slots=True)
3086
+ class _LimitedText:
3087
+ text: str
3088
+ truncated: bool
3089
+
3090
+
3091
+ def _read_limited_text(path: Path, *, max_bytes: int) -> _LimitedText:
3092
+ try:
3093
+ size = path.stat().st_size
3094
+ with path.open("rb") as handle:
3095
+ payload = handle.read(max_bytes + 1)
3096
+ except OSError as e:
3097
+ raise ProtocolError("diff_not_readable", "Diff artifact was not readable.") from e
3098
+ truncated = len(payload) > max_bytes or size > max_bytes
3099
+ return _LimitedText(
3100
+ text=payload[:max_bytes].decode("utf-8", errors="replace"),
3101
+ truncated=truncated,
3102
+ )
3103
+
3104
+
3105
+ def _bounded_diff_bytes(value: Any) -> int:
3106
+ if value is None or value == "":
3107
+ return DEFAULT_MAX_DIFF_BYTES
3108
+ try:
3109
+ parsed = int(value)
3110
+ except (TypeError, ValueError) as e:
3111
+ raise ProtocolError("invalid_field", "max_bytes must be an integer.") from e
3112
+ return max(1, min(parsed, MAX_DIFF_BYTES))