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,65 @@
1
+ """Per-turn controller intervention telemetry.
2
+
3
+ The headline target is controller_interventions_total ≈ 0 on clean successful turns.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from collections import Counter
9
+ from collections.abc import Mapping
10
+ from typing import Any
11
+
12
+ HEADLINE_EXCLUDED_CLASSES = frozenset(
13
+ {
14
+ "context_setup",
15
+ "deadline_directive",
16
+ "safety_block",
17
+ }
18
+ )
19
+
20
+
21
+ class ControllerInterventionTracker:
22
+ def __init__(self, store: Any) -> None:
23
+ self._store = store
24
+ self._counts: Counter[str] = Counter()
25
+ self._headline_total = 0
26
+
27
+ @property
28
+ def headline_total(self) -> int:
29
+ return self._headline_total
30
+
31
+ def record(
32
+ self,
33
+ intervention_class: str,
34
+ detail: str,
35
+ *,
36
+ step: int | None = None,
37
+ metadata: Mapping[str, Any] | None = None,
38
+ headline_counted: bool | None = None,
39
+ ) -> None:
40
+ normalized_class = str(intervention_class or "").strip() or "other"
41
+ normalized_detail = str(detail or "").strip() or "unspecified"
42
+ counted = (
43
+ normalized_class not in HEADLINE_EXCLUDED_CLASSES
44
+ if headline_counted is None
45
+ else bool(headline_counted)
46
+ )
47
+ self._counts[normalized_class] += 1
48
+ if counted:
49
+ self._headline_total += 1
50
+ payload: dict[str, Any] = {
51
+ "class": normalized_class,
52
+ "detail": normalized_detail,
53
+ "step": step,
54
+ "headline_counted": counted,
55
+ "controller_interventions_total": self._headline_total,
56
+ }
57
+ if metadata:
58
+ payload["metadata"] = dict(metadata)
59
+ self._store.append("controller_intervention", payload)
60
+
61
+ def payload(self) -> dict[str, Any]:
62
+ return {
63
+ "total": self._headline_total,
64
+ "by_class": dict(sorted(self._counts.items())),
65
+ }
@@ -0,0 +1,420 @@
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+ import os
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from ..prompt_context import _normalize_repo_relative_hint_path
10
+ from ..tools_assembly import ToolDef
11
+
12
+ _SAME_BATCH_FS_READ_DEFAULT_MAX_BYTES = 20_000
13
+ _SAME_BATCH_FS_READ_LINES_DEFAULT_MAX_LINES = 200
14
+ # Mirrors tools.fs._DEFAULT_READ_LINES_MAX_BYTES: an absent max_bytes argument
15
+ # keys and caps identically to the tool's own default ceiling.
16
+ _SAME_BATCH_FS_READ_LINES_DEFAULT_MAX_BYTES = 48_000
17
+ _SAME_BATCH_READ_CACHE_SAFE_TOOL_NAMES = {
18
+ "fs_read",
19
+ "fs_read_lines",
20
+ "fs_list",
21
+ "web_fetch",
22
+ "web_search",
23
+ "symbol_search",
24
+ "test_discover",
25
+ "repo_map",
26
+ "search_rg",
27
+ "history_search",
28
+ "skill_read",
29
+ "git_status",
30
+ "git_diff",
31
+ "git_history",
32
+ }
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class _SameBatchFsReadRecord:
37
+ path_key: str
38
+ raw_lines: tuple[str, ...]
39
+ result: dict[str, Any]
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class _SameBatchFsReadLinesRecord:
44
+ path_key: str
45
+ start_line: int
46
+ end_line: int
47
+ total_lines: int | None
48
+ truncated: bool
49
+ include_line_numbers: bool
50
+ content_lines: tuple[str, ...]
51
+ result: dict[str, Any]
52
+
53
+
54
+ @dataclass
55
+ class _SameBatchReadReuseCache:
56
+ exact_fs_reads: dict[tuple[str, int, bool], dict[str, Any]] = field(default_factory=dict)
57
+ exact_fs_read_lines: dict[tuple[str, int, int | None, int, bool, int], dict[str, Any]] = field(
58
+ default_factory=dict
59
+ )
60
+ full_fs_reads: dict[str, _SameBatchFsReadRecord] = field(default_factory=dict)
61
+ fs_read_lines_by_path: dict[str, list[_SameBatchFsReadLinesRecord]] = field(
62
+ default_factory=dict
63
+ )
64
+
65
+ def clear(self) -> None:
66
+ self.exact_fs_reads.clear()
67
+ self.exact_fs_read_lines.clear()
68
+ self.full_fs_reads.clear()
69
+ self.fs_read_lines_by_path.clear()
70
+
71
+
72
+ def _same_batch_read_path_key(*, root: Path, raw_path: Any) -> str | None:
73
+ normalized = _normalize_repo_relative_hint_path(root=root, raw=str(raw_path or ""))
74
+ if normalized:
75
+ return normalized
76
+ candidate = str(raw_path or "").strip().replace("\\", "/")
77
+ while candidate.startswith("./"):
78
+ candidate = candidate[2:]
79
+ if not candidate:
80
+ return None
81
+ normalized_candidate = os.path.normpath(candidate).replace("\\", "/")
82
+ if normalized_candidate in {"", ".", ".."} or normalized_candidate.startswith("../"):
83
+ return None
84
+ if normalized_candidate.startswith("/"):
85
+ normalized_candidate = normalized_candidate[1:]
86
+ return normalized_candidate or None
87
+
88
+
89
+ def _split_text_preserving_lines(text: str) -> tuple[str, ...]:
90
+ if not text:
91
+ return ()
92
+ return tuple(text.splitlines(keepends=True))
93
+
94
+
95
+ def _coerce_fs_read_request(
96
+ *,
97
+ root: Path,
98
+ arguments: dict[str, Any],
99
+ ) -> tuple[str, str, int, bool] | None:
100
+ raw_path = str(arguments.get("path") or "")
101
+ path_key = _same_batch_read_path_key(root=root, raw_path=raw_path)
102
+ if not path_key:
103
+ return None
104
+ raw_max_bytes = arguments.get("max_bytes")
105
+ max_bytes = (
106
+ _SAME_BATCH_FS_READ_DEFAULT_MAX_BYTES if raw_max_bytes is None else int(raw_max_bytes)
107
+ )
108
+ return (
109
+ path_key,
110
+ raw_path,
111
+ max_bytes,
112
+ bool(arguments.get("allow_derived", False)),
113
+ )
114
+
115
+
116
+ def _coerce_fs_read_lines_request(
117
+ *,
118
+ root: Path,
119
+ arguments: dict[str, Any],
120
+ ) -> tuple[str, str, int, int | None, int, bool, int] | None:
121
+ raw_path = str(arguments.get("path") or "")
122
+ path_key = _same_batch_read_path_key(root=root, raw_path=raw_path)
123
+ if not path_key:
124
+ return None
125
+ start_line = int(arguments["start_line"]) if arguments.get("start_line") is not None else 0
126
+ requested_end_line = (
127
+ int(arguments["end_line"]) if arguments.get("end_line") is not None else None
128
+ )
129
+ max_lines = (
130
+ int(arguments["max_lines"])
131
+ if arguments.get("max_lines") is not None
132
+ else _SAME_BATCH_FS_READ_LINES_DEFAULT_MAX_LINES
133
+ )
134
+ include_line_numbers = bool(arguments.get("include_line_numbers", True))
135
+ raw_max_bytes = arguments.get("max_bytes")
136
+ if raw_max_bytes is None:
137
+ max_bytes = _SAME_BATCH_FS_READ_LINES_DEFAULT_MAX_BYTES
138
+ else:
139
+ max_bytes = int(raw_max_bytes)
140
+ if max_bytes < 1:
141
+ # Invalid ceiling: never serve it from cache - the real tool owns
142
+ # the FsError for out-of-range values.
143
+ return None
144
+ return (
145
+ path_key,
146
+ raw_path,
147
+ start_line,
148
+ requested_end_line,
149
+ max_lines,
150
+ include_line_numbers,
151
+ max_bytes,
152
+ )
153
+
154
+
155
+ def _build_fs_read_lines_result_from_full_fs_read(
156
+ *,
157
+ raw_path: str,
158
+ request: tuple[str, str, int, int | None, int, bool, int],
159
+ record: _SameBatchFsReadRecord,
160
+ ) -> dict[str, Any] | None:
161
+ _, _, start_line, requested_end_line, max_lines, include_line_numbers, max_bytes = request
162
+ total_lines = len(record.raw_lines)
163
+ if start_line < 1 or start_line > total_lines:
164
+ return None
165
+
166
+ effective_end_line = start_line + max_lines - 1
167
+ if requested_end_line is not None:
168
+ effective_end_line = min(effective_end_line, requested_end_line)
169
+ actual_end_line = min(effective_end_line, total_lines)
170
+ selected_lines = record.raw_lines[start_line - 1 : actual_end_line]
171
+ if not selected_lines:
172
+ return None
173
+
174
+ if include_line_numbers:
175
+ content = "".join(
176
+ f"{lineno}: {line}"
177
+ for lineno, line in zip(
178
+ range(start_line, actual_end_line + 1), selected_lines, strict=False
179
+ )
180
+ )
181
+ else:
182
+ content = "".join(selected_lines)
183
+
184
+ if len(content.encode("utf-8")) > max_bytes:
185
+ # The rebuilt window would exceed the request's byte ceiling; let the
186
+ # real tool run and apply its own clipping rather than replicate it.
187
+ return None
188
+
189
+ return {
190
+ "path": raw_path,
191
+ "start_line": start_line,
192
+ "end_line": actual_end_line,
193
+ "total_lines": total_lines if actual_end_line == total_lines else None,
194
+ "content": content,
195
+ "truncated": requested_end_line is None and actual_end_line < total_lines,
196
+ }
197
+
198
+
199
+ def _build_fs_read_lines_result_from_cached_range(
200
+ *,
201
+ raw_path: str,
202
+ request: tuple[str, str, int, int | None, int, bool, int],
203
+ record: _SameBatchFsReadLinesRecord,
204
+ ) -> dict[str, Any] | None:
205
+ _, _, start_line, requested_end_line, max_lines, include_line_numbers, max_bytes = request
206
+ if start_line < record.start_line:
207
+ return None
208
+ if include_line_numbers != record.include_line_numbers:
209
+ return None
210
+
211
+ requested_max_end = start_line + max_lines - 1
212
+ if requested_end_line is not None:
213
+ requested_max_end = min(requested_max_end, requested_end_line)
214
+
215
+ if requested_end_line is not None:
216
+ if requested_max_end <= record.end_line:
217
+ actual_end_line = requested_max_end
218
+ elif (
219
+ record.total_lines is not None
220
+ and start_line <= record.total_lines
221
+ and record.end_line == record.total_lines
222
+ ):
223
+ actual_end_line = record.total_lines
224
+ else:
225
+ return None
226
+ else:
227
+ if requested_max_end < record.end_line:
228
+ actual_end_line = requested_max_end
229
+ elif requested_max_end == record.end_line:
230
+ if record.total_lines is not None:
231
+ actual_end_line = requested_max_end
232
+ elif record.truncated:
233
+ actual_end_line = requested_max_end
234
+ else:
235
+ return None
236
+ elif (
237
+ record.total_lines is not None
238
+ and start_line <= record.total_lines
239
+ and record.end_line == record.total_lines
240
+ ):
241
+ actual_end_line = record.total_lines
242
+ else:
243
+ return None
244
+
245
+ if actual_end_line < start_line:
246
+ return None
247
+
248
+ start_offset = start_line - record.start_line
249
+ end_offset = actual_end_line - record.start_line + 1
250
+ selected_lines = record.content_lines[start_offset:end_offset]
251
+ if len(selected_lines) != (actual_end_line - start_line + 1):
252
+ return None
253
+
254
+ truncated = False
255
+ if requested_end_line is None:
256
+ if actual_end_line < record.end_line:
257
+ truncated = True
258
+ elif record.total_lines is not None:
259
+ truncated = actual_end_line < record.total_lines
260
+ else:
261
+ truncated = record.truncated
262
+
263
+ content = "".join(selected_lines)
264
+ if len(content.encode("utf-8")) > max_bytes:
265
+ # The rebuilt window would exceed the request's byte ceiling; let the
266
+ # real tool run and apply its own clipping rather than replicate it.
267
+ return None
268
+
269
+ return {
270
+ "path": raw_path,
271
+ "start_line": start_line,
272
+ "end_line": actual_end_line,
273
+ "total_lines": (
274
+ record.total_lines
275
+ if record.total_lines is not None and actual_end_line == record.total_lines
276
+ else None
277
+ ),
278
+ "content": content,
279
+ "truncated": truncated,
280
+ }
281
+
282
+
283
+ def _maybe_reuse_same_batch_read_result(
284
+ *,
285
+ root: Path,
286
+ cache: _SameBatchReadReuseCache,
287
+ tool_name: str,
288
+ arguments: dict[str, Any],
289
+ ) -> dict[str, Any] | None:
290
+ if tool_name == "fs_read":
291
+ request = _coerce_fs_read_request(root=root, arguments=arguments)
292
+ if request is None:
293
+ return None
294
+ path_key, raw_path, max_bytes, allow_derived = request
295
+ cached = cache.exact_fs_reads.get((path_key, max_bytes, allow_derived))
296
+ if cached is None:
297
+ return None
298
+ reused = copy.deepcopy(cached)
299
+ reused["path"] = raw_path
300
+ return reused
301
+
302
+ if tool_name != "fs_read_lines":
303
+ return None
304
+
305
+ request = _coerce_fs_read_lines_request(root=root, arguments=arguments)
306
+ if request is None:
307
+ return None
308
+ (
309
+ path_key,
310
+ raw_path,
311
+ start_line,
312
+ requested_end_line,
313
+ max_lines,
314
+ include_line_numbers,
315
+ max_bytes,
316
+ ) = request
317
+ cached_exact = cache.exact_fs_read_lines.get(
318
+ (path_key, start_line, requested_end_line, max_lines, include_line_numbers, max_bytes)
319
+ )
320
+ if cached_exact is not None:
321
+ reused = copy.deepcopy(cached_exact)
322
+ reused["path"] = raw_path
323
+ return reused
324
+
325
+ full_read = cache.full_fs_reads.get(path_key)
326
+ if full_read is not None:
327
+ reused = _build_fs_read_lines_result_from_full_fs_read(
328
+ raw_path=raw_path,
329
+ request=request,
330
+ record=full_read,
331
+ )
332
+ if reused is not None:
333
+ return reused
334
+
335
+ for record in reversed(cache.fs_read_lines_by_path.get(path_key, [])):
336
+ reused = _build_fs_read_lines_result_from_cached_range(
337
+ raw_path=raw_path,
338
+ request=request,
339
+ record=record,
340
+ )
341
+ if reused is not None:
342
+ return reused
343
+ return None
344
+
345
+
346
+ def _remember_same_batch_read_result(
347
+ *,
348
+ root: Path,
349
+ cache: _SameBatchReadReuseCache,
350
+ tool_name: str,
351
+ arguments: dict[str, Any],
352
+ result: dict[str, Any],
353
+ ) -> None:
354
+ if tool_name == "fs_read":
355
+ request = _coerce_fs_read_request(root=root, arguments=arguments)
356
+ if request is None:
357
+ return
358
+ path_key, _, max_bytes, allow_derived = request
359
+ stored = copy.deepcopy(result)
360
+ cache.exact_fs_reads[(path_key, max_bytes, allow_derived)] = stored
361
+ if bool(result.get("truncated")):
362
+ return
363
+ content = result.get("content")
364
+ if isinstance(content, str):
365
+ cache.full_fs_reads[path_key] = _SameBatchFsReadRecord(
366
+ path_key=path_key,
367
+ raw_lines=_split_text_preserving_lines(content),
368
+ result=stored,
369
+ )
370
+ return
371
+
372
+ if tool_name != "fs_read_lines":
373
+ return
374
+ request = _coerce_fs_read_lines_request(root=root, arguments=arguments)
375
+ if request is None:
376
+ return
377
+ (
378
+ path_key,
379
+ _,
380
+ start_line,
381
+ requested_end_line,
382
+ max_lines,
383
+ include_line_numbers,
384
+ max_bytes,
385
+ ) = request
386
+ end_line = result.get("end_line")
387
+ content = result.get("content")
388
+ if not isinstance(end_line, int) or not isinstance(content, str):
389
+ return
390
+ if bool(result.get("byte_truncated")) or bool(result.get("line_clipped")):
391
+ # A byte-capped window may end mid-line; reusing it to answer other
392
+ # range requests would silently serve incomplete lines.
393
+ return
394
+ stored = copy.deepcopy(result)
395
+ cache.exact_fs_read_lines[
396
+ (path_key, start_line, requested_end_line, max_lines, include_line_numbers, max_bytes)
397
+ ] = stored
398
+ cache.fs_read_lines_by_path.setdefault(path_key, []).append(
399
+ _SameBatchFsReadLinesRecord(
400
+ path_key=path_key,
401
+ start_line=start_line,
402
+ end_line=end_line,
403
+ total_lines=(
404
+ int(result["total_lines"]) if result.get("total_lines") is not None else None
405
+ ),
406
+ truncated=bool(result.get("truncated")),
407
+ include_line_numbers=include_line_numbers,
408
+ content_lines=_split_text_preserving_lines(content),
409
+ result=stored,
410
+ )
411
+ )
412
+
413
+
414
+ def _same_batch_read_cache_should_invalidate(tool_name: str, tool: ToolDef | None) -> bool:
415
+ normalized = str(tool_name or "").strip()
416
+ if not normalized:
417
+ return True
418
+ if normalized not in _SAME_BATCH_READ_CACHE_SAFE_TOOL_NAMES:
419
+ return True
420
+ return tool is None
@@ -0,0 +1,179 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import subprocess
5
+ from collections.abc import Callable, Iterable
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from ...runtime_artifacts import is_runtime_artifact_path
10
+ from ..prompt_context import _normalize_repo_relative_hint_path
11
+
12
+ _SHELL_MUTATION_SNAPSHOT_METADATA_PREFIX = "meta"
13
+
14
+
15
+ def _list_git_workspace_snapshot_paths(root: Path) -> set[str] | None:
16
+ try:
17
+ proc = subprocess.run(
18
+ [
19
+ "git",
20
+ "-C",
21
+ os.fspath(root),
22
+ "ls-files",
23
+ "-z",
24
+ "--cached",
25
+ "--others",
26
+ "--exclude-standard",
27
+ ],
28
+ check=False,
29
+ capture_output=True,
30
+ )
31
+ except OSError:
32
+ return None
33
+ if proc.returncode != 0:
34
+ return None
35
+
36
+ paths: set[str] = set()
37
+ stdout = proc.stdout or b""
38
+ raw_items: list[str | bytes]
39
+ if isinstance(stdout, str):
40
+ raw_items = stdout.split("\0")
41
+ else:
42
+ raw_items = stdout.split(b"\0")
43
+ for raw_item in raw_items:
44
+ if not raw_item:
45
+ continue
46
+ raw_path = (
47
+ raw_item
48
+ if isinstance(raw_item, str)
49
+ else raw_item.decode("utf-8", errors="surrogateescape")
50
+ )
51
+ normalized = _normalize_repo_relative_hint_path(
52
+ root=root,
53
+ raw=raw_path,
54
+ )
55
+ if not normalized or is_runtime_artifact_path(normalized, root=root):
56
+ continue
57
+ paths.add(normalized)
58
+ return paths
59
+
60
+
61
+ def _walk_workspace_snapshot_paths(root: Path) -> set[str]:
62
+ root_resolved = root.resolve()
63
+ paths: set[str] = set()
64
+ for current_root, dirnames, filenames in os.walk(root_resolved):
65
+ current_path = Path(current_root)
66
+ resolved_current = current_path.resolve()
67
+ rel_dir = (
68
+ resolved_current.relative_to(root_resolved).as_posix()
69
+ if resolved_current != root_resolved
70
+ else ""
71
+ )
72
+ kept_dirnames: list[str] = []
73
+ for dirname in dirnames:
74
+ rel_candidate = dirname if not rel_dir else f"{rel_dir}/{dirname}"
75
+ normalized = _normalize_repo_relative_hint_path(root=root, raw=rel_candidate)
76
+ if normalized and is_runtime_artifact_path(normalized, root=root):
77
+ continue
78
+ kept_dirnames.append(dirname)
79
+ dirnames[:] = kept_dirnames
80
+ for filename in filenames:
81
+ rel_candidate = filename if not rel_dir else f"{rel_dir}/{filename}"
82
+ normalized = _normalize_repo_relative_hint_path(root=root, raw=rel_candidate)
83
+ if not normalized or is_runtime_artifact_path(normalized, root=root):
84
+ continue
85
+ paths.add(normalized)
86
+ return paths
87
+
88
+
89
+ def _workspace_snapshot_signature(path: Path) -> str | None:
90
+ try:
91
+ stat = path.stat()
92
+ except OSError:
93
+ return None
94
+ if not path.is_file():
95
+ return None
96
+ ctime_ns = getattr(stat, "st_ctime_ns", int(stat.st_ctime * 1_000_000_000))
97
+ return (
98
+ f"{_SHELL_MUTATION_SNAPSHOT_METADATA_PREFIX}:{stat.st_size}:{stat.st_mtime_ns}:{ctime_ns}"
99
+ )
100
+
101
+
102
+ def _snapshot_workspace_for_command_mutation_detection(root: Path) -> dict[str, str]:
103
+ candidate_paths = _list_git_workspace_snapshot_paths(root)
104
+ if candidate_paths is None:
105
+ candidate_paths = _walk_workspace_snapshot_paths(root)
106
+ snapshot: dict[str, str] = {}
107
+ for rel_path in sorted(candidate_paths):
108
+ signature = _workspace_snapshot_signature(root / rel_path)
109
+ if signature is None:
110
+ continue
111
+ snapshot[rel_path] = signature
112
+ return snapshot
113
+
114
+
115
+ def _path_matches_snapshot_ignore(
116
+ rel_path: str,
117
+ *,
118
+ ignored_paths: set[str],
119
+ ) -> bool:
120
+ return any(
121
+ rel_path == ignored or rel_path.startswith(f"{ignored}/") for ignored in ignored_paths
122
+ )
123
+
124
+
125
+ def _normalize_snapshot_ignore_paths(root: Path, paths: Iterable[Path]) -> set[str]:
126
+ ignored: set[str] = set()
127
+ resolved_root = root.resolve()
128
+ for path in paths:
129
+ try:
130
+ rel_path = path.resolve().relative_to(resolved_root)
131
+ except ValueError:
132
+ continue
133
+ normalized = _normalize_repo_relative_hint_path(root=root, raw=os.fspath(rel_path))
134
+ if normalized:
135
+ ignored.add(normalized)
136
+ return ignored
137
+
138
+
139
+ def _detect_command_mutation_paths(
140
+ *,
141
+ before: dict[str, str],
142
+ after: dict[str, str],
143
+ ) -> list[str]:
144
+ changed = {
145
+ rel_path
146
+ for rel_path in (set(before) | set(after))
147
+ if before.get(rel_path) != after.get(rel_path)
148
+ }
149
+ return sorted(changed)
150
+
151
+
152
+ def _run_with_command_mutation_detection(
153
+ *,
154
+ root: Path,
155
+ enabled: bool,
156
+ ignored_paths: Iterable[Path] = (),
157
+ operation: Callable[[], Any],
158
+ ) -> tuple[Any, list[str]]:
159
+ if not enabled:
160
+ return operation(), []
161
+ ignored = _normalize_snapshot_ignore_paths(root, ignored_paths)
162
+ before_snapshot = {
163
+ rel_path: signature
164
+ for rel_path, signature in _snapshot_workspace_for_command_mutation_detection(root).items()
165
+ if not _path_matches_snapshot_ignore(rel_path, ignored_paths=ignored)
166
+ }
167
+ result = operation()
168
+ after_snapshot = {
169
+ rel_path: signature
170
+ for rel_path, signature in _snapshot_workspace_for_command_mutation_detection(root).items()
171
+ if not _path_matches_snapshot_ignore(rel_path, ignored_paths=ignored)
172
+ }
173
+ return (
174
+ result,
175
+ _detect_command_mutation_paths(
176
+ before=before_snapshot,
177
+ after=after_snapshot,
178
+ ),
179
+ )