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,1456 @@
1
+ from __future__ import annotations
2
+
3
+ import errno
4
+ import hashlib
5
+ import os
6
+ import secrets
7
+ import stat
8
+ import subprocess
9
+ from dataclasses import dataclass
10
+ from fnmatch import fnmatch
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from ..file_classification import derived_artifact_reason
15
+ from ..git_safe import build_git_process_env
16
+ from ..runtime_artifacts import RUNTIME_ARTIFACT_DIR_NAMES
17
+
18
+
19
+ class FsError(RuntimeError):
20
+ pass
21
+
22
+
23
+ class StaleFileError(FsError):
24
+ """A guarded mutation no longer matches the file state it was prepared from."""
25
+
26
+ code = "stale_file"
27
+
28
+ def __init__(self, path: str) -> None:
29
+ self.path = path
30
+ super().__init__(
31
+ f"stale_file: {path} changed after the operation was prepared; "
32
+ "the file was not modified"
33
+ )
34
+
35
+
36
+ _DEFAULT_IGNORE_DIRS = {
37
+ ".git",
38
+ ".venv",
39
+ "venv",
40
+ "dist",
41
+ "build",
42
+ ".idea",
43
+ ".vscode",
44
+ } | set(RUNTIME_ARTIFACT_DIR_NAMES)
45
+ _DEFAULT_READ_LINES_MAX_LINES = 200
46
+ # Byte ceiling for fs_read_lines: far above any sane 200-line source window,
47
+ # far below what a single minified/generated line can inject into the context.
48
+ _DEFAULT_READ_LINES_MAX_BYTES = 48_000
49
+ # Derived artifacts smaller than this are returned whole; the stub would not
50
+ # save anything meaningful.
51
+ _DERIVED_ARTIFACT_STUB_MIN_BYTES = 2_048
52
+ _DERIVED_ARTIFACT_HEAD_BYTES = 1_000
53
+ _DERIVED_ARTIFACT_NOTE = (
54
+ "Content withheld by default: this is a machine-generated artifact whose "
55
+ "full text rarely informs a task relative to its size. The head sample "
56
+ "above is provided for orientation. If this artifact's contents are "
57
+ "genuinely the subject of the task, re-call fs_read with "
58
+ "allow_derived=true, or use fs_read_lines for a bounded range."
59
+ )
60
+ _FS_EDIT_OPERATIONS = {
61
+ "replace_exact",
62
+ "insert_before_exact",
63
+ "insert_after_exact",
64
+ "replace_lines",
65
+ "insert_before_line",
66
+ "insert_after_line",
67
+ "append",
68
+ "prepend",
69
+ }
70
+ _FS_EDIT_OPERATION_ALIASES = {
71
+ "replace": "replace_exact",
72
+ }
73
+ _DEFAULT_FS_READ_MAX_BYTES = 12_000
74
+ _DEFAULT_FS_LIST_MAX_RESULTS = 150
75
+ _GIT_PROBE_TIMEOUT_S = 2.0
76
+ _SAFE_ENV_TEMPLATE_SUFFIXES = (
77
+ ".example",
78
+ ".sample",
79
+ ".template",
80
+ ".dist",
81
+ ".defaults",
82
+ )
83
+ _PRIVATE_KEY_SUFFIXES = (".key", ".pem", ".p12", ".pfx", ".ppk", ".jks", ".keystore")
84
+ _PRIVATE_KEY_NAMES = {
85
+ "id_dsa",
86
+ "id_ecdsa",
87
+ "id_ed25519",
88
+ "id_rsa",
89
+ }
90
+ _CREDENTIAL_FILE_NAMES = {
91
+ ".git-credentials",
92
+ ".netrc",
93
+ ".npmrc",
94
+ ".pypirc",
95
+ "application_default_credentials.json",
96
+ "auth.json",
97
+ "credentials",
98
+ "credentials.json",
99
+ "credentials.tfrc.json",
100
+ "dockerconfigjson",
101
+ "kubeconfig",
102
+ "netrc",
103
+ "service-account.json",
104
+ "service_account.json",
105
+ }
106
+ _SENSITIVE_DIRECTORY_NAMES = {
107
+ ".aws",
108
+ ".azure",
109
+ ".docker",
110
+ ".git",
111
+ ".gnupg",
112
+ ".kube",
113
+ ".ssh",
114
+ ".alysis",
115
+ }
116
+
117
+
118
+ @dataclass(frozen=True, slots=True)
119
+ class SensitivePathClassification:
120
+ sensitive: bool
121
+ category: str | None = None
122
+
123
+
124
+ @dataclass(frozen=True, slots=True)
125
+ class FilePrecondition:
126
+ path: str
127
+ exists: bool
128
+ content_sha256: str | None
129
+ identity_sha256: str | None
130
+ mode: int | None
131
+
132
+
133
+ @dataclass(frozen=True, slots=True)
134
+ class PreparedFsWrite:
135
+ root_obj: Path
136
+ path: str
137
+ path_obj: Path
138
+ content: str
139
+ precondition: FilePrecondition
140
+
141
+
142
+ @dataclass(frozen=True)
143
+ class PreparedFsEdit:
144
+ root_obj: Path
145
+ path: str
146
+ path_obj: Path
147
+ original_content: str
148
+ updated_content: str
149
+ applied_edits: int
150
+ precondition: FilePrecondition
151
+
152
+ @property
153
+ def original_content_sha256(self) -> str:
154
+ return str(self.precondition.content_sha256 or "")
155
+
156
+ @property
157
+ def original_file_identity_sha256(self) -> str:
158
+ return str(self.precondition.identity_sha256 or "")
159
+
160
+
161
+ def classify_sensitive_path(path: str | os.PathLike[str]) -> SensitivePathClassification:
162
+ """Classify credential-bearing paths without opening or inspecting their content."""
163
+
164
+ normalized = os.fspath(path).replace("\\", "/").strip("/")
165
+ parts = [part.casefold() for part in normalized.split("/") if part]
166
+ if not parts:
167
+ return SensitivePathClassification(False)
168
+ name = parts[-1]
169
+
170
+ if any(part in _SENSITIVE_DIRECTORY_NAMES for part in parts):
171
+ return SensitivePathClassification(True, "credential_directory")
172
+
173
+ if name == ".env" or name.startswith(".env."):
174
+ if name.endswith(_SAFE_ENV_TEMPLATE_SUFFIXES):
175
+ return SensitivePathClassification(False)
176
+ return SensitivePathClassification(True, "environment_file")
177
+
178
+ if name in _PRIVATE_KEY_NAMES or (
179
+ name.endswith(_PRIVATE_KEY_SUFFIXES) and not name.endswith(".pub")
180
+ ):
181
+ return SensitivePathClassification(True, "private_key")
182
+
183
+ if name in _CREDENTIAL_FILE_NAMES:
184
+ return SensitivePathClassification(True, "credential_file")
185
+ if name.startswith(("service-account-", "service_account_")) and name.endswith(".json"):
186
+ return SensitivePathClassification(True, "credential_file")
187
+ if name.startswith(("secret.", "secrets.")) or name in {
188
+ "secret",
189
+ "secrets",
190
+ "secrets.json",
191
+ "secrets.yaml",
192
+ "secrets.yml",
193
+ }:
194
+ return SensitivePathClassification(True, "secret_file")
195
+ if len(parts) >= 2 and parts[-2:] == [".aws", "credentials"]:
196
+ return SensitivePathClassification(True, "credential_file")
197
+ if len(parts) >= 2 and parts[-2:] == [".docker", "config.json"]:
198
+ return SensitivePathClassification(True, "credential_file")
199
+ if len(parts) >= 2 and parts[-2:] == [".azure", "accesstokens.json"]:
200
+ return SensitivePathClassification(True, "credential_file")
201
+ return SensitivePathClassification(False)
202
+
203
+
204
+ def _content_sha256(data: bytes) -> str:
205
+ return hashlib.sha256(data).hexdigest()
206
+
207
+
208
+ def _identity_sha256(path_stat: os.stat_result) -> str:
209
+ identity = ":".join(
210
+ str(value)
211
+ for value in (
212
+ getattr(path_stat, "st_dev", 0),
213
+ getattr(path_stat, "st_ino", 0),
214
+ path_stat.st_size,
215
+ getattr(path_stat, "st_mtime_ns", int(path_stat.st_mtime * 1_000_000_000)),
216
+ getattr(path_stat, "st_ctime_ns", int(path_stat.st_ctime * 1_000_000_000)),
217
+ )
218
+ )
219
+ return hashlib.sha256(identity.encode("ascii", errors="strict")).hexdigest()
220
+
221
+
222
+ def _snapshot_existing_file(path_obj: Path, user_path: str) -> tuple[bytes, FilePrecondition]:
223
+ try:
224
+ with path_obj.open("rb") as handle:
225
+ before = os.fstat(handle.fileno())
226
+ if not stat.S_ISREG(before.st_mode):
227
+ raise FsError(f"Is not a regular file: {user_path}")
228
+ data = handle.read()
229
+ after = os.fstat(handle.fileno())
230
+ except FileNotFoundError as exc:
231
+ raise FsError(f"Not found: {user_path}") from exc
232
+ if _identity_sha256(before) != _identity_sha256(after):
233
+ raise StaleFileError(user_path)
234
+ return data, FilePrecondition(
235
+ path=user_path,
236
+ exists=True,
237
+ content_sha256=_content_sha256(data),
238
+ identity_sha256=_identity_sha256(after),
239
+ mode=stat.S_IMODE(after.st_mode),
240
+ )
241
+
242
+
243
+ def capture_file_precondition(*, root: Path, path: str) -> FilePrecondition:
244
+ path_obj = _resolve_under_root(root, path)
245
+ try:
246
+ data, precondition = _snapshot_existing_file(path_obj, path)
247
+ except FsError:
248
+ if path_obj.exists():
249
+ raise
250
+ return FilePrecondition(
251
+ path=path,
252
+ exists=False,
253
+ content_sha256=None,
254
+ identity_sha256=None,
255
+ mode=None,
256
+ )
257
+ _ = data
258
+ return precondition
259
+
260
+
261
+ def assert_file_precondition(*, root: Path, precondition: FilePrecondition) -> None:
262
+ path_obj = _resolve_under_root(root, precondition.path)
263
+ if not precondition.exists:
264
+ if path_obj.exists():
265
+ raise StaleFileError(precondition.path)
266
+ return
267
+ try:
268
+ _data, current = _snapshot_existing_file(path_obj, precondition.path)
269
+ except FsError as exc:
270
+ if isinstance(exc, StaleFileError):
271
+ raise
272
+ raise StaleFileError(precondition.path) from exc
273
+ if (
274
+ current.content_sha256 != precondition.content_sha256
275
+ or current.identity_sha256 != precondition.identity_sha256
276
+ ):
277
+ raise StaleFileError(precondition.path)
278
+
279
+
280
+ def _resolve_under_root(root: Path, user_path: str) -> Path:
281
+ root_abs = root.resolve()
282
+ p = (root_abs / user_path).resolve()
283
+ try:
284
+ p.relative_to(root_abs)
285
+ except ValueError as e:
286
+ raise FsError(f"Path escapes root: {user_path}") from e
287
+ return p
288
+
289
+
290
+ def _count_text_lines(path: Path) -> int:
291
+ _total = 0
292
+ with path.open("r", encoding="utf-8", errors="replace", newline="") as fh:
293
+ for _total, _line in enumerate(fh, start=1):
294
+ pass
295
+ return _total
296
+
297
+
298
+ def _truncated_line_ranges(
299
+ *,
300
+ start_line: int,
301
+ end_line: int,
302
+ total_lines: int,
303
+ next_max_lines: int,
304
+ ) -> dict[str, Any]:
305
+ next_start = end_line + 1
306
+ next_range = None
307
+ if next_start <= total_lines:
308
+ next_range = {
309
+ "start_line": next_start,
310
+ "end_line": min(total_lines, next_start + max(1, next_max_lines) - 1),
311
+ }
312
+ return {
313
+ "total_lines": total_lines,
314
+ "returned_range": {"start_line": start_line, "end_line": end_line},
315
+ "next_range": next_range,
316
+ }
317
+
318
+
319
+ def fs_read(
320
+ *,
321
+ root: Path,
322
+ path: str,
323
+ max_bytes: int = _DEFAULT_FS_READ_MAX_BYTES,
324
+ allow_derived: bool = False,
325
+ ) -> dict[str, Any]:
326
+ p = _resolve_under_root(root, path)
327
+ if not p.exists():
328
+ raise FsError(f"Not found: {path}")
329
+ if p.is_dir():
330
+ raise FsError(f"Is a directory: {path}")
331
+
332
+ if not allow_derived:
333
+ derived_reason = derived_artifact_reason(path)
334
+ if derived_reason is not None:
335
+ try:
336
+ size_bytes: int | None = p.stat().st_size
337
+ except OSError:
338
+ size_bytes = None
339
+ if size_bytes is None or size_bytes > _DERIVED_ARTIFACT_STUB_MIN_BYTES:
340
+ # The head sample honors an explicit smaller max_bytes: the
341
+ # caller's ceiling bounds every read shape, stub included.
342
+ with p.open("rb") as fh:
343
+ head = fh.read(min(_DERIVED_ARTIFACT_HEAD_BYTES, max(0, max_bytes)))
344
+ content = head.decode("utf-8", errors="ignore")
345
+ returned_end_line = content.count("\n")
346
+ if content and not content.endswith("\n"):
347
+ returned_end_line += 1
348
+ # ``truncated`` is always True for a stub so downstream caches
349
+ # never treat the head sample as the complete file content.
350
+ result = {
351
+ "path": path,
352
+ "content": content,
353
+ "truncated": True,
354
+ "bytes_read": len(head),
355
+ "max_bytes": max_bytes,
356
+ "derived_artifact": True,
357
+ "derived_artifact_reason": derived_reason,
358
+ "size_bytes": size_bytes,
359
+ "note": _DERIVED_ARTIFACT_NOTE,
360
+ }
361
+ if content and not content.endswith("\n"):
362
+ result["line_clipped"] = True
363
+ result.update(
364
+ _truncated_line_ranges(
365
+ start_line=1,
366
+ end_line=returned_end_line,
367
+ total_lines=_count_text_lines(p),
368
+ next_max_lines=_DEFAULT_READ_LINES_MAX_LINES,
369
+ )
370
+ )
371
+ return result
372
+
373
+ # Read only what we need (+1 byte lookahead for truncation detection).
374
+ with p.open("rb") as fh:
375
+ data = fh.read(max_bytes + 1)
376
+ truncated = len(data) > max_bytes
377
+ if truncated:
378
+ data = data[:max_bytes]
379
+ text = data.decode("utf-8", errors="replace")
380
+ result = {
381
+ "path": path,
382
+ "content": text,
383
+ "truncated": truncated,
384
+ "bytes_read": len(data),
385
+ "max_bytes": max_bytes,
386
+ }
387
+ if truncated:
388
+ if text and not text.endswith("\n"):
389
+ result["line_clipped"] = True
390
+ returned_end_line = text.count("\n")
391
+ if text and not text.endswith("\n"):
392
+ returned_end_line += 1
393
+ result.update(
394
+ _truncated_line_ranges(
395
+ start_line=1,
396
+ end_line=returned_end_line,
397
+ total_lines=_count_text_lines(p),
398
+ next_max_lines=_DEFAULT_READ_LINES_MAX_LINES,
399
+ )
400
+ )
401
+ return result
402
+
403
+
404
+ def fs_read_lines(
405
+ *,
406
+ root: Path,
407
+ path: str,
408
+ start_line: int,
409
+ end_line: int | None = None,
410
+ max_lines: int = _DEFAULT_READ_LINES_MAX_LINES,
411
+ include_line_numbers: bool = True,
412
+ max_bytes: int = _DEFAULT_READ_LINES_MAX_BYTES,
413
+ ) -> dict[str, Any]:
414
+ if start_line < 1:
415
+ raise FsError(f"Invalid start_line: {start_line} (must be >= 1)")
416
+ if end_line is not None and end_line < start_line:
417
+ raise FsError(
418
+ f"Invalid line range: end_line ({end_line}) must be >= start_line ({start_line})"
419
+ )
420
+ if max_lines < 1:
421
+ raise FsError(f"Invalid max_lines: {max_lines} (must be >= 1)")
422
+ if max_bytes < 1:
423
+ raise FsError(f"Invalid max_bytes: {max_bytes} (must be >= 1)")
424
+
425
+ p = _resolve_under_root(root, path)
426
+ if not p.exists():
427
+ raise FsError(f"Not found: {path}")
428
+ if p.is_dir():
429
+ raise FsError(f"Is a directory: {path}")
430
+
431
+ requested_end_line = end_line
432
+ effective_end_line = start_line + max_lines - 1
433
+ if requested_end_line is not None:
434
+ effective_end_line = min(effective_end_line, requested_end_line)
435
+
436
+ content_lines: list[str] = []
437
+ actual_end_line = start_line - 1
438
+ total_lines: int | None = None
439
+ lines_seen = 0
440
+ truncated = False
441
+ bytes_used = 0
442
+ byte_truncated = False
443
+ line_clipped = False
444
+
445
+ # Stream forward to the requested window and only report total_lines when
446
+ # we naturally reach EOF, so focused range reads stay cheap on large files.
447
+ # A byte ceiling bounds the result even when individual lines are enormous
448
+ # (minified bundles, generated single-line files) so one line can never
449
+ # flood the caller's context.
450
+ with p.open("r", encoding="utf-8", errors="replace", newline="") as fh:
451
+ for lineno, raw_line in enumerate(fh, start=1):
452
+ lines_seen = lineno
453
+ if lineno < start_line:
454
+ continue
455
+ if lineno > effective_end_line:
456
+ truncated = requested_end_line is None or lineno <= requested_end_line
457
+ break
458
+
459
+ piece = f"{lineno}: {raw_line}" if include_line_numbers else raw_line
460
+ piece_bytes = len(piece.encode("utf-8"))
461
+ if bytes_used + piece_bytes > max_bytes:
462
+ if not content_lines:
463
+ # Even the first requested line exceeds the ceiling: return
464
+ # a clipped head of it rather than nothing. Drop any partial
465
+ # trailing character instead of decoding it with a
466
+ # replacement glyph, so the clipped text never re-encodes to
467
+ # more than the advertised max_bytes.
468
+ clipped = piece.encode("utf-8")[:max_bytes]
469
+ content_lines.append(clipped.decode("utf-8", errors="ignore"))
470
+ actual_end_line = lineno
471
+ line_clipped = True
472
+ byte_truncated = True
473
+ truncated = True
474
+ break
475
+ bytes_used += piece_bytes
476
+ actual_end_line = lineno
477
+ content_lines.append(piece)
478
+ else:
479
+ total_lines = lines_seen
480
+
481
+ if lines_seen < start_line:
482
+ raise FsError(f"Start line {start_line} is beyond end of file ({lines_seen} lines): {path}")
483
+
484
+ if truncated and total_lines is None:
485
+ total_lines = _count_text_lines(p)
486
+
487
+ result: dict[str, Any] = {
488
+ "path": path,
489
+ "start_line": start_line,
490
+ "end_line": actual_end_line,
491
+ "total_lines": total_lines,
492
+ "content": "".join(content_lines),
493
+ "truncated": truncated,
494
+ }
495
+ if byte_truncated:
496
+ result["byte_truncated"] = True
497
+ result["max_bytes"] = max_bytes
498
+ result["note"] = (
499
+ "Byte ceiling reached before the requested line range completed. "
500
+ "Request a narrower range, or raise max_bytes if the full range is "
501
+ "genuinely required."
502
+ )
503
+ if line_clipped:
504
+ result["line_clipped"] = True
505
+ if truncated:
506
+ result.update(
507
+ _truncated_line_ranges(
508
+ start_line=start_line,
509
+ end_line=actual_end_line,
510
+ total_lines=int(total_lines or 0),
511
+ next_max_lines=max_lines,
512
+ )
513
+ )
514
+ return result
515
+
516
+
517
+ def _atomic_replace_text(
518
+ path_obj: Path,
519
+ content: str,
520
+ *,
521
+ root: Path,
522
+ precondition: FilePrecondition,
523
+ ) -> None:
524
+ """Durably stage text and commit it without clobbering a racing writer.
525
+
526
+ ``os.replace`` is atomic, but it is not compare-and-swap: a writer can
527
+ change the destination after our final comparison and before the replace.
528
+ For an existing file we therefore move the destination to a private sibling,
529
+ verify the *displaced* bytes, and install the staged inode with a no-clobber
530
+ hard link. A racing version is either restored or left at the public path;
531
+ it is never overwritten by the prepared content.
532
+ """
533
+
534
+ path_obj.parent.mkdir(parents=True, exist_ok=True)
535
+ fd = -1
536
+ temp_path: Path | None = None
537
+ for _attempt in range(20):
538
+ candidate = path_obj.parent / f".{path_obj.name}.{secrets.token_hex(8)}.tmp"
539
+ try:
540
+ fd = os.open(candidate, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o666)
541
+ except FileExistsError:
542
+ continue
543
+ temp_path = candidate
544
+ break
545
+ if temp_path is None or fd < 0:
546
+ raise FsError(f"Could not create temporary file for: {precondition.path}")
547
+
548
+ try:
549
+ if precondition.mode is not None:
550
+ os.chmod(temp_path, precondition.mode)
551
+ with os.fdopen(fd, "w", encoding="utf-8", newline="") as handle:
552
+ fd = -1
553
+ handle.write(content)
554
+ handle.flush()
555
+ os.fsync(handle.fileno())
556
+ # Keep the cheap early comparison for a useful stale error before any
557
+ # namespace mutation. The displaced-file comparison in
558
+ # ``_commit_staged_regular_file`` closes the check/replace race.
559
+ assert_file_precondition(root=root, precondition=precondition)
560
+ current_path = _resolve_under_root(root, precondition.path)
561
+ if os.path.normcase(os.fspath(current_path)) != os.path.normcase(os.fspath(path_obj)):
562
+ raise StaleFileError(precondition.path)
563
+ _commit_staged_regular_file(
564
+ staged_path=temp_path,
565
+ target_path=path_obj,
566
+ precondition=precondition,
567
+ )
568
+ temp_path = None
569
+ finally:
570
+ if fd >= 0:
571
+ os.close(fd)
572
+ if temp_path is not None:
573
+ try:
574
+ temp_path.unlink()
575
+ except FileNotFoundError:
576
+ pass
577
+
578
+
579
+ def _commit_staged_regular_file(
580
+ *,
581
+ staged_path: Path,
582
+ target_path: Path,
583
+ precondition: FilePrecondition,
584
+ ) -> None:
585
+ """Install ``staged_path`` iff ``target_path`` still matches ``precondition``."""
586
+
587
+ displaced_path = _displace_regular_file_if_matches(
588
+ target_path=target_path,
589
+ precondition=precondition,
590
+ )
591
+ if displaced_path is None:
592
+ try:
593
+ _link_regular_file_no_replace(staged_path, target_path)
594
+ except FileExistsError as exc:
595
+ raise StaleFileError(precondition.path) from exc
596
+ staged_path.unlink()
597
+ return
598
+
599
+ try:
600
+ _link_regular_file_no_replace(staged_path, target_path)
601
+ except FileExistsError as exc:
602
+ # Another writer claimed the public name after we moved aside the
603
+ # expected version. Their version wins; the prepared write fails.
604
+ displaced_path.unlink(missing_ok=True)
605
+ raise StaleFileError(precondition.path) from exc
606
+ except OSError:
607
+ if not _restore_displaced_no_replace(displaced_path, target_path):
608
+ raise FsError(
609
+ f"Unable to install or safely restore {precondition.path}; "
610
+ f"the previous version remains at {displaced_path.name}"
611
+ ) from None
612
+ raise
613
+ staged_path.unlink()
614
+ displaced_path.unlink()
615
+
616
+
617
+ def _displace_regular_file_if_matches(
618
+ *, target_path: Path, precondition: FilePrecondition
619
+ ) -> Path | None:
620
+ """Move a matching file aside, restoring it when the displaced bytes are stale."""
621
+
622
+ if not precondition.exists:
623
+ if os.path.lexists(target_path):
624
+ raise StaleFileError(precondition.path)
625
+ return None
626
+
627
+ displaced_path = _reserve_displaced_path(target_path)
628
+ target_was_displaced = False
629
+ try:
630
+ try:
631
+ os.replace(target_path, displaced_path)
632
+ target_was_displaced = True
633
+ except (FileNotFoundError, IsADirectoryError, NotADirectoryError) as exc:
634
+ raise StaleFileError(precondition.path) from exc
635
+ if _displaced_file_matches(displaced_path, precondition):
636
+ return displaced_path
637
+ if not _restore_displaced_no_replace(displaced_path, target_path):
638
+ raise FsError(
639
+ f"A concurrent edit was preserved at {precondition.path}; "
640
+ f"the displaced version remains at {displaced_path.name}"
641
+ )
642
+ raise StaleFileError(precondition.path)
643
+ finally:
644
+ if not target_was_displaced:
645
+ displaced_path.unlink(missing_ok=True)
646
+
647
+
648
+ def _reserve_displaced_path(target_path: Path) -> Path:
649
+ for _attempt in range(20):
650
+ candidate = target_path.parent / (f".{target_path.name}.{secrets.token_hex(8)}.displaced")
651
+ try:
652
+ fd = os.open(candidate, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
653
+ except FileExistsError:
654
+ continue
655
+ os.close(fd)
656
+ return candidate
657
+ raise FsError(f"Could not reserve a displaced-file path for: {target_path.name}")
658
+
659
+
660
+ def _displaced_file_matches(path: Path, precondition: FilePrecondition) -> bool:
661
+ try:
662
+ data, current = _snapshot_existing_file(path, precondition.path)
663
+ except (FsError, OSError):
664
+ return False
665
+ # Rename metadata is not stable across all supported filesystems, so compare
666
+ # the durable semantics that matter: exact bytes, regular-file type, and mode.
667
+ return (
668
+ current.content_sha256 == precondition.content_sha256
669
+ and current.mode == precondition.mode
670
+ and _content_sha256(data) == precondition.content_sha256
671
+ )
672
+
673
+
674
+ def _link_regular_file_no_replace(source: Path, destination: Path) -> None:
675
+ """Atomically publish a same-directory regular inode without replacement."""
676
+
677
+ os.link(source, destination, follow_symlinks=False)
678
+
679
+
680
+ def _stage_bytes_sibling(target: Path, data: bytes, *, mode: int | None) -> Path:
681
+ target.parent.mkdir(parents=True, exist_ok=True)
682
+ fd = -1
683
+ staged: Path | None = None
684
+ for _attempt in range(20):
685
+ candidate = target.parent / f".{target.name}.{secrets.token_hex(8)}.tmp"
686
+ try:
687
+ fd = os.open(candidate, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o666)
688
+ except FileExistsError:
689
+ continue
690
+ staged = candidate
691
+ break
692
+ if staged is None or fd < 0:
693
+ raise FsError(f"Could not create temporary file for: {target.name}")
694
+ try:
695
+ if mode is not None:
696
+ os.chmod(staged, mode)
697
+ with os.fdopen(fd, "wb") as stream:
698
+ fd = -1
699
+ stream.write(data)
700
+ stream.flush()
701
+ os.fsync(stream.fileno())
702
+ return staged
703
+ except Exception:
704
+ if fd >= 0:
705
+ os.close(fd)
706
+ staged.unlink(missing_ok=True)
707
+ raise
708
+
709
+
710
+ def _publish_regular_move_no_replace(source: Path, destination: Path, *, mode: int | None) -> None:
711
+ try:
712
+ _link_regular_file_no_replace(source, destination)
713
+ except FileExistsError:
714
+ raise
715
+ except OSError as exc:
716
+ if exc.errno != errno.EXDEV:
717
+ raise
718
+ # A cross-device move cannot share an inode. Stage a verified byte copy
719
+ # on the destination filesystem and publish that name without clobber.
720
+ data = source.read_bytes()
721
+ staged = _stage_bytes_sibling(destination, data, mode=mode)
722
+ try:
723
+ _link_regular_file_no_replace(staged, destination)
724
+ finally:
725
+ staged.unlink(missing_ok=True)
726
+ source.unlink()
727
+
728
+
729
+ def _restore_displaced_no_replace(displaced: Path, target: Path) -> bool:
730
+ """Restore a displaced entry only while the public name remains unclaimed."""
731
+
732
+ try:
733
+ info = displaced.lstat()
734
+ if stat.S_ISREG(info.st_mode):
735
+ _link_regular_file_no_replace(displaced, target)
736
+ elif stat.S_ISLNK(info.st_mode):
737
+ os.symlink(os.readlink(displaced), target)
738
+ else:
739
+ return False
740
+ except FileExistsError:
741
+ return False
742
+ except OSError:
743
+ return False
744
+ displaced.unlink()
745
+ return True
746
+
747
+
748
+ def prepare_fs_write(*, root: Path, path: str, content: str) -> PreparedFsWrite:
749
+ path_obj = _resolve_under_root(root, path)
750
+ if path_obj.exists() and path_obj.is_dir():
751
+ raise FsError(f"Is a directory: {path}")
752
+ return PreparedFsWrite(
753
+ root_obj=root.resolve(),
754
+ path=path,
755
+ path_obj=path_obj,
756
+ content=content,
757
+ precondition=capture_file_precondition(root=root, path=path),
758
+ )
759
+
760
+
761
+ def write_prepared_fs_write(prepared: PreparedFsWrite, *, root: Path) -> dict[str, Any]:
762
+ _atomic_replace_text(
763
+ prepared.path_obj,
764
+ prepared.content,
765
+ root=root,
766
+ precondition=prepared.precondition,
767
+ )
768
+ return {
769
+ "path": prepared.path,
770
+ "bytes": len(prepared.content.encode("utf-8")),
771
+ "created": not prepared.precondition.exists,
772
+ }
773
+
774
+
775
+ def fs_write(*, root: Path, path: str, content: str) -> dict[str, Any]:
776
+ prepared = prepare_fs_write(root=root, path=path, content=content)
777
+ result = write_prepared_fs_write(prepared, root=root)
778
+ # ``created`` distinguishes a brand-new file from an overwrite. Regression
779
+ # attribution uses it to mark a failing test the agent just authored this
780
+ # turn as signal (agent_authored) rather than a regression.
781
+ return result
782
+
783
+
784
+ def fs_mkdir(
785
+ *,
786
+ root: Path,
787
+ path: str,
788
+ parents: bool = True,
789
+ exist_ok: bool = True,
790
+ ) -> dict[str, Any]:
791
+ p = _resolve_under_root(root, path)
792
+ if p.exists():
793
+ if not p.is_dir():
794
+ raise FsError(f"Target exists as a file: {path}")
795
+ if not exist_ok:
796
+ raise FsError(f"Directory already exists and exist_ok is false: {path}")
797
+ return {
798
+ "path": path,
799
+ "created": False,
800
+ "already_exists": True,
801
+ "parents": bool(parents),
802
+ "exist_ok": bool(exist_ok),
803
+ }
804
+
805
+ try:
806
+ p.mkdir(parents=bool(parents), exist_ok=bool(exist_ok))
807
+ except FileExistsError as e:
808
+ raise FsError(f"Directory already exists and exist_ok is false: {path}") from e
809
+ except OSError as e:
810
+ raise FsError(str(e)) from e
811
+
812
+ return {
813
+ "path": path,
814
+ "created": True,
815
+ "already_exists": False,
816
+ "parents": bool(parents),
817
+ "exist_ok": bool(exist_ok),
818
+ }
819
+
820
+
821
+ def _require_edit_string(edit: dict[str, Any], key: str, *, index: int, op: str) -> str:
822
+ value = edit.get(key)
823
+ if not isinstance(value, str):
824
+ raise FsError(f"Edit {index} ({op}) requires string field: {key}")
825
+ return value
826
+
827
+
828
+ def _require_edit_line_number(edit: dict[str, Any], key: str, *, index: int, op: str) -> int:
829
+ value = edit.get(key)
830
+ if isinstance(value, bool) or not isinstance(value, int):
831
+ raise FsError(f"Edit {index} ({op}) requires integer field: {key}")
832
+ if value < 1:
833
+ raise FsError(f"Edit {index} ({op}) {key} must be >= 1")
834
+ return value
835
+
836
+
837
+ def _optional_expected_match_count(edit: dict[str, Any], *, index: int, op: str) -> int | None:
838
+ value = edit.get("expected_match_count")
839
+ if value is None:
840
+ return None
841
+ if not isinstance(value, int) or value < 0:
842
+ raise FsError(f"Edit {index} ({op}) expected_match_count must be a non-negative integer")
843
+ return value
844
+
845
+
846
+ def _count_matches(content: str, target: str, *, index: int, op: str) -> int:
847
+ if not target:
848
+ raise FsError(f"Edit {index} ({op}) requires a non-empty target")
849
+ return content.count(target)
850
+
851
+
852
+ def _validate_match_count(
853
+ *,
854
+ count: int,
855
+ expected_count: int | None,
856
+ index: int,
857
+ op: str,
858
+ ) -> None:
859
+ if expected_count is None:
860
+ if count == 1:
861
+ return
862
+ if count == 0:
863
+ raise FsError(f"Edit {index} ({op}) target matched 0 times; expected exactly 1")
864
+ raise FsError(
865
+ f"Edit {index} ({op}) target matched {count} times; expected exactly 1. "
866
+ "Set expected_match_count to allow this."
867
+ )
868
+ if count != expected_count:
869
+ raise FsError(
870
+ f"Edit {index} ({op}) target matched {count} times; expected {expected_count}"
871
+ )
872
+
873
+
874
+ def _content_lines(content: str) -> list[str]:
875
+ return content.splitlines(keepends=True)
876
+
877
+
878
+ def _validate_line_range(
879
+ *,
880
+ lines: list[str],
881
+ start_line: int,
882
+ end_line: int,
883
+ index: int,
884
+ op: str,
885
+ ) -> None:
886
+ total_lines = len(lines)
887
+ if end_line < start_line:
888
+ raise FsError(
889
+ f"Edit {index} ({op}) end_line ({end_line}) must be >= start_line ({start_line})"
890
+ )
891
+ if start_line > total_lines:
892
+ raise FsError(
893
+ f"Edit {index} ({op}) start_line {start_line} is beyond end of file "
894
+ f"({total_lines} lines)"
895
+ )
896
+ if end_line > total_lines:
897
+ raise FsError(
898
+ f"Edit {index} ({op}) end_line {end_line} is beyond end of file ({total_lines} lines)"
899
+ )
900
+
901
+
902
+ def _line_selection(lines: list[str], *, start_line: int, end_line: int) -> str:
903
+ return "".join(lines[start_line - 1 : end_line])
904
+
905
+
906
+ def _canonical_line_text(text: str) -> str:
907
+ return text.replace("\r\n", "\n").replace("\r", "\n")
908
+
909
+
910
+ def _validate_expected_old(
911
+ *,
912
+ selected: str,
913
+ edit: dict[str, Any],
914
+ index: int,
915
+ op: str,
916
+ ) -> None:
917
+ expected_old = edit.get("expected_old")
918
+ if expected_old is None:
919
+ return
920
+ if not isinstance(expected_old, str):
921
+ raise FsError(f"Edit {index} ({op}) expected_old must be a string when provided")
922
+ if selected != expected_old and _canonical_line_text(selected) != _canonical_line_text(
923
+ expected_old
924
+ ):
925
+ selected_preview = selected[:500].replace("\n", "\\n")
926
+ expected_preview = expected_old[:500].replace("\n", "\\n")
927
+ raise FsError(
928
+ f"Edit {index} ({op}) selected line text did not match expected_old. "
929
+ f"selected={selected_preview!r} expected={expected_preview!r}"
930
+ )
931
+
932
+
933
+ def _apply_line_edit(content: str, edit: dict[str, Any], *, index: int, op: str) -> str:
934
+ lines = _content_lines(content)
935
+ total_lines = len(lines)
936
+
937
+ if op == "replace_lines":
938
+ start_line = _require_edit_line_number(edit, "start_line", index=index, op=op)
939
+ end_line = _require_edit_line_number(edit, "end_line", index=index, op=op)
940
+ _validate_line_range(
941
+ lines=lines,
942
+ start_line=start_line,
943
+ end_line=end_line,
944
+ index=index,
945
+ op=op,
946
+ )
947
+ selected = _line_selection(lines, start_line=start_line, end_line=end_line)
948
+ _validate_expected_old(selected=selected, edit=edit, index=index, op=op)
949
+ replacement = _require_edit_string(edit, "replacement", index=index, op=op)
950
+ return "".join(lines[: start_line - 1]) + replacement + "".join(lines[end_line:])
951
+
952
+ line = _require_edit_line_number(edit, "line", index=index, op=op)
953
+ if line > total_lines:
954
+ raise FsError(
955
+ f"Edit {index} ({op}) line {line} is beyond end of file ({total_lines} lines)"
956
+ )
957
+ insert_content = _require_edit_string(edit, "content", index=index, op=op)
958
+ if op == "insert_before_line":
959
+ return "".join(lines[: line - 1]) + insert_content + "".join(lines[line - 1 :])
960
+ return "".join(lines[:line]) + insert_content + "".join(lines[line:])
961
+
962
+
963
+ def _apply_single_fs_edit(content: str, edit: dict[str, Any], *, index: int) -> str:
964
+ raw_op = edit.get("op")
965
+ if not isinstance(raw_op, str):
966
+ raise FsError(f"Edit {index} is missing required string field: op")
967
+ op = _FS_EDIT_OPERATION_ALIASES.get(raw_op.strip(), raw_op.strip())
968
+ if op not in _FS_EDIT_OPERATIONS:
969
+ allowed = ", ".join(sorted(_FS_EDIT_OPERATIONS))
970
+ raise FsError(f"Edit {index} has unsupported op: {op!r}. Expected one of: {allowed}")
971
+
972
+ if op == "append":
973
+ return content + _require_edit_string(edit, "content", index=index, op=op)
974
+ if op == "prepend":
975
+ return _require_edit_string(edit, "content", index=index, op=op) + content
976
+ if op in {"replace_lines", "insert_before_line", "insert_after_line"}:
977
+ return _apply_line_edit(content, edit, index=index, op=op)
978
+
979
+ target = _require_edit_string(edit, "target", index=index, op=op)
980
+ replacement: str | None = None
981
+ insert_content: str | None = None
982
+ if op == "replace_exact":
983
+ replacement = _require_edit_string(edit, "replacement", index=index, op=op)
984
+ else:
985
+ insert_content = _require_edit_string(edit, "content", index=index, op=op)
986
+ expected_count = _optional_expected_match_count(edit, index=index, op=op)
987
+ count = _count_matches(content, target, index=index, op=op)
988
+ _validate_match_count(count=count, expected_count=expected_count, index=index, op=op)
989
+ if count == 0:
990
+ return content
991
+
992
+ if op == "replace_exact":
993
+ assert replacement is not None
994
+ return content.replace(target, replacement)
995
+
996
+ if op == "insert_before_exact":
997
+ assert insert_content is not None
998
+ return content.replace(target, insert_content + target)
999
+ assert insert_content is not None
1000
+ return content.replace(target, target + insert_content)
1001
+
1002
+
1003
+ def prepare_fs_edit(*, root: Path, path: str, edits: list[dict[str, Any]]) -> PreparedFsEdit:
1004
+ if not isinstance(edits, list) or not edits:
1005
+ raise FsError("edits must be a non-empty array of edit objects")
1006
+
1007
+ path_obj = _resolve_under_root(root, path)
1008
+ if not path_obj.exists():
1009
+ raise FsError(f"Not found: {path}")
1010
+ if path_obj.is_dir():
1011
+ raise FsError(f"Is a directory: {path}")
1012
+
1013
+ original_bytes, precondition = _snapshot_existing_file(path_obj, path)
1014
+ original_content = original_bytes.decode("utf-8", errors="replace")
1015
+ updated_content = original_content
1016
+ for index, raw_edit in enumerate(edits, start=1):
1017
+ if not isinstance(raw_edit, dict):
1018
+ raise FsError(f"Edit {index} must be an object")
1019
+ updated_content = _apply_single_fs_edit(updated_content, raw_edit, index=index)
1020
+
1021
+ return PreparedFsEdit(
1022
+ root_obj=root.resolve(),
1023
+ path=path,
1024
+ path_obj=path_obj,
1025
+ original_content=original_content,
1026
+ updated_content=updated_content,
1027
+ applied_edits=len(edits),
1028
+ precondition=precondition,
1029
+ )
1030
+
1031
+
1032
+ def write_prepared_fs_edit(prepared: PreparedFsEdit, *, root: Path | None = None) -> dict[str, Any]:
1033
+ effective_root = (root or prepared.root_obj).resolve()
1034
+ _atomic_replace_text(
1035
+ prepared.path_obj,
1036
+ prepared.updated_content,
1037
+ root=effective_root,
1038
+ precondition=prepared.precondition,
1039
+ )
1040
+ return {
1041
+ "path": prepared.path,
1042
+ "applied_edits": prepared.applied_edits,
1043
+ "changed": prepared.updated_content != prepared.original_content,
1044
+ "bytes": len(prepared.updated_content.encode("utf-8")),
1045
+ }
1046
+
1047
+
1048
+ def fs_edit(*, root: Path, path: str, edits: list[dict[str, Any]]) -> dict[str, Any]:
1049
+ prepared = prepare_fs_edit(root=root, path=path, edits=edits)
1050
+ return write_prepared_fs_edit(prepared, root=root)
1051
+
1052
+
1053
+ def _require_existing_file(path_obj: Path, user_path: str) -> None:
1054
+ if not path_obj.exists():
1055
+ raise FsError(f"Not found: {user_path}")
1056
+ if path_obj.is_dir():
1057
+ raise FsError(f"Is a directory: {user_path}")
1058
+
1059
+
1060
+ def _prepare_destination_file(
1061
+ destination_obj: Path, destination_path: str, *, overwrite: bool
1062
+ ) -> bool:
1063
+ overwritten = False
1064
+ if destination_obj.exists():
1065
+ if destination_obj.is_dir():
1066
+ raise FsError(f"Destination is a directory: {destination_path}")
1067
+ if not overwrite:
1068
+ raise FsError(f"Destination exists and overwrite is false: {destination_path}")
1069
+ overwritten = True
1070
+ destination_obj.parent.mkdir(parents=True, exist_ok=True)
1071
+ return overwritten
1072
+
1073
+
1074
+ def fs_move(
1075
+ *,
1076
+ root: Path,
1077
+ source_path: str,
1078
+ destination_path: str,
1079
+ overwrite: bool = False,
1080
+ source_precondition: FilePrecondition | None = None,
1081
+ destination_precondition: FilePrecondition | None = None,
1082
+ ) -> dict[str, Any]:
1083
+ source_obj = _resolve_under_root(root, source_path)
1084
+ destination_obj = _resolve_under_root(root, destination_path)
1085
+ if source_obj == destination_obj:
1086
+ raise FsError(f"Source and destination are the same: {source_path}")
1087
+ if source_precondition is not None:
1088
+ assert_file_precondition(root=root, precondition=source_precondition)
1089
+ if destination_precondition is not None:
1090
+ assert_file_precondition(root=root, precondition=destination_precondition)
1091
+ _require_existing_file(source_obj, source_path)
1092
+ overwritten = _prepare_destination_file(
1093
+ destination_obj,
1094
+ destination_path,
1095
+ overwrite=overwrite,
1096
+ )
1097
+ size = source_obj.stat().st_size
1098
+ # Parent creation and destination validation can take observable time.
1099
+ # These checks reject the common approval race cheaply; the displacement
1100
+ # transaction below verifies the actual inode removed from each name.
1101
+ if source_precondition is not None:
1102
+ assert_file_precondition(root=root, precondition=source_precondition)
1103
+ if destination_precondition is not None:
1104
+ assert_file_precondition(root=root, precondition=destination_precondition)
1105
+ effective_source = source_precondition or capture_file_precondition(root=root, path=source_path)
1106
+ effective_destination = destination_precondition or capture_file_precondition(
1107
+ root=root, path=destination_path
1108
+ )
1109
+ source_displaced = _displace_regular_file_if_matches(
1110
+ target_path=source_obj,
1111
+ precondition=effective_source,
1112
+ )
1113
+ assert source_displaced is not None
1114
+ destination_displaced: Path | None = None
1115
+ try:
1116
+ destination_displaced = _displace_regular_file_if_matches(
1117
+ target_path=destination_obj,
1118
+ precondition=effective_destination,
1119
+ )
1120
+ # Detect writes through an already-open source handle before publishing
1121
+ # the displaced inode at its new name.
1122
+ if not _displaced_file_matches(source_displaced, effective_source):
1123
+ raise StaleFileError(source_path)
1124
+ _publish_regular_move_no_replace(
1125
+ source_displaced,
1126
+ destination_obj,
1127
+ mode=effective_source.mode,
1128
+ )
1129
+ except Exception as exc:
1130
+ source_restored = _restore_displaced_no_replace(source_displaced, source_obj)
1131
+ destination_restored = True
1132
+ if destination_displaced is not None:
1133
+ destination_restored = _restore_displaced_no_replace(
1134
+ destination_displaced, destination_obj
1135
+ )
1136
+ if not source_restored or not destination_restored:
1137
+ raise FsError(
1138
+ "The move encountered concurrent changes; newer public content was preserved "
1139
+ "and a displaced recovery copy remains beside the affected file."
1140
+ ) from exc
1141
+ raise
1142
+ if destination_displaced is not None:
1143
+ destination_displaced.unlink(missing_ok=True)
1144
+ return {
1145
+ "source_path": source_path,
1146
+ "destination_path": destination_path,
1147
+ "moved": True,
1148
+ "overwritten": overwritten,
1149
+ "bytes": size,
1150
+ }
1151
+
1152
+
1153
+ def fs_copy(
1154
+ *,
1155
+ root: Path,
1156
+ source_path: str,
1157
+ destination_path: str,
1158
+ overwrite: bool = False,
1159
+ source_precondition: FilePrecondition | None = None,
1160
+ destination_precondition: FilePrecondition | None = None,
1161
+ ) -> dict[str, Any]:
1162
+ source_obj = _resolve_under_root(root, source_path)
1163
+ destination_obj = _resolve_under_root(root, destination_path)
1164
+ if source_obj == destination_obj:
1165
+ raise FsError(f"Source and destination are the same: {source_path}")
1166
+ if source_precondition is not None:
1167
+ assert_file_precondition(root=root, precondition=source_precondition)
1168
+ if destination_precondition is not None:
1169
+ assert_file_precondition(root=root, precondition=destination_precondition)
1170
+ _require_existing_file(source_obj, source_path)
1171
+ overwritten = _prepare_destination_file(
1172
+ destination_obj,
1173
+ destination_path,
1174
+ overwrite=overwrite,
1175
+ )
1176
+ source_data, current_source = _snapshot_existing_file(source_obj, source_path)
1177
+ effective_source = source_precondition or current_source
1178
+ if (
1179
+ current_source.content_sha256 != effective_source.content_sha256
1180
+ or current_source.identity_sha256 != effective_source.identity_sha256
1181
+ ):
1182
+ raise StaleFileError(source_path)
1183
+ effective_destination = destination_precondition or capture_file_precondition(
1184
+ root=root, path=destination_path
1185
+ )
1186
+ if not overwrite and effective_destination.exists:
1187
+ raise FsError(f"Destination exists and overwrite is false: {destination_path}")
1188
+ # The copied bytes are a coherent snapshot. Re-check the source before the
1189
+ # destination commit so an approval-time source edit is reported as stale.
1190
+ assert_file_precondition(root=root, precondition=effective_source)
1191
+ staged = _stage_bytes_sibling(
1192
+ destination_obj,
1193
+ source_data,
1194
+ mode=effective_source.mode,
1195
+ )
1196
+ try:
1197
+ assert_file_precondition(root=root, precondition=effective_destination)
1198
+ _commit_staged_regular_file(
1199
+ staged_path=staged,
1200
+ target_path=destination_obj,
1201
+ precondition=effective_destination,
1202
+ )
1203
+ finally:
1204
+ staged.unlink(missing_ok=True)
1205
+ return {
1206
+ "source_path": source_path,
1207
+ "destination_path": destination_path,
1208
+ "copied": True,
1209
+ "overwritten": overwritten,
1210
+ "bytes": len(source_data),
1211
+ }
1212
+
1213
+
1214
+ def fs_delete(
1215
+ *,
1216
+ root: Path,
1217
+ path: str,
1218
+ precondition: FilePrecondition | None = None,
1219
+ ) -> dict[str, Any]:
1220
+ path_obj = _resolve_under_root(root, path)
1221
+ if precondition is not None:
1222
+ assert_file_precondition(root=root, precondition=precondition)
1223
+ _require_existing_file(path_obj, path)
1224
+ size = path_obj.stat().st_size
1225
+ effective_precondition = precondition or capture_file_precondition(root=root, path=path)
1226
+ displaced = _displace_regular_file_if_matches(
1227
+ target_path=path_obj,
1228
+ precondition=effective_precondition,
1229
+ )
1230
+ assert displaced is not None
1231
+ if not _displaced_file_matches(displaced, effective_precondition):
1232
+ if not _restore_displaced_no_replace(displaced, path_obj):
1233
+ raise FsError(
1234
+ f"A concurrent edit was preserved at {path}; "
1235
+ f"the displaced version remains at {displaced.name}"
1236
+ )
1237
+ raise StaleFileError(path)
1238
+ if os.path.lexists(path_obj):
1239
+ # A writer recreated the name after the matching version was removed.
1240
+ # Preserve the new file and fail rather than reporting a clean delete.
1241
+ displaced.unlink(missing_ok=True)
1242
+ raise StaleFileError(path)
1243
+ displaced.unlink()
1244
+ return {
1245
+ "path": path,
1246
+ "deleted": True,
1247
+ "bytes": size,
1248
+ }
1249
+
1250
+
1251
+ def _find_git_marker_root(path: Path, *, boundary: Path) -> Path | None:
1252
+ boundary_abs = boundary.resolve()
1253
+ for candidate in (path, *path.parents):
1254
+ candidate_abs = candidate.resolve()
1255
+ try:
1256
+ candidate_abs.relative_to(boundary_abs)
1257
+ except ValueError:
1258
+ return None
1259
+ if (candidate_abs / ".git").exists():
1260
+ return candidate_abs
1261
+ return None
1262
+
1263
+
1264
+ def _git_repo_root(root: Path, *, boundary: Path) -> Path | None:
1265
+ marker_root = _find_git_marker_root(root, boundary=boundary)
1266
+ if marker_root is None:
1267
+ return None
1268
+ try:
1269
+ cp = subprocess.run(
1270
+ ["git", "-C", str(root), "rev-parse", "--show-toplevel"],
1271
+ check=False,
1272
+ capture_output=True,
1273
+ text=True,
1274
+ env=build_git_process_env(),
1275
+ timeout=_GIT_PROBE_TIMEOUT_S,
1276
+ )
1277
+ except (OSError, subprocess.TimeoutExpired):
1278
+ return None
1279
+ if cp.returncode != 0:
1280
+ return None
1281
+ raw_root = cp.stdout.strip()
1282
+ if not raw_root:
1283
+ return marker_root
1284
+ try:
1285
+ repo_root = Path(raw_root).resolve()
1286
+ repo_root.relative_to(boundary.resolve())
1287
+ except (OSError, ValueError):
1288
+ return marker_root
1289
+ return repo_root
1290
+
1291
+
1292
+ def _git_check_ignored(repo_root: Path, rel_paths: list[str]) -> set[str]:
1293
+ if not rel_paths:
1294
+ return set()
1295
+ try:
1296
+ cp = subprocess.run(
1297
+ ["git", "-C", str(repo_root), "check-ignore", "--stdin"],
1298
+ input="\n".join(rel_paths) + "\n",
1299
+ check=False,
1300
+ capture_output=True,
1301
+ text=True,
1302
+ env=build_git_process_env(),
1303
+ timeout=_GIT_PROBE_TIMEOUT_S,
1304
+ )
1305
+ except (OSError, subprocess.TimeoutExpired):
1306
+ return set()
1307
+ if cp.returncode not in (0, 1): # 1 means "no matches"
1308
+ return _fallback_gitignore_ignored_untracked(repo_root, rel_paths)
1309
+ ignored = {Path(line.strip()).as_posix() for line in cp.stdout.splitlines() if line.strip()}
1310
+ if not ignored:
1311
+ ignored = _fallback_gitignore_ignored_untracked(repo_root, rel_paths)
1312
+ return ignored
1313
+
1314
+
1315
+ def _git_tracked_paths(repo_root: Path, rel_paths: list[str]) -> set[str]:
1316
+ if not rel_paths:
1317
+ return set()
1318
+ try:
1319
+ cp = subprocess.run(
1320
+ ["git", "-C", str(repo_root), "ls-files", "-z", "--", *rel_paths],
1321
+ check=False,
1322
+ capture_output=True,
1323
+ text=True,
1324
+ env=build_git_process_env(),
1325
+ timeout=_GIT_PROBE_TIMEOUT_S,
1326
+ )
1327
+ except (OSError, subprocess.TimeoutExpired):
1328
+ return set()
1329
+ if cp.returncode != 0:
1330
+ return set()
1331
+ return {Path(item).as_posix() for item in cp.stdout.split("\0") if item}
1332
+
1333
+
1334
+ def _fallback_gitignore_ignored_untracked(repo_root: Path, rel_paths: list[str]) -> set[str]:
1335
+ ignored = _fallback_gitignore_ignored(repo_root, rel_paths)
1336
+ if ignored:
1337
+ ignored.difference_update(_git_tracked_paths(repo_root, rel_paths))
1338
+ return ignored
1339
+
1340
+
1341
+ def _fallback_gitignore_ignored(repo_root: Path, rel_paths: list[str]) -> set[str]:
1342
+ gitignore = repo_root / ".gitignore"
1343
+ try:
1344
+ raw_patterns = gitignore.read_text(encoding="utf-8").splitlines()
1345
+ except OSError:
1346
+ return set()
1347
+
1348
+ ignored: set[str] = set()
1349
+ normalized_paths = [Path(rel).as_posix() for rel in rel_paths]
1350
+ for raw in raw_patterns:
1351
+ pattern = raw.strip()
1352
+ if not pattern or pattern.startswith("#"):
1353
+ continue
1354
+ negated = pattern.startswith("!")
1355
+ if negated:
1356
+ pattern = pattern[1:].strip()
1357
+ if not pattern or pattern.endswith("/"):
1358
+ continue
1359
+ pattern = pattern.lstrip("/").replace("\\", "/")
1360
+ for rel in normalized_paths:
1361
+ name = rel.rsplit("/", 1)[-1]
1362
+ matched = fnmatch(rel, pattern) or ("/" not in pattern and fnmatch(name, pattern))
1363
+ if not matched:
1364
+ continue
1365
+ if negated:
1366
+ ignored.discard(rel)
1367
+ else:
1368
+ ignored.add(rel)
1369
+ return ignored
1370
+
1371
+
1372
+ def fs_list(
1373
+ *,
1374
+ root: Path,
1375
+ root_path: str = ".",
1376
+ globs: list[str] | None = None,
1377
+ ignore: list[str] | None = None,
1378
+ max_results: int = _DEFAULT_FS_LIST_MAX_RESULTS,
1379
+ ) -> dict[str, Any]:
1380
+ base = _resolve_under_root(root, root_path)
1381
+ patterns = globs or ["**/*"]
1382
+ ignore_set = set(ignore or [])
1383
+
1384
+ repo_root = _git_repo_root(base, boundary=root)
1385
+ entries: list[dict[str, Any]] = []
1386
+ truncated = False
1387
+ batch_size = max(256, max_results or 0)
1388
+ pending: list[tuple[Path, str, str | None]] = []
1389
+
1390
+ def _flush_pending() -> bool:
1391
+ nonlocal truncated
1392
+ ignored_by_git: set[str] = set()
1393
+ if repo_root:
1394
+ rels = [rel_git for _, _, rel_git in pending if rel_git]
1395
+ ignored_by_git = _git_check_ignored(repo_root, rels)
1396
+
1397
+ # Count visibility after gitignore filtering so returned entries fill the
1398
+ # visible result window and `truncated` only reflects hidden extra visible files.
1399
+ for path_obj, rel, rel_git in pending:
1400
+ if rel_git and rel_git in ignored_by_git:
1401
+ continue
1402
+ if len(entries) >= max_results:
1403
+ truncated = True
1404
+ return True
1405
+ try:
1406
+ size = path_obj.stat().st_size
1407
+ except OSError:
1408
+ size = None
1409
+ entries.append({"path": rel, "size": size})
1410
+ return False
1411
+
1412
+ for pat in patterns:
1413
+ for p in base.glob(pat):
1414
+ rel_path = p.relative_to(base)
1415
+ rel_parts = rel_path.parts
1416
+ if set(rel_parts) & _DEFAULT_IGNORE_DIRS:
1417
+ continue
1418
+ if any(seg in ignore_set for seg in rel_parts):
1419
+ continue
1420
+ if p.is_dir():
1421
+ continue
1422
+
1423
+ rel = rel_path.as_posix()
1424
+ rel_git: str | None = None
1425
+ if repo_root:
1426
+ try:
1427
+ rel_git_path = os.path.relpath(p, repo_root)
1428
+ except ValueError:
1429
+ rel_git = None
1430
+ else:
1431
+ if rel_git_path in {os.curdir, os.pardir} or rel_git_path.startswith(
1432
+ os.pardir + os.sep
1433
+ ):
1434
+ rel_git = None
1435
+ else:
1436
+ rel_git = Path(rel_git_path).as_posix()
1437
+
1438
+ pending.append((p, rel, rel_git))
1439
+ if len(pending) >= batch_size:
1440
+ if _flush_pending():
1441
+ pending.clear()
1442
+ break
1443
+ pending.clear()
1444
+ if truncated:
1445
+ break
1446
+
1447
+ if not truncated and pending:
1448
+ _flush_pending()
1449
+
1450
+ return {
1451
+ "root": os.fspath(base),
1452
+ "entries": entries,
1453
+ "truncated": truncated,
1454
+ "returned_count": len(entries),
1455
+ "max_results": max_results,
1456
+ }