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,659 @@
1
+ """Edit discipline: wholesale-rewrite detection, thrash detection, scratch files.
2
+
3
+ Three warn-only guardrails against three observed failure modes. None of them
4
+ blocks or rewrites a model action; each one states a fact the model could not
5
+ otherwise see and then gets out of the way.
6
+
7
+ *Wholesale rewrites.* A full-file write that reproduces a file's meaning but not
8
+ its bytes passes every semantic test and fails every byte-comparison one. The
9
+ observed case re-serialized HTML it was only meant to filter -- indentation
10
+ stripped, attributes reordered, ``<br>`` become ``<br/>``, ``&copy;`` become
11
+ ``(c)`` -- so a checker asserting that untouched files were untouched failed on
12
+ files the task never asked it to change. The same disease shows up on patch
13
+ benchmarks as pure regressions: the target test passes, unrelated ones break.
14
+ Two signals catch it cheaply: what fraction of the original lines did not
15
+ survive, and whether the surviving difference is *only* serialization.
16
+
17
+ *Thrashing.* A run that keeps re-attempting one variation of one idea -- 45
18
+ material actions, 52 scratch files, no answer -- has stopped learning from its
19
+ own results. Counting near-identical failures per action *family* (a path with
20
+ its numeric tail removed, so ``analyze_final.py`` / ``analyze_final2.py`` /
21
+ ``analyze_final3.py`` are one thing) turns that into a number worth reporting
22
+ back once.
23
+
24
+ *Scratch files.* The same run left its whole working set on disk. Naming them at
25
+ finalization costs nothing and is the difference between a tree-state verifier
26
+ passing and failing. This module only *identifies* them; deleting is a
27
+ deliberate non-goal here.
28
+
29
+ Stdlib only, no package imports: the agent pulls this in from the tool layer and
30
+ the turn controller, and the tests load it straight from this file path in a
31
+ bare interpreter.
32
+
33
+ Cost note: nothing here walks the workspace. The rewrite guard is handed the
34
+ pre-write content that the write tool has already read for its own diff preview,
35
+ so the guard adds no I/O at all; every other input is something the caller
36
+ already had in hand.
37
+ """
38
+
39
+ from __future__ import annotations
40
+
41
+ import difflib
42
+ import re
43
+ import threading
44
+ from collections.abc import Iterable, Sequence
45
+ from dataclasses import dataclass
46
+
47
+ # ---------------------------------------------------------------------------
48
+ # Line-change fraction
49
+ # ---------------------------------------------------------------------------
50
+
51
+
52
+ def changed_line_fraction(old: str, new: str) -> float:
53
+ """Fraction of lines that a rewrite did not carry over unchanged.
54
+
55
+ The denominator is the longer of the two line counts, so both directions of
56
+ size change are penalised: deleting half a file and doubling it are each a
57
+ large fraction. The numerator is everything ``difflib`` could not place in a
58
+ common block, which means a line that merely *moved* still counts as
59
+ changed. That is the intent -- a reordering serializer is exactly the thing
60
+ this signal exists to notice.
61
+
62
+ Returns 0.0 for two empty inputs, and 1.0 when one side is empty and the
63
+ other is not.
64
+ """
65
+
66
+ old_lines = old.splitlines()
67
+ new_lines = new.splitlines()
68
+ denominator = max(len(old_lines), len(new_lines))
69
+ if denominator == 0:
70
+ return 0.0
71
+ matcher = difflib.SequenceMatcher(a=old_lines, b=new_lines, autojunk=False)
72
+ matched = sum(block.size for block in matcher.get_matching_blocks())
73
+ return (denominator - matched) / denominator
74
+
75
+
76
+ def changed_line_percent(old: str, new: str) -> int:
77
+ """``changed_line_fraction`` as a whole percent, for display."""
78
+
79
+ return int(round(changed_line_fraction(old, new) * 100))
80
+
81
+
82
+ # ---------------------------------------------------------------------------
83
+ # Serialization-only detection
84
+ # ---------------------------------------------------------------------------
85
+
86
+ _WHITESPACE_RUN = re.compile(r"\s+")
87
+
88
+ # A tag with a name and optional attributes. Deliberately not an HTML parser:
89
+ # this only has to recognise the shapes a re-serializer produces, and anything
90
+ # it fails to recognise is left byte-for-byte alone (a miss costs a warning that
91
+ # is not emitted, never a wrong claim about semantics).
92
+ _TAG = re.compile(r"<(/?)([A-Za-z][-A-Za-z0-9:]*)((?:\s+[^<>]*?)?)\s*(/?)>")
93
+ _ATTR = re.compile(
94
+ r"""([-A-Za-z_:][-A-Za-z0-9_:.]*) # name
95
+ (?:\s*=\s* # optional value
96
+ (?:"([^"]*)"|'([^']*)'|([^\s"'<>`]+))
97
+ )?""",
98
+ re.VERBOSE,
99
+ )
100
+
101
+ # Only entities that cannot be confused with markup. Normalising the structural
102
+ # five (&amp; &lt; &gt; &quot; &#39;) would let a document containing a literal
103
+ # escaped tag compare equal to one containing a real tag, which is a genuine
104
+ # semantic difference -- so they are deliberately left alone.
105
+ _NON_STRUCTURAL_ENTITIES: dict[str, str] = {
106
+ "&copy;": "©",
107
+ "&reg;": "®",
108
+ "&trade;": "™",
109
+ "&nbsp;": " ",
110
+ "&mdash;": "—",
111
+ "&ndash;": "–",
112
+ "&hellip;": "…",
113
+ "&laquo;": "«",
114
+ "&raquo;": "»",
115
+ "&deg;": "°",
116
+ }
117
+ _NUMERIC_ENTITY = re.compile(r"&#(?:[xX]([0-9A-Fa-f]+)|([0-9]+));")
118
+
119
+ # Above this, the markup pass is skipped and only whitespace normalisation
120
+ # applies. A re-serializer that touched a file this large is already going to
121
+ # trip the line-fraction signal, so the cheaper check loses nothing.
122
+ MAX_MARKUP_NORMALIZE_CHARS = 2_000_000
123
+
124
+
125
+ def _normalize_entities(text: str) -> str:
126
+ for entity, replacement in _NON_STRUCTURAL_ENTITIES.items():
127
+ if entity in text:
128
+ text = text.replace(entity, replacement)
129
+
130
+ def _numeric(match: re.Match[str]) -> str:
131
+ raw_hex, raw_dec = match.group(1), match.group(2)
132
+ try:
133
+ code = int(raw_hex, 16) if raw_hex else int(raw_dec or "", 10)
134
+ except ValueError: # pragma: no cover - regex guarantees digits
135
+ return match.group(0)
136
+ # Below 160 lives the structural range (&#38; &#60; &#39; ...), left as-is
137
+ # for the same reason as the named structural entities.
138
+ if code < 160 or code > 0x10FFFF:
139
+ return match.group(0)
140
+ return chr(code)
141
+
142
+ return _NUMERIC_ENTITY.sub(_numeric, text)
143
+
144
+
145
+ def _normalize_tag(match: re.Match[str]) -> str:
146
+ closing, name, raw_attrs, self_closing = match.groups()
147
+ attrs: list[str] = []
148
+ for attr in _ATTR.finditer(raw_attrs or ""):
149
+ attr_name = attr.group(1).lower()
150
+ value = attr.group(2)
151
+ if value is None:
152
+ value = attr.group(3)
153
+ if value is None:
154
+ value = attr.group(4)
155
+ if value is None:
156
+ attrs.append(attr_name)
157
+ else:
158
+ attrs.append(f'{attr_name}="{value}"')
159
+ # Sorted, so `<a href=".." class="..">` and `<a class=".." href="..">` agree.
160
+ # Order is not semantic in HTML/XML attributes; a serializer routinely
161
+ # changes it and nothing downstream should care.
162
+ rendered = " ".join(sorted(attrs))
163
+ body = f"{closing}{name.lower()}"
164
+ if rendered:
165
+ body = f"{body} {rendered}"
166
+ # `<br>` and `<br/>` are the same element; the trailing slash is dropped so
167
+ # the two spellings compare equal.
168
+ del self_closing
169
+ return f"<{body}>"
170
+
171
+
172
+ def normalize_for_serialization(text: str) -> str:
173
+ """Strip the differences a re-serializer is allowed to introduce.
174
+
175
+ Two layers. The first always runs: collapse every whitespace run to one
176
+ space, strip each line, and drop blank lines -- so re-indentation and
177
+ re-wrapping vanish. The second runs only when the text looks like markup:
178
+ lowercase tag and attribute names, sort attributes, drop the trailing slash
179
+ on self-closing tags, and decode non-structural character entities.
180
+
181
+ Known limits, all in the safe direction (a missed normalisation suppresses a
182
+ warning; it never invents one):
183
+
184
+ * Not a parser. Tags inside comments, CDATA, ``<script>`` or ``<style>``
185
+ bodies are normalised like any other tag, and malformed markup is left
186
+ alone rather than repaired.
187
+ * Whitespace collapsing is content-blind, so it also flattens the interior
188
+ of ``<pre>`` and of triple-quoted strings in source files. For the
189
+ *serialization-only* question that is the desired reading -- but it means
190
+ a change that only altered whitespace inside a ``<pre>`` block is reported
191
+ as serialization-only even though it is visible.
192
+ * Attribute *values* are compared verbatim: no URL, case, or numeric
193
+ normalisation.
194
+ * The structural entities are left encoded on purpose (see
195
+ ``_NON_STRUCTURAL_ENTITIES``).
196
+ """
197
+
198
+ body = text
199
+ if "<" in body and len(body) <= MAX_MARKUP_NORMALIZE_CHARS and _TAG.search(body):
200
+ body = _TAG.sub(_normalize_tag, body)
201
+ body = _normalize_entities(body)
202
+ lines = []
203
+ for line in body.splitlines():
204
+ collapsed = _WHITESPACE_RUN.sub(" ", line).strip()
205
+ if collapsed:
206
+ lines.append(collapsed)
207
+ return "\n".join(lines)
208
+
209
+
210
+ def is_serialization_only(old: str, new: str) -> bool:
211
+ """True when the bytes changed but nothing survives normalisation.
212
+
213
+ This is the filter-js signature exactly: a file whose meaning is identical
214
+ and whose bytes are not.
215
+ """
216
+
217
+ if old == new:
218
+ return False
219
+ return normalize_for_serialization(old) == normalize_for_serialization(new)
220
+
221
+
222
+ # ---------------------------------------------------------------------------
223
+ # Wholesale-rewrite assessment
224
+ # ---------------------------------------------------------------------------
225
+
226
+ # The single model-visible string this guard introduces. Kept as one constant so
227
+ # it is greppable and cannot drift.
228
+ REWRITE_WARNING_TEMPLATE = (
229
+ "warning: full-file rewrite of {path}: {pct}% of lines changed. "
230
+ "If the task requires preserving formatting of untouched regions, "
231
+ "prefer a targeted edit."
232
+ )
233
+
234
+ # Strictly greater-than: a write that changes exactly 80% of the lines does not
235
+ # warn.
236
+ REWRITE_LINE_FRACTION_THRESHOLD = 0.80
237
+
238
+
239
+ @dataclass(frozen=True)
240
+ class RewriteAssessment:
241
+ """What a full-file write did to a file that already existed."""
242
+
243
+ path: str
244
+ changed_fraction: float
245
+ changed_percent: int
246
+ serialization_only: bool
247
+ should_warn: bool
248
+
249
+ def warning_text(self) -> str | None:
250
+ if not self.should_warn:
251
+ return None
252
+ return REWRITE_WARNING_TEMPLATE.format(path=self.path, pct=self.changed_percent)
253
+
254
+
255
+ def assess_full_file_rewrite(*, path: str, original: str, updated: str) -> RewriteAssessment:
256
+ """Score one overwrite of an existing file.
257
+
258
+ Warns on either signal. A large line fraction says the write replaced the
259
+ file rather than edited it; serialization-only says the write changed bytes
260
+ it had no reason to change. The second matters even when the fraction is
261
+ small, because a checker comparing bytes does not care how few lines moved.
262
+ """
263
+
264
+ fraction = changed_line_fraction(original, updated)
265
+ serialization_only = is_serialization_only(original, updated)
266
+ should_warn = original != updated and (
267
+ fraction > REWRITE_LINE_FRACTION_THRESHOLD or serialization_only
268
+ )
269
+ return RewriteAssessment(
270
+ path=str(path),
271
+ changed_fraction=fraction,
272
+ changed_percent=int(round(fraction * 100)),
273
+ serialization_only=serialization_only,
274
+ should_warn=should_warn,
275
+ )
276
+
277
+
278
+ # How many already-warned paths to remember. Past this the oldest entry is
279
+ # dropped, which can at worst let one path warn a second time in a very long
280
+ # run -- the bound on memory is worth more than the bound on repeats.
281
+ MAX_TRACKED_REWRITE_PATHS = 512
282
+
283
+
284
+ class RewriteGuard:
285
+ """Per-run, at-most-once-per-file rewrite warnings.
286
+
287
+ Holds no file content. The caller passes the pre-write bytes it already
288
+ read, so nothing here is cached between calls except the set of paths that
289
+ have already been warned about.
290
+ """
291
+
292
+ def __init__(self, *, max_paths: int = MAX_TRACKED_REWRITE_PATHS) -> None:
293
+ self.max_paths = max(1, int(max_paths))
294
+ self._warned: dict[str, None] = {}
295
+ self._lock = threading.Lock()
296
+
297
+ def warn_for_write(self, *, path: str, original: str, updated: str) -> str | None:
298
+ """The warning for this write, or None.
299
+
300
+ Returns None for a first-time create (no original), for an unchanged
301
+ write, for a change too small to be interesting, and for every write to
302
+ a path already warned about in this run.
303
+ """
304
+
305
+ key = str(path)
306
+ if not original:
307
+ # Nothing existed to preserve; a create cannot be a rewrite.
308
+ return None
309
+ with self._lock:
310
+ if key in self._warned:
311
+ return None
312
+ assessment = assess_full_file_rewrite(path=key, original=original, updated=updated)
313
+ text = assessment.warning_text()
314
+ if text is None:
315
+ return None
316
+ with self._lock:
317
+ if key in self._warned:
318
+ return None
319
+ self._warned[key] = None
320
+ while len(self._warned) > self.max_paths:
321
+ self._warned.pop(next(iter(self._warned)), None)
322
+ return text
323
+
324
+ def warned_paths(self) -> tuple[str, ...]:
325
+ with self._lock:
326
+ return tuple(self._warned)
327
+
328
+
329
+ # ---------------------------------------------------------------------------
330
+ # Action families
331
+ # ---------------------------------------------------------------------------
332
+
333
+ # A stem ending in digits, where something non-numeric precedes them. Requiring
334
+ # a preceding non-digit keeps `2024` intact instead of collapsing it to nothing.
335
+ _NUMERIC_TAIL = re.compile(r"^(?P<base>.*[^0-9])(?P<digits>[0-9]+)$")
336
+ _PATHISH = re.compile(r"[/\\.]")
337
+
338
+
339
+ def _strip_numeric_tail(stem: str) -> str:
340
+ match = _NUMERIC_TAIL.match(stem)
341
+ if match is None:
342
+ return stem
343
+ base = match.group("base")
344
+ # `foo_2` and `foo-2` are variations of `foo`, not of `foo_`.
345
+ return base.rstrip("_-") or base
346
+
347
+
348
+ def path_family(path: str) -> str:
349
+ """A path with its extension and numeric tail removed.
350
+
351
+ ``analyze_final.py``, ``analyze_final2.py`` and ``analyze_final3.py`` all
352
+ reduce to ``analyze_final``: three names for one idea the run keeps
353
+ re-attempting. Directory structure is kept, so the same basename in two
354
+ different directories stays two families.
355
+ """
356
+
357
+ text = str(path or "").strip().replace("\\", "/")
358
+ if not text:
359
+ return ""
360
+ text = text.rstrip("/")
361
+ head, _, tail = text.rpartition("/")
362
+ stem, dot, _ext = tail.rpartition(".")
363
+ if not dot:
364
+ stem = tail
365
+ stem = _strip_numeric_tail(stem)
366
+ return f"{head}/{stem}" if head else stem
367
+
368
+
369
+ def command_family(command: str) -> str:
370
+ """A command line with whitespace collapsed and path arguments reduced.
371
+
372
+ ``python analyze_final2.py --check`` and ``python analyze_final3.py --check``
373
+ are the same attempt, so the tokens that look like paths go through
374
+ ``path_family`` and everything else is kept verbatim.
375
+ """
376
+
377
+ tokens = str(command or "").split()
378
+ if not tokens:
379
+ return ""
380
+ reduced = [path_family(token) if _PATHISH.search(token) else token for token in tokens]
381
+ return " ".join(token for token in reduced if token)
382
+
383
+
384
+ def action_family(tool: str, target: str) -> str:
385
+ """The family key for one mutation attempt.
386
+
387
+ Keyed by tool as well as target so a file being written and the same file
388
+ being run are not conflated.
389
+ """
390
+
391
+ name = str(tool or "").strip() or "action"
392
+ text = str(target or "").strip()
393
+ if not text:
394
+ return name
395
+ reduced = command_family(text) if " " in text else path_family(text)
396
+ return f"{name}:{reduced}" if reduced else name
397
+
398
+
399
+ # ---------------------------------------------------------------------------
400
+ # Repetition counter
401
+ # ---------------------------------------------------------------------------
402
+
403
+ # The single model-visible string this guard introduces.
404
+ THRASH_NOTICE_TEMPLATE = (
405
+ "Progress check: {n} similar attempts on {family} without a passing result. "
406
+ "Synthesize what you know into a final answer or a concrete blocker report now."
407
+ )
408
+
409
+ THRASH_REPETITION_THRESHOLD = 8
410
+ MAX_THRASH_NOTICES_PER_RUN = 2
411
+ # Families past this are dropped oldest-first. A run cycling through hundreds of
412
+ # distinct targets is not thrashing on any one of them, so the eviction cannot
413
+ # hide the pattern this counts.
414
+ MAX_TRACKED_FAMILIES = 256
415
+
416
+
417
+ class ThrashCounter:
418
+ """Per-run count of failed, near-identical mutation attempts.
419
+
420
+ Success is the reset: a family that produces a passing result starts over,
421
+ because the notice claims ``without a passing result`` and must be true when
422
+ it fires. Fires at most once per family and at most
423
+ ``MAX_THRASH_NOTICES_PER_RUN`` times per run -- past that the model has been
424
+ told, and repeating it is the same failure the guard is warning about.
425
+ """
426
+
427
+ def __init__(
428
+ self,
429
+ *,
430
+ threshold: int = THRASH_REPETITION_THRESHOLD,
431
+ max_notices: int = MAX_THRASH_NOTICES_PER_RUN,
432
+ max_families: int = MAX_TRACKED_FAMILIES,
433
+ ) -> None:
434
+ self.threshold = max(1, int(threshold))
435
+ self.max_notices = max(0, int(max_notices))
436
+ self.max_families = max(1, int(max_families))
437
+ self._failures: dict[str, int] = {}
438
+ self._notified: set[str] = set()
439
+ self._notices_sent = 0
440
+ self._lock = threading.Lock()
441
+
442
+ @property
443
+ def notices_sent(self) -> int:
444
+ with self._lock:
445
+ return self._notices_sent
446
+
447
+ def failure_count(self, family: str) -> int:
448
+ with self._lock:
449
+ return self._failures.get(str(family), 0)
450
+
451
+ def record(self, *, tool: str, target: str, failed: bool) -> str | None:
452
+ """Record one mutation attempt; return a notice when it should fire."""
453
+
454
+ family = action_family(tool, target)
455
+ if not family:
456
+ return None
457
+ with self._lock:
458
+ if not failed:
459
+ self._failures.pop(family, None)
460
+ return None
461
+ count = self._failures.pop(family, 0) + 1
462
+ # Re-inserted at the end, so the family just touched is never the
463
+ # eviction candidate and the dict cannot grow past its cap.
464
+ self._failures[family] = count
465
+ while len(self._failures) > self.max_families:
466
+ self._failures.pop(next(iter(self._failures)), None)
467
+ if count < self.threshold:
468
+ return None
469
+ if family in self._notified or self._notices_sent >= self.max_notices:
470
+ return None
471
+ self._notified.add(family)
472
+ self._notices_sent += 1
473
+ return THRASH_NOTICE_TEMPLATE.format(n=count, family=family)
474
+
475
+
476
+ # ---------------------------------------------------------------------------
477
+ # Scratch files
478
+ # ---------------------------------------------------------------------------
479
+
480
+ # The one line added to the local runtime finalization summary. That summary is
481
+ # not model-visible (it is a runtime artifact printed and logged locally), so
482
+ # this is not a model-facing string -- it is kept here beside the others so the
483
+ # whole vocabulary of this PR lives in one file.
484
+ SCRATCH_FILES_SUMMARY_LINE = "Scratch files left in tree: {list}"
485
+
486
+ # `analyze_`, `analysis3_`, `scratch-`, `tmp_` ... at a word boundary. The
487
+ # optional digits are what let `analysis3_output.txt` match the same rule as
488
+ # `analyze_final.py`.
489
+ _SCRATCH_NAME = re.compile(
490
+ r"(?:^|[_\-.])(?:analyze|analysis|scratch|tmp|temp)[0-9]*[_\-]",
491
+ re.IGNORECASE,
492
+ )
493
+
494
+ MAX_TRACKED_CREATED_PATHS = 2048
495
+
496
+
497
+ def _split_numbered(name: str) -> tuple[str, str] | None:
498
+ """``('analyze_final', '.py')`` for ``analyze_final2.py``, else None."""
499
+
500
+ stem, dot, ext = name.rpartition(".")
501
+ if not dot:
502
+ stem, ext = name, ""
503
+ match = _NUMERIC_TAIL.match(stem)
504
+ if match is None:
505
+ return None
506
+ base = match.group("base")
507
+ if not base or base.rstrip("_-") == "":
508
+ return None
509
+ suffix = f".{ext}" if ext else ""
510
+ return base, suffix
511
+
512
+
513
+ def looks_like_scratch_name(name: str) -> bool:
514
+ """True for names that announce themselves as working files."""
515
+
516
+ return bool(_SCRATCH_NAME.search(str(name or "")))
517
+
518
+
519
+ def find_scratch_files(
520
+ created_paths: Iterable[str],
521
+ *,
522
+ existing_paths: Iterable[str] = (),
523
+ referenced_paths: Iterable[str] = (),
524
+ ) -> tuple[str, ...]:
525
+ """Files created this run that look like leftovers, in creation order.
526
+
527
+ Two rules. A path is scratch when its name carries a numeric tail and a
528
+ sibling without that tail exists (``analyze_final2.py`` beside
529
+ ``analyze_final.py`` -- the second attempt at one thing), or when its name
530
+ matches a scratch word.
531
+
532
+ ``existing_paths`` widens the sibling search to files the run did not
533
+ create; ``referenced_paths`` is a caller-supplied allowlist of paths that
534
+ are part of the deliverable and must never be reported. Reference analysis
535
+ itself is out of scope: deciding whether a file is referenced means reading
536
+ the workspace, and these guards are not allowed to scan it.
537
+
538
+ No deletion, here or anywhere: this reports, and the run decides.
539
+ """
540
+
541
+ created = [str(path).replace("\\", "/") for path in created_paths if str(path or "").strip()]
542
+ keep = {str(path).replace("\\", "/") for path in referenced_paths}
543
+ universe = set(created)
544
+ universe.update(str(path).replace("\\", "/") for path in existing_paths)
545
+
546
+ found: list[str] = []
547
+ seen: set[str] = set()
548
+ for path in created:
549
+ if path in keep or path in seen:
550
+ continue
551
+ head, _, name = path.rpartition("/")
552
+ scratch = looks_like_scratch_name(name)
553
+ if not scratch:
554
+ split = _split_numbered(name)
555
+ if split is not None:
556
+ base, suffix = split
557
+ for sibling_stem in (base, base.rstrip("_-")):
558
+ sibling = f"{sibling_stem}{suffix}"
559
+ candidate = f"{head}/{sibling}" if head else sibling
560
+ if candidate != path and candidate in universe:
561
+ scratch = True
562
+ break
563
+ if scratch:
564
+ seen.add(path)
565
+ found.append(path)
566
+ return tuple(found)
567
+
568
+
569
+ def scratch_summary_line(paths: Sequence[str], *, limit: int = 10) -> str | None:
570
+ """The one-line finalization-summary addition, or None when the tree is clean."""
571
+
572
+ if not paths:
573
+ return None
574
+ shown = ", ".join(paths[:limit])
575
+ if len(paths) > limit:
576
+ shown += f", ... (+{len(paths) - limit} more)"
577
+ return SCRATCH_FILES_SUMMARY_LINE.format(list=shown)
578
+
579
+
580
+ # ---------------------------------------------------------------------------
581
+ # Per-run state
582
+ # ---------------------------------------------------------------------------
583
+
584
+
585
+ class EditDisciplineState:
586
+ """Every per-run edit-discipline counter, in one bounded, thread-safe object.
587
+
588
+ One object because the tool layer and the turn controller each need part of
589
+ it and neither should have to know about the other's. Bounded because a long
590
+ run must not grow it without limit: at most
591
+ ``MAX_TRACKED_REWRITE_PATHS`` warned paths, ``MAX_TRACKED_FAMILIES``
592
+ families and ``MAX_TRACKED_CREATED_PATHS`` created paths, each evicting
593
+ oldest-first. Thread-safe because tool calls can be dispatched
594
+ concurrently.
595
+ """
596
+
597
+ def __init__(self, *, max_created_paths: int = MAX_TRACKED_CREATED_PATHS) -> None:
598
+ self.rewrite_guard = RewriteGuard()
599
+ self.thrash = ThrashCounter()
600
+ self.max_created_paths = max(1, int(max_created_paths))
601
+ self._created: dict[str, None] = {}
602
+ self._pending_notices: list[str] = []
603
+ self._lock = threading.Lock()
604
+
605
+ # -- rewrite guard ----------------------------------------------------
606
+
607
+ def warn_for_write(self, *, path: str, original: str, updated: str) -> str | None:
608
+ return self.rewrite_guard.warn_for_write(path=path, original=original, updated=updated)
609
+
610
+ # -- thrash guard -----------------------------------------------------
611
+
612
+ def record_attempt(self, *, tool: str, target: str, failed: bool) -> str | None:
613
+ """Record an attempt and queue any notice it triggers.
614
+
615
+ The queue exists because the two halves run at different moments: an
616
+ attempt is observed while tool results come back, and a notice can only
617
+ be delivered when the next request's system messages are assembled.
618
+ Queueing per run rather than per turn means a notice earned on the last
619
+ tool call of a turn is still delivered on the next one instead of being
620
+ silently dropped -- and dropped notices would be doubly wrong here,
621
+ since the counter has already spent one of its two per-run slots.
622
+ """
623
+
624
+ notice = self.thrash.record(tool=tool, target=target, failed=failed)
625
+ if notice is not None:
626
+ with self._lock:
627
+ self._pending_notices.append(notice)
628
+ return notice
629
+
630
+ def take_notice(self) -> str | None:
631
+ """Pop the next queued notice, or None. Delivering is the caller's job."""
632
+
633
+ with self._lock:
634
+ if not self._pending_notices:
635
+ return None
636
+ return self._pending_notices.pop(0)
637
+
638
+ # -- scratch files ----------------------------------------------------
639
+
640
+ def record_created(self, path: str) -> None:
641
+ key = str(path or "").strip().replace("\\", "/")
642
+ if not key:
643
+ return
644
+ with self._lock:
645
+ if key in self._created:
646
+ return
647
+ self._created[key] = None
648
+ while len(self._created) > self.max_created_paths:
649
+ self._created.pop(next(iter(self._created)), None)
650
+
651
+ def created_paths(self) -> tuple[str, ...]:
652
+ with self._lock:
653
+ return tuple(self._created)
654
+
655
+ def scratch_files(self, *, referenced_paths: Iterable[str] = ()) -> tuple[str, ...]:
656
+ return find_scratch_files(self.created_paths(), referenced_paths=referenced_paths)
657
+
658
+ def scratch_summary_line(self) -> str | None:
659
+ return scratch_summary_line(self.scratch_files())