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,1110 @@
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+ import hashlib
5
+ import json
6
+ import os
7
+ import re
8
+ import subprocess
9
+ import time
10
+ from collections.abc import Callable
11
+ from concurrent.futures import ThreadPoolExecutor, as_completed
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from ..branding import with_legacy_env_aliases
16
+ from .audit import build_hook_audit_event
17
+ from .config import ResolvedHookConfig, ResolvedHookMatcherGroup
18
+ from .models import (
19
+ CanonicalHookEventName,
20
+ CommandHookSpec,
21
+ HookDispatchResult,
22
+ HookInvocationContext,
23
+ MutableHookDispatchState,
24
+ ParsedHookOutput,
25
+ )
26
+
27
+ _BLOCKING_HOOK_EVENTS: frozenset[str] = frozenset(
28
+ {"PreToolUse", "SessionStart", "UserPromptSubmit"}
29
+ )
30
+ _PARALLELIZABLE_EVENTS: frozenset[str] = frozenset(
31
+ {
32
+ "PostToolUse",
33
+ "PostWrite",
34
+ "TurnComplete",
35
+ "Notification",
36
+ "PreCompact",
37
+ "SessionEnd",
38
+ "SubagentStop",
39
+ }
40
+ )
41
+ _TOOL_MATCHER_EVENTS: frozenset[str] = frozenset({"PreToolUse", "PostToolUse", "SubagentStop"})
42
+ _SESSION_END_EVENT = "SessionEnd"
43
+ _SESSION_START_EVENT = "SessionStart"
44
+ _TURN_COMPLETE_EVENT = "TurnComplete"
45
+
46
+
47
+ def _legacy_event_alias(event_name: CanonicalHookEventName) -> str | None:
48
+ if event_name == _TURN_COMPLETE_EVENT:
49
+ return "Stop"
50
+ return None
51
+
52
+
53
+ def _normalize_text_messages(value: object) -> list[str]:
54
+ if value is None:
55
+ return []
56
+ if isinstance(value, str):
57
+ text = value.strip()
58
+ return [text] if text else []
59
+ if isinstance(value, list):
60
+ normalized: list[str] = []
61
+ for item in value:
62
+ if not isinstance(item, str):
63
+ continue
64
+ text = item.strip()
65
+ if text:
66
+ normalized.append(text)
67
+ return normalized
68
+ return []
69
+
70
+
71
+ def _coerce_decision(value: object) -> str:
72
+ if not isinstance(value, str):
73
+ return ""
74
+ return value.strip().lower()
75
+
76
+
77
+ _SECRET_KEY_PATTERNS: tuple[re.Pattern[str], ...] = (
78
+ re.compile(r".*_API_KEY$"),
79
+ re.compile(r".*_TOKEN$"),
80
+ re.compile(r".*_SECRET$"),
81
+ re.compile(r"^AWS_"),
82
+ re.compile(r"^OPENAI_"),
83
+ re.compile(r"^ANTHROPIC_"),
84
+ re.compile(r"^GITHUB_TOKEN$"),
85
+ re.compile(r"^GH_TOKEN$"),
86
+ )
87
+ _SAFE_ENV_BASELINE: frozenset[str] = frozenset(
88
+ {
89
+ "PATH",
90
+ "HOME",
91
+ "USER",
92
+ "LOGNAME",
93
+ "LANG",
94
+ "LC_ALL",
95
+ "LC_CTYPE",
96
+ "TZ",
97
+ "SHELL",
98
+ "TERM",
99
+ "PWD",
100
+ "TMPDIR",
101
+ }
102
+ )
103
+
104
+
105
+ def _is_secret_env_key(key: str) -> bool:
106
+ return any(p.match(key) for p in _SECRET_KEY_PATTERNS)
107
+
108
+
109
+ def _build_hook_env(
110
+ hook: CommandHookSpec, *, base: dict[str, str] | os._Environ[str]
111
+ ) -> dict[str, str]:
112
+ allow = set(hook.env_allow or ())
113
+ if hook.env_passthrough == "all":
114
+ env = dict(base)
115
+ elif hook.env_passthrough == "explicit":
116
+ env = {
117
+ k: v
118
+ for k, v in base.items()
119
+ # The legacy prefix passes through too, so a hook that reads a
120
+ # SYLLIPTOR_* variable the user still sets does not silently see
121
+ # nothing under `explicit` passthrough.
122
+ if k in _SAFE_ENV_BASELINE
123
+ or k in allow
124
+ or k.startswith("ALYSIS_")
125
+ or k.startswith("SYLLIPTOR_")
126
+ }
127
+ else: # safe
128
+ env = {k: v for k, v in base.items() if not _is_secret_env_key(k) or k in allow}
129
+ if hook.env:
130
+ env.update(hook.env)
131
+ return env
132
+
133
+
134
+ _TRUNCATABLE_PAYLOAD_KEYS: frozenset[str] = frozenset(
135
+ {"content", "patch", "diff", "stdout", "stderr", "output", "text", "body", "data"}
136
+ )
137
+ _DEFAULT_TRUNCATE_FIELD_BYTES = 65_536
138
+
139
+
140
+ def _trunc_marker(text: str) -> dict[str, Any]:
141
+ raw = text.encode("utf-8", errors="replace")
142
+ return {
143
+ "__truncated": True,
144
+ "bytes": len(raw),
145
+ "preview": text[:256],
146
+ "sha256": hashlib.sha256(raw).hexdigest(),
147
+ }
148
+
149
+
150
+ def _truncate_large_strings(payload: Any, *, max_field_bytes: int) -> Any:
151
+ if isinstance(payload, dict):
152
+ out: dict[str, Any] = {}
153
+ for k, v in payload.items():
154
+ if (
155
+ k in _TRUNCATABLE_PAYLOAD_KEYS
156
+ and isinstance(v, str)
157
+ and len(v.encode("utf-8")) > max_field_bytes
158
+ ):
159
+ out[k] = _trunc_marker(v)
160
+ else:
161
+ out[k] = _truncate_large_strings(v, max_field_bytes=max_field_bytes)
162
+ return out
163
+ if isinstance(payload, list):
164
+ return [_truncate_large_strings(i, max_field_bytes=max_field_bytes) for i in payload]
165
+ return payload
166
+
167
+
168
+ class HookDispatcher:
169
+ def __init__(
170
+ self,
171
+ *,
172
+ config: ResolvedHookConfig,
173
+ workspace_root: Path,
174
+ repo_root: Path,
175
+ session_id: str,
176
+ mode: str,
177
+ runtime_kind: str,
178
+ warning_callback: Callable[[str], None] | None = None,
179
+ log_callback: Callable[[str, dict[str, Any]], None] | None = None,
180
+ audit_callback: Callable[[dict[str, Any]], None] | None = None,
181
+ max_stdin_bytes: int = 1_048_576,
182
+ parallel_enabled: bool = True,
183
+ max_parallel_workers: int = 4,
184
+ ) -> None:
185
+ self.config = config
186
+ self.workspace_root = workspace_root.resolve()
187
+ self.repo_root = repo_root.resolve()
188
+ self.session_id = session_id
189
+ self.mode = str(mode)
190
+ self.runtime_kind = str(runtime_kind)
191
+ self.warning_callback = warning_callback
192
+ self.log_callback = log_callback
193
+ self.audit_callback = audit_callback
194
+ self.max_stdin_bytes = int(max_stdin_bytes)
195
+ self.parallel_enabled = bool(parallel_enabled)
196
+ self.max_parallel_workers = max(1, int(max_parallel_workers))
197
+ self._compiled_matchers: dict[tuple[str, str, str], re.Pattern[str]] = {}
198
+
199
+ @property
200
+ def has_any_hooks(self) -> bool:
201
+ return self.config.has_any_hooks
202
+
203
+ def fire_session_start(
204
+ self,
205
+ *,
206
+ cwd: Path,
207
+ active_workdir_relpath: str,
208
+ payload: dict[str, Any] | None = None,
209
+ session_source: str = "startup",
210
+ ) -> HookDispatchResult:
211
+ effective_payload = dict(payload or {})
212
+ effective_payload["session_source"] = session_source
213
+ return self._dispatch(
214
+ event_name="SessionStart",
215
+ cwd=cwd,
216
+ active_workdir_relpath=active_workdir_relpath,
217
+ payload=effective_payload,
218
+ )
219
+
220
+ def fire_session_end(
221
+ self,
222
+ *,
223
+ cwd: Path,
224
+ active_workdir_relpath: str,
225
+ payload: dict[str, Any] | None = None,
226
+ ) -> HookDispatchResult:
227
+ return self._dispatch(
228
+ event_name=_SESSION_END_EVENT,
229
+ cwd=cwd,
230
+ active_workdir_relpath=active_workdir_relpath,
231
+ payload=dict(payload or {}),
232
+ )
233
+
234
+ def fire_user_prompt_submit(
235
+ self,
236
+ *,
237
+ prompt: str,
238
+ image_paths: list[str] | None,
239
+ cwd: Path,
240
+ active_workdir_relpath: str,
241
+ ) -> HookDispatchResult:
242
+ return self._dispatch(
243
+ event_name="UserPromptSubmit",
244
+ cwd=cwd,
245
+ active_workdir_relpath=active_workdir_relpath,
246
+ payload={
247
+ "prompt": prompt,
248
+ "image_paths": list(image_paths or []),
249
+ },
250
+ )
251
+
252
+ def fire_pre_tool_use(
253
+ self,
254
+ *,
255
+ tool_name: str,
256
+ tool_input: dict[str, Any],
257
+ cwd: Path,
258
+ active_workdir_relpath: str,
259
+ step: int,
260
+ ) -> HookDispatchResult:
261
+ return self._dispatch(
262
+ event_name="PreToolUse",
263
+ cwd=cwd,
264
+ active_workdir_relpath=active_workdir_relpath,
265
+ step=step,
266
+ matcher_target=tool_name,
267
+ payload={"tool_name": tool_name, "tool_input": copy.deepcopy(tool_input)},
268
+ )
269
+
270
+ def fire_post_tool_use(
271
+ self,
272
+ *,
273
+ tool_name: str,
274
+ tool_input: dict[str, Any],
275
+ tool_response: dict[str, Any],
276
+ cwd: Path,
277
+ active_workdir_relpath: str,
278
+ step: int,
279
+ ) -> HookDispatchResult:
280
+ return self._dispatch(
281
+ event_name="PostToolUse",
282
+ cwd=cwd,
283
+ active_workdir_relpath=active_workdir_relpath,
284
+ step=step,
285
+ matcher_target=tool_name,
286
+ payload={
287
+ "tool_name": tool_name,
288
+ "tool_input": copy.deepcopy(tool_input),
289
+ "tool_response": copy.deepcopy(tool_response),
290
+ },
291
+ )
292
+
293
+ def fire_turn_complete(
294
+ self,
295
+ *,
296
+ cwd: Path,
297
+ active_workdir_relpath: str,
298
+ payload: dict[str, Any],
299
+ ) -> HookDispatchResult:
300
+ return self._dispatch(
301
+ event_name="TurnComplete",
302
+ cwd=cwd,
303
+ active_workdir_relpath=active_workdir_relpath,
304
+ payload=payload,
305
+ )
306
+
307
+ def fire_stop(
308
+ self,
309
+ *,
310
+ cwd: Path,
311
+ active_workdir_relpath: str,
312
+ payload: dict[str, Any],
313
+ ) -> HookDispatchResult:
314
+ return self.fire_turn_complete(
315
+ cwd=cwd,
316
+ active_workdir_relpath=active_workdir_relpath,
317
+ payload=payload,
318
+ )
319
+
320
+ def fire_notification(
321
+ self,
322
+ *,
323
+ cwd: Path,
324
+ active_workdir_relpath: str,
325
+ message: str,
326
+ level: str = "info",
327
+ cause: str = "",
328
+ payload: dict[str, Any] | None = None,
329
+ ) -> HookDispatchResult:
330
+ effective_payload = dict(payload or {})
331
+ effective_payload.setdefault("message", str(message or ""))
332
+ effective_payload.setdefault("level", str(level or "info"))
333
+ if cause:
334
+ effective_payload.setdefault("cause", str(cause))
335
+ return self._dispatch(
336
+ event_name="Notification",
337
+ cwd=cwd,
338
+ active_workdir_relpath=active_workdir_relpath,
339
+ payload=effective_payload,
340
+ )
341
+
342
+ def fire_pre_compact(
343
+ self,
344
+ *,
345
+ cwd: Path,
346
+ active_workdir_relpath: str,
347
+ trigger: str,
348
+ message_count: int,
349
+ payload: dict[str, Any] | None = None,
350
+ ) -> HookDispatchResult:
351
+ effective_payload = dict(payload or {})
352
+ effective_payload.setdefault("trigger", str(trigger or "auto"))
353
+ effective_payload.setdefault("message_count", int(message_count))
354
+ return self._dispatch(
355
+ event_name="PreCompact",
356
+ cwd=cwd,
357
+ active_workdir_relpath=active_workdir_relpath,
358
+ payload=effective_payload,
359
+ )
360
+
361
+ def fire_subagent_stop(
362
+ self,
363
+ *,
364
+ cwd: Path,
365
+ active_workdir_relpath: str,
366
+ tool_name: str,
367
+ subagent_name: str = "",
368
+ subagent_session_id: str = "",
369
+ status: str = "success",
370
+ exit_code: int | None = None,
371
+ payload: dict[str, Any] | None = None,
372
+ ) -> HookDispatchResult:
373
+ effective_payload = dict(payload or {})
374
+ effective_payload["tool_name"] = str(tool_name or "")
375
+ effective_payload["subagent_name"] = str(subagent_name or "")
376
+ effective_payload["subagent_session_id"] = str(subagent_session_id or "")
377
+ effective_payload["status"] = str(status or "")
378
+ if exit_code is not None:
379
+ effective_payload["exit_code"] = int(exit_code)
380
+ return self._dispatch(
381
+ event_name="SubagentStop",
382
+ cwd=cwd,
383
+ active_workdir_relpath=active_workdir_relpath,
384
+ matcher_target=str(tool_name or ""),
385
+ payload=effective_payload,
386
+ )
387
+
388
+ def _build_common_payload(
389
+ self,
390
+ *,
391
+ event_name: CanonicalHookEventName,
392
+ cwd: Path,
393
+ active_workdir_relpath: str,
394
+ hook_payload: dict[str, Any],
395
+ step: int | None,
396
+ ) -> dict[str, Any]:
397
+ legacy_event_name = _legacy_event_alias(event_name)
398
+ common_payload: dict[str, Any] = {
399
+ "hook_event_name": event_name,
400
+ "session_id": self.session_id,
401
+ "mode": self.mode,
402
+ "runtime_kind": self.runtime_kind,
403
+ "cwd": os.fspath(cwd),
404
+ "workspace_root": os.fspath(self.workspace_root),
405
+ "repo_root": os.fspath(self.repo_root),
406
+ "active_workdir_relpath": active_workdir_relpath,
407
+ }
408
+ if legacy_event_name is not None:
409
+ common_payload["legacy_hook_event_name"] = legacy_event_name
410
+ common_payload["hook_event_aliases"] = [legacy_event_name]
411
+ if step is not None:
412
+ common_payload["step"] = step
413
+ common_payload.update(hook_payload)
414
+ return common_payload
415
+
416
+ def _dispatch(
417
+ self,
418
+ *,
419
+ event_name: CanonicalHookEventName,
420
+ cwd: Path,
421
+ active_workdir_relpath: str,
422
+ payload: dict[str, Any],
423
+ matcher_target: str | None = None,
424
+ step: int | None = None,
425
+ ) -> HookDispatchResult:
426
+ groups = self.config.groups_for_event(event_name)
427
+ if not groups:
428
+ return HookDispatchResult()
429
+
430
+ blocking_supported = event_name in _BLOCKING_HOOK_EVENTS
431
+ use_parallel = (
432
+ self.parallel_enabled
433
+ and event_name in _PARALLELIZABLE_EVENTS
434
+ and self.max_parallel_workers > 1
435
+ )
436
+ if use_parallel:
437
+ return self._dispatch_parallel(
438
+ event_name=event_name,
439
+ cwd=cwd,
440
+ active_workdir_relpath=active_workdir_relpath,
441
+ payload=payload,
442
+ matcher_target=matcher_target,
443
+ step=step,
444
+ groups=groups,
445
+ blocking_supported=blocking_supported,
446
+ )
447
+
448
+ state = MutableHookDispatchState()
449
+ original_prompt = str(payload.get("prompt")) if "prompt" in payload else ""
450
+ current_prompt = original_prompt
451
+ current_tool_input = payload.get("tool_input")
452
+ if not isinstance(current_tool_input, dict):
453
+ current_tool_input = None
454
+
455
+ short_circuit = False
456
+ for group in groups:
457
+ if short_circuit:
458
+ break
459
+ if not self._matcher_matches(
460
+ group=group,
461
+ event_name=event_name,
462
+ matcher_target=matcher_target,
463
+ ):
464
+ continue
465
+
466
+ for hook in group.hooks:
467
+ if not self._hook_runtime_matches(hook):
468
+ continue
469
+ if not self._hook_session_source_matches(
470
+ hook=hook,
471
+ session_source=str(payload.get("session_source") or "").strip().lower(),
472
+ ):
473
+ continue
474
+
475
+ hook_payload = dict(payload)
476
+ if event_name == "UserPromptSubmit":
477
+ hook_payload["prompt"] = current_prompt
478
+ if current_tool_input is not None and event_name in _TOOL_MATCHER_EVENTS:
479
+ hook_payload["tool_input"] = copy.deepcopy(current_tool_input)
480
+
481
+ common_payload = self._build_common_payload(
482
+ event_name=event_name,
483
+ cwd=cwd,
484
+ active_workdir_relpath=active_workdir_relpath,
485
+ hook_payload=hook_payload,
486
+ step=step,
487
+ )
488
+
489
+ parsed = self._run_command_hook(
490
+ group=group,
491
+ hook=hook,
492
+ event_name=event_name,
493
+ payload=common_payload,
494
+ blocking_supported=blocking_supported,
495
+ )
496
+ state.additional_system_messages.extend(parsed.additional_system_messages)
497
+ state.additional_user_messages.extend(parsed.additional_user_messages)
498
+ state.system_notices.extend(parsed.system_notices)
499
+ if parsed.stop_reason:
500
+ state.stop_reason = parsed.stop_reason
501
+ if parsed.permission_decision:
502
+ state.permission_decision = parsed.permission_decision
503
+ if parsed.permission_decision_reason:
504
+ state.permission_decision_reason = parsed.permission_decision_reason
505
+
506
+ if parsed.modified_prompt is not None and event_name == "UserPromptSubmit":
507
+ current_prompt = parsed.modified_prompt
508
+ state.modified_prompt = parsed.modified_prompt
509
+ if parsed.modified_input is not None and event_name == "PreToolUse":
510
+ current_tool_input = parsed.modified_input
511
+ state.modified_input = copy.deepcopy(parsed.modified_input)
512
+
513
+ if parsed.ask_requested and event_name == "PreToolUse":
514
+ state.ask_requested = True
515
+ state.ask_reason = parsed.ask_reason
516
+ return state.freeze()
517
+
518
+ if parsed.blocked:
519
+ if blocking_supported:
520
+ state.blocked = True
521
+ state.reason = parsed.reason
522
+ return state.freeze()
523
+ self._warn(
524
+ f"Ignoring blocking decision from {event_name} hook in {group.source_path}."
525
+ )
526
+ elif parsed.halt and not blocking_supported:
527
+ self._warn(
528
+ f"{event_name} hook requested halt via continue=false; stopping remaining "
529
+ f"hooks for this event ({group.source_path})."
530
+ )
531
+ short_circuit = True
532
+ break
533
+ elif parsed.allow_short_circuit:
534
+ short_circuit = True
535
+ break
536
+
537
+ if event_name == "UserPromptSubmit":
538
+ state.modified_prompt = current_prompt if current_prompt != original_prompt else None
539
+ if current_tool_input is not None and event_name == "PreToolUse":
540
+ original_input = payload.get("tool_input")
541
+ if isinstance(original_input, dict) and current_tool_input != original_input:
542
+ state.modified_input = copy.deepcopy(current_tool_input)
543
+ return state.freeze()
544
+
545
+ def _dispatch_parallel(
546
+ self,
547
+ *,
548
+ event_name: CanonicalHookEventName,
549
+ cwd: Path,
550
+ active_workdir_relpath: str,
551
+ payload: dict[str, Any],
552
+ matcher_target: str | None,
553
+ step: int | None,
554
+ groups: tuple[ResolvedHookMatcherGroup, ...],
555
+ blocking_supported: bool,
556
+ ) -> HookDispatchResult:
557
+ state = MutableHookDispatchState()
558
+ plan: list[tuple[int, ResolvedHookMatcherGroup, CommandHookSpec, dict[str, Any]]] = []
559
+ index = 0
560
+ session_source = str(payload.get("session_source") or "").strip().lower()
561
+ for group in groups:
562
+ if not self._matcher_matches(
563
+ group=group,
564
+ event_name=event_name,
565
+ matcher_target=matcher_target,
566
+ ):
567
+ continue
568
+ for hook in group.hooks:
569
+ if not self._hook_runtime_matches(hook):
570
+ continue
571
+ if not self._hook_session_source_matches(
572
+ hook=hook,
573
+ session_source=session_source,
574
+ ):
575
+ continue
576
+ hook_payload = dict(payload)
577
+ common_payload = self._build_common_payload(
578
+ event_name=event_name,
579
+ cwd=cwd,
580
+ active_workdir_relpath=active_workdir_relpath,
581
+ hook_payload=hook_payload,
582
+ step=step,
583
+ )
584
+ plan.append((index, group, hook, common_payload))
585
+ index += 1
586
+
587
+ if not plan:
588
+ return state.freeze()
589
+
590
+ results: dict[int, ParsedHookOutput] = {}
591
+ with ThreadPoolExecutor(max_workers=self.max_parallel_workers) as executor:
592
+ future_to_index = {
593
+ executor.submit(
594
+ self._run_command_hook,
595
+ group=group,
596
+ hook=hook,
597
+ event_name=event_name,
598
+ payload=common_payload,
599
+ blocking_supported=blocking_supported,
600
+ ): idx
601
+ for idx, group, hook, common_payload in plan
602
+ }
603
+ for future in as_completed(future_to_index):
604
+ idx = future_to_index[future]
605
+ results[idx] = future.result()
606
+
607
+ halt_detected = False
608
+ halt_source_path: str | None = None
609
+ for idx, group, _hook, _ in plan:
610
+ parsed = results[idx]
611
+ state.additional_system_messages.extend(parsed.additional_system_messages)
612
+ state.additional_user_messages.extend(parsed.additional_user_messages)
613
+ state.system_notices.extend(parsed.system_notices)
614
+ if parsed.stop_reason:
615
+ state.stop_reason = parsed.stop_reason
616
+ if parsed.permission_decision:
617
+ state.permission_decision = parsed.permission_decision
618
+ if parsed.permission_decision_reason:
619
+ state.permission_decision_reason = parsed.permission_decision_reason
620
+ if parsed.blocked and not blocking_supported:
621
+ self._warn(
622
+ f"Ignoring blocking decision from {event_name} hook in {group.source_path}."
623
+ )
624
+ if parsed.halt and not blocking_supported and not halt_detected:
625
+ halt_detected = True
626
+ halt_source_path = os.fspath(group.source_path)
627
+ if halt_detected and halt_source_path is not None:
628
+ self._warn(
629
+ f"{event_name} hook requested halt via continue=false; remaining hooks for this "
630
+ f"event already ran in parallel ({halt_source_path})."
631
+ )
632
+ return state.freeze()
633
+
634
+ def _hook_runtime_matches(self, hook: CommandHookSpec) -> bool:
635
+ if not hook.runtime_kinds:
636
+ return True
637
+ return self.runtime_kind in set(hook.runtime_kinds)
638
+
639
+ def _hook_session_source_matches(
640
+ self,
641
+ *,
642
+ hook: CommandHookSpec,
643
+ session_source: str,
644
+ ) -> bool:
645
+ if not hook.session_source:
646
+ return True
647
+ if not session_source:
648
+ return False
649
+ return session_source in set(hook.session_source)
650
+
651
+ def _matcher_matches(
652
+ self,
653
+ *,
654
+ group: ResolvedHookMatcherGroup,
655
+ event_name: CanonicalHookEventName,
656
+ matcher_target: str | None,
657
+ ) -> bool:
658
+ matcher = str(group.matcher or "")
659
+ if event_name not in _TOOL_MATCHER_EVENTS:
660
+ return True
661
+ if not matcher:
662
+ return True
663
+ if matcher_target is None:
664
+ return False
665
+ key = (event_name, matcher, os.fspath(group.source_path))
666
+ compiled = self._compiled_matchers.get(key)
667
+ if compiled is None:
668
+ try:
669
+ compiled = re.compile(matcher)
670
+ except re.error as exc:
671
+ self._warn(
672
+ f"Invalid {event_name} hook matcher {matcher!r} in {group.source_path}: {exc}"
673
+ )
674
+ self._compiled_matchers[key] = re.compile(r"(?!x)x")
675
+ return False
676
+ self._compiled_matchers[key] = compiled
677
+ return compiled.search(matcher_target) is not None
678
+
679
+ def _run_command_hook(
680
+ self,
681
+ *,
682
+ group: ResolvedHookMatcherGroup,
683
+ hook: CommandHookSpec,
684
+ event_name: CanonicalHookEventName,
685
+ payload: dict[str, Any],
686
+ blocking_supported: bool,
687
+ ) -> ParsedHookOutput:
688
+ payload_was_truncated = False
689
+ payload_bytes_original = 0
690
+ if not hook.receives_full_payload:
691
+ candidate = json.dumps(payload, ensure_ascii=True)
692
+ payload_bytes_original = len(candidate.encode("utf-8"))
693
+ if payload_bytes_original > self.max_stdin_bytes:
694
+ payload = _truncate_large_strings(
695
+ payload, max_field_bytes=_DEFAULT_TRUNCATE_FIELD_BYTES
696
+ )
697
+ payload_was_truncated = True
698
+ payload_json = json.dumps(payload, ensure_ascii=True)
699
+ env = _build_hook_env(hook, base=os.environ)
700
+ legacy_event_name = _legacy_event_alias(event_name)
701
+ env.update(
702
+ {
703
+ "ALYSIS_HOOK_EVENT_NAME": event_name,
704
+ "ALYSIS_SESSION_ID": self.session_id,
705
+ "ALYSIS_WORKSPACE_ROOT": os.fspath(self.workspace_root),
706
+ "ALYSIS_REPO_ROOT": os.fspath(self.repo_root),
707
+ "ALYSIS_ACTIVE_WORKDIR": str(payload.get("cwd") or os.fspath(self.workspace_root)),
708
+ "ALYSIS_ACTIVE_WORKDIR_RELPATH": str(payload.get("active_workdir_relpath") or "."),
709
+ }
710
+ )
711
+ if legacy_event_name is not None:
712
+ env["ALYSIS_HOOK_EVENT_ALIAS"] = legacy_event_name
713
+ tool_name = payload.get("tool_name")
714
+ if isinstance(tool_name, str) and tool_name.strip():
715
+ env["ALYSIS_TOOL_NAME"] = tool_name.strip()
716
+
717
+ # Hook commands are user-authored and predate the rename: a script that
718
+ # reads SYLLIPTOR_TOOL_NAME must keep working, so every ALYSIS_* value is
719
+ # also exported under its legacy name.
720
+ env = with_legacy_env_aliases(env)
721
+
722
+ try:
723
+ started_at = time.monotonic()
724
+ proc = subprocess.run(
725
+ hook.command,
726
+ shell=True,
727
+ cwd=os.fspath(self.workspace_root),
728
+ env=env,
729
+ input=payload_json,
730
+ text=True,
731
+ capture_output=True,
732
+ check=False,
733
+ timeout=hook.timeout,
734
+ )
735
+ duration_ms = max(0, int((time.monotonic() - started_at) * 1000))
736
+ except subprocess.TimeoutExpired:
737
+ message = (
738
+ f"{event_name} hook timed out after {hook.timeout:g}s: {hook.command!r} "
739
+ f"({group.source_path})"
740
+ )
741
+ self._log_hook(
742
+ HookInvocationContext(
743
+ event_name=event_name,
744
+ source_path=os.fspath(group.source_path),
745
+ source_scope=group.source_scope,
746
+ matcher=group.matcher,
747
+ hook_id=hook.id or "",
748
+ priority=hook.priority,
749
+ failure_policy=hook.failure_policy,
750
+ command=hook.command,
751
+ timeout_s=hook.timeout,
752
+ trusted=group.trusted,
753
+ status="blocked"
754
+ if blocking_supported and group.trusted and hook.failure_policy == "block"
755
+ else "warning",
756
+ warnings=(message,) if hook.failure_policy != "continue" else (),
757
+ duration_ms=max(0, int(hook.timeout * 1000)),
758
+ )
759
+ )
760
+ return self._hook_failure_output(
761
+ event_name=event_name,
762
+ message=message,
763
+ failure_policy=hook.failure_policy,
764
+ trusted=group.trusted,
765
+ blocking_supported=blocking_supported,
766
+ )
767
+ except OSError as exc:
768
+ message = (
769
+ f"Failed to execute {event_name} hook {hook.command!r} ({group.source_path}): {exc}"
770
+ )
771
+ self._log_hook(
772
+ HookInvocationContext(
773
+ event_name=event_name,
774
+ source_path=os.fspath(group.source_path),
775
+ source_scope=group.source_scope,
776
+ matcher=group.matcher,
777
+ hook_id=hook.id or "",
778
+ priority=hook.priority,
779
+ failure_policy=hook.failure_policy,
780
+ command=hook.command,
781
+ timeout_s=hook.timeout,
782
+ trusted=group.trusted,
783
+ status="blocked"
784
+ if blocking_supported and group.trusted and hook.failure_policy == "block"
785
+ else "warning",
786
+ warnings=(message,) if hook.failure_policy != "continue" else (),
787
+ duration_ms=0,
788
+ )
789
+ )
790
+ return self._hook_failure_output(
791
+ event_name=event_name,
792
+ message=message,
793
+ failure_policy=hook.failure_policy,
794
+ trusted=group.trusted,
795
+ blocking_supported=blocking_supported,
796
+ )
797
+
798
+ parsed = self._parse_hook_output(
799
+ event_name=event_name,
800
+ stdout_text=str(proc.stdout or ""),
801
+ stderr_text=str(proc.stderr or ""),
802
+ returncode=int(proc.returncode),
803
+ failure_policy=hook.failure_policy,
804
+ trusted=group.trusted,
805
+ blocking_supported=blocking_supported,
806
+ )
807
+ self._log_hook(
808
+ HookInvocationContext(
809
+ event_name=event_name,
810
+ source_path=os.fspath(group.source_path),
811
+ source_scope=group.source_scope,
812
+ matcher=group.matcher,
813
+ hook_id=hook.id or "",
814
+ priority=hook.priority,
815
+ failure_policy=hook.failure_policy,
816
+ command=hook.command,
817
+ timeout_s=hook.timeout,
818
+ trusted=group.trusted,
819
+ returncode=int(proc.returncode),
820
+ blocked=parsed.blocked,
821
+ modified_input=parsed.modified_input is not None,
822
+ modified_prompt=parsed.modified_prompt is not None,
823
+ additional_system_message_count=len(parsed.additional_system_messages),
824
+ additional_user_message_count=len(parsed.additional_user_messages),
825
+ system_notices_count=len(parsed.system_notices),
826
+ halt_requested=parsed.halt,
827
+ allow_short_circuited=parsed.allow_short_circuit,
828
+ suppress_output=parsed.suppress_output,
829
+ permission_decision=parsed.permission_decision,
830
+ stop_reason=parsed.stop_reason,
831
+ stdout_chars=0 if parsed.suppress_output else parsed.stdout_chars,
832
+ stderr_chars=parsed.stderr_chars,
833
+ modified_input_fields=tuple(sorted((parsed.modified_input or {}).keys())),
834
+ modified_prompt_chars=len(parsed.modified_prompt or ""),
835
+ duration_ms=duration_ms,
836
+ status=parsed.status,
837
+ warnings=parsed.warnings,
838
+ payload_truncated=payload_was_truncated,
839
+ payload_bytes=payload_bytes_original,
840
+ )
841
+ )
842
+ for warning in parsed.warnings:
843
+ self._warn(warning)
844
+ return parsed
845
+
846
+ def _parse_hook_output(
847
+ self,
848
+ *,
849
+ event_name: CanonicalHookEventName,
850
+ stdout_text: str,
851
+ stderr_text: str,
852
+ returncode: int,
853
+ failure_policy: str,
854
+ trusted: bool,
855
+ blocking_supported: bool,
856
+ ) -> ParsedHookOutput:
857
+ stdout_text = stdout_text.strip()
858
+ stderr_text = stderr_text.strip()
859
+ warnings: list[str] = []
860
+ output_obj: dict[str, Any] = {}
861
+ blocked = False
862
+ reason = stderr_text or stdout_text
863
+ status = "ok"
864
+
865
+ if stdout_text:
866
+ try:
867
+ raw_output = json.loads(stdout_text)
868
+ except json.JSONDecodeError:
869
+ raw_output = None
870
+ if returncode == 0:
871
+ warnings.append(
872
+ f"{event_name} hook ignored non-JSON stdout; hooks must return a JSON object."
873
+ )
874
+ if raw_output is not None:
875
+ if isinstance(raw_output, dict):
876
+ output_obj = raw_output
877
+ elif returncode == 0:
878
+ warnings.append(
879
+ f"{event_name} hook ignored JSON stdout that was not an object."
880
+ )
881
+
882
+ hook_specific = output_obj.get("hookSpecificOutput")
883
+ if not isinstance(hook_specific, dict):
884
+ hook_specific = {}
885
+
886
+ decision = _coerce_decision(output_obj.get("decision"))
887
+ if not decision:
888
+ decision = _coerce_decision(hook_specific.get("decision"))
889
+
890
+ perm_decision = _coerce_decision(hook_specific.get("permissionDecision"))
891
+ perm_decision_reason = str(hook_specific.get("permissionDecisionReason") or "").strip()
892
+
893
+ continue_value = output_obj.get("continue")
894
+ halt = continue_value is False
895
+ stop_reason_text = str(output_obj.get("stopReason") or "").strip()
896
+ if not stop_reason_text:
897
+ stop_reason_text = str(hook_specific.get("stopReason") or "").strip()
898
+
899
+ suppress_output = bool(output_obj.get("suppressOutput"))
900
+
901
+ system_notices: list[str] = []
902
+ system_message_raw = output_obj.get("systemMessage")
903
+ if isinstance(system_message_raw, str):
904
+ text = system_message_raw.strip()
905
+ if text:
906
+ system_notices.append(text)
907
+
908
+ allow_short_circuit = False
909
+ block_decisions = {"block", "blocked", "deny", "denied"}
910
+ allow_decisions = {"allow", "approve", "approved"}
911
+ if returncode == 2 or decision in block_decisions or perm_decision in block_decisions:
912
+ blocked = True
913
+ status = "blocked"
914
+ reason = (
915
+ str(output_obj.get("reason") or "").strip()
916
+ or str(hook_specific.get("reason") or "").strip()
917
+ or perm_decision_reason
918
+ or stderr_text
919
+ or stdout_text
920
+ or "blocked by hook"
921
+ )
922
+ elif halt and blocking_supported:
923
+ blocked = True
924
+ status = "blocked"
925
+ reason = stop_reason_text or reason or "halted by hook"
926
+ elif decision in allow_decisions or perm_decision in allow_decisions:
927
+ allow_short_circuit = True
928
+ elif returncode != 0:
929
+ warning_message = (
930
+ f"{event_name} hook failed with exit code {returncode}."
931
+ if not stderr_text
932
+ else f"{event_name} hook failed with exit code {returncode}: {stderr_text}"
933
+ )
934
+ return self._hook_failure_output(
935
+ event_name=event_name,
936
+ message=warning_message,
937
+ failure_policy=failure_policy,
938
+ trusted=trusted,
939
+ blocking_supported=blocking_supported,
940
+ stdout_chars=len(stdout_text),
941
+ stderr_chars=len(stderr_text),
942
+ reason=reason,
943
+ )
944
+
945
+ ask_decisions = {"ask"}
946
+ ask_requested = False
947
+ ask_reason = ""
948
+ if (
949
+ not blocked
950
+ and not allow_short_circuit
951
+ and (decision in ask_decisions or perm_decision in ask_decisions)
952
+ ):
953
+ ask_requested = True
954
+ ask_reason = (
955
+ perm_decision_reason
956
+ or str(output_obj.get("reason") or "").strip()
957
+ or stop_reason_text
958
+ or "hook requested approval"
959
+ )
960
+
961
+ modified_input = output_obj.get("modifiedInput")
962
+ if modified_input is None:
963
+ modified_input = hook_specific.get("modifiedInput")
964
+ if modified_input is not None and not isinstance(modified_input, dict):
965
+ warnings.append(
966
+ f"{event_name} hook ignored modifiedInput because it was not an object."
967
+ )
968
+ modified_input = None
969
+
970
+ modified_prompt = output_obj.get("modifiedPrompt")
971
+ if modified_prompt is None:
972
+ modified_prompt = hook_specific.get("modifiedPrompt")
973
+ if modified_prompt is not None:
974
+ if isinstance(modified_prompt, str):
975
+ modified_prompt = modified_prompt.strip()
976
+ else:
977
+ warnings.append(
978
+ f"{event_name} hook ignored modifiedPrompt because it was not a string."
979
+ )
980
+ modified_prompt = None
981
+
982
+ additional_system_messages: list[str] = []
983
+ additional_system_messages.extend(
984
+ _normalize_text_messages(output_obj.get("additionalContext"))
985
+ )
986
+ additional_system_messages.extend(
987
+ _normalize_text_messages(output_obj.get("additionalSystemMessages"))
988
+ )
989
+ additional_system_messages.extend(
990
+ _normalize_text_messages(hook_specific.get("additionalContext"))
991
+ )
992
+ additional_system_messages.extend(
993
+ _normalize_text_messages(hook_specific.get("additionalSystemMessages"))
994
+ )
995
+ additional_user_messages = _normalize_text_messages(
996
+ output_obj.get("additionalUserMessages")
997
+ )
998
+ additional_user_messages.extend(
999
+ _normalize_text_messages(hook_specific.get("additionalUserMessages"))
1000
+ )
1001
+
1002
+ if returncode == 0 and warnings:
1003
+ status = "warning"
1004
+
1005
+ return ParsedHookOutput(
1006
+ blocked=blocked,
1007
+ reason=reason,
1008
+ modified_input=copy.deepcopy(modified_input)
1009
+ if isinstance(modified_input, dict)
1010
+ else None,
1011
+ modified_prompt=modified_prompt,
1012
+ additional_system_messages=tuple(additional_system_messages),
1013
+ additional_user_messages=tuple(additional_user_messages),
1014
+ system_notices=tuple(system_notices),
1015
+ halt=halt,
1016
+ stop_reason=stop_reason_text,
1017
+ allow_short_circuit=allow_short_circuit,
1018
+ suppress_output=suppress_output,
1019
+ permission_decision=perm_decision or "",
1020
+ permission_decision_reason=perm_decision_reason,
1021
+ stdout_chars=len(stdout_text),
1022
+ stderr_chars=len(stderr_text),
1023
+ status=status,
1024
+ warnings=tuple(warnings),
1025
+ ask_requested=ask_requested,
1026
+ ask_reason=ask_reason,
1027
+ )
1028
+
1029
+ def _hook_failure_output(
1030
+ self,
1031
+ *,
1032
+ event_name: CanonicalHookEventName,
1033
+ message: str,
1034
+ failure_policy: str,
1035
+ trusted: bool,
1036
+ blocking_supported: bool,
1037
+ stdout_chars: int = 0,
1038
+ stderr_chars: int = 0,
1039
+ reason: str = "",
1040
+ ) -> ParsedHookOutput:
1041
+ if failure_policy == "block" and trusted and blocking_supported:
1042
+ return ParsedHookOutput(
1043
+ blocked=True,
1044
+ reason=reason or message,
1045
+ stdout_chars=stdout_chars,
1046
+ stderr_chars=stderr_chars,
1047
+ status="blocked",
1048
+ )
1049
+ warnings = () if failure_policy == "continue" else (message,)
1050
+ return ParsedHookOutput(
1051
+ blocked=False,
1052
+ reason=reason or message,
1053
+ stdout_chars=stdout_chars,
1054
+ stderr_chars=stderr_chars,
1055
+ status="warning" if warnings else "ok",
1056
+ warnings=warnings,
1057
+ )
1058
+
1059
+ def _log_hook(self, context: HookInvocationContext) -> None:
1060
+ if self.log_callback is None:
1061
+ pass
1062
+ else:
1063
+ self.log_callback(
1064
+ "hook_command",
1065
+ {
1066
+ "event_name": context.event_name,
1067
+ "source_path": context.source_path,
1068
+ "source_scope": context.source_scope,
1069
+ "matcher": context.matcher,
1070
+ "hook_id": context.hook_id,
1071
+ "priority": context.priority,
1072
+ "failure_policy": context.failure_policy,
1073
+ "command": context.command,
1074
+ "timeout_s": context.timeout_s,
1075
+ "trusted": context.trusted,
1076
+ "returncode": context.returncode,
1077
+ "blocked": context.blocked,
1078
+ "modified_input": context.modified_input,
1079
+ "modified_input_fields": list(context.modified_input_fields),
1080
+ "modified_prompt": context.modified_prompt,
1081
+ "modified_prompt_chars": context.modified_prompt_chars,
1082
+ "additional_system_message_count": context.additional_system_message_count,
1083
+ "additional_user_message_count": context.additional_user_message_count,
1084
+ "system_notices_count": context.system_notices_count,
1085
+ "halt_requested": context.halt_requested,
1086
+ "allow_short_circuited": context.allow_short_circuited,
1087
+ "suppress_output": context.suppress_output,
1088
+ "permission_decision": context.permission_decision,
1089
+ "stop_reason": context.stop_reason,
1090
+ "stdout_chars": context.stdout_chars,
1091
+ "stderr_chars": context.stderr_chars,
1092
+ "duration_ms": context.duration_ms,
1093
+ "status": context.status,
1094
+ "warnings": list(context.warnings),
1095
+ "payload_truncated": context.payload_truncated,
1096
+ "payload_bytes": context.payload_bytes,
1097
+ },
1098
+ )
1099
+ if self.audit_callback is not None:
1100
+ self.audit_callback(build_hook_audit_event(session_id=self.session_id, context=context))
1101
+
1102
+ def _warn(self, message: str) -> None:
1103
+ clean = str(message or "").strip()
1104
+ if not clean:
1105
+ return
1106
+ if self.warning_callback is not None:
1107
+ self.warning_callback(clean)
1108
+
1109
+
1110
+ __all__ = ["HookDispatcher"]