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,109 @@
1
+ """Owl ASCII animation for the TUI.
2
+
3
+ This reuses the *exact* owl frames and loaders from the welcome screen
4
+ (``cli_impl/commands/welcome.py``) so the launch animation is identical to the
5
+ one the classic CLI shows. We only change how the frames are driven: instead of
6
+ raw ``time.sleep`` + cursor-up ANSI writes, the prompt_toolkit event loop
7
+ advances the frame index and repaints.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ import sys
14
+ from typing import Any
15
+
16
+ from prompt_toolkit.formatted_text import ANSI
17
+
18
+ # Render the owl as a crisp *white* silhouette on a dark terminal — without
19
+ # painting a background box. The source art is a single grayscale ramp (index 16
20
+ # plus the 232–255 gray ramp, with a stray system grey 7). The old mapping only
21
+ # lifted that ramp into 250–255, which left the owl reading as a washed-out light
22
+ # grey (#bcbcbc) and — worse — squashed the beak's faint mid-grey marks to within
23
+ # one step of the body, so the nose showed up as a smudge. Folding the whole ramp
24
+ # to pure white fixes both: the silhouette is genuinely white, and the beak/eyes
25
+ # now read cleanly because their shape is carried by the block-glyph geometry and
26
+ # the dark background showing through, not by tiny grey-on-grey contrast.
27
+ _OWL_WHITE = 231 # xterm-256 pure white (#ffffff)
28
+ _FG_256_RE = re.compile(r"\x1b\[38;5;(\d+)m")
29
+
30
+
31
+ def _whiten_index(idx: int) -> int:
32
+ # Any grey the owl art uses → pure white; leave genuine colours untouched.
33
+ if idx in (7, 8, 16) or 232 <= idx <= 255:
34
+ return _OWL_WHITE
35
+ return idx
36
+
37
+
38
+ def _whiten_frames(frames: list[list[str]]) -> list[list[str]]:
39
+ def _sub(match: re.Match[str]) -> str:
40
+ return f"\x1b[38;5;{_whiten_index(int(match.group(1)))}m"
41
+
42
+ return [[_FG_256_RE.sub(_sub, line) for line in frame] for frame in frames]
43
+
44
+
45
+ def _load_frames(*, color_enabled: bool, stream: Any | None) -> list[list[str]]:
46
+ """Load + crop the owl frames, returning [] when disabled or unavailable.
47
+
48
+ Theme detection (which may probe the terminal via OSC 11) happens here, so
49
+ callers must invoke this *before* the prompt_toolkit application takes over
50
+ the terminal.
51
+ """
52
+ target_stream = stream if stream is not None else getattr(sys, "stdout", None)
53
+ try:
54
+ from ..commands.welcome import ( # local import avoids cli_surface cycles
55
+ _crop_owl_logo_frames,
56
+ _detect_owl_theme,
57
+ _load_owl_logo_frames,
58
+ )
59
+ except Exception:
60
+ return []
61
+ try:
62
+ theme = _detect_owl_theme(target_stream)
63
+ frames = _load_owl_logo_frames(
64
+ stream=target_stream,
65
+ color_enabled=color_enabled,
66
+ theme=theme,
67
+ )
68
+ frames = _crop_owl_logo_frames(frames)
69
+ # White silhouette (no background box) on a confirmed dark terminal.
70
+ # Light terminals keep the asset's dark grays; neutral frames were
71
+ # stripped to the terminal's own foreground by the shared loader.
72
+ if color_enabled and theme == "dark":
73
+ frames = _whiten_frames(frames)
74
+ except Exception:
75
+ return []
76
+ return [frame for frame in (frames or []) if frame]
77
+
78
+
79
+ class OwlAnimation:
80
+ """Holds the owl frames and the current frame index."""
81
+
82
+ def __init__(self, frames: list[list[str]]):
83
+ self._frames = frames or []
84
+ self._index = 0
85
+
86
+ @property
87
+ def available(self) -> bool:
88
+ return bool(self._frames)
89
+
90
+ @property
91
+ def frame_count(self) -> int:
92
+ return len(self._frames)
93
+
94
+ def advance(self) -> None:
95
+ if self._frames:
96
+ self._index = (self._index + 1) % len(self._frames)
97
+
98
+ def current_ansi(self) -> ANSI | None:
99
+ if not self._frames:
100
+ return None
101
+ frame = self._frames[self._index]
102
+ return ANSI("\n".join(frame))
103
+
104
+
105
+ def load_owl_animation(*, color_enabled: bool = True, stream: Any | None = None) -> OwlAnimation:
106
+ return OwlAnimation(_load_frames(color_enabled=color_enabled, stream=stream))
107
+
108
+
109
+ __all__ = ["OwlAnimation", "load_owl_animation"]
@@ -0,0 +1,477 @@
1
+ """Structured rendering for the Forge planner's plan-update aside.
2
+
3
+ While Forge mode chats with the planner, the TUI captures the turn's console
4
+ output (the ``│ ``-prefixed meta/warning bar lines that ``_print_forge_meta``
5
+ and ``_print_forge_warning_messages`` emit) and shows it beneath the reply as
6
+ a collapsible aside. This module turns that captured text back into logical
7
+ notes and renders them as a structured block instead of a flat dim dump:
8
+
9
+ * the canonical status line becomes an honest headline (``plan updated`` /
10
+ ``plan unchanged`` / ``planner error``) instead of the first note,
11
+ * notes about the same task are grouped under one bright title (the single
12
+ biggest source of the old wall-of-text was the task title repeated in
13
+ every warning),
14
+ * planner warnings carry an amber ``⚠`` mark, info notes a dim ``·``, and the
15
+ ``▸``/``▾`` chevrons take the Forge violet so the aside reads as Forge UI.
16
+
17
+ The note grammar matched here is produced by this repo's own emit sites
18
+ (``forge_helpers`` / ``plan_assistant`` / ``task_readiness`` /
19
+ ``plan_reconciliation``); a source-contract test keeps the literals in sync.
20
+ Unrecognized notes degrade gracefully to plain dim bullets — parsing can only
21
+ ever *improve* the presentation, never lose a note.
22
+
23
+ Every rendered row is at most ``width`` columns (the transcript's cursor-pin
24
+ scroll math depends on logical rows matching screen rows).
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import re
30
+ from dataclasses import dataclass, field
31
+
32
+ # Style classes (declared in ``app._STYLE``).
33
+ _S_MARK = "class:tui.planmeta.mark"
34
+ _S_HEAD = "class:tui.planmeta.head"
35
+ _S_DIM = "class:tui.planmeta.dim"
36
+ _S_SUBJECT = "class:tui.planmeta.subject"
37
+ _S_TAG = "class:tui.planmeta.tag"
38
+ _S_WARN = "class:tui.planmeta.warn"
39
+ _S_HINT = "class:tui.planmeta.hint"
40
+
41
+ # The bar prefix ``_forge_bar_text`` puts in front of every console meta /
42
+ # warning line (the rail marks where each logical note starts in a capture).
43
+ _BAR = "│"
44
+
45
+ # Warning-group labels used on the planner turn path (``emit_warning_group``
46
+ # call sites in ``forge_helpers``).
47
+ _WARN_LABELS = ("Planner", "Plan reconciliation")
48
+
49
+ # Canonical status lines the planner turn controller emits, matched by prefix
50
+ # so a small wording tweak degrades to a generic note rather than a wrong
51
+ # headline. The matching note folds into the headline instead of repeating —
52
+ # except the "ignored" line, whose tail carries the actionable reason and is
53
+ # kept as an info note (minus the "; plan unchanged." suffix the headline
54
+ # already states).
55
+ _STATUS_HEADLINES = (
56
+ ("Applied planner update", "plan updated", False),
57
+ ("Planner update contained no applicable changes", "plan unchanged", False),
58
+ ("Planner update ignored", "plan unchanged", True),
59
+ )
60
+ _ERROR_NOTE_PREFIX = "Final planner error"
61
+ _DEFAULT_HEADLINE = "planner notes"
62
+ _ERROR_HEADLINE = "planner error"
63
+
64
+ # Subject extraction: pull the task a note talks about out of the note text so
65
+ # repeated titles collapse into one group header. Subject captures are LAZY
66
+ # (``.+?``) so the FIRST ``': `` boundary closes the title: the emit sites
67
+ # write ``'<title>': `` with the quote-colon immediately after the title,
68
+ # while everything AFTER that boundary is verbatim model output (echoed
69
+ # dependency ids, invalid path entries) that can itself contain quotes and
70
+ # colons — a greedy capture would let such junk swallow the subject and the
71
+ # actionable detail. Titles with plain apostrophes still parse whole (an
72
+ # apostrophe alone is not a boundary); the pathological title containing a
73
+ # literal ``': `` truncates at its first boundary — still grouped, with the
74
+ # tail kept in the detail, never lost.
75
+ _DROPPED_RES = (
76
+ re.compile(
77
+ r"^Dropped (?P<what>[a-z -]+) dependencies for (?P<target>.+?) "
78
+ r"'(?P<subject>.+?)':\s*(?P<rest>.*)$"
79
+ ),
80
+ re.compile(r"^Dropped (?P<what>[a-z -]+) dependencies for (?P<target>.+?) '(?P<subject>.+)'$"),
81
+ )
82
+ _SUBJECT_PREFIX_RES = (
83
+ (re.compile(r"^New task '(?P<subject>.+?)':\s*(?P<rest>.+)$"), "new"),
84
+ (re.compile(r"^Updated task '(?P<subject>.+?)':\s*(?P<rest>.+)$"), "updated"),
85
+ (re.compile(r"^Task '(?P<subject>.+?)':\s*(?P<rest>.+)$"), "task"),
86
+ (re.compile(r"^Task (?P<subject>[\w.-]+):\s*(?P<rest>.+)$"), "task"),
87
+ )
88
+
89
+ _SUBJECT_GLYPHS = {"new": "+", "follow-up": "+", "updated": "~", "task": "○"}
90
+ _SUBJECT_TAGS = {
91
+ "new": "new task",
92
+ "follow-up": "follow-up task",
93
+ "updated": "updated task",
94
+ "task": "task",
95
+ }
96
+
97
+ # UI decoration on rendered rows (stripped again on clipboard copy).
98
+ _EXPAND_HINT = " · click to expand"
99
+ _GLYPH_STRIP_RE = re.compile(r"^(\s*)[▸▾+~○·⚠] (.*)$")
100
+ # Shortest hint fragment worth stripping from a sweep released mid-hint: the
101
+ # distinctive " · " lead-in (a rendered note never ends with it).
102
+ _HINT_MIN_FRAGMENT = 5
103
+
104
+
105
+ def strip_plan_meta_copy_chrome(text: str, *, starts_row: bool = True) -> str:
106
+ """Return a rendered plan-meta row's text without its UI decoration — the
107
+ chevron/bullet/warning glyph and the expand hint — keeping the indentation
108
+ so a copied block preserves its note hierarchy.
109
+
110
+ ``starts_row`` is False for a selection sweep that begins mid-row: its
111
+ first character is note content, never a lead glyph, so only the hint
112
+ suffix is stripped. A sweep released inside the hint drops the trailing
113
+ hint fragment too (the fragment is still chrome). Wrap-continuation rows
114
+ must NOT be routed here at all — their first character is content (see
115
+ ``ROW_KIND_CONT``)."""
116
+ text = str(text or "")
117
+ for length in range(len(_EXPAND_HINT), _HINT_MIN_FRAGMENT - 1, -1):
118
+ if text.endswith(_EXPAND_HINT[:length]):
119
+ text = text[:-length]
120
+ break
121
+ if not starts_row:
122
+ return text
123
+ match = _GLYPH_STRIP_RE.match(text)
124
+ if match:
125
+ return f"{match.group(1)}{match.group(2)}"
126
+ return text
127
+
128
+
129
+ @dataclass(frozen=True)
130
+ class PlanMetaNote:
131
+ """One logical planner note, classified for display."""
132
+
133
+ kind: str = "info" # "info" | "warn"
134
+ label: str = "" # warning-group label ("Planner", "Plan reconciliation")
135
+ subject: str = "" # task title/id the note is about ("" = none)
136
+ subject_kind: str = "" # "new" | "updated" | "follow-up" | "task"
137
+ detail: str = "" # note text with the label + subject phrase folded out
138
+
139
+
140
+ @dataclass
141
+ class _SubjectGroup:
142
+ subject: str
143
+ subject_kind: str
144
+ details: list[PlanMetaNote] = field(default_factory=list)
145
+
146
+
147
+ @dataclass
148
+ class PlanMetaView:
149
+ headline: str
150
+ notes: list[PlanMetaNote]
151
+ entries: list[tuple[str, PlanMetaNote | _SubjectGroup]]
152
+
153
+
154
+ def _is_rule(line: str) -> bool:
155
+ return len(line) >= 3 and not set(line) - set("─-–—═ ")
156
+
157
+
158
+ def _logical_lines(text: str) -> list[str]:
159
+ """Reassemble logical notes from captured console text.
160
+
161
+ Bar-prefixed lines start a note; bare lines are wrap continuations from a
162
+ narrow capture console and re-join the note above them. Text with no bar
163
+ rail at all (already-clean note lines) keeps one note per line.
164
+ """
165
+ raw = [line.strip() for line in str(text or "").split("\n")]
166
+ raw = [line for line in raw if line and not _is_rule(line)]
167
+ has_bar = any(line.startswith(_BAR) for line in raw)
168
+ notes: list[str] = []
169
+ for line in raw:
170
+ if not has_bar:
171
+ notes.append(line)
172
+ elif line.startswith(_BAR):
173
+ content = line[len(_BAR) :].strip()
174
+ if not _is_rule(content): # a bar-prefixed rule is still chrome
175
+ notes.append(content)
176
+ elif notes:
177
+ notes[-1] = f"{notes[-1]} {line}"
178
+ else:
179
+ notes.append(line)
180
+ return [note for note in (" ".join(n.split()) for n in notes) if note]
181
+
182
+
183
+ def _dropped_target_kind(target: str) -> str:
184
+ """Map a dropped-dependencies target phrase to a subject kind — e.g.
185
+ ``new task`` / ``planner tasks_add`` → new, ``synthesized follow-up task``
186
+ → follow-up, ``planner tasks_update`` → updated."""
187
+ lowered = target.lower()
188
+ if "follow-up" in lowered:
189
+ return "follow-up"
190
+ if "new task" in lowered or "tasks_add" in lowered:
191
+ return "new"
192
+ if "updated task" in lowered or "tasks_update" in lowered:
193
+ return "updated"
194
+ return "task"
195
+
196
+
197
+ def _classify(text: str) -> PlanMetaNote:
198
+ kind, label, body = "info", "", text
199
+ for warn_label in _WARN_LABELS:
200
+ prefix = f"{warn_label}: "
201
+ if body.startswith(prefix):
202
+ kind, label, body = "warn", warn_label, body[len(prefix) :].strip()
203
+ break
204
+ subject = subject_kind = ""
205
+ detail = body
206
+ match = next((m for pattern in _DROPPED_RES if (m := pattern.match(body))), None)
207
+ if match:
208
+ subject = match.group("subject")
209
+ subject_kind = _dropped_target_kind(match.group("target"))
210
+ what = match.group("what").strip()
211
+ rest = (match.groupdict().get("rest") or "").strip()
212
+ detail = f"dropped {what} dependencies"
213
+ if rest:
214
+ detail = f"{detail}: {rest}"
215
+ else:
216
+ for pattern, pat_kind in _SUBJECT_PREFIX_RES:
217
+ match = pattern.match(body)
218
+ if match:
219
+ subject = match.group("subject")
220
+ subject_kind = pat_kind
221
+ detail = match.group("rest").strip()
222
+ break
223
+ return PlanMetaNote(
224
+ kind=kind, label=label, subject=subject, subject_kind=subject_kind, detail=detail
225
+ )
226
+
227
+
228
+ def parse_plan_meta(text: str) -> PlanMetaView:
229
+ """Parse captured planner console text into a headline + grouped notes."""
230
+ headline = ""
231
+ notes: list[PlanMetaNote] = []
232
+ for line in _logical_lines(text):
233
+ if not headline:
234
+ matched = next(
235
+ (
236
+ (title, keep_reason)
237
+ for prefix, title, keep_reason in _STATUS_HEADLINES
238
+ if line.startswith(prefix)
239
+ ),
240
+ None,
241
+ )
242
+ if matched is not None:
243
+ headline, keep_reason = matched
244
+ if keep_reason:
245
+ # Keep the actionable tail (e.g. WHY the update was
246
+ # ignored); the headline already says "plan unchanged".
247
+ reason = line.removesuffix("; plan unchanged.").strip()
248
+ if reason:
249
+ notes.append(_classify(reason))
250
+ continue # the status line IS the headline; don't repeat it
251
+ notes.append(_classify(line))
252
+ if not headline:
253
+ error_note = any(
254
+ note.kind == "warn" and note.detail.startswith(_ERROR_NOTE_PREFIX) for note in notes
255
+ )
256
+ headline = _ERROR_HEADLINE if error_note else _DEFAULT_HEADLINE
257
+
258
+ entries: list[tuple[str, PlanMetaNote | _SubjectGroup]] = []
259
+ groups: dict[str, _SubjectGroup] = {}
260
+ for note in notes:
261
+ if not note.subject:
262
+ entries.append(("note", note))
263
+ continue
264
+ group = groups.get(note.subject)
265
+ if group is None:
266
+ group = _SubjectGroup(subject=note.subject, subject_kind=note.subject_kind)
267
+ groups[note.subject] = group
268
+ entries.append(("group", group))
269
+ elif group.subject_kind in {"", "task"} and note.subject_kind not in {"", "task"}:
270
+ # A later note names the touched task more precisely (new/updated).
271
+ group.subject_kind = note.subject_kind
272
+ group.details.append(note)
273
+ return PlanMetaView(headline=headline, notes=notes, entries=entries)
274
+
275
+
276
+ # ------------------------------------------------------------------ rendering
277
+
278
+
279
+ def _iter_words(segments: list[tuple[str, str]]):
280
+ for style, text in segments:
281
+ for word in text.split(" "):
282
+ if word:
283
+ yield style, word
284
+
285
+
286
+ def _wrap_segments(
287
+ lead: list[tuple[str, str]],
288
+ body: list[tuple[str, str]],
289
+ width: int,
290
+ *,
291
+ cont_indent: int,
292
+ ) -> list[list[tuple[str, str]]]:
293
+ """Flow styled ``body`` words after the fixed ``lead``, hanging-indenting
294
+ continuation rows. Every produced row is at most ``width`` columns."""
295
+ width = max(1, int(width))
296
+ cont_indent = max(0, min(int(cont_indent), width - 8)) if width > 8 else 0
297
+ rows: list[list[tuple[str, str]]] = []
298
+ # Clip the lead too, so the row-width contract holds even for a lead wider
299
+ # than a pathologically narrow panel.
300
+ cur: list[tuple[str, str]] = _fit_segments([(s, t) for s, t in lead if t], width)
301
+ used = sum(len(text) for _style, text in cur)
302
+ has_word = False
303
+
304
+ def push(style: str, text: str) -> None:
305
+ nonlocal used
306
+ if cur and cur[-1][0] == style:
307
+ cur[-1] = (style, cur[-1][1] + text)
308
+ else:
309
+ cur.append((style, text))
310
+ used += len(text)
311
+
312
+ def break_row() -> None:
313
+ nonlocal cur, used, has_word
314
+ rows.append(cur)
315
+ cur = [(_S_DIM, " " * cont_indent)] if cont_indent else []
316
+ used = cont_indent
317
+ has_word = False
318
+
319
+ for style, word in _iter_words(body):
320
+ while word:
321
+ room = width - used - (1 if has_word else 0)
322
+ if len(word) <= room:
323
+ if has_word:
324
+ push(style, " ")
325
+ push(style, word)
326
+ has_word = True
327
+ word = ""
328
+ elif not has_word and room > 0:
329
+ push(style, word[:room]) # hard-break a word longer than the row
330
+ word = word[room:]
331
+ break_row()
332
+ else:
333
+ break_row()
334
+ rows.append(cur)
335
+ return rows
336
+
337
+
338
+ def _fit_segments(segments: list[tuple[str, str]], width: int) -> list[tuple[str, str]]:
339
+ """Clip a single styled row to ``width`` columns, ellipsizing the cut."""
340
+ out: list[tuple[str, str]] = []
341
+ used = 0
342
+ for style, text in segments:
343
+ if not text:
344
+ continue
345
+ room = width - used
346
+ if room <= 0:
347
+ break
348
+ if len(text) <= room:
349
+ out.append((style, text))
350
+ used += len(text)
351
+ else:
352
+ clipped = text[: room - 1].rstrip() + "…" if room >= 2 else "…"
353
+ out.append((style, clipped[:room]))
354
+ used += len(clipped)
355
+ break
356
+ return out
357
+
358
+
359
+ def _summary_counts(view: PlanMetaView) -> tuple[int, int, int]:
360
+ new_n = sum(
361
+ 1
362
+ for kind, entry in view.entries
363
+ if kind == "group" and entry.subject_kind in {"new", "follow-up"}
364
+ )
365
+ updated_n = sum(
366
+ 1 for kind, entry in view.entries if kind == "group" and entry.subject_kind == "updated"
367
+ )
368
+ warn_n = sum(1 for note in view.notes if note.kind == "warn")
369
+ return new_n, updated_n, warn_n
370
+
371
+
372
+ def _collapsed_row(view: PlanMetaView, width: int) -> list[tuple[str, str]]:
373
+ n = len(view.notes)
374
+ if n == 0:
375
+ return _fit_segments([(_S_MARK, "▸ "), (_S_HEAD, view.headline)], width)
376
+ only = view.notes[0]
377
+ if view.headline == _DEFAULT_HEADLINE and n == 1 and only.kind == "info" and not only.subject:
378
+ # A single free-form note (e.g. "planner unavailable"): show it verbatim,
379
+ # exactly like the old aside did — a synthetic headline would hide it.
380
+ # When the note is clipped, reserve room for the expand hint so the
381
+ # full text stays reachable.
382
+ segments = [(_S_MARK, "▸ "), (_S_DIM, only.detail)]
383
+ if 2 + len(only.detail) <= width or width < len(_EXPAND_HINT) + 12:
384
+ return _fit_segments(segments, width)
385
+ row = _fit_segments(segments, width - len(_EXPAND_HINT))
386
+ row.append((_S_HINT, _EXPAND_HINT))
387
+ return row
388
+ new_n, updated_n, warn_n = _summary_counts(view)
389
+ # (priority, segment): lowest priority sheds first when the panel is narrow.
390
+ candidates: list[tuple[int, tuple[str, str]]] = [
391
+ (100, (_S_MARK, "▸ ")),
392
+ (90, (_S_HEAD, view.headline)),
393
+ (60, (_S_DIM, f" · {n} note{'s' if n != 1 else ''}")),
394
+ ]
395
+ if new_n:
396
+ candidates.append((50, (_S_DIM, f" · {new_n} new task{'s' if new_n != 1 else ''}")))
397
+ if updated_n:
398
+ candidates.append((40, (_S_DIM, f" · {updated_n} updated")))
399
+ if warn_n:
400
+ candidates.append((70, (_S_WARN, f" · {warn_n} warning{'s' if warn_n != 1 else ''}")))
401
+ # The expand affordance outlives the task-count chips on a narrow panel —
402
+ # discoverability beats detail.
403
+ candidates.append((55, (_S_HINT, _EXPAND_HINT)))
404
+ while (
405
+ sum(len(text) for _p, (_s, text) in candidates) > width
406
+ and min(p for p, _seg in candidates) < 90
407
+ ):
408
+ drop = min(range(len(candidates)), key=lambda i: candidates[i][0])
409
+ candidates.pop(drop)
410
+ return _fit_segments([seg for _p, seg in candidates], width)
411
+
412
+
413
+ def _note_body(note: PlanMetaNote) -> list[tuple[str, str]]:
414
+ body: list[tuple[str, str]] = []
415
+ if note.kind == "warn" and note.label and not note.subject:
416
+ body.append((_S_TAG, f"{note.label} · "))
417
+ body.append((_S_DIM, note.detail))
418
+ return body
419
+
420
+
421
+ def _note_lead(note: PlanMetaNote, *, indent: str) -> list[tuple[str, str]]:
422
+ if note.kind == "warn":
423
+ return [(_S_DIM, indent), (_S_WARN, "⚠ ")]
424
+ return [(_S_DIM, f"{indent}· ")]
425
+
426
+
427
+ # Per-row kinds for ``plan_meta_rows_with_kinds``: a "planmeta" row starts a
428
+ # note (its first glyph is UI chrome the copy path strips), a "planmetacont"
429
+ # row is a wrap continuation (its first character is note CONTENT — stripping
430
+ # a leading '+'/'~'/'·' there would silently delete it from copies).
431
+ ROW_KIND_LEAD = "planmeta"
432
+ ROW_KIND_CONT = "planmetacont"
433
+ PLAN_META_ROW_ROLES = frozenset({ROW_KIND_LEAD, ROW_KIND_CONT})
434
+
435
+
436
+ def plan_meta_rows_with_kinds(
437
+ text: str, width: int, *, expanded: bool
438
+ ) -> list[tuple[list[tuple[str, str]], str]]:
439
+ """Render the plan-update aside as ``(row, kind)`` pairs. Collapsed: one
440
+ summary row. Expanded: a header plus grouped, styled notes. Rows never
441
+ exceed ``width`` columns."""
442
+ width = max(20, int(width))
443
+ view = parse_plan_meta(text)
444
+ if not view.notes and view.headline == _DEFAULT_HEADLINE:
445
+ return []
446
+ if not expanded:
447
+ return [(_collapsed_row(view, width), ROW_KIND_LEAD)]
448
+ n = len(view.notes)
449
+ header = [(_S_MARK, "▾ "), (_S_HEAD, view.headline)]
450
+ if n:
451
+ header.append((_S_DIM, f" · {n} note{'s' if n != 1 else ''}"))
452
+ rows: list[tuple[list[tuple[str, str]], str]] = [(_fit_segments(header, width), ROW_KIND_LEAD)]
453
+
454
+ def _extend(lead: list[tuple[str, str]], body: list[tuple[str, str]], cont_indent: int) -> None:
455
+ wrapped = _wrap_segments(lead, body, width, cont_indent=cont_indent)
456
+ rows.append((wrapped[0], ROW_KIND_LEAD))
457
+ rows.extend((row, ROW_KIND_CONT) for row in wrapped[1:])
458
+
459
+ for kind, entry in view.entries:
460
+ if kind == "note":
461
+ _extend(_note_lead(entry, indent=" "), _note_body(entry), 4)
462
+ continue
463
+ glyph = _SUBJECT_GLYPHS.get(entry.subject_kind, "○")
464
+ tag = _SUBJECT_TAGS.get(entry.subject_kind, "task")
465
+ _extend(
466
+ [(_S_MARK, f" {glyph} ")],
467
+ [(_S_TAG, f"{tag} · "), (_S_SUBJECT, entry.subject)],
468
+ 4,
469
+ )
470
+ for note in entry.details:
471
+ _extend(_note_lead(note, indent=" "), [(_S_DIM, note.detail)], 6)
472
+ return rows
473
+
474
+
475
+ def plan_meta_rows(text: str, width: int, *, expanded: bool) -> list[list[tuple[str, str]]]:
476
+ """Render the plan-update aside (rows only — see ``plan_meta_rows_with_kinds``)."""
477
+ return [row for row, _kind in plan_meta_rows_with_kinds(text, width, expanded=expanded)]