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,1647 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import platform
5
+ import re
6
+ import shutil
7
+ import webbrowser
8
+ import zipfile
9
+ from collections.abc import Callable
10
+ from dataclasses import dataclass
11
+ from datetime import UTC, datetime
12
+ from pathlib import Path
13
+ from typing import Any
14
+ from urllib.parse import urlencode, urlsplit, urlunsplit
15
+
16
+ from . import __version__
17
+ from .config import (
18
+ AppConfig,
19
+ resolve_feedback_github_enabled,
20
+ resolve_feedback_github_repo,
21
+ resolve_feedback_open_browser,
22
+ )
23
+ from .failure_category import (
24
+ empty_failure_category_counts,
25
+ increment_failure_category_count,
26
+ )
27
+ from .forge import (
28
+ ForgeError,
29
+ RunPaths,
30
+ current_run_pointer_path,
31
+ load_current_run_paths,
32
+ make_run_paths,
33
+ )
34
+ from .git_ops import GitOpsError, ensure_git_repo, ensure_runtime_artifact_excludes
35
+ from .serialized_paths import (
36
+ looks_like_serialized_path_field,
37
+ safe_serialized_path,
38
+ safe_serialized_path_field,
39
+ sanitize_paths_in_text,
40
+ )
41
+ from .session_metrics import score_session_events, score_session_log
42
+ from .session_store import (
43
+ filter_sessions_to_local_owner,
44
+ list_sessions,
45
+ read_session_events,
46
+ resolve_sessions_dir,
47
+ sanitize_session_id,
48
+ )
49
+ from .web_research import (
50
+ SessionWebResearchTracker,
51
+ build_web_research_metrics_from_artifact_payload,
52
+ web_research_artifact_has_activity,
53
+ )
54
+ from .workspace_context import WorkspaceContextError, resolve_workspace_context
55
+
56
+
57
+ class FeedbackReportError(RuntimeError):
58
+ pass
59
+
60
+
61
+ _SAFE_EXPORT_ID_RE = re.compile(r"^[A-Za-z0-9_-]+$")
62
+ _HIERARCHICAL_URL_RE = re.compile(
63
+ r"\b[A-Za-z][A-Za-z0-9+.-]*://\S+",
64
+ )
65
+ _ISSUE_TITLE_MAX_CHARS = 96
66
+ _ISSUE_FEEDBACK_MAX_CHARS = 1400
67
+ _ISSUE_BODY_MAX_CHARS = 3600
68
+ _ISSUE_URL_MAX_CHARS = 8000
69
+ _SECRET_REPLACEMENTS: tuple[tuple[re.Pattern[str], str], ...] = (
70
+ (re.compile(r"sk-[A-Za-z0-9_\-]{16,}"), "[REDACTED]"),
71
+ (re.compile(r"(Bearer\s+)[A-Za-z0-9._\-]{8,}", re.IGNORECASE), r"\1[REDACTED]"),
72
+ (re.compile(r"(Authorization\s*:\s*)(.+)", re.IGNORECASE), r"\1[REDACTED]"),
73
+ (
74
+ re.compile(r"((?:ALYSIS_API_KEY|OPENAI_API_KEY)\s*[:=]\s*)([^\s\"']+)", re.IGNORECASE),
75
+ r"\1[REDACTED]",
76
+ ),
77
+ (
78
+ re.compile(r'((?:"(?:api_key|api-key|authorization)"\s*:\s*"))([^"]+)(")', re.IGNORECASE),
79
+ r"\1[REDACTED]\3",
80
+ ),
81
+ (
82
+ re.compile(r"((?:api_key|api-key)\s*[:=]\s*)([^\s,]+)", re.IGNORECASE),
83
+ r"\1[REDACTED]",
84
+ ),
85
+ )
86
+
87
+ # Provider continuation state is required inside a live session but must never
88
+ # leave the private session store through a support bundle. They can contain raw
89
+ # chain-of-thought, encrypted/redacted blocks, provider signatures, response
90
+ # items, and replay tokens. Drop the whole container rather than trying to
91
+ # maintain a provider-by-provider nested denylist.
92
+ _PROVIDER_CONTINUATION_CONTAINER_KEYS = frozenset(
93
+ {
94
+ "providermetadata",
95
+ "alysisprovidermetadata",
96
+ }
97
+ )
98
+ _REASONING_BLOCK_TYPES = frozenset(
99
+ {
100
+ "reasoning",
101
+ "reasoningencrypted",
102
+ "reasoningsummary",
103
+ "reasoningtext",
104
+ "redactedthinking",
105
+ "thinking",
106
+ "thinkchunk",
107
+ "thought",
108
+ }
109
+ )
110
+ _REASONING_FAMILIES = ("reasoning", "thinking", "thought")
111
+ _REASONING_SENSITIVE_MARKERS = (
112
+ "block",
113
+ "chunk",
114
+ "content",
115
+ "detail",
116
+ "encrypted",
117
+ "monologue",
118
+ "raw",
119
+ "redacted",
120
+ "signature",
121
+ "summary",
122
+ "text",
123
+ )
124
+ _REASONING_DIAGNOSTIC_MARKERS = (
125
+ "active",
126
+ "adapter",
127
+ "available",
128
+ "availability",
129
+ "budget",
130
+ "capability",
131
+ "confidence",
132
+ "config",
133
+ "count",
134
+ "display",
135
+ "effort",
136
+ "enabled",
137
+ "format",
138
+ "include",
139
+ "kind",
140
+ "level",
141
+ "mode",
142
+ "policy",
143
+ "requested",
144
+ "source",
145
+ "support",
146
+ "token",
147
+ "trace",
148
+ "type",
149
+ )
150
+ _OPAQUE_CONTINUATION_KEYS = frozenset(
151
+ {
152
+ "cachedcontent",
153
+ "continuation",
154
+ "continuationdata",
155
+ "continuationpayload",
156
+ "continuationstate",
157
+ "continuationtoken",
158
+ "encryptedcontent",
159
+ "encryptedreasoning",
160
+ "encryptedthinking",
161
+ "opaquecontinuation",
162
+ "opaquepayload",
163
+ "opaquestate",
164
+ "previousresponseid",
165
+ "redactedcontent",
166
+ "redactedthinking",
167
+ }
168
+ )
169
+ _INVALID_STRUCTURED_EXPORT = {
170
+ "redacted": "Invalid structured artifact omitted from support bundle."
171
+ }
172
+
173
+
174
+ @dataclass(frozen=True)
175
+ class FeedbackBundleResult:
176
+ bundle_dir: Path
177
+ zip_path: Path
178
+ output_root: Path
179
+ workspace_root: Path
180
+ session_id: str | None
181
+ run_id: str | None
182
+
183
+
184
+ @dataclass(frozen=True)
185
+ class FeedbackGithubIssueResult:
186
+ repo: str
187
+ issue_url: str | None
188
+ opened: bool
189
+ open_attempted: bool
190
+ disabled_reason: str | None = None
191
+ open_error: str | None = None
192
+
193
+
194
+ @dataclass(frozen=True)
195
+ class _ResolvedSessionSource:
196
+ source: str
197
+ session_id: str | None
198
+ log_path: Path | None
199
+ log_meta_path: Path | None
200
+ artifact_root: Path | None
201
+ logging_enabled: bool
202
+ score_payload: dict[str, Any]
203
+ snapshot_payload: dict[str, Any] | None
204
+ web_research_payload: dict[str, Any] | None
205
+
206
+
207
+ @dataclass(frozen=True)
208
+ class _ResolvedRunSource:
209
+ source: str
210
+ paths: RunPaths | None
211
+ pointer_path: Path | None
212
+ pointer_matches_run: bool
213
+
214
+
215
+ def resolve_feedback_workspace_root(path: Path) -> Path:
216
+ candidate = path.expanduser().resolve()
217
+ if not candidate.exists():
218
+ raise FeedbackReportError(f"Workspace path does not exist: {candidate}")
219
+ if not candidate.is_dir():
220
+ raise FeedbackReportError(f"Workspace path is not a directory: {candidate}")
221
+ try:
222
+ return resolve_workspace_context(candidate).workspace_root
223
+ except WorkspaceContextError:
224
+ return candidate
225
+
226
+
227
+ def create_feedback_bundle(
228
+ *,
229
+ workspace_root: Path,
230
+ feedback_text: str | None = None,
231
+ cfg: AppConfig | None = None,
232
+ active_session: Any | None = None,
233
+ active_run_paths: RunPaths | None = None,
234
+ pending_images: list[str] | None = None,
235
+ session_id: str | None = None,
236
+ run_id: str | None = None,
237
+ latest: bool = False,
238
+ ) -> FeedbackBundleResult:
239
+ if session_id and latest:
240
+ raise FeedbackReportError("Use either session_id or latest, not both.")
241
+
242
+ root = resolve_feedback_workspace_root(workspace_root)
243
+ output_root = root / "alysis-feedback"
244
+ output_root.mkdir(parents=True, exist_ok=True)
245
+ _ensure_feedback_dir_is_git_ignored(root)
246
+
247
+ session_source = _resolve_session_source(
248
+ cfg=cfg or AppConfig(),
249
+ active_session=active_session,
250
+ pending_images=pending_images,
251
+ session_id=session_id,
252
+ latest=latest,
253
+ )
254
+ run_source = _resolve_run_source(
255
+ root=root,
256
+ active_run_paths=active_run_paths,
257
+ run_id=run_id,
258
+ )
259
+
260
+ if not _session_source_has_exportable_content(session_source) and run_source.paths is None:
261
+ raise FeedbackReportError(
262
+ "No retained session logs found and no current Forge run could be resolved."
263
+ )
264
+
265
+ created_at = _now_utc()
266
+ bundle_name = _make_bundle_name(
267
+ created_at=created_at,
268
+ session_id=session_source.session_id,
269
+ run_id=run_source.paths.run_id if run_source.paths is not None else None,
270
+ )
271
+ bundle_dir = _allocate_bundle_dir(output_root=output_root, base_name=bundle_name)
272
+ zip_path = bundle_dir.with_suffix(".zip")
273
+ bundle_dir.mkdir(parents=True, exist_ok=False)
274
+
275
+ copied_session_log_path: Path | None = None
276
+ copied_session_artifacts_path: Path | None = None
277
+ snapshot_path: Path | None = None
278
+ copied_web_research_path: Path | None = None
279
+ copied_run_root_path: Path | None = None
280
+ copied_pointer_path: Path | None = None
281
+
282
+ session_bundle_dir = bundle_dir / "session"
283
+ if session_source.log_path is not None:
284
+ copied_session_log_path = session_bundle_dir / "log.jsonl"
285
+ _copy_file(session_source.log_path, copied_session_log_path, workspace_root=root)
286
+ if session_source.log_meta_path is not None:
287
+ _copy_file(
288
+ session_source.log_meta_path,
289
+ session_bundle_dir / "log.meta.json",
290
+ workspace_root=root,
291
+ )
292
+ if session_source.artifact_root is not None:
293
+ copied_session_artifacts_path = session_bundle_dir / "artifacts"
294
+ _copy_tree(
295
+ session_source.artifact_root,
296
+ copied_session_artifacts_path,
297
+ workspace_root=root,
298
+ )
299
+ if session_source.snapshot_payload is not None:
300
+ snapshot_path = session_bundle_dir / "session_snapshot.json"
301
+ _write_json(snapshot_path, session_source.snapshot_payload, workspace_root=root)
302
+ if session_source.web_research_payload is not None:
303
+ copied_web_research_path = session_bundle_dir / "web_research_sources.json"
304
+ _write_json(
305
+ copied_web_research_path,
306
+ session_source.web_research_payload,
307
+ workspace_root=root,
308
+ )
309
+
310
+ if run_source.paths is not None:
311
+ failure_category_counts = _collect_run_failure_category_counts(run_source.paths)
312
+ copied_run_root_path = bundle_dir / "forge" / "run"
313
+ _copy_selected_run_artifacts(
314
+ run_source.paths,
315
+ copied_run_root_path,
316
+ workspace_root=root,
317
+ )
318
+ if run_source.pointer_path is not None and run_source.pointer_matches_run:
319
+ copied_pointer_path = bundle_dir / "forge" / "current_run.json"
320
+ _copy_file(run_source.pointer_path, copied_pointer_path, workspace_root=root)
321
+ else:
322
+ failure_category_counts = empty_failure_category_counts()
323
+
324
+ session_score_path = bundle_dir / "session_score.json"
325
+ _write_json(session_score_path, session_source.score_payload, workspace_root=root)
326
+
327
+ feedback_path = bundle_dir / "feedback.md"
328
+ feedback_path.write_text(
329
+ _render_feedback_markdown(
330
+ created_at=created_at,
331
+ feedback_text=feedback_text,
332
+ session_id=session_source.session_id,
333
+ run_id=run_source.paths.run_id if run_source.paths is not None else None,
334
+ workspace_root=root,
335
+ ),
336
+ encoding="utf-8",
337
+ )
338
+
339
+ summary_path = bundle_dir / "summary.md"
340
+ summary_path.write_text(
341
+ _render_summary_markdown(
342
+ workspace_root=root,
343
+ bundle_dir=bundle_dir,
344
+ output_root=output_root,
345
+ session_source=session_source,
346
+ run_source=run_source,
347
+ copied_session_log_path=copied_session_log_path,
348
+ copied_session_artifacts_path=copied_session_artifacts_path,
349
+ snapshot_path=snapshot_path,
350
+ copied_web_research_path=copied_web_research_path,
351
+ copied_pointer_path=copied_pointer_path,
352
+ copied_run_root_path=copied_run_root_path,
353
+ ),
354
+ encoding="utf-8",
355
+ )
356
+
357
+ manifest_path = bundle_dir / "manifest.json"
358
+ _write_json(
359
+ manifest_path,
360
+ {
361
+ "schema_version": 1,
362
+ "tool": "alysis",
363
+ "tool_version": __version__,
364
+ "created_at": _now_iso(created_at),
365
+ "workspace_root": safe_serialized_path_field(
366
+ "workspace_root",
367
+ root,
368
+ workspace_root=root,
369
+ ),
370
+ "bundle_dir": safe_serialized_path_field(
371
+ "bundle_dir",
372
+ bundle_dir,
373
+ workspace_root=root,
374
+ ),
375
+ "zip_path": safe_serialized_path_field(
376
+ "zip_path",
377
+ zip_path,
378
+ workspace_root=root,
379
+ ),
380
+ "session": {
381
+ "source": session_source.source,
382
+ "session_id": session_source.session_id,
383
+ "logging_enabled": session_source.logging_enabled,
384
+ "log_path": safe_serialized_path_field(
385
+ "log_path",
386
+ session_source.log_path,
387
+ workspace_root=root,
388
+ ),
389
+ "artifact_root": (
390
+ safe_serialized_path_field(
391
+ "artifact_root",
392
+ session_source.artifact_root,
393
+ workspace_root=root,
394
+ )
395
+ if session_source.artifact_root
396
+ else None
397
+ ),
398
+ "included_log": copied_session_log_path is not None,
399
+ "included_artifacts": copied_session_artifacts_path is not None,
400
+ "included_snapshot": snapshot_path is not None,
401
+ "included_web_research_artifact": copied_web_research_path is not None,
402
+ },
403
+ "run": {
404
+ "source": run_source.source,
405
+ "run_id": run_source.paths.run_id if run_source.paths is not None else None,
406
+ "run_dir": (
407
+ safe_serialized_path_field(
408
+ "run_dir",
409
+ run_source.paths.run_dir,
410
+ workspace_root=root,
411
+ )
412
+ if run_source.paths is not None
413
+ else None
414
+ ),
415
+ "current_run_pointer_path": (
416
+ safe_serialized_path_field(
417
+ "current_run_pointer_path",
418
+ run_source.pointer_path,
419
+ workspace_root=root,
420
+ )
421
+ if run_source.pointer_path is not None
422
+ else None
423
+ ),
424
+ "included_current_run_pointer": copied_pointer_path is not None,
425
+ "included_plan": copied_run_root_path is not None
426
+ and (copied_run_root_path / "plan").exists(),
427
+ "included_execution": copied_run_root_path is not None
428
+ and (copied_run_root_path / "execution").exists(),
429
+ "included_knowledge": copied_run_root_path is not None
430
+ and (copied_run_root_path / "knowledge").exists(),
431
+ "failure_category_counts": failure_category_counts,
432
+ "excluded_worktrees": True,
433
+ },
434
+ "platform": {
435
+ "python_version": platform.python_version(),
436
+ "platform": platform.platform(),
437
+ "system": platform.system(),
438
+ "release": platform.release(),
439
+ },
440
+ "bundle_policy": {
441
+ "local_only": True,
442
+ "excluded": [
443
+ "api_keys",
444
+ "environment_dumps",
445
+ "full_workspace_snapshots",
446
+ "forge_worktrees",
447
+ ],
448
+ },
449
+ },
450
+ workspace_root=root,
451
+ )
452
+
453
+ _write_deterministic_zip(bundle_dir=bundle_dir, zip_path=zip_path)
454
+
455
+ return FeedbackBundleResult(
456
+ bundle_dir=bundle_dir,
457
+ zip_path=zip_path,
458
+ output_root=output_root,
459
+ workspace_root=root,
460
+ session_id=session_source.session_id,
461
+ run_id=run_source.paths.run_id if run_source.paths is not None else None,
462
+ )
463
+
464
+
465
+ def create_feedback_github_issue_draft(
466
+ *,
467
+ bundle_result: FeedbackBundleResult,
468
+ feedback_text: str | None = None,
469
+ cfg: AppConfig | None = None,
470
+ github_enabled: bool | None = None,
471
+ open_browser: bool | None = None,
472
+ browser_open: Callable[..., bool] = webbrowser.open,
473
+ ) -> FeedbackGithubIssueResult:
474
+ enabled = (
475
+ resolve_feedback_github_enabled(cfg) if github_enabled is None else bool(github_enabled)
476
+ )
477
+ if not enabled:
478
+ return FeedbackGithubIssueResult(
479
+ repo="",
480
+ issue_url=None,
481
+ opened=False,
482
+ open_attempted=False,
483
+ disabled_reason="disabled",
484
+ )
485
+
486
+ repo = resolve_feedback_github_repo(cfg)
487
+ title, body = build_feedback_github_issue_payload(
488
+ bundle_result=bundle_result,
489
+ feedback_text=feedback_text,
490
+ )
491
+ issue_url = _github_new_issue_url(
492
+ repo=repo,
493
+ title=title,
494
+ body=body,
495
+ bundle_result=bundle_result,
496
+ )
497
+ should_open = resolve_feedback_open_browser(cfg) if open_browser is None else bool(open_browser)
498
+ if not should_open:
499
+ return FeedbackGithubIssueResult(
500
+ repo=repo,
501
+ issue_url=issue_url,
502
+ opened=False,
503
+ open_attempted=False,
504
+ disabled_reason="browser_open_disabled",
505
+ )
506
+
507
+ try:
508
+ opened = bool(browser_open(issue_url, new=2))
509
+ except Exception as exc: # noqa: BLE001 - platform/browser integration is best-effort.
510
+ return FeedbackGithubIssueResult(
511
+ repo=repo,
512
+ issue_url=issue_url,
513
+ opened=False,
514
+ open_attempted=True,
515
+ open_error=str(exc),
516
+ )
517
+ return FeedbackGithubIssueResult(
518
+ repo=repo,
519
+ issue_url=issue_url,
520
+ opened=opened,
521
+ open_attempted=True,
522
+ open_error=None if opened else "browser did not accept the URL",
523
+ )
524
+
525
+
526
+ def build_feedback_github_issue_payload(
527
+ *,
528
+ bundle_result: FeedbackBundleResult,
529
+ feedback_text: str | None = None,
530
+ ) -> tuple[str, str]:
531
+ feedback = _sanitize_exported_freeform_text(
532
+ (feedback_text or "").strip(),
533
+ workspace_root=bundle_result.workspace_root,
534
+ )
535
+ title = _feedback_issue_title(feedback)
536
+ manifest = _read_optional_json(bundle_result.bundle_dir / "manifest.json")
537
+ score = _read_optional_json(bundle_result.bundle_dir / "session_score.json")
538
+ body = _feedback_issue_body(
539
+ bundle_result=bundle_result,
540
+ feedback_text=feedback,
541
+ manifest=manifest,
542
+ score=score,
543
+ )
544
+ return title, body
545
+
546
+
547
+ def feedback_github_issue_status_lines(result: FeedbackGithubIssueResult) -> list[str]:
548
+ if result.issue_url is None:
549
+ return []
550
+ lines: list[str] = []
551
+ if result.opened:
552
+ lines.append(f"GitHub issue draft opened: {result.issue_url}")
553
+ else:
554
+ lines.append(f"GitHub issue draft URL: {result.issue_url}")
555
+ if result.open_attempted and result.open_error:
556
+ lines.append(f"Browser open failed: {result.open_error}")
557
+ lines.append("Review the issue in GitHub before submitting; the archive was not uploaded.")
558
+ return lines
559
+
560
+
561
+ def _github_new_issue_url(
562
+ *,
563
+ repo: str,
564
+ title: str,
565
+ body: str,
566
+ bundle_result: FeedbackBundleResult,
567
+ ) -> str:
568
+ url = _raw_github_new_issue_url(repo=repo, title=title, body=body)
569
+ if len(url) <= _ISSUE_URL_MAX_CHARS:
570
+ return url
571
+
572
+ compact_body = _compact_feedback_issue_body(bundle_result=bundle_result)
573
+ url = _raw_github_new_issue_url(repo=repo, title=title, body=compact_body)
574
+ while len(url) > _ISSUE_URL_MAX_CHARS and len(compact_body) > 240:
575
+ compact_body = _truncate_issue_text(
576
+ compact_body,
577
+ max_chars=max(240, len(compact_body) // 2),
578
+ )
579
+ url = _raw_github_new_issue_url(repo=repo, title=title, body=compact_body)
580
+ return url
581
+
582
+
583
+ def _raw_github_new_issue_url(*, repo: str, title: str, body: str) -> str:
584
+ query = urlencode({"title": title, "body": body})
585
+ return f"https://github.com/{repo}/issues/new?{query}"
586
+
587
+
588
+ def _feedback_issue_title(feedback_text: str) -> str:
589
+ first_line = next(
590
+ (line.strip() for line in feedback_text.splitlines() if line.strip()),
591
+ "",
592
+ )
593
+ if not first_line:
594
+ return "Alysis Code feedback report"
595
+ normalized = re.sub(r"\s+", " ", first_line)
596
+ prefix = "Alysis Code feedback: "
597
+ available = _ISSUE_TITLE_MAX_CHARS - len(prefix)
598
+ if len(normalized) > available:
599
+ normalized = normalized[: max(0, available - 1)].rstrip() + "..."
600
+ return f"{prefix}{normalized}"
601
+
602
+
603
+ def _feedback_issue_body(
604
+ *,
605
+ bundle_result: FeedbackBundleResult,
606
+ feedback_text: str,
607
+ manifest: dict[str, Any],
608
+ score: dict[str, Any],
609
+ ) -> str:
610
+ bundle_dir = safe_serialized_path(
611
+ bundle_result.bundle_dir,
612
+ workspace_root=bundle_result.workspace_root,
613
+ )
614
+ zip_path = safe_serialized_path(
615
+ bundle_result.zip_path,
616
+ workspace_root=bundle_result.workspace_root,
617
+ )
618
+ platform_payload = (
619
+ manifest.get("platform") if isinstance(manifest.get("platform"), dict) else {}
620
+ )
621
+ run_payload = manifest.get("run") if isinstance(manifest.get("run"), dict) else {}
622
+ session_payload = manifest.get("session") if isinstance(manifest.get("session"), dict) else {}
623
+ feedback_block = _truncate_issue_text(
624
+ feedback_text or "_No feedback text was provided._",
625
+ max_chars=_ISSUE_FEEDBACK_MAX_CHARS,
626
+ )
627
+ lines = [
628
+ "### Feedback",
629
+ "",
630
+ feedback_block,
631
+ "",
632
+ "### Local Support Bundle",
633
+ "",
634
+ f"- Directory: `{bundle_dir or '(unknown)'}`",
635
+ f"- Archive: `{zip_path or '(unknown)'}`",
636
+ "",
637
+ (
638
+ "Please review the archive before attaching it. It is generated locally, applies "
639
+ "best-effort secret/path redaction, and is not uploaded automatically."
640
+ ),
641
+ "",
642
+ "### Session",
643
+ "",
644
+ f"- Session ID: `{bundle_result.session_id or '(none)'}`",
645
+ f"- Run ID: `{bundle_result.run_id or '(none)'}`",
646
+ f"- Session source: `{session_payload.get('source') or '(unknown)'}`",
647
+ f"- Run source: `{run_payload.get('source') or '(unknown)'}`",
648
+ f"- Verification authoritative: `{_yes_no(score.get('verification_authoritative'))}`",
649
+ f"- Last verification failure: `{score.get('last_verification_failure_kind') or '(none recorded)'}`",
650
+ "",
651
+ "### Environment",
652
+ "",
653
+ f"- Alysis Code version: `{__version__}`",
654
+ f"- Python: `{platform_payload.get('python_version') or platform.python_version()}`",
655
+ f"- Platform: `{platform_payload.get('platform') or platform.platform()}`",
656
+ "",
657
+ "### Bundle Policy",
658
+ "",
659
+ "- No report data or archive was submitted automatically.",
660
+ "- The issue body intentionally excludes raw logs, tool outputs, environment dumps, and API keys.",
661
+ "- Attach the archive only if you are comfortable sharing the local support bundle.",
662
+ "",
663
+ ]
664
+ body = "\n".join(lines)
665
+ return _truncate_issue_text(body, max_chars=_ISSUE_BODY_MAX_CHARS)
666
+
667
+
668
+ def _compact_feedback_issue_body(*, bundle_result: FeedbackBundleResult) -> str:
669
+ bundle_dir = safe_serialized_path(
670
+ bundle_result.bundle_dir,
671
+ workspace_root=bundle_result.workspace_root,
672
+ )
673
+ zip_path = safe_serialized_path(
674
+ bundle_result.zip_path,
675
+ workspace_root=bundle_result.workspace_root,
676
+ )
677
+ lines = [
678
+ "### Feedback",
679
+ "",
680
+ "The original feedback text was too large for a stable GitHub prefill URL.",
681
+ "",
682
+ "### Local Support Bundle",
683
+ "",
684
+ f"- Directory: `{bundle_dir or '(unknown)'}`",
685
+ f"- Archive: `{zip_path or '(unknown)'}`",
686
+ "",
687
+ "Please review the archive before attaching it. It was not uploaded automatically.",
688
+ "",
689
+ "### Bundle Policy",
690
+ "",
691
+ "- No report data or archive was submitted automatically.",
692
+ "- The issue body intentionally excludes raw logs, tool outputs, environment dumps, and API keys.",
693
+ ]
694
+ return "\n".join(lines)
695
+
696
+
697
+ def _read_optional_json(path: Path) -> dict[str, Any]:
698
+ try:
699
+ payload = json.loads(path.read_text(encoding="utf-8"))
700
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError):
701
+ return {}
702
+ return payload if isinstance(payload, dict) else {}
703
+
704
+
705
+ def _truncate_issue_text(text: str, *, max_chars: int) -> str:
706
+ clean = str(text or "")
707
+ if len(clean) <= max_chars:
708
+ return clean
709
+ suffix = "\n\n[Truncated for GitHub issue prefill. See local feedback bundle for details.]"
710
+ return clean[: max(0, max_chars - len(suffix))].rstrip() + suffix
711
+
712
+
713
+ def _yes_no(value: Any) -> str:
714
+ if isinstance(value, bool):
715
+ return "yes" if value else "no"
716
+ return "(unknown)"
717
+
718
+
719
+ def _resolve_session_source(
720
+ *,
721
+ cfg: AppConfig,
722
+ active_session: Any | None,
723
+ pending_images: list[str] | None,
724
+ session_id: str | None,
725
+ latest: bool,
726
+ ) -> _ResolvedSessionSource:
727
+ if active_session is not None:
728
+ return _resolve_active_session_source(
729
+ active_session=active_session,
730
+ pending_images=pending_images,
731
+ )
732
+
733
+ sessions_dir = resolve_sessions_dir(cfg)
734
+ target_session_id = _validate_export_id("session id", session_id) if session_id else ""
735
+ if not target_session_id:
736
+ infos = filter_sessions_to_local_owner(list_sessions(sessions_dir))
737
+ if infos:
738
+ target_session_id = infos[0].session_id
739
+ elif latest:
740
+ raise FeedbackReportError(f"No retained sessions found in {sessions_dir}")
741
+
742
+ if not target_session_id:
743
+ return _ResolvedSessionSource(
744
+ source="none",
745
+ session_id=None,
746
+ log_path=None,
747
+ log_meta_path=None,
748
+ artifact_root=None,
749
+ logging_enabled=False,
750
+ score_payload=_fallback_session_score(
751
+ session_id=None,
752
+ reason="no retained session selected",
753
+ ),
754
+ snapshot_payload=None,
755
+ web_research_payload=None,
756
+ )
757
+
758
+ log_path = _path_within_dir(
759
+ base_dir=sessions_dir,
760
+ candidate=sessions_dir / f"{target_session_id}.jsonl",
761
+ label="session log",
762
+ require_exists=True,
763
+ )
764
+ if not log_path.exists():
765
+ raise FeedbackReportError(f"Session log not found: {log_path}")
766
+ artifact_root_candidate = sessions_dir / sanitize_session_id(target_session_id)
767
+ artifact_root = (
768
+ _path_within_dir(
769
+ base_dir=sessions_dir,
770
+ candidate=artifact_root_candidate,
771
+ label="session artifact root",
772
+ require_exists=False,
773
+ )
774
+ if artifact_root_candidate.exists()
775
+ else artifact_root_candidate
776
+ )
777
+ meta_path = log_path.with_suffix(".meta.json")
778
+ events = list(read_session_events(log_path))
779
+ score_payload = score_session_events(events)
780
+ score_payload["session_id"] = str(score_payload.get("session_id") or target_session_id)
781
+ score_payload["path"] = str(log_path)
782
+ web_research_payload = _merge_web_research_payloads(
783
+ events=events,
784
+ persisted_payload=_read_web_research_payload(
785
+ artifact_root if artifact_root.exists() else None
786
+ ),
787
+ )
788
+ _overlay_web_research_metrics(score_payload, web_research_payload)
789
+ return _ResolvedSessionSource(
790
+ source="explicit_session_id" if session_id else "latest_retained",
791
+ session_id=target_session_id,
792
+ log_path=log_path,
793
+ log_meta_path=meta_path if meta_path.exists() else None,
794
+ artifact_root=artifact_root if artifact_root.exists() else None,
795
+ logging_enabled=True,
796
+ score_payload=score_payload,
797
+ snapshot_payload=None,
798
+ web_research_payload=web_research_payload,
799
+ )
800
+
801
+
802
+ def _resolve_active_session_source(
803
+ *,
804
+ active_session: Any,
805
+ pending_images: list[str] | None,
806
+ ) -> _ResolvedSessionSource:
807
+ store = getattr(active_session, "store", None)
808
+ session_id = str(getattr(store, "session_id", "") or "").strip() or None
809
+ logging_enabled = bool(getattr(store, "enabled", False))
810
+ log_path_obj = getattr(store, "path", None)
811
+ log_path = Path(log_path_obj) if log_path_obj is not None else None
812
+ if log_path is not None and not log_path.exists():
813
+ log_path = None
814
+ artifact_root_obj = getattr(store, "session_artifact_root", None)
815
+ artifact_root = artifact_root_obj if isinstance(artifact_root_obj, Path) else None
816
+ if artifact_root is not None and not artifact_root.exists():
817
+ artifact_root = None
818
+ meta_path = log_path.with_suffix(".meta.json") if log_path is not None else None
819
+ event_snapshot = (
820
+ events_fn() if callable(events_fn := getattr(store, "events_snapshot", None)) else None
821
+ )
822
+ web_research_payload = (
823
+ artifact_fn()
824
+ if callable(artifact_fn := getattr(store, "web_research_artifact_payload", None))
825
+ else None
826
+ )
827
+ snapshot_payload = None
828
+ if not logging_enabled or log_path is None:
829
+ snapshot_payload = _build_in_memory_session_snapshot(
830
+ active_session=active_session,
831
+ pending_images=pending_images,
832
+ )
833
+ if isinstance(event_snapshot, list) and event_snapshot:
834
+ score_payload = score_session_events(event_snapshot)
835
+ score_payload["session_id"] = str(score_payload.get("session_id") or session_id or "")
836
+ score_payload["path"] = str(log_path) if log_path is not None else None
837
+ score_payload["available"] = True
838
+ score_payload["reason"] = "derived from active in-memory session events"
839
+ elif log_path is not None:
840
+ score_payload = score_session_log(log_path)
841
+ else:
842
+ score_payload = _fallback_session_score(
843
+ session_id=session_id,
844
+ reason="session logging disabled or log not retained",
845
+ )
846
+ _overlay_web_research_metrics(score_payload, web_research_payload)
847
+ return _ResolvedSessionSource(
848
+ source="active_session",
849
+ session_id=session_id,
850
+ log_path=log_path,
851
+ log_meta_path=meta_path if meta_path is not None and meta_path.exists() else None,
852
+ artifact_root=artifact_root,
853
+ logging_enabled=logging_enabled,
854
+ score_payload=score_payload,
855
+ snapshot_payload=snapshot_payload,
856
+ web_research_payload=(
857
+ web_research_payload
858
+ if web_research_artifact_has_activity(web_research_payload)
859
+ else None
860
+ ),
861
+ )
862
+
863
+
864
+ def _read_web_research_payload(artifact_root: Path | None) -> dict[str, Any] | None:
865
+ if artifact_root is None:
866
+ return None
867
+ artifact_path = artifact_root / "web_research_sources.json"
868
+ if not artifact_path.exists():
869
+ return None
870
+ try:
871
+ payload = json.loads(artifact_path.read_text(encoding="utf-8"))
872
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError):
873
+ return None
874
+ return payload if web_research_artifact_has_activity(payload) else None
875
+
876
+
877
+ def _merge_web_research_payloads(
878
+ *,
879
+ events: list[dict[str, Any]] | None = None,
880
+ persisted_payload: dict[str, Any] | None = None,
881
+ ) -> dict[str, Any] | None:
882
+ tracker = SessionWebResearchTracker()
883
+ if isinstance(events, list):
884
+ for event in events:
885
+ payload = event.get("payload") if isinstance(event.get("payload"), dict) else {}
886
+ tracker.observe_event(
887
+ event_type=str(event.get("type") or "").strip(),
888
+ payload=payload,
889
+ ts=str(event.get("ts") or "").strip() or None,
890
+ )
891
+ if isinstance(persisted_payload, dict):
892
+ tracker.merge_from_artifact_payload(persisted_payload)
893
+ tracker.clear_pending()
894
+ payload = tracker.artifact_payload()
895
+ return payload if web_research_artifact_has_activity(payload) else None
896
+
897
+
898
+ def _overlay_web_research_metrics(
899
+ score_payload: dict[str, Any],
900
+ web_research_payload: dict[str, Any] | None,
901
+ ) -> None:
902
+ if not web_research_artifact_has_activity(web_research_payload):
903
+ return
904
+ score_payload.update(build_web_research_metrics_from_artifact_payload(web_research_payload))
905
+
906
+
907
+ def _resolve_run_source(
908
+ *,
909
+ root: Path,
910
+ active_run_paths: RunPaths | None,
911
+ run_id: str | None,
912
+ ) -> _ResolvedRunSource:
913
+ if active_run_paths is not None:
914
+ _validate_run_paths(active_run_paths)
915
+ pointer_path = current_run_pointer_path(active_run_paths.root)
916
+ return _ResolvedRunSource(
917
+ source="active_chat_forge",
918
+ paths=active_run_paths,
919
+ pointer_path=pointer_path if pointer_path.exists() else None,
920
+ pointer_matches_run=True,
921
+ )
922
+
923
+ selected_run_id = _validate_export_id("run id", run_id) if run_id else ""
924
+ if selected_run_id:
925
+ paths = make_run_paths(root=root, run_id=selected_run_id)
926
+ _validate_run_paths(paths)
927
+ if not paths.run_dir.exists():
928
+ raise FeedbackReportError(f"Forge run not found: {paths.run_dir}")
929
+ pointer_path = current_run_pointer_path(root)
930
+ pointer_matches = False
931
+ if pointer_path.exists():
932
+ try:
933
+ current_paths = load_current_run_paths(root)
934
+ pointer_matches = current_paths.run_id == paths.run_id
935
+ except ForgeError:
936
+ pointer_matches = False
937
+ return _ResolvedRunSource(
938
+ source="explicit_run_id",
939
+ paths=paths,
940
+ pointer_path=pointer_path if pointer_path.exists() else None,
941
+ pointer_matches_run=pointer_matches,
942
+ )
943
+
944
+ try:
945
+ paths = load_current_run_paths(root)
946
+ except ForgeError:
947
+ return _ResolvedRunSource(
948
+ source="none",
949
+ paths=None,
950
+ pointer_path=None,
951
+ pointer_matches_run=False,
952
+ )
953
+
954
+ _validate_run_paths(paths)
955
+ pointer_path = current_run_pointer_path(paths.root)
956
+ return _ResolvedRunSource(
957
+ source="current_run_pointer",
958
+ paths=paths,
959
+ pointer_path=pointer_path if pointer_path.exists() else None,
960
+ pointer_matches_run=True,
961
+ )
962
+
963
+
964
+ def _build_in_memory_session_snapshot(
965
+ *,
966
+ active_session: Any,
967
+ pending_images: list[str] | None,
968
+ ) -> dict[str, Any]:
969
+ session_root_obj = getattr(active_session, "root", None)
970
+ session_root = session_root_obj if isinstance(session_root_obj, Path) else None
971
+ usage_summary = getattr(active_session, "usage_summary", None)
972
+ totals_fn = getattr(usage_summary, "totals", None)
973
+ usage_totals = totals_fn() if callable(totals_fn) else {}
974
+ messages_obj = getattr(active_session, "messages", [])
975
+ messages = messages_obj if isinstance(messages_obj, list) else []
976
+ non_system_messages = [
977
+ _to_jsonable(message)
978
+ for message in messages
979
+ if isinstance(message, dict) and str(message.get("role") or "").strip().lower() != "system"
980
+ ]
981
+ store = getattr(active_session, "store", None)
982
+ web_research_sources = None
983
+ has_web_research_activity = getattr(store, "has_web_research_activity", None)
984
+ web_research_artifact_payload = getattr(store, "web_research_artifact_payload", None)
985
+ if callable(has_web_research_activity) and callable(web_research_artifact_payload):
986
+ if has_web_research_activity():
987
+ web_research_sources = _to_jsonable(web_research_artifact_payload())
988
+ return {
989
+ "snapshot_type": "in_memory_session",
990
+ "session_id": str(getattr(store, "session_id", "") or "").strip() or None,
991
+ "workspace_root": safe_serialized_path_field(
992
+ "workspace_root",
993
+ session_root,
994
+ workspace_root=session_root,
995
+ ),
996
+ "mode": str(getattr(active_session, "mode", "") or ""),
997
+ "model": str(getattr(getattr(active_session, "client", None), "model", "") or ""),
998
+ "stream": bool(getattr(active_session, "stream", False)),
999
+ "no_log": bool(getattr(active_session, "no_log", False)),
1000
+ "message_count": len(messages),
1001
+ "messages": non_system_messages,
1002
+ "pending_images": [
1003
+ safe_serialized_path(str(item), workspace_root=session_root) or str(item)
1004
+ for item in list(pending_images or [])
1005
+ if str(item).strip()
1006
+ ],
1007
+ "usage_totals": _to_jsonable(usage_totals),
1008
+ "effective_verification_commands": _to_jsonable(
1009
+ getattr(active_session, "effective_verification_commands", [])
1010
+ ),
1011
+ "authoritative_verification_commands": _to_jsonable(
1012
+ getattr(active_session, "authoritative_verification_commands", None)
1013
+ ),
1014
+ "verification_selection_source": str(
1015
+ getattr(active_session, "verification_selection_source", "") or ""
1016
+ ),
1017
+ "verification_selection_reason": str(
1018
+ getattr(active_session, "verification_selection_reason", "") or ""
1019
+ ),
1020
+ "verification_contract_type": str(
1021
+ getattr(active_session, "verification_contract_type", "") or ""
1022
+ ),
1023
+ "verification_authoritative": bool(
1024
+ getattr(active_session, "verification_authoritative", False)
1025
+ ),
1026
+ "web_research_sources": web_research_sources,
1027
+ }
1028
+
1029
+
1030
+ def _fallback_session_score(*, session_id: str | None, reason: str) -> dict[str, Any]:
1031
+ payload = score_session_events(
1032
+ [
1033
+ {
1034
+ "type": "session_start",
1035
+ "session_id": str(session_id or ""),
1036
+ "payload": {},
1037
+ }
1038
+ ]
1039
+ )
1040
+ payload["session_id"] = str(session_id or "")
1041
+ payload["available"] = False
1042
+ payload["reason"] = reason
1043
+ payload["path"] = None
1044
+ return payload
1045
+
1046
+
1047
+ def _ensure_feedback_dir_is_git_ignored(root: Path) -> None:
1048
+ try:
1049
+ ensure_git_repo(root)
1050
+ except GitOpsError:
1051
+ return
1052
+ ensure_runtime_artifact_excludes(root)
1053
+
1054
+
1055
+ def _session_source_has_exportable_content(source: _ResolvedSessionSource) -> bool:
1056
+ return (
1057
+ source.log_path is not None
1058
+ or source.artifact_root is not None
1059
+ or source.snapshot_payload is not None
1060
+ or source.web_research_payload is not None
1061
+ )
1062
+
1063
+
1064
+ def _copy_selected_run_artifacts(
1065
+ paths: RunPaths,
1066
+ target_root: Path,
1067
+ *,
1068
+ workspace_root: Path,
1069
+ ) -> None:
1070
+ selected = [
1071
+ (paths.plan_dir, target_root / "plan"),
1072
+ (paths.execution_dir, target_root / "execution"),
1073
+ (paths.knowledge_dir, target_root / "knowledge"),
1074
+ ]
1075
+ for src, dest in selected:
1076
+ if src.exists():
1077
+ _copy_tree(src, dest, workspace_root=workspace_root)
1078
+
1079
+
1080
+ def _collect_run_failure_category_counts(paths: RunPaths) -> dict[str, int]:
1081
+ counts = empty_failure_category_counts()
1082
+ _collect_failure_categories_from_json_dir(paths.execution_dir / "worker_results", counts)
1083
+ _collect_failure_categories_from_json_dir(paths.execution_integration_dir, counts)
1084
+ return counts
1085
+
1086
+
1087
+ def _collect_failure_categories_from_json_dir(path: Path, counts: dict[str, int]) -> None:
1088
+ if not path.exists():
1089
+ return
1090
+ for item in sorted(path.rglob("*.json")):
1091
+ try:
1092
+ payload = json.loads(item.read_text(encoding="utf-8"))
1093
+ except (OSError, json.JSONDecodeError):
1094
+ continue
1095
+ if not isinstance(payload, dict):
1096
+ continue
1097
+ increment_failure_category_count(counts, payload.get("failure_category"))
1098
+
1099
+
1100
+ def _copy_tree(src: Path, dest: Path, *, workspace_root: Path) -> None:
1101
+ if not src.exists():
1102
+ return
1103
+ if src.is_dir():
1104
+ dest.mkdir(parents=True, exist_ok=True)
1105
+ for path in sorted(src.rglob("*")):
1106
+ if path.is_symlink():
1107
+ continue
1108
+ rel = path.relative_to(src)
1109
+ target = dest / rel
1110
+ if path.is_dir():
1111
+ target.mkdir(parents=True, exist_ok=True)
1112
+ continue
1113
+ if not path.is_file():
1114
+ continue
1115
+ target.parent.mkdir(parents=True, exist_ok=True)
1116
+ _copy_file(path, target, workspace_root=workspace_root)
1117
+
1118
+
1119
+ def _copy_file(src: Path, dest: Path, *, workspace_root: Path) -> None:
1120
+ dest.parent.mkdir(parents=True, exist_ok=True)
1121
+ text = _read_text_if_utf8(src)
1122
+ suffix = src.suffix.lower()
1123
+ if text is None:
1124
+ if suffix in {".json", ".jsonl"}:
1125
+ _write_invalid_structured_export(dest, jsonl=suffix == ".jsonl")
1126
+ shutil.copystat(src, dest)
1127
+ return
1128
+ shutil.copy2(src, dest)
1129
+ return
1130
+ if suffix == ".json":
1131
+ sanitized_json = _sanitize_json_text(text, workspace_root=workspace_root)
1132
+ if sanitized_json is not None:
1133
+ dest.write_text(sanitized_json, encoding="utf-8")
1134
+ shutil.copystat(src, dest)
1135
+ return
1136
+ _write_invalid_structured_export(dest, jsonl=False)
1137
+ shutil.copystat(src, dest)
1138
+ return
1139
+ if suffix == ".jsonl":
1140
+ sanitized_jsonl = _sanitize_jsonl_text(text, workspace_root=workspace_root)
1141
+ if sanitized_jsonl is not None:
1142
+ dest.write_text(sanitized_jsonl, encoding="utf-8")
1143
+ shutil.copystat(src, dest)
1144
+ return
1145
+ _write_invalid_structured_export(dest, jsonl=True)
1146
+ shutil.copystat(src, dest)
1147
+ return
1148
+ dest.write_text(
1149
+ _sanitize_exported_freeform_text(text, workspace_root=workspace_root),
1150
+ encoding="utf-8",
1151
+ )
1152
+ shutil.copystat(src, dest)
1153
+
1154
+
1155
+ def _write_invalid_structured_export(path: Path, *, jsonl: bool) -> None:
1156
+ """Replace an unreadable/malformed structured artifact instead of copying
1157
+ bytes that could bypass the recursive reasoning/continuation scrubber."""
1158
+
1159
+ if jsonl:
1160
+ serialized = (
1161
+ json.dumps(_INVALID_STRUCTURED_EXPORT, sort_keys=True, ensure_ascii=True) + "\n"
1162
+ )
1163
+ else:
1164
+ serialized = (
1165
+ json.dumps(
1166
+ _INVALID_STRUCTURED_EXPORT,
1167
+ indent=2,
1168
+ sort_keys=True,
1169
+ ensure_ascii=True,
1170
+ )
1171
+ + "\n"
1172
+ )
1173
+ path.write_text(serialized, encoding="utf-8")
1174
+
1175
+
1176
+ def _write_json(
1177
+ path: Path,
1178
+ payload: dict[str, Any],
1179
+ *,
1180
+ workspace_root: Path | None = None,
1181
+ ) -> None:
1182
+ path.parent.mkdir(parents=True, exist_ok=True)
1183
+ path.write_text(
1184
+ json.dumps(
1185
+ _redact_jsonable(payload, workspace_root=workspace_root),
1186
+ indent=2,
1187
+ sort_keys=True,
1188
+ ensure_ascii=True,
1189
+ )
1190
+ + "\n",
1191
+ encoding="utf-8",
1192
+ )
1193
+
1194
+
1195
+ def _write_deterministic_zip(*, bundle_dir: Path, zip_path: Path) -> None:
1196
+ with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
1197
+ for path in sorted(bundle_dir.rglob("*")):
1198
+ if not path.is_file() or path.is_symlink():
1199
+ continue
1200
+ rel = path.relative_to(bundle_dir).as_posix()
1201
+ info = zipfile.ZipInfo(rel)
1202
+ info.compress_type = zipfile.ZIP_DEFLATED
1203
+ info.date_time = (1980, 1, 1, 0, 0, 0)
1204
+ zf.writestr(info, path.read_bytes())
1205
+
1206
+
1207
+ def _render_feedback_markdown(
1208
+ *,
1209
+ created_at: datetime,
1210
+ feedback_text: str | None,
1211
+ session_id: str | None,
1212
+ run_id: str | None,
1213
+ workspace_root: Path,
1214
+ ) -> str:
1215
+ lines = [
1216
+ "# Feedback",
1217
+ "",
1218
+ f"- Created At: {_now_iso(created_at)}",
1219
+ f"- Session ID: {session_id or '(none)'}",
1220
+ f"- Run ID: {run_id or '(none)'}",
1221
+ "",
1222
+ "## User Feedback",
1223
+ "",
1224
+ ]
1225
+ text = _sanitize_exported_freeform_text(
1226
+ (feedback_text or "").strip(),
1227
+ workspace_root=workspace_root,
1228
+ )
1229
+ lines.append(text if text else "_No feedback text was provided._")
1230
+ lines.append("")
1231
+ return "\n".join(lines)
1232
+
1233
+
1234
+ def _render_summary_markdown(
1235
+ *,
1236
+ workspace_root: Path,
1237
+ bundle_dir: Path,
1238
+ output_root: Path,
1239
+ session_source: _ResolvedSessionSource,
1240
+ run_source: _ResolvedRunSource,
1241
+ copied_session_log_path: Path | None,
1242
+ copied_session_artifacts_path: Path | None,
1243
+ snapshot_path: Path | None,
1244
+ copied_web_research_path: Path | None,
1245
+ copied_pointer_path: Path | None,
1246
+ copied_run_root_path: Path | None,
1247
+ ) -> str:
1248
+ score_payload = (
1249
+ session_source.score_payload if isinstance(session_source.score_payload, dict) else {}
1250
+ )
1251
+ verification_selection_source = str(
1252
+ score_payload.get("verification_selection_source") or ""
1253
+ ).strip()
1254
+ verification_selection_reason = str(
1255
+ score_payload.get("verification_selection_reason") or ""
1256
+ ).strip()
1257
+ verification_contract_type = str(score_payload.get("verification_contract_type") or "").strip()
1258
+ verification_authoritative = bool(score_payload.get("verification_authoritative", False))
1259
+ last_verification_failure_kind = str(
1260
+ score_payload.get("last_verification_failure_kind") or ""
1261
+ ).strip()
1262
+ lines = [
1263
+ "# Support Bundle Summary",
1264
+ "",
1265
+ f"- Output Root: `{safe_serialized_path(output_root, workspace_root=workspace_root)}`",
1266
+ f"- Session Source: `{session_source.source}`",
1267
+ f"- Session ID: `{session_source.session_id or '(none)'}`",
1268
+ f"- Session Logging Enabled: {'yes' if session_source.logging_enabled else 'no'}",
1269
+ (
1270
+ f"- Included Session Log: `"
1271
+ f"{safe_serialized_path(copied_session_log_path, bundle_root=bundle_dir, prefer_bundle_relative=True)}`"
1272
+ if copied_session_log_path is not None
1273
+ else "- Included Session Log: (none)"
1274
+ ),
1275
+ (
1276
+ f"- Included Session Artifacts: "
1277
+ f"`{safe_serialized_path(copied_session_artifacts_path, bundle_root=bundle_dir, prefer_bundle_relative=True)}`"
1278
+ if copied_session_artifacts_path is not None
1279
+ else "- Included Session Artifacts: (none)"
1280
+ ),
1281
+ (
1282
+ f"- Included In-Memory Snapshot: `"
1283
+ f"{safe_serialized_path(snapshot_path, bundle_root=bundle_dir, prefer_bundle_relative=True)}`"
1284
+ if snapshot_path is not None
1285
+ else "- Included In-Memory Snapshot: (none)"
1286
+ ),
1287
+ (
1288
+ f"- Included Web Research Artifact: `"
1289
+ f"{safe_serialized_path(copied_web_research_path, bundle_root=bundle_dir, prefer_bundle_relative=True)}`"
1290
+ if copied_web_research_path is not None
1291
+ else "- Included Web Research Artifact: (none)"
1292
+ ),
1293
+ f"- Run Source: `{run_source.source}`",
1294
+ f"- Run ID: `{run_source.paths.run_id if run_source.paths is not None else '(none)'}`",
1295
+ (
1296
+ f"- Included Current Run Pointer: `"
1297
+ f"{safe_serialized_path(copied_pointer_path, bundle_root=bundle_dir, prefer_bundle_relative=True)}`"
1298
+ if copied_pointer_path is not None
1299
+ else "- Included Current Run Pointer: (none)"
1300
+ ),
1301
+ (
1302
+ f"- Included Run Artifacts: `"
1303
+ f"{safe_serialized_path(copied_run_root_path, bundle_root=bundle_dir, prefer_bundle_relative=True)}`"
1304
+ if copied_run_root_path is not None
1305
+ else "- Included Run Artifacts: (none)"
1306
+ ),
1307
+ f"- Verification Selection Source: `{verification_selection_source or '(unknown)'}`",
1308
+ f"- Verification Contract Type: `{verification_contract_type or '(unknown)'}`",
1309
+ f"- Verification Authoritative: {'yes' if verification_authoritative else 'no'}",
1310
+ (
1311
+ f"- Verification Failure Kind: `{last_verification_failure_kind}`"
1312
+ if last_verification_failure_kind
1313
+ else "- Verification Failure Kind: (none recorded)"
1314
+ ),
1315
+ (
1316
+ f"- Verification Selection Reason: {verification_selection_reason}"
1317
+ if verification_selection_reason
1318
+ else "- Verification Selection Reason: (none recorded)"
1319
+ ),
1320
+ "- Excluded By Policy: API keys, environment dumps, full workspace snapshots, Forge worktrees",
1321
+ "",
1322
+ ]
1323
+ return "\n".join(lines)
1324
+
1325
+
1326
+ def _allocate_bundle_dir(*, output_root: Path, base_name: str) -> Path:
1327
+ candidate = output_root / base_name
1328
+ if not candidate.exists() and not candidate.with_suffix(".zip").exists():
1329
+ return candidate
1330
+ index = 2
1331
+ while True:
1332
+ candidate = output_root / f"{base_name}_{index:02d}"
1333
+ if not candidate.exists() and not candidate.with_suffix(".zip").exists():
1334
+ return candidate
1335
+ index += 1
1336
+
1337
+
1338
+ def _make_bundle_name(
1339
+ *,
1340
+ created_at: datetime,
1341
+ session_id: str | None,
1342
+ run_id: str | None,
1343
+ ) -> str:
1344
+ parts = [f"report_{created_at.strftime('%Y%m%dT%H%M%SZ')}"]
1345
+ if session_id:
1346
+ parts.append(f"session_{sanitize_session_id(session_id)[:24]}")
1347
+ if run_id:
1348
+ parts.append(f"run_{sanitize_session_id(run_id)[:24]}")
1349
+ return "__".join(parts)
1350
+
1351
+
1352
+ def _to_jsonable(value: Any) -> Any:
1353
+ if value is None or isinstance(value, (str, int, float, bool)):
1354
+ return value
1355
+ if isinstance(value, Path):
1356
+ return str(value)
1357
+ if isinstance(value, dict):
1358
+ return {str(key): _to_jsonable(item) for key, item in value.items()}
1359
+ if isinstance(value, (list, tuple)):
1360
+ return [_to_jsonable(item) for item in value]
1361
+ return str(value)
1362
+
1363
+
1364
+ def _validate_export_id(kind: str, value: str | None) -> str:
1365
+ normalized = str(value or "").strip()
1366
+ if not normalized:
1367
+ raise FeedbackReportError(f"Missing {kind}.")
1368
+ if not _SAFE_EXPORT_ID_RE.fullmatch(normalized):
1369
+ raise FeedbackReportError(
1370
+ f"Invalid {kind}: {normalized!r}. Only letters, numbers, '_' and '-' are allowed."
1371
+ )
1372
+ return normalized
1373
+
1374
+
1375
+ def _path_within_dir(
1376
+ *,
1377
+ base_dir: Path,
1378
+ candidate: Path,
1379
+ label: str,
1380
+ require_exists: bool,
1381
+ ) -> Path:
1382
+ base_resolved = base_dir.resolve()
1383
+ try:
1384
+ candidate_resolved = candidate.resolve(strict=require_exists)
1385
+ except OSError as e:
1386
+ raise FeedbackReportError(f"Unable to resolve {label}: {candidate}") from e
1387
+ try:
1388
+ candidate_resolved.relative_to(base_resolved)
1389
+ except ValueError as e:
1390
+ raise FeedbackReportError(f"{label.capitalize()} escapes its expected directory.") from e
1391
+ return candidate_resolved
1392
+
1393
+
1394
+ def _validate_run_paths(paths: RunPaths) -> None:
1395
+ allowed_runs_dir = paths.runs_dir.resolve()
1396
+ run_dir_resolved = paths.run_dir.resolve()
1397
+ try:
1398
+ run_dir_resolved.relative_to(allowed_runs_dir)
1399
+ except ValueError as e:
1400
+ raise FeedbackReportError(f"Resolved run path escapes {allowed_runs_dir}") from e
1401
+ for label, candidate in (
1402
+ ("plan directory", paths.plan_dir),
1403
+ ("execution directory", paths.execution_dir),
1404
+ ("knowledge directory", paths.knowledge_dir),
1405
+ ):
1406
+ if not candidate.exists():
1407
+ continue
1408
+ try:
1409
+ candidate.resolve().relative_to(run_dir_resolved)
1410
+ except ValueError as e:
1411
+ raise FeedbackReportError(f"Resolved {label} escapes run directory") from e
1412
+
1413
+
1414
+ def _redact_bundle_text(text: str) -> str:
1415
+ clean = str(text or "")
1416
+ for pattern, replacement in _SECRET_REPLACEMENTS:
1417
+ clean = pattern.sub(replacement, clean)
1418
+ return clean
1419
+
1420
+
1421
+ def _sanitize_url_match(match: re.Match[str], *, strip_path: bool) -> str:
1422
+ raw = match.group(0)
1423
+ trailing = ""
1424
+ while raw and raw[-1] in ".,;:!?)]":
1425
+ trailing = raw[-1] + trailing
1426
+ raw = raw[:-1]
1427
+ try:
1428
+ parsed = urlsplit(raw)
1429
+ hostname = str(parsed.hostname or "").rstrip(".")
1430
+ safe_host = hostname.encode("idna").decode("ascii").casefold()
1431
+ if not safe_host or any(char.isspace() or char in "/@?#" for char in safe_host):
1432
+ raise ValueError("URL has no safe host")
1433
+ if ":" in safe_host and not safe_host.startswith("["):
1434
+ safe_host = f"[{safe_host}]"
1435
+ try:
1436
+ port = parsed.port
1437
+ except ValueError:
1438
+ port = None
1439
+ netloc = f"{safe_host}:{port}" if port is not None else safe_host
1440
+ sanitized = urlunsplit(
1441
+ (
1442
+ parsed.scheme.casefold(),
1443
+ netloc,
1444
+ "" if strip_path else parsed.path,
1445
+ "",
1446
+ "",
1447
+ )
1448
+ )
1449
+ except (UnicodeError, ValueError):
1450
+ sanitized = "[redacted URL]"
1451
+ return sanitized + trailing
1452
+
1453
+
1454
+ def _sanitize_urls_in_text(text: str, *, strip_path: bool = False) -> str:
1455
+ return _HIERARCHICAL_URL_RE.sub(
1456
+ lambda match: _sanitize_url_match(match, strip_path=strip_path),
1457
+ str(text or ""),
1458
+ )
1459
+
1460
+
1461
+ def _is_endpoint_url_field(key: object) -> bool:
1462
+ normalized = _normalize_export_key(key)
1463
+ return normalized == "baseurl" or normalized.endswith("baseurl")
1464
+
1465
+
1466
+ def _sanitize_exported_freeform_text(
1467
+ text: str | Path | None,
1468
+ *,
1469
+ workspace_root: Path | None,
1470
+ ) -> str:
1471
+ # Free-form exported strings can still contain absolute host paths even when
1472
+ # the surrounding JSON field is not path-shaped (for example payload.content
1473
+ # or user-authored feedback text), so bundle serialization must sanitize
1474
+ # text values separately from explicit path fields.
1475
+ without_host_paths = sanitize_paths_in_text(str(text or ""), workspace_root=workspace_root)
1476
+ # Support bundles are shared outside the live session boundary. Keep only
1477
+ # scheme + sanitized host (+ optional port) for hierarchical URLs: signed
1478
+ # path segments can be just as sensitive as userinfo and query values.
1479
+ return _redact_bundle_text(_sanitize_urls_in_text(without_host_paths, strip_path=True))
1480
+
1481
+
1482
+ def _normalize_export_key(value: object) -> str:
1483
+ return re.sub(r"[^a-z0-9]+", "", str(value or "").casefold())
1484
+
1485
+
1486
+ def _is_provider_reasoning_block(value: Any) -> bool:
1487
+ """Return whether a mapping is a provider-owned reasoning/thought block.
1488
+
1489
+ The check intentionally uses protocol-level markers shared by OpenAI,
1490
+ Anthropic, Gemini, OpenRouter, and Mistral rather than provider names, so an
1491
+ unknown compatible provider fails closed too.
1492
+ """
1493
+
1494
+ if not isinstance(value, dict):
1495
+ return False
1496
+ block_type = _normalize_export_key(value.get("type"))
1497
+ if block_type in _REASONING_BLOCK_TYPES:
1498
+ return True
1499
+ if block_type == "signaturedelta":
1500
+ return True
1501
+ if any(family in block_type for family in _REASONING_FAMILIES) and (
1502
+ any(marker in block_type for marker in _REASONING_SENSITIVE_MARKERS)
1503
+ or block_type.endswith(("block", "chunk", "delta", "item", "part"))
1504
+ ):
1505
+ return True
1506
+ return value.get("thought") is True
1507
+
1508
+
1509
+ def _is_reasoning_payload_key(key: object) -> bool:
1510
+ normalized = _normalize_export_key(key)
1511
+ if not normalized:
1512
+ return False
1513
+ if normalized in _PROVIDER_CONTINUATION_CONTAINER_KEYS:
1514
+ return True
1515
+ if normalized in _OPAQUE_CONTINUATION_KEYS:
1516
+ return True
1517
+ if "chainofthought" in normalized or "internalmonologue" in normalized:
1518
+ return True
1519
+ if not any(family in normalized for family in _REASONING_FAMILIES):
1520
+ return False
1521
+ # Content-bearing markers always win over diagnostic markers. For example,
1522
+ # `reasoning_summary_tokens` still identifies summary content and is removed,
1523
+ # while `reasoning_tokens` and `reasoning_effort` remain useful diagnostics.
1524
+ if any(marker in normalized for marker in _REASONING_SENSITIVE_MARKERS):
1525
+ return True
1526
+ if normalized in {"reasoning", "thinking", "thought", "thoughts"}:
1527
+ return True
1528
+ if any(marker in normalized for marker in _REASONING_DIAGNOSTIC_MARKERS):
1529
+ return False
1530
+ # Unknown content-like fields fail closed; an explicitly diagnostic field
1531
+ # must carry one of the allowlisted markers above.
1532
+ return True
1533
+
1534
+
1535
+ def _redact_jsonable(
1536
+ value: Any,
1537
+ *,
1538
+ current_key: str | None = None,
1539
+ workspace_root: Path | None = None,
1540
+ ) -> Any:
1541
+ if value is None or isinstance(value, (int, float, bool)):
1542
+ return value
1543
+ if isinstance(value, str):
1544
+ if _is_endpoint_url_field(current_key):
1545
+ return _redact_bundle_text(_sanitize_urls_in_text(value, strip_path=True))
1546
+ if looks_like_serialized_path_field(current_key):
1547
+ return _redact_bundle_text(
1548
+ safe_serialized_path_field(
1549
+ current_key or "",
1550
+ value,
1551
+ workspace_root=workspace_root,
1552
+ )
1553
+ or ""
1554
+ )
1555
+ # Keep path-shaped fields on the precise structured serializer, then use
1556
+ # best-effort text sanitization for generic strings so exported JSON and
1557
+ # JSONL artifacts do not leak absolute host paths via free-form text.
1558
+ return _sanitize_exported_freeform_text(value, workspace_root=workspace_root)
1559
+ if isinstance(value, Path):
1560
+ if looks_like_serialized_path_field(current_key):
1561
+ return _redact_bundle_text(
1562
+ safe_serialized_path_field(
1563
+ current_key or "",
1564
+ value,
1565
+ workspace_root=workspace_root,
1566
+ )
1567
+ or ""
1568
+ )
1569
+ return _sanitize_exported_freeform_text(str(value), workspace_root=workspace_root)
1570
+ if isinstance(value, dict):
1571
+ if _is_provider_reasoning_block(value):
1572
+ return {}
1573
+ sanitized: dict[str, Any] = {}
1574
+ for key, item in value.items():
1575
+ key_text = str(key)
1576
+ if _is_reasoning_payload_key(key_text) or _is_provider_reasoning_block(item):
1577
+ continue
1578
+ sanitized[key_text] = _redact_jsonable(
1579
+ item,
1580
+ current_key=key_text,
1581
+ workspace_root=workspace_root,
1582
+ )
1583
+ return sanitized
1584
+ if isinstance(value, (list, tuple)):
1585
+ return [
1586
+ _redact_jsonable(
1587
+ item,
1588
+ current_key=current_key,
1589
+ workspace_root=workspace_root,
1590
+ )
1591
+ for item in value
1592
+ if not _is_provider_reasoning_block(item)
1593
+ ]
1594
+ return _sanitize_exported_freeform_text(str(value), workspace_root=workspace_root)
1595
+
1596
+
1597
+ def _sanitize_json_text(text: str, *, workspace_root: Path) -> str | None:
1598
+ try:
1599
+ payload = json.loads(text)
1600
+ except json.JSONDecodeError:
1601
+ return None
1602
+ return (
1603
+ json.dumps(
1604
+ _redact_jsonable(payload, workspace_root=workspace_root),
1605
+ indent=2,
1606
+ sort_keys=True,
1607
+ ensure_ascii=True,
1608
+ )
1609
+ + "\n"
1610
+ )
1611
+
1612
+
1613
+ def _sanitize_jsonl_text(text: str, *, workspace_root: Path) -> str | None:
1614
+ lines: list[str] = []
1615
+ for raw_line in text.splitlines():
1616
+ if not raw_line.strip():
1617
+ continue
1618
+ try:
1619
+ payload = json.loads(raw_line)
1620
+ except json.JSONDecodeError:
1621
+ return None
1622
+ # JSONL bundle exports must sanitize both explicit path fields and
1623
+ # generic free-form strings because session events often nest paths
1624
+ # inside payload.content/message-style fields.
1625
+ lines.append(
1626
+ json.dumps(
1627
+ _redact_jsonable(payload, workspace_root=workspace_root),
1628
+ sort_keys=True,
1629
+ ensure_ascii=True,
1630
+ )
1631
+ )
1632
+ return ("\n".join(lines) + "\n") if lines else ""
1633
+
1634
+
1635
+ def _read_text_if_utf8(path: Path) -> str | None:
1636
+ try:
1637
+ return path.read_text(encoding="utf-8")
1638
+ except (UnicodeDecodeError, OSError):
1639
+ return None
1640
+
1641
+
1642
+ def _now_utc() -> datetime:
1643
+ return datetime.now(UTC).replace(microsecond=0)
1644
+
1645
+
1646
+ def _now_iso(value: datetime) -> str:
1647
+ return value.isoformat().replace("+00:00", "Z")