alysis-code 0.13.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (458) hide show
  1. alysis_code/__init__.py +3 -0
  2. alysis_code/__main__.py +4 -0
  3. alysis_code/_build_info.py +14 -0
  4. alysis_code/account_login.py +468 -0
  5. alysis_code/agent/README.md +35 -0
  6. alysis_code/agent/__init__.py +11 -0
  7. alysis_code/agent/acceptance_contract.py +2217 -0
  8. alysis_code/agent/blast_radius.py +1403 -0
  9. alysis_code/agent/cache_keepalive.py +227 -0
  10. alysis_code/agent/completion_certificate.py +366 -0
  11. alysis_code/agent/completion_gate.py +306 -0
  12. alysis_code/agent/empty_response_stall.py +403 -0
  13. alysis_code/agent/errors.py +28 -0
  14. alysis_code/agent/llm_calls.py +475 -0
  15. alysis_code/agent/mutation_classification.py +227 -0
  16. alysis_code/agent/prompt_context.py +2508 -0
  17. alysis_code/agent/read_ledger.py +253 -0
  18. alysis_code/agent/regression_baseline.py +642 -0
  19. alysis_code/agent/reproduction_first.py +610 -0
  20. alysis_code/agent/sensitive_output.py +629 -0
  21. alysis_code/agent/session.py +3218 -0
  22. alysis_code/agent/steering.py +191 -0
  23. alysis_code/agent/subagent_execution.py +5177 -0
  24. alysis_code/agent/subagent_workspace.py +666 -0
  25. alysis_code/agent/tools_assembly.py +4728 -0
  26. alysis_code/agent/turn/__init__.py +101 -0
  27. alysis_code/agent/turn/core.py +8483 -0
  28. alysis_code/agent/turn/events.py +113 -0
  29. alysis_code/agent/turn/exploration.py +590 -0
  30. alysis_code/agent/turn/interventions.py +65 -0
  31. alysis_code/agent/turn/read_cache.py +420 -0
  32. alysis_code/agent/turn/snapshot.py +179 -0
  33. alysis_code/agent/turn_contract.py +661 -0
  34. alysis_code/agent/turn_path.py +129 -0
  35. alysis_code/agent/verification.py +2885 -0
  36. alysis_code/agent/verification_commands.py +512 -0
  37. alysis_code/agent/verification_evidence.py +738 -0
  38. alysis_code/agent_loop.py +693 -0
  39. alysis_code/agent_runtimes/__init__.py +51 -0
  40. alysis_code/agent_runtimes/base.py +114 -0
  41. alysis_code/agent_runtimes/builtins.py +129 -0
  42. alysis_code/agent_runtimes/codex_cli.py +664 -0
  43. alysis_code/agent_runtimes/host.py +263 -0
  44. alysis_code/agent_runtimes/registry.py +64 -0
  45. alysis_code/agent_runtimes/service.py +150 -0
  46. alysis_code/agentbox_client.py +416 -0
  47. alysis_code/agentbox_integration.py +310 -0
  48. alysis_code/alysis_cloud.py +152 -0
  49. alysis_code/approval_scope.py +276 -0
  50. alysis_code/assets/README.md +33 -0
  51. alysis_code/assets/__init__.py +126 -0
  52. alysis_code/assets/asset_read_core.py +281 -0
  53. alysis_code/assets/budget_allocator.py +456 -0
  54. alysis_code/assets/comprehender.py +759 -0
  55. alysis_code/assets/index.py +654 -0
  56. alysis_code/assets/ingestion.py +275 -0
  57. alysis_code/assets/legacy_migration.py +413 -0
  58. alysis_code/assets/models.py +263 -0
  59. alysis_code/assets/ocr.py +239 -0
  60. alysis_code/assets/owl/ascii/f-000.txt +13 -0
  61. alysis_code/assets/owl/ascii/f-001.txt +13 -0
  62. alysis_code/assets/owl/ascii/f-002.txt +13 -0
  63. alysis_code/assets/owl/ascii/f-003.txt +13 -0
  64. alysis_code/assets/owl/ascii/f-004.txt +13 -0
  65. alysis_code/assets/owl/ascii/f-005.txt +13 -0
  66. alysis_code/assets/owl/ascii/f-006.txt +13 -0
  67. alysis_code/assets/owl/ascii/f-007.txt +13 -0
  68. alysis_code/assets/owl/ascii/f-008.txt +13 -0
  69. alysis_code/assets/owl/ascii/f-009.txt +13 -0
  70. alysis_code/assets/owl/ascii/f-010.txt +13 -0
  71. alysis_code/assets/owl/ascii/f-011.txt +13 -0
  72. alysis_code/assets/owl/ascii/f-012.txt +13 -0
  73. alysis_code/assets/owl/ascii/f-013.txt +13 -0
  74. alysis_code/assets/owl/ascii/f-014.txt +13 -0
  75. alysis_code/assets/owl/ascii/f-015.txt +13 -0
  76. alysis_code/assets/owl/ascii/f-016.txt +13 -0
  77. alysis_code/assets/owl/ascii/f-017.txt +13 -0
  78. alysis_code/assets/owl/ascii/f-018.txt +13 -0
  79. alysis_code/assets/owl/ascii/f-019.txt +13 -0
  80. alysis_code/assets/owl/ascii/f-020.txt +13 -0
  81. alysis_code/assets/owl/index.html +98 -0
  82. alysis_code/assets/owl/show-owl.sh +761 -0
  83. alysis_code/assets/paths.py +49 -0
  84. alysis_code/assets/plan_binding.py +326 -0
  85. alysis_code/assets/planner_context.py +466 -0
  86. alysis_code/assets/planner_tools.py +184 -0
  87. alysis_code/assets/prompts.py +101 -0
  88. alysis_code/assets/replanner_context.py +239 -0
  89. alysis_code/assets/surface.py +521 -0
  90. alysis_code/assets/untrusted_content.py +48 -0
  91. alysis_code/assets/usage_logger.py +94 -0
  92. alysis_code/assets/worker_mirror.py +428 -0
  93. alysis_code/assets/worker_section.py +303 -0
  94. alysis_code/assets/worker_tools.py +468 -0
  95. alysis_code/atomic_io.py +83 -0
  96. alysis_code/auth_diagnostics.py +272 -0
  97. alysis_code/background_runner.py +366 -0
  98. alysis_code/branding.py +270 -0
  99. alysis_code/budget_policy.py +390 -0
  100. alysis_code/build_identity.py +465 -0
  101. alysis_code/builtin_hooks/__init__.py +7 -0
  102. alysis_code/builtin_hooks/notify_done_windows.py +65 -0
  103. alysis_code/bwrap_etc.py +76 -0
  104. alysis_code/cancellation.py +41 -0
  105. alysis_code/capabilities.py +137 -0
  106. alysis_code/chatgpt_codex_static_provider.py +133 -0
  107. alysis_code/cli.py +51 -0
  108. alysis_code/cli_impl/__init__.py +1 -0
  109. alysis_code/cli_impl/assets_cli.py +537 -0
  110. alysis_code/cli_impl/assets_modal.py +412 -0
  111. alysis_code/cli_impl/chat/__init__.py +156 -0
  112. alysis_code/cli_impl/chat/commands.py +2616 -0
  113. alysis_code/cli_impl/chat/loop.py +4508 -0
  114. alysis_code/cli_impl/chat/mid_turn_policy.py +125 -0
  115. alysis_code/cli_impl/chat/rendering.py +444 -0
  116. alysis_code/cli_impl/chat/state.py +124 -0
  117. alysis_code/cli_impl/chat_resume.py +830 -0
  118. alysis_code/cli_impl/chat_slash_completer.py +258 -0
  119. alysis_code/cli_impl/commands/__init__.py +11 -0
  120. alysis_code/cli_impl/commands/_shared.py +89 -0
  121. alysis_code/cli_impl/commands/auth.py +623 -0
  122. alysis_code/cli_impl/commands/chat_resume_helpers.py +1531 -0
  123. alysis_code/cli_impl/commands/chat_state.py +158 -0
  124. alysis_code/cli_impl/commands/chat_status.py +1248 -0
  125. alysis_code/cli_impl/commands/chat_terminal.py +942 -0
  126. alysis_code/cli_impl/commands/chat_tui_panels.py +1018 -0
  127. alysis_code/cli_impl/commands/cli_common.py +1223 -0
  128. alysis_code/cli_impl/commands/cli_surface.py +77 -0
  129. alysis_code/cli_impl/commands/config.py +131 -0
  130. alysis_code/cli_impl/commands/conventions.py +85 -0
  131. alysis_code/cli_impl/commands/execution_helpers.py +350 -0
  132. alysis_code/cli_impl/commands/extensions.py +401 -0
  133. alysis_code/cli_impl/commands/forge.py +1282 -0
  134. alysis_code/cli_impl/commands/forge_asset_view.py +121 -0
  135. alysis_code/cli_impl/commands/forge_helpers.py +1215 -0
  136. alysis_code/cli_impl/commands/hooks.py +737 -0
  137. alysis_code/cli_impl/commands/ide_bridge.py +31 -0
  138. alysis_code/cli_impl/commands/mcp.py +700 -0
  139. alysis_code/cli_impl/commands/profile.py +453 -0
  140. alysis_code/cli_impl/commands/prompt_helpers.py +307 -0
  141. alysis_code/cli_impl/commands/report.py +88 -0
  142. alysis_code/cli_impl/commands/root.py +1118 -0
  143. alysis_code/cli_impl/commands/sandbox.py +184 -0
  144. alysis_code/cli_impl/commands/server.py +54 -0
  145. alysis_code/cli_impl/commands/sessions.py +252 -0
  146. alysis_code/cli_impl/commands/skills.py +404 -0
  147. alysis_code/cli_impl/commands/startup.py +946 -0
  148. alysis_code/cli_impl/commands/tools.py +335 -0
  149. alysis_code/cli_impl/commands/update.py +364 -0
  150. alysis_code/cli_impl/commands/welcome.py +972 -0
  151. alysis_code/cli_impl/config_menu.py +3882 -0
  152. alysis_code/cli_impl/forge.py +4509 -0
  153. alysis_code/cli_impl/forge_recovery.py +485 -0
  154. alysis_code/cli_impl/setup_wizard.py +2409 -0
  155. alysis_code/cli_impl/tui/__init__.py +58 -0
  156. alysis_code/cli_impl/tui/app.py +4551 -0
  157. alysis_code/cli_impl/tui/config.py +32 -0
  158. alysis_code/cli_impl/tui/config_flow.py +2754 -0
  159. alysis_code/cli_impl/tui/config_overlay.py +566 -0
  160. alysis_code/cli_impl/tui/content.py +78 -0
  161. alysis_code/cli_impl/tui/footer.py +218 -0
  162. alysis_code/cli_impl/tui/forge_status.py +136 -0
  163. alysis_code/cli_impl/tui/markdown.py +244 -0
  164. alysis_code/cli_impl/tui/owl.py +109 -0
  165. alysis_code/cli_impl/tui/plan_meta.py +477 -0
  166. alysis_code/cli_impl/tui/setup_app.py +519 -0
  167. alysis_code/cli_impl/tui/setup_flow.py +1622 -0
  168. alysis_code/cli_impl/tui/state.py +101 -0
  169. alysis_code/cli_impl/tui/subagent_identity.py +66 -0
  170. alysis_code/cli_impl/tui/subagent_panel.py +186 -0
  171. alysis_code/cli_impl/tui/surface.py +796 -0
  172. alysis_code/cli_impl/tui/transcript.py +514 -0
  173. alysis_code/cli_impl/tui/update_prompt.py +79 -0
  174. alysis_code/cli_impl/tui/workspace_guard.py +384 -0
  175. alysis_code/clipboard.py +172 -0
  176. alysis_code/code_review.py +1211 -0
  177. alysis_code/compaction/__init__.py +28 -0
  178. alysis_code/compaction/conversation_compactor.py +2932 -0
  179. alysis_code/compaction/importance.py +177 -0
  180. alysis_code/compaction/settings.py +297 -0
  181. alysis_code/compaction/tool_output_offload.py +447 -0
  182. alysis_code/config.py +3509 -0
  183. alysis_code/conflict_auto_resolver.py +895 -0
  184. alysis_code/context/__init__.py +1 -0
  185. alysis_code/context/tool_schema_budgeter.py +220 -0
  186. alysis_code/crash_diagnostics.py +282 -0
  187. alysis_code/custom_tools/README.md +34 -0
  188. alysis_code/custom_tools/__init__.py +43 -0
  189. alysis_code/custom_tools/discovery.py +903 -0
  190. alysis_code/custom_tools/runtime.py +1516 -0
  191. alysis_code/custom_tools/session.py +227 -0
  192. alysis_code/custom_tools/trust.py +232 -0
  193. alysis_code/diff_paths.py +113 -0
  194. alysis_code/direction_change.py +293 -0
  195. alysis_code/dispatch_timing.py +306 -0
  196. alysis_code/durable_service_manager.py +1236 -0
  197. alysis_code/edit_discipline.py +659 -0
  198. alysis_code/error_text.py +73 -0
  199. alysis_code/execution_budget.py +411 -0
  200. alysis_code/execution_context.py +915 -0
  201. alysis_code/execution_deadline.py +1065 -0
  202. alysis_code/execution_shared.py +1904 -0
  203. alysis_code/extensions/README.md +30 -0
  204. alysis_code/extensions/__init__.py +93 -0
  205. alysis_code/extensions/activation.py +138 -0
  206. alysis_code/extensions/install.py +1436 -0
  207. alysis_code/extensions/manifest.py +487 -0
  208. alysis_code/extensions/models.py +74 -0
  209. alysis_code/extensions/paths.py +56 -0
  210. alysis_code/extensions/registry.json +4 -0
  211. alysis_code/extensions/registry.py +52 -0
  212. alysis_code/extensions/state.py +83 -0
  213. alysis_code/extensions/workspace_trust.py +101 -0
  214. alysis_code/failed_task_evidence.py +369 -0
  215. alysis_code/failure_category.py +315 -0
  216. alysis_code/feedback_report.py +1647 -0
  217. alysis_code/file_classification.py +485 -0
  218. alysis_code/forge.py +2064 -0
  219. alysis_code/forge_completion.py +362 -0
  220. alysis_code/forge_events.py +475 -0
  221. alysis_code/frontmatter_utils.py +95 -0
  222. alysis_code/git_evidence.py +1181 -0
  223. alysis_code/git_ops.py +560 -0
  224. alysis_code/git_safe.py +62 -0
  225. alysis_code/git_worktrees.py +190 -0
  226. alysis_code/hooks/README.md +33 -0
  227. alysis_code/hooks/__init__.py +67 -0
  228. alysis_code/hooks/audit.py +171 -0
  229. alysis_code/hooks/config.py +225 -0
  230. alysis_code/hooks/dispatcher.py +1110 -0
  231. alysis_code/hooks/models.py +447 -0
  232. alysis_code/hooks/trust.py +202 -0
  233. alysis_code/host_actions.py +543 -0
  234. alysis_code/host_browser.py +103 -0
  235. alysis_code/ide/__init__.py +5 -0
  236. alysis_code/ide/activity_events.py +399 -0
  237. alysis_code/ide/approvals.py +337 -0
  238. alysis_code/ide/artifacts.py +153 -0
  239. alysis_code/ide/browser_egress_proxy.py +1076 -0
  240. alysis_code/ide/cdp_websocket_transport.py +1192 -0
  241. alysis_code/ide/change_ledger.py +1721 -0
  242. alysis_code/ide/context_blocks.py +979 -0
  243. alysis_code/ide/event_stream.py +531 -0
  244. alysis_code/ide/forge_protocol.py +3112 -0
  245. alysis_code/ide/forge_request_ledger.py +737 -0
  246. alysis_code/ide/health.py +965 -0
  247. alysis_code/ide/managed_browser.py +2251 -0
  248. alysis_code/ide/management_protocol.py +3414 -0
  249. alysis_code/ide/mcp_oauth_coordinator.py +744 -0
  250. alysis_code/ide/mcp_oauth_lifecycle.py +1504 -0
  251. alysis_code/ide/prompt_queue.py +1070 -0
  252. alysis_code/ide/protocol.py +191 -0
  253. alysis_code/ide/resumable_swarm.py +1543 -0
  254. alysis_code/ide/session_search.py +295 -0
  255. alysis_code/ide/stdio_bridge.py +9935 -0
  256. alysis_code/ide/structured_state.py +1579 -0
  257. alysis_code/ide/swarm_protocol.py +816 -0
  258. alysis_code/integration_gate.py +506 -0
  259. alysis_code/interactive_input_guard.py +39 -0
  260. alysis_code/interactive_plan_mode.py +26 -0
  261. alysis_code/internal_artifacts.py +179 -0
  262. alysis_code/knowledge_base.py +1409 -0
  263. alysis_code/knowledge_capture.py +1190 -0
  264. alysis_code/knowledge_librarian.py +605 -0
  265. alysis_code/language_policy.py +34 -0
  266. alysis_code/litellm_static_provider.py +535 -0
  267. alysis_code/llm/__init__.py +1 -0
  268. alysis_code/llm/anthropic_messages.py +2288 -0
  269. alysis_code/llm/base.py +71 -0
  270. alysis_code/llm/cache_capabilities.py +985 -0
  271. alysis_code/llm/cache_control_blocks.py +244 -0
  272. alysis_code/llm/cache_policy.py +388 -0
  273. alysis_code/llm/factory.py +373 -0
  274. alysis_code/llm/gemini_generate_content.py +2652 -0
  275. alysis_code/llm/gemini_interactions.py +739 -0
  276. alysis_code/llm/metadata.py +450 -0
  277. alysis_code/llm/openai_compat.py +2947 -0
  278. alysis_code/llm/openai_responses.py +2604 -0
  279. alysis_code/llm/protocols.py +609 -0
  280. alysis_code/llm/provider_limits.py +525 -0
  281. alysis_code/llm/request_plan.py +389 -0
  282. alysis_code/llm/request_shape.py +238 -0
  283. alysis_code/llm/streaming.py +108 -0
  284. alysis_code/llm/temperature_compat.py +78 -0
  285. alysis_code/llm/types.py +195 -0
  286. alysis_code/llm/usage_normalization.py +222 -0
  287. alysis_code/llm_error_display.py +315 -0
  288. alysis_code/logging_redaction.py +326 -0
  289. alysis_code/managed_host_deadline.py +196 -0
  290. alysis_code/mcp/README.md +33 -0
  291. alysis_code/mcp/__init__.py +24 -0
  292. alysis_code/mcp/client.py +1137 -0
  293. alysis_code/mcp/config.py +597 -0
  294. alysis_code/mcp/errors.py +113 -0
  295. alysis_code/mcp/forge_scope.py +154 -0
  296. alysis_code/mcp/jsonrpc.py +214 -0
  297. alysis_code/mcp/manager.py +2308 -0
  298. alysis_code/mcp/models.py +666 -0
  299. alysis_code/mcp/oauth.py +972 -0
  300. alysis_code/mcp/oauth_runtime.py +310 -0
  301. alysis_code/mcp/oauth_store.py +276 -0
  302. alysis_code/mcp/prompts.py +329 -0
  303. alysis_code/mcp/resources.py +295 -0
  304. alysis_code/mcp/roots.py +106 -0
  305. alysis_code/mcp/server_requests.py +75 -0
  306. alysis_code/mcp/token_store.py +859 -0
  307. alysis_code/mcp/transport_http.py +1338 -0
  308. alysis_code/mcp/transport_stdio.py +1267 -0
  309. alysis_code/mcp/untrusted_content.py +119 -0
  310. alysis_code/merge_conflict_reviewer.py +729 -0
  311. alysis_code/model_catalog/__init__.py +1 -0
  312. alysis_code/model_catalog/chatgpt_codex_subscription_snapshot.json +186 -0
  313. alysis_code/model_catalog/litellm_model_prices_snapshot.json +44715 -0
  314. alysis_code/model_catalog/litellm_model_prices_snapshot.meta.json +17 -0
  315. alysis_code/model_metadata_policy.py +223 -0
  316. alysis_code/model_metadata_utils.py +103 -0
  317. alysis_code/model_registry.py +1420 -0
  318. alysis_code/model_router.py +147 -0
  319. alysis_code/permission_policy.py +1016 -0
  320. alysis_code/personas.py +451 -0
  321. alysis_code/pipeline_facts.py +233 -0
  322. alysis_code/plan_assistant.py +4763 -0
  323. alysis_code/plan_mode.py +393 -0
  324. alysis_code/plan_reconciliation.py +1228 -0
  325. alysis_code/plan_repair.py +652 -0
  326. alysis_code/plan_validation.py +1099 -0
  327. alysis_code/planning_constraints.py +904 -0
  328. alysis_code/policy.py +95 -0
  329. alysis_code/preview_server.py +457 -0
  330. alysis_code/process_reaping.py +566 -0
  331. alysis_code/profile_presets.py +1834 -0
  332. alysis_code/profiles.py +666 -0
  333. alysis_code/provider_auth/__init__.py +29 -0
  334. alysis_code/provider_auth/base.py +99 -0
  335. alysis_code/provider_auth/openai_codex.py +951 -0
  336. alysis_code/provider_auth/registry.py +76 -0
  337. alysis_code/provider_auth/store.py +125 -0
  338. alysis_code/provider_diagnostics.py +1209 -0
  339. alysis_code/provider_model_catalog.py +685 -0
  340. alysis_code/provider_telemetry.py +1699 -0
  341. alysis_code/provider_url.py +75 -0
  342. alysis_code/reasoning_contracts.py +911 -0
  343. alysis_code/remote_sync.py +350 -0
  344. alysis_code/replanning.py +1195 -0
  345. alysis_code/repo_scan.py +1152 -0
  346. alysis_code/request_estimation.py +296 -0
  347. alysis_code/review_gate.py +617 -0
  348. alysis_code/run_lock.py +1141 -0
  349. alysis_code/run_outcome.py +58 -0
  350. alysis_code/run_provenance.py +774 -0
  351. alysis_code/run_state.py +445 -0
  352. alysis_code/runtime_artifacts.py +116 -0
  353. alysis_code/runtime_context_features.py +78 -0
  354. alysis_code/runtime_kind.py +52 -0
  355. alysis_code/safety/__init__.py +11 -0
  356. alysis_code/safety/mcp_sanitize.py +29 -0
  357. alysis_code/safety/safe_http.py +297 -0
  358. alysis_code/safety/subagent_report.py +184 -0
  359. alysis_code/sandbox_doctor.py +682 -0
  360. alysis_code/sandbox_runner.py +1025 -0
  361. alysis_code/sandbox_settings.py +423 -0
  362. alysis_code/serialized_paths.py +355 -0
  363. alysis_code/server/__init__.py +3 -0
  364. alysis_code/server/app.py +367 -0
  365. alysis_code/server/auth.py +34 -0
  366. alysis_code/server/job_config.py +30 -0
  367. alysis_code/server/settings.py +215 -0
  368. alysis_code/server/store.py +193 -0
  369. alysis_code/server/worker_runner.py +657 -0
  370. alysis_code/service_persistence.py +355 -0
  371. alysis_code/session_artifacts.py +108 -0
  372. alysis_code/session_metrics.py +331 -0
  373. alysis_code/session_store.py +624 -0
  374. alysis_code/skills/README.md +34 -0
  375. alysis_code/skills/__init__.py +104 -0
  376. alysis_code/skills/conventions.py +84 -0
  377. alysis_code/skills/discovery.py +176 -0
  378. alysis_code/skills/eval_models.py +232 -0
  379. alysis_code/skills/eval_runner.py +372 -0
  380. alysis_code/skills/evals.py +1344 -0
  381. alysis_code/skills/install.py +293 -0
  382. alysis_code/skills/loader.py +118 -0
  383. alysis_code/skills/matching.py +103 -0
  384. alysis_code/skills/models.py +71 -0
  385. alysis_code/skills/paths.py +56 -0
  386. alysis_code/skills/prompting.py +500 -0
  387. alysis_code/skills/scaffold.py +142 -0
  388. alysis_code/skills/state.py +441 -0
  389. alysis_code/skills/transactions.py +125 -0
  390. alysis_code/skills/validation.py +304 -0
  391. alysis_code/step_budget.py +238 -0
  392. alysis_code/subagent_labels.py +49 -0
  393. alysis_code/subagents.py +1072 -0
  394. alysis_code/surface/__init__.py +80 -0
  395. alysis_code/surface/base.py +305 -0
  396. alysis_code/surface/console.py +387 -0
  397. alysis_code/surface/events.py +372 -0
  398. alysis_code/surface/hidden_surface.py +529 -0
  399. alysis_code/surface/noop_surface.py +219 -0
  400. alysis_code/surface/rich_surface.py +1555 -0
  401. alysis_code/surface/styles.py +67 -0
  402. alysis_code/surface/theme.py +455 -0
  403. alysis_code/surface/types.py +100 -0
  404. alysis_code/swarm_backend.py +926 -0
  405. alysis_code/swarm_orchestrator.py +4020 -0
  406. alysis_code/swarm_scheduler.py +441 -0
  407. alysis_code/swarm_trace.py +429 -0
  408. alysis_code/swarm_worker.py +2119 -0
  409. alysis_code/swarm_write_guard.py +348 -0
  410. alysis_code/task_dependencies.py +170 -0
  411. alysis_code/task_readiness.py +992 -0
  412. alysis_code/task_scope.py +2148 -0
  413. alysis_code/terminal_manager.py +762 -0
  414. alysis_code/terminal_ownership.py +460 -0
  415. alysis_code/text_normalization.py +30 -0
  416. alysis_code/token_budget.py +97 -0
  417. alysis_code/tools/README.md +34 -0
  418. alysis_code/tools/__init__.py +1 -0
  419. alysis_code/tools/artifacts.py +127 -0
  420. alysis_code/tools/availability.py +188 -0
  421. alysis_code/tools/fs.py +1456 -0
  422. alysis_code/tools/git.py +461 -0
  423. alysis_code/tools/history.py +229 -0
  424. alysis_code/tools/http_timeout.py +78 -0
  425. alysis_code/tools/image_generation.py +552 -0
  426. alysis_code/tools/registry.py +2936 -0
  427. alysis_code/tools/repo_map.py +476 -0
  428. alysis_code/tools/search.py +563 -0
  429. alysis_code/tools/shell.py +135 -0
  430. alysis_code/tools/symbols.py +1350 -0
  431. alysis_code/tools/test_discovery.py +643 -0
  432. alysis_code/tools/web.py +482 -0
  433. alysis_code/tools/web_search.py +2012 -0
  434. alysis_code/tools/web_search_dashscope.py +557 -0
  435. alysis_code/tools/web_search_ddgs.py +221 -0
  436. alysis_code/tools/web_search_provider_adapters.py +1429 -0
  437. alysis_code/tools/web_search_tavily.py +194 -0
  438. alysis_code/updates.py +933 -0
  439. alysis_code/usage_tracker.py +1990 -0
  440. alysis_code/verification_command_analysis.py +1004 -0
  441. alysis_code/verification_contract.py +574 -0
  442. alysis_code/verification_failure_summary.py +273 -0
  443. alysis_code/verification_repair.py +385 -0
  444. alysis_code/verify_gate.py +3129 -0
  445. alysis_code/web_research.py +1872 -0
  446. alysis_code/web_search_adapters.py +66 -0
  447. alysis_code/web_search_policy.py +27 -0
  448. alysis_code/workspace_binding.py +389 -0
  449. alysis_code/workspace_binding_ui.py +408 -0
  450. alysis_code/workspace_context.py +273 -0
  451. alysis_code/workspace_isolation.py +138 -0
  452. alysis_code/workspace_provisioning.py +455 -0
  453. alysis_code-0.13.0.dist-info/METADATA +507 -0
  454. alysis_code-0.13.0.dist-info/RECORD +458 -0
  455. alysis_code-0.13.0.dist-info/WHEEL +4 -0
  456. alysis_code-0.13.0.dist-info/entry_points.txt +3 -0
  457. alysis_code-0.13.0.dist-info/licenses/LICENSE +176 -0
  458. alysis_code-0.13.0.dist-info/licenses/NOTICE +4 -0
alysis_code/updates.py ADDED
@@ -0,0 +1,933 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ import os
6
+ import shlex
7
+ import shutil
8
+ import subprocess
9
+ import sys
10
+ import threading
11
+ from collections.abc import Callable, Mapping
12
+ from dataclasses import dataclass
13
+ from datetime import UTC, datetime, timedelta
14
+ from importlib import metadata as importlib_metadata
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ import httpx
19
+ from packaging.version import InvalidVersion, Version
20
+
21
+ from .atomic_io import atomic_write_json
22
+ from .branding import (
23
+ LEGACY_PYTHON_PACKAGE_NAME,
24
+ PYTHON_PACKAGE_NAME,
25
+ canonical_user_data_dir,
26
+ env_get,
27
+ )
28
+ from .config import AppConfig, ConfigError
29
+ from .safety import SafeHttpError, safe_http_request
30
+
31
+ PYPI_JSON_URL = f"https://pypi.org/pypi/{PYTHON_PACKAGE_NAME}/json"
32
+ UPDATE_CACHE_SCHEMA_VERSION = 1
33
+ UPDATE_PROMPT_STATE_SCHEMA_VERSION = 1
34
+ DEFAULT_UPDATE_CHECK_INTERVAL_HOURS = 24
35
+ DEFAULT_UPDATE_CHECK_TIMEOUT_S = 3.0
36
+ DEFAULT_UPDATE_PROMPT_SNOOZE_HOURS = 24
37
+ UPDATE_CACHE_MAX_BYTES = 512 * 1024
38
+ _update_refresh_lock = threading.Lock()
39
+ _update_refresh_started = False
40
+
41
+
42
+ class UpdateCheckError(RuntimeError):
43
+ pass
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class UpdateCacheRecord:
48
+ checked_at: datetime
49
+ package: str
50
+ source: str
51
+ latest_version: str | None = None
52
+ url: str | None = None
53
+ error: str | None = None
54
+
55
+ @property
56
+ def ok(self) -> bool:
57
+ return bool(self.latest_version) and not self.error
58
+
59
+ def to_json(self) -> dict[str, Any]:
60
+ return {
61
+ "schema_version": UPDATE_CACHE_SCHEMA_VERSION,
62
+ "checked_at": self.checked_at.astimezone(UTC).isoformat(),
63
+ "package": self.package,
64
+ "source": self.source,
65
+ "latest_version": self.latest_version,
66
+ "url": self.url,
67
+ "error": self.error,
68
+ }
69
+
70
+
71
+ @dataclass(frozen=True)
72
+ class UpdateStatus:
73
+ current_version: str
74
+ latest_version: str | None
75
+ checked_at: datetime | None
76
+ source: str
77
+ url: str | None = None
78
+ error: str | None = None
79
+ from_cache: bool = False
80
+
81
+ @property
82
+ def update_available(self) -> bool:
83
+ if not self.latest_version:
84
+ return False
85
+ return _version_is_newer(self.latest_version, self.current_version)
86
+
87
+ @property
88
+ def up_to_date(self) -> bool:
89
+ if not self.latest_version or self.error:
90
+ return False
91
+ return not self.update_available
92
+
93
+ @property
94
+ def state(self) -> str:
95
+ if self.error:
96
+ return "error"
97
+ if self.update_available:
98
+ return "update_available"
99
+ if self.up_to_date:
100
+ return "current"
101
+ return "unknown"
102
+
103
+ def to_json(self) -> dict[str, Any]:
104
+ return {
105
+ "current_version": self.current_version,
106
+ "latest_version": self.latest_version,
107
+ "checked_at": self.checked_at.astimezone(UTC).isoformat() if self.checked_at else None,
108
+ "source": self.source,
109
+ "url": self.url,
110
+ "error": self.error,
111
+ "from_cache": self.from_cache,
112
+ "state": self.state,
113
+ "update_available": self.update_available,
114
+ }
115
+
116
+
117
+ @dataclass(frozen=True)
118
+ class UpdatePromptState:
119
+ """Per-user memory of the startup update prompt.
120
+
121
+ Lives next to ``update_check.json`` in the data dir (its own file, so the
122
+ background cache refresh can never wipe it).
123
+ """
124
+
125
+ skipped_version: str | None = None
126
+ last_prompted_version: str | None = None
127
+ last_prompted_at: datetime | None = None
128
+
129
+ def to_json(self) -> dict[str, Any]:
130
+ return {
131
+ "schema_version": UPDATE_PROMPT_STATE_SCHEMA_VERSION,
132
+ "package": PYTHON_PACKAGE_NAME,
133
+ "skipped_version": self.skipped_version,
134
+ "last_prompted_version": self.last_prompted_version,
135
+ "last_prompted_at": (
136
+ self.last_prompted_at.astimezone(UTC).isoformat() if self.last_prompted_at else None
137
+ ),
138
+ }
139
+
140
+
141
+ @dataclass(frozen=True)
142
+ class InstallerPlan:
143
+ method: str
144
+ supported: bool
145
+ command: tuple[str, ...] = ()
146
+ reason: str = ""
147
+
148
+ @property
149
+ def display_command(self) -> str:
150
+ if not self.command:
151
+ return ""
152
+ return shlex.join(self.command)
153
+
154
+
155
+ def update_cache_path() -> Path:
156
+ override = env_get("ALYSIS_UPDATE_CACHE_PATH")
157
+ if override:
158
+ return Path(override)
159
+ data_override = env_get("ALYSIS_DATA_DIR")
160
+ data_dir = Path(data_override) if data_override else canonical_user_data_dir()
161
+ return data_dir / "update_check.json"
162
+
163
+
164
+ def resolve_update_check_enabled(cfg: AppConfig | None) -> bool:
165
+ env_value = env_get("ALYSIS_UPDATE_CHECK_ENABLED")
166
+ if env_value is not None:
167
+ return _parse_bool(env_value, key="ALYSIS_UPDATE_CHECK_ENABLED")
168
+ if cfg is None:
169
+ return True
170
+ return bool(getattr(cfg, "update_check_enabled", True))
171
+
172
+
173
+ def resolve_update_check_interval_hours(cfg: AppConfig | None) -> int:
174
+ env_value = env_get("ALYSIS_UPDATE_CHECK_INTERVAL_HOURS")
175
+ if env_value is not None:
176
+ return _parse_positive_int(env_value, key="ALYSIS_UPDATE_CHECK_INTERVAL_HOURS")
177
+ if cfg is None:
178
+ return DEFAULT_UPDATE_CHECK_INTERVAL_HOURS
179
+ return _parse_positive_int(
180
+ getattr(cfg, "update_check_interval_hours", DEFAULT_UPDATE_CHECK_INTERVAL_HOURS),
181
+ key="update_check_interval_hours",
182
+ )
183
+
184
+
185
+ def resolve_update_check_timeout_s(cfg: AppConfig | None) -> float:
186
+ env_value = env_get("ALYSIS_UPDATE_CHECK_TIMEOUT_S")
187
+ if env_value is not None:
188
+ return _parse_positive_float(env_value, key="ALYSIS_UPDATE_CHECK_TIMEOUT_S")
189
+ if cfg is None:
190
+ return DEFAULT_UPDATE_CHECK_TIMEOUT_S
191
+ return _parse_positive_float(
192
+ getattr(cfg, "update_check_timeout_s", DEFAULT_UPDATE_CHECK_TIMEOUT_S),
193
+ key="update_check_timeout_s",
194
+ )
195
+
196
+
197
+ def read_update_cache(path: Path | None = None) -> UpdateCacheRecord | None:
198
+ cache_path = path or update_cache_path()
199
+ if not cache_path.exists():
200
+ return None
201
+ try:
202
+ if cache_path.stat().st_size > UPDATE_CACHE_MAX_BYTES:
203
+ return None
204
+ except OSError:
205
+ return None
206
+ try:
207
+ raw = json.loads(cache_path.read_text(encoding="utf-8"))
208
+ except Exception:
209
+ return None
210
+ if not isinstance(raw, dict):
211
+ return None
212
+ try:
213
+ schema_version = int(raw.get("schema_version") or 0)
214
+ except (TypeError, ValueError):
215
+ return None
216
+ if schema_version != UPDATE_CACHE_SCHEMA_VERSION:
217
+ return None
218
+ checked_at = _parse_datetime(raw.get("checked_at"))
219
+ if checked_at is None:
220
+ return None
221
+ package = str(raw.get("package") or "").strip()
222
+ if package != PYTHON_PACKAGE_NAME:
223
+ return None
224
+ return UpdateCacheRecord(
225
+ checked_at=checked_at,
226
+ package=package,
227
+ source=str(raw.get("source") or "pypi").strip() or "pypi",
228
+ latest_version=_optional_non_empty_string(raw.get("latest_version")),
229
+ url=_optional_non_empty_string(raw.get("url")),
230
+ error=_optional_non_empty_string(raw.get("error")),
231
+ )
232
+
233
+
234
+ def write_update_cache(record: UpdateCacheRecord, path: Path | None = None) -> None:
235
+ atomic_write_json(path or update_cache_path(), record.to_json())
236
+
237
+
238
+ def cache_is_fresh(
239
+ record: UpdateCacheRecord | None,
240
+ *,
241
+ cfg: AppConfig | None,
242
+ now: datetime | None = None,
243
+ ) -> bool:
244
+ if record is None:
245
+ return False
246
+ current = _utcnow() if now is None else _ensure_utc(now)
247
+ age = current - record.checked_at
248
+ return timedelta(0) <= age < timedelta(hours=resolve_update_check_interval_hours(cfg))
249
+
250
+
251
+ def status_from_cache(
252
+ *,
253
+ current_version: str,
254
+ cfg: AppConfig | None,
255
+ now: datetime | None = None,
256
+ path: Path | None = None,
257
+ ) -> UpdateStatus:
258
+ if not resolve_update_check_enabled(cfg):
259
+ return UpdateStatus(
260
+ current_version=current_version,
261
+ latest_version=None,
262
+ checked_at=None,
263
+ source="disabled",
264
+ error=None,
265
+ from_cache=True,
266
+ )
267
+ record = read_update_cache(path)
268
+ if record is None:
269
+ return UpdateStatus(
270
+ current_version=current_version,
271
+ latest_version=None,
272
+ checked_at=None,
273
+ source="cache",
274
+ from_cache=True,
275
+ )
276
+ stale = not cache_is_fresh(record, cfg=cfg, now=now)
277
+ error = record.error
278
+ if stale and not record.latest_version:
279
+ error = error or "cached update check is stale"
280
+ return UpdateStatus(
281
+ current_version=current_version,
282
+ latest_version=record.latest_version,
283
+ checked_at=record.checked_at,
284
+ source=record.source,
285
+ url=record.url,
286
+ error=error,
287
+ from_cache=True,
288
+ )
289
+
290
+
291
+ def check_for_updates(
292
+ *,
293
+ current_version: str,
294
+ cfg: AppConfig | None,
295
+ force: bool = False,
296
+ now: datetime | None = None,
297
+ transport: httpx.AsyncBaseTransport | None = None,
298
+ cache_path: Path | None = None,
299
+ ) -> UpdateStatus:
300
+ current_time = _utcnow() if now is None else _ensure_utc(now)
301
+ if not force:
302
+ cached = read_update_cache(cache_path)
303
+ if cache_is_fresh(cached, cfg=cfg, now=current_time):
304
+ assert cached is not None
305
+ return UpdateStatus(
306
+ current_version=current_version,
307
+ latest_version=cached.latest_version,
308
+ checked_at=cached.checked_at,
309
+ source=cached.source,
310
+ url=cached.url,
311
+ error=cached.error,
312
+ from_cache=True,
313
+ )
314
+
315
+ try:
316
+ latest_version, url = fetch_latest_pypi_version(
317
+ current_version=current_version,
318
+ cfg=cfg,
319
+ transport=transport,
320
+ )
321
+ except ConfigError:
322
+ raise
323
+ except Exception as exc: # noqa: BLE001 - explicit update check should preserve readable errors
324
+ message = str(exc).strip() or exc.__class__.__name__
325
+ record = UpdateCacheRecord(
326
+ checked_at=current_time,
327
+ package=PYTHON_PACKAGE_NAME,
328
+ source="pypi",
329
+ error=message,
330
+ )
331
+ write_update_cache(record, cache_path)
332
+ return UpdateStatus(
333
+ current_version=current_version,
334
+ latest_version=None,
335
+ checked_at=current_time,
336
+ source="pypi",
337
+ error=message,
338
+ )
339
+
340
+ record = UpdateCacheRecord(
341
+ checked_at=current_time,
342
+ package=PYTHON_PACKAGE_NAME,
343
+ source="pypi",
344
+ latest_version=latest_version,
345
+ url=url,
346
+ )
347
+ write_update_cache(record, cache_path)
348
+ return UpdateStatus(
349
+ current_version=current_version,
350
+ latest_version=latest_version,
351
+ checked_at=current_time,
352
+ source="pypi",
353
+ url=url,
354
+ )
355
+
356
+
357
+ def maybe_refresh_update_cache_in_background(
358
+ *,
359
+ current_version: str,
360
+ cfg: AppConfig | None,
361
+ now: datetime | None = None,
362
+ path: Path | None = None,
363
+ thread_factory: Callable[..., Any] | None = None,
364
+ ) -> bool:
365
+ if not resolve_update_check_enabled(cfg):
366
+ return False
367
+ current_time = _utcnow() if now is None else _ensure_utc(now)
368
+ cached = read_update_cache(path)
369
+ if cache_is_fresh(cached, cfg=cfg, now=current_time):
370
+ return False
371
+
372
+ global _update_refresh_started
373
+ with _update_refresh_lock:
374
+ if _update_refresh_started:
375
+ return False
376
+ _update_refresh_started = True
377
+
378
+ def _refresh() -> None:
379
+ global _update_refresh_started
380
+ try:
381
+ check_for_updates(
382
+ current_version=current_version,
383
+ cfg=cfg,
384
+ force=True,
385
+ now=current_time,
386
+ cache_path=path,
387
+ )
388
+ except Exception:
389
+ pass
390
+ finally:
391
+ with _update_refresh_lock:
392
+ _update_refresh_started = False
393
+
394
+ factory = thread_factory or threading.Thread
395
+ try:
396
+ thread = factory(
397
+ target=_refresh,
398
+ name="alysis-update-check",
399
+ daemon=True,
400
+ )
401
+ thread.start()
402
+ except Exception:
403
+ with _update_refresh_lock:
404
+ _update_refresh_started = False
405
+ return False
406
+ return True
407
+
408
+
409
+ def fetch_latest_pypi_version(
410
+ *,
411
+ current_version: str,
412
+ cfg: AppConfig | None,
413
+ transport: httpx.AsyncBaseTransport | None = None,
414
+ ) -> tuple[str, str | None]:
415
+ timeout_s = resolve_update_check_timeout_s(cfg)
416
+ headers = {
417
+ "Accept": "application/json",
418
+ "User-Agent": f"{PYTHON_PACKAGE_NAME}/{current_version}",
419
+ }
420
+ try:
421
+ response = asyncio.run(
422
+ safe_http_request(
423
+ "GET",
424
+ PYPI_JSON_URL,
425
+ timeout=timeout_s,
426
+ max_bytes=UPDATE_CACHE_MAX_BYTES,
427
+ headers=headers,
428
+ _transport=transport,
429
+ )
430
+ )
431
+ except SafeHttpError as exc:
432
+ raise UpdateCheckError(f"update check blocked: {exc}") from exc
433
+ except httpx.TimeoutException as exc:
434
+ raise UpdateCheckError(f"update check timed out after {timeout_s:g}s") from exc
435
+ except Exception as exc: # noqa: BLE001
436
+ raise UpdateCheckError(f"update check failed: {exc}") from exc
437
+
438
+ if response.status_code == 404:
439
+ raise UpdateCheckError(f"No PyPI release found for {PYTHON_PACKAGE_NAME}.")
440
+ if response.status_code >= 400:
441
+ raise UpdateCheckError(f"PyPI update check failed with HTTP {response.status_code}")
442
+ try:
443
+ payload = response.json()
444
+ except Exception as exc: # noqa: BLE001
445
+ raise UpdateCheckError("PyPI update check returned non-JSON response") from exc
446
+ if not isinstance(payload, dict):
447
+ raise UpdateCheckError("PyPI update check returned unexpected payload")
448
+ info = payload.get("info")
449
+ if not isinstance(info, dict):
450
+ raise UpdateCheckError("PyPI update check payload is missing info")
451
+ latest_version = str(info.get("version") or "").strip()
452
+ if not latest_version:
453
+ raise UpdateCheckError("PyPI update check payload is missing latest version")
454
+ _parse_version(latest_version)
455
+ project_url = str(info.get("package_url") or "").strip() or None
456
+ if project_url is None:
457
+ project_urls = info.get("project_urls")
458
+ if isinstance(project_urls, dict):
459
+ project_url = str(
460
+ project_urls.get("Changelog") or project_urls.get("Source") or ""
461
+ ).strip()
462
+ project_url = project_url or None
463
+ return latest_version, project_url
464
+
465
+
466
+ def passive_update_notice(
467
+ *,
468
+ current_version: str,
469
+ cfg: AppConfig | None,
470
+ now: datetime | None = None,
471
+ path: Path | None = None,
472
+ ) -> str | None:
473
+ status = status_from_cache(current_version=current_version, cfg=cfg, now=now, path=path)
474
+ if not status.update_available or not status.latest_version:
475
+ return None
476
+ return (
477
+ f"Alysis Code {status.latest_version} is available; you have {status.current_version}. "
478
+ "Run `alysis update`."
479
+ )
480
+
481
+
482
+ def update_prompt_state_path() -> Path:
483
+ override = env_get("ALYSIS_UPDATE_PROMPT_STATE_PATH")
484
+ if override:
485
+ return Path(override)
486
+ data_override = env_get("ALYSIS_DATA_DIR")
487
+ data_dir = Path(data_override) if data_override else canonical_user_data_dir()
488
+ return data_dir / "update_prompt.json"
489
+
490
+
491
+ def resolve_update_prompt_enabled(cfg: AppConfig | None) -> bool:
492
+ env_value = env_get("ALYSIS_UPDATE_PROMPT_ENABLED")
493
+ if env_value is not None:
494
+ return _parse_bool(env_value, key="ALYSIS_UPDATE_PROMPT_ENABLED")
495
+ if cfg is None:
496
+ return True
497
+ return bool(getattr(cfg, "update_prompt_enabled", True))
498
+
499
+
500
+ def read_update_prompt_state(path: Path | None = None) -> UpdatePromptState:
501
+ state_path = path or update_prompt_state_path()
502
+ if not state_path.exists():
503
+ return UpdatePromptState()
504
+ try:
505
+ if state_path.stat().st_size > UPDATE_CACHE_MAX_BYTES:
506
+ return UpdatePromptState()
507
+ except OSError:
508
+ return UpdatePromptState()
509
+ try:
510
+ raw = json.loads(state_path.read_text(encoding="utf-8"))
511
+ except Exception:
512
+ return UpdatePromptState()
513
+ if not isinstance(raw, dict):
514
+ return UpdatePromptState()
515
+ try:
516
+ schema_version = int(raw.get("schema_version") or 0)
517
+ except (TypeError, ValueError):
518
+ return UpdatePromptState()
519
+ if schema_version != UPDATE_PROMPT_STATE_SCHEMA_VERSION:
520
+ return UpdatePromptState()
521
+ package = str(raw.get("package") or "").strip()
522
+ if package != PYTHON_PACKAGE_NAME:
523
+ return UpdatePromptState()
524
+ return UpdatePromptState(
525
+ skipped_version=_optional_non_empty_string(raw.get("skipped_version")),
526
+ last_prompted_version=_optional_non_empty_string(raw.get("last_prompted_version")),
527
+ last_prompted_at=_parse_datetime(raw.get("last_prompted_at")),
528
+ )
529
+
530
+
531
+ def write_update_prompt_state(state: UpdatePromptState, path: Path | None = None) -> None:
532
+ atomic_write_json(path or update_prompt_state_path(), state.to_json())
533
+
534
+
535
+ def record_update_prompt_shown(
536
+ latest_version: str,
537
+ *,
538
+ path: Path | None = None,
539
+ now: datetime | None = None,
540
+ ) -> None:
541
+ state = read_update_prompt_state(path)
542
+ write_update_prompt_state(
543
+ UpdatePromptState(
544
+ skipped_version=state.skipped_version,
545
+ last_prompted_version=latest_version,
546
+ last_prompted_at=_utcnow() if now is None else _ensure_utc(now),
547
+ ),
548
+ path,
549
+ )
550
+
551
+
552
+ def record_update_skipped(
553
+ latest_version: str,
554
+ *,
555
+ path: Path | None = None,
556
+ now: datetime | None = None,
557
+ ) -> None:
558
+ state = read_update_prompt_state(path)
559
+ write_update_prompt_state(
560
+ UpdatePromptState(
561
+ skipped_version=latest_version,
562
+ last_prompted_version=state.last_prompted_version,
563
+ last_prompted_at=state.last_prompted_at,
564
+ ),
565
+ path,
566
+ )
567
+
568
+
569
+ def should_prompt_for_update(
570
+ *,
571
+ status: UpdateStatus,
572
+ state: UpdatePromptState,
573
+ cfg: AppConfig | None,
574
+ now: datetime | None = None,
575
+ ) -> bool:
576
+ """Pure gate for the startup update prompt.
577
+
578
+ True only when a newer release is known from the cache and the user has
579
+ neither skipped that release nor been prompted about it within the snooze
580
+ window.
581
+ """
582
+ if not resolve_update_check_enabled(cfg):
583
+ return False
584
+ if not resolve_update_prompt_enabled(cfg):
585
+ return False
586
+ if not status.update_available or not status.latest_version:
587
+ return False
588
+ latest = status.latest_version
589
+ if state.skipped_version == latest:
590
+ return False
591
+ if state.last_prompted_version == latest and state.last_prompted_at is not None:
592
+ current = _utcnow() if now is None else _ensure_utc(now)
593
+ age = current - state.last_prompted_at
594
+ if timedelta(0) <= age < timedelta(hours=DEFAULT_UPDATE_PROMPT_SNOOZE_HOURS):
595
+ return False
596
+ return True
597
+
598
+
599
+ def installed_distribution_name(preferred: str = PYTHON_PACKAGE_NAME) -> str:
600
+ """Return whichever distribution is actually installed.
601
+
602
+ An install predating the rename is registered as ``sylliptor-agent-cli``.
603
+ Introspection keyed on the new name would find nothing and report the
604
+ package as missing.
605
+ """
606
+ for name in (preferred, LEGACY_PYTHON_PACKAGE_NAME):
607
+ try:
608
+ importlib_metadata.distribution(name)
609
+ except importlib_metadata.PackageNotFoundError:
610
+ continue
611
+ return name
612
+ return preferred
613
+
614
+
615
+ def detect_installer_plan(
616
+ *,
617
+ package_name: str = PYTHON_PACKAGE_NAME,
618
+ executable: str | None = None,
619
+ prefix: str | None = None,
620
+ base_prefix: str | None = None,
621
+ env: Mapping[str, str] | None = None,
622
+ ) -> InstallerPlan:
623
+ executable_value = executable or sys.executable
624
+ prefix_value = prefix or sys.prefix
625
+ base_prefix_value = base_prefix or getattr(sys, "base_prefix", sys.prefix)
626
+ env_map = env or os.environ
627
+
628
+ # Introspect the distribution that is really on disk, but always upgrade
629
+ # *to* the current package. Upgrading the legacy name in place would keep
630
+ # the user on a package that no longer receives releases; upgrading the new
631
+ # name without replacing the old one would leave two copies fighting over
632
+ # the same console script.
633
+ installed_name = installed_distribution_name(package_name)
634
+ if installed_name != package_name:
635
+ return _legacy_distribution_plan(
636
+ installed_name=installed_name,
637
+ target_name=package_name,
638
+ executable=executable_value,
639
+ prefix=prefix_value,
640
+ env=env_map,
641
+ )
642
+
643
+ editable_reason = _editable_install_reason(package_name)
644
+ if editable_reason:
645
+ return InstallerPlan(
646
+ method="editable",
647
+ supported=False,
648
+ reason=editable_reason,
649
+ )
650
+
651
+ if _looks_like_pipx_install(prefix_value, executable_value, package_name, env_map):
652
+ if shutil.which("pipx") is None:
653
+ return InstallerPlan(
654
+ method="pipx",
655
+ supported=False,
656
+ reason="This looks like a pipx install, but `pipx` is not on PATH.",
657
+ )
658
+ return InstallerPlan(
659
+ method="pipx",
660
+ supported=True,
661
+ command=("pipx", "upgrade", package_name),
662
+ reason="Detected a pipx-managed virtual environment.",
663
+ )
664
+
665
+ installer = _distribution_installer(package_name)
666
+ if installer == "uv":
667
+ return _uv_installer_plan(
668
+ package_name=package_name,
669
+ executable=executable_value,
670
+ prefix=prefix_value,
671
+ env=env_map,
672
+ )
673
+
674
+ command = (executable_value, "-m", "pip", "install", "--upgrade", package_name)
675
+ if prefix_value != base_prefix_value:
676
+ return InstallerPlan(
677
+ method="venv-pip",
678
+ supported=True,
679
+ command=command,
680
+ reason="Detected a Python virtual environment.",
681
+ )
682
+
683
+ if installer and installer != "pip":
684
+ return InstallerPlan(
685
+ method=installer,
686
+ supported=False,
687
+ reason=f"This install reports installer `{installer}`; update it with that tool.",
688
+ )
689
+
690
+ return InstallerPlan(
691
+ method="pip",
692
+ supported=True,
693
+ command=command,
694
+ reason="Detected a pip-style Python install.",
695
+ )
696
+
697
+
698
+ def _uv_installer_plan(
699
+ *,
700
+ package_name: str,
701
+ executable: str,
702
+ prefix: str,
703
+ env: Mapping[str, str],
704
+ ) -> InstallerPlan:
705
+ if shutil.which("uv") is None:
706
+ return InstallerPlan(
707
+ method="uv",
708
+ supported=False,
709
+ reason="This install reports installer `uv`, but `uv` is not on PATH.",
710
+ )
711
+ if _looks_like_uv_tool_install(prefix, package_name, env):
712
+ return InstallerPlan(
713
+ method="uv-tool",
714
+ supported=True,
715
+ command=("uv", "tool", "upgrade", package_name),
716
+ reason="Detected a uv-managed tool install.",
717
+ )
718
+ return InstallerPlan(
719
+ method="uv-pip",
720
+ supported=True,
721
+ command=("uv", "pip", "install", "--python", executable, "--upgrade", package_name),
722
+ reason="Detected a uv-managed Python environment.",
723
+ )
724
+
725
+
726
+ def run_installer_plan(
727
+ plan: InstallerPlan,
728
+ *,
729
+ runner: Callable[..., subprocess.CompletedProcess[Any]] = subprocess.run,
730
+ ) -> int:
731
+ if not plan.supported or not plan.command:
732
+ raise UpdateCheckError(plan.reason or "No supported update command is available.")
733
+ completed = runner(list(plan.command), check=False)
734
+ return int(getattr(completed, "returncode", 0) or 0)
735
+
736
+
737
+ def _looks_like_pipx_install(
738
+ prefix: str,
739
+ executable: str,
740
+ package_name: str,
741
+ env: Mapping[str, str],
742
+ ) -> bool:
743
+ package_variants = {_normalize_package_name(package_name), "alysis"}
744
+ for raw_path in (prefix, executable):
745
+ parts = [part.casefold() for part in Path(raw_path).parts]
746
+ for index, part in enumerate(parts):
747
+ if part != "pipx":
748
+ continue
749
+ if index + 2 < len(parts) and parts[index + 1] == "venvs":
750
+ return _normalize_package_name(parts[index + 2]) in package_variants
751
+ pipx_home = str(env.get("PIPX_HOME") or "").strip()
752
+ return bool(pipx_home and str(Path(prefix)).startswith(os.fspath(Path(pipx_home))))
753
+
754
+
755
+ def _looks_like_uv_tool_install(prefix: str, package_name: str, env: Mapping[str, str]) -> bool:
756
+ package_variants = {_normalize_package_name(package_name), "alysis"}
757
+ prefix_path = Path(prefix)
758
+ for configured_root in _uv_tool_roots(env):
759
+ try:
760
+ relative = prefix_path.resolve().relative_to(configured_root.resolve())
761
+ except (OSError, ValueError):
762
+ continue
763
+ first_part = relative.parts[0] if relative.parts else ""
764
+ if _normalize_package_name(first_part) in package_variants:
765
+ return True
766
+
767
+ parts = [part.casefold() for part in prefix_path.parts]
768
+ for index, part in enumerate(parts):
769
+ if part == "uv" and index + 2 < len(parts) and parts[index + 1] == "tools":
770
+ return _normalize_package_name(parts[index + 2]) in package_variants
771
+ return False
772
+
773
+
774
+ def _uv_tool_roots(env: Mapping[str, str]) -> tuple[Path, ...]:
775
+ raw_roots = [
776
+ str(env.get("UV_TOOL_DIR") or "").strip(),
777
+ os.fspath(Path.home() / ".local" / "share" / "uv" / "tools"),
778
+ ]
779
+ return tuple(Path(raw) for raw in raw_roots if raw)
780
+
781
+
782
+ def _legacy_distribution_plan(
783
+ *,
784
+ installed_name: str,
785
+ target_name: str,
786
+ executable: str,
787
+ prefix: str,
788
+ env: Mapping[str, str],
789
+ ) -> InstallerPlan:
790
+ """Plan the move from the pre-rebrand distribution to the current one."""
791
+ if _editable_install_reason(installed_name):
792
+ return InstallerPlan(
793
+ method="editable",
794
+ supported=False,
795
+ reason=(
796
+ f"This is an editable install of `{installed_name}`. Pull the renamed "
797
+ f"source and reinstall as `{target_name}`."
798
+ ),
799
+ )
800
+
801
+ if _looks_like_pipx_install(prefix, executable, installed_name, env):
802
+ if shutil.which("pipx") is None:
803
+ return InstallerPlan(
804
+ method="pipx",
805
+ supported=False,
806
+ reason="This looks like a pipx install, but `pipx` is not on PATH.",
807
+ )
808
+ # `pipx upgrade` cannot rename an app; the old one must go first or its
809
+ # `sylliptor` script would shadow the new package's alias.
810
+ return InstallerPlan(
811
+ method="pipx",
812
+ supported=False,
813
+ command=("pipx", "uninstall", installed_name),
814
+ reason=(
815
+ f"`{installed_name}` was renamed to `{target_name}`. Run "
816
+ f"`pipx uninstall {installed_name}` then `pipx install {target_name}`."
817
+ ),
818
+ )
819
+
820
+ return InstallerPlan(
821
+ method="venv-pip",
822
+ supported=False,
823
+ command=(executable, "-m", "pip", "install", "--upgrade", target_name),
824
+ reason=(
825
+ f"`{installed_name}` was renamed to `{target_name}`. Install the new package, "
826
+ f"then `pip uninstall {installed_name}` to remove the old one."
827
+ ),
828
+ )
829
+
830
+
831
+ def _editable_install_reason(package_name: str) -> str | None:
832
+ try:
833
+ dist = importlib_metadata.distribution(package_name)
834
+ except importlib_metadata.PackageNotFoundError:
835
+ return "Package metadata was not found; update from the source checkout or reinstall."
836
+ raw = dist.read_text("direct_url.json")
837
+ if not raw:
838
+ return None
839
+ try:
840
+ payload = json.loads(raw)
841
+ except json.JSONDecodeError:
842
+ return None
843
+ if not isinstance(payload, dict):
844
+ return None
845
+ dir_info = payload.get("dir_info")
846
+ if isinstance(dir_info, dict) and dir_info.get("editable") is True:
847
+ url = str(payload.get("url") or "").strip()
848
+ suffix = f" ({url})" if url else ""
849
+ return f"This is an editable/source install{suffix}; update the checkout manually."
850
+ return None
851
+
852
+
853
+ def _distribution_installer(package_name: str) -> str | None:
854
+ try:
855
+ dist = importlib_metadata.distribution(package_name)
856
+ except importlib_metadata.PackageNotFoundError:
857
+ return None
858
+ raw = dist.read_text("INSTALLER")
859
+ value = str(raw or "").strip().lower()
860
+ return value or None
861
+
862
+
863
+ def _normalize_package_name(value: str) -> str:
864
+ return str(value or "").strip().replace("_", "-").casefold()
865
+
866
+
867
+ def _version_is_newer(latest: str, current: str) -> bool:
868
+ try:
869
+ return _parse_version(latest) > _parse_version(current)
870
+ except InvalidVersion:
871
+ return False
872
+
873
+
874
+ def _parse_version(value: str) -> Version:
875
+ try:
876
+ return Version(str(value or "").strip())
877
+ except InvalidVersion as exc:
878
+ raise UpdateCheckError(f"Invalid package version: {value!r}") from exc
879
+
880
+
881
+ def _parse_bool(value: object, *, key: str) -> bool:
882
+ normalized = str(value).strip().lower()
883
+ if normalized in {"1", "true", "yes", "on"}:
884
+ return True
885
+ if normalized in {"0", "false", "no", "off"}:
886
+ return False
887
+ raise ConfigError(f"{key} must be true/false")
888
+
889
+
890
+ def _parse_positive_int(value: object, *, key: str) -> int:
891
+ try:
892
+ parsed = int(value)
893
+ except (TypeError, ValueError) as exc:
894
+ raise ConfigError(f"{key} must be an integer") from exc
895
+ if parsed <= 0:
896
+ raise ConfigError(f"{key} must be > 0")
897
+ return parsed
898
+
899
+
900
+ def _parse_positive_float(value: object, *, key: str) -> float:
901
+ try:
902
+ parsed = float(value)
903
+ except (TypeError, ValueError) as exc:
904
+ raise ConfigError(f"{key} must be a number") from exc
905
+ if parsed <= 0:
906
+ raise ConfigError(f"{key} must be > 0")
907
+ return parsed
908
+
909
+
910
+ def _optional_non_empty_string(value: object) -> str | None:
911
+ text = str(value or "").strip()
912
+ return text or None
913
+
914
+
915
+ def _parse_datetime(value: object) -> datetime | None:
916
+ text = str(value or "").strip()
917
+ if not text:
918
+ return None
919
+ try:
920
+ parsed = datetime.fromisoformat(text.replace("Z", "+00:00"))
921
+ except ValueError:
922
+ return None
923
+ return _ensure_utc(parsed)
924
+
925
+
926
+ def _ensure_utc(value: datetime) -> datetime:
927
+ if value.tzinfo is None:
928
+ return value.replace(tzinfo=UTC)
929
+ return value.astimezone(UTC)
930
+
931
+
932
+ def _utcnow() -> datetime:
933
+ return datetime.now(UTC)