alysis-code 0.13.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (458) hide show
  1. alysis_code/__init__.py +3 -0
  2. alysis_code/__main__.py +4 -0
  3. alysis_code/_build_info.py +14 -0
  4. alysis_code/account_login.py +468 -0
  5. alysis_code/agent/README.md +35 -0
  6. alysis_code/agent/__init__.py +11 -0
  7. alysis_code/agent/acceptance_contract.py +2217 -0
  8. alysis_code/agent/blast_radius.py +1403 -0
  9. alysis_code/agent/cache_keepalive.py +227 -0
  10. alysis_code/agent/completion_certificate.py +366 -0
  11. alysis_code/agent/completion_gate.py +306 -0
  12. alysis_code/agent/empty_response_stall.py +403 -0
  13. alysis_code/agent/errors.py +28 -0
  14. alysis_code/agent/llm_calls.py +475 -0
  15. alysis_code/agent/mutation_classification.py +227 -0
  16. alysis_code/agent/prompt_context.py +2508 -0
  17. alysis_code/agent/read_ledger.py +253 -0
  18. alysis_code/agent/regression_baseline.py +642 -0
  19. alysis_code/agent/reproduction_first.py +610 -0
  20. alysis_code/agent/sensitive_output.py +629 -0
  21. alysis_code/agent/session.py +3218 -0
  22. alysis_code/agent/steering.py +191 -0
  23. alysis_code/agent/subagent_execution.py +5177 -0
  24. alysis_code/agent/subagent_workspace.py +666 -0
  25. alysis_code/agent/tools_assembly.py +4728 -0
  26. alysis_code/agent/turn/__init__.py +101 -0
  27. alysis_code/agent/turn/core.py +8483 -0
  28. alysis_code/agent/turn/events.py +113 -0
  29. alysis_code/agent/turn/exploration.py +590 -0
  30. alysis_code/agent/turn/interventions.py +65 -0
  31. alysis_code/agent/turn/read_cache.py +420 -0
  32. alysis_code/agent/turn/snapshot.py +179 -0
  33. alysis_code/agent/turn_contract.py +661 -0
  34. alysis_code/agent/turn_path.py +129 -0
  35. alysis_code/agent/verification.py +2885 -0
  36. alysis_code/agent/verification_commands.py +512 -0
  37. alysis_code/agent/verification_evidence.py +738 -0
  38. alysis_code/agent_loop.py +693 -0
  39. alysis_code/agent_runtimes/__init__.py +51 -0
  40. alysis_code/agent_runtimes/base.py +114 -0
  41. alysis_code/agent_runtimes/builtins.py +129 -0
  42. alysis_code/agent_runtimes/codex_cli.py +664 -0
  43. alysis_code/agent_runtimes/host.py +263 -0
  44. alysis_code/agent_runtimes/registry.py +64 -0
  45. alysis_code/agent_runtimes/service.py +150 -0
  46. alysis_code/agentbox_client.py +416 -0
  47. alysis_code/agentbox_integration.py +310 -0
  48. alysis_code/alysis_cloud.py +152 -0
  49. alysis_code/approval_scope.py +276 -0
  50. alysis_code/assets/README.md +33 -0
  51. alysis_code/assets/__init__.py +126 -0
  52. alysis_code/assets/asset_read_core.py +281 -0
  53. alysis_code/assets/budget_allocator.py +456 -0
  54. alysis_code/assets/comprehender.py +759 -0
  55. alysis_code/assets/index.py +654 -0
  56. alysis_code/assets/ingestion.py +275 -0
  57. alysis_code/assets/legacy_migration.py +413 -0
  58. alysis_code/assets/models.py +263 -0
  59. alysis_code/assets/ocr.py +239 -0
  60. alysis_code/assets/owl/ascii/f-000.txt +13 -0
  61. alysis_code/assets/owl/ascii/f-001.txt +13 -0
  62. alysis_code/assets/owl/ascii/f-002.txt +13 -0
  63. alysis_code/assets/owl/ascii/f-003.txt +13 -0
  64. alysis_code/assets/owl/ascii/f-004.txt +13 -0
  65. alysis_code/assets/owl/ascii/f-005.txt +13 -0
  66. alysis_code/assets/owl/ascii/f-006.txt +13 -0
  67. alysis_code/assets/owl/ascii/f-007.txt +13 -0
  68. alysis_code/assets/owl/ascii/f-008.txt +13 -0
  69. alysis_code/assets/owl/ascii/f-009.txt +13 -0
  70. alysis_code/assets/owl/ascii/f-010.txt +13 -0
  71. alysis_code/assets/owl/ascii/f-011.txt +13 -0
  72. alysis_code/assets/owl/ascii/f-012.txt +13 -0
  73. alysis_code/assets/owl/ascii/f-013.txt +13 -0
  74. alysis_code/assets/owl/ascii/f-014.txt +13 -0
  75. alysis_code/assets/owl/ascii/f-015.txt +13 -0
  76. alysis_code/assets/owl/ascii/f-016.txt +13 -0
  77. alysis_code/assets/owl/ascii/f-017.txt +13 -0
  78. alysis_code/assets/owl/ascii/f-018.txt +13 -0
  79. alysis_code/assets/owl/ascii/f-019.txt +13 -0
  80. alysis_code/assets/owl/ascii/f-020.txt +13 -0
  81. alysis_code/assets/owl/index.html +98 -0
  82. alysis_code/assets/owl/show-owl.sh +761 -0
  83. alysis_code/assets/paths.py +49 -0
  84. alysis_code/assets/plan_binding.py +326 -0
  85. alysis_code/assets/planner_context.py +466 -0
  86. alysis_code/assets/planner_tools.py +184 -0
  87. alysis_code/assets/prompts.py +101 -0
  88. alysis_code/assets/replanner_context.py +239 -0
  89. alysis_code/assets/surface.py +521 -0
  90. alysis_code/assets/untrusted_content.py +48 -0
  91. alysis_code/assets/usage_logger.py +94 -0
  92. alysis_code/assets/worker_mirror.py +428 -0
  93. alysis_code/assets/worker_section.py +303 -0
  94. alysis_code/assets/worker_tools.py +468 -0
  95. alysis_code/atomic_io.py +83 -0
  96. alysis_code/auth_diagnostics.py +272 -0
  97. alysis_code/background_runner.py +366 -0
  98. alysis_code/branding.py +270 -0
  99. alysis_code/budget_policy.py +390 -0
  100. alysis_code/build_identity.py +465 -0
  101. alysis_code/builtin_hooks/__init__.py +7 -0
  102. alysis_code/builtin_hooks/notify_done_windows.py +65 -0
  103. alysis_code/bwrap_etc.py +76 -0
  104. alysis_code/cancellation.py +41 -0
  105. alysis_code/capabilities.py +137 -0
  106. alysis_code/chatgpt_codex_static_provider.py +133 -0
  107. alysis_code/cli.py +51 -0
  108. alysis_code/cli_impl/__init__.py +1 -0
  109. alysis_code/cli_impl/assets_cli.py +537 -0
  110. alysis_code/cli_impl/assets_modal.py +412 -0
  111. alysis_code/cli_impl/chat/__init__.py +156 -0
  112. alysis_code/cli_impl/chat/commands.py +2616 -0
  113. alysis_code/cli_impl/chat/loop.py +4508 -0
  114. alysis_code/cli_impl/chat/mid_turn_policy.py +125 -0
  115. alysis_code/cli_impl/chat/rendering.py +444 -0
  116. alysis_code/cli_impl/chat/state.py +124 -0
  117. alysis_code/cli_impl/chat_resume.py +830 -0
  118. alysis_code/cli_impl/chat_slash_completer.py +258 -0
  119. alysis_code/cli_impl/commands/__init__.py +11 -0
  120. alysis_code/cli_impl/commands/_shared.py +89 -0
  121. alysis_code/cli_impl/commands/auth.py +623 -0
  122. alysis_code/cli_impl/commands/chat_resume_helpers.py +1531 -0
  123. alysis_code/cli_impl/commands/chat_state.py +158 -0
  124. alysis_code/cli_impl/commands/chat_status.py +1248 -0
  125. alysis_code/cli_impl/commands/chat_terminal.py +942 -0
  126. alysis_code/cli_impl/commands/chat_tui_panels.py +1018 -0
  127. alysis_code/cli_impl/commands/cli_common.py +1223 -0
  128. alysis_code/cli_impl/commands/cli_surface.py +77 -0
  129. alysis_code/cli_impl/commands/config.py +131 -0
  130. alysis_code/cli_impl/commands/conventions.py +85 -0
  131. alysis_code/cli_impl/commands/execution_helpers.py +350 -0
  132. alysis_code/cli_impl/commands/extensions.py +401 -0
  133. alysis_code/cli_impl/commands/forge.py +1282 -0
  134. alysis_code/cli_impl/commands/forge_asset_view.py +121 -0
  135. alysis_code/cli_impl/commands/forge_helpers.py +1215 -0
  136. alysis_code/cli_impl/commands/hooks.py +737 -0
  137. alysis_code/cli_impl/commands/ide_bridge.py +31 -0
  138. alysis_code/cli_impl/commands/mcp.py +700 -0
  139. alysis_code/cli_impl/commands/profile.py +453 -0
  140. alysis_code/cli_impl/commands/prompt_helpers.py +307 -0
  141. alysis_code/cli_impl/commands/report.py +88 -0
  142. alysis_code/cli_impl/commands/root.py +1118 -0
  143. alysis_code/cli_impl/commands/sandbox.py +184 -0
  144. alysis_code/cli_impl/commands/server.py +54 -0
  145. alysis_code/cli_impl/commands/sessions.py +252 -0
  146. alysis_code/cli_impl/commands/skills.py +404 -0
  147. alysis_code/cli_impl/commands/startup.py +946 -0
  148. alysis_code/cli_impl/commands/tools.py +335 -0
  149. alysis_code/cli_impl/commands/update.py +364 -0
  150. alysis_code/cli_impl/commands/welcome.py +972 -0
  151. alysis_code/cli_impl/config_menu.py +3882 -0
  152. alysis_code/cli_impl/forge.py +4509 -0
  153. alysis_code/cli_impl/forge_recovery.py +485 -0
  154. alysis_code/cli_impl/setup_wizard.py +2409 -0
  155. alysis_code/cli_impl/tui/__init__.py +58 -0
  156. alysis_code/cli_impl/tui/app.py +4551 -0
  157. alysis_code/cli_impl/tui/config.py +32 -0
  158. alysis_code/cli_impl/tui/config_flow.py +2754 -0
  159. alysis_code/cli_impl/tui/config_overlay.py +566 -0
  160. alysis_code/cli_impl/tui/content.py +78 -0
  161. alysis_code/cli_impl/tui/footer.py +218 -0
  162. alysis_code/cli_impl/tui/forge_status.py +136 -0
  163. alysis_code/cli_impl/tui/markdown.py +244 -0
  164. alysis_code/cli_impl/tui/owl.py +109 -0
  165. alysis_code/cli_impl/tui/plan_meta.py +477 -0
  166. alysis_code/cli_impl/tui/setup_app.py +519 -0
  167. alysis_code/cli_impl/tui/setup_flow.py +1622 -0
  168. alysis_code/cli_impl/tui/state.py +101 -0
  169. alysis_code/cli_impl/tui/subagent_identity.py +66 -0
  170. alysis_code/cli_impl/tui/subagent_panel.py +186 -0
  171. alysis_code/cli_impl/tui/surface.py +796 -0
  172. alysis_code/cli_impl/tui/transcript.py +514 -0
  173. alysis_code/cli_impl/tui/update_prompt.py +79 -0
  174. alysis_code/cli_impl/tui/workspace_guard.py +384 -0
  175. alysis_code/clipboard.py +172 -0
  176. alysis_code/code_review.py +1211 -0
  177. alysis_code/compaction/__init__.py +28 -0
  178. alysis_code/compaction/conversation_compactor.py +2932 -0
  179. alysis_code/compaction/importance.py +177 -0
  180. alysis_code/compaction/settings.py +297 -0
  181. alysis_code/compaction/tool_output_offload.py +447 -0
  182. alysis_code/config.py +3509 -0
  183. alysis_code/conflict_auto_resolver.py +895 -0
  184. alysis_code/context/__init__.py +1 -0
  185. alysis_code/context/tool_schema_budgeter.py +220 -0
  186. alysis_code/crash_diagnostics.py +282 -0
  187. alysis_code/custom_tools/README.md +34 -0
  188. alysis_code/custom_tools/__init__.py +43 -0
  189. alysis_code/custom_tools/discovery.py +903 -0
  190. alysis_code/custom_tools/runtime.py +1516 -0
  191. alysis_code/custom_tools/session.py +227 -0
  192. alysis_code/custom_tools/trust.py +232 -0
  193. alysis_code/diff_paths.py +113 -0
  194. alysis_code/direction_change.py +293 -0
  195. alysis_code/dispatch_timing.py +306 -0
  196. alysis_code/durable_service_manager.py +1236 -0
  197. alysis_code/edit_discipline.py +659 -0
  198. alysis_code/error_text.py +73 -0
  199. alysis_code/execution_budget.py +411 -0
  200. alysis_code/execution_context.py +915 -0
  201. alysis_code/execution_deadline.py +1065 -0
  202. alysis_code/execution_shared.py +1904 -0
  203. alysis_code/extensions/README.md +30 -0
  204. alysis_code/extensions/__init__.py +93 -0
  205. alysis_code/extensions/activation.py +138 -0
  206. alysis_code/extensions/install.py +1436 -0
  207. alysis_code/extensions/manifest.py +487 -0
  208. alysis_code/extensions/models.py +74 -0
  209. alysis_code/extensions/paths.py +56 -0
  210. alysis_code/extensions/registry.json +4 -0
  211. alysis_code/extensions/registry.py +52 -0
  212. alysis_code/extensions/state.py +83 -0
  213. alysis_code/extensions/workspace_trust.py +101 -0
  214. alysis_code/failed_task_evidence.py +369 -0
  215. alysis_code/failure_category.py +315 -0
  216. alysis_code/feedback_report.py +1647 -0
  217. alysis_code/file_classification.py +485 -0
  218. alysis_code/forge.py +2064 -0
  219. alysis_code/forge_completion.py +362 -0
  220. alysis_code/forge_events.py +475 -0
  221. alysis_code/frontmatter_utils.py +95 -0
  222. alysis_code/git_evidence.py +1181 -0
  223. alysis_code/git_ops.py +560 -0
  224. alysis_code/git_safe.py +62 -0
  225. alysis_code/git_worktrees.py +190 -0
  226. alysis_code/hooks/README.md +33 -0
  227. alysis_code/hooks/__init__.py +67 -0
  228. alysis_code/hooks/audit.py +171 -0
  229. alysis_code/hooks/config.py +225 -0
  230. alysis_code/hooks/dispatcher.py +1110 -0
  231. alysis_code/hooks/models.py +447 -0
  232. alysis_code/hooks/trust.py +202 -0
  233. alysis_code/host_actions.py +543 -0
  234. alysis_code/host_browser.py +103 -0
  235. alysis_code/ide/__init__.py +5 -0
  236. alysis_code/ide/activity_events.py +399 -0
  237. alysis_code/ide/approvals.py +337 -0
  238. alysis_code/ide/artifacts.py +153 -0
  239. alysis_code/ide/browser_egress_proxy.py +1076 -0
  240. alysis_code/ide/cdp_websocket_transport.py +1192 -0
  241. alysis_code/ide/change_ledger.py +1721 -0
  242. alysis_code/ide/context_blocks.py +979 -0
  243. alysis_code/ide/event_stream.py +531 -0
  244. alysis_code/ide/forge_protocol.py +3112 -0
  245. alysis_code/ide/forge_request_ledger.py +737 -0
  246. alysis_code/ide/health.py +965 -0
  247. alysis_code/ide/managed_browser.py +2251 -0
  248. alysis_code/ide/management_protocol.py +3414 -0
  249. alysis_code/ide/mcp_oauth_coordinator.py +744 -0
  250. alysis_code/ide/mcp_oauth_lifecycle.py +1504 -0
  251. alysis_code/ide/prompt_queue.py +1070 -0
  252. alysis_code/ide/protocol.py +191 -0
  253. alysis_code/ide/resumable_swarm.py +1543 -0
  254. alysis_code/ide/session_search.py +295 -0
  255. alysis_code/ide/stdio_bridge.py +9935 -0
  256. alysis_code/ide/structured_state.py +1579 -0
  257. alysis_code/ide/swarm_protocol.py +816 -0
  258. alysis_code/integration_gate.py +506 -0
  259. alysis_code/interactive_input_guard.py +39 -0
  260. alysis_code/interactive_plan_mode.py +26 -0
  261. alysis_code/internal_artifacts.py +179 -0
  262. alysis_code/knowledge_base.py +1409 -0
  263. alysis_code/knowledge_capture.py +1190 -0
  264. alysis_code/knowledge_librarian.py +605 -0
  265. alysis_code/language_policy.py +34 -0
  266. alysis_code/litellm_static_provider.py +535 -0
  267. alysis_code/llm/__init__.py +1 -0
  268. alysis_code/llm/anthropic_messages.py +2288 -0
  269. alysis_code/llm/base.py +71 -0
  270. alysis_code/llm/cache_capabilities.py +985 -0
  271. alysis_code/llm/cache_control_blocks.py +244 -0
  272. alysis_code/llm/cache_policy.py +388 -0
  273. alysis_code/llm/factory.py +373 -0
  274. alysis_code/llm/gemini_generate_content.py +2652 -0
  275. alysis_code/llm/gemini_interactions.py +739 -0
  276. alysis_code/llm/metadata.py +450 -0
  277. alysis_code/llm/openai_compat.py +2947 -0
  278. alysis_code/llm/openai_responses.py +2604 -0
  279. alysis_code/llm/protocols.py +609 -0
  280. alysis_code/llm/provider_limits.py +525 -0
  281. alysis_code/llm/request_plan.py +389 -0
  282. alysis_code/llm/request_shape.py +238 -0
  283. alysis_code/llm/streaming.py +108 -0
  284. alysis_code/llm/temperature_compat.py +78 -0
  285. alysis_code/llm/types.py +195 -0
  286. alysis_code/llm/usage_normalization.py +222 -0
  287. alysis_code/llm_error_display.py +315 -0
  288. alysis_code/logging_redaction.py +326 -0
  289. alysis_code/managed_host_deadline.py +196 -0
  290. alysis_code/mcp/README.md +33 -0
  291. alysis_code/mcp/__init__.py +24 -0
  292. alysis_code/mcp/client.py +1137 -0
  293. alysis_code/mcp/config.py +597 -0
  294. alysis_code/mcp/errors.py +113 -0
  295. alysis_code/mcp/forge_scope.py +154 -0
  296. alysis_code/mcp/jsonrpc.py +214 -0
  297. alysis_code/mcp/manager.py +2308 -0
  298. alysis_code/mcp/models.py +666 -0
  299. alysis_code/mcp/oauth.py +972 -0
  300. alysis_code/mcp/oauth_runtime.py +310 -0
  301. alysis_code/mcp/oauth_store.py +276 -0
  302. alysis_code/mcp/prompts.py +329 -0
  303. alysis_code/mcp/resources.py +295 -0
  304. alysis_code/mcp/roots.py +106 -0
  305. alysis_code/mcp/server_requests.py +75 -0
  306. alysis_code/mcp/token_store.py +859 -0
  307. alysis_code/mcp/transport_http.py +1338 -0
  308. alysis_code/mcp/transport_stdio.py +1267 -0
  309. alysis_code/mcp/untrusted_content.py +119 -0
  310. alysis_code/merge_conflict_reviewer.py +729 -0
  311. alysis_code/model_catalog/__init__.py +1 -0
  312. alysis_code/model_catalog/chatgpt_codex_subscription_snapshot.json +186 -0
  313. alysis_code/model_catalog/litellm_model_prices_snapshot.json +44715 -0
  314. alysis_code/model_catalog/litellm_model_prices_snapshot.meta.json +17 -0
  315. alysis_code/model_metadata_policy.py +223 -0
  316. alysis_code/model_metadata_utils.py +103 -0
  317. alysis_code/model_registry.py +1420 -0
  318. alysis_code/model_router.py +147 -0
  319. alysis_code/permission_policy.py +1016 -0
  320. alysis_code/personas.py +451 -0
  321. alysis_code/pipeline_facts.py +233 -0
  322. alysis_code/plan_assistant.py +4763 -0
  323. alysis_code/plan_mode.py +393 -0
  324. alysis_code/plan_reconciliation.py +1228 -0
  325. alysis_code/plan_repair.py +652 -0
  326. alysis_code/plan_validation.py +1099 -0
  327. alysis_code/planning_constraints.py +904 -0
  328. alysis_code/policy.py +95 -0
  329. alysis_code/preview_server.py +457 -0
  330. alysis_code/process_reaping.py +566 -0
  331. alysis_code/profile_presets.py +1834 -0
  332. alysis_code/profiles.py +666 -0
  333. alysis_code/provider_auth/__init__.py +29 -0
  334. alysis_code/provider_auth/base.py +99 -0
  335. alysis_code/provider_auth/openai_codex.py +951 -0
  336. alysis_code/provider_auth/registry.py +76 -0
  337. alysis_code/provider_auth/store.py +125 -0
  338. alysis_code/provider_diagnostics.py +1209 -0
  339. alysis_code/provider_model_catalog.py +685 -0
  340. alysis_code/provider_telemetry.py +1699 -0
  341. alysis_code/provider_url.py +75 -0
  342. alysis_code/reasoning_contracts.py +911 -0
  343. alysis_code/remote_sync.py +350 -0
  344. alysis_code/replanning.py +1195 -0
  345. alysis_code/repo_scan.py +1152 -0
  346. alysis_code/request_estimation.py +296 -0
  347. alysis_code/review_gate.py +617 -0
  348. alysis_code/run_lock.py +1141 -0
  349. alysis_code/run_outcome.py +58 -0
  350. alysis_code/run_provenance.py +774 -0
  351. alysis_code/run_state.py +445 -0
  352. alysis_code/runtime_artifacts.py +116 -0
  353. alysis_code/runtime_context_features.py +78 -0
  354. alysis_code/runtime_kind.py +52 -0
  355. alysis_code/safety/__init__.py +11 -0
  356. alysis_code/safety/mcp_sanitize.py +29 -0
  357. alysis_code/safety/safe_http.py +297 -0
  358. alysis_code/safety/subagent_report.py +184 -0
  359. alysis_code/sandbox_doctor.py +682 -0
  360. alysis_code/sandbox_runner.py +1025 -0
  361. alysis_code/sandbox_settings.py +423 -0
  362. alysis_code/serialized_paths.py +355 -0
  363. alysis_code/server/__init__.py +3 -0
  364. alysis_code/server/app.py +367 -0
  365. alysis_code/server/auth.py +34 -0
  366. alysis_code/server/job_config.py +30 -0
  367. alysis_code/server/settings.py +215 -0
  368. alysis_code/server/store.py +193 -0
  369. alysis_code/server/worker_runner.py +657 -0
  370. alysis_code/service_persistence.py +355 -0
  371. alysis_code/session_artifacts.py +108 -0
  372. alysis_code/session_metrics.py +331 -0
  373. alysis_code/session_store.py +624 -0
  374. alysis_code/skills/README.md +34 -0
  375. alysis_code/skills/__init__.py +104 -0
  376. alysis_code/skills/conventions.py +84 -0
  377. alysis_code/skills/discovery.py +176 -0
  378. alysis_code/skills/eval_models.py +232 -0
  379. alysis_code/skills/eval_runner.py +372 -0
  380. alysis_code/skills/evals.py +1344 -0
  381. alysis_code/skills/install.py +293 -0
  382. alysis_code/skills/loader.py +118 -0
  383. alysis_code/skills/matching.py +103 -0
  384. alysis_code/skills/models.py +71 -0
  385. alysis_code/skills/paths.py +56 -0
  386. alysis_code/skills/prompting.py +500 -0
  387. alysis_code/skills/scaffold.py +142 -0
  388. alysis_code/skills/state.py +441 -0
  389. alysis_code/skills/transactions.py +125 -0
  390. alysis_code/skills/validation.py +304 -0
  391. alysis_code/step_budget.py +238 -0
  392. alysis_code/subagent_labels.py +49 -0
  393. alysis_code/subagents.py +1072 -0
  394. alysis_code/surface/__init__.py +80 -0
  395. alysis_code/surface/base.py +305 -0
  396. alysis_code/surface/console.py +387 -0
  397. alysis_code/surface/events.py +372 -0
  398. alysis_code/surface/hidden_surface.py +529 -0
  399. alysis_code/surface/noop_surface.py +219 -0
  400. alysis_code/surface/rich_surface.py +1555 -0
  401. alysis_code/surface/styles.py +67 -0
  402. alysis_code/surface/theme.py +455 -0
  403. alysis_code/surface/types.py +100 -0
  404. alysis_code/swarm_backend.py +926 -0
  405. alysis_code/swarm_orchestrator.py +4020 -0
  406. alysis_code/swarm_scheduler.py +441 -0
  407. alysis_code/swarm_trace.py +429 -0
  408. alysis_code/swarm_worker.py +2119 -0
  409. alysis_code/swarm_write_guard.py +348 -0
  410. alysis_code/task_dependencies.py +170 -0
  411. alysis_code/task_readiness.py +992 -0
  412. alysis_code/task_scope.py +2148 -0
  413. alysis_code/terminal_manager.py +762 -0
  414. alysis_code/terminal_ownership.py +460 -0
  415. alysis_code/text_normalization.py +30 -0
  416. alysis_code/token_budget.py +97 -0
  417. alysis_code/tools/README.md +34 -0
  418. alysis_code/tools/__init__.py +1 -0
  419. alysis_code/tools/artifacts.py +127 -0
  420. alysis_code/tools/availability.py +188 -0
  421. alysis_code/tools/fs.py +1456 -0
  422. alysis_code/tools/git.py +461 -0
  423. alysis_code/tools/history.py +229 -0
  424. alysis_code/tools/http_timeout.py +78 -0
  425. alysis_code/tools/image_generation.py +552 -0
  426. alysis_code/tools/registry.py +2936 -0
  427. alysis_code/tools/repo_map.py +476 -0
  428. alysis_code/tools/search.py +563 -0
  429. alysis_code/tools/shell.py +135 -0
  430. alysis_code/tools/symbols.py +1350 -0
  431. alysis_code/tools/test_discovery.py +643 -0
  432. alysis_code/tools/web.py +482 -0
  433. alysis_code/tools/web_search.py +2012 -0
  434. alysis_code/tools/web_search_dashscope.py +557 -0
  435. alysis_code/tools/web_search_ddgs.py +221 -0
  436. alysis_code/tools/web_search_provider_adapters.py +1429 -0
  437. alysis_code/tools/web_search_tavily.py +194 -0
  438. alysis_code/updates.py +933 -0
  439. alysis_code/usage_tracker.py +1990 -0
  440. alysis_code/verification_command_analysis.py +1004 -0
  441. alysis_code/verification_contract.py +574 -0
  442. alysis_code/verification_failure_summary.py +273 -0
  443. alysis_code/verification_repair.py +385 -0
  444. alysis_code/verify_gate.py +3129 -0
  445. alysis_code/web_research.py +1872 -0
  446. alysis_code/web_search_adapters.py +66 -0
  447. alysis_code/web_search_policy.py +27 -0
  448. alysis_code/workspace_binding.py +389 -0
  449. alysis_code/workspace_binding_ui.py +408 -0
  450. alysis_code/workspace_context.py +273 -0
  451. alysis_code/workspace_isolation.py +138 -0
  452. alysis_code/workspace_provisioning.py +455 -0
  453. alysis_code-0.13.0.dist-info/METADATA +507 -0
  454. alysis_code-0.13.0.dist-info/RECORD +458 -0
  455. alysis_code-0.13.0.dist-info/WHEEL +4 -0
  456. alysis_code-0.13.0.dist-info/entry_points.txt +3 -0
  457. alysis_code-0.13.0.dist-info/licenses/LICENSE +176 -0
  458. alysis_code-0.13.0.dist-info/licenses/NOTICE +4 -0
@@ -0,0 +1,1904 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import shutil
6
+ import subprocess
7
+ import tempfile
8
+ from dataclasses import dataclass
9
+ from hashlib import sha256
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from .agent.prompt_context import _build_user_message
14
+ from .branding import env_get
15
+ from .compaction.settings import resolve_compaction_settings
16
+ from .config import AppConfig
17
+ from .execution_budget import (
18
+ DEFAULT_EXECUTION_HEADROOM_RESERVE_TOKENS,
19
+ DEFAULT_EXECUTION_RESPONSE_RESERVE_TOKENS,
20
+ DEFAULT_EXECUTION_SAFETY_MARGIN_TOKENS,
21
+ DEFAULT_MINIMUM_EXECUTION_INSTRUCTION_BUDGET_TOKENS,
22
+ ExecutionPromptBudget,
23
+ compute_execution_prompt_budget_inputs,
24
+ )
25
+ from .execution_context import (
26
+ build_task_context_pack,
27
+ build_task_context_pack_result,
28
+ select_relevant_assets,
29
+ select_relevant_image_paths,
30
+ )
31
+ from .forge import RunPaths, ensure_execution_dirs
32
+ from .git_ops import GitOpsError, changed_files_since, diff_text_since
33
+ from .git_safe import build_git_process_env
34
+ from .knowledge_librarian import MaterializedKnowledgeSelection, prepare_relevant_knowledge
35
+ from .model_registry import ModelRegistry
36
+ from .request_estimation import estimate_request_tokens
37
+ from .runtime_artifacts import (
38
+ ROOT_RUNTIME_ARTIFACT_DIR_NAMES,
39
+ RUNTIME_ARTIFACT_DIR_NAMES,
40
+ is_runtime_artifact_path,
41
+ )
42
+ from .serialized_paths import safe_serialized_path
43
+ from .session_store import resolve_sessions_dir, sanitize_session_id
44
+ from .step_budget import (
45
+ AUTONOMOUS_STEP_BUDGET_POLICY,
46
+ DEFAULT_TASK_MAX_STEPS,
47
+ StepBudgetRequest,
48
+ StepBudgetResolution,
49
+ resolve_step_budget,
50
+ )
51
+ from .task_scope import is_non_material_untracked_path
52
+ from .token_budget import compute_input_budget
53
+
54
+ _SNAPSHOT_HASH_MAX_BYTES = 1 * 1024 * 1024
55
+ # Legacy env gate. The new asset system supersedes this and is enabled by
56
+ # default via cfg.assets.worker.inline_images. The env var continues to control
57
+ # the legacy plan["assets"][]-based image path injection until those code paths
58
+ # are removed in a future major version.
59
+ _TASK_IMAGES_ENV = "ALYSIS_TASK_IMAGES"
60
+ _DEFAULT_SNAPSHOT_EXCLUDES = RUNTIME_ARTIFACT_DIR_NAMES
61
+ _READ_ONLY_GIT_TIMEOUT_S = 5.0
62
+
63
+
64
+ @dataclass(frozen=True)
65
+ class PreparedTaskExecutionInstruction:
66
+ instruction: str
67
+ artifact_text: str
68
+ budget: ExecutionPromptBudget
69
+ final_instruction_token_estimate: int
70
+ truncated: bool
71
+ truncation_strategy: str
72
+ subagents_enabled: bool
73
+ image_paths: tuple[str, ...]
74
+ startup_headroom: StartupHeadroomTelemetry | None = None
75
+
76
+ def to_budget_artifact_payload(self) -> dict[str, Any]:
77
+ payload = dict(self.budget.to_payload())
78
+ payload["final_instruction_token_estimate"] = self.final_instruction_token_estimate
79
+ payload["truncated"] = self.truncated
80
+ payload["truncation_strategy"] = self.truncation_strategy
81
+ if self.startup_headroom is not None:
82
+ payload.update(self.startup_headroom.to_payload())
83
+ return payload
84
+
85
+
86
+ @dataclass(frozen=True)
87
+ class StartupHeadroomTelemetry:
88
+ initial_request_token_estimate: int
89
+ initial_request_token_estimate_before_adjustment: int | None
90
+ compaction_budget_tokens: int
91
+ compaction_trigger_tokens: int
92
+ startup_target_tokens: int
93
+ startup_headroom_tokens: int
94
+ startup_headroom_adjustment_applied: bool
95
+ startup_headroom_adjustment_reason: str | None
96
+
97
+ def to_payload(self) -> dict[str, Any]:
98
+ payload: dict[str, Any] = {
99
+ "initial_request_token_estimate": self.initial_request_token_estimate,
100
+ "compaction_budget_tokens": self.compaction_budget_tokens,
101
+ "compaction_trigger_tokens": self.compaction_trigger_tokens,
102
+ "startup_target_tokens": self.startup_target_tokens,
103
+ "startup_headroom_tokens": self.startup_headroom_tokens,
104
+ "startup_headroom_adjustment_applied": self.startup_headroom_adjustment_applied,
105
+ "startup_headroom_adjustment_reason": self.startup_headroom_adjustment_reason,
106
+ }
107
+ if self.initial_request_token_estimate_before_adjustment is not None:
108
+ payload["initial_request_token_estimate_before_adjustment"] = (
109
+ self.initial_request_token_estimate_before_adjustment
110
+ )
111
+ return payload
112
+
113
+
114
+ @dataclass(frozen=True)
115
+ class PreparedExecutionKnowledge:
116
+ materialized: MaterializedKnowledgeSelection
117
+ prompt_section: str
118
+
119
+ @property
120
+ def manifest_path(self) -> Path:
121
+ return self.materialized.manifest_path
122
+
123
+ @property
124
+ def selected_dir(self) -> Path:
125
+ return self.materialized.selected_dir
126
+
127
+
128
+ def _task_execution_text(task: dict[str, Any] | None) -> str:
129
+ if not isinstance(task, dict):
130
+ return ""
131
+ parts: list[str] = []
132
+ for key in ("title", "description"):
133
+ value = task.get(key)
134
+ if value:
135
+ parts.append(str(value))
136
+ for key in ("acceptance_criteria", "estimated_files", "write_scope"):
137
+ value = task.get(key)
138
+ if isinstance(value, list):
139
+ parts.extend(str(item) for item in value if str(item).strip())
140
+ return "\n".join(parts)
141
+
142
+
143
+ def _scope_patterns_are_test_only(patterns: list[str] | None, task: dict[str, Any] | None) -> bool:
144
+ raw_patterns = list(patterns or [])
145
+ if not raw_patterns and isinstance(task, dict):
146
+ raw_patterns = [str(item) for item in task.get("write_scope") or []]
147
+ normalized = [pattern.strip().replace("\\", "/").lower() for pattern in raw_patterns]
148
+ normalized = [pattern for pattern in normalized if pattern]
149
+ if not normalized:
150
+ return False
151
+
152
+ def _is_test_pattern(pattern: str) -> bool:
153
+ leaf = pattern.rstrip("/").rsplit("/", 1)[-1]
154
+ return (
155
+ pattern.startswith("tests/")
156
+ or pattern.startswith("test/")
157
+ or "/tests/" in pattern
158
+ or "/test/" in pattern
159
+ or leaf.startswith("test_")
160
+ or leaf.endswith("_test.py")
161
+ or leaf.endswith(".test.js")
162
+ or leaf.endswith(".spec.js")
163
+ or leaf.endswith(".test.ts")
164
+ or leaf.endswith(".spec.ts")
165
+ )
166
+
167
+ return all(_is_test_pattern(pattern) for pattern in normalized)
168
+
169
+
170
+ def _scope_patterns_are_docs_only(patterns: list[str] | None, task: dict[str, Any] | None) -> bool:
171
+ raw_patterns = list(patterns or [])
172
+ if not raw_patterns and isinstance(task, dict):
173
+ raw_patterns = [str(item) for item in task.get("write_scope") or []]
174
+ normalized = [pattern.strip().replace("\\", "/").lower() for pattern in raw_patterns]
175
+ normalized = [pattern for pattern in normalized if pattern]
176
+ if not normalized:
177
+ return False
178
+
179
+ def _is_docs_pattern(pattern: str) -> bool:
180
+ leaf = pattern.rstrip("/").rsplit("/", 1)[-1]
181
+ return (
182
+ pattern in {"readme", "readme.md"}
183
+ or pattern.startswith("docs/")
184
+ or pattern.startswith("doc/")
185
+ or leaf.endswith(".md")
186
+ or leaf.endswith(".rst")
187
+ or leaf.endswith(".txt")
188
+ )
189
+
190
+ return all(_is_docs_pattern(pattern) for pattern in normalized)
191
+
192
+
193
+ def _authoritative_verification_context_section(
194
+ commands: list[str] | None,
195
+ *,
196
+ task: dict[str, Any] | None = None,
197
+ allow_write_globs: list[str] | None = None,
198
+ ) -> str:
199
+ cleaned = [str(command).strip() for command in commands or [] if str(command).strip()]
200
+ if not cleaned:
201
+ return ""
202
+ command_text = "\n".join(cleaned).casefold()
203
+ task_text = _task_execution_text(task).casefold()
204
+ test_only_scope = _scope_patterns_are_test_only(allow_write_globs, task)
205
+ docs_only_scope = _scope_patterns_are_docs_only(allow_write_globs, task)
206
+ doctest_related = "doctest" in command_text or "doctest" in task_text
207
+ packaging_related = any(
208
+ marker in task_text
209
+ for marker in (
210
+ "console script",
211
+ "entry point",
212
+ "entrypoint",
213
+ "installable",
214
+ "pip install",
215
+ "pyproject",
216
+ "setuptools",
217
+ "wheel",
218
+ "packaging",
219
+ )
220
+ ) or any(marker in command_text for marker in ("pip ", "python -m build", "build ", "wheel"))
221
+ lines = [
222
+ "## Authoritative Verification",
223
+ "",
224
+ "The managed task has locked verification commands. Before the final response,",
225
+ "run `verify_run` with no arguments or run every command exactly, and make sure",
226
+ "every listed command passes. Do not claim success from an alternate command",
227
+ "while any listed command is still failing; fix the task or report a concrete",
228
+ "infrastructure blocker.",
229
+ "",
230
+ "If a verification failure points toward changing files outside the write scope,",
231
+ "first look for an in-scope repair that makes the allowed change self-contained",
232
+ "and executable. Report a blocker only after ruling out an in-scope fix.",
233
+ "",
234
+ "Do not leave temporary command-output files in the repository root. Use `/tmp`",
235
+ "or the command output shown by the tool, and remove scratch logs before finalizing.",
236
+ "",
237
+ ]
238
+ if test_only_scope:
239
+ lines.extend(
240
+ [
241
+ "For test-only tasks, the tests are part of the deliverable. If verification",
242
+ "fails because the generated tests or test harness are wrong, fix the test",
243
+ "code within scope before reporting a blocker. Prefer pytest capture fixtures",
244
+ "such as `capsys`/`monkeypatch` over patching builtins or global stdout.",
245
+ "",
246
+ ]
247
+ )
248
+ if doctest_related and docs_only_scope:
249
+ lines.extend(
250
+ [
251
+ "For docs/doctest-only tasks, a local import failure from a locked doctest",
252
+ "command is still actionable when the editable document can provide setup.",
253
+ "Use in-document doctest setup/import-path lines or another docs-only repair",
254
+ "so every locked command passes before declaring infrastructure blocked.",
255
+ "",
256
+ ]
257
+ )
258
+ if packaging_related:
259
+ lines.extend(
260
+ [
261
+ "For packaging/install tasks, prefer in-scope project metadata repairs",
262
+ "such as `pyproject.toml` configuration. Do not create setup.cfg/setup.py",
263
+ "unless those files are already in scope; inspect build/install output",
264
+ "without committing diagnostic logs.",
265
+ "",
266
+ ]
267
+ )
268
+ lines.extend(f"- `{command}`" for command in cleaned)
269
+ return "\n".join(lines)
270
+
271
+
272
+ @dataclass(frozen=True)
273
+ class ExecutionReportingDiff:
274
+ changed_files: tuple[str, ...]
275
+ patch_text: str
276
+ inspection_error: str | None = None
277
+
278
+
279
+ @dataclass(frozen=True)
280
+ class TaskLocalWorkspaceBaseline:
281
+ before_commit: str | None
282
+ before_snapshot: dict[str, str]
283
+ preexisting_changed_files: tuple[str, ...] = ()
284
+ snapshot_root: Path | None = None
285
+
286
+ @property
287
+ def had_preexisting_workspace_dirt(self) -> bool:
288
+ return bool(self.preexisting_changed_files)
289
+
290
+
291
+ @dataclass(frozen=True)
292
+ class _GitStatusEntry:
293
+ status: str
294
+ path: str
295
+ orig_path: str | None = None
296
+
297
+
298
+ def _workspace_snapshot_signature(path: Path) -> str | None:
299
+ try:
300
+ stat = path.stat()
301
+ except OSError:
302
+ return None
303
+ if not path.is_file():
304
+ return None
305
+ # Small files use content hashing so snapshot-based task-local deltas do not report
306
+ # touched-and-restored paths as real changes. Large files keep the cheaper metadata fallback.
307
+ if stat.st_size <= _SNAPSHOT_HASH_MAX_BYTES:
308
+ try:
309
+ digest = sha256(path.read_bytes()).hexdigest()
310
+ except OSError:
311
+ digest = ""
312
+ if digest:
313
+ return f"sha256:{digest}"
314
+ ctime_ns = getattr(stat, "st_ctime_ns", int(stat.st_ctime * 1_000_000_000))
315
+ return f"meta:{stat.st_size}:{stat.st_mtime_ns}:{ctime_ns}"
316
+
317
+
318
+ def _workspace_snapshot_git_paths(root: Path) -> set[str] | None:
319
+ if shutil.which("git") is None:
320
+ return None
321
+ git_dir = root / ".git"
322
+ if not git_dir.exists():
323
+ return None
324
+ paths: set[str] = set()
325
+ for item in [*_git_cached_files(root), *_git_untracked_files(root)]:
326
+ normalized = _normalize_reporting_changed_path(root=root, path=item)
327
+ if normalized:
328
+ paths.add(normalized)
329
+ return paths
330
+
331
+
332
+ def _workspace_snapshot_walk_paths(root: Path) -> set[str]:
333
+ root_resolved = root.resolve()
334
+ paths: set[str] = set()
335
+ for current_root, dirnames, filenames in os.walk(root_resolved):
336
+ current_path = Path(current_root)
337
+ resolved_current = current_path.resolve()
338
+ rel_dir = (
339
+ resolved_current.relative_to(root_resolved).as_posix()
340
+ if resolved_current != root_resolved
341
+ else ""
342
+ )
343
+ kept_dirnames: list[str] = []
344
+ for dirname in dirnames:
345
+ rel_candidate = dirname if not rel_dir else f"{rel_dir}/{dirname}"
346
+ normalized = _normalize_reporting_changed_path(root=root_resolved, path=rel_candidate)
347
+ if normalized and is_runtime_artifact_path(normalized, root=root_resolved):
348
+ continue
349
+ kept_dirnames.append(dirname)
350
+ dirnames[:] = kept_dirnames
351
+
352
+ for filename in filenames:
353
+ rel_candidate = filename if not rel_dir else f"{rel_dir}/{filename}"
354
+ normalized = _normalize_reporting_changed_path(root=root_resolved, path=rel_candidate)
355
+ if not normalized:
356
+ continue
357
+ paths.add(normalized)
358
+ return paths
359
+
360
+
361
+ def snapshot_workspace_tree(root: Path) -> dict[str, str]:
362
+ candidate_paths = _workspace_snapshot_git_paths(root)
363
+ if candidate_paths is None:
364
+ candidate_paths = _workspace_snapshot_walk_paths(root)
365
+
366
+ snapshot: dict[str, str] = {}
367
+ for rel_path in sorted(candidate_paths):
368
+ signature = _workspace_snapshot_signature(
369
+ _path_under_root(root=root.resolve(), rel_path=rel_path)
370
+ )
371
+ if signature is None:
372
+ continue
373
+ snapshot[rel_path] = signature
374
+ return snapshot
375
+
376
+
377
+ def _workspace_snapshot_changed_files(
378
+ *,
379
+ before: dict[str, str],
380
+ after: dict[str, str],
381
+ ) -> tuple[str, ...]:
382
+ changed = {
383
+ rel_path
384
+ for rel_path in (set(before) | set(after))
385
+ if before.get(rel_path) != after.get(rel_path)
386
+ }
387
+ return tuple(sorted(changed))
388
+
389
+
390
+ def safe_task_file_component(task_id: str) -> str:
391
+ safe = "".join(c if c.isalnum() or c in {"-", "_"} else "_" for c in task_id)
392
+ return safe or "task"
393
+
394
+
395
+ def build_task_execution_instruction(
396
+ *,
397
+ plan: dict[str, Any],
398
+ task: dict[str, Any],
399
+ cfg: AppConfig | None = None,
400
+ role_model: str | None = None,
401
+ instruction_token_budget: int | None = None,
402
+ leading_sections: list[str] | None = None,
403
+ relevant_assets_section: str | None = None,
404
+ ) -> str:
405
+ effective_model = (role_model or "").strip()
406
+ effective_cfg = cfg or AppConfig(model=effective_model)
407
+ if not effective_model:
408
+ effective_model = (effective_cfg.model or "").strip() or "unknown-model"
409
+ return build_task_context_pack(
410
+ cfg=effective_cfg,
411
+ plan=plan,
412
+ task=task,
413
+ role_model=effective_model,
414
+ instruction_token_budget=instruction_token_budget,
415
+ leading_sections=leading_sections,
416
+ relevant_assets_section=relevant_assets_section,
417
+ )
418
+
419
+
420
+ def _estimate_initial_execution_request_tokens(
421
+ *,
422
+ root: Path,
423
+ prefix_messages: tuple[dict[str, Any], ...],
424
+ tool_list: tuple[dict[str, Any], ...],
425
+ instruction: str,
426
+ image_paths: tuple[str, ...],
427
+ ) -> int:
428
+ user_message, _log_payload = _build_user_message(
429
+ root=root,
430
+ instruction=instruction,
431
+ image_paths=list(image_paths) or None,
432
+ )
433
+ return estimate_request_tokens(
434
+ [*prefix_messages, user_message],
435
+ list(tool_list),
436
+ )
437
+
438
+
439
+ def _resolve_startup_headroom_target(
440
+ *,
441
+ cfg: AppConfig,
442
+ role_model: str,
443
+ model_registry: ModelRegistry | None,
444
+ ) -> tuple[int, int, int]:
445
+ registry = model_registry or ModelRegistry(cfg=cfg)
446
+ meta = registry.get(role_model)
447
+ compaction_settings = resolve_compaction_settings(cfg)
448
+ compaction_budget_tokens = compute_input_budget(
449
+ meta,
450
+ safety_margin=compaction_settings.safety_margin_tokens,
451
+ )
452
+ compaction_trigger_tokens = max(
453
+ 0,
454
+ int(compaction_budget_tokens * compaction_settings.trigger_ratio),
455
+ )
456
+ compaction_target_tokens = max(
457
+ 0,
458
+ int(compaction_budget_tokens * compaction_settings.target_ratio),
459
+ )
460
+
461
+ # Keep the first managed-exec request below the real execution compaction
462
+ # trigger with enough slack for a meaningful future compaction bundle.
463
+ removable_buffer_tokens = max(
464
+ 256,
465
+ int(compaction_settings.execution_min_removable_tokens),
466
+ )
467
+ startup_target_tokens = max(
468
+ compaction_target_tokens,
469
+ compaction_trigger_tokens - removable_buffer_tokens,
470
+ )
471
+ if compaction_trigger_tokens > 0:
472
+ startup_target_tokens = min(
473
+ startup_target_tokens,
474
+ compaction_trigger_tokens - 1,
475
+ )
476
+ else:
477
+ startup_target_tokens = 0
478
+ return (
479
+ compaction_budget_tokens,
480
+ compaction_trigger_tokens,
481
+ max(0, startup_target_tokens),
482
+ )
483
+
484
+
485
+ def _apply_startup_headroom_preflight(
486
+ *,
487
+ root: Path,
488
+ effective_cfg: AppConfig,
489
+ effective_model: str,
490
+ plan: dict[str, Any],
491
+ task: dict[str, Any],
492
+ model_registry: ModelRegistry | None,
493
+ budget: ExecutionPromptBudget,
494
+ prefix_messages: tuple[dict[str, Any], ...],
495
+ tool_list: tuple[dict[str, Any], ...],
496
+ image_paths: tuple[str, ...],
497
+ leading_sections: list[str] | None,
498
+ relevant_assets_section: str | None,
499
+ initial_pack_result: Any,
500
+ ) -> tuple[Any, StartupHeadroomTelemetry]:
501
+ compaction_budget_tokens, compaction_trigger_tokens, startup_target_tokens = (
502
+ _resolve_startup_headroom_target(
503
+ cfg=effective_cfg,
504
+ role_model=effective_model,
505
+ model_registry=model_registry,
506
+ )
507
+ )
508
+ initial_request_token_estimate = _estimate_initial_execution_request_tokens(
509
+ root=root,
510
+ prefix_messages=prefix_messages,
511
+ tool_list=tool_list,
512
+ instruction=initial_pack_result.content,
513
+ image_paths=image_paths,
514
+ )
515
+ if initial_request_token_estimate <= startup_target_tokens:
516
+ return initial_pack_result, StartupHeadroomTelemetry(
517
+ initial_request_token_estimate=initial_request_token_estimate,
518
+ initial_request_token_estimate_before_adjustment=None,
519
+ compaction_budget_tokens=compaction_budget_tokens,
520
+ compaction_trigger_tokens=compaction_trigger_tokens,
521
+ startup_target_tokens=startup_target_tokens,
522
+ startup_headroom_tokens=startup_target_tokens - initial_request_token_estimate,
523
+ startup_headroom_adjustment_applied=False,
524
+ startup_headroom_adjustment_reason=None,
525
+ )
526
+
527
+ empty_instruction_request_tokens = _estimate_initial_execution_request_tokens(
528
+ root=root,
529
+ prefix_messages=prefix_messages,
530
+ tool_list=tool_list,
531
+ instruction="",
532
+ image_paths=image_paths,
533
+ )
534
+ if empty_instruction_request_tokens >= startup_target_tokens:
535
+ return initial_pack_result, StartupHeadroomTelemetry(
536
+ initial_request_token_estimate=initial_request_token_estimate,
537
+ initial_request_token_estimate_before_adjustment=None,
538
+ compaction_budget_tokens=compaction_budget_tokens,
539
+ compaction_trigger_tokens=compaction_trigger_tokens,
540
+ startup_target_tokens=startup_target_tokens,
541
+ startup_headroom_tokens=startup_target_tokens - initial_request_token_estimate,
542
+ startup_headroom_adjustment_applied=False,
543
+ startup_headroom_adjustment_reason=(
544
+ "deterministic startup overhead already exceeds the startup target, so task-pack "
545
+ "reduction would not recover enough headroom"
546
+ ),
547
+ )
548
+
549
+ adjusted_pack_result = initial_pack_result
550
+ adjusted_request_token_estimate = initial_request_token_estimate
551
+ for _attempt in range(3):
552
+ if adjusted_request_token_estimate <= startup_target_tokens:
553
+ break
554
+
555
+ current_instruction_request_tokens = max(
556
+ 0,
557
+ adjusted_request_token_estimate - empty_instruction_request_tokens,
558
+ )
559
+ current_instruction_budget = adjusted_pack_result.instruction_token_estimate
560
+ if current_instruction_budget <= 0:
561
+ break
562
+
563
+ overflow_tokens = adjusted_request_token_estimate - startup_target_tokens
564
+ target_instruction_request_tokens = max(
565
+ 0,
566
+ current_instruction_request_tokens - overflow_tokens,
567
+ )
568
+ if current_instruction_request_tokens > 0:
569
+ adjusted_instruction_budget = int(
570
+ current_instruction_budget
571
+ * target_instruction_request_tokens
572
+ / current_instruction_request_tokens
573
+ )
574
+ else:
575
+ adjusted_instruction_budget = 0
576
+ adjusted_instruction_budget = min(
577
+ budget.final_instruction_budget,
578
+ current_instruction_budget,
579
+ max(0, adjusted_instruction_budget),
580
+ )
581
+ if adjusted_instruction_budget >= current_instruction_budget:
582
+ adjusted_instruction_budget = max(
583
+ 0,
584
+ current_instruction_budget - max(64, overflow_tokens),
585
+ )
586
+ if adjusted_instruction_budget == current_instruction_budget:
587
+ break
588
+
589
+ # Rebuild with a tighter managed-exec-only budget so the context pack
590
+ # shrinks in proportion to the instruction's measured request footprint.
591
+ adjusted_pack_result = build_task_context_pack_result(
592
+ cfg=effective_cfg,
593
+ plan=plan,
594
+ task=task,
595
+ role_model=effective_model,
596
+ model_registry=model_registry,
597
+ instruction_token_budget=adjusted_instruction_budget,
598
+ leading_sections=leading_sections,
599
+ relevant_assets_section=relevant_assets_section,
600
+ prefer_startup_headroom_reduction=True,
601
+ )
602
+ adjusted_request_token_estimate = _estimate_initial_execution_request_tokens(
603
+ root=root,
604
+ prefix_messages=prefix_messages,
605
+ tool_list=tool_list,
606
+ instruction=adjusted_pack_result.content,
607
+ image_paths=image_paths,
608
+ )
609
+
610
+ if adjusted_request_token_estimate > startup_target_tokens:
611
+ fallback_instruction_budget = min(
612
+ adjusted_pack_result.instruction_token_estimate,
613
+ 128,
614
+ )
615
+ if fallback_instruction_budget < adjusted_pack_result.instruction_token_estimate:
616
+ adjusted_pack_result = build_task_context_pack_result(
617
+ cfg=effective_cfg,
618
+ plan=plan,
619
+ task=task,
620
+ role_model=effective_model,
621
+ model_registry=model_registry,
622
+ instruction_token_budget=fallback_instruction_budget,
623
+ leading_sections=leading_sections,
624
+ relevant_assets_section=relevant_assets_section,
625
+ prefer_startup_headroom_reduction=True,
626
+ )
627
+ adjusted_request_token_estimate = _estimate_initial_execution_request_tokens(
628
+ root=root,
629
+ prefix_messages=prefix_messages,
630
+ tool_list=tool_list,
631
+ instruction=adjusted_pack_result.content,
632
+ image_paths=image_paths,
633
+ )
634
+
635
+ adjustment_reason: str | None
636
+ if adjusted_request_token_estimate <= startup_target_tokens:
637
+ adjustment_reason = (
638
+ "reduced the managed-execution context pack so the first request starts below the "
639
+ "execution compaction trigger"
640
+ )
641
+ else:
642
+ adjustment_reason = (
643
+ "reduced the managed-execution context pack to the minimal startup profile, but "
644
+ "deterministic startup overhead still exceeds the startup target"
645
+ )
646
+ return adjusted_pack_result, StartupHeadroomTelemetry(
647
+ initial_request_token_estimate=adjusted_request_token_estimate,
648
+ initial_request_token_estimate_before_adjustment=initial_request_token_estimate,
649
+ compaction_budget_tokens=compaction_budget_tokens,
650
+ compaction_trigger_tokens=compaction_trigger_tokens,
651
+ startup_target_tokens=startup_target_tokens,
652
+ startup_headroom_tokens=startup_target_tokens - adjusted_request_token_estimate,
653
+ startup_headroom_adjustment_applied=True,
654
+ startup_headroom_adjustment_reason=adjustment_reason,
655
+ )
656
+
657
+
658
+ def build_task_execution_instruction_bundle(
659
+ *,
660
+ plan: dict[str, Any],
661
+ task: dict[str, Any],
662
+ root: Path,
663
+ cfg: AppConfig | None = None,
664
+ role_model: str | None = None,
665
+ mode: str,
666
+ yes: bool,
667
+ deny_write_prefixes: list[str] | None = None,
668
+ allow_write_globs: list[str] | None = None,
669
+ non_interactive: bool = False,
670
+ verification_enabled: bool = True,
671
+ authoritative_verification_commands: list[str] | None = None,
672
+ trusted_system_prompt_override: str | None = None,
673
+ trusted_system_prompt_append: str | None = None,
674
+ untrusted_prompt_prelude: str | None = None,
675
+ model_registry: ModelRegistry | None = None,
676
+ api_key: str | None = None,
677
+ safety_margin_tokens: int = DEFAULT_EXECUTION_SAFETY_MARGIN_TOKENS,
678
+ execution_response_reserve_tokens: int = DEFAULT_EXECUTION_RESPONSE_RESERVE_TOKENS,
679
+ execution_headroom_reserve_tokens: int = DEFAULT_EXECUTION_HEADROOM_RESERVE_TOKENS,
680
+ minimum_instruction_budget_tokens: int = DEFAULT_MINIMUM_EXECUTION_INSTRUCTION_BUDGET_TOKENS,
681
+ subagents_enabled: bool = False,
682
+ leading_sections: list[str] | None = None,
683
+ relevant_assets_section: str | None = None,
684
+ managed_execution_startup_headroom: bool = False,
685
+ ) -> PreparedTaskExecutionInstruction:
686
+ effective_model = (role_model or "").strip()
687
+ effective_cfg = cfg or AppConfig(model=effective_model)
688
+ if not effective_model:
689
+ effective_model = (effective_cfg.model or "").strip() or "unknown-model"
690
+ task_image_paths = select_task_image_paths_for_execution(
691
+ cfg=effective_cfg,
692
+ plan=plan,
693
+ task=task,
694
+ root=root,
695
+ role_model=effective_model,
696
+ model_registry=model_registry,
697
+ )
698
+ budget_inputs = compute_execution_prompt_budget_inputs(
699
+ cfg=effective_cfg,
700
+ root=root,
701
+ mode=mode,
702
+ yes=yes,
703
+ deny_write_prefixes=deny_write_prefixes,
704
+ allow_write_globs=allow_write_globs,
705
+ non_interactive=non_interactive,
706
+ one_shot_execution=False,
707
+ verification_enabled=verification_enabled,
708
+ authoritative_verification_commands=authoritative_verification_commands,
709
+ trusted_system_prompt_override=trusted_system_prompt_override,
710
+ trusted_system_prompt_append=trusted_system_prompt_append,
711
+ untrusted_prompt_prelude=untrusted_prompt_prelude,
712
+ subagents_enabled=subagents_enabled,
713
+ subagent_depth=0,
714
+ subagent_registry=None,
715
+ workspace_binding=None,
716
+ api_key=api_key,
717
+ model_registry=model_registry,
718
+ safety_margin_tokens=safety_margin_tokens,
719
+ execution_response_reserve_tokens=execution_response_reserve_tokens,
720
+ execution_headroom_reserve_tokens=execution_headroom_reserve_tokens,
721
+ minimum_instruction_budget_tokens=minimum_instruction_budget_tokens,
722
+ image_count=len(task_image_paths or []),
723
+ )
724
+ budget = budget_inputs.budget
725
+ effective_leading_sections = list(leading_sections or [])
726
+ authoritative_verification_section = (
727
+ _authoritative_verification_context_section(
728
+ authoritative_verification_commands,
729
+ task=task,
730
+ allow_write_globs=allow_write_globs,
731
+ )
732
+ if verification_enabled
733
+ else ""
734
+ )
735
+ if authoritative_verification_section:
736
+ effective_leading_sections.insert(0, authoritative_verification_section)
737
+ pack_result = build_task_context_pack_result(
738
+ cfg=effective_cfg,
739
+ plan=plan,
740
+ task=task,
741
+ role_model=effective_model,
742
+ model_registry=model_registry,
743
+ instruction_token_budget=budget.final_instruction_budget,
744
+ leading_sections=effective_leading_sections,
745
+ relevant_assets_section=relevant_assets_section,
746
+ )
747
+ startup_headroom: StartupHeadroomTelemetry | None = None
748
+ if managed_execution_startup_headroom:
749
+ pack_result, startup_headroom = _apply_startup_headroom_preflight(
750
+ root=root,
751
+ effective_cfg=effective_cfg,
752
+ effective_model=effective_model,
753
+ plan=plan,
754
+ task=task,
755
+ model_registry=model_registry,
756
+ budget=budget,
757
+ prefix_messages=budget_inputs.prefix_messages,
758
+ tool_list=budget_inputs.tool_list,
759
+ image_paths=tuple(task_image_paths or ()),
760
+ leading_sections=leading_sections,
761
+ relevant_assets_section=relevant_assets_section,
762
+ initial_pack_result=pack_result,
763
+ )
764
+ return PreparedTaskExecutionInstruction(
765
+ instruction=pack_result.content,
766
+ artifact_text=pack_result.artifact_text,
767
+ budget=budget,
768
+ final_instruction_token_estimate=pack_result.instruction_token_estimate,
769
+ truncated=pack_result.truncated,
770
+ truncation_strategy=pack_result.truncation_strategy,
771
+ subagents_enabled=budget.subagents_enabled,
772
+ image_paths=tuple(task_image_paths or ()),
773
+ startup_headroom=startup_headroom,
774
+ )
775
+
776
+
777
+ def _parse_bool_env(value: str | None) -> bool:
778
+ if value is None:
779
+ return False
780
+ raw = value.strip().lower()
781
+ return raw in {"1", "true", "yes", "on"}
782
+
783
+
784
+ def select_task_image_paths_for_execution(
785
+ *,
786
+ cfg: AppConfig,
787
+ plan: dict[str, Any],
788
+ task: dict[str, Any],
789
+ root: Path,
790
+ role_model: str,
791
+ model_registry: ModelRegistry | None = None,
792
+ ) -> list[str] | None:
793
+ if not _parse_bool_env(env_get(_TASK_IMAGES_ENV)):
794
+ return None
795
+
796
+ registry = model_registry or ModelRegistry(cfg=cfg)
797
+ meta = registry.get(role_model)
798
+ if not meta.supports_vision:
799
+ return None
800
+
801
+ paths = select_relevant_image_paths(
802
+ plan=plan,
803
+ task=task,
804
+ root=root,
805
+ max_images=4,
806
+ )
807
+ return paths or None
808
+
809
+
810
+ def write_execution_context_artifact(
811
+ *,
812
+ run_paths: RunPaths,
813
+ task_id: str,
814
+ context_text: str,
815
+ ) -> Path:
816
+ ensure_execution_dirs(run_paths)
817
+ safe_task = safe_task_file_component(task_id)
818
+ context_path = run_paths.execution_context_dir / f"{safe_task}_context.md"
819
+ context_path.write_text(context_text, encoding="utf-8")
820
+ return context_path
821
+
822
+
823
+ def write_execution_budget_artifact(
824
+ *,
825
+ run_paths: RunPaths,
826
+ task_id: str,
827
+ payload: dict[str, Any],
828
+ ) -> Path:
829
+ ensure_execution_dirs(run_paths)
830
+ safe_task = safe_task_file_component(task_id)
831
+ budget_path = run_paths.execution_budgets_dir / f"{safe_task}.json"
832
+ budget_path.write_text(
833
+ json.dumps(payload, indent=2, sort_keys=True) + "\n",
834
+ encoding="utf-8",
835
+ )
836
+ return budget_path
837
+
838
+
839
+ def resolve_managed_task_step_budget(
840
+ *,
841
+ cfg: AppConfig,
842
+ plan: dict[str, Any],
843
+ task: dict[str, Any],
844
+ kind: str = "managed_task",
845
+ mode: str | None = None,
846
+ verification_enabled: bool,
847
+ max_steps_override: int | None = None,
848
+ attempt_count: int | None = None,
849
+ image_count: int = 0,
850
+ conflict_file_count: int = 0,
851
+ ) -> StepBudgetResolution:
852
+ acceptance_criteria = task.get("acceptance_criteria") or []
853
+ estimated_files = task.get("estimated_files") or []
854
+ write_scope = task.get("write_scope") or []
855
+ dependencies = task.get("dependencies") or []
856
+ selected_assets = select_relevant_assets(plan, task)
857
+ request = StepBudgetRequest(
858
+ kind=str(kind or "managed_task").strip() or "managed_task",
859
+ policy=str(
860
+ getattr(cfg, "step_budget_policy", AUTONOMOUS_STEP_BUDGET_POLICY)
861
+ or AUTONOMOUS_STEP_BUDGET_POLICY
862
+ ),
863
+ hard_cap=int(
864
+ max_steps_override
865
+ if max_steps_override is not None
866
+ else getattr(cfg, "task_max_steps", DEFAULT_TASK_MAX_STEPS)
867
+ ),
868
+ fixed_override=max_steps_override,
869
+ mode=mode,
870
+ verification_enabled=bool(verification_enabled),
871
+ attempt_count=int(attempt_count or 1),
872
+ image_count=int(image_count or 0),
873
+ acceptance_criteria_count=len(acceptance_criteria)
874
+ if isinstance(acceptance_criteria, list)
875
+ else 0,
876
+ estimated_files_count=len(estimated_files) if isinstance(estimated_files, list) else 0,
877
+ write_scope_count=len(write_scope) if isinstance(write_scope, list) else 0,
878
+ dependency_count=len(dependencies) if isinstance(dependencies, list) else 0,
879
+ asset_count=len(selected_assets),
880
+ conflict_file_count=int(conflict_file_count or 0),
881
+ )
882
+ return resolve_step_budget(request)
883
+
884
+
885
+ def prepare_task_execution_knowledge(
886
+ *,
887
+ run_paths: RunPaths,
888
+ task: dict[str, Any],
889
+ selection_label: str,
890
+ extra_paths: list[str] | None = None,
891
+ limit: int = 4,
892
+ ) -> PreparedExecutionKnowledge:
893
+ materialized = prepare_relevant_knowledge(
894
+ paths=run_paths,
895
+ task=task,
896
+ selection_label=selection_label,
897
+ extra_paths=extra_paths,
898
+ limit=limit,
899
+ )
900
+ return PreparedExecutionKnowledge(
901
+ materialized=materialized,
902
+ prompt_section=materialized.render_prompt_section(workspace_root=run_paths.root),
903
+ )
904
+
905
+
906
+ def mirror_plan_into_worktree(*, run_paths: RunPaths, worktree_repo_path: Path) -> None:
907
+ src_plan_dir = run_paths.plan_dir.resolve()
908
+ if not src_plan_dir.exists():
909
+ return
910
+ dest_plan_dir = worktree_repo_path.resolve() / ".alysis" / "runs" / run_paths.run_id / "plan"
911
+ dest_plan_dir.parent.mkdir(parents=True, exist_ok=True)
912
+ _sync_tree_lightweight(src=src_plan_dir, dest=dest_plan_dir)
913
+
914
+
915
+ def mirror_selected_knowledge_into_worktree(
916
+ *,
917
+ materialized: PreparedExecutionKnowledge,
918
+ run_paths: RunPaths,
919
+ worktree_repo_path: Path,
920
+ ) -> None:
921
+ src_selected_dir = materialized.selected_dir.resolve()
922
+ if not src_selected_dir.exists():
923
+ return
924
+ rel = src_selected_dir.relative_to(run_paths.root.resolve())
925
+ dest_selected_dir = worktree_repo_path.resolve() / rel
926
+ dest_selected_dir.parent.mkdir(parents=True, exist_ok=True)
927
+ _sync_tree_lightweight(src=src_selected_dir, dest=dest_selected_dir)
928
+
929
+
930
+ def _same_file_by_size_and_mtime(*, src: Path, dest: Path) -> bool:
931
+ if not dest.exists() or not dest.is_file():
932
+ return False
933
+ src_stat = src.stat()
934
+ dest_stat = dest.stat()
935
+ if src_stat.st_size != dest_stat.st_size:
936
+ return False
937
+ if src_stat.st_mtime_ns == dest_stat.st_mtime_ns:
938
+ return True
939
+ if src_stat.st_size > _SNAPSHOT_HASH_MAX_BYTES:
940
+ return False
941
+ return sha256(src.read_bytes()).digest() == sha256(dest.read_bytes()).digest()
942
+
943
+
944
+ def _windows_long_path(path: Path) -> str:
945
+ raw = os.path.abspath(os.fspath(path))
946
+ if os.name != "nt" or raw.startswith("\\\\?\\"):
947
+ return raw
948
+ if raw.startswith("\\\\"):
949
+ return "\\\\?\\UNC\\" + raw.lstrip("\\")
950
+ return "\\\\?\\" + raw
951
+
952
+
953
+ def _mkdir_existing(path: Path) -> None:
954
+ os.makedirs(_windows_long_path(path), exist_ok=True)
955
+
956
+
957
+ def _sync_tree_lightweight(*, src: Path, dest: Path) -> None:
958
+ _mkdir_existing(dest)
959
+ src_files: dict[Path, Path] = {}
960
+ src_dirs: set[Path] = {Path(".")}
961
+
962
+ for src_path in sorted(src.rglob("*")):
963
+ rel = src_path.relative_to(src)
964
+ if src_path.is_dir():
965
+ src_dirs.add(rel)
966
+ _mkdir_existing(dest / rel)
967
+ continue
968
+ src_files[rel] = src_path
969
+ dest_path = dest / rel
970
+ _mkdir_existing(dest_path.parent)
971
+ if _same_file_by_size_and_mtime(src=src_path, dest=dest_path):
972
+ continue
973
+ shutil.copy2(_windows_long_path(src_path), _windows_long_path(dest_path))
974
+
975
+ for dest_path in sorted(dest.rglob("*"), reverse=True):
976
+ rel = dest_path.relative_to(dest)
977
+ if dest_path.is_file():
978
+ if rel not in src_files:
979
+ dest_path.unlink()
980
+ continue
981
+ if rel not in src_dirs:
982
+ try:
983
+ dest_path.rmdir()
984
+ except OSError:
985
+ shutil.rmtree(dest_path, ignore_errors=True)
986
+
987
+
988
+ def _normalize_workspace_relpath(rel_path: str) -> str:
989
+ cleaned = rel_path.strip().replace("\\", "/")
990
+ while cleaned.startswith("./"):
991
+ cleaned = cleaned[2:]
992
+ return cleaned.strip("/")
993
+
994
+
995
+ def _path_under_root(*, root: Path, rel_path: str) -> Path:
996
+ candidate = (root / rel_path).resolve()
997
+ try:
998
+ candidate.relative_to(root.resolve())
999
+ except ValueError as e:
1000
+ raise ValueError(f"path escapes workspace root: {rel_path}") from e
1001
+ return candidate
1002
+
1003
+
1004
+ def _ignored_snapshot_path(*, rel_parts: tuple[str, ...], excluded_names: frozenset[str]) -> bool:
1005
+ if any(part in excluded_names for part in rel_parts):
1006
+ return True
1007
+ return False
1008
+
1009
+
1010
+ def copy_workspace_snapshot(
1011
+ *,
1012
+ src_root: Path,
1013
+ dest_root: Path,
1014
+ excluded_names: frozenset[str] | None = None,
1015
+ ) -> None:
1016
+ src_root = src_root.resolve()
1017
+ dest_root = dest_root.resolve()
1018
+ excluded = excluded_names or _DEFAULT_SNAPSHOT_EXCLUDES
1019
+
1020
+ if dest_root.exists():
1021
+ shutil.rmtree(dest_root, ignore_errors=True)
1022
+ dest_root.mkdir(parents=True, exist_ok=True)
1023
+
1024
+ for src_path in sorted(src_root.rglob("*")):
1025
+ rel = src_path.relative_to(src_root)
1026
+ if _ignored_snapshot_path(
1027
+ rel_parts=rel.parts, excluded_names=excluded
1028
+ ) or is_runtime_artifact_path(
1029
+ rel.as_posix(),
1030
+ root=src_root,
1031
+ ):
1032
+ continue
1033
+ dest_path = dest_root / rel
1034
+ if src_path.is_dir():
1035
+ dest_path.mkdir(parents=True, exist_ok=True)
1036
+ continue
1037
+ dest_path.parent.mkdir(parents=True, exist_ok=True)
1038
+ shutil.copy2(src_path, dest_path)
1039
+
1040
+
1041
+ def sync_snapshot_changed_files(
1042
+ *,
1043
+ snapshot_root: Path,
1044
+ workspace_root: Path,
1045
+ changed_files: list[str],
1046
+ protected_prefixes: tuple[str, ...] = tuple(sorted(ROOT_RUNTIME_ARTIFACT_DIR_NAMES)),
1047
+ ) -> list[str]:
1048
+ snapshot_root = snapshot_root.resolve()
1049
+ workspace_root = workspace_root.resolve()
1050
+ applied: list[str] = []
1051
+ normalized_prefixes = tuple(
1052
+ _normalize_workspace_relpath(prefix) for prefix in protected_prefixes if prefix.strip()
1053
+ )
1054
+
1055
+ for raw_path in changed_files:
1056
+ rel_path = _normalize_workspace_relpath(raw_path)
1057
+ if not rel_path:
1058
+ continue
1059
+ if is_runtime_artifact_path(rel_path, root=snapshot_root):
1060
+ continue
1061
+ if any(
1062
+ rel_path == prefix or rel_path.startswith(prefix + "/")
1063
+ for prefix in normalized_prefixes
1064
+ if prefix
1065
+ ):
1066
+ continue
1067
+
1068
+ src_path = _path_under_root(root=snapshot_root, rel_path=rel_path)
1069
+ dest_path = _path_under_root(root=workspace_root, rel_path=rel_path)
1070
+ if src_path.exists():
1071
+ if src_path.is_dir():
1072
+ dest_path.mkdir(parents=True, exist_ok=True)
1073
+ else:
1074
+ dest_path.parent.mkdir(parents=True, exist_ok=True)
1075
+ shutil.copy2(src_path, dest_path)
1076
+ elif dest_path.exists():
1077
+ if dest_path.is_dir():
1078
+ shutil.rmtree(dest_path)
1079
+ else:
1080
+ dest_path.unlink()
1081
+ parent = dest_path.parent
1082
+ while parent != workspace_root and parent.exists():
1083
+ try:
1084
+ parent.rmdir()
1085
+ except OSError:
1086
+ break
1087
+ parent = parent.parent
1088
+ applied.append(rel_path)
1089
+
1090
+ return applied
1091
+
1092
+
1093
+ def snapshot_runtime_tree(root: Path) -> dict[str, str]:
1094
+ runtime_dir = (root / ".alysis").resolve()
1095
+ if not runtime_dir.exists():
1096
+ return {}
1097
+ snapshot: dict[str, str] = {}
1098
+ root_resolved = root.resolve()
1099
+ for p in sorted(runtime_dir.rglob("*")):
1100
+ if not p.is_file():
1101
+ continue
1102
+ rel = os.fspath(p.resolve().relative_to(root_resolved))
1103
+ rel_norm = rel.replace("\\", "/")
1104
+ stat = p.stat()
1105
+ is_plan_asset = "/plan/assets/" in f"/{rel_norm}/"
1106
+ if is_plan_asset or stat.st_size > _SNAPSHOT_HASH_MAX_BYTES:
1107
+ snapshot[rel] = f"meta:{stat.st_size}:{stat.st_mtime_ns}"
1108
+ continue
1109
+ digest = sha256(p.read_bytes()).hexdigest()
1110
+ snapshot[rel] = f"sha256:{digest}"
1111
+ return snapshot
1112
+
1113
+
1114
+ def _run_git_capture(
1115
+ *,
1116
+ root: Path | None,
1117
+ args: list[str],
1118
+ text: bool = False,
1119
+ ) -> subprocess.CompletedProcess[Any] | None:
1120
+ cmd = ["git", *args] if root is None else ["git", "-C", os.fspath(root), *args]
1121
+ try:
1122
+ return subprocess.run(
1123
+ cmd,
1124
+ capture_output=True,
1125
+ text=text,
1126
+ check=False,
1127
+ env=build_git_process_env(),
1128
+ timeout=_READ_ONLY_GIT_TIMEOUT_S,
1129
+ )
1130
+ except (OSError, subprocess.TimeoutExpired):
1131
+ return None
1132
+
1133
+
1134
+ def git_diff_text(root: Path) -> str | None:
1135
+ if shutil.which("git") is None:
1136
+ return None
1137
+ proc = _run_git_capture(root=root, args=["diff"], text=True)
1138
+ if proc is None:
1139
+ return None
1140
+ if proc.returncode != 0:
1141
+ return None
1142
+ return proc.stdout
1143
+
1144
+
1145
+ def _git_status_entries(root: Path) -> list[_GitStatusEntry]:
1146
+ if shutil.which("git") is None:
1147
+ return []
1148
+ proc = _run_git_capture(root=root, args=["status", "--porcelain=v1", "-z"])
1149
+ if proc is None:
1150
+ return []
1151
+ if proc.returncode != 0:
1152
+ return []
1153
+
1154
+ entries: list[_GitStatusEntry] = []
1155
+ stdout = proc.stdout
1156
+ raw_stdout = (
1157
+ stdout.encode("utf-8", errors="surrogateescape")
1158
+ if isinstance(stdout, str)
1159
+ else (stdout or b"")
1160
+ )
1161
+ parts = raw_stdout.split(b"\0")
1162
+ index = 0
1163
+ while index < len(parts):
1164
+ raw_entry = parts[index]
1165
+ index += 1
1166
+ if not raw_entry or len(raw_entry) < 3:
1167
+ continue
1168
+ status = raw_entry[:2].decode("utf-8", errors="replace")
1169
+ path = raw_entry[3:].decode("utf-8", errors="surrogateescape")
1170
+ orig_path: str | None = None
1171
+ if any(marker in status for marker in {"R", "C"}) and index < len(parts):
1172
+ second_raw = parts[index]
1173
+ index += 1
1174
+ if second_raw:
1175
+ orig_path = second_raw.decode("utf-8", errors="surrogateescape")
1176
+ entries.append(_GitStatusEntry(status=status, path=path, orig_path=orig_path))
1177
+ return entries
1178
+
1179
+
1180
+ def _git_has_head_commit(root: Path) -> bool:
1181
+ if shutil.which("git") is None:
1182
+ return False
1183
+ proc = _run_git_capture(root=root, args=["rev-parse", "--verify", "HEAD"], text=True)
1184
+ if proc is None:
1185
+ return False
1186
+ return proc.returncode == 0
1187
+
1188
+
1189
+ def _git_untracked_files(root: Path) -> list[str]:
1190
+ return _git_ls_files(
1191
+ root,
1192
+ ["ls-files", "--others", "--exclude-standard", "-z"],
1193
+ include_non_material=False,
1194
+ )
1195
+
1196
+
1197
+ def _git_cached_files(root: Path) -> list[str]:
1198
+ return _git_ls_files(
1199
+ root,
1200
+ ["ls-files", "--cached", "-z"],
1201
+ include_non_material=True,
1202
+ )
1203
+
1204
+
1205
+ def _git_ls_files(
1206
+ root: Path,
1207
+ args: list[str],
1208
+ *,
1209
+ include_non_material: bool,
1210
+ ) -> list[str]:
1211
+ if shutil.which("git") is None:
1212
+ return []
1213
+ proc = _run_git_capture(root=root, args=args)
1214
+ if proc is None:
1215
+ return []
1216
+ if proc.returncode != 0:
1217
+ return []
1218
+ stdout = proc.stdout
1219
+ raw_stdout = (
1220
+ stdout.encode("utf-8", errors="surrogateescape")
1221
+ if isinstance(stdout, str)
1222
+ else (stdout or b"")
1223
+ )
1224
+ paths: list[str] = []
1225
+ for item in raw_stdout.split(b"\0"):
1226
+ if not item:
1227
+ continue
1228
+ decoded = item.decode("utf-8", errors="surrogateescape")
1229
+ if not include_non_material and is_non_material_untracked_path(decoded):
1230
+ continue
1231
+ paths.append(decoded)
1232
+ return paths
1233
+
1234
+
1235
+ def _git_text_output(
1236
+ *,
1237
+ root: Path,
1238
+ args: list[str],
1239
+ allowed_returncodes: tuple[int, ...] = (0,),
1240
+ ) -> str | None:
1241
+ if shutil.which("git") is None:
1242
+ return None
1243
+ proc = _run_git_capture(root=root, args=args, text=True)
1244
+ if proc is None:
1245
+ return None
1246
+ if proc.returncode not in allowed_returncodes:
1247
+ return None
1248
+ return proc.stdout
1249
+
1250
+
1251
+ def _tracked_patch_text(root: Path) -> str | None:
1252
+ if _git_has_head_commit(root):
1253
+ combined = _git_text_output(
1254
+ root=root,
1255
+ args=["diff", "HEAD", "--binary", "-M"],
1256
+ )
1257
+ if combined is not None:
1258
+ return combined
1259
+
1260
+ patch_parts: list[str] = []
1261
+ staged = _git_text_output(
1262
+ root=root,
1263
+ args=["diff", "--cached", "--binary", "-M", "--root"],
1264
+ )
1265
+ unstaged = _git_text_output(
1266
+ root=root,
1267
+ args=["diff", "--binary", "-M"],
1268
+ )
1269
+ if staged is None and unstaged is None:
1270
+ return None
1271
+ if staged:
1272
+ patch_parts.append(staged if staged.endswith("\n") else staged + "\n")
1273
+ if unstaged:
1274
+ patch_parts.append(unstaged if unstaged.endswith("\n") else unstaged + "\n")
1275
+ return "".join(patch_parts)
1276
+
1277
+
1278
+ def _normalize_reporting_changed_path(*, root: Path, path: str) -> str | None:
1279
+ rel_path = _normalize_workspace_relpath(path.strip('"'))
1280
+ if not rel_path or is_runtime_artifact_path(rel_path, root=root):
1281
+ return None
1282
+ return rel_path
1283
+
1284
+
1285
+ def _execution_reporting_changed_files(
1286
+ root: Path,
1287
+ *,
1288
+ status_entries: list[_GitStatusEntry] | None = None,
1289
+ untracked_files: list[str] | None = None,
1290
+ ) -> tuple[str, ...]:
1291
+ seen: set[str] = set()
1292
+ changed: list[str] = []
1293
+ entries = status_entries if status_entries is not None else _git_status_entries(root)
1294
+
1295
+ def _append(path: str) -> None:
1296
+ normalized = _normalize_reporting_changed_path(root=root, path=path)
1297
+ if not normalized or normalized in seen:
1298
+ return
1299
+ seen.add(normalized)
1300
+ changed.append(normalized)
1301
+
1302
+ for entry in entries:
1303
+ if entry.status in {"!!", "??"}:
1304
+ continue
1305
+ if entry.orig_path is not None:
1306
+ _append(entry.orig_path)
1307
+ _append(entry.path)
1308
+
1309
+ for path in untracked_files if untracked_files is not None else _git_untracked_files(root):
1310
+ _append(path)
1311
+
1312
+ return tuple(sorted(changed))
1313
+
1314
+
1315
+ def _untracked_file_patch_text(*, root: Path, rel_path: str) -> str | None:
1316
+ if shutil.which("git") is None:
1317
+ return None
1318
+ abs_path = _path_under_root(root=root.resolve(), rel_path=rel_path)
1319
+ if not abs_path.exists() or not abs_path.is_file():
1320
+ return None
1321
+ proc = _run_git_capture(
1322
+ root=root,
1323
+ args=["diff", "--no-index", "--", os.devnull, rel_path],
1324
+ text=True,
1325
+ )
1326
+ if proc is None:
1327
+ return None
1328
+ # git diff --no-index returns 1 when differences are found.
1329
+ if proc.returncode not in {0, 1}:
1330
+ return None
1331
+ return proc.stdout
1332
+
1333
+
1334
+ def build_execution_reporting_diff(root: Path) -> ExecutionReportingDiff:
1335
+ # Sequential forge execution used plain `git diff` / `git diff --name-only`, which
1336
+ # made reports truthful for one slice of repo state at a time and silently dropped new
1337
+ # untracked files or staged-only changes. Build one deterministic status-based view for the
1338
+ # changed-file list, then keep tracked patch text close to normal git diff behavior.
1339
+ status_entries = _git_status_entries(root)
1340
+ tracked_patch_text = _tracked_patch_text(root)
1341
+ raw_untracked_files = _git_untracked_files(root)
1342
+ changed_files = _execution_reporting_changed_files(
1343
+ root,
1344
+ status_entries=status_entries,
1345
+ untracked_files=raw_untracked_files,
1346
+ )
1347
+ normalized_untracked = {
1348
+ normalized
1349
+ for path in raw_untracked_files
1350
+ if (normalized := _normalize_reporting_changed_path(root=root, path=path)) is not None
1351
+ }
1352
+ untracked_files = tuple(path for path in changed_files if path in normalized_untracked)
1353
+
1354
+ patch_parts: list[str] = []
1355
+ if tracked_patch_text is None:
1356
+ if not changed_files:
1357
+ return ExecutionReportingDiff(
1358
+ changed_files=changed_files,
1359
+ patch_text="(no git diff available)\n",
1360
+ )
1361
+ elif tracked_patch_text:
1362
+ patch_parts.append(
1363
+ tracked_patch_text if tracked_patch_text.endswith("\n") else tracked_patch_text + "\n"
1364
+ )
1365
+
1366
+ for rel_path in untracked_files:
1367
+ untracked_patch = _untracked_file_patch_text(root=root, rel_path=rel_path)
1368
+ if not untracked_patch:
1369
+ continue
1370
+ patch_parts.append(
1371
+ untracked_patch if untracked_patch.endswith("\n") else untracked_patch + "\n"
1372
+ )
1373
+
1374
+ return ExecutionReportingDiff(
1375
+ changed_files=changed_files,
1376
+ patch_text="".join(patch_parts),
1377
+ )
1378
+
1379
+
1380
+ def build_workspace_snapshot_reporting_diff(
1381
+ root: Path,
1382
+ *,
1383
+ before_snapshot: dict[str, str],
1384
+ after_snapshot: dict[str, str],
1385
+ ) -> ExecutionReportingDiff:
1386
+ changed_files = _workspace_snapshot_changed_files(
1387
+ before=before_snapshot,
1388
+ after=after_snapshot,
1389
+ )
1390
+ if not changed_files:
1391
+ return ExecutionReportingDiff(changed_files=(), patch_text="")
1392
+
1393
+ summary_lines = ["# Workspace snapshot diff", ""]
1394
+ patch_parts: list[str] = []
1395
+ for rel_path in changed_files:
1396
+ if rel_path not in before_snapshot:
1397
+ summary_lines.append(f"added: {rel_path}")
1398
+ untracked_patch = _untracked_file_patch_text(root=root, rel_path=rel_path)
1399
+ if untracked_patch:
1400
+ patch_parts.append(
1401
+ untracked_patch if untracked_patch.endswith("\n") else untracked_patch + "\n"
1402
+ )
1403
+ elif rel_path not in after_snapshot:
1404
+ summary_lines.append(f"deleted: {rel_path}")
1405
+ else:
1406
+ summary_lines.append(f"modified: {rel_path}")
1407
+
1408
+ summary_text = "\n".join(summary_lines) + "\n"
1409
+ return ExecutionReportingDiff(
1410
+ changed_files=changed_files,
1411
+ patch_text=summary_text + ("\n" + "".join(patch_parts) if patch_parts else ""),
1412
+ )
1413
+
1414
+
1415
+ def _git_no_index_display_path(path: Path) -> str:
1416
+ return path.resolve().as_posix().lstrip("/")
1417
+
1418
+
1419
+ def _rewrite_no_index_patch_paths(
1420
+ patch_text: str,
1421
+ *,
1422
+ before_root: Path,
1423
+ after_root: Path,
1424
+ ) -> str:
1425
+ normalized = patch_text.replace("\\", "/")
1426
+ while "//" in normalized:
1427
+ normalized = normalized.replace("//", "/")
1428
+ normalized = "".join(
1429
+ line.replace('"', "") if line.startswith(("diff --git ", "--- ", "+++ ")) else line
1430
+ for line in normalized.splitlines(keepends=True)
1431
+ )
1432
+ replacements = (
1433
+ (f"a/{_git_no_index_display_path(before_root)}/", "a/"),
1434
+ (f"b/{_git_no_index_display_path(before_root)}/", "b/"),
1435
+ (f"a/{_git_no_index_display_path(after_root)}/", "a/"),
1436
+ (f"b/{_git_no_index_display_path(after_root)}/", "b/"),
1437
+ )
1438
+ for old, new in replacements:
1439
+ normalized = normalized.replace(old, new)
1440
+ return normalized
1441
+
1442
+
1443
+ def _task_local_snapshot_file_patch_text(
1444
+ *,
1445
+ before_root: Path,
1446
+ after_root: Path,
1447
+ rel_path: str,
1448
+ exists_before: bool,
1449
+ exists_after: bool,
1450
+ ) -> str | None:
1451
+ if shutil.which("git") is None:
1452
+ return None
1453
+
1454
+ before_path = (
1455
+ os.fspath(_path_under_root(root=before_root.resolve(), rel_path=rel_path))
1456
+ if exists_before
1457
+ else os.devnull
1458
+ )
1459
+ after_path = (
1460
+ os.fspath(_path_under_root(root=after_root.resolve(), rel_path=rel_path))
1461
+ if exists_after
1462
+ else os.devnull
1463
+ )
1464
+ proc = _run_git_capture(
1465
+ root=None,
1466
+ args=[
1467
+ "diff",
1468
+ "--no-index",
1469
+ "--binary",
1470
+ "-M",
1471
+ "--",
1472
+ before_path,
1473
+ after_path,
1474
+ ],
1475
+ text=True,
1476
+ )
1477
+ if proc is None:
1478
+ return None
1479
+ if proc.returncode not in {0, 1}:
1480
+ return None
1481
+ if not proc.stdout:
1482
+ return ""
1483
+ return _rewrite_no_index_patch_paths(
1484
+ proc.stdout,
1485
+ before_root=before_root,
1486
+ after_root=after_root,
1487
+ )
1488
+
1489
+
1490
+ def _build_task_local_snapshot_reporting_diff(
1491
+ root: Path,
1492
+ *,
1493
+ baseline: TaskLocalWorkspaceBaseline,
1494
+ after_snapshot: dict[str, str],
1495
+ ) -> ExecutionReportingDiff:
1496
+ changed_files = _workspace_snapshot_changed_files(
1497
+ before=baseline.before_snapshot,
1498
+ after=after_snapshot,
1499
+ )
1500
+ if not changed_files:
1501
+ return ExecutionReportingDiff(changed_files=(), patch_text="")
1502
+
1503
+ summary_lines = ["# Task-local workspace diff", ""]
1504
+ if baseline.preexisting_changed_files:
1505
+ summary_lines.append(
1506
+ "# Workspace was already dirty at task start; this artifact excludes "
1507
+ f"{len(baseline.preexisting_changed_files)} pre-existing change(s)."
1508
+ )
1509
+ summary_lines.append("")
1510
+
1511
+ patch_parts: list[str] = []
1512
+ for rel_path in changed_files:
1513
+ exists_before = rel_path in baseline.before_snapshot
1514
+ exists_after = rel_path in after_snapshot
1515
+ if not exists_before:
1516
+ summary_lines.append(f"added: {rel_path}")
1517
+ elif not exists_after:
1518
+ summary_lines.append(f"deleted: {rel_path}")
1519
+ else:
1520
+ summary_lines.append(f"modified: {rel_path}")
1521
+
1522
+ if baseline.snapshot_root is None:
1523
+ continue
1524
+ patch_text = _task_local_snapshot_file_patch_text(
1525
+ before_root=baseline.snapshot_root,
1526
+ after_root=root,
1527
+ rel_path=rel_path,
1528
+ exists_before=exists_before,
1529
+ exists_after=exists_after,
1530
+ )
1531
+ if not patch_text:
1532
+ continue
1533
+ patch_parts.append(patch_text if patch_text.endswith("\n") else patch_text + "\n")
1534
+
1535
+ summary_text = "\n".join(summary_lines) + "\n"
1536
+ return ExecutionReportingDiff(
1537
+ changed_files=changed_files,
1538
+ patch_text=summary_text + ("\n" + "".join(patch_parts) if patch_parts else ""),
1539
+ )
1540
+
1541
+
1542
+ def capture_task_local_workspace_baseline(
1543
+ root: Path,
1544
+ *,
1545
+ before_commit: str | None,
1546
+ ) -> TaskLocalWorkspaceBaseline:
1547
+ # Always capture a file-state snapshot so changed-file attribution stays task-local even in
1548
+ # normal git repos. Only materialize a full workspace copy when the task starts dirty, because
1549
+ # that is the case where a plain end-of-task git diff would otherwise include unrelated work.
1550
+ before_snapshot = snapshot_workspace_tree(root)
1551
+ if before_commit is None:
1552
+ return TaskLocalWorkspaceBaseline(
1553
+ before_commit=None,
1554
+ before_snapshot=before_snapshot,
1555
+ )
1556
+
1557
+ status_entries = _git_status_entries(root)
1558
+ raw_untracked_files = _git_untracked_files(root)
1559
+ preexisting_changed_files = _execution_reporting_changed_files(
1560
+ root,
1561
+ status_entries=status_entries,
1562
+ untracked_files=raw_untracked_files,
1563
+ )
1564
+ if not preexisting_changed_files:
1565
+ return TaskLocalWorkspaceBaseline(
1566
+ before_commit=before_commit,
1567
+ before_snapshot=before_snapshot,
1568
+ )
1569
+
1570
+ snapshot_root = Path(tempfile.mkdtemp(prefix="alysis-task-baseline-")).resolve()
1571
+ try:
1572
+ copy_workspace_snapshot(src_root=root, dest_root=snapshot_root)
1573
+ except Exception:
1574
+ shutil.rmtree(snapshot_root, ignore_errors=True)
1575
+ raise
1576
+
1577
+ return TaskLocalWorkspaceBaseline(
1578
+ before_commit=before_commit,
1579
+ before_snapshot=before_snapshot,
1580
+ preexisting_changed_files=preexisting_changed_files,
1581
+ snapshot_root=snapshot_root,
1582
+ )
1583
+
1584
+
1585
+ def cleanup_task_local_workspace_baseline(baseline: TaskLocalWorkspaceBaseline) -> None:
1586
+ if baseline.snapshot_root is None:
1587
+ return
1588
+ shutil.rmtree(baseline.snapshot_root, ignore_errors=True)
1589
+
1590
+
1591
+ def build_task_local_workspace_reporting_diff(
1592
+ root: Path,
1593
+ *,
1594
+ baseline: TaskLocalWorkspaceBaseline,
1595
+ after_commit: str | None,
1596
+ ) -> ExecutionReportingDiff:
1597
+ after_snapshot = snapshot_workspace_tree(root)
1598
+ task_local_diff = _build_task_local_snapshot_reporting_diff(
1599
+ root,
1600
+ baseline=baseline,
1601
+ after_snapshot=after_snapshot,
1602
+ )
1603
+ if baseline.before_commit is None or baseline.had_preexisting_workspace_dirt:
1604
+ return task_local_diff
1605
+
1606
+ try:
1607
+ live_git_diff = build_execution_reporting_diff_with_commit_range(
1608
+ root,
1609
+ before_commit=baseline.before_commit,
1610
+ after_commit=after_commit,
1611
+ )
1612
+ except GitOpsError as e:
1613
+ return ExecutionReportingDiff(
1614
+ changed_files=task_local_diff.changed_files,
1615
+ patch_text=task_local_diff.patch_text,
1616
+ inspection_error=f"scope inspection failed: {e}",
1617
+ )
1618
+
1619
+ return ExecutionReportingDiff(
1620
+ changed_files=live_git_diff.changed_files,
1621
+ patch_text=live_git_diff.patch_text,
1622
+ )
1623
+
1624
+
1625
+ def _merge_execution_reporting_changed_files(
1626
+ root: Path,
1627
+ *path_groups: list[str],
1628
+ ) -> tuple[str, ...]:
1629
+ seen: set[str] = set()
1630
+ merged: list[str] = []
1631
+ for group in path_groups:
1632
+ for path in group:
1633
+ normalized = _normalize_reporting_changed_path(root=root, path=str(path))
1634
+ if not normalized or normalized in seen:
1635
+ continue
1636
+ seen.add(normalized)
1637
+ merged.append(normalized)
1638
+ return tuple(merged)
1639
+
1640
+
1641
+ def build_execution_reporting_diff_with_commit_range(
1642
+ root: Path,
1643
+ *,
1644
+ before_commit: str | None,
1645
+ after_commit: str | None,
1646
+ ) -> ExecutionReportingDiff:
1647
+ live = build_execution_reporting_diff(root)
1648
+ if not after_commit or after_commit == before_commit:
1649
+ return live
1650
+
1651
+ committed_files = changed_files_since(
1652
+ root,
1653
+ before_commit=before_commit,
1654
+ after_commit=after_commit,
1655
+ )
1656
+ committed_patch_text = diff_text_since(
1657
+ root,
1658
+ before_commit=before_commit,
1659
+ after_commit=after_commit,
1660
+ )
1661
+
1662
+ patch_parts: list[str] = []
1663
+ if committed_patch_text:
1664
+ patch_parts.append(
1665
+ committed_patch_text
1666
+ if committed_patch_text.endswith("\n")
1667
+ else committed_patch_text + "\n"
1668
+ )
1669
+ if live.patch_text and live.patch_text != "(no git diff available)\n":
1670
+ patch_parts.append(
1671
+ live.patch_text if live.patch_text.endswith("\n") else live.patch_text + "\n"
1672
+ )
1673
+
1674
+ return ExecutionReportingDiff(
1675
+ changed_files=_merge_execution_reporting_changed_files(
1676
+ root,
1677
+ list(live.changed_files),
1678
+ committed_files,
1679
+ ),
1680
+ patch_text="".join(patch_parts) or live.patch_text,
1681
+ )
1682
+
1683
+
1684
+ def git_changed_files(root: Path) -> list[str]:
1685
+ if shutil.which("git") is None:
1686
+ return []
1687
+ proc = _run_git_capture(root=root, args=["diff", "--name-only"], text=True)
1688
+ if proc is None:
1689
+ return []
1690
+ if proc.returncode != 0:
1691
+ return []
1692
+ return [line.strip() for line in proc.stdout.splitlines() if line.strip()]
1693
+
1694
+
1695
+ def write_patch_from_diff(*, root: Path, patch_path: Path) -> None:
1696
+ diff_text = git_diff_text(root)
1697
+ if diff_text is None:
1698
+ patch_path.write_text("(no git diff available)\n", encoding="utf-8")
1699
+ return
1700
+ patch_path.write_text(diff_text, encoding="utf-8")
1701
+
1702
+
1703
+ def snapshot_session_logs(cfg: AppConfig) -> set[Path]:
1704
+ sessions_dir = resolve_sessions_dir(cfg)
1705
+ if not sessions_dir.exists():
1706
+ return set()
1707
+ return set(sessions_dir.glob("*.jsonl"))
1708
+
1709
+
1710
+ def _path_is_relative_to(path: Path, root: Path) -> bool:
1711
+ try:
1712
+ path.resolve().relative_to(root.resolve())
1713
+ return True
1714
+ except ValueError:
1715
+ return False
1716
+
1717
+
1718
+ def _execution_runtime_base_candidates() -> tuple[Path, ...]:
1719
+ return (
1720
+ resolve_sessions_dir(AppConfig()).resolve().parent / "execution_runtime_sessions",
1721
+ Path(tempfile.gettempdir()).resolve() / "alysis" / "execution_runtime_sessions",
1722
+ )
1723
+
1724
+
1725
+ def _safe_execution_runtime_base(*, workspace_root: Path) -> Path:
1726
+ workspace_root = workspace_root.resolve()
1727
+ protected_workspace_root = (workspace_root / ".alysis").resolve()
1728
+ for candidate in _execution_runtime_base_candidates():
1729
+ candidate_resolved = candidate.resolve()
1730
+ if _path_is_relative_to(candidate_resolved, workspace_root):
1731
+ continue
1732
+ if _path_is_relative_to(candidate_resolved, protected_workspace_root):
1733
+ continue
1734
+ return candidate_resolved
1735
+ raise ValueError(
1736
+ f"unable to resolve safe execution runtime base outside workspace: {workspace_root}"
1737
+ )
1738
+
1739
+
1740
+ def execution_private_sessions_dir(
1741
+ *, cfg: AppConfig, run_id: str, task_id: str, workspace_root: Path
1742
+ ) -> Path:
1743
+ del cfg
1744
+ base_dir = _safe_execution_runtime_base(workspace_root=workspace_root)
1745
+ return base_dir / sanitize_session_id(run_id) / safe_task_file_component(task_id)
1746
+
1747
+
1748
+ def cleanup_execution_private_sessions_dir(path: Path) -> None:
1749
+ shutil.rmtree(path, ignore_errors=True)
1750
+
1751
+
1752
+ def task_execution_session_artifact_dir(*, run_paths: RunPaths, task_id: str) -> Path:
1753
+ ensure_execution_dirs(run_paths)
1754
+ safe_task = safe_task_file_component(task_id)
1755
+ return run_paths.execution_sessions_dir / safe_task
1756
+
1757
+
1758
+ @dataclass(frozen=True)
1759
+ class ExecutionLogArtifactsResult:
1760
+ log_copy_path: Path
1761
+ pointer_path: Path
1762
+ logging_enabled: bool
1763
+ log_retained: bool
1764
+ session_artifacts_retained: bool
1765
+ copied_log_path: Path | None
1766
+ source_log_path: Path | None
1767
+ session_id: str | None
1768
+ session_artifact_dir: Path | None
1769
+ note: str | None = None
1770
+ cleanup_note: str | None = None
1771
+
1772
+ def pointer_payload(self, *, workspace_root: Path) -> dict[str, Any]:
1773
+ # Persist repo-relative paths when the artifact lives under the workspace,
1774
+ # and redact external runtime locations so beta-facing logs do not leak
1775
+ # host directory structures.
1776
+ payload: dict[str, Any] = {
1777
+ "task_id": self.pointer_path.stem.removesuffix(".log"),
1778
+ "logging_enabled": self.logging_enabled,
1779
+ "log_retained": self.log_retained,
1780
+ "session_artifacts_retained": self.session_artifacts_retained,
1781
+ "copied_log_path": (
1782
+ safe_serialized_path(self.copied_log_path, workspace_root=workspace_root)
1783
+ if self.copied_log_path is not None
1784
+ else None
1785
+ ),
1786
+ "source_log_path": (
1787
+ safe_serialized_path(self.source_log_path, workspace_root=workspace_root)
1788
+ if self.source_log_path is not None
1789
+ else None
1790
+ ),
1791
+ "session_id": self.session_id,
1792
+ "session_artifact_dir": (
1793
+ safe_serialized_path(self.session_artifact_dir, workspace_root=workspace_root)
1794
+ if self.session_artifact_dir is not None
1795
+ else None
1796
+ ),
1797
+ }
1798
+ if self.note is not None:
1799
+ payload["note"] = self.note
1800
+ if self.cleanup_note is not None:
1801
+ payload["cleanup_note"] = self.cleanup_note
1802
+ return payload
1803
+
1804
+
1805
+ def write_exec_log_artifacts(
1806
+ *,
1807
+ paths: RunPaths,
1808
+ task_id: str,
1809
+ cfg: AppConfig,
1810
+ no_log: bool,
1811
+ before_logs: set[Path] | None,
1812
+ sessions_dir: Path | None = None,
1813
+ expected_session_id: str | None = None,
1814
+ ) -> ExecutionLogArtifactsResult:
1815
+ ensure_execution_dirs(paths)
1816
+ safe_task = safe_task_file_component(task_id)
1817
+ log_copy_path = paths.execution_logs_dir / f"{safe_task}.jsonl"
1818
+ pointer_path = paths.execution_logs_dir / f"{safe_task}.log.json"
1819
+ retained_session_artifact_dir = task_execution_session_artifact_dir(
1820
+ run_paths=paths,
1821
+ task_id=task_id,
1822
+ )
1823
+
1824
+ resolved_sessions_dir = sessions_dir or resolve_sessions_dir(cfg)
1825
+ retained_sessions_parent = paths.execution_sessions_dir.resolve()
1826
+ selected: Path | None = None
1827
+ if expected_session_id:
1828
+ candidate = resolved_sessions_dir / f"{expected_session_id}.jsonl"
1829
+ if candidate.exists():
1830
+ selected = candidate
1831
+
1832
+ after_logs = sorted(
1833
+ resolved_sessions_dir.glob("*.jsonl"),
1834
+ key=lambda p: p.stat().st_mtime if p.exists() else 0.0,
1835
+ )
1836
+ if selected is None:
1837
+ previous_logs = before_logs or set()
1838
+ new_logs = [p for p in after_logs if p not in previous_logs]
1839
+ selected = new_logs[-1] if new_logs else (after_logs[-1] if after_logs else None)
1840
+
1841
+ source_session_id = selected.stem if selected is not None else expected_session_id
1842
+ source_session_artifact_dir = (
1843
+ resolved_sessions_dir / sanitize_session_id(source_session_id)
1844
+ if source_session_id
1845
+ else None
1846
+ )
1847
+ source_artifacts_exist = bool(
1848
+ source_session_artifact_dir
1849
+ and source_session_artifact_dir.exists()
1850
+ and source_session_artifact_dir.is_dir()
1851
+ )
1852
+ source_artifacts_are_run_owned = resolved_sessions_dir.resolve() == retained_sessions_parent
1853
+
1854
+ log_retained = False
1855
+ copied_log: Path | None = None
1856
+ source_log: Path | None = None
1857
+ retained_artifacts = False
1858
+ session_artifact_dir: Path | None = None
1859
+ note: str | None = None
1860
+ cleanup_note: str | None = None
1861
+
1862
+ if no_log:
1863
+ note = "session logging disabled (--no-log)"
1864
+ if source_artifacts_exist and source_artifacts_are_run_owned:
1865
+ retained_artifacts = True
1866
+ session_artifact_dir = source_session_artifact_dir
1867
+ elif source_artifacts_exist:
1868
+ cleanup_note = "temporary runtime session artifacts will be cleaned up"
1869
+ elif selected and selected.exists():
1870
+ shutil.copy2(selected, log_copy_path)
1871
+ log_retained = True
1872
+ copied_log = log_copy_path
1873
+ source_log = selected
1874
+ if source_artifacts_exist and source_session_artifact_dir is not None:
1875
+ if source_session_artifact_dir.resolve() != retained_session_artifact_dir.resolve():
1876
+ _sync_tree_lightweight(
1877
+ src=source_session_artifact_dir,
1878
+ dest=retained_session_artifact_dir,
1879
+ )
1880
+ retained_artifacts = True
1881
+ session_artifact_dir = retained_session_artifact_dir
1882
+ else:
1883
+ note = "session log not found"
1884
+
1885
+ result = ExecutionLogArtifactsResult(
1886
+ log_copy_path=log_copy_path,
1887
+ pointer_path=pointer_path,
1888
+ logging_enabled=not no_log,
1889
+ log_retained=log_retained,
1890
+ session_artifacts_retained=retained_artifacts,
1891
+ copied_log_path=copied_log,
1892
+ source_log_path=source_log,
1893
+ session_id=source_session_id,
1894
+ session_artifact_dir=session_artifact_dir,
1895
+ note=note,
1896
+ cleanup_note=cleanup_note,
1897
+ )
1898
+ pointer_payload = dict(result.pointer_payload(workspace_root=paths.root))
1899
+ pointer_payload["task_id"] = task_id
1900
+ pointer_path.write_text(
1901
+ json.dumps(pointer_payload, indent=2, sort_keys=True) + "\n",
1902
+ encoding="utf-8",
1903
+ )
1904
+ return result