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,654 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import socket
6
+ import threading
7
+ import time
8
+ import uuid
9
+ import weakref
10
+ from collections.abc import Callable
11
+ from contextlib import suppress
12
+ from dataclasses import replace
13
+ from pathlib import Path
14
+ from typing import Any, Literal
15
+
16
+ from ..atomic_io import atomic_write_json
17
+ from ..forge import RunPaths, now_iso
18
+ from .models import (
19
+ AssetAlreadyExistsError,
20
+ AssetError,
21
+ AssetNotFoundError,
22
+ AssetRecord,
23
+ ComprehensionRecord,
24
+ )
25
+ from .paths import (
26
+ asset_comprehension_current_path,
27
+ asset_comprehension_version_path,
28
+ )
29
+
30
+ ASSET_INDEX_SCHEMA_VERSION = 2
31
+ _LOCK_POLL_SECONDS = 0.02
32
+ _LOCK_TIMEOUT_SECONDS = 5.0
33
+ _PROCESS_LOCKS_GUARD = threading.Lock()
34
+ _PROCESS_LOCKS: weakref.WeakValueDictionary[str, threading.Lock] = weakref.WeakValueDictionary()
35
+
36
+
37
+ def _process_lock_for_path(path: Path) -> threading.Lock:
38
+ key = os.path.normcase(os.path.abspath(os.fspath(path)))
39
+ with _PROCESS_LOCKS_GUARD:
40
+ lock = _PROCESS_LOCKS.get(key)
41
+ if lock is None:
42
+ lock = threading.Lock()
43
+ _PROCESS_LOCKS[key] = lock
44
+ return lock
45
+
46
+
47
+ class AssetIndex:
48
+ def __init__(self, run_paths: RunPaths) -> None:
49
+ self.run_paths = run_paths
50
+
51
+ def load(self) -> dict[str, Any]:
52
+ path = self.run_paths.assets_index_path
53
+ try:
54
+ raw_text = path.read_text(encoding="utf-8")
55
+ except FileNotFoundError:
56
+ return self._empty_payload()
57
+ try:
58
+ payload = json.loads(raw_text)
59
+ except json.JSONDecodeError as e:
60
+ raise AssetError(f"Invalid asset index JSON: {path}") from e
61
+ if not isinstance(payload, dict):
62
+ raise AssetError(f"Invalid asset index structure: {path}")
63
+ schema_version = int(payload.get("schema_version") or 0)
64
+ if schema_version != ASSET_INDEX_SCHEMA_VERSION:
65
+ raise AssetError(
66
+ "Unsupported asset index schema_version "
67
+ f"{schema_version}; run the legacy asset migration before loading this index."
68
+ )
69
+ assets = payload.get("assets")
70
+ if not isinstance(assets, list):
71
+ raise AssetError("Invalid asset index: assets must be an array")
72
+ by_sha256 = payload.get("by_sha256")
73
+ if not isinstance(by_sha256, dict):
74
+ raise AssetError("Invalid asset index: by_sha256 must be an object")
75
+ return payload
76
+
77
+ def records(self, *, include_deleted: bool = False) -> list[AssetRecord]:
78
+ records = [AssetRecord.from_dict(item) for item in self.load().get("assets", [])]
79
+ if include_deleted:
80
+ return records
81
+ return [record for record in records if record.deleted_at is None]
82
+
83
+ def get(self, asset_id: str, *, include_deleted: bool = False) -> AssetRecord:
84
+ for record in self.records(include_deleted=include_deleted):
85
+ if record.id == asset_id:
86
+ return record
87
+ raise AssetNotFoundError(f"Asset not found: {asset_id}")
88
+
89
+ def find_by_sha256(self, sha256: str, *, include_deleted: bool = False) -> AssetRecord | None:
90
+ digest = sha256.strip().lower()
91
+ for record in self.records(include_deleted=include_deleted):
92
+ if record.sha256 == digest:
93
+ return record
94
+ return None
95
+
96
+ def add(
97
+ self,
98
+ record: AssetRecord,
99
+ *,
100
+ dedupe_policy: Literal["reject", "link"] = "reject",
101
+ ) -> AssetRecord:
102
+ with _AssetIndexLock(self.run_paths.assets_index_lock_path):
103
+ payload = self.load()
104
+ existing = self._find_active_by_sha(payload, record.sha256)
105
+ if existing is not None:
106
+ if dedupe_policy == "link":
107
+ return existing
108
+ raise AssetAlreadyExistsError(existing.id)
109
+ records = [AssetRecord.from_dict(item) for item in payload.get("assets", [])]
110
+ if any(item.id == record.id for item in records):
111
+ raise AssetError(f"Asset id collision: {record.id}")
112
+ records.append(record)
113
+ self._write_records(records)
114
+ return record
115
+
116
+ def add_with_publish(
117
+ self,
118
+ *,
119
+ sha256: str,
120
+ dedupe_policy: Literal["reject", "link"],
121
+ build_record: Callable[[set[str]], AssetRecord],
122
+ publish: Callable[[AssetRecord], None],
123
+ rollback: Callable[[AssetRecord], None] | None = None,
124
+ ) -> AssetRecord:
125
+ with _AssetIndexLock(self.run_paths.assets_index_lock_path):
126
+ payload = self.load()
127
+ existing = self._find_active_by_sha(payload, sha256)
128
+ if existing is not None:
129
+ if dedupe_policy == "link":
130
+ return existing
131
+ raise AssetAlreadyExistsError(existing.id)
132
+ records = [AssetRecord.from_dict(item) for item in payload.get("assets", [])]
133
+ record = build_record({item.id for item in records})
134
+ if any(item.id == record.id for item in records):
135
+ raise AssetError(f"Asset id collision: {record.id}")
136
+ try:
137
+ publish(record)
138
+ records.append(record)
139
+ self._write_records(records)
140
+ except Exception:
141
+ if rollback is not None:
142
+ rollback(record)
143
+ raise
144
+ return record
145
+
146
+ def update(self, record: AssetRecord) -> AssetRecord:
147
+ with _AssetIndexLock(self.run_paths.assets_index_lock_path):
148
+ records = self.records(include_deleted=True)
149
+ updated: list[AssetRecord] = []
150
+ found = False
151
+ for current in records:
152
+ if current.id == record.id:
153
+ updated.append(record)
154
+ found = True
155
+ else:
156
+ updated.append(current)
157
+ if not found:
158
+ raise AssetNotFoundError(f"Asset not found: {record.id}")
159
+ self._write_records(updated)
160
+ return record
161
+
162
+ def delete(self, asset_id: str) -> AssetRecord:
163
+ with _AssetIndexLock(self.run_paths.assets_index_lock_path):
164
+ records = self.records(include_deleted=True)
165
+ updated: list[AssetRecord] = []
166
+ deleted: AssetRecord | None = None
167
+ for current in records:
168
+ if current.id == asset_id:
169
+ deleted = (
170
+ current if current.deleted_at else replace(current, deleted_at=now_iso())
171
+ )
172
+ updated.append(deleted)
173
+ else:
174
+ updated.append(current)
175
+ if deleted is None:
176
+ raise AssetNotFoundError(f"Asset not found: {asset_id}")
177
+ self._write_records(updated)
178
+ return deleted
179
+
180
+ def update_comprehension_version(
181
+ self,
182
+ asset_id: str,
183
+ *,
184
+ status: Literal["ready", "failed", "minimal"],
185
+ version: int,
186
+ ) -> AssetRecord:
187
+ current = self.get(asset_id, include_deleted=True)
188
+ return self.update(
189
+ replace(
190
+ current,
191
+ comprehension_status=status,
192
+ comprehension_current_version=version,
193
+ )
194
+ )
195
+
196
+ def write_comprehension(self, record: ComprehensionRecord) -> ComprehensionRecord:
197
+ with _AssetIndexLock(self.run_paths.assets_index_lock_path):
198
+ asset = self.get(record.asset_id, include_deleted=True)
199
+ current_version = max(
200
+ int(asset.comprehension_current_version or 0),
201
+ _latest_comprehension_version(self.run_paths, record.asset_id) or 0,
202
+ )
203
+ next_version = current_version + 1
204
+ versioned = replace(record, version=next_version)
205
+ version_path = asset_comprehension_version_path(
206
+ self.run_paths,
207
+ record.asset_id,
208
+ next_version,
209
+ )
210
+ current_path = asset_comprehension_current_path(self.run_paths, record.asset_id)
211
+ atomic_write_json(version_path, versioned.to_dict())
212
+ records = self.records(include_deleted=True)
213
+ updated = [
214
+ replace(
215
+ item,
216
+ comprehension_status=versioned.status,
217
+ comprehension_current_version=next_version,
218
+ )
219
+ if item.id == record.asset_id
220
+ else item
221
+ for item in records
222
+ ]
223
+ self._write_records(updated)
224
+ atomic_write_json(
225
+ current_path,
226
+ {
227
+ "asset_id": record.asset_id,
228
+ "updated_at": now_iso(),
229
+ "version": next_version,
230
+ },
231
+ )
232
+ return versioned
233
+
234
+ def read_comprehension(self, asset_id: str, version: int | None = None) -> ComprehensionRecord:
235
+ if version is None:
236
+ candidate_versions: list[int] = []
237
+ pointer_path = asset_comprehension_current_path(self.run_paths, asset_id)
238
+ try:
239
+ pointer = json.loads(pointer_path.read_text(encoding="utf-8"))
240
+ except FileNotFoundError:
241
+ pointer = None
242
+ if pointer is not None:
243
+ if not isinstance(pointer, dict):
244
+ raise AssetError(f"Invalid comprehension pointer: {pointer_path}")
245
+ pointer_version = int(pointer.get("version") or 0)
246
+ if (
247
+ pointer_version > 0
248
+ and asset_comprehension_version_path(
249
+ self.run_paths, asset_id, pointer_version
250
+ ).exists()
251
+ ):
252
+ candidate_versions.append(pointer_version)
253
+ try:
254
+ asset_version = int(
255
+ self.get(asset_id, include_deleted=True).comprehension_current_version or 0
256
+ )
257
+ except AssetNotFoundError:
258
+ asset_version = 0
259
+ if (
260
+ asset_version > 0
261
+ and asset_comprehension_version_path(
262
+ self.run_paths, asset_id, asset_version
263
+ ).exists()
264
+ ):
265
+ candidate_versions.append(asset_version)
266
+ latest_version = _latest_comprehension_version(self.run_paths, asset_id)
267
+ if latest_version is not None:
268
+ candidate_versions.append(latest_version)
269
+ if not candidate_versions:
270
+ raise AssetNotFoundError(f"No comprehension exists for asset: {asset_id}")
271
+ version = max(candidate_versions)
272
+ if version <= 0:
273
+ raise AssetNotFoundError(f"No comprehension version exists for asset: {asset_id}")
274
+ path = asset_comprehension_version_path(self.run_paths, asset_id, version)
275
+ try:
276
+ payload = json.loads(path.read_text(encoding="utf-8"))
277
+ except FileNotFoundError as e:
278
+ raise AssetNotFoundError(
279
+ f"Comprehension version not found: {asset_id} v{version}"
280
+ ) from e
281
+ if not isinstance(payload, dict):
282
+ raise AssetError(f"Invalid comprehension record: {path}")
283
+ return ComprehensionRecord.from_dict(payload)
284
+
285
+ def find_existing_comprehension_by_sha256(
286
+ self,
287
+ sha256: str,
288
+ *,
289
+ model: str | None,
290
+ role: str | None,
291
+ ) -> ComprehensionRecord | None:
292
+ digest = sha256.strip().lower()
293
+ for asset in self.records(include_deleted=False):
294
+ if asset.sha256 != digest:
295
+ continue
296
+ try:
297
+ comprehension = self.read_comprehension(asset.id)
298
+ except AssetError:
299
+ continue
300
+ if (
301
+ comprehension.status == "ready"
302
+ and comprehension.model == model
303
+ and comprehension.role == role
304
+ ):
305
+ return comprehension
306
+ return None
307
+
308
+ def diff_comprehension(
309
+ self,
310
+ asset_id: str,
311
+ version_a: int,
312
+ version_b: int,
313
+ ) -> dict[str, Any]:
314
+ left = self.read_comprehension(asset_id, version_a)
315
+ right = self.read_comprehension(asset_id, version_b)
316
+ left_data = left.data
317
+ right_data = right.data
318
+ changed_fields: dict[str, dict[str, Any]] = {}
319
+ for field_name in ("semantic_summary", "relations_hint", "classification", "confidence"):
320
+ left_value = getattr(left_data, field_name)
321
+ right_value = getattr(right_data, field_name)
322
+ if left_value != right_value:
323
+ changed_fields[field_name] = {"from": left_value, "to": right_value}
324
+ return {
325
+ "asset_id": asset_id,
326
+ "version_a": version_a,
327
+ "version_b": version_b,
328
+ "changed_fields": changed_fields,
329
+ "key_entities": _list_diff(left_data.key_entities, right_data.key_entities),
330
+ "stated_facts": _list_diff(left_data.stated_facts, right_data.stated_facts),
331
+ "stated_decisions": _list_diff(left_data.stated_decisions, right_data.stated_decisions),
332
+ "stated_constraints": _list_diff(
333
+ left_data.stated_constraints, right_data.stated_constraints
334
+ ),
335
+ "actionable_signals": _list_diff(
336
+ left_data.actionable_signals,
337
+ right_data.actionable_signals,
338
+ ),
339
+ "open_questions": _list_diff(left_data.open_questions, right_data.open_questions),
340
+ }
341
+
342
+ def _empty_payload(self) -> dict[str, Any]:
343
+ return {
344
+ "schema_version": ASSET_INDEX_SCHEMA_VERSION,
345
+ "run_id": self.run_paths.run_id,
346
+ "updated_at": now_iso(),
347
+ "assets": [],
348
+ "by_sha256": {},
349
+ }
350
+
351
+ def _write_records(self, records: list[AssetRecord]) -> None:
352
+ sorted_records = sorted(records, key=lambda item: (item.added_at, item.id))
353
+ by_sha256 = {
354
+ record.sha256: record.id
355
+ for record in sorted_records
356
+ if record.deleted_at is None and record.sha256
357
+ }
358
+ atomic_write_json(
359
+ self.run_paths.assets_index_path,
360
+ {
361
+ "schema_version": ASSET_INDEX_SCHEMA_VERSION,
362
+ "run_id": self.run_paths.run_id,
363
+ "updated_at": now_iso(),
364
+ "assets": [record.to_dict() for record in sorted_records],
365
+ "by_sha256": by_sha256,
366
+ },
367
+ )
368
+
369
+ @staticmethod
370
+ def _find_active_by_sha(payload: dict[str, Any], sha256: str) -> AssetRecord | None:
371
+ digest = sha256.strip().lower()
372
+ for item in payload.get("assets", []):
373
+ if not isinstance(item, dict):
374
+ continue
375
+ record = AssetRecord.from_dict(item)
376
+ if record.deleted_at is None and record.sha256 == digest:
377
+ return record
378
+ return None
379
+
380
+
381
+ class _AssetIndexLock:
382
+ def __init__(self, path: Path) -> None:
383
+ self.path = path
384
+ self.recovery_path = path.with_name(f"{path.name}.recovering")
385
+ self.owner_token = uuid.uuid4().hex
386
+ self._process_lock = _process_lock_for_path(path) if os.name == "nt" else None
387
+ self._process_lock_acquired = False
388
+
389
+ def __enter__(self) -> _AssetIndexLock:
390
+ if self._process_lock is not None:
391
+ self._process_lock.acquire()
392
+ self._process_lock_acquired = True
393
+ try:
394
+ self.path.parent.mkdir(parents=True, exist_ok=True)
395
+ payload = _build_lock_metadata(owner_token=self.owner_token, kind="lock")
396
+ payload_text = _metadata_text(payload)
397
+ deadline = time.monotonic() + _LOCK_TIMEOUT_SECONDS
398
+ while True:
399
+ _clear_stale_recovery_claim(self.recovery_path)
400
+ try:
401
+ _write_exclusive(self.path, payload_text)
402
+ return self
403
+ except OSError as exc:
404
+ if not _is_existing_lock_contention(exc, self.path):
405
+ raise
406
+ existing_metadata, existing_text = _read_lock_metadata_with_text(self.path)
407
+ if (
408
+ existing_metadata is not None
409
+ and existing_text is not None
410
+ and _lock_metadata_is_stale(existing_metadata)
411
+ and self._recover_stale_lock(
412
+ existing_metadata=existing_metadata,
413
+ existing_text=existing_text,
414
+ replacement_text=payload_text,
415
+ )
416
+ ):
417
+ return self
418
+ if time.monotonic() >= deadline:
419
+ raise AssetError(
420
+ f"Timed out waiting for asset index lock: {self.path}"
421
+ ) from None
422
+ time.sleep(_LOCK_POLL_SECONDS)
423
+ except BaseException:
424
+ self._release_process_lock()
425
+ raise
426
+
427
+ def __exit__(self, exc_type: object, exc: object, tb: object) -> None:
428
+ _ = exc_type
429
+ _ = exc
430
+ _ = tb
431
+ self.release()
432
+
433
+ def release(self) -> None:
434
+ try:
435
+ deadline = time.monotonic() + _LOCK_TIMEOUT_SECONDS
436
+ while True:
437
+ try:
438
+ text = self.path.read_text(encoding="utf-8")
439
+ except FileNotFoundError:
440
+ return
441
+ except PermissionError:
442
+ if time.monotonic() >= deadline:
443
+ raise AssetError(
444
+ f"Timed out releasing asset index lock: {self.path}"
445
+ ) from None
446
+ time.sleep(_LOCK_POLL_SECONDS / 4)
447
+ continue
448
+ try:
449
+ metadata = json.loads(text)
450
+ except json.JSONDecodeError:
451
+ return
452
+ if not isinstance(metadata, dict):
453
+ return
454
+ if str(metadata.get("owner_token") or "") != self.owner_token:
455
+ return
456
+ try:
457
+ self.path.unlink()
458
+ return
459
+ except FileNotFoundError:
460
+ return
461
+ except PermissionError:
462
+ # Windows readers do not share delete access by default. A
463
+ # waiter inspecting this lock can therefore make unlink
464
+ # fail briefly even after the creating file descriptor is
465
+ # closed.
466
+ if time.monotonic() >= deadline:
467
+ raise AssetError(
468
+ f"Timed out releasing asset index lock: {self.path}"
469
+ ) from None
470
+ time.sleep(_LOCK_POLL_SECONDS / 4)
471
+ finally:
472
+ self._release_process_lock()
473
+
474
+ def _release_process_lock(self) -> None:
475
+ if not self._process_lock_acquired:
476
+ return
477
+ self._process_lock_acquired = False
478
+ if self._process_lock is not None:
479
+ self._process_lock.release()
480
+
481
+ def _recover_stale_lock(
482
+ self,
483
+ *,
484
+ existing_metadata: dict[str, Any],
485
+ existing_text: str,
486
+ replacement_text: str,
487
+ ) -> bool:
488
+ recovery_payload = _build_lock_metadata(
489
+ owner_token=self.owner_token,
490
+ kind="recovery",
491
+ recovery_reason="stale asset index lock",
492
+ observed_owner_token=str(existing_metadata.get("owner_token") or ""),
493
+ )
494
+ try:
495
+ _write_exclusive(self.recovery_path, _metadata_text(recovery_payload))
496
+ except OSError as exc:
497
+ if not _is_existing_lock_contention(exc, self.recovery_path):
498
+ raise
499
+ _clear_stale_recovery_claim(self.recovery_path)
500
+ return False
501
+ try:
502
+ current_metadata, current_text = _read_lock_metadata_with_text(self.path)
503
+ if current_metadata is None or current_text != existing_text:
504
+ return False
505
+ if not _lock_metadata_is_stale(current_metadata):
506
+ return False
507
+ try:
508
+ self.path.unlink()
509
+ except FileNotFoundError:
510
+ return False
511
+ try:
512
+ _write_exclusive(self.path, replacement_text)
513
+ except OSError as exc:
514
+ if not _is_existing_lock_contention(exc, self.path):
515
+ raise
516
+ return False
517
+ return True
518
+ finally:
519
+ recovery_metadata = _read_lock_metadata(self.recovery_path)
520
+ if str((recovery_metadata or {}).get("owner_token") or "") == self.owner_token:
521
+ with suppress(FileNotFoundError):
522
+ self.recovery_path.unlink()
523
+
524
+
525
+ def _write_exclusive(path: Path, text: str) -> None:
526
+ flags = os.O_CREAT | os.O_EXCL | os.O_WRONLY
527
+ fd = os.open(path, flags, 0o644)
528
+ try:
529
+ with os.fdopen(fd, "w", encoding="utf-8", newline="") as handle:
530
+ handle.write(text)
531
+ handle.flush()
532
+ os.fsync(handle.fileno())
533
+ except Exception:
534
+ with suppress(FileNotFoundError):
535
+ path.unlink()
536
+ raise
537
+
538
+
539
+ def _is_existing_lock_contention(error: OSError, path: Path) -> bool:
540
+ """Return true when exclusive-create lost a race to an existing lock.
541
+
542
+ POSIX reports ``EEXIST``/``FileExistsError`` for this case. Windows can
543
+ instead report ``EACCES``/``PermissionError`` while the winning thread is
544
+ still writing the newly created file. Treat that Windows-only shape as
545
+ normal contention only when the target now exists; genuine permission
546
+ failures on an absent target must continue to propagate.
547
+ """
548
+
549
+ return isinstance(error, FileExistsError) or (
550
+ os.name == "nt" and isinstance(error, PermissionError) and path.exists()
551
+ )
552
+
553
+
554
+ def _build_lock_metadata(
555
+ *,
556
+ owner_token: str,
557
+ kind: str,
558
+ recovery_reason: str | None = None,
559
+ observed_owner_token: str | None = None,
560
+ ) -> dict[str, Any]:
561
+ payload: dict[str, Any] = {
562
+ "schema_version": 1,
563
+ "pid": os.getpid(),
564
+ "hostname": socket.gethostname(),
565
+ "owner_token": owner_token,
566
+ "acquired_at": now_iso(),
567
+ "kind": kind,
568
+ }
569
+ if recovery_reason:
570
+ payload["recovery_reason"] = recovery_reason
571
+ if observed_owner_token:
572
+ payload["observed_owner_token"] = observed_owner_token
573
+ return payload
574
+
575
+
576
+ def _metadata_text(payload: dict[str, Any]) -> str:
577
+ return json.dumps(payload, indent=2, sort_keys=True) + "\n"
578
+
579
+
580
+ def _read_lock_metadata(path: Path) -> dict[str, Any] | None:
581
+ metadata, _text = _read_lock_metadata_with_text(path)
582
+ return metadata
583
+
584
+
585
+ def _read_lock_metadata_with_text(path: Path) -> tuple[dict[str, Any] | None, str | None]:
586
+ try:
587
+ text = path.read_text(encoding="utf-8")
588
+ raw = json.loads(text)
589
+ except (FileNotFoundError, OSError, json.JSONDecodeError):
590
+ return None, None
591
+ return (raw, text) if isinstance(raw, dict) else (None, text)
592
+
593
+
594
+ def _clear_stale_recovery_claim(path: Path) -> None:
595
+ metadata = _read_lock_metadata(path)
596
+ if metadata is not None and _lock_metadata_is_stale(metadata):
597
+ with suppress(FileNotFoundError):
598
+ path.unlink()
599
+
600
+
601
+ def _lock_is_stale(path: Path) -> bool:
602
+ metadata = _read_lock_metadata(path)
603
+ return metadata is not None and _lock_metadata_is_stale(metadata)
604
+
605
+
606
+ def _lock_metadata_is_stale(metadata: dict[str, Any]) -> bool:
607
+ if not metadata or str(metadata.get("hostname") or "") != socket.gethostname():
608
+ return False
609
+ try:
610
+ pid = int(metadata.get("pid") or 0)
611
+ except (TypeError, ValueError):
612
+ return False
613
+ if pid <= 0:
614
+ return False
615
+ try:
616
+ os.kill(pid, 0)
617
+ except ProcessLookupError:
618
+ return True
619
+ except (PermissionError, OSError):
620
+ return False
621
+ return False
622
+
623
+
624
+ def _latest_comprehension_version(run_paths: RunPaths, asset_id: str) -> int | None:
625
+ directory = run_paths.assets_comprehensions_dir / asset_id
626
+ try:
627
+ candidates = list(directory.glob("v*.json"))
628
+ except OSError:
629
+ return None
630
+ versions: list[int] = []
631
+ for path in candidates:
632
+ stem = path.stem
633
+ if not stem.startswith("v"):
634
+ continue
635
+ try:
636
+ version = int(stem[1:])
637
+ except ValueError:
638
+ continue
639
+ if version > 0:
640
+ versions.append(version)
641
+ return max(versions) if versions else None
642
+
643
+
644
+ def _list_diff(left: list[Any], right: list[Any]) -> dict[str, list[Any]]:
645
+ left_map = {_stable_item_key(item): item for item in left}
646
+ right_map = {_stable_item_key(item): item for item in right}
647
+ return {
648
+ "added": [right_map[key] for key in sorted(set(right_map) - set(left_map))],
649
+ "removed": [left_map[key] for key in sorted(set(left_map) - set(right_map))],
650
+ }
651
+
652
+
653
+ def _stable_item_key(value: Any) -> str:
654
+ return json.dumps(value, ensure_ascii=False, sort_keys=True)