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,138 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import shutil
5
+ import stat
6
+ import subprocess
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from .git_ops import (
11
+ GitOpsError,
12
+ clean_untracked,
13
+ ensure_runtime_artifact_excludes,
14
+ reset_hard,
15
+ status_porcelain,
16
+ )
17
+ from .git_safe import build_git_cmd
18
+
19
+
20
+ def _run_git_checked(
21
+ root: Path,
22
+ args: list[str],
23
+ *,
24
+ error_message: str,
25
+ extra_config: dict[str, str] | None = None,
26
+ ) -> subprocess.CompletedProcess[str]:
27
+ cmd = build_git_cmd(root, args, extra_config=extra_config)
28
+ try:
29
+ cp = subprocess.run(
30
+ cmd,
31
+ check=False,
32
+ capture_output=True,
33
+ text=True,
34
+ )
35
+ except OSError as e:
36
+ raise GitOpsError("failed to run git") from e
37
+ if cp.returncode != 0:
38
+ detail = (cp.stderr or cp.stdout).strip()
39
+ raise GitOpsError(f"{error_message}: {detail or 'unknown error'}")
40
+ return cp
41
+
42
+
43
+ def _inspect_existing_git_workspace(
44
+ *,
45
+ worktree_path: Path,
46
+ expected_branch: str,
47
+ ) -> tuple[bool, bool, str]:
48
+ try:
49
+ inside = subprocess.run(
50
+ build_git_cmd(worktree_path, ["rev-parse", "--is-inside-work-tree"]),
51
+ check=False,
52
+ capture_output=True,
53
+ text=True,
54
+ )
55
+ except OSError as e:
56
+ raise GitOpsError("failed to run git") from e
57
+ if inside.returncode != 0 or inside.stdout.strip().lower() != "true":
58
+ return False, False, "path exists but is not a valid git worktree"
59
+
60
+ try:
61
+ branch_cp = subprocess.run(
62
+ build_git_cmd(worktree_path, ["rev-parse", "--abbrev-ref", "HEAD"]),
63
+ check=False,
64
+ capture_output=True,
65
+ text=True,
66
+ )
67
+ except OSError as e:
68
+ raise GitOpsError("failed to run git") from e
69
+ if branch_cp.returncode != 0:
70
+ return False, False, "failed to resolve worktree branch"
71
+ current_branch = branch_cp.stdout.strip()
72
+ if current_branch != expected_branch:
73
+ return (
74
+ False,
75
+ False,
76
+ f"worktree branch mismatch (expected {expected_branch}, got {current_branch or '(empty)'})",
77
+ )
78
+
79
+ ensure_runtime_artifact_excludes(worktree_path)
80
+ status_lines = status_porcelain(worktree_path)
81
+ if status_lines:
82
+ preview = ", ".join(status_lines[:5])
83
+ if len(status_lines) > 5:
84
+ preview += ", ..."
85
+ return True, False, f"worktree has stale filesystem state ({preview})"
86
+ return True, True, ""
87
+
88
+
89
+ def _sanitize_existing_git_workspace(*, worktree_path: Path) -> None:
90
+ ensure_runtime_artifact_excludes(worktree_path)
91
+ reset_hard(worktree_path)
92
+ clean_untracked(worktree_path)
93
+ status_lines = status_porcelain(worktree_path)
94
+ if status_lines:
95
+ preview = ", ".join(status_lines[:5])
96
+ if len(status_lines) > 5:
97
+ preview += ", ..."
98
+ raise GitOpsError(f"failed to sanitize worktree state ({preview})")
99
+
100
+
101
+ def _reset_git_workspace_to_target(*, worktree_path: Path, target: str) -> None:
102
+ ensure_runtime_artifact_excludes(worktree_path)
103
+ reset_hard(worktree_path, target=target)
104
+ clean_untracked(worktree_path)
105
+ status_lines = status_porcelain(worktree_path)
106
+ if status_lines:
107
+ preview = ", ".join(status_lines[:5])
108
+ if len(status_lines) > 5:
109
+ preview += ", ..."
110
+ raise GitOpsError(f"failed to reset workspace to {target} ({preview})")
111
+
112
+
113
+ def _cleanup_workspace_path(path: Path) -> None:
114
+ if not path.exists():
115
+ return
116
+ if path.is_dir():
117
+ shutil.rmtree(path, onerror=_retry_readonly_removal)
118
+ else:
119
+ try:
120
+ path.unlink()
121
+ except OSError:
122
+ pass
123
+ if path.exists():
124
+ raise GitOpsError(f"failed to cleanup workspace path {path}")
125
+
126
+
127
+ def _retry_readonly_removal(
128
+ remove: Any,
129
+ path: str,
130
+ exc_info: tuple[type[BaseException], BaseException, Any],
131
+ ) -> None:
132
+ """Retry a Windows removal after clearing Git's read-only file attribute."""
133
+
134
+ error = exc_info[1]
135
+ if not isinstance(error, PermissionError):
136
+ raise error
137
+ os.chmod(path, stat.S_IWRITE | stat.S_IREAD)
138
+ remove(path)
@@ -0,0 +1,455 @@
1
+ """Pre-provisioning of a workspace's *declared* test runner.
2
+
3
+ Most autonomous sessions in a Python repo start the same way: run the tests,
4
+ watch them fail with ``No module named pytest``, install pytest, run them
5
+ again. The gap is knowable before the first command -- the repo declares its
6
+ runner in a config file -- so discovering it by failing a test run is pure
7
+ waste, repeated once per session.
8
+
9
+ This module closes exactly that gap and nothing wider:
10
+
11
+ * Detection is **config-file evidence only**. A repo declares pytest in
12
+ ``pytest.ini``, ``pyproject.toml`` (``[tool.pytest.ini_options]``),
13
+ ``tox.ini`` (``[pytest]``), or ``setup.cfg`` (``[tool:pytest]``). Source
14
+ trees are never scanned, and the presence of ``tests/`` or a ``test_*.py``
15
+ file is *not* evidence -- those say tests exist, not which runner the repo
16
+ declared.
17
+ * The only package that can ever be installed is the declared runner, and only
18
+ when it is not importable. An importable runner is never upgraded or
19
+ reinstalled, and no other package is ever touched.
20
+ * Both the "is it importable" probe and the install run **through the session's
21
+ own shell execution path**, never through the agent's interpreter. That is the
22
+ environment the agent's test command will actually resolve, so it is the only
23
+ environment whose answer means anything -- and routing through it also keeps
24
+ the operator's shell-sandbox policy (network, filesystem) binding on an action
25
+ the agent would otherwise have taken itself. A session with the shell disabled
26
+ provisions nothing.
27
+ * Installation happens only in a **top-level autonomous run**, is attempted
28
+ **once** per process, and is never retried after a failure. Interactive runs
29
+ and nested sessions (subagents and conflict resolvers) never
30
+ install: an interactive run reports the gap and leaves the call to the agent
31
+ or the user, and a nested run inherits whatever its parent already decided.
32
+
33
+ The decision is a pure function over facts, so the policy is testable without a
34
+ filesystem, a subprocess, or an LLM.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import shlex
40
+ import threading
41
+ from collections.abc import Callable
42
+ from dataclasses import dataclass
43
+ from enum import StrEnum
44
+ from pathlib import Path
45
+ from typing import Any
46
+
47
+ from .branding import env_get
48
+ from .runtime_kind import RuntimeKind, normalize_runtime_kind
49
+
50
+ PYTEST_PACKAGE = "pytest"
51
+ PROVISION_TIMEOUT_SECONDS = 60.0
52
+ IMPORT_PROBE_TIMEOUT_SECONDS = 20.0
53
+ # Chosen to collide with nothing a shell or interpreter produces on its own:
54
+ # 1/2 are generic errors, 125/126/127 are container and exec failures.
55
+ PACKAGE_MISSING_EXIT_CODE = 3
56
+ _MAX_CONFIG_BYTES = 1_000_000
57
+
58
+ # Runtime kinds that are a top-level autonomous execution run. Mirrors the
59
+ # non-interactive predicate already used by the deadline logic in agent_loop and
60
+ # the turn loop; nested kinds are deliberately absent (see the module docstring).
61
+ AUTONOMOUS_PROVISIONING_RUNTIME_KINDS = frozenset(
62
+ {RuntimeKind.ONE_SHOT, RuntimeKind.FORGE_EXEC, RuntimeKind.SWARM_WORKER}
63
+ )
64
+
65
+ _attempt_lock = threading.Lock()
66
+ _attempted: set[str] = set()
67
+
68
+
69
+ def _workspace_provisioning_enabled(cfg: Any | None) -> bool:
70
+ """Kill-switch for workspace test-runner pre-provisioning (step 5).
71
+
72
+ ``ALYSIS_WORKSPACE_PROVISIONING`` (off/0/false/no/disabled) wins over the
73
+ config value; default is on. When off, nothing is detected, installed, or
74
+ reported -- fully legacy behaviour.
75
+ """
76
+ env_value = env_get("ALYSIS_WORKSPACE_PROVISIONING")
77
+ if env_value is not None:
78
+ normalized = str(env_value).strip().lower()
79
+ if normalized in {"off", "0", "false", "no", "disabled"}:
80
+ return False
81
+ if normalized in {"on", "1", "true", "yes", "enabled"}:
82
+ return True
83
+ return bool(getattr(cfg, "workspace_provisioning_enabled", True))
84
+
85
+
86
+ # ---------------------------------------------------------------------------
87
+ # Detection (config files only)
88
+ # ---------------------------------------------------------------------------
89
+
90
+
91
+ @dataclass(frozen=True)
92
+ class DeclaredTestRunner:
93
+ package: str
94
+ trigger_config_file: str
95
+ evidence: str
96
+
97
+
98
+ def _read_bounded(path: Path) -> str | None:
99
+ """Read at most ``_MAX_CONFIG_BYTES`` of a repo config file, or ``None``.
100
+
101
+ Bounded for every format, TOML included: this runs during session startup on
102
+ a file the repo controls, and startup must not be held hostage to its size.
103
+ """
104
+ try:
105
+ with path.open("rb") as handle:
106
+ raw = handle.read(_MAX_CONFIG_BYTES)
107
+ except (OSError, MemoryError):
108
+ return None
109
+ return raw.decode("utf-8", errors="replace")
110
+
111
+
112
+ def ini_has_section(text: str, section: str) -> bool:
113
+ """True when an INI document contains a ``[section]`` header.
114
+
115
+ A hand-rolled header scan rather than :mod:`configparser`: the file belongs
116
+ to the user's repo and may be invalid, use duplicate keys, or use syntax
117
+ configparser rejects. Only the header line matters here, and a malformed
118
+ file must degrade to "not declared" rather than raise.
119
+
120
+ Indented lines are skipped, not stripped: in INI an indented line continues
121
+ the previous value, so ``[pytest]`` inside a multi-line value is text, not a
122
+ section -- reading it as a declaration would invent one the repo never made.
123
+ """
124
+ target = f"[{section}]"
125
+ for raw_line in str(text or "").splitlines():
126
+ if raw_line[:1] in {" ", "\t"}:
127
+ continue
128
+ line = raw_line.strip()
129
+ if not line or line.startswith("#") or line.startswith(";"):
130
+ continue
131
+ if not line.startswith("["):
132
+ continue
133
+ end = line.find("]")
134
+ if end == -1:
135
+ continue
136
+ if line[: end + 1] == target:
137
+ return True
138
+ return False
139
+
140
+
141
+ def _pyproject_declares_pytest(path: Path) -> bool:
142
+ try:
143
+ import tomllib
144
+ except ImportError: # pragma: no cover - stdlib on every supported version
145
+ return False
146
+ raw = _read_bounded(path)
147
+ if raw is None:
148
+ return False
149
+ try:
150
+ payload = tomllib.loads(raw)
151
+ except (tomllib.TOMLDecodeError, ValueError):
152
+ return False
153
+ tool = payload.get("tool")
154
+ if not isinstance(tool, dict):
155
+ return False
156
+ pytest_table = tool.get("pytest")
157
+ if not isinstance(pytest_table, dict):
158
+ return False
159
+ return isinstance(pytest_table.get("ini_options"), dict)
160
+
161
+
162
+ def detect_declared_test_runner(root: Path) -> DeclaredTestRunner | None:
163
+ """Return the runner the repo declares in config, or ``None``.
164
+
165
+ Files are checked in pytest's own configuration precedence order, so the
166
+ reported trigger is the file pytest would actually honour.
167
+ """
168
+ workspace_root = Path(root)
169
+
170
+ pytest_ini = workspace_root / "pytest.ini"
171
+ if pytest_ini.is_file():
172
+ # pytest.ini exists only to configure pytest; its presence is the
173
+ # declaration, and it is valid for the file to be empty.
174
+ return DeclaredTestRunner(
175
+ package=PYTEST_PACKAGE,
176
+ trigger_config_file="pytest.ini",
177
+ evidence="pytest_ini_present",
178
+ )
179
+
180
+ pyproject = workspace_root / "pyproject.toml"
181
+ if pyproject.is_file() and _pyproject_declares_pytest(pyproject):
182
+ return DeclaredTestRunner(
183
+ package=PYTEST_PACKAGE,
184
+ trigger_config_file="pyproject.toml",
185
+ evidence="tool.pytest.ini_options",
186
+ )
187
+
188
+ tox_ini = workspace_root / "tox.ini"
189
+ if tox_ini.is_file():
190
+ text = _read_bounded(tox_ini)
191
+ if text is not None and ini_has_section(text, "pytest"):
192
+ return DeclaredTestRunner(
193
+ package=PYTEST_PACKAGE,
194
+ trigger_config_file="tox.ini",
195
+ evidence="[pytest]",
196
+ )
197
+
198
+ setup_cfg = workspace_root / "setup.cfg"
199
+ if setup_cfg.is_file():
200
+ text = _read_bounded(setup_cfg)
201
+ if text is not None and ini_has_section(text, "tool:pytest"):
202
+ return DeclaredTestRunner(
203
+ package=PYTEST_PACKAGE,
204
+ trigger_config_file="setup.cfg",
205
+ evidence="[tool:pytest]",
206
+ )
207
+
208
+ return None
209
+
210
+
211
+ @dataclass(frozen=True)
212
+ class ShellProbeResult:
213
+ exit_code: int | None
214
+ stderr: str = ""
215
+
216
+ @property
217
+ def ran(self) -> bool:
218
+ return self.exit_code is not None
219
+
220
+
221
+ # A shell command runner: takes a command string and a timeout, reports what
222
+ # happened. The session supplies one backed by its own (possibly sandboxed)
223
+ # shell runner, so the probe and the install see exactly the environment the
224
+ # agent's own commands will see.
225
+ ShellCommandRunner = Callable[[str, float], ShellProbeResult]
226
+
227
+
228
+ # Resolve the interpreter the way the workspace itself would. Plenty of hosts
229
+ # ship only `python3`; without this the probe exits 127, the answer is "unknown",
230
+ # and the whole feature silently never fires there.
231
+ _PYTHON_PREFIX = "PY=python; command -v python >/dev/null 2>&1 || PY=python3; "
232
+
233
+
234
+ def build_import_probe_command(package: str) -> str:
235
+ """A probe whose exit code distinguishes "missing" from "could not ask".
236
+
237
+ Exit 0 means present and ``PACKAGE_MISSING_EXIT_CODE`` means definitively
238
+ absent. Any other code -- 127 from a missing interpreter, a sandbox backend
239
+ that failed to start, a shell that could not spawn -- means the question was
240
+ never answered, and answering it wrongly would install into an environment
241
+ nobody probed.
242
+ """
243
+ script = (
244
+ "import importlib.util,sys;"
245
+ f"sys.exit(0 if importlib.util.find_spec({package!r}) else {PACKAGE_MISSING_EXIT_CODE})"
246
+ )
247
+ return f'{_PYTHON_PREFIX}"$PY" -c {shlex.quote(script)}'
248
+
249
+
250
+ def build_provisioning_command(package: str) -> str:
251
+ return f'{_PYTHON_PREFIX}"$PY" -m pip install {shlex.quote(package)} --quiet'
252
+
253
+
254
+ def probe_runner_importable(
255
+ package: str,
256
+ *,
257
+ run_command: ShellCommandRunner,
258
+ timeout_s: float = IMPORT_PROBE_TIMEOUT_SECONDS,
259
+ ) -> bool | None:
260
+ """Is ``package`` importable in the environment the agent's commands use?
261
+
262
+ ``None`` when the probe could not answer -- the shell is disabled, the
263
+ sandbox backend is unavailable, the interpreter is not on PATH. The decision
264
+ treats that as "do nothing" rather than guessing in either direction.
265
+ """
266
+ result = run_command(build_import_probe_command(package), timeout_s)
267
+ if not result.ran:
268
+ return None
269
+ if result.exit_code == 0:
270
+ return True
271
+ if result.exit_code == PACKAGE_MISSING_EXIT_CODE:
272
+ return False
273
+ return None
274
+
275
+
276
+ # ---------------------------------------------------------------------------
277
+ # Decision (pure)
278
+ # ---------------------------------------------------------------------------
279
+
280
+
281
+ class ProvisioningAction(StrEnum):
282
+ PROVISION = "provision"
283
+ REPORT_GAP = "report_gap"
284
+ SKIP = "skip"
285
+
286
+
287
+ @dataclass(frozen=True)
288
+ class ProvisioningDecision:
289
+ action: ProvisioningAction
290
+ reason: str
291
+ package: str = ""
292
+ trigger_config_file: str = ""
293
+
294
+
295
+ def runtime_kind_provisions_autonomously(kind: RuntimeKind | str | None) -> bool:
296
+ """True only for a top-level autonomous execution run.
297
+
298
+ Nested kinds (subagent and conflict resolver) are excluded on
299
+ purpose: they can be spawned from an interactive chat, and letting them
300
+ install would smuggle into that session exactly the silent environment
301
+ mutation it already declined to make. An unknown kind never installs.
302
+ """
303
+ try:
304
+ normalized = normalize_runtime_kind(kind, fallback=RuntimeKind.INTERACTIVE_CHAT)
305
+ except Exception: # noqa: BLE001 - an unknown kind must never install
306
+ return False
307
+ return normalized in AUTONOMOUS_PROVISIONING_RUNTIME_KINDS
308
+
309
+
310
+ def resolve_provisioning_decision(
311
+ *,
312
+ declared: DeclaredTestRunner | None,
313
+ importable: bool | None,
314
+ runtime_kind: RuntimeKind | str | None,
315
+ enabled: bool,
316
+ subagent_depth: int = 0,
317
+ already_attempted: bool = False,
318
+ ) -> ProvisioningDecision:
319
+ """Decide whether to install, report, or do nothing. Pure.
320
+
321
+ Only one combination installs: the feature is on, the repo declared a runner
322
+ in a config file, that runner is *confirmed* missing from the environment the
323
+ agent's commands run in, the run is a top-level autonomous one, and no
324
+ attempt has been made yet. Everything else is a no-op or a report.
325
+ """
326
+ if not enabled:
327
+ return ProvisioningDecision(ProvisioningAction.SKIP, "workspace_provisioning_disabled")
328
+ if declared is None:
329
+ return ProvisioningDecision(ProvisioningAction.SKIP, "no_declared_test_runner")
330
+ if importable is None:
331
+ return ProvisioningDecision(
332
+ ProvisioningAction.SKIP,
333
+ "import_probe_unavailable",
334
+ package=declared.package,
335
+ trigger_config_file=declared.trigger_config_file,
336
+ )
337
+ if importable:
338
+ return ProvisioningDecision(
339
+ ProvisioningAction.SKIP,
340
+ "declared_test_runner_importable",
341
+ package=declared.package,
342
+ trigger_config_file=declared.trigger_config_file,
343
+ )
344
+ if subagent_depth > 0:
345
+ return ProvisioningDecision(
346
+ ProvisioningAction.SKIP,
347
+ "nested_session_defers_to_parent",
348
+ package=declared.package,
349
+ trigger_config_file=declared.trigger_config_file,
350
+ )
351
+ if not runtime_kind_provisions_autonomously(runtime_kind):
352
+ return ProvisioningDecision(
353
+ ProvisioningAction.REPORT_GAP,
354
+ "interactive_run_does_not_install",
355
+ package=declared.package,
356
+ trigger_config_file=declared.trigger_config_file,
357
+ )
358
+ if already_attempted:
359
+ return ProvisioningDecision(
360
+ ProvisioningAction.REPORT_GAP,
361
+ "provisioning_already_attempted",
362
+ package=declared.package,
363
+ trigger_config_file=declared.trigger_config_file,
364
+ )
365
+ return ProvisioningDecision(
366
+ ProvisioningAction.PROVISION,
367
+ "declared_test_runner_missing",
368
+ package=declared.package,
369
+ trigger_config_file=declared.trigger_config_file,
370
+ )
371
+
372
+
373
+ # ---------------------------------------------------------------------------
374
+ # Execution (one bounded attempt)
375
+ # ---------------------------------------------------------------------------
376
+
377
+
378
+ @dataclass(frozen=True)
379
+ class ProvisioningOutcome:
380
+ package: str
381
+ trigger_config_file: str
382
+ success: bool
383
+ command: str
384
+ exit_code: int | None = None
385
+ error: str = ""
386
+
387
+ def payload(self) -> dict[str, Any]:
388
+ out: dict[str, Any] = {
389
+ "package": self.package,
390
+ "trigger_config_file": self.trigger_config_file,
391
+ "success": self.success,
392
+ "command": self.command,
393
+ }
394
+ if self.exit_code is not None:
395
+ out["exit_code"] = self.exit_code
396
+ if self.error:
397
+ out["error"] = self.error
398
+ return out
399
+
400
+
401
+ def provisioning_already_attempted(package: str) -> bool:
402
+ with _attempt_lock:
403
+ return package in _attempted
404
+
405
+
406
+ def _claim_provisioning_attempt(package: str) -> bool:
407
+ """Claim the single attempt for ``package``. False when someone else holds it."""
408
+ with _attempt_lock:
409
+ if package in _attempted:
410
+ return False
411
+ _attempted.add(package)
412
+ return True
413
+
414
+
415
+ def reset_provisioning_attempts() -> None:
416
+ """Clear the one-attempt ledger. Test seam only."""
417
+ with _attempt_lock:
418
+ _attempted.clear()
419
+
420
+
421
+ def provision_test_runner(
422
+ decision: ProvisioningDecision,
423
+ *,
424
+ run_command: ShellCommandRunner,
425
+ timeout_s: float = PROVISION_TIMEOUT_SECONDS,
426
+ ) -> ProvisioningOutcome | None:
427
+ """Run the single install attempt described by ``decision``.
428
+
429
+ Returns ``None`` when another session in this process already claimed the
430
+ attempt. That is not a failure and must not be reported as one, or every
431
+ swarm worker but the first would log an install failure that never happened.
432
+ The claim is taken before the command runs, so a failure is never retried: a
433
+ broken or offline environment costs one install, not one per session.
434
+ """
435
+ command = build_provisioning_command(decision.package)
436
+ if not _claim_provisioning_attempt(decision.package):
437
+ return None
438
+ result = run_command(command, timeout_s)
439
+ if not result.ran:
440
+ return ProvisioningOutcome(
441
+ package=decision.package,
442
+ trigger_config_file=decision.trigger_config_file,
443
+ success=False,
444
+ command=command,
445
+ error=result.stderr.strip()[:2000] or "the install command could not be executed",
446
+ )
447
+ exit_code = int(result.exit_code or 0)
448
+ return ProvisioningOutcome(
449
+ package=decision.package,
450
+ trigger_config_file=decision.trigger_config_file,
451
+ success=exit_code == 0,
452
+ command=command,
453
+ exit_code=exit_code,
454
+ error=("" if exit_code == 0 else result.stderr.strip()[:2000]),
455
+ )