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,652 @@
1
+ """Validate-and-repair policy for planner payloads.
2
+
3
+ The planner is asked for one JSON object per turn. When the model returns
4
+ something the schema rejects, the honest first move is to tell the model exactly
5
+ what was wrong and ask again -- not to quietly reshape the payload host-side and
6
+ pretend the model produced it. This module holds the facts that bound that
7
+ retry loop, plus the vocabulary for reporting what happened when the loop ends.
8
+
9
+ It is pure: no LLM calls, no filesystem, no clock. Everything here is a function
10
+ of a plan dict, a payload dict, or a config object.
11
+
12
+ * ``resolve_plan_repair_policy`` -- attempt caps and the kill-switch.
13
+ * ``repair_retry_instruction`` -- the escalating-strictness follow-up text. Each
14
+ attempt restates the same schema demand in blunter terms and drops more of the
15
+ model's latitude, because a model that ignored the polite version has already
16
+ demonstrated the polite version does not work on it.
17
+ * ``host_repaired_field_paths`` -- which fields host-side repair had to change,
18
+ so a salvaged payload is labelled rather than passed off as the model's own.
19
+ * ``execution_readiness_errors`` -- the R1-R5 acceptance rules rendered as
20
+ re-prompt material, so an unexecutable plan is a retry trigger and not a
21
+ surprise at ``forge exec`` time.
22
+ * ``ClarificationLoopTracker`` -- consecutive clarification rounds for one goal.
23
+ Past the cap the caller forces a concrete draft instead of asking forever.
24
+ * ``assess_plan_status`` -- draft vs execution_ready, decided by the *same* rule
25
+ the execution gate uses, so a plan that will be blocked later says so now.
26
+
27
+ Nothing here inspects prose or vocabulary: every decision is driven by payload
28
+ shape, plan structure, and the acceptance rules already in
29
+ :mod:`alysis_code.plan_validation`.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ from collections.abc import Mapping, Sequence
35
+ from dataclasses import dataclass, field
36
+ from typing import Any
37
+
38
+ from .branding import env_get
39
+ from .plan_validation import PlanAcceptanceIssue, find_plan_acceptance_issues
40
+
41
+ # ---------------------------------------------------------------------------
42
+ # Policy (kill-switch mirrors the empty-response-stall / evidence-v2 idiom)
43
+ # ---------------------------------------------------------------------------
44
+
45
+ DEFAULT_MAX_PAYLOAD_ATTEMPTS = 3
46
+ DEFAULT_MAX_CLARIFICATION_ROUNDS = 2
47
+
48
+ # Hard ceilings. A misconfigured value must not turn one planner turn into an
49
+ # unbounded spend; the loop is a repair mechanism, not a search strategy.
50
+ MAX_PAYLOAD_ATTEMPTS_CEILING = 6
51
+ MAX_CLARIFICATION_ROUNDS_CEILING = 6
52
+
53
+ PLAN_STATUS_DRAFT = "draft"
54
+ PLAN_STATUS_EXECUTION_READY = "execution_ready"
55
+
56
+ PLAN_STATUS_KEY = "plan_status"
57
+ PLAN_STATUS_DETAIL_KEY = "plan_status_detail"
58
+ PLAN_REPAIR_KEY = "plan_repair"
59
+
60
+ # Terminal states of one planner turn. Exactly one of these is always reached.
61
+ TERMINAL_VALIDATED = "validated"
62
+ TERMINAL_HOST_REPAIRED = "host_repaired"
63
+ TERMINAL_FORCED_DRAFT = "forced_draft"
64
+ TERMINAL_FAILED = "failed"
65
+
66
+ _TRUTHY = {"on", "1", "true", "yes", "enabled"}
67
+ _FALSEY = {"off", "0", "false", "no", "disabled"}
68
+
69
+
70
+ @dataclass(frozen=True)
71
+ class PlanRepairPolicy:
72
+ """Bounds for one planner turn's validate-and-repair loop."""
73
+
74
+ enabled: bool = True
75
+ max_payload_attempts: int = DEFAULT_MAX_PAYLOAD_ATTEMPTS
76
+ max_clarification_rounds: int = DEFAULT_MAX_CLARIFICATION_ROUNDS
77
+
78
+ @property
79
+ def max_repair_retries(self) -> int:
80
+ """Follow-up requests allowed after the first response."""
81
+ return max(self.max_payload_attempts - 1, 0)
82
+
83
+
84
+ def plan_repair_enabled(cfg: Any | None) -> bool:
85
+ """Kill-switch for the general validate-and-repair loop.
86
+
87
+ ``ALYSIS_PLAN_REPAIR`` wins over config; default is on. When off the
88
+ policy collapses to the legacy shape: one schema retry, host-side repair
89
+ applied eagerly, no execution-readiness retry, no clarification cap.
90
+ """
91
+ flag = _env_flag("ALYSIS_PLAN_REPAIR")
92
+ if flag is not None:
93
+ return flag
94
+ return bool(getattr(cfg, "plan_repair_enabled", True))
95
+
96
+
97
+ def resolve_plan_repair_policy(cfg: Any | None) -> PlanRepairPolicy:
98
+ """Resolve attempt caps from env, then config, then the defaults."""
99
+ enabled = plan_repair_enabled(cfg)
100
+ attempts = _bounded_int(
101
+ _env_int("ALYSIS_PLAN_REPAIR_ATTEMPTS"),
102
+ getattr(cfg, "plan_repair_max_attempts", None),
103
+ fallback=DEFAULT_MAX_PAYLOAD_ATTEMPTS,
104
+ ceiling=MAX_PAYLOAD_ATTEMPTS_CEILING,
105
+ )
106
+ clarifications = _bounded_int(
107
+ _env_int("ALYSIS_PLAN_REPAIR_CLARIFICATION_ROUNDS"),
108
+ getattr(cfg, "plan_repair_max_clarification_rounds", None),
109
+ fallback=DEFAULT_MAX_CLARIFICATION_ROUNDS,
110
+ ceiling=MAX_CLARIFICATION_ROUNDS_CEILING,
111
+ )
112
+ if not enabled:
113
+ # Legacy shape: the single retry that predates this module.
114
+ return PlanRepairPolicy(
115
+ enabled=False,
116
+ max_payload_attempts=2,
117
+ max_clarification_rounds=0,
118
+ )
119
+ return PlanRepairPolicy(
120
+ enabled=True,
121
+ max_payload_attempts=attempts,
122
+ max_clarification_rounds=clarifications,
123
+ )
124
+
125
+
126
+ def _env_flag(name: str) -> bool | None:
127
+ raw = env_get(name)
128
+ if raw is None:
129
+ return None
130
+ normalized = str(raw).strip().lower()
131
+ if normalized in _FALSEY:
132
+ return False
133
+ if normalized in _TRUTHY:
134
+ return True
135
+ return None
136
+
137
+
138
+ def _env_int(name: str) -> int | None:
139
+ raw = env_get(name)
140
+ if raw is None:
141
+ return None
142
+ try:
143
+ return int(str(raw).strip())
144
+ except (TypeError, ValueError):
145
+ return None
146
+
147
+
148
+ def _bounded_int(*candidates: Any, fallback: int, ceiling: int) -> int:
149
+ for candidate in candidates:
150
+ if candidate is None:
151
+ continue
152
+ try:
153
+ parsed = int(candidate)
154
+ except (TypeError, ValueError):
155
+ continue
156
+ if parsed < 1:
157
+ continue
158
+ return min(parsed, ceiling)
159
+ return min(max(fallback, 1), ceiling)
160
+
161
+
162
+ # ---------------------------------------------------------------------------
163
+ # Escalating-strictness retry text
164
+ # ---------------------------------------------------------------------------
165
+
166
+ # Rungs for the intermediate follow-ups, in order. The last rung repeats when the
167
+ # attempt cap is raised past the ladder, so a bigger budget never runs out of text.
168
+ _STRICTNESS_LADDER: tuple[str, ...] = (
169
+ (
170
+ "Return the corrected payload as ONE JSON object. Keep every field that was "
171
+ "already correct; change only what the validation errors name."
172
+ ),
173
+ (
174
+ "STRICT MODE. The previous correction also failed validation. Output MUST begin "
175
+ "with '{' and end with '}'. No markdown fence, no prose before or after, no "
176
+ "commentary field, no keys outside the documented schema. Every array field must "
177
+ "be a JSON array of strings even when it holds a single value. If you are unsure "
178
+ "about an optional field, omit the key entirely rather than guessing its shape."
179
+ ),
180
+ )
181
+
182
+ # Reserved for the last allowed attempt, whatever the cap is: it is the only rung
183
+ # that tells the model what happens when it fails again.
184
+ _FINAL_STRICTNESS = (
185
+ "FINAL ATTEMPT. Emit the smallest payload that validates. Drop every optional key "
186
+ "you are not certain about, keep only 'assistant_message' plus the plan_update "
187
+ "fields you can state exactly, and re-check each value's type against the schema "
188
+ "before answering. After this attempt the host repairs the payload itself and the "
189
+ "resulting plan is recorded as host-repaired rather than model-authored."
190
+ )
191
+
192
+
193
+ def repair_strictness_instruction(*, attempt: int, max_attempts: int) -> str:
194
+ """Instruction text for follow-up ``attempt`` (2-based: the first retry is 2)."""
195
+ if int(attempt) >= int(max_attempts):
196
+ return _FINAL_STRICTNESS
197
+ rung = min(max(int(attempt) - 2, 0), len(_STRICTNESS_LADDER) - 1)
198
+ return _STRICTNESS_LADDER[rung]
199
+
200
+
201
+ def repair_retry_instruction(
202
+ *,
203
+ attempt: int,
204
+ max_attempts: int,
205
+ validation_errors: Sequence[str],
206
+ previous_response: str | None = None,
207
+ kind: str = "schema",
208
+ ) -> str:
209
+ """Follow-up block naming the exact errors, the attempt number, and the strictness rung.
210
+
211
+ ``previous_response`` is optional so a caller that already quotes the model's
212
+ last output does not quote it twice in one prompt.
213
+ """
214
+ errors = [str(item).strip() for item in validation_errors if str(item).strip()]
215
+ if kind == "execution_readiness":
216
+ headline = (
217
+ "Your previous planner output parsed, but the plan it produces cannot be "
218
+ "executed. Every listed rule must be satisfied or the plan is rejected at "
219
+ "execution time."
220
+ )
221
+ error_label = "Execution-readiness errors"
222
+ else:
223
+ headline = "Your previous planner output did not match the required schema."
224
+ error_label = "Validation errors"
225
+ rendered_errors = "\n".join(f"- {item}" for item in errors[:10]) or "- (unspecified)"
226
+ if len(errors) > 10:
227
+ rendered_errors += f"\n- (+{len(errors) - 10} more)"
228
+ previous_block = (
229
+ f"Previous response:\n{previous_response}\n\n" if previous_response is not None else ""
230
+ )
231
+ return (
232
+ f"{headline}\n"
233
+ f"Correction attempt {attempt} of {max_attempts}.\n"
234
+ f"{error_label}:\n{rendered_errors}\n\n"
235
+ f"{previous_block}"
236
+ f"{repair_strictness_instruction(attempt=attempt, max_attempts=max_attempts)}\n"
237
+ "Return the full corrected payload, not a diff or a patch. Preserve the latest "
238
+ "user intent, target roots, and decoy/forbidden constraints from the prompt."
239
+ )
240
+
241
+
242
+ # ---------------------------------------------------------------------------
243
+ # Host-side repair accounting
244
+ # ---------------------------------------------------------------------------
245
+
246
+ # A payload with thousands of leaves is already pathological; the cap keeps one
247
+ # bad response from writing an unbounded field list into the plan.
248
+ _MAX_REPAIRED_FIELD_PATHS = 60
249
+
250
+
251
+ def host_repaired_field_paths(*, raw: Any, repaired: Any) -> list[str]:
252
+ """Dotted paths where host-side repair changed the model's payload.
253
+
254
+ Both sides are pre-validation, so shared normalisation does not show up here:
255
+ what remains is exactly what the repairer had to touch.
256
+ """
257
+ paths: list[str] = []
258
+ _collect_diff_paths(raw, repaired, prefix="", out=paths)
259
+ deduped = list(dict.fromkeys(paths))
260
+ return sorted(deduped[:_MAX_REPAIRED_FIELD_PATHS])
261
+
262
+
263
+ def _collect_diff_paths(left: Any, right: Any, *, prefix: str, out: list[str]) -> None:
264
+ if len(out) >= _MAX_REPAIRED_FIELD_PATHS:
265
+ return
266
+ if isinstance(left, Mapping) and isinstance(right, Mapping):
267
+ for key in sorted({*left.keys(), *right.keys()}, key=str):
268
+ child = f"{prefix}.{key}" if prefix else str(key)
269
+ if key not in left or key not in right:
270
+ out.append(child)
271
+ continue
272
+ _collect_diff_paths(left[key], right[key], prefix=child, out=out)
273
+ return
274
+ if _is_plain_sequence(left) and _is_plain_sequence(right):
275
+ if len(left) != len(right):
276
+ out.append(prefix or "(root)")
277
+ return
278
+ for index, (left_item, right_item) in enumerate(zip(left, right, strict=False)):
279
+ _collect_diff_paths(
280
+ left_item,
281
+ right_item,
282
+ prefix=f"{prefix}[{index}]",
283
+ out=out,
284
+ )
285
+ return
286
+ if left != right:
287
+ out.append(prefix or "(root)")
288
+
289
+
290
+ def _is_plain_sequence(value: Any) -> bool:
291
+ return isinstance(value, Sequence) and not isinstance(value, str | bytes)
292
+
293
+
294
+ # ---------------------------------------------------------------------------
295
+ # Execution readiness as a retry trigger
296
+ # ---------------------------------------------------------------------------
297
+
298
+
299
+ def format_acceptance_issue(issue: PlanAcceptanceIssue) -> str:
300
+ parts = [str(issue.rule_id)]
301
+ if issue.task_id:
302
+ parts.append(f"task={issue.task_id}")
303
+ parts.append(f"observed={issue.observed}")
304
+ if issue.detail:
305
+ parts.append(f"detail={issue.detail}")
306
+ return " ".join(parts)
307
+
308
+
309
+ def execution_readiness_errors(plan: Mapping[str, Any]) -> list[str]:
310
+ """R1-R5 acceptance failures for ``plan``, rendered for a re-prompt."""
311
+ try:
312
+ issues = find_plan_acceptance_issues(dict(plan))
313
+ except Exception: # noqa: BLE001 - readiness reporting must not break the turn
314
+ return []
315
+ return [format_acceptance_issue(issue) for issue in issues]
316
+
317
+
318
+ def plan_update_proposes_task_work(plan_update: Any) -> bool:
319
+ """True when the payload asks for task work that execution would have to run.
320
+
321
+ A clarifying turn -- questions, a goal edit, requirement notes -- proposes no
322
+ task work, so holding it to the execution-acceptance rules would burn the
323
+ retry budget on a payload that was never meant to be executable.
324
+ """
325
+ if not isinstance(plan_update, Mapping):
326
+ return False
327
+ for key in ("tasks_add", "tasks_update"):
328
+ entries = plan_update.get(key)
329
+ if isinstance(entries, Sequence) and not isinstance(entries, str | bytes) and len(entries):
330
+ return True
331
+ return False
332
+
333
+
334
+ # ---------------------------------------------------------------------------
335
+ # Clarification loop cap
336
+ # ---------------------------------------------------------------------------
337
+
338
+
339
+ def clarification_goal_key(*, plan: Mapping[str, Any] | None, user_text: str) -> str:
340
+ """Stable key for "the same goal", so a new goal restarts the count.
341
+
342
+ The plan's own goal is the anchor when it has one; before that exists the
343
+ first message of the streak is all there is to key on.
344
+ """
345
+ goal = str((plan or {}).get("project_goal") or "").strip()
346
+ if goal:
347
+ return " ".join(goal.split()).casefold()[:200]
348
+ return " ".join(str(user_text or "").split()).casefold()[:200]
349
+
350
+
351
+ @dataclass
352
+ class ClarificationLoopTracker:
353
+ """Consecutive clarification-only planner turns for one goal."""
354
+
355
+ max_rounds: int = DEFAULT_MAX_CLARIFICATION_ROUNDS
356
+ goal_key: str = ""
357
+ rounds: int = 0
358
+
359
+ def rounds_for(self, goal_key: str) -> int:
360
+ """Rounds already recorded for ``goal_key`` (0 when the goal changed)."""
361
+ return self.rounds if goal_key == self.goal_key else 0
362
+
363
+ def record(self, *, goal_key: str, awaiting_clarification: bool) -> int:
364
+ """Record one planner turn's outcome and return the running round count."""
365
+ if goal_key != self.goal_key:
366
+ self.goal_key = goal_key
367
+ self.rounds = 0
368
+ if not awaiting_clarification:
369
+ self.rounds = 0
370
+ return 0
371
+ self.rounds += 1
372
+ return self.rounds
373
+
374
+ def cap_reached(self, goal_key: str) -> bool:
375
+ if self.max_rounds <= 0:
376
+ return False
377
+ return self.rounds_for(goal_key) >= self.max_rounds
378
+
379
+ def reset(self) -> None:
380
+ self.goal_key = ""
381
+ self.rounds = 0
382
+
383
+
384
+ def payload_awaits_clarification(validated: Mapping[str, Any] | None) -> bool:
385
+ """True when the payload asks the user something and proposes no plan work."""
386
+ if not isinstance(validated, Mapping):
387
+ return False
388
+ if validated.get("plan_update"):
389
+ return False
390
+ questions = validated.get("questions")
391
+ if not isinstance(questions, Sequence) or isinstance(questions, str | bytes):
392
+ return False
393
+ return any(str(item).strip() for item in questions)
394
+
395
+
396
+ # ---------------------------------------------------------------------------
397
+ # Turn report
398
+ # ---------------------------------------------------------------------------
399
+
400
+
401
+ @dataclass(frozen=True)
402
+ class PlannerRepairReport:
403
+ """What the validate-and-repair loop did during one planner turn."""
404
+
405
+ terminal_state: str = TERMINAL_VALIDATED
406
+ attempts: int = 1
407
+ max_attempts: int = DEFAULT_MAX_PAYLOAD_ATTEMPTS
408
+ schema_retries: int = 0
409
+ readiness_retries: int = 0
410
+ host_repaired: bool = False
411
+ host_repaired_fields: list[str] = field(default_factory=list)
412
+ execution_readiness_errors: list[str] = field(default_factory=list)
413
+ clarification_rounds: int = 0
414
+ forced_draft: bool = False
415
+ validation_errors: list[str] = field(default_factory=list)
416
+
417
+ @property
418
+ def degraded(self) -> bool:
419
+ """True when the turn did not produce what a clean planner turn would.
420
+
421
+ A host-repaired payload, a forced draft, and a payload that never became
422
+ execution-ready all qualify: each is worth keeping in the plan's history,
423
+ because each explains a plan that is not what the planner was asked for.
424
+ """
425
+ return bool(self.host_repaired or self.forced_draft or self.execution_readiness_errors)
426
+
427
+ def to_dict(self) -> dict[str, Any]:
428
+ return {
429
+ "terminal_state": self.terminal_state,
430
+ "attempts": int(self.attempts),
431
+ "max_attempts": int(self.max_attempts),
432
+ "schema_retries": int(self.schema_retries),
433
+ "readiness_retries": int(self.readiness_retries),
434
+ "host_repaired": bool(self.host_repaired),
435
+ "host_repaired_fields": list(self.host_repaired_fields),
436
+ "execution_readiness_errors": list(self.execution_readiness_errors),
437
+ "clarification_rounds": int(self.clarification_rounds),
438
+ "forced_draft": bool(self.forced_draft),
439
+ "validation_errors": list(self.validation_errors),
440
+ }
441
+
442
+
443
+ # ---------------------------------------------------------------------------
444
+ # Plan status
445
+ # ---------------------------------------------------------------------------
446
+
447
+
448
+ @dataclass(frozen=True)
449
+ class PlanStatusAssessment:
450
+ """Whether a saved plan can be executed, and why not when it cannot."""
451
+
452
+ status: str
453
+ blocking_reasons: list[str] = field(default_factory=list)
454
+ warnings: list[str] = field(default_factory=list)
455
+
456
+ @property
457
+ def execution_ready(self) -> bool:
458
+ return self.status == PLAN_STATUS_EXECUTION_READY
459
+
460
+ def to_dict(self) -> dict[str, Any]:
461
+ return {
462
+ "status": self.status,
463
+ "blocking_reasons": list(self.blocking_reasons),
464
+ "warnings": list(self.warnings),
465
+ }
466
+
467
+
468
+ def assess_plan_status(
469
+ plan: Mapping[str, Any],
470
+ *,
471
+ validation_warnings: Sequence[str] | None = None,
472
+ ) -> PlanStatusAssessment:
473
+ """Classify a plan as draft or execution_ready.
474
+
475
+ The rule is the execution gate's own rule, so the two can never disagree: a
476
+ plan is ``execution_ready`` exactly when ``forge exec`` would not reject it.
477
+ ``validate_plan`` warnings (a missing ``acceptance_criteria``, say) are
478
+ recorded but do not by themselves make a plan a draft -- they are advisory,
479
+ and calling them blocking here would contradict the gate.
480
+ """
481
+ blocking = execution_readiness_errors(plan)
482
+ warnings = [str(item).strip() for item in (validation_warnings or []) if str(item).strip()]
483
+ status = PLAN_STATUS_DRAFT if blocking else PLAN_STATUS_EXECUTION_READY
484
+ return PlanStatusAssessment(
485
+ status=status,
486
+ blocking_reasons=blocking,
487
+ warnings=warnings,
488
+ )
489
+
490
+
491
+ def apply_plan_status(
492
+ plan: dict[str, Any],
493
+ *,
494
+ validation_warnings: Sequence[str] | None = None,
495
+ assessment: PlanStatusAssessment | None = None,
496
+ ) -> PlanStatusAssessment:
497
+ """Write the draft/execution_ready status onto ``plan`` and return it."""
498
+ resolved = (
499
+ assessment
500
+ if assessment is not None
501
+ else assess_plan_status(plan, validation_warnings=validation_warnings)
502
+ )
503
+ plan[PLAN_STATUS_KEY] = resolved.status
504
+ plan[PLAN_STATUS_DETAIL_KEY] = {
505
+ "blocking_reasons": list(resolved.blocking_reasons),
506
+ "warnings": list(resolved.warnings),
507
+ }
508
+ return resolved
509
+
510
+
511
+ def resolved_plan_status(plan: Mapping[str, Any]) -> PlanStatusAssessment:
512
+ """The plan's status as recorded, or assessed live when it was never recorded.
513
+
514
+ Status and reasons always come from the same source: a plan saved before this
515
+ field existed must not report a status from one place and empty reasons from
516
+ another.
517
+ """
518
+ recorded = str(plan.get(PLAN_STATUS_KEY) or "").strip()
519
+ detail = plan.get(PLAN_STATUS_DETAIL_KEY)
520
+ if recorded in {PLAN_STATUS_DRAFT, PLAN_STATUS_EXECUTION_READY} and isinstance(detail, Mapping):
521
+ return PlanStatusAssessment(
522
+ status=recorded,
523
+ blocking_reasons=[str(item) for item in detail.get("blocking_reasons") or []],
524
+ warnings=[str(item) for item in detail.get("warnings") or []],
525
+ )
526
+ return assess_plan_status(plan)
527
+
528
+
529
+ def plan_status(plan: Mapping[str, Any]) -> str:
530
+ """Recorded status, or the live assessment for a plan saved before this existed."""
531
+ return resolved_plan_status(plan).status
532
+
533
+
534
+ def plan_status_detail(plan: Mapping[str, Any]) -> dict[str, Any]:
535
+ assessment = resolved_plan_status(plan)
536
+ return {
537
+ "blocking_reasons": list(assessment.blocking_reasons),
538
+ "warnings": list(assessment.warnings),
539
+ }
540
+
541
+
542
+ # ---------------------------------------------------------------------------
543
+ # Repair metadata on the plan
544
+ # ---------------------------------------------------------------------------
545
+
546
+ # Keep the on-plan history bounded: the most recent turns are what a reader
547
+ # needs, and plan.json is re-serialised on every save.
548
+ _MAX_RECORDED_REPAIRS = 20
549
+
550
+
551
+ def record_plan_repair(plan: dict[str, Any], report: PlannerRepairReport) -> dict[str, Any]:
552
+ """Append one turn's repair report to the plan's repair metadata.
553
+
554
+ Only degraded turns are recorded. A turn the model got right on its own adds
555
+ no history, so the presence of this block always means something was salvaged.
556
+ """
557
+ metadata = plan.get(PLAN_REPAIR_KEY)
558
+ if not isinstance(metadata, dict):
559
+ metadata = {}
560
+ entries = metadata.get("entries")
561
+ if not isinstance(entries, list):
562
+ entries = []
563
+
564
+ if report.degraded:
565
+ entries.append(report.to_dict())
566
+ entries = entries[-_MAX_RECORDED_REPAIRS:]
567
+
568
+ host_fields: list[str] = []
569
+ for entry in entries:
570
+ if isinstance(entry, Mapping):
571
+ host_fields.extend(str(item) for item in entry.get("host_repaired_fields") or [])
572
+
573
+ metadata["entries"] = entries
574
+ metadata["host_repaired_fields"] = sorted(dict.fromkeys(host_fields))
575
+ metadata["host_repaired"] = any(
576
+ bool(entry.get("host_repaired")) for entry in entries if isinstance(entry, Mapping)
577
+ )
578
+ metadata["forced_draft"] = any(
579
+ bool(entry.get("forced_draft")) for entry in entries if isinstance(entry, Mapping)
580
+ )
581
+ if entries:
582
+ plan[PLAN_REPAIR_KEY] = metadata
583
+ return metadata
584
+
585
+
586
+ def plan_repair_metadata(plan: Mapping[str, Any]) -> dict[str, Any]:
587
+ """Repair metadata for ``plan``, normalised for reporting surfaces."""
588
+ metadata = plan.get(PLAN_REPAIR_KEY)
589
+ if not isinstance(metadata, Mapping):
590
+ return {
591
+ "host_repaired": False,
592
+ "host_repaired_fields": [],
593
+ "forced_draft": False,
594
+ "entries": [],
595
+ }
596
+ entries = [dict(entry) for entry in metadata.get("entries") or [] if isinstance(entry, Mapping)]
597
+ return {
598
+ "host_repaired": bool(metadata.get("host_repaired")),
599
+ "host_repaired_fields": [str(item) for item in metadata.get("host_repaired_fields") or []],
600
+ "forced_draft": bool(metadata.get("forced_draft")),
601
+ "entries": entries,
602
+ }
603
+
604
+
605
+ def plan_repair_event_payload(plan: Mapping[str, Any]) -> dict[str, Any]:
606
+ """The status + repair fields every ``plan_saved``/``plan_invalid`` event carries."""
607
+ metadata = plan_repair_metadata(plan)
608
+ assessment = resolved_plan_status(plan)
609
+ return {
610
+ "plan_status": assessment.status,
611
+ "plan_status_blocking_reasons": list(assessment.blocking_reasons),
612
+ "host_repaired": metadata["host_repaired"],
613
+ "host_repaired_fields": metadata["host_repaired_fields"],
614
+ "forced_draft": metadata["forced_draft"],
615
+ }
616
+
617
+
618
+ __all__ = [
619
+ "DEFAULT_MAX_CLARIFICATION_ROUNDS",
620
+ "DEFAULT_MAX_PAYLOAD_ATTEMPTS",
621
+ "PLAN_REPAIR_KEY",
622
+ "PLAN_STATUS_DETAIL_KEY",
623
+ "PLAN_STATUS_DRAFT",
624
+ "PLAN_STATUS_EXECUTION_READY",
625
+ "PLAN_STATUS_KEY",
626
+ "TERMINAL_FAILED",
627
+ "TERMINAL_FORCED_DRAFT",
628
+ "TERMINAL_HOST_REPAIRED",
629
+ "TERMINAL_VALIDATED",
630
+ "ClarificationLoopTracker",
631
+ "PlanRepairPolicy",
632
+ "PlanStatusAssessment",
633
+ "PlannerRepairReport",
634
+ "apply_plan_status",
635
+ "assess_plan_status",
636
+ "clarification_goal_key",
637
+ "execution_readiness_errors",
638
+ "format_acceptance_issue",
639
+ "host_repaired_field_paths",
640
+ "payload_awaits_clarification",
641
+ "plan_repair_enabled",
642
+ "plan_repair_event_payload",
643
+ "plan_repair_metadata",
644
+ "plan_status",
645
+ "plan_status_detail",
646
+ "plan_update_proposes_task_work",
647
+ "record_plan_repair",
648
+ "resolved_plan_status",
649
+ "repair_retry_instruction",
650
+ "repair_strictness_instruction",
651
+ "resolve_plan_repair_policy",
652
+ ]