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,563 @@
1
+ from __future__ import annotations
2
+
3
+ import fnmatch
4
+ import json
5
+ import os
6
+ import queue
7
+ import re
8
+ import subprocess
9
+ import threading
10
+ import time
11
+ from collections import defaultdict
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+
16
+ class SearchError(RuntimeError):
17
+ pass
18
+
19
+
20
+ _RG_NO_CONFIG_SUPPORTED: bool | None = None
21
+ _SEARCH_RG_MAX_MATCHES_PER_FILE = 8
22
+ _SEARCH_RG_MAX_MATCH_TEXT_CHARS = 240
23
+ _SEARCH_RG_MAX_OUTPUT_CHARS = 12_000
24
+ _SEARCH_RG_MAX_CONTEXT_LINES = 5
25
+ _SEARCH_RG_MAX_RESULTS = 500
26
+ _RG_PROBE_TIMEOUT_S = 2.0
27
+ _SEARCH_RG_TIMEOUT_S = 15.0
28
+
29
+
30
+ def _rg_available() -> bool:
31
+ try:
32
+ cp = subprocess.run(
33
+ ["rg", "--version"],
34
+ check=False,
35
+ capture_output=True,
36
+ text=True,
37
+ timeout=_RG_PROBE_TIMEOUT_S,
38
+ )
39
+ except (OSError, subprocess.TimeoutExpired):
40
+ return False
41
+ return cp.returncode == 0
42
+
43
+
44
+ def _rg_supports_no_config() -> bool:
45
+ global _RG_NO_CONFIG_SUPPORTED
46
+ if _RG_NO_CONFIG_SUPPORTED is not None:
47
+ return _RG_NO_CONFIG_SUPPORTED
48
+ try:
49
+ cp = subprocess.run(
50
+ ["rg", "--no-config", "--version"],
51
+ check=False,
52
+ capture_output=True,
53
+ text=True,
54
+ timeout=_RG_PROBE_TIMEOUT_S,
55
+ )
56
+ except (OSError, subprocess.TimeoutExpired):
57
+ _RG_NO_CONFIG_SUPPORTED = False
58
+ return False
59
+ _RG_NO_CONFIG_SUPPORTED = cp.returncode == 0
60
+ return _RG_NO_CONFIG_SUPPORTED
61
+
62
+
63
+ def _clip_match_text(
64
+ text: str, *, max_chars: int = _SEARCH_RG_MAX_MATCH_TEXT_CHARS
65
+ ) -> tuple[str, bool]:
66
+ normalized = text.rstrip("\n")
67
+ if len(normalized) <= max_chars:
68
+ return normalized, False
69
+ return normalized[:max_chars].rstrip() + "...(truncated)", True
70
+
71
+
72
+ def _rel_path(root: Path, path: Path) -> str:
73
+ try:
74
+ rel = path.resolve().relative_to(root.resolve())
75
+ except ValueError:
76
+ return os.fspath(path).replace("\\", "/")
77
+ return os.fspath(rel).replace("\\", "/")
78
+
79
+
80
+ def _clean_globs(globs: list[str] | None) -> list[str] | None:
81
+ cleaned = [str(pattern) for pattern in (globs or []) if str(pattern).strip()]
82
+ return cleaned or None
83
+
84
+
85
+ def _matches_globs(rel_path: str, globs: list[str] | None) -> bool:
86
+ if not globs:
87
+ return True
88
+ return any(fnmatch.fnmatchcase(rel_path, pattern) for pattern in globs)
89
+
90
+
91
+ def _is_hidden_relpath(rel_path: str) -> bool:
92
+ return any(part.startswith(".") and part not in {"", ".", ".."} for part in rel_path.split("/"))
93
+
94
+
95
+ def _iter_candidate_files(
96
+ *,
97
+ root: Path,
98
+ base: Path,
99
+ globs: list[str] | None,
100
+ include_hidden: bool,
101
+ ):
102
+ if base.is_file():
103
+ # ripgrep does not apply --glob filtering to an explicitly targeted file path
104
+ yield base
105
+ return
106
+
107
+ for current_root, dirnames, filenames in os.walk(base):
108
+ if not include_hidden:
109
+ dirnames[:] = sorted(dirname for dirname in dirnames if not dirname.startswith("."))
110
+ else:
111
+ dirnames[:] = sorted(dirnames)
112
+ for filename in sorted(filenames):
113
+ path = Path(current_root) / filename
114
+ if not path.is_file():
115
+ continue
116
+ rel = _rel_path(root, path)
117
+ if not include_hidden and _is_hidden_relpath(rel):
118
+ continue
119
+ base_rel = _rel_path(base, path)
120
+ if _matches_globs(base_rel, globs):
121
+ yield path
122
+
123
+
124
+ def _context_entries_from_lines(
125
+ lines: list[str],
126
+ *,
127
+ line_number: int,
128
+ before_context: int,
129
+ after_context: int,
130
+ ) -> tuple[list[dict[str, Any]], list[dict[str, Any]], bool]:
131
+ before: list[dict[str, Any]] = []
132
+ after: list[dict[str, Any]] = []
133
+ text_truncated = False
134
+ line_index = min(max(0, line_number - 1), len(lines))
135
+
136
+ before_start = max(0, line_index - before_context)
137
+ for index in range(before_start, line_index):
138
+ clipped_text, clipped = _clip_match_text(lines[index])
139
+ before.append({"line": index + 1, "text": clipped_text})
140
+ text_truncated = text_truncated or clipped
141
+
142
+ after_end = min(len(lines), line_index + 1 + after_context)
143
+ for index in range(line_index + 1, after_end):
144
+ clipped_text, clipped = _clip_match_text(lines[index])
145
+ after.append({"line": index + 1, "text": clipped_text})
146
+ text_truncated = text_truncated or clipped
147
+
148
+ return before, after, text_truncated
149
+
150
+
151
+ def _safe_bounded_int(value: Any, *, default: int, minimum: int, maximum: int) -> int:
152
+ try:
153
+ parsed = int(value)
154
+ except (TypeError, ValueError):
155
+ parsed = default
156
+ return max(minimum, min(parsed, maximum))
157
+
158
+
159
+ def _read_context_lines(
160
+ *,
161
+ root: Path,
162
+ path: Path,
163
+ cache: dict[str, list[str] | None],
164
+ ) -> list[str] | None:
165
+ try:
166
+ resolved = path.resolve()
167
+ resolved.relative_to(root)
168
+ except (OSError, ValueError):
169
+ return None
170
+ key = os.fspath(resolved)
171
+ if key in cache:
172
+ return cache[key]
173
+ try:
174
+ if resolved.stat().st_size > 1024 * 1024:
175
+ cache[key] = None
176
+ return None
177
+ cache[key] = resolved.read_text(encoding="utf-8", errors="replace").splitlines()
178
+ except OSError:
179
+ cache[key] = None
180
+ return cache[key]
181
+
182
+
183
+ def _compile_python_matcher(*, pattern: str, literal: bool, case_sensitive: bool):
184
+ if literal:
185
+ needle = pattern if case_sensitive else pattern.casefold()
186
+
187
+ def _literal_matches(line: str) -> bool:
188
+ haystack = line if case_sensitive else line.casefold()
189
+ return needle in haystack
190
+
191
+ return _literal_matches
192
+
193
+ flags = 0 if case_sensitive else re.IGNORECASE
194
+ try:
195
+ rx = re.compile(pattern, flags=flags)
196
+ except re.error as e:
197
+ raise SearchError(f"Invalid regex: {e}") from e
198
+
199
+ return rx.search
200
+
201
+
202
+ def _rg_output_path_to_context_path(*, raw_path: str, root: Path) -> Path:
203
+ path = Path(raw_path)
204
+ if path.is_absolute():
205
+ return path
206
+ return (root / path).resolve()
207
+
208
+
209
+ def _rg_rel_path(*, raw_path: str, root: Path) -> str:
210
+ path = Path(raw_path)
211
+ target = path if path.is_absolute() else root / path
212
+ try:
213
+ return os.fspath(target.resolve().relative_to(root)).replace("\\", "/")
214
+ except (OSError, ValueError):
215
+ return raw_path.replace("\\", "/")
216
+
217
+
218
+ def _queue_text_stream_lines(
219
+ stream: Any,
220
+ output_queue: queue.Queue[str | object],
221
+ done_sentinel: object,
222
+ ) -> None:
223
+ try:
224
+ for line in iter(stream.readline, ""):
225
+ output_queue.put(line)
226
+ finally:
227
+ try:
228
+ stream.close()
229
+ except Exception:
230
+ pass
231
+ output_queue.put(done_sentinel)
232
+
233
+
234
+ def _collect_text_stream(stream: Any, collector: list[str]) -> None:
235
+ try:
236
+ for line in iter(stream.readline, ""):
237
+ collector.append(line)
238
+ finally:
239
+ try:
240
+ stream.close()
241
+ except Exception:
242
+ pass
243
+
244
+
245
+ def _stop_process(process: Any) -> int:
246
+ try:
247
+ process.terminate()
248
+ except OSError:
249
+ pass
250
+ try:
251
+ return int(process.wait(timeout=0.5))
252
+ except subprocess.TimeoutExpired:
253
+ try:
254
+ process.kill()
255
+ except OSError:
256
+ pass
257
+ return int(process.wait())
258
+
259
+
260
+ def search_rg(
261
+ *,
262
+ root: Path,
263
+ pattern: str,
264
+ root_path: str = ".",
265
+ globs: list[str] | None = None,
266
+ max_results: int = 200,
267
+ before_context: int = 0,
268
+ after_context: int = 0,
269
+ literal: bool = False,
270
+ case_sensitive: bool = True,
271
+ include_hidden: bool = False,
272
+ ) -> dict[str, Any]:
273
+ root_abs = root.resolve()
274
+ base = (root_abs / root_path).resolve()
275
+ try:
276
+ base.relative_to(root_abs)
277
+ except ValueError as e:
278
+ raise SearchError(f"root_path escapes root: {root_path}") from e
279
+ if pattern == "":
280
+ raise SearchError("pattern must be a non-empty string")
281
+
282
+ safe_max_results = _safe_bounded_int(
283
+ max_results,
284
+ default=200,
285
+ minimum=1,
286
+ maximum=_SEARCH_RG_MAX_RESULTS,
287
+ )
288
+ safe_before_context = _safe_bounded_int(
289
+ before_context,
290
+ default=0,
291
+ minimum=0,
292
+ maximum=_SEARCH_RG_MAX_CONTEXT_LINES,
293
+ )
294
+ safe_after_context = _safe_bounded_int(
295
+ after_context,
296
+ default=0,
297
+ minimum=0,
298
+ maximum=_SEARCH_RG_MAX_CONTEXT_LINES,
299
+ )
300
+ cleaned_globs = _clean_globs(globs)
301
+ matches: list[dict[str, Any]] = []
302
+ per_file_match_counts: dict[str, int] = defaultdict(int)
303
+ context_cache: dict[str, list[str] | None] = {}
304
+ output_chars = 0
305
+ truncated = False
306
+ per_file_truncated = False
307
+ match_text_truncated = False
308
+ context_text_truncated = False
309
+
310
+ def _timeout_message(*, backend: str) -> str:
311
+ return (
312
+ f"{backend} search timed out after {_SEARCH_RG_TIMEOUT_S:g}s; "
313
+ "try a narrower root_path, globs, or pattern"
314
+ )
315
+
316
+ def _append_match(
317
+ *,
318
+ path: str,
319
+ line_number: int,
320
+ line_text: str,
321
+ context_lines: list[str] | None = None,
322
+ ) -> str:
323
+ nonlocal output_chars, truncated, per_file_truncated, match_text_truncated
324
+ nonlocal context_text_truncated
325
+ if per_file_match_counts[path] >= _SEARCH_RG_MAX_MATCHES_PER_FILE:
326
+ truncated = True
327
+ per_file_truncated = True
328
+ return "per_file_limit"
329
+ clipped_text, clipped = _clip_match_text(line_text)
330
+ entry = {
331
+ "path": path,
332
+ "line": int(line_number),
333
+ "text": clipped_text,
334
+ }
335
+ if clipped:
336
+ entry["text_truncated"] = True
337
+ match_text_truncated = True
338
+ if context_lines is not None and (safe_before_context or safe_after_context):
339
+ before, after, context_clipped = _context_entries_from_lines(
340
+ context_lines,
341
+ line_number=int(line_number),
342
+ before_context=safe_before_context,
343
+ after_context=safe_after_context,
344
+ )
345
+ if before:
346
+ entry["before_context"] = before
347
+ if after:
348
+ entry["after_context"] = after
349
+ context_text_truncated = context_text_truncated or context_clipped
350
+ entry_chars = len(json.dumps(entry, ensure_ascii=True, separators=(",", ":")))
351
+ if len(matches) >= safe_max_results:
352
+ truncated = True
353
+ return "max_results"
354
+ if matches and (output_chars + entry_chars) > _SEARCH_RG_MAX_OUTPUT_CHARS:
355
+ truncated = True
356
+ return "output_cap"
357
+ matches.append(entry)
358
+ per_file_match_counts[path] += 1
359
+ output_chars += entry_chars
360
+ return "appended"
361
+
362
+ if _rg_available():
363
+ cmd = ["rg"]
364
+ if _rg_supports_no_config():
365
+ cmd.append("--no-config")
366
+ if literal:
367
+ cmd.append("--fixed-strings")
368
+ if not case_sensitive:
369
+ cmd.append("--ignore-case")
370
+ if include_hidden:
371
+ cmd.append("--hidden")
372
+ cmd.extend(["--glob", "!.git/**"])
373
+ cmd.extend(
374
+ [
375
+ "--json",
376
+ "--line-buffered",
377
+ "-e",
378
+ pattern,
379
+ ]
380
+ )
381
+ if cleaned_globs:
382
+ for g in cleaned_globs:
383
+ cmd.extend(["--glob", g])
384
+ cmd.append(os.fspath(base))
385
+ try:
386
+ proc = subprocess.Popen(
387
+ cmd,
388
+ stdout=subprocess.PIPE,
389
+ stderr=subprocess.PIPE,
390
+ text=True,
391
+ bufsize=1,
392
+ )
393
+ except OSError as e:
394
+ raise SearchError("Failed to execute ripgrep") from e
395
+
396
+ if proc.stdout is None or proc.stderr is None:
397
+ raise SearchError("Failed to capture ripgrep output")
398
+
399
+ stdout_queue: queue.Queue[str | object] = queue.Queue()
400
+ stdout_done = object()
401
+ stderr_lines: list[str] = []
402
+ stdout_thread = threading.Thread(
403
+ target=_queue_text_stream_lines,
404
+ args=(proc.stdout, stdout_queue, stdout_done),
405
+ daemon=True,
406
+ )
407
+ stderr_thread = threading.Thread(
408
+ target=_collect_text_stream,
409
+ args=(proc.stderr, stderr_lines),
410
+ daemon=True,
411
+ )
412
+ stdout_thread.start()
413
+ stderr_thread.start()
414
+ deadline = time.monotonic() + _SEARCH_RG_TIMEOUT_S
415
+ stdout_finished = False
416
+ terminated_early = False
417
+
418
+ while not stdout_finished:
419
+ remaining = deadline - time.monotonic()
420
+ if remaining <= 0:
421
+ _stop_process(proc)
422
+ stdout_thread.join(timeout=0.5)
423
+ stderr_thread.join(timeout=0.5)
424
+ raise SearchError(_timeout_message(backend="ripgrep"))
425
+ try:
426
+ line = stdout_queue.get(timeout=min(0.1, remaining))
427
+ except queue.Empty:
428
+ if proc.poll() is not None and not stdout_thread.is_alive():
429
+ stdout_finished = True
430
+ continue
431
+ if line is stdout_done:
432
+ stdout_finished = True
433
+ continue
434
+ try:
435
+ obj = json.loads(line)
436
+ except json.JSONDecodeError:
437
+ continue
438
+ if obj.get("type") != "match":
439
+ continue
440
+ data = obj.get("data") or {}
441
+ path = ((data.get("path") or {}).get("text")) or ""
442
+ line_number = data.get("line_number")
443
+ lines = ((data.get("lines") or {}).get("text")) or ""
444
+ if not path or not line_number:
445
+ continue
446
+ rel = _rg_rel_path(raw_path=path, root=root_abs)
447
+ context_lines = None
448
+ if safe_before_context or safe_after_context:
449
+ context_lines = _read_context_lines(
450
+ root=root_abs,
451
+ path=_rg_output_path_to_context_path(raw_path=path, root=root_abs),
452
+ cache=context_cache,
453
+ )
454
+ append_status = _append_match(
455
+ path=rel,
456
+ line_number=int(line_number),
457
+ line_text=lines,
458
+ context_lines=context_lines,
459
+ )
460
+ if append_status == "max_results":
461
+ terminated_early = True
462
+ break
463
+ if append_status == "output_cap":
464
+ terminated_early = True
465
+ break
466
+
467
+ returncode = _stop_process(proc) if terminated_early else int(proc.wait())
468
+ stdout_thread.join(timeout=0.5)
469
+ stderr_thread.join(timeout=0.5)
470
+ stderr_text = "".join(stderr_lines).strip()
471
+ if not terminated_early and returncode not in (0, 1): # 1 means no matches
472
+ raise SearchError(stderr_text or f"rg failed with exit code {returncode}")
473
+
474
+ return {
475
+ "pattern": pattern,
476
+ "matches": matches,
477
+ "backend": "rg",
478
+ "truncated": truncated,
479
+ "per_file_truncated": per_file_truncated,
480
+ "match_text_truncated": match_text_truncated,
481
+ "context_text_truncated": context_text_truncated,
482
+ "returned_matches": len(matches),
483
+ "literal": bool(literal),
484
+ "case_sensitive": bool(case_sensitive),
485
+ "include_hidden": bool(include_hidden),
486
+ "before_context": safe_before_context,
487
+ "after_context": safe_after_context,
488
+ }
489
+
490
+ matcher = _compile_python_matcher(
491
+ pattern=pattern,
492
+ literal=bool(literal),
493
+ case_sensitive=bool(case_sensitive),
494
+ )
495
+
496
+ deadline = time.monotonic() + _SEARCH_RG_TIMEOUT_S
497
+
498
+ def _raise_if_python_timeout() -> None:
499
+ if time.monotonic() >= deadline:
500
+ raise SearchError(_timeout_message(backend="python fallback"))
501
+
502
+ for p in _iter_candidate_files(
503
+ root=root_abs,
504
+ base=base,
505
+ globs=cleaned_globs,
506
+ include_hidden=bool(include_hidden),
507
+ ):
508
+ _raise_if_python_timeout()
509
+ if len(matches) >= safe_max_results:
510
+ truncated = True
511
+ break
512
+ # Skip large files.
513
+ try:
514
+ if p.stat().st_size > 512 * 1024:
515
+ continue
516
+ except OSError:
517
+ continue
518
+ try:
519
+ text = p.read_text(encoding="utf-8", errors="ignore")
520
+ except OSError:
521
+ continue
522
+ stop_search = False
523
+ source_lines = text.splitlines()
524
+ for i, line in enumerate(source_lines, start=1):
525
+ _raise_if_python_timeout()
526
+ if len(matches) >= safe_max_results:
527
+ truncated = True
528
+ stop_search = True
529
+ break
530
+ if matcher(line):
531
+ rel = _rel_path(root_abs, p)
532
+ append_status = _append_match(
533
+ path=rel,
534
+ line_number=i,
535
+ line_text=line,
536
+ context_lines=source_lines,
537
+ )
538
+ if append_status == "max_results":
539
+ stop_search = True
540
+ break
541
+ if append_status == "output_cap":
542
+ stop_search = True
543
+ break
544
+ if append_status == "per_file_limit":
545
+ break
546
+ if stop_search:
547
+ break
548
+
549
+ return {
550
+ "pattern": pattern,
551
+ "matches": matches,
552
+ "backend": "python",
553
+ "truncated": truncated,
554
+ "per_file_truncated": per_file_truncated,
555
+ "match_text_truncated": match_text_truncated,
556
+ "context_text_truncated": context_text_truncated,
557
+ "returned_matches": len(matches),
558
+ "literal": bool(literal),
559
+ "case_sensitive": bool(case_sensitive),
560
+ "include_hidden": bool(include_hidden),
561
+ "before_context": safe_before_context,
562
+ "after_context": safe_after_context,
563
+ }
@@ -0,0 +1,135 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import re
5
+ import subprocess
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from ..pipeline_facts import (
10
+ build_pipeline_status_capture_command,
11
+ command_has_top_level_pipe,
12
+ extract_pipeline_status,
13
+ pipeline_meaningful_stage,
14
+ split_top_level_pipeline,
15
+ )
16
+
17
+
18
+ class ShellError(RuntimeError):
19
+ pass
20
+
21
+
22
+ _PYTHON_CMD_PATTERN = re.compile(r"(^|(?:&&|\|\||;)\s*)python(?=\s)")
23
+
24
+
25
+ def _python3_fallback_cmd(cmd: str) -> str | None:
26
+ match = _PYTHON_CMD_PATTERN.search(cmd)
27
+ if not match:
28
+ return None
29
+ start, end = match.span()
30
+ prefix = match.group(1)
31
+ replacement = f"{prefix}python3"
32
+ return f"{cmd[:start]}{replacement}{cmd[end:]}"
33
+
34
+
35
+ def _is_python_permission_denied(stderr: str) -> bool:
36
+ lowered = stderr.lower()
37
+ return "python: permission denied" in lowered
38
+
39
+
40
+ def _pipeline_capture_platform_ok() -> bool:
41
+ # PIPESTATUS capture uses a POSIX-shell wrapper; it is unavailable under the
42
+ # Windows cmd.exe shell. (Kept as a separate function so tests can exercise
43
+ # the capture path on any host.)
44
+ return os.name != "nt"
45
+
46
+
47
+ def shell_run(
48
+ *,
49
+ root: Path,
50
+ cmd: str,
51
+ cwd: str | None = None,
52
+ timeout_s: float = 60,
53
+ runner: Any | None = None,
54
+ capture_pipeline_status: bool = False,
55
+ ) -> dict[str, Any]:
56
+ base = root.resolve()
57
+ if cwd:
58
+ cwd_path = (base / cwd).resolve()
59
+ try:
60
+ cwd_path.relative_to(base)
61
+ except ValueError as e:
62
+ raise ShellError(f"cwd escapes root: {cwd}") from e
63
+ else:
64
+ cwd_path = base
65
+
66
+ if runner is None:
67
+ raise ShellError("Shell runner is required; implicit host execution is disabled.")
68
+
69
+ # Observe per-stage pipeline exit codes (bash PIPESTATUS) only when it would
70
+ # matter and can be done safely: a top-level pipeline is present, on a POSIX
71
+ # host. The wrapper preserves stdout/stderr/exit-code exactly, so plain
72
+ # commands and non-capturing runs behave identically to before.
73
+ wants_capture = bool(
74
+ capture_pipeline_status
75
+ and _pipeline_capture_platform_ok()
76
+ and pipeline_meaningful_stage(cmd) is not None
77
+ )
78
+
79
+ def _run(command_text: str) -> tuple[Any, list[int] | None, str]:
80
+ run_cmd = (
81
+ build_pipeline_status_capture_command(command_text) if wants_capture else command_text
82
+ )
83
+ completed = runner.run(root=base, cwd=cwd_path, cmd=run_cmd, timeout_s=timeout_s)
84
+ if wants_capture:
85
+ status, cleaned_stderr = extract_pipeline_status(completed.stderr or "")
86
+ return completed, status, cleaned_stderr
87
+ return completed, None, (completed.stderr or "")
88
+
89
+ try:
90
+ cp, stage_status, stderr_text = _run(cmd)
91
+ except subprocess.TimeoutExpired as e:
92
+ raise ShellError(f"Command timed out after {timeout_s:g}s") from e
93
+ except Exception as e: # noqa: BLE001
94
+ raise ShellError(f"Failed to run command: {e}") from e
95
+
96
+ effective_cmd = cmd
97
+ fallback_cmd = _python3_fallback_cmd(cmd)
98
+ if fallback_cmd and cp.returncode != 0 and _is_python_permission_denied(stderr_text):
99
+ try:
100
+ cp, stage_status, stderr_text = _run(fallback_cmd)
101
+ effective_cmd = fallback_cmd
102
+ except subprocess.TimeoutExpired as e:
103
+ raise ShellError(f"Command timed out after {timeout_s:g}s") from e
104
+ except Exception as e: # noqa: BLE001
105
+ raise ShellError(f"Failed to run command: {e}") from e
106
+
107
+ stdout = cp.stdout or ""
108
+ stderr = stderr_text
109
+ truncated = False
110
+ limit = 20000
111
+ if len(stdout) > limit:
112
+ stdout = stdout[:limit] + "...(truncated)"
113
+ truncated = True
114
+ if len(stderr) > limit:
115
+ stderr = stderr[:limit] + "...(truncated)"
116
+ truncated = True
117
+
118
+ # A plain command (no top-level pipe) has a one-element status list equal to
119
+ # its exit code. A pipeline whose per-stage status could not be observed
120
+ # leaves the list as None so the classifier falls back to a single re-run.
121
+ if stage_status is None and not command_has_top_level_pipe(effective_cmd):
122
+ stage_status = [cp.returncode]
123
+ pipeline_stages = split_top_level_pipeline(effective_cmd) or [effective_cmd]
124
+
125
+ return {
126
+ "cmd": cmd,
127
+ "effective_cmd": effective_cmd,
128
+ "cwd": str(cwd_path),
129
+ "exit_code": cp.returncode,
130
+ "stdout": stdout,
131
+ "stderr": stderr,
132
+ "truncated": truncated,
133
+ "pipeline_stages": pipeline_stages,
134
+ "pipeline_stage_status": stage_status,
135
+ }