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
alysis_code/forge.py ADDED
@@ -0,0 +1,2064 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import logging
5
+ import os
6
+ import re
7
+ import shutil
8
+ import warnings
9
+ from contextlib import suppress
10
+ from dataclasses import dataclass
11
+ from datetime import UTC, datetime
12
+ from pathlib import Path, PurePosixPath, PureWindowsPath
13
+ from typing import TYPE_CHECKING, Any
14
+
15
+ from .atomic_io import atomic_write_json, atomic_write_text
16
+ from .branding import PROJECT_DIR_NAME, resolve_project_dir
17
+ from .error_text import sanitize_optional_error_summary
18
+ from .mcp.forge_scope import describe_task_mcp_scope, normalize_task_mcp_scope
19
+ from .repo_scan import (
20
+ REPO_SCAN_SCHEMA_VERSION,
21
+ RepoScanResult,
22
+ render_repo_scan_markdown,
23
+ render_repo_scan_summary_lines,
24
+ scan_workspace,
25
+ )
26
+ from .run_state import (
27
+ RUN_STATUS_APPROVED,
28
+ RUN_STATUS_DRAFT,
29
+ RUN_STATUS_RUNNING,
30
+ apply_run_status,
31
+ plan_fingerprint,
32
+ pointer_status,
33
+ reconcile_status_after_crash,
34
+ )
35
+ from .runtime_artifacts import is_runtime_artifact_path
36
+ from .serialized_paths import safe_serialized_path
37
+ from .task_dependencies import infer_ordered_predecessor_dependency
38
+ from .task_readiness import (
39
+ TASK_KIND_ANALYSIS_ONLY,
40
+ has_runnable_local_file_scope,
41
+ manual_task_scope_error_message,
42
+ normalize_task_file_fields,
43
+ )
44
+ from .verify_gate import compact_verification_payload
45
+ from .workspace_binding import (
46
+ WorkspaceAction,
47
+ WorkspaceBinding,
48
+ WorkspaceBindingError,
49
+ WorkspaceRiskLevel,
50
+ ensure_workspace_policy,
51
+ resolve_workspace_binding,
52
+ )
53
+ from .workspace_context import (
54
+ WorkspaceContext,
55
+ WorkspaceContextError,
56
+ resolve_workspace_context,
57
+ )
58
+
59
+ if TYPE_CHECKING:
60
+ from .execution_shared import ExecutionLogArtifactsResult
61
+
62
+ MAX_TEXT_ASSET_BYTES = 200 * 1024
63
+ # 5 added the run lifecycle block: ``status`` (see :mod:`alysis_code.run_state`),
64
+ # ``status_updated_at``, ``status_history``, ``run_owner``, and ``plan_fingerprint``.
65
+ # Readers must keep accepting older pointers; they simply read as ``draft``.
66
+ CURRENT_RUN_POINTER_SCHEMA_VERSION = 5
67
+ PLAN_SCHEMA_VERSION = 2
68
+ LOGGER = logging.getLogger(__name__)
69
+
70
+
71
+ class ForgeError(RuntimeError):
72
+ pass
73
+
74
+
75
+ @dataclass(frozen=True)
76
+ class RunPaths:
77
+ root: Path
78
+ run_id: str
79
+ runtime_dir: Path
80
+ runs_dir: Path
81
+ run_dir: Path
82
+ plan_dir: Path
83
+ plan_json_path: Path
84
+ plan_md_path: Path
85
+ decisions_path: Path
86
+ risks_path: Path
87
+ assets_dir: Path
88
+ assets_text_dir: Path
89
+ asset_store_dir: Path
90
+ assets_index_path: Path
91
+ assets_index_lock_path: Path
92
+ assets_raw_dir: Path
93
+ assets_extracted_dir: Path
94
+ assets_comprehensions_dir: Path
95
+ notes_dir: Path
96
+ notes_path: Path
97
+ planner_chat_path: Path
98
+ planner_summary_path: Path
99
+ plan_replans_dir: Path
100
+ execution_dir: Path
101
+ execution_reports_dir: Path
102
+ execution_patches_dir: Path
103
+ execution_logs_dir: Path
104
+ execution_sessions_dir: Path
105
+ execution_reviews_dir: Path
106
+ execution_verify_dir: Path
107
+ execution_context_dir: Path
108
+ execution_budgets_dir: Path
109
+ execution_asset_briefings_dir: Path
110
+ execution_asset_usage_dir: Path
111
+ execution_knowledge_capture_dir: Path
112
+ execution_integration_dir: Path
113
+ execution_integration_issues_path: Path
114
+ knowledge_dir: Path
115
+ knowledge_index_path: Path
116
+ knowledge_task_attempts_dir: Path
117
+ knowledge_issues_dir: Path
118
+ knowledge_facts_dir: Path
119
+ knowledge_decisions_dir: Path
120
+ knowledge_selected_dir: Path
121
+ plan_context_dir: Path | None = None
122
+ workspace_context_json_path: Path | None = None
123
+ workspace_summary_md_path: Path | None = None
124
+ focus_path: Path | None = None
125
+ focus_relpath: str = "."
126
+ workspace_kind: str = "plain_dir"
127
+ git_root: Path | None = None
128
+ has_head_commit: bool = False
129
+ greenfield: bool = False
130
+ current_branch: str | None = None
131
+ binding_requested_path: Path | None = None
132
+ binding_source: str = "explicit_path"
133
+ workspace_created_at_startup: bool = False
134
+ binding_risk_level: str = WorkspaceRiskLevel.HEALTHY
135
+ binding_risk_reasons: tuple[str, ...] = ()
136
+ binding_broad_workspace_override_used: bool = False
137
+
138
+
139
+ def now_iso() -> str:
140
+ return datetime.now(UTC).replace(microsecond=0).isoformat()
141
+
142
+
143
+ # Words for counter-based run ids: legendary smiths, bright stars, and smithing
144
+ # terms. Order is load-bearing — the word is a pure function of the counter, so
145
+ # reordering or removing entries renames future runs at existing counters.
146
+ RUN_NAME_WORDS: tuple[str, ...] = (
147
+ "vulcan",
148
+ "vega",
149
+ "wayland",
150
+ "sirius",
151
+ "brigid",
152
+ "altair",
153
+ "sindri",
154
+ "rigel",
155
+ "brokkr",
156
+ "deneb",
157
+ "ilmarinen",
158
+ "atlas",
159
+ "svarog",
160
+ "mira",
161
+ "goibniu",
162
+ "lyra",
163
+ "kalvis",
164
+ "orion",
165
+ "seppo",
166
+ "castor",
167
+ "anvil",
168
+ "pollux",
169
+ "ember",
170
+ "capella",
171
+ "ingot",
172
+ "antares",
173
+ "crucible",
174
+ "spica",
175
+ "hearth",
176
+ "mizar",
177
+ "kiln",
178
+ "alcor",
179
+ "rivet",
180
+ "procyon",
181
+ "alloy",
182
+ "canopus",
183
+ "temper",
184
+ "arcturus",
185
+ "bellows",
186
+ "regulus",
187
+ "tongs",
188
+ "thuban",
189
+ "flux",
190
+ "kochab",
191
+ "forgefire",
192
+ "alkaid",
193
+ "quench",
194
+ "hamal",
195
+ "smelt",
196
+ "ankaa",
197
+ "brazier",
198
+ "acrux",
199
+ "mantle",
200
+ "alphard",
201
+ "gilder",
202
+ "sabik",
203
+ "solder",
204
+ "shaula",
205
+ "chisel",
206
+ "nunki",
207
+ "sledge",
208
+ "markab",
209
+ "patina",
210
+ "polaris",
211
+ )
212
+
213
+ _RUN_ID_COUNTER_RE = re.compile(r"^(\d{3,6})-[a-z]+$")
214
+
215
+
216
+ def format_run_id(counter: int) -> str:
217
+ word = RUN_NAME_WORDS[(counter - 1) % len(RUN_NAME_WORDS)]
218
+ return f"{counter:03d}-{word}"
219
+
220
+
221
+ def _next_run_counter(runs_dir: Path) -> int:
222
+ highest = 0
223
+ try:
224
+ entries = list(runs_dir.iterdir())
225
+ except OSError:
226
+ return 1
227
+ for entry in entries:
228
+ match = _RUN_ID_COUNTER_RE.match(entry.name)
229
+ if match is not None:
230
+ highest = max(highest, int(match.group(1)))
231
+ return highest + 1
232
+
233
+
234
+ _RUN_COUNTER_MARK_NAME = ".run_counter"
235
+
236
+
237
+ def _legacy_run_id() -> str:
238
+ ts = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
239
+ import uuid
240
+
241
+ return f"{ts}_{uuid.uuid4().hex[:8]}"
242
+
243
+
244
+ def _stored_run_counter(runs_dir: Path) -> int:
245
+ try:
246
+ raw = (runs_dir / _RUN_COUNTER_MARK_NAME).read_text(encoding="utf-8")
247
+ return max(0, int(raw.strip()))
248
+ except (OSError, ValueError):
249
+ return 0
250
+
251
+
252
+ def _store_run_counter(runs_dir: Path, counter: int) -> None:
253
+ value = max(counter, _stored_run_counter(runs_dir))
254
+ try:
255
+ atomic_write_text(runs_dir / _RUN_COUNTER_MARK_NAME, f"{value}\n")
256
+ except OSError:
257
+ # The mark only guards against re-minting ids of deleted runs; the
258
+ # mkdir claim stays the uniqueness authority when it cannot be written.
259
+ pass
260
+
261
+
262
+ def make_run_id(
263
+ root: Path | None = None,
264
+ *,
265
+ runtime_dir_name: str = ".alysis",
266
+ ) -> str:
267
+ if root is None:
268
+ # Without a workspace there is no run counter to scan; fall back to the
269
+ # legacy globally-unique id so uniqueness never depends on shared state.
270
+ return _legacy_run_id()
271
+ resolved = root.expanduser().resolve()
272
+ runs_dir = _runs_dir(resolved, runtime_dir_name=runtime_dir_name)
273
+ counter = max(_next_run_counter(runs_dir), _stored_run_counter(runs_dir) + 1)
274
+ if not resolved.is_dir():
275
+ # Workspace existence is validated later by make_run_paths; never create
276
+ # directories under a root that does not exist yet.
277
+ return format_run_id(counter)
278
+ try:
279
+ runs_dir.mkdir(parents=True, exist_ok=True)
280
+ except OSError:
281
+ # The runs dir cannot be materialized (e.g. a stray file occupies the
282
+ # path). Hand out a legacy globally-unique id instead of an unclaimed
283
+ # counter so uniqueness never degrades; downstream directory creation
284
+ # surfaces the underlying error.
285
+ return _legacy_run_id()
286
+ while True:
287
+ candidate = format_run_id(counter)
288
+ try:
289
+ # mkdir is the atomic claim: concurrent creators that computed the
290
+ # same counter collide here and retry with the next one. The parent
291
+ # was materialized above, so FileExistsError can only mean the
292
+ # candidate itself is taken.
293
+ (runs_dir / candidate).mkdir(exist_ok=False)
294
+ except FileExistsError:
295
+ counter += 1
296
+ continue
297
+ except OSError:
298
+ return _legacy_run_id()
299
+ _store_run_counter(runs_dir, counter)
300
+ return candidate
301
+
302
+
303
+ def ensure_repo_root(root: Path) -> Path:
304
+ resolved = root.expanduser().resolve()
305
+ if not resolved.exists():
306
+ raise ForgeError(f"Workspace path does not exist: {resolved}")
307
+ if not resolved.is_dir():
308
+ raise ForgeError(f"Workspace path is not a directory: {resolved}")
309
+ return resolved
310
+
311
+
312
+ def _ensure_under_root(root: Path, path: Path) -> None:
313
+ try:
314
+ path.resolve().relative_to(root.resolve())
315
+ except ValueError as e:
316
+ raise ForgeError(f"Path escapes workspace root: {path}") from e
317
+
318
+
319
+ def _runtime_dir(root: Path, *, runtime_dir_name: str = PROJECT_DIR_NAME) -> Path:
320
+ if runtime_dir_name == PROJECT_DIR_NAME:
321
+ # A repo carried over from before the rename has a committed
322
+ # ``.sylliptor/`` and no ``.alysis/``. Keep using it in place rather
323
+ # than starting an empty one beside it — renaming a tracked directory
324
+ # would show up as an unexplained diff in the user's working tree.
325
+ return resolve_project_dir(root)
326
+ return root / runtime_dir_name
327
+
328
+
329
+ def _runtime_state_dir(root: Path) -> Path:
330
+ return _runtime_dir(root)
331
+
332
+
333
+ def _runs_dir(root: Path, *, runtime_dir_name: str = ".alysis") -> Path:
334
+ return _runtime_dir(root, runtime_dir_name=runtime_dir_name) / "runs"
335
+
336
+
337
+ def current_run_pointer_path(root: Path) -> Path:
338
+ return _runtime_state_dir(root) / "current_run.json"
339
+
340
+
341
+ def make_run_paths(
342
+ *,
343
+ root: Path,
344
+ run_id: str,
345
+ focus_path: Path | None = None,
346
+ focus_relpath: str = ".",
347
+ workspace_kind: str = "plain_dir",
348
+ git_root: Path | None = None,
349
+ has_head_commit: bool = False,
350
+ greenfield: bool | None = None,
351
+ current_branch: str | None = None,
352
+ binding_requested_path: Path | None = None,
353
+ binding_source: str = "explicit_path",
354
+ workspace_created_at_startup: bool = False,
355
+ binding_risk_level: str = WorkspaceRiskLevel.HEALTHY,
356
+ binding_risk_reasons: tuple[str, ...] | list[str] = (),
357
+ binding_broad_workspace_override_used: bool = False,
358
+ runtime_dir_name: str = ".alysis",
359
+ ) -> RunPaths:
360
+ root = ensure_repo_root(root)
361
+ resolved_focus_path = root if focus_path is None else focus_path.expanduser().resolve()
362
+ _ensure_under_root(root, resolved_focus_path)
363
+ normalized_focus_relpath = _normalize_stored_relpath(focus_relpath)
364
+ resolved_git_root = git_root.expanduser().resolve() if git_root is not None else None
365
+ resolved_requested_path = (
366
+ resolved_focus_path
367
+ if binding_requested_path is None
368
+ else binding_requested_path.expanduser().resolve(strict=False)
369
+ )
370
+ _ensure_under_root(root, resolved_requested_path)
371
+ runtime_dir = _runtime_dir(root, runtime_dir_name=runtime_dir_name)
372
+ runs_dir = _runs_dir(root, runtime_dir_name=runtime_dir_name)
373
+ run_dir = runs_dir / run_id
374
+ plan_dir = run_dir / "plan"
375
+ execution_dir = run_dir / "execution"
376
+ plan_context_dir = plan_dir / "context"
377
+ asset_store_dir = run_dir / "assets"
378
+ resolved_greenfield = (
379
+ bool(greenfield)
380
+ if greenfield is not None
381
+ else derive_greenfield_workspace(
382
+ root=root,
383
+ workspace_kind=workspace_kind or "plain_dir",
384
+ has_head_commit=has_head_commit,
385
+ workspace_created_at_startup=workspace_created_at_startup,
386
+ )
387
+ )
388
+ return RunPaths(
389
+ root=root,
390
+ run_id=run_id,
391
+ runtime_dir=runtime_dir,
392
+ runs_dir=runs_dir,
393
+ run_dir=run_dir,
394
+ plan_dir=plan_dir,
395
+ plan_json_path=plan_dir / "plan.json",
396
+ plan_md_path=plan_dir / "PLAN.md",
397
+ decisions_path=plan_dir / "DECISIONS.md",
398
+ risks_path=plan_dir / "RISKS.md",
399
+ assets_dir=plan_dir / "assets",
400
+ assets_text_dir=plan_dir / "assets_text",
401
+ asset_store_dir=asset_store_dir,
402
+ assets_index_path=asset_store_dir / "index.json",
403
+ assets_index_lock_path=asset_store_dir / "index.lock",
404
+ assets_raw_dir=asset_store_dir / "raw",
405
+ assets_extracted_dir=asset_store_dir / "extracted",
406
+ assets_comprehensions_dir=asset_store_dir / "comprehensions",
407
+ notes_dir=plan_dir / "notes",
408
+ notes_path=plan_dir / "notes" / "user_notes.md",
409
+ planner_chat_path=plan_dir / "notes" / "planner_chat.md",
410
+ planner_summary_path=plan_dir / "notes" / "planner_summary.md",
411
+ plan_replans_dir=plan_dir / "replans",
412
+ execution_dir=execution_dir,
413
+ execution_reports_dir=execution_dir / "reports",
414
+ execution_patches_dir=execution_dir / "patches",
415
+ execution_logs_dir=execution_dir / "logs",
416
+ execution_sessions_dir=execution_dir / "sessions",
417
+ execution_reviews_dir=execution_dir / "reviews",
418
+ execution_verify_dir=execution_dir / "verify",
419
+ execution_context_dir=execution_dir / "context",
420
+ execution_budgets_dir=execution_dir / "budgets",
421
+ execution_asset_briefings_dir=execution_dir / "asset_briefings",
422
+ execution_asset_usage_dir=execution_dir / "asset_usage",
423
+ execution_knowledge_capture_dir=execution_dir / "knowledge_capture",
424
+ execution_integration_dir=execution_dir / "integration",
425
+ execution_integration_issues_path=execution_dir / "integration" / "integration_issues.md",
426
+ knowledge_dir=run_dir / "knowledge",
427
+ knowledge_index_path=run_dir / "knowledge" / "index.json",
428
+ knowledge_task_attempts_dir=run_dir / "knowledge" / "task_attempts",
429
+ knowledge_issues_dir=run_dir / "knowledge" / "issues",
430
+ knowledge_facts_dir=run_dir / "knowledge" / "facts",
431
+ knowledge_decisions_dir=run_dir / "knowledge" / "decisions",
432
+ knowledge_selected_dir=run_dir / "knowledge" / "selected",
433
+ plan_context_dir=plan_context_dir,
434
+ workspace_context_json_path=plan_context_dir / "workspace_context.json",
435
+ workspace_summary_md_path=plan_context_dir / "workspace_summary.md",
436
+ focus_path=resolved_focus_path,
437
+ focus_relpath=normalized_focus_relpath,
438
+ workspace_kind=workspace_kind or "plain_dir",
439
+ git_root=resolved_git_root,
440
+ has_head_commit=has_head_commit,
441
+ greenfield=resolved_greenfield,
442
+ current_branch=current_branch.strip() if current_branch else None,
443
+ binding_requested_path=resolved_requested_path,
444
+ binding_source=binding_source.strip() or "explicit_path",
445
+ workspace_created_at_startup=workspace_created_at_startup,
446
+ binding_risk_level=binding_risk_level.strip() or WorkspaceRiskLevel.HEALTHY,
447
+ binding_risk_reasons=tuple(
448
+ str(reason).strip() for reason in binding_risk_reasons if str(reason).strip()
449
+ ),
450
+ binding_broad_workspace_override_used=binding_broad_workspace_override_used,
451
+ )
452
+
453
+
454
+ def derive_greenfield_workspace(
455
+ *,
456
+ root: Path,
457
+ workspace_kind: str,
458
+ has_head_commit: bool,
459
+ workspace_created_at_startup: bool = False,
460
+ ) -> bool:
461
+ """Classify workspaces where declared plan paths are usually create targets."""
462
+ normalized_kind = str(workspace_kind or "").strip()
463
+ if normalized_kind == "plain_dir":
464
+ return True
465
+ if not has_head_commit:
466
+ return True
467
+ if workspace_created_at_startup:
468
+ return True
469
+ return _workspace_tree_is_empty_or_near_empty(root)
470
+
471
+
472
+ def _workspace_tree_is_empty_or_near_empty(root: Path) -> bool:
473
+ try:
474
+ root_resolved = root.resolve()
475
+ except OSError:
476
+ return False
477
+ visited_dirs = 0
478
+ for current_dir, dirnames, filenames in os.walk(root_resolved):
479
+ visited_dirs += 1
480
+ if visited_dirs > 128:
481
+ return False
482
+ current_path = Path(current_dir)
483
+ kept_dirs: list[str] = []
484
+ for dirname in dirnames:
485
+ rel_path = (current_path / dirname).relative_to(root_resolved).as_posix()
486
+ if not is_runtime_artifact_path(rel_path, root=root_resolved):
487
+ kept_dirs.append(dirname)
488
+ dirnames[:] = kept_dirs
489
+ for filename in filenames:
490
+ rel_path = (current_path / filename).relative_to(root_resolved).as_posix()
491
+ if not is_runtime_artifact_path(rel_path, root=root_resolved):
492
+ return False
493
+ return True
494
+
495
+
496
+ def _repo_rel(root: Path, path: Path) -> str:
497
+ return path.resolve().relative_to(root.resolve()).as_posix()
498
+
499
+
500
+ def _normalize_stored_relpath(value: str | None) -> str:
501
+ raw = str(value or ".").strip() or "."
502
+ if PurePosixPath(raw).is_absolute() or PureWindowsPath(raw).is_absolute():
503
+ raise ForgeError("workspace relative path must be relative")
504
+ normalized = PurePosixPath(raw.replace("\\", "/")).as_posix()
505
+ return "." if normalized in {"", "."} else normalized
506
+
507
+
508
+ def _write_json(path: Path, data: dict[str, Any]) -> None:
509
+ atomic_write_json(path, data)
510
+
511
+
512
+ def _read_json(path: Path) -> dict[str, Any]:
513
+ try:
514
+ raw = json.loads(path.read_text(encoding="utf-8"))
515
+ except FileNotFoundError as e:
516
+ raise ForgeError(f"Missing file: {path}") from e
517
+ except json.JSONDecodeError as e:
518
+ raise ForgeError(f"Invalid JSON file: {path}") from e
519
+ if not isinstance(raw, dict):
520
+ raise ForgeError(f"Invalid JSON structure in {path}")
521
+ return raw
522
+
523
+
524
+ def _next_task_id(tasks: list[dict[str, Any]]) -> str:
525
+ highest = 0
526
+ for task in tasks:
527
+ tid = str(task.get("id") or "")
528
+ if tid.startswith("T"):
529
+ tail = tid[1:]
530
+ if tail.isdigit():
531
+ highest = max(highest, int(tail))
532
+ return f"T{highest + 1:02d}"
533
+
534
+
535
+ def add_task(
536
+ plan: dict[str, Any],
537
+ *,
538
+ title: str,
539
+ description: str = "",
540
+ acceptance_criteria: list[str] | None = None,
541
+ dependencies: list[str] | None = None,
542
+ estimated_files: list[str] | None = None,
543
+ write_scope: list[str] | None = None,
544
+ branch: str = "",
545
+ status: str = "planned",
546
+ attempts: int = 0,
547
+ mcp_scope: dict[str, Any] | None = None,
548
+ _allow_execution_unready: bool = False,
549
+ ) -> dict[str, Any]:
550
+ tasks = plan.setdefault("tasks", [])
551
+ if not isinstance(tasks, list):
552
+ raise ForgeError("plan.json field 'tasks' must be an array")
553
+
554
+ title_text = title.strip()
555
+ description_text = description.strip()
556
+ acceptance = acceptance_criteria or []
557
+ scope = normalize_task_file_fields(
558
+ title=title_text,
559
+ description=description_text,
560
+ acceptance_criteria=acceptance,
561
+ estimated_files=estimated_files,
562
+ write_scope=write_scope,
563
+ warning_prefix=f"Task '{title_text}'",
564
+ )
565
+ if (
566
+ scope.requires_runnable_scope
567
+ and not has_runnable_local_file_scope(
568
+ estimated_files=scope.estimated_files,
569
+ write_scope=scope.write_scope,
570
+ )
571
+ and not _allow_execution_unready
572
+ ):
573
+ raise ForgeError(manual_task_scope_error_message(title=title_text))
574
+
575
+ task = {
576
+ "id": _next_task_id(tasks),
577
+ "title": title_text,
578
+ "description": description_text,
579
+ "acceptance_criteria": acceptance,
580
+ "dependencies": dependencies or [],
581
+ "estimated_files": scope.estimated_files,
582
+ "write_scope": scope.write_scope,
583
+ "branch": branch,
584
+ "status": status,
585
+ "attempts": attempts,
586
+ "task_kind": scope.task_kind,
587
+ "task_kind_reason": scope.task_kind_reason,
588
+ }
589
+ if isinstance(mcp_scope, dict):
590
+ task["mcp_scope"] = dict(mcp_scope)
591
+ if scope.task_kind == TASK_KIND_ANALYSIS_ONLY:
592
+ task["analysis_only"] = True
593
+ tasks.append(task)
594
+ if not dependencies:
595
+ inferred_dependency = infer_ordered_predecessor_dependency(tasks=tasks, task=task)
596
+ if inferred_dependency is not None:
597
+ task["dependencies"] = [inferred_dependency.depends_on]
598
+ plan["updated_at"] = now_iso()
599
+ return task
600
+
601
+
602
+ def find_task(plan: dict[str, Any], task_id: str) -> dict[str, Any] | None:
603
+ tasks = plan.get("tasks") or []
604
+ if not isinstance(tasks, list):
605
+ raise ForgeError("plan.json field 'tasks' must be an array")
606
+ target = task_id.strip().lower()
607
+ for task in tasks:
608
+ tid = str(task.get("id") or "").strip().lower()
609
+ if tid == target:
610
+ return task
611
+ return None
612
+
613
+
614
+ def set_task_status(plan: dict[str, Any], task_id: str, status: str) -> dict[str, Any]:
615
+ task = find_task(plan, task_id)
616
+ if not task:
617
+ raise ForgeError(f"Task not found: {task_id}")
618
+ task["status"] = status
619
+ plan["updated_at"] = now_iso()
620
+ return task
621
+
622
+
623
+ def requirement_text(item: Any) -> str:
624
+ if isinstance(item, dict):
625
+ for key in ("text", "requirement", "title", "description", "content"):
626
+ raw = item.get(key)
627
+ if isinstance(raw, str) and raw.strip():
628
+ return raw.strip()
629
+ return ""
630
+ return str(item).strip()
631
+
632
+
633
+ def requirement_is_execution_ready(item: Any) -> bool:
634
+ if isinstance(item, dict):
635
+ return item.get("execution_ready") is not False
636
+ return True
637
+
638
+
639
+ def add_requirement(
640
+ plan: dict[str, Any],
641
+ text: str,
642
+ *,
643
+ execution_ready: bool = True,
644
+ source: str | None = None,
645
+ ) -> None:
646
+ reqs = plan.setdefault("requirements", [])
647
+ if not isinstance(reqs, list):
648
+ raise ForgeError("plan.json field 'requirements' must be an array")
649
+ clean_text = text.strip()
650
+ if not clean_text:
651
+ return
652
+ if execution_ready and not source:
653
+ reqs.append(clean_text)
654
+ else:
655
+ requirement: dict[str, Any] = {
656
+ "text": clean_text,
657
+ "execution_ready": bool(execution_ready),
658
+ }
659
+ if source:
660
+ requirement["source"] = source
661
+ reqs.append(requirement)
662
+ plan["updated_at"] = now_iso()
663
+
664
+
665
+ def _default_plan_json(run_id: str) -> dict[str, Any]:
666
+ ts = now_iso()
667
+ return {
668
+ "schema_version": PLAN_SCHEMA_VERSION,
669
+ "run_id": run_id,
670
+ "created_at": ts,
671
+ "updated_at": ts,
672
+ "project_goal": "",
673
+ "summary": "",
674
+ "requirements": [],
675
+ "tasks": [],
676
+ "planning_constraints": {
677
+ "schema_version": 1,
678
+ "target_roots": [],
679
+ "forbidden_roots": [],
680
+ "decoy_roots": [],
681
+ "unrelated_roots": [],
682
+ },
683
+ "assets": [],
684
+ }
685
+
686
+
687
+ def _resolve_workspace_context_or_error(path: Path) -> WorkspaceContext:
688
+ try:
689
+ return resolve_workspace_context(path)
690
+ except WorkspaceContextError as e:
691
+ raise ForgeError(str(e)) from e
692
+
693
+
694
+ def _run_paths_from_workspace_context(
695
+ *,
696
+ context: WorkspaceContext,
697
+ run_id: str,
698
+ binding_requested_path: Path | None = None,
699
+ binding_source: str = "explicit_path",
700
+ workspace_created_at_startup: bool = False,
701
+ binding_risk_level: str = WorkspaceRiskLevel.HEALTHY,
702
+ binding_risk_reasons: tuple[str, ...] | list[str] = (),
703
+ binding_broad_workspace_override_used: bool = False,
704
+ runtime_dir_name: str = ".alysis",
705
+ ) -> RunPaths:
706
+ return make_run_paths(
707
+ root=context.workspace_root,
708
+ run_id=run_id,
709
+ focus_path=context.focus_path,
710
+ focus_relpath=context.focus_relpath,
711
+ workspace_kind=context.workspace_kind,
712
+ git_root=context.git_root,
713
+ has_head_commit=context.has_head_commit,
714
+ current_branch=context.current_branch,
715
+ binding_requested_path=binding_requested_path or context.input_path,
716
+ binding_source=binding_source,
717
+ workspace_created_at_startup=workspace_created_at_startup,
718
+ binding_risk_level=binding_risk_level,
719
+ binding_risk_reasons=binding_risk_reasons,
720
+ binding_broad_workspace_override_used=binding_broad_workspace_override_used,
721
+ runtime_dir_name=runtime_dir_name,
722
+ )
723
+
724
+
725
+ def rebind_run_paths_to_workspace_binding(
726
+ *,
727
+ paths: RunPaths,
728
+ workspace_binding: WorkspaceBinding,
729
+ ) -> RunPaths:
730
+ stored_workspace_root = paths.root.resolve()
731
+ current_workspace_root = workspace_binding.workspace_context.workspace_root.resolve()
732
+ if stored_workspace_root != current_workspace_root:
733
+ raise ForgeError(
734
+ "Cannot resume a Forge run across workspace roots without creating a fresh run."
735
+ )
736
+
737
+ # Preserve the existing run directory/runtime layout while rebinding focus and
738
+ # workspace metadata to the chat's current workspace binding.
739
+ return _run_paths_from_workspace_context(
740
+ context=workspace_binding.workspace_context,
741
+ run_id=paths.run_id,
742
+ binding_requested_path=workspace_binding.requested_path,
743
+ binding_source=workspace_binding.binding_source,
744
+ workspace_created_at_startup=workspace_binding.created_path,
745
+ binding_risk_level=workspace_binding.risk_level,
746
+ binding_risk_reasons=workspace_binding.risk_reasons,
747
+ binding_broad_workspace_override_used=workspace_binding.broad_workspace_override_used,
748
+ runtime_dir_name=paths.runtime_dir.name,
749
+ )
750
+
751
+
752
+ def _workspace_binding_error(binding: WorkspaceBinding) -> ForgeError:
753
+ detail = "; ".join(binding.risk_reasons) or "workspace selection is not allowed"
754
+ if binding.risk_level == WorkspaceRiskLevel.BLOCKED:
755
+ return ForgeError(f"Workspace binding blocked for {binding.requested_path}: {detail}.")
756
+ if binding.risk_level == WorkspaceRiskLevel.GUARDED:
757
+ return ForgeError(
758
+ f"Workspace binding is guarded for {binding.requested_path}: {detail}. "
759
+ "Pass allow_broad_workspace=True to continue."
760
+ )
761
+ return ForgeError(detail)
762
+
763
+
764
+ def _candidate_pointer_roots(*, input_path: Path, workspace_root: Path) -> list[Path]:
765
+ candidates: list[Path] = [workspace_root]
766
+ current = input_path
767
+ while True:
768
+ if current not in candidates:
769
+ candidates.append(current)
770
+ if current == workspace_root or current.parent == current:
771
+ break
772
+ current = current.parent
773
+ return candidates
774
+
775
+
776
+ def _load_current_run_pointer(
777
+ *,
778
+ pointer_root: Path,
779
+ pointer_path: Path,
780
+ ) -> tuple[dict[str, Any], str, str]:
781
+ pointer = _read_json(pointer_path)
782
+ run_id = str(pointer.get("run_id") or "").strip()
783
+ if not run_id:
784
+ raise ForgeError(f"Invalid current run pointer: {pointer_path}")
785
+ run_path_raw = str(pointer.get("run_path") or "").strip()
786
+ if not run_path_raw:
787
+ raise ForgeError(f"Invalid current run pointer (missing run_path): {pointer_path}")
788
+
789
+ run_path = Path(run_path_raw)
790
+ if run_path.is_absolute():
791
+ raise ForgeError("current run pointer must use workspace-relative run_path")
792
+
793
+ run_dir = (pointer_root / run_path).resolve()
794
+ _ensure_under_root(pointer_root, run_dir)
795
+ runtime_dir_name = pointer_path.parent.name
796
+ expected_run_dir = (
797
+ _runs_dir(pointer_root, runtime_dir_name=runtime_dir_name) / run_id
798
+ ).resolve()
799
+ _ensure_under_root(pointer_root, expected_run_dir)
800
+ if run_dir != expected_run_dir:
801
+ raise ForgeError(
802
+ "Invalid current run pointer: run_id and run_path disagree "
803
+ f"(run_id={run_id}, run_path={run_path_raw})."
804
+ )
805
+ if not run_dir.exists():
806
+ raise ForgeError(
807
+ f"Current run directory does not exist: {run_dir}. Start a new run with "
808
+ "'alysis forge plan'."
809
+ )
810
+ return pointer, run_id, runtime_dir_name
811
+
812
+
813
+ def _pointer_focus_relpath(pointer: dict[str, Any]) -> str:
814
+ try:
815
+ return _normalize_stored_relpath(str(pointer.get("focus_relpath") or "."))
816
+ except ForgeError as e:
817
+ raise ForgeError("current run pointer focus_relpath must be relative") from e
818
+
819
+
820
+ def _focus_path_from_rel(*, root: Path, focus_relpath: str) -> Path:
821
+ if focus_relpath == ".":
822
+ return root
823
+ focus_path = (root / focus_relpath).resolve()
824
+ _ensure_under_root(root, focus_path)
825
+ return focus_path
826
+
827
+
828
+ def _pointer_requested_path(
829
+ *,
830
+ pointer_root: Path,
831
+ pointer: dict[str, Any],
832
+ default_path: Path,
833
+ ) -> Path:
834
+ raw = str(pointer.get("binding_requested_path") or "").strip()
835
+ if not raw:
836
+ return default_path
837
+ candidate = Path(raw).expanduser()
838
+ if not candidate.is_absolute():
839
+ candidate = pointer_root / candidate
840
+ return candidate.resolve(strict=False)
841
+
842
+
843
+ def _pointer_binding_source(pointer: dict[str, Any]) -> str:
844
+ raw = str(pointer.get("binding_source") or "").strip()
845
+ if raw:
846
+ return raw
847
+ return "current_run_pointer"
848
+
849
+
850
+ def _pointer_binding_risk_reasons(pointer: dict[str, Any]) -> tuple[str, ...]:
851
+ raw = pointer.get("binding_risk_reasons")
852
+ if not isinstance(raw, list):
853
+ return ()
854
+ return tuple(str(reason).strip() for reason in raw if str(reason).strip())
855
+
856
+
857
+ def _pointer_broad_workspace_override_used(pointer: dict[str, Any]) -> bool:
858
+ return bool(pointer.get("binding_broad_workspace_override_used", False))
859
+
860
+
861
+ def _run_paths_from_pointer(
862
+ *,
863
+ pointer_root: Path,
864
+ pointer_path: Path,
865
+ canonical_workspace_root: Path,
866
+ ) -> RunPaths:
867
+ pointer, run_id, runtime_dir_name = _load_current_run_pointer(
868
+ pointer_root=pointer_root, pointer_path=pointer_path
869
+ )
870
+ metadata_context = _resolve_workspace_context_or_error(pointer_root)
871
+ if pointer_root == canonical_workspace_root:
872
+ focus_relpath = _pointer_focus_relpath(pointer)
873
+ focus_path = _focus_path_from_rel(root=pointer_root, focus_relpath=focus_relpath)
874
+ requested_path = _pointer_requested_path(
875
+ pointer_root=pointer_root,
876
+ pointer=pointer,
877
+ default_path=focus_path,
878
+ )
879
+ return make_run_paths(
880
+ root=pointer_root,
881
+ run_id=run_id,
882
+ focus_path=focus_path,
883
+ focus_relpath=focus_relpath,
884
+ workspace_kind=metadata_context.workspace_kind,
885
+ git_root=metadata_context.git_root,
886
+ has_head_commit=metadata_context.has_head_commit,
887
+ current_branch=metadata_context.current_branch,
888
+ binding_requested_path=requested_path,
889
+ binding_source=_pointer_binding_source(pointer),
890
+ workspace_created_at_startup=bool(pointer.get("workspace_created_at_startup", False)),
891
+ binding_risk_level=(
892
+ str(pointer.get("binding_risk_level") or "").strip() or WorkspaceRiskLevel.HEALTHY
893
+ ),
894
+ binding_risk_reasons=_pointer_binding_risk_reasons(pointer),
895
+ binding_broad_workspace_override_used=_pointer_broad_workspace_override_used(pointer),
896
+ runtime_dir_name=runtime_dir_name,
897
+ )
898
+ requested_path = _pointer_requested_path(
899
+ pointer_root=pointer_root,
900
+ pointer=pointer,
901
+ default_path=pointer_root,
902
+ )
903
+ return make_run_paths(
904
+ root=pointer_root,
905
+ run_id=run_id,
906
+ focus_path=pointer_root,
907
+ focus_relpath=".",
908
+ workspace_kind=metadata_context.workspace_kind,
909
+ git_root=metadata_context.git_root,
910
+ has_head_commit=metadata_context.has_head_commit,
911
+ current_branch=metadata_context.current_branch,
912
+ binding_requested_path=requested_path,
913
+ binding_source=_pointer_binding_source(pointer),
914
+ workspace_created_at_startup=bool(pointer.get("workspace_created_at_startup", False)),
915
+ binding_risk_level=(
916
+ str(pointer.get("binding_risk_level") or "").strip() or WorkspaceRiskLevel.HEALTHY
917
+ ),
918
+ binding_risk_reasons=_pointer_binding_risk_reasons(pointer),
919
+ binding_broad_workspace_override_used=_pointer_broad_workspace_override_used(pointer),
920
+ runtime_dir_name=runtime_dir_name,
921
+ )
922
+
923
+
924
+ def render_plan_markdown(plan: dict[str, Any], *, asset_index: Any | None = None) -> str:
925
+ goal = str(plan.get("project_goal") or "").strip() or "(not set yet)"
926
+ summary = str(plan.get("summary") or "").strip() or "(not set yet)"
927
+ requirements = plan.get("requirements") or []
928
+ superseded_requirements = plan.get("superseded_requirements") or []
929
+ tasks = plan.get("tasks") or []
930
+ assets = plan.get("assets") or []
931
+ asset_titles, pinned_assets = _asset_markdown_metadata(asset_index)
932
+
933
+ lines: list[str] = [
934
+ "# PLAN",
935
+ "",
936
+ f"- Run ID: `{plan.get('run_id', '')}`",
937
+ f"- Created: `{plan.get('created_at', '')}`",
938
+ f"- Updated: `{plan.get('updated_at', '')}`",
939
+ "",
940
+ "## Project Goal",
941
+ "",
942
+ goal,
943
+ "",
944
+ "## Summary",
945
+ "",
946
+ summary,
947
+ "",
948
+ "## Requirements",
949
+ "",
950
+ ]
951
+
952
+ if requirements:
953
+ for req in requirements:
954
+ text = requirement_text(req)
955
+ if not text:
956
+ continue
957
+ suffix = "" if requirement_is_execution_ready(req) else " (not execution-ready)"
958
+ lines.append(f"- {text}{suffix}")
959
+ else:
960
+ lines.append("- (none)")
961
+
962
+ if superseded_requirements:
963
+ lines.extend(["", "## Superseded Requirements", ""])
964
+ for item in superseded_requirements:
965
+ if isinstance(item, dict):
966
+ text = str(item.get("text") or "").strip()
967
+ reason = str(item.get("reason") or "").strip()
968
+ else:
969
+ text = str(item).strip()
970
+ reason = ""
971
+ if not text:
972
+ continue
973
+ suffix = f" ({reason})" if reason else ""
974
+ lines.append(f"- {text}{suffix}")
975
+
976
+ planning_constraints = plan.get("planning_constraints")
977
+ if isinstance(planning_constraints, dict):
978
+ constraint_lines: list[str] = []
979
+ for label, key in (
980
+ ("Target roots", "target_roots"),
981
+ ("Forbidden roots", "forbidden_roots"),
982
+ ("Decoy roots", "decoy_roots"),
983
+ ("Unrelated roots", "unrelated_roots"),
984
+ ):
985
+ entries = [
986
+ str(entry.get("path") or "").strip()
987
+ for entry in planning_constraints.get(key) or []
988
+ if isinstance(entry, dict) and str(entry.get("path") or "").strip()
989
+ ]
990
+ if entries:
991
+ constraint_lines.append(f"- {label}: " + ", ".join(f"`{path}`" for path in entries))
992
+ if constraint_lines:
993
+ lines.extend(["", "## Planning Constraints", "", *constraint_lines])
994
+
995
+ def _md_cell(value: Any) -> str:
996
+ text = str(value or "").strip()
997
+ if not text:
998
+ return "-"
999
+ return text.replace("|", r"\|").replace("\n", " ")
1000
+
1001
+ lines.extend(["", "## Tasks Overview", ""])
1002
+ if tasks:
1003
+ status_counts: dict[str, int] = {}
1004
+ for task in tasks:
1005
+ status = str(task.get("status") or "planned").strip() or "planned"
1006
+ status_counts[status] = status_counts.get(status, 0) + 1
1007
+ counts_label = ", ".join(
1008
+ f"{status}: {count}" for status, count in sorted(status_counts.items())
1009
+ )
1010
+ if counts_label:
1011
+ lines.append(f"Status counts: {counts_label}")
1012
+ lines.append("")
1013
+ lines.append("| ID | Status | Title | Dependencies |")
1014
+ lines.append("|----|--------|-------|--------------|")
1015
+ if tasks:
1016
+ for task in tasks:
1017
+ tid = _md_cell(task.get("id"))
1018
+ status = _md_cell(task.get("status") or "planned")
1019
+ title = _md_cell(task.get("title"))
1020
+ deps = task.get("dependencies") or []
1021
+ deps_label = ", ".join(str(dep) for dep in deps) if deps else "-"
1022
+ lines.append(f"| {tid} | {status} | {title} | {_md_cell(deps_label)} |")
1023
+ else:
1024
+ lines.append("| - | - | (none) | - |")
1025
+
1026
+ lines.extend(["", "## Tasks", ""])
1027
+ if tasks:
1028
+ for task in tasks:
1029
+ tid = task.get("id", "")
1030
+ title = task.get("title", "")
1031
+ status = task.get("status", "")
1032
+ lines.append(f"### {tid} - {title}")
1033
+ lines.append(f"- Status: `{status}`")
1034
+ attempts = int(task.get("attempts") or 0)
1035
+ lines.append(f"- Attempts: `{attempts}`")
1036
+ desc = str(task.get("description") or "").strip()
1037
+ lines.append(f"- Description: {desc or '(none)'}")
1038
+ deps = task.get("dependencies") or []
1039
+ lines.append(f"- Dependencies: {', '.join(deps) if deps else '(none)'}")
1040
+ asset_lines = _task_asset_markdown_lines(task, asset_titles)
1041
+ if asset_lines:
1042
+ lines.append("- Assets:")
1043
+ lines.extend(asset_lines)
1044
+ acc = task.get("acceptance_criteria") or []
1045
+ if acc:
1046
+ lines.append("- Acceptance Criteria:")
1047
+ for c in acc:
1048
+ lines.append(f" - {c}")
1049
+ else:
1050
+ lines.append("- Acceptance Criteria: (none)")
1051
+ files = task.get("estimated_files") or []
1052
+ lines.append(f"- Estimated Files: {', '.join(files) if files else '(none)'}")
1053
+ write_scope = task.get("write_scope") or []
1054
+ lines.append(f"- Write Scope: {', '.join(write_scope) if write_scope else '(derived)'}")
1055
+ lines.append(
1056
+ "- MCP Scope: "
1057
+ + describe_task_mcp_scope(
1058
+ normalize_task_mcp_scope(
1059
+ task.get("mcp_scope"),
1060
+ warning_prefix=f"Task {tid or '(unknown)'}",
1061
+ )[0]
1062
+ )
1063
+ )
1064
+ branch = str(task.get("branch") or "")
1065
+ lines.append(f"- Branch: {branch or '(not set)'}")
1066
+ lines.append("")
1067
+ else:
1068
+ lines.append("- (none)")
1069
+ lines.append("")
1070
+
1071
+ if pinned_assets:
1072
+ lines.extend(["## Pinned Assets", ""])
1073
+ lines.append("These assets are available to all tasks regardless of binding:")
1074
+ for asset_id, title in pinned_assets:
1075
+ lines.append(f'- "{title}" ({asset_id})')
1076
+ lines.append("")
1077
+
1078
+ lines.extend(["## Assets", ""])
1079
+ if assets:
1080
+ for asset in assets:
1081
+ stored = asset.get("stored_path", "")
1082
+ size = asset.get("size_bytes", 0)
1083
+ lines.append(f"- `{stored}` ({size} bytes)")
1084
+ else:
1085
+ lines.append("- (none)")
1086
+
1087
+ lines.extend(
1088
+ [
1089
+ "",
1090
+ "## Notes",
1091
+ "",
1092
+ "- Transcript: `plan/notes/user_notes.md`",
1093
+ "- Planner Chat: `plan/notes/planner_chat.md`",
1094
+ "- Planner Summary: `plan/notes/planner_summary.md`",
1095
+ "- Plan Validation: `plan/notes/plan_validation.md`",
1096
+ ]
1097
+ )
1098
+
1099
+ return "\n".join(lines).rstrip() + "\n"
1100
+
1101
+
1102
+ def _asset_markdown_metadata(
1103
+ asset_index: Any | None,
1104
+ ) -> tuple[dict[str, str], list[tuple[str, str]]]:
1105
+ if asset_index is None:
1106
+ return {}, []
1107
+ records_method = getattr(asset_index, "records", None)
1108
+ if not callable(records_method):
1109
+ return {}, []
1110
+ try:
1111
+ records = records_method(include_deleted=True)
1112
+ except Exception:
1113
+ return {}, []
1114
+ titles: dict[str, str] = {}
1115
+ pinned: list[tuple[str, str]] = []
1116
+ for record in records:
1117
+ asset_id = str(getattr(record, "id", "") or "").strip()
1118
+ if not asset_id:
1119
+ continue
1120
+ title = str(getattr(record, "title", "") or "").strip() or "<unknown>"
1121
+ titles[asset_id] = title
1122
+ if bool(getattr(record, "pinned", False)) and getattr(record, "deleted_at", None) is None:
1123
+ pinned.append((asset_id, title))
1124
+ return titles, sorted(pinned)
1125
+
1126
+
1127
+ def _task_asset_markdown_lines(task: dict[str, Any], asset_titles: dict[str, str]) -> list[str]:
1128
+ briefing = task.get("asset_briefing")
1129
+ if not isinstance(briefing, dict):
1130
+ return []
1131
+ lines: list[str] = []
1132
+ for group in ("primary", "may_need"):
1133
+ entries = briefing.get(group)
1134
+ if not isinstance(entries, list):
1135
+ continue
1136
+ for entry in entries:
1137
+ if not isinstance(entry, dict):
1138
+ continue
1139
+ asset_id = str(entry.get("asset_id") or "").strip()
1140
+ if not asset_id:
1141
+ continue
1142
+ title = asset_titles.get(asset_id, "<unknown>")
1143
+ rationale = str(entry.get("rationale") or "").strip()
1144
+ lines.append(f' - "{title}" ({asset_id}) - {group} - {rationale}')
1145
+ return lines
1146
+
1147
+
1148
+ def _asset_index_for_markdown(paths: RunPaths) -> Any | None:
1149
+ if not hasattr(paths, "assets_index_path"):
1150
+ return None
1151
+ try:
1152
+ from .assets.index import AssetIndex
1153
+
1154
+ return AssetIndex(paths)
1155
+ except Exception:
1156
+ return None
1157
+
1158
+
1159
+ def load_plan(paths: RunPaths, *, migrate_legacy: bool = True) -> dict[str, Any]:
1160
+ plan = _read_json(paths.plan_json_path)
1161
+ if migrate_legacy and _plan_schema_version(plan) < PLAN_SCHEMA_VERSION:
1162
+ plan = _migrate_legacy_assets_on_load(paths, plan)
1163
+ tasks = plan.get("tasks")
1164
+ assets = plan.get("assets")
1165
+ requirements = plan.get("requirements")
1166
+ if not isinstance(tasks, list):
1167
+ raise ForgeError("Invalid plan.json: 'tasks' must be an array")
1168
+ if not isinstance(assets, list):
1169
+ raise ForgeError("Invalid plan.json: 'assets' must be an array")
1170
+ if not isinstance(requirements, list):
1171
+ raise ForgeError("Invalid plan.json: 'requirements' must be an array")
1172
+ return plan
1173
+
1174
+
1175
+ def _plan_schema_version(plan: dict[str, Any]) -> int:
1176
+ try:
1177
+ return int(plan.get("schema_version", 1) or 1)
1178
+ except (TypeError, ValueError):
1179
+ return 1
1180
+
1181
+
1182
+ def _migrate_legacy_assets_on_load(paths: RunPaths, plan: dict[str, Any]) -> dict[str, Any]:
1183
+ try:
1184
+ from .assets.legacy_migration import migrate_legacy_assets
1185
+ from .assets.surface import build_asset_surface
1186
+ from .config import load_config
1187
+
1188
+ cfg = load_config()
1189
+ surface = build_asset_surface(cfg=cfg, run_paths=paths)
1190
+ result = migrate_legacy_assets(
1191
+ cfg=cfg,
1192
+ run_paths=paths,
1193
+ surface=surface,
1194
+ comprehend_mode="async",
1195
+ )
1196
+ except Exception as exc: # noqa: BLE001 - plan loading must remain recoverable
1197
+ LOGGER.warning(
1198
+ "legacy_asset_migration load_plan failed run_id=%s: %s",
1199
+ paths.run_id,
1200
+ exc,
1201
+ )
1202
+ return plan
1203
+ if result.plan_v2_written:
1204
+ try:
1205
+ return _read_json(paths.plan_json_path)
1206
+ except ForgeError:
1207
+ return plan
1208
+ return plan
1209
+
1210
+
1211
+ def _plan_semantic_fingerprint(plan: dict[str, Any]) -> str:
1212
+ semantic_plan = dict(plan)
1213
+ semantic_plan.pop("updated_at", None)
1214
+ return json.dumps(
1215
+ semantic_plan,
1216
+ sort_keys=True,
1217
+ ensure_ascii=False,
1218
+ separators=(",", ":"),
1219
+ )
1220
+
1221
+
1222
+ def save_plan(paths: RunPaths, plan: dict[str, Any]) -> None:
1223
+ persisted_plan: dict[str, Any] | None
1224
+ try:
1225
+ persisted_plan = _read_json(paths.plan_json_path)
1226
+ except ForgeError:
1227
+ persisted_plan = None
1228
+
1229
+ # Keep no-op persistence byte-stable: only semantic plan changes should refresh updated_at.
1230
+ semantic_changed = persisted_plan is None or (
1231
+ _plan_semantic_fingerprint(persisted_plan) != _plan_semantic_fingerprint(plan)
1232
+ )
1233
+ if semantic_changed:
1234
+ plan["updated_at"] = now_iso()
1235
+ elif persisted_plan is not None and "updated_at" in persisted_plan:
1236
+ plan["updated_at"] = persisted_plan["updated_at"]
1237
+
1238
+ rendered_plan_md = render_plan_markdown(plan, asset_index=_asset_index_for_markdown(paths))
1239
+ if semantic_changed or not paths.plan_json_path.exists():
1240
+ _write_json(paths.plan_json_path, plan)
1241
+
1242
+ write_plan_md = semantic_changed or not paths.plan_md_path.exists()
1243
+ if not write_plan_md:
1244
+ try:
1245
+ write_plan_md = paths.plan_md_path.read_text(encoding="utf-8") != rendered_plan_md
1246
+ except OSError:
1247
+ write_plan_md = True
1248
+ if write_plan_md:
1249
+ atomic_write_text(paths.plan_md_path, rendered_plan_md)
1250
+
1251
+ if semantic_changed:
1252
+ # Best-effort: the plan is already on disk, and a pointer that cannot be
1253
+ # updated is a stale status line, not a lost plan.
1254
+ with suppress(Exception):
1255
+ sync_current_run_plan_status(paths, plan)
1256
+
1257
+
1258
+ def load_workspace_context_artifact(paths: RunPaths) -> RepoScanResult | None:
1259
+ artifact_path = paths.workspace_context_json_path
1260
+ if artifact_path is None or not artifact_path.exists():
1261
+ return None
1262
+ raw = _read_json(artifact_path)
1263
+ if int(raw.get("schema_version") or 0) != REPO_SCAN_SCHEMA_VERSION:
1264
+ return None
1265
+ scan = RepoScanResult.from_dict(raw)
1266
+ if not scan.workspace_root:
1267
+ return None
1268
+ return scan
1269
+
1270
+
1271
+ def format_workspace_context_summary_lines(scan: RepoScanResult) -> list[str]:
1272
+ return render_repo_scan_summary_lines(scan)
1273
+
1274
+
1275
+ def refresh_workspace_context_artifacts(paths: RunPaths) -> RepoScanResult:
1276
+ plan_context_dir = paths.plan_context_dir
1277
+ artifact_path = paths.workspace_context_json_path
1278
+ summary_path = paths.workspace_summary_md_path
1279
+ if plan_context_dir is None or artifact_path is None or summary_path is None:
1280
+ raise ForgeError("Run paths are missing workspace context artifact locations.")
1281
+ plan_context_dir.mkdir(parents=True, exist_ok=True)
1282
+ context = WorkspaceContext(
1283
+ input_path=paths.focus_path or paths.root,
1284
+ focus_path=paths.focus_path or paths.root,
1285
+ workspace_root=paths.root,
1286
+ git_root=paths.git_root,
1287
+ focus_relpath=paths.focus_relpath or ".",
1288
+ workspace_kind=paths.workspace_kind or "plain_dir",
1289
+ has_head_commit=paths.has_head_commit,
1290
+ current_branch=paths.current_branch,
1291
+ )
1292
+ scan = scan_workspace(context=context)
1293
+ _write_json(artifact_path, scan.to_dict())
1294
+ atomic_write_text(summary_path, render_repo_scan_markdown(scan))
1295
+ return scan
1296
+
1297
+
1298
+ def ensure_workspace_context_artifacts(
1299
+ paths: RunPaths,
1300
+ *,
1301
+ refresh_if_stale: bool = False,
1302
+ ) -> RepoScanResult:
1303
+ scan = load_workspace_context_artifact(paths)
1304
+ if scan is None:
1305
+ return refresh_workspace_context_artifacts(paths)
1306
+ if refresh_if_stale and _workspace_context_artifacts_stale(paths, scan):
1307
+ return refresh_workspace_context_artifacts(paths)
1308
+ if paths.workspace_summary_md_path is None or not paths.workspace_summary_md_path.exists():
1309
+ return refresh_workspace_context_artifacts(paths)
1310
+ return scan
1311
+
1312
+
1313
+ def _workspace_context_artifacts_stale(paths: RunPaths, scan: RepoScanResult) -> bool:
1314
+ artifact_path = paths.workspace_context_json_path
1315
+ summary_path = paths.workspace_summary_md_path
1316
+ if artifact_path is None or summary_path is None:
1317
+ return True
1318
+ if not artifact_path.exists() or not summary_path.exists():
1319
+ return True
1320
+ if scan.workspace_root != os.fspath(paths.root):
1321
+ return True
1322
+ if scan.focus_relpath != (paths.focus_relpath or "."):
1323
+ return True
1324
+ if scan.workspace_kind != (paths.workspace_kind or "plain_dir"):
1325
+ return True
1326
+ if (scan.git_root or None) != (
1327
+ os.fspath(paths.git_root) if paths.git_root is not None else None
1328
+ ):
1329
+ return True
1330
+ if bool(scan.has_head_commit) != bool(paths.has_head_commit):
1331
+ return True
1332
+ if (scan.current_branch or None) != (paths.current_branch or None):
1333
+ return True
1334
+
1335
+ json_mtime_ns = artifact_path.stat().st_mtime_ns
1336
+ focus_path = paths.focus_path or paths.root
1337
+ tracked_paths = [paths.root, focus_path]
1338
+ tracked_paths.extend((paths.root / rel_path).resolve() for rel_path in scan.observed_paths)
1339
+ for candidate in tracked_paths:
1340
+ try:
1341
+ stat = candidate.stat()
1342
+ except OSError:
1343
+ return True
1344
+ if stat.st_mtime_ns > json_mtime_ns:
1345
+ return True
1346
+ return False
1347
+
1348
+
1349
+ def _sanitize_note_text(value: str) -> str:
1350
+ # Prevent UnicodeEncodeError for invalid surrogate code points from terminal input.
1351
+ return value.encode("utf-8", errors="replace").decode("utf-8")
1352
+
1353
+
1354
+ def append_transcript_note(paths: RunPaths, *, role: str, message: str) -> None:
1355
+ paths.notes_dir.mkdir(parents=True, exist_ok=True)
1356
+ if not paths.notes_path.exists():
1357
+ paths.notes_path.write_text("# Planning Notes\n\n", encoding="utf-8")
1358
+ ts = now_iso()
1359
+ with paths.notes_path.open("a", encoding="utf-8") as fh:
1360
+ safe_role = _sanitize_note_text(role).strip()
1361
+ safe_message = _sanitize_note_text(message).strip()
1362
+ fh.write(f"- [{ts}] {safe_role}: {safe_message}\n")
1363
+
1364
+
1365
+ def append_planner_chat(paths: RunPaths, *, role: str, message: str) -> None:
1366
+ paths.notes_dir.mkdir(parents=True, exist_ok=True)
1367
+ if not paths.planner_chat_path.exists():
1368
+ paths.planner_chat_path.write_text("# Planner Chat\n\n", encoding="utf-8")
1369
+ ts = now_iso()
1370
+ with paths.planner_chat_path.open("a", encoding="utf-8") as fh:
1371
+ safe_role = _sanitize_note_text(role).strip()
1372
+ safe_message = _sanitize_note_text(message).strip()
1373
+ fh.write(f"- [{ts}] {safe_role}: {safe_message}\n")
1374
+
1375
+
1376
+ def append_planner_summary(paths: RunPaths, summary_line: str) -> None:
1377
+ paths.notes_dir.mkdir(parents=True, exist_ok=True)
1378
+ if not paths.planner_summary_path.exists():
1379
+ paths.planner_summary_path.write_text("# Planner Summary\n\n", encoding="utf-8")
1380
+ ts = now_iso()
1381
+ with paths.planner_summary_path.open("a", encoding="utf-8") as fh:
1382
+ safe_summary = _sanitize_note_text(summary_line).strip()
1383
+ fh.write(f"- [{ts}] {safe_summary}\n")
1384
+
1385
+
1386
+ def finalize_plan(plan: dict[str, Any]) -> dict[str, Any]:
1387
+ raw_requirements = plan.get("requirements") or []
1388
+ requirements = [requirement_text(x) for x in raw_requirements if requirement_text(x)]
1389
+ execution_ready_requirements = [
1390
+ requirement_text(x)
1391
+ for x in raw_requirements
1392
+ if requirement_text(x) and requirement_is_execution_ready(x)
1393
+ ]
1394
+ if not plan.get("project_goal"):
1395
+ if requirements:
1396
+ plan["project_goal"] = requirements[0]
1397
+ else:
1398
+ plan["project_goal"] = "Define the project goal and implementation scope."
1399
+
1400
+ if not plan.get("summary"):
1401
+ if requirements:
1402
+ plan["summary"] = " ".join(requirements[:3])
1403
+ else:
1404
+ plan["summary"] = "Initial planning scaffold created."
1405
+
1406
+ tasks = plan.get("tasks") or []
1407
+ if not tasks:
1408
+ if execution_ready_requirements:
1409
+ for req in execution_ready_requirements[:8]:
1410
+ title = req if len(req) <= 80 else req[:77] + "..."
1411
+ add_task(
1412
+ plan,
1413
+ title=title,
1414
+ description=req,
1415
+ _allow_execution_unready=True,
1416
+ )
1417
+ plan["updated_at"] = now_iso()
1418
+ return plan
1419
+
1420
+
1421
+ _LIFECYCLE_POINTER_KEYS: tuple[str, ...] = (
1422
+ "status",
1423
+ "status_updated_at",
1424
+ "status_reason",
1425
+ "status_history",
1426
+ "run_owner",
1427
+ "plan_fingerprint",
1428
+ )
1429
+
1430
+
1431
+ def read_current_run_pointer(root: Path) -> dict[str, Any] | None:
1432
+ """The raw pointer payload for ``root``, or ``None`` when there is none."""
1433
+ try:
1434
+ return _read_json(current_run_pointer_path(root))
1435
+ except ForgeError:
1436
+ return None
1437
+
1438
+
1439
+ def write_current_run_pointer(paths: RunPaths) -> None:
1440
+ pointer = {
1441
+ "schema_version": CURRENT_RUN_POINTER_SCHEMA_VERSION,
1442
+ "run_id": paths.run_id,
1443
+ "run_path": _repo_rel(paths.root, paths.run_dir),
1444
+ "updated_at": now_iso(),
1445
+ "workspace_root": os.fspath(paths.root),
1446
+ "focus_path": os.fspath(paths.focus_path or paths.root),
1447
+ "focus_relpath": paths.focus_relpath or ".",
1448
+ "workspace_kind": paths.workspace_kind or "plain_dir",
1449
+ "has_head_commit": paths.has_head_commit,
1450
+ "greenfield": paths.greenfield,
1451
+ "binding_requested_path": os.fspath(
1452
+ paths.binding_requested_path or paths.focus_path or paths.root
1453
+ ),
1454
+ "binding_source": paths.binding_source or "explicit_path",
1455
+ "workspace_created_at_startup": paths.workspace_created_at_startup,
1456
+ "binding_risk_level": paths.binding_risk_level or WorkspaceRiskLevel.HEALTHY,
1457
+ "binding_risk_reasons": list(paths.binding_risk_reasons),
1458
+ "binding_broad_workspace_override_used": paths.binding_broad_workspace_override_used,
1459
+ }
1460
+ if paths.git_root is not None:
1461
+ pointer["git_root"] = os.fspath(paths.git_root)
1462
+ if paths.current_branch:
1463
+ pointer["current_branch"] = paths.current_branch
1464
+
1465
+ # Rebinding a workspace must not silently reset the run's lifecycle. The binding
1466
+ # fields above are recomputed every time; the lifecycle block belongs to the run
1467
+ # and only ``set_current_run_status`` may move it.
1468
+ existing = read_current_run_pointer(paths.root)
1469
+ if existing is not None and str(existing.get("run_id") or "").strip() == paths.run_id:
1470
+ for key in _LIFECYCLE_POINTER_KEYS:
1471
+ if key in existing:
1472
+ pointer[key] = existing[key]
1473
+ else:
1474
+ pointer["status"] = RUN_STATUS_DRAFT
1475
+ pointer["status_updated_at"] = now_iso()
1476
+ pointer["status_history"] = [
1477
+ {"status": RUN_STATUS_DRAFT, "at": pointer["status_updated_at"]}
1478
+ ]
1479
+ _write_json(current_run_pointer_path(paths.root), pointer)
1480
+
1481
+
1482
+ def set_current_run_status(
1483
+ paths: RunPaths,
1484
+ status: str,
1485
+ *,
1486
+ reason: str = "",
1487
+ owner: dict[str, Any] | None = None,
1488
+ plan: dict[str, Any] | None = None,
1489
+ ) -> dict[str, Any] | None:
1490
+ """Move the current-run pointer to ``status``, if it still tracks this run.
1491
+
1492
+ Returns the written payload, or ``None`` when the pointer is missing or has
1493
+ moved on to a different run -- a finishing run must never stamp its outcome
1494
+ onto whatever run became current in the meantime.
1495
+ """
1496
+ pointer = read_current_run_pointer(paths.root)
1497
+ if pointer is None or str(pointer.get("run_id") or "").strip() != paths.run_id:
1498
+ return None
1499
+ updated = apply_run_status(
1500
+ pointer,
1501
+ status,
1502
+ reason=reason,
1503
+ owner=owner,
1504
+ plan_fingerprint=plan_fingerprint(plan) if plan is not None else None,
1505
+ )
1506
+ _write_json(current_run_pointer_path(paths.root), updated)
1507
+ return updated
1508
+
1509
+
1510
+ def current_run_status(root: Path) -> str:
1511
+ """The recorded lifecycle status for the workspace's current run."""
1512
+ return pointer_status(read_current_run_pointer(root))
1513
+
1514
+
1515
+ def sync_current_run_plan_status(paths: RunPaths, plan: dict[str, Any]) -> None:
1516
+ """Keep the pointer's draft/approved status and fingerprint in step with the plan.
1517
+
1518
+ Called from :func:`save_plan`, which is the one choke point every plan edit goes
1519
+ through. Two things happen here:
1520
+
1521
+ * A plan crossing the execution-readiness gate flips ``draft`` <-> ``approved``,
1522
+ so a UI can show "ready to run" without re-deriving the gate itself.
1523
+ * While a run is ``running``, its own saves refresh the recorded fingerprint.
1524
+ That is what makes drift detection mean "changed while nobody was running it":
1525
+ in-run edits (scope amendments, replanning) are the run observing its own
1526
+ plan, whereas an edit made after the run died is exactly what `forge resume`
1527
+ must stop and surface.
1528
+ """
1529
+ from .plan_repair import PLAN_STATUS_EXECUTION_READY, plan_status
1530
+
1531
+ pointer = read_current_run_pointer(paths.root)
1532
+ if pointer is None or str(pointer.get("run_id") or "").strip() != paths.run_id:
1533
+ return
1534
+ status = pointer_status(pointer)
1535
+ if status == RUN_STATUS_RUNNING:
1536
+ fingerprint = plan_fingerprint(plan)
1537
+ # Most in-run saves only move task status/attempts, which the fingerprint
1538
+ # deliberately ignores. Rewriting an identical pointer on every one of them
1539
+ # would be pure churn.
1540
+ if pointer.get("plan_fingerprint") != fingerprint:
1541
+ _write_json(
1542
+ current_run_pointer_path(paths.root),
1543
+ {**pointer, "plan_fingerprint": fingerprint},
1544
+ )
1545
+ return
1546
+ if status not in {RUN_STATUS_DRAFT, RUN_STATUS_APPROVED}:
1547
+ return
1548
+ try:
1549
+ ready = plan_status(plan) == PLAN_STATUS_EXECUTION_READY
1550
+ except Exception: # noqa: BLE001 - a plan we cannot grade is not a status change
1551
+ return
1552
+ desired = RUN_STATUS_APPROVED if ready else RUN_STATUS_DRAFT
1553
+ if desired == status:
1554
+ return
1555
+ _write_json(
1556
+ current_run_pointer_path(paths.root),
1557
+ apply_run_status(
1558
+ pointer,
1559
+ desired,
1560
+ reason=(
1561
+ "plan passes the execution-readiness gate"
1562
+ if ready
1563
+ else "plan no longer passes the execution-readiness gate"
1564
+ ),
1565
+ plan_fingerprint=plan_fingerprint(plan) if ready else None,
1566
+ ),
1567
+ )
1568
+
1569
+
1570
+ def reconcile_current_run_status(root: Path) -> str | None:
1571
+ """Demote a crashed ``running`` pointer to ``interrupted``. Returns the new status.
1572
+
1573
+ A run marked ``running`` holds the workspace mutation lock for its whole life,
1574
+ so ``running`` with no live lock means the owning process died without ever
1575
+ reaching a terminal state. Rewriting it here is what removes the need for
1576
+ outside tools to patch ``current_run.json`` themselves to un-stick a workspace.
1577
+ """
1578
+ # Imported lazily: run_lock imports this module, so a top-level import would
1579
+ # close the cycle.
1580
+ from .run_lock import lock_is_live
1581
+
1582
+ pointer = read_current_run_pointer(root)
1583
+ if pointer is None or pointer_status(pointer) != RUN_STATUS_RUNNING:
1584
+ return None
1585
+ workspace_lock_dir = _runtime_dir(root) / "workspace_execution"
1586
+ run_path_raw = str(pointer.get("run_path") or "").strip()
1587
+ run_lock_dirs = [workspace_lock_dir]
1588
+ if run_path_raw and not Path(run_path_raw).is_absolute():
1589
+ run_lock_dirs.append(root / run_path_raw)
1590
+ if any(lock_is_live(candidate) for candidate in run_lock_dirs):
1591
+ return None
1592
+ updated = reconcile_status_after_crash(pointer, lock_is_live=False)
1593
+ if updated is None:
1594
+ return None
1595
+ _write_json(current_run_pointer_path(root), updated)
1596
+ return pointer_status(updated)
1597
+
1598
+
1599
+ def refresh_current_run_pointer_if_tracking_same_run(paths: RunPaths) -> bool:
1600
+ pointer_path = current_run_pointer_path(paths.root)
1601
+ try:
1602
+ pointer = _read_json(pointer_path)
1603
+ except ForgeError:
1604
+ return False
1605
+ if str(pointer.get("run_id") or "").strip() != paths.run_id:
1606
+ return False
1607
+ write_current_run_pointer(paths)
1608
+ return True
1609
+
1610
+
1611
+ def load_current_run_paths(root: Path) -> RunPaths:
1612
+ input_path = ensure_repo_root(root)
1613
+ workspace_context = _resolve_workspace_context_or_error(input_path)
1614
+ candidates = _candidate_pointer_roots(
1615
+ input_path=input_path,
1616
+ workspace_root=workspace_context.workspace_root,
1617
+ )
1618
+ for pointer_root in candidates:
1619
+ pointer_path = current_run_pointer_path(pointer_root)
1620
+ if not pointer_path.exists():
1621
+ continue
1622
+ # Every surface that shows a run reaches it through here, so this is where a
1623
+ # crash leftover stops being shown as active. Best-effort on purpose: an
1624
+ # unwritable pointer must not make reading the run fail.
1625
+ with suppress(Exception):
1626
+ reconcile_current_run_status(pointer_root)
1627
+ return _run_paths_from_pointer(
1628
+ pointer_root=pointer_root,
1629
+ pointer_path=pointer_path,
1630
+ canonical_workspace_root=workspace_context.workspace_root,
1631
+ )
1632
+ raise ForgeError(f"Missing file: {current_run_pointer_path(workspace_context.workspace_root)}")
1633
+
1634
+
1635
+ def create_plan_run(
1636
+ root: Path,
1637
+ *,
1638
+ create_if_missing: bool = False,
1639
+ allow_broad_workspace: bool = False,
1640
+ workspace_binding: WorkspaceBinding | None = None,
1641
+ ) -> RunPaths:
1642
+ if workspace_binding is None:
1643
+ try:
1644
+ binding = resolve_workspace_binding(
1645
+ root,
1646
+ create_if_missing=create_if_missing,
1647
+ allow_broad_workspace=allow_broad_workspace,
1648
+ source="explicit_path",
1649
+ )
1650
+ except WorkspaceBindingError as e:
1651
+ raise ForgeError(str(e)) from e
1652
+ else:
1653
+ binding = workspace_binding
1654
+ try:
1655
+ ensure_workspace_policy(
1656
+ binding,
1657
+ action=WorkspaceAction.FORGE_PLAN,
1658
+ allow_broad_workspace=allow_broad_workspace,
1659
+ )
1660
+ except WorkspaceBindingError:
1661
+ raise _workspace_binding_error(binding) from None
1662
+ paths = _run_paths_from_workspace_context(
1663
+ context=binding.workspace_context,
1664
+ run_id=make_run_id(binding.workspace_context.workspace_root),
1665
+ binding_requested_path=binding.requested_path,
1666
+ binding_source=binding.binding_source,
1667
+ workspace_created_at_startup=binding.created_path,
1668
+ binding_risk_level=binding.risk_level,
1669
+ binding_risk_reasons=binding.risk_reasons,
1670
+ binding_broad_workspace_override_used=binding.broad_workspace_override_used,
1671
+ )
1672
+ _ensure_under_root(paths.root, paths.run_dir)
1673
+
1674
+ paths.assets_dir.mkdir(parents=True, exist_ok=True)
1675
+ paths.asset_store_dir.mkdir(parents=True, exist_ok=True)
1676
+ paths.assets_raw_dir.mkdir(parents=True, exist_ok=True)
1677
+ paths.assets_extracted_dir.mkdir(parents=True, exist_ok=True)
1678
+ paths.assets_comprehensions_dir.mkdir(parents=True, exist_ok=True)
1679
+ paths.notes_dir.mkdir(parents=True, exist_ok=True)
1680
+ paths.plan_replans_dir.mkdir(parents=True, exist_ok=True)
1681
+ paths.decisions_path.parent.mkdir(parents=True, exist_ok=True)
1682
+ paths.knowledge_task_attempts_dir.mkdir(parents=True, exist_ok=True)
1683
+ paths.knowledge_issues_dir.mkdir(parents=True, exist_ok=True)
1684
+ paths.knowledge_facts_dir.mkdir(parents=True, exist_ok=True)
1685
+ paths.knowledge_decisions_dir.mkdir(parents=True, exist_ok=True)
1686
+ paths.knowledge_selected_dir.mkdir(parents=True, exist_ok=True)
1687
+
1688
+ plan = _default_plan_json(paths.run_id)
1689
+ _write_json(paths.plan_json_path, plan)
1690
+ atomic_write_text(
1691
+ paths.plan_md_path,
1692
+ render_plan_markdown(plan, asset_index=_asset_index_for_markdown(paths)),
1693
+ )
1694
+ paths.decisions_path.write_text("", encoding="utf-8")
1695
+ paths.risks_path.write_text("", encoding="utf-8")
1696
+ paths.notes_path.write_text("# Planning Notes\n\n", encoding="utf-8")
1697
+ paths.planner_chat_path.write_text("# Planner Chat\n\n", encoding="utf-8")
1698
+ paths.planner_summary_path.write_text("# Planner Summary\n\n", encoding="utf-8")
1699
+ refresh_workspace_context_artifacts(paths)
1700
+
1701
+ write_current_run_pointer(paths)
1702
+ return paths
1703
+
1704
+
1705
+ def _safe_unique_path(directory: Path, filename: str) -> Path:
1706
+ directory.mkdir(parents=True, exist_ok=True)
1707
+ base_name = Path(filename).name
1708
+ stem = Path(base_name).stem
1709
+ suffix = Path(base_name).suffix
1710
+ candidate = directory / base_name
1711
+ i = 1
1712
+ while candidate.exists():
1713
+ candidate = directory / f"{stem}_{i}{suffix}"
1714
+ i += 1
1715
+ return candidate
1716
+
1717
+
1718
+ def _try_extract_text(source: Path) -> str | None:
1719
+ size = source.stat().st_size
1720
+ if size > MAX_TEXT_ASSET_BYTES:
1721
+ return None
1722
+ raw = source.read_bytes()
1723
+ if b"\x00" in raw:
1724
+ return None
1725
+ try:
1726
+ return raw.decode("utf-8")
1727
+ except UnicodeDecodeError:
1728
+ return None
1729
+
1730
+
1731
+ def attach_asset(root: Path, source_path: Path) -> tuple[RunPaths, dict[str, Any]]:
1732
+ warnings.warn(
1733
+ "attach_asset() is deprecated. Use AssetSurface.add_asset() or the /assets modal / "
1734
+ "`alysis forge assets add` CLI subcommand. Legacy attached assets are "
1735
+ "auto-migrated on next plan load.",
1736
+ DeprecationWarning,
1737
+ stacklevel=2,
1738
+ )
1739
+ LOGGER.warning("attach_asset deprecated legacy_flow=true")
1740
+ input_path = ensure_repo_root(root)
1741
+ paths = load_current_run_paths(input_path)
1742
+ source = source_path.expanduser().resolve()
1743
+ if not source.exists():
1744
+ raise ForgeError(f"Attachment file does not exist: {source}")
1745
+ if not source.is_file():
1746
+ raise ForgeError(f"Attachment path is not a file: {source}")
1747
+
1748
+ dest = _safe_unique_path(paths.assets_dir, source.name)
1749
+ _ensure_under_root(paths.root, dest)
1750
+ shutil.copy2(source, dest)
1751
+
1752
+ plan = load_plan(paths, migrate_legacy=False)
1753
+ metadata: dict[str, Any] = {
1754
+ "original_path": os.fspath(source),
1755
+ "stored_path": _repo_rel(paths.root, dest),
1756
+ "size_bytes": dest.stat().st_size,
1757
+ "added_at": now_iso(),
1758
+ }
1759
+
1760
+ extracted = _try_extract_text(source)
1761
+ if extracted is not None:
1762
+ text_name = source.stem + ".txt"
1763
+ text_dest = _safe_unique_path(paths.assets_text_dir, text_name)
1764
+ _ensure_under_root(paths.root, text_dest)
1765
+ text_dest.write_text(extracted, encoding="utf-8")
1766
+ metadata["text_copy_path"] = _repo_rel(paths.root, text_dest)
1767
+
1768
+ assets = plan.setdefault("assets", [])
1769
+ if not isinstance(assets, list):
1770
+ raise ForgeError("Invalid plan.json: 'assets' must be an array")
1771
+ assets.append(metadata)
1772
+ plan["schema_version"] = 1
1773
+ plan.pop("legacy_assets_migrated_at", None)
1774
+ save_plan(paths, plan)
1775
+ return paths, metadata
1776
+
1777
+
1778
+ def ensure_execution_dirs(paths: RunPaths) -> None:
1779
+ paths.execution_reports_dir.mkdir(parents=True, exist_ok=True)
1780
+ paths.execution_patches_dir.mkdir(parents=True, exist_ok=True)
1781
+ paths.execution_logs_dir.mkdir(parents=True, exist_ok=True)
1782
+ paths.execution_sessions_dir.mkdir(parents=True, exist_ok=True)
1783
+ paths.execution_reviews_dir.mkdir(parents=True, exist_ok=True)
1784
+ paths.execution_verify_dir.mkdir(parents=True, exist_ok=True)
1785
+ paths.execution_context_dir.mkdir(parents=True, exist_ok=True)
1786
+ paths.execution_budgets_dir.mkdir(parents=True, exist_ok=True)
1787
+ paths.execution_asset_briefings_dir.mkdir(parents=True, exist_ok=True)
1788
+ paths.execution_asset_usage_dir.mkdir(parents=True, exist_ok=True)
1789
+ paths.execution_knowledge_capture_dir.mkdir(parents=True, exist_ok=True)
1790
+ paths.execution_integration_dir.mkdir(parents=True, exist_ok=True)
1791
+ paths.knowledge_task_attempts_dir.mkdir(parents=True, exist_ok=True)
1792
+ paths.knowledge_issues_dir.mkdir(parents=True, exist_ok=True)
1793
+ paths.knowledge_facts_dir.mkdir(parents=True, exist_ok=True)
1794
+ paths.knowledge_decisions_dir.mkdir(parents=True, exist_ok=True)
1795
+ paths.knowledge_selected_dir.mkdir(parents=True, exist_ok=True)
1796
+
1797
+
1798
+ def _safe_task_filename(task_id: str) -> str:
1799
+ safe = "".join(c if c.isalnum() or c in {"-", "_"} else "_" for c in task_id)
1800
+ return safe or "task"
1801
+
1802
+
1803
+ def _real_execution_label(value: object) -> str:
1804
+ if value is True:
1805
+ return "yes"
1806
+ if value is False:
1807
+ return "no"
1808
+ return "unknown"
1809
+
1810
+
1811
+ def _verification_results_lines(
1812
+ verify_payload: dict[str, Any] | None,
1813
+ ) -> list[str]:
1814
+ compact_payload = compact_verification_payload(
1815
+ verify_payload,
1816
+ max_command_results=10,
1817
+ output_preview_chars=240,
1818
+ )
1819
+ if compact_payload is None:
1820
+ return ["- Structured verification details unavailable."]
1821
+
1822
+ lines: list[str] = [
1823
+ f"- Summary: {str(compact_payload.get('summary') or '(not available)')}",
1824
+ (
1825
+ f"- All Passed: {'yes' if compact_payload.get('all_passed') else 'no'}"
1826
+ if compact_payload.get("all_passed") is not None
1827
+ else "- All Passed: unknown"
1828
+ ),
1829
+ ]
1830
+ failed_commands = compact_payload.get("failed_commands")
1831
+ if isinstance(failed_commands, list) and failed_commands:
1832
+ lines.append(
1833
+ "- Failed Commands: "
1834
+ + ", ".join(str(item).strip() for item in failed_commands if str(item).strip())
1835
+ )
1836
+ else:
1837
+ lines.append("- Failed Commands: (none)")
1838
+
1839
+ command_results = compact_payload.get("command_results")
1840
+ if not isinstance(command_results, list) or not command_results:
1841
+ lines.append("- Command Results: (none)")
1842
+ return lines
1843
+
1844
+ command_results_total = compact_payload.get("command_results_total")
1845
+ if isinstance(command_results_total, int):
1846
+ total_results = command_results_total
1847
+ elif isinstance(command_results_total, str) and command_results_total.strip():
1848
+ try:
1849
+ total_results = int(command_results_total)
1850
+ except ValueError:
1851
+ total_results = len(command_results)
1852
+ else:
1853
+ total_results = len(command_results)
1854
+ if bool(compact_payload.get("command_results_truncated")):
1855
+ lines.append(
1856
+ f"- Command Results: showing {len(command_results)} of {total_results} commands"
1857
+ )
1858
+ else:
1859
+ lines.append(f"- Command Results: {len(command_results)} command(s)")
1860
+
1861
+ for idx, item in enumerate(command_results, start=1):
1862
+ if not isinstance(item, dict):
1863
+ continue
1864
+ command = str(item.get("command") or "").strip() or "(unknown)"
1865
+ effective_command = str(item.get("effective_command") or command).strip() or command
1866
+ lines.extend(
1867
+ [
1868
+ "",
1869
+ f"### Verification Command {idx}",
1870
+ f"- Command: `{command}`",
1871
+ f"- Effective Command: `{effective_command}`",
1872
+ f"- Exit Code: {item.get('exit_code')}",
1873
+ f"- OK: {'yes' if item.get('ok') else 'no'}",
1874
+ f"- Real Execution: {_real_execution_label(item.get('real_execution'))}",
1875
+ f"- Fallback Used: {'yes' if item.get('fallback_used') else 'no'}",
1876
+ ]
1877
+ )
1878
+ if item.get("fallback_reason") is not None:
1879
+ lines.append(f"- Fallback Reason: {str(item.get('fallback_reason') or '')}")
1880
+ if item.get("non_execution_reason") is not None:
1881
+ lines.append(f"- Non-Execution Reason: {str(item.get('non_execution_reason') or '')}")
1882
+ preview = str(item.get("output_preview") or "").rstrip()
1883
+ lines.extend(
1884
+ [
1885
+ "- Output Preview:",
1886
+ "",
1887
+ "```text",
1888
+ preview or "(empty)",
1889
+ "```",
1890
+ ]
1891
+ )
1892
+ return lines
1893
+
1894
+
1895
+ def write_task_report(
1896
+ *,
1897
+ paths: RunPaths,
1898
+ task: dict[str, Any],
1899
+ result: str,
1900
+ result_kind: str | None = None,
1901
+ summary: str,
1902
+ started_at: str,
1903
+ finished_at: str,
1904
+ changed_files: list[str],
1905
+ verify_commands: list[str],
1906
+ patch_path: Path,
1907
+ budget_artifact_path: Path | None = None,
1908
+ execution_log_artifacts: ExecutionLogArtifactsResult | None = None,
1909
+ verify_artifact_path: Path | None = None,
1910
+ verify_summary: str | None = None,
1911
+ verify_payload: dict[str, Any] | None = None,
1912
+ verify_command_source: str | None = None,
1913
+ base_branch: str | None = None,
1914
+ task_branch: str | None = None,
1915
+ commit_hash: str | None = None,
1916
+ merge_commit_hash: str | None = None,
1917
+ merge_result: str | None = None,
1918
+ salvaged_nonzero_exit: bool = False,
1919
+ salvaged_agent_exception: bool = False,
1920
+ agent_exception_summary: str | None = None,
1921
+ noop_reason: str | None = None,
1922
+ task_kind: str | None = None,
1923
+ task_lifecycle_reason: str | None = None,
1924
+ remote_lines: list[str] | None = None,
1925
+ scope_amendments: list[dict[str, Any]] | None = None,
1926
+ scope_amended_patterns: list[str] | None = None,
1927
+ ) -> Path:
1928
+ ensure_execution_dirs(paths)
1929
+ task_id = str(task.get("id") or "")
1930
+ task_title = str(task.get("title") or "")
1931
+ report_path = paths.execution_reports_dir / f"{_safe_task_filename(task_id)}.md"
1932
+ clean_agent_exception_summary = sanitize_optional_error_summary(agent_exception_summary)
1933
+ lines: list[str] = [
1934
+ f"# Task Execution Report: {task_id}",
1935
+ "",
1936
+ f"- Task Title: {task_title}",
1937
+ f"- Started At: {started_at}",
1938
+ f"- Finished At: {finished_at}",
1939
+ f"- Result: {result}",
1940
+ f"- Result Kind: {result_kind or '(n/a)'}",
1941
+ f"- Task Kind: {task_kind or str(task.get('task_kind') or '(n/a)')}",
1942
+ f"- Task Lifecycle Reason: {task_lifecycle_reason or str(task.get('task_kind_reason') or '(n/a)')}",
1943
+ f"- Salvaged Non-Zero Exit: {'yes' if salvaged_nonzero_exit else 'no'}",
1944
+ f"- Salvaged Agent Exception: {'yes' if salvaged_agent_exception else 'no'}",
1945
+ f"- Agent Exception Summary: {clean_agent_exception_summary or '(none)'}",
1946
+ f"- No-Op Reason: {noop_reason or '(n/a)'}",
1947
+ f"- Patch: `{_repo_rel(paths.root, patch_path)}`",
1948
+ (
1949
+ f"- Budget Artifact: `{_repo_rel(paths.root, budget_artifact_path)}`"
1950
+ if budget_artifact_path is not None
1951
+ else "- Budget Artifact: (none)"
1952
+ ),
1953
+ (
1954
+ f"- Verify Artifact: `{_repo_rel(paths.root, verify_artifact_path)}`"
1955
+ if verify_artifact_path is not None
1956
+ else "- Verify Artifact: (none)"
1957
+ ),
1958
+ (
1959
+ "- Session Logging: enabled"
1960
+ if execution_log_artifacts is None or execution_log_artifacts.logging_enabled
1961
+ else "- Session Logging: disabled (--no-log)"
1962
+ ),
1963
+ (
1964
+ f"- Execution Log: `{_repo_rel(paths.root, execution_log_artifacts.copied_log_path)}`"
1965
+ if execution_log_artifacts is not None
1966
+ and execution_log_artifacts.log_retained
1967
+ and execution_log_artifacts.copied_log_path is not None
1968
+ else "- Execution Log: (not retained)"
1969
+ ),
1970
+ (
1971
+ f"- Session Artifacts: `{_repo_rel(paths.root, execution_log_artifacts.session_artifact_dir)}`"
1972
+ if execution_log_artifacts is not None
1973
+ and execution_log_artifacts.session_artifacts_retained
1974
+ and execution_log_artifacts.session_artifact_dir is not None
1975
+ else "- Session Artifacts: (none retained)"
1976
+ ),
1977
+ f"- Verify Summary: {verify_summary or '(not run)'}",
1978
+ f"- Verify Command Source: {verify_command_source or '(n/a)'}",
1979
+ f"- Base Branch: {base_branch or '(n/a)'}",
1980
+ f"- Task Branch: {task_branch or '(n/a)'}",
1981
+ f"- Commit: {commit_hash or '(none)'}",
1982
+ f"- Merge Commit: {merge_commit_hash or '(none)'}",
1983
+ f"- Merge Result: {merge_result or '(not applicable)'}",
1984
+ "",
1985
+ "## Summary",
1986
+ "",
1987
+ summary or "(no summary)",
1988
+ "",
1989
+ "## Runtime Artifacts",
1990
+ "",
1991
+ ]
1992
+ if execution_log_artifacts is None:
1993
+ lines.append("- Session logging/artifact retention details unavailable.")
1994
+ else:
1995
+ lines.extend(
1996
+ [
1997
+ f"- Logging Enabled: {'yes' if execution_log_artifacts.logging_enabled else 'no'}",
1998
+ f"- Log Retained: {'yes' if execution_log_artifacts.log_retained else 'no'}",
1999
+ (
2000
+ f"- Source Log Path: `"
2001
+ f"{safe_serialized_path(execution_log_artifacts.source_log_path, workspace_root=paths.root)}`"
2002
+ if execution_log_artifacts.source_log_path is not None
2003
+ else "- Source Log Path: (none)"
2004
+ ),
2005
+ (
2006
+ "- Session Artifacts Retained: yes"
2007
+ if execution_log_artifacts.session_artifacts_retained
2008
+ else "- Session Artifacts Retained: no"
2009
+ ),
2010
+ ]
2011
+ )
2012
+ if execution_log_artifacts.note is not None:
2013
+ lines.append(f"- Note: {execution_log_artifacts.note}")
2014
+ if execution_log_artifacts.cleanup_note is not None:
2015
+ lines.append(f"- Cleanup Note: {execution_log_artifacts.cleanup_note}")
2016
+ lines.extend(
2017
+ [
2018
+ "",
2019
+ "## Changed Files",
2020
+ "",
2021
+ ]
2022
+ )
2023
+ if changed_files:
2024
+ for item in changed_files:
2025
+ lines.append(f"- `{item}`")
2026
+ else:
2027
+ lines.append("- (none detected)")
2028
+
2029
+ lines.extend(["", "## Scope Amendments", ""])
2030
+ if scope_amendments:
2031
+ for item in scope_amendments:
2032
+ path = str(item.get("path") or "")
2033
+ reason_code = str(item.get("reason_code") or "unknown")
2034
+ evidence = str(item.get("evidence") or "")
2035
+ lines.append(f"- `{path}` ({reason_code}): {evidence}")
2036
+ added = [str(pattern) for pattern in (scope_amended_patterns or []) if str(pattern).strip()]
2037
+ lines.append(
2038
+ "- write_scope additions: "
2039
+ + (", ".join(f"`{pattern}`" for pattern in added) if added else "(already covered)")
2040
+ )
2041
+ else:
2042
+ lines.append("- (none)")
2043
+
2044
+ lines.extend(["", "## Verification Results", ""])
2045
+ lines.extend(_verification_results_lines(verify_payload))
2046
+
2047
+ lines.extend(["", "## How To Verify", ""])
2048
+ if verify_commands:
2049
+ for cmd in verify_commands:
2050
+ lines.append(f"- `{cmd}`")
2051
+ elif verify_command_source == "task_refinement.no_authoritative_commands":
2052
+ lines.append("- No authoritative verification commands were available for this task yet.")
2053
+ else:
2054
+ lines.append("- Run the task-specific tests and checks relevant to the changed files.")
2055
+
2056
+ lines.extend(["", "## Remote Sync", ""])
2057
+ if remote_lines:
2058
+ for line in remote_lines:
2059
+ lines.append(f"- {line}")
2060
+ else:
2061
+ lines.append("- (not used)")
2062
+
2063
+ atomic_write_text(report_path, "\n".join(lines).rstrip() + "\n")
2064
+ return report_path