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,2148 @@
1
+ from __future__ import annotations
2
+
3
+ import fnmatch
4
+ import os
5
+ import re
6
+ import shutil
7
+ import subprocess
8
+ from collections.abc import Collection
9
+ from dataclasses import dataclass, field
10
+ from pathlib import Path, PurePosixPath
11
+ from typing import Any
12
+
13
+ from .file_classification import BROAD_SOURCE_EXTENSIONS, INFERRED_FILE_EXTENSIONS, classify_path
14
+ from .git_safe import build_git_process_env
15
+ from .runtime_artifacts import ROOT_RUNTIME_ARTIFACT_DIR_NAMES, is_runtime_artifact_path
16
+
17
+ _AGENT_INTERNAL_SCOPE_PREFIXES = tuple(sorted(ROOT_RUNTIME_ARTIFACT_DIR_NAMES | {".forge"}))
18
+ _IGNORED_INTERNAL_PREFIXES = _AGENT_INTERNAL_SCOPE_PREFIXES
19
+ _SPECIAL_FILENAMES = {
20
+ "README",
21
+ "README.md",
22
+ "Dockerfile",
23
+ "Makefile",
24
+ "LICENSE",
25
+ "NOTICE",
26
+ "CHANGELOG",
27
+ }
28
+ _README_ALIAS_FILENAMES = frozenset({"README", "README.md"})
29
+ _BROAD_NON_PACKAGE_DIR_NAMES = {
30
+ "app",
31
+ "apps",
32
+ "bin",
33
+ "doc",
34
+ "docs",
35
+ "example",
36
+ "examples",
37
+ "lib",
38
+ "scripts",
39
+ "src",
40
+ "tool",
41
+ "tools",
42
+ }
43
+ _INFERRED_FILE_EXTENSIONS = INFERRED_FILE_EXTENSIONS
44
+ _INFERRED_SOURCE_FILE_EXTENSIONS = frozenset(
45
+ suffix.lstrip(".").casefold() for suffix in BROAD_SOURCE_EXTENSIONS
46
+ )
47
+ _PATH_HINT_RE = re.compile(
48
+ r"(?<![\w/-])("
49
+ r"(?:\.[A-Za-z0-9_-]+(?:/[A-Za-z0-9_.-]+)*)"
50
+ r"|(?:[A-Za-z0-9_-]+(?:/[A-Za-z0-9_.-]+)+)"
51
+ r"|(?:[A-Za-z0-9_.-]+\.[A-Za-z0-9_*?\[\]-]+)"
52
+ r"|(?:README(?:\.md)?)"
53
+ r"|(?:Dockerfile|Makefile|LICENSE|NOTICE|CHANGELOG)"
54
+ r")(?![\w/-])"
55
+ )
56
+ _PATH_HINT_CONTEXT_SPLIT_RE = re.compile(r"[\n;]+|(?<=[.!?])\s+")
57
+ _FORBIDDEN_PATH_DIRECT_PREFIX_RE = re.compile(
58
+ r"(?:^|[\s,(])(?:(?:do\s+not|don't|must\s+not|never)\s+"
59
+ r"(?:edit|modify|change|touch|write(?:\s+to)?|update|delete|remove|create)"
60
+ r"|without\s+(?:touching|modifying|changing|writing\s+to)"
61
+ r"|not\s+(?:touch|read|write|include)"
62
+ r"|no\s+longer\s+read"
63
+ r"|(?:exclude|ignore|skip)"
64
+ r"|(?:leave|keep)\s+(?:the\s+)?(?:file\s+)?"
65
+ r"|(?:preserve|retain|maintain)\s+(?:the\s+)?(?:file\s+)?)\s+"
66
+ r"(?:the\s+)?(?:(?:untracked|tracked|existing|current|local|generated|root|"
67
+ r"workspace|repo|repository)\s+){0,3}(?:file\s+)?$",
68
+ re.IGNORECASE,
69
+ )
70
+ _FORBIDDEN_PATH_DIRECT_OBJECT_RE = re.compile(
71
+ r"\b(?:preserve|retain|maintain|leave|keep)\s+(?:the\s+)?"
72
+ r"(?:(?:untracked|tracked|existing|current|local|generated|root|workspace|repo|repository)\s+){0,3}"
73
+ r"(?:file\s+)?$",
74
+ re.IGNORECASE,
75
+ )
76
+ _FORBIDDEN_PATH_SUFFIX_MARKER_RE = re.compile(
77
+ r"^\s*(?:file\s+)?(?:is|are|must\s+remain|should\s+remain|stays?|stay|left)?\s*"
78
+ r"(?:not\s+)?(?:untouched|unchanged|unmodified|preserved|not\s+modified|"
79
+ r"not\s+changed|not\s+touched)\b"
80
+ r"|^\s*(?:file\s+)?not\s+(?:modified|changed|touched|written)\b",
81
+ re.IGNORECASE,
82
+ )
83
+ _CONDITIONAL_FORBIDDEN_PATH_EXCEPTION_RE = re.compile(
84
+ r"\bunless\s+(?:a\s+|the\s+)?(?:genuine\s+|real\s+|actual\s+)?"
85
+ r"(?:bug|defect|implementation\s+bug|product\s+bug)\s+"
86
+ r"(?:is\s+)?(?:found|discovered|identified|confirmed)\b"
87
+ r"|\bunless\s+(?:it\s+is\s+)?(?:necessary|required|needed)\s+"
88
+ r"(?:to\s+fix|for\s+the\s+fix|to\s+make\s+verification\s+pass)\b"
89
+ r"|\bexcept\s+(?:when|if)\s+(?:it\s+is\s+)?(?:necessary|required|needed)\b",
90
+ re.IGNORECASE,
91
+ )
92
+ _FORBIDDEN_PATH_MARKER_WINDOW_CHARS = 100
93
+ _NON_MATERIAL_ROOT_SCRATCH_FILENAMES = frozenset(
94
+ {
95
+ "command_output.txt",
96
+ "output.txt",
97
+ "pip_err.txt",
98
+ "pip_install_out.txt",
99
+ "pip_log.txt",
100
+ "pip_out.txt",
101
+ "pip_output.txt",
102
+ "pytest_output.txt",
103
+ "pytest_results.txt",
104
+ "shell_output.txt",
105
+ "stderr.txt",
106
+ "stdout.txt",
107
+ "test_output.txt",
108
+ "test_results.txt",
109
+ "wheel_log.txt",
110
+ "wheel_output.txt",
111
+ }
112
+ )
113
+ _NON_MATERIAL_ROOT_SCRATCH_SUFFIXES = (
114
+ "-output.txt",
115
+ "-results.txt",
116
+ "_output.txt",
117
+ "_results.txt",
118
+ ".log",
119
+ )
120
+ _NON_MATERIAL_ROOT_SCRATCH_RE = re.compile(
121
+ r"^(?:pytest|test|shell|command|run|stdout|stderr|debug|diagnostic|tmp|temp|"
122
+ r"pip|uv|poetry|python|wheel|build|install|package|npm|pnpm|yarn|node|cargo|go)"
123
+ r"[_-](?:out|output|stdout|stderr|err|errors?|log|logs|full|results?|dump|"
124
+ r"lines[_-]dump|install[_-]out)"
125
+ r"(?:\d+)?\.txt$"
126
+ r"|^_(?:(?:tmp|temp|debug|diag|diagnostic|out|output|content|pytest|test)"
127
+ r"(?:\d+|[_-][a-z0-9][a-z0-9_-]*)?|d\d+)\.txt$"
128
+ )
129
+ _NON_MATERIAL_ROOT_STATE_FILE_RE = re.compile(
130
+ r"^\.(?:[a-z0-9_-]*"
131
+ r"(?:state|data|db|store|storage|cache|habits?|todos?|tasks?|notes?|items?)"
132
+ r"[a-z0-9_-]*)\.json$"
133
+ )
134
+ _EXTENSIONLESS_FILE_RE = re.compile(r"[A-Za-z0-9_-]+")
135
+ _CARGO_MANIFEST_FILENAME = "Cargo.toml"
136
+ _CARGO_LOCK_FILENAME = "Cargo.lock"
137
+ _CARGO_WORKSPACE_RE = re.compile(r"(?m)^\s*\[workspace\]\s*$")
138
+ _CARGO_PACKAGE_RE = re.compile(r"(?m)^\s*\[package\]\s*$")
139
+ _RUST_SCOPE_DIR_NAMES = frozenset({"src", "tests", "test", "benches", "examples"})
140
+ _RUST_ENTRYPOINT_FILENAMES = ("lib.rs", "main.rs")
141
+ _READ_ONLY_GIT_TIMEOUT_S = 5.0
142
+ _UNTRACKED_SOURCE_KINDS = frozenset(
143
+ {"config", "fixture", "frontend_surface", "implementation", "source", "test"}
144
+ )
145
+ SCRATCH_ARTIFACT_ENV = "ALYSIS_ARTIFACT_SCRATCH_DIR"
146
+
147
+ SCOPE_CLASS_IN_SCOPE = "in_scope"
148
+ SCOPE_CLASS_EXPECTED_COMPANION = "expected_companion_file"
149
+ SCOPE_CLASS_SCRATCH_ARTIFACT = "scratch_diagnostic_artifact"
150
+ SCOPE_CLASS_LIKELY_MISSING_SCOPE = "likely_legitimate_missing_scope"
151
+ SCOPE_CLASS_DANGEROUS_UNRELATED = "dangerous_unrelated_path"
152
+ SCOPE_CLASS_FORBIDDEN = "forbidden_path_violation"
153
+ SCOPE_CLASS_ADJACENT = "adjacent_to_declared_scope"
154
+ SCOPE_CLASS_PROTECTED = "protected_path"
155
+
156
+ # Triage buckets an out-of-scope change falls into. ``adjacent`` is work the plan should
157
+ # plainly have declared and is safe to amend into the task scope; ``protected`` is never
158
+ # amendable; ``unrelated`` is a plan defect a human or replanner has to resolve.
159
+ SCOPE_TRIAGE_ADJACENT = "adjacent"
160
+ SCOPE_TRIAGE_PROTECTED = "protected"
161
+ SCOPE_TRIAGE_UNRELATED = "unrelated"
162
+
163
+ SCOPE_ADJACENT_NEW_FILE_IN_SCOPE_DIR = "new_file_in_declared_scope_directory"
164
+ SCOPE_ADJACENT_SIBLING_TEST = "sibling_test_file_for_declared_path"
165
+ SCOPE_ADJACENT_GENERATED_ARTIFACT = "generated_artifact_of_declared_path"
166
+
167
+ _SCOPE_TRIAGE_BY_CLASSIFICATION = {
168
+ SCOPE_CLASS_ADJACENT: SCOPE_TRIAGE_ADJACENT,
169
+ SCOPE_CLASS_EXPECTED_COMPANION: SCOPE_TRIAGE_ADJACENT,
170
+ SCOPE_CLASS_SCRATCH_ARTIFACT: SCOPE_TRIAGE_ADJACENT,
171
+ SCOPE_CLASS_PROTECTED: SCOPE_TRIAGE_PROTECTED,
172
+ SCOPE_CLASS_FORBIDDEN: SCOPE_TRIAGE_PROTECTED,
173
+ SCOPE_CLASS_LIKELY_MISSING_SCOPE: SCOPE_TRIAGE_UNRELATED,
174
+ SCOPE_CLASS_DANGEROUS_UNRELATED: SCOPE_TRIAGE_UNRELATED,
175
+ }
176
+
177
+ # Paths no task scope may ever cover, amendment or not: agent-internal runtime state and
178
+ # version-control metadata.
179
+ _PROTECTED_SCOPE_PREFIXES = tuple(sorted({*_AGENT_INTERNAL_SCOPE_PREFIXES, ".git", ".hg", ".svn"}))
180
+ _WINDOWS_ABSOLUTE_PATH_RE = re.compile(r"^[A-Za-z]:/")
181
+ # Directories whose contents are vendored or built, never "adjacent" source work.
182
+ _NON_ADJACENT_DIR_NAMES = frozenset({"node_modules", "vendor", "dist", "build", "target"})
183
+ _TEST_FILENAME_STEM_PREFIXES = ("test_", "test-")
184
+ _TEST_FILENAME_STEM_SUFFIXES = ("_test", "-test", ".test", ".spec", "_spec")
185
+ _GENERATED_ARTIFACT_NAME_SUFFIXES = (".map", ".lock", ".sum", ".snap")
186
+ _GENERATED_ARTIFACT_NAME_MARKERS = (".generated.", ".g.", ".min.", ".pb.", "_pb2.", ".d.ts")
187
+
188
+
189
+ @dataclass(frozen=True)
190
+ class ForbiddenPathHint:
191
+ path: str
192
+ reason_code: str
193
+ evidence: str
194
+
195
+ def to_payload(self) -> dict[str, str]:
196
+ return {
197
+ "path": self.path,
198
+ "reason_code": self.reason_code,
199
+ "evidence": self.evidence,
200
+ }
201
+
202
+
203
+ @dataclass(frozen=True)
204
+ class ScopeCompanionExpansion:
205
+ source_path: str
206
+ companion_path: str
207
+ reason_code: str
208
+ evidence: str
209
+
210
+ def to_payload(self) -> dict[str, str]:
211
+ return {
212
+ "source_path": self.source_path,
213
+ "companion_path": self.companion_path,
214
+ "reason_code": self.reason_code,
215
+ "evidence": self.evidence,
216
+ }
217
+
218
+
219
+ @dataclass(frozen=True)
220
+ class ScopeViolationDiagnostic:
221
+ path: str
222
+ classification: str
223
+ reason_code: str
224
+ evidence: str
225
+ recommended_action: str
226
+ allowed: bool = False
227
+ source_path: str | None = None
228
+ suggested_pattern: str = ""
229
+
230
+ @property
231
+ def triage(self) -> str:
232
+ """Which of adjacent/protected/unrelated this diagnostic belongs to."""
233
+ return _SCOPE_TRIAGE_BY_CLASSIFICATION.get(self.classification, SCOPE_TRIAGE_UNRELATED)
234
+
235
+ def to_payload(self) -> dict[str, Any]:
236
+ payload: dict[str, Any] = {
237
+ "path": self.path,
238
+ "classification": self.classification,
239
+ "triage": self.triage,
240
+ "reason_code": self.reason_code,
241
+ "evidence": self.evidence,
242
+ "recommended_action": self.recommended_action,
243
+ "allowed": self.allowed,
244
+ }
245
+ if self.source_path is not None:
246
+ payload["source_path"] = self.source_path
247
+ if self.suggested_pattern:
248
+ payload["suggested_pattern"] = self.suggested_pattern
249
+ return payload
250
+
251
+
252
+ @dataclass(frozen=True)
253
+ class ScopeAmendment:
254
+ """One write_scope entry an adjacent change earned for its task."""
255
+
256
+ path: str
257
+ pattern: str
258
+ reason_code: str
259
+ evidence: str
260
+ suggested_pattern: str = ""
261
+ source_path: str | None = None
262
+
263
+ def to_payload(self) -> dict[str, Any]:
264
+ payload: dict[str, Any] = {
265
+ "path": self.path,
266
+ "pattern": self.pattern,
267
+ "reason_code": self.reason_code,
268
+ "evidence": self.evidence,
269
+ }
270
+ if self.suggested_pattern:
271
+ payload["suggested_pattern"] = self.suggested_pattern
272
+ if self.source_path is not None:
273
+ payload["source_path"] = self.source_path
274
+ return payload
275
+
276
+
277
+ @dataclass(frozen=True)
278
+ class ScopeAssessment:
279
+ ok: bool
280
+ blocking_paths: list[str]
281
+ diagnostics: list[ScopeViolationDiagnostic]
282
+ effective_changed_files: list[str]
283
+ expanded_allowed_scope: list[str]
284
+ companion_expansions: list[ScopeCompanionExpansion]
285
+ in_scope_paths: list[str] = field(default_factory=list)
286
+ adjacent_paths: list[str] = field(default_factory=list)
287
+ amendments: list[ScopeAmendment] = field(default_factory=list)
288
+ suggested_scope_patterns: list[str] = field(default_factory=list)
289
+
290
+ @property
291
+ def protected_paths(self) -> list[str]:
292
+ return [item.path for item in self.diagnostics if item.triage == SCOPE_TRIAGE_PROTECTED]
293
+
294
+ @property
295
+ def unrelated_paths(self) -> list[str]:
296
+ return [
297
+ item.path
298
+ for item in self.diagnostics
299
+ if item.triage == SCOPE_TRIAGE_UNRELATED and not item.allowed
300
+ ]
301
+
302
+ def to_payload(self) -> dict[str, Any]:
303
+ return {
304
+ "ok": self.ok,
305
+ "blocking_paths": self.blocking_paths,
306
+ "diagnostics": [item.to_payload() for item in self.diagnostics],
307
+ "effective_changed_files": self.effective_changed_files,
308
+ "expanded_allowed_scope": self.expanded_allowed_scope,
309
+ "companion_expansions": [item.to_payload() for item in self.companion_expansions],
310
+ "in_scope_paths": self.in_scope_paths,
311
+ "adjacent_paths": self.adjacent_paths,
312
+ "amendments": [item.to_payload() for item in self.amendments],
313
+ "suggested_scope_patterns": self.suggested_scope_patterns,
314
+ }
315
+
316
+
317
+ @dataclass(frozen=True)
318
+ class WorkspaceGitDiffState:
319
+ available: bool
320
+ tracked_diff_paths: tuple[str, ...] = ()
321
+ untracked_source_paths: tuple[str, ...] = ()
322
+
323
+ @property
324
+ def changed_paths(self) -> tuple[str, ...]:
325
+ return tuple(sorted({*self.tracked_diff_paths, *self.untracked_source_paths}))
326
+
327
+ @property
328
+ def empty(self) -> bool:
329
+ return self.available and not self.changed_paths
330
+
331
+ def to_payload(self) -> dict[str, Any]:
332
+ return {
333
+ "available": self.available,
334
+ "empty": self.empty,
335
+ "tracked_diff_paths": list(self.tracked_diff_paths),
336
+ "untracked_source_paths": list(self.untracked_source_paths),
337
+ "changed_paths": list(self.changed_paths),
338
+ }
339
+
340
+
341
+ @dataclass(frozen=True)
342
+ class ExistingTestEdit:
343
+ path: str
344
+ added_lines: int | None = None
345
+ deleted_lines: int | None = None
346
+
347
+ def to_payload(self) -> dict[str, Any]:
348
+ return {
349
+ "path": self.path,
350
+ "added_lines": self.added_lines,
351
+ "deleted_lines": self.deleted_lines,
352
+ }
353
+
354
+
355
+ @dataclass(frozen=True)
356
+ class ExistingTestEditsState:
357
+ available: bool
358
+ edits: tuple[ExistingTestEdit, ...] = ()
359
+
360
+ @property
361
+ def paths(self) -> tuple[str, ...]:
362
+ return tuple(edit.path for edit in self.edits)
363
+
364
+ @property
365
+ def has_edits(self) -> bool:
366
+ return bool(self.edits)
367
+
368
+ def to_payload(self) -> dict[str, Any]:
369
+ return {
370
+ "available": self.available,
371
+ "has_edits": self.has_edits,
372
+ "paths": list(self.paths),
373
+ "edits": [edit.to_payload() for edit in self.edits],
374
+ }
375
+
376
+
377
+ def _normalize_path(value: str) -> str:
378
+ cleaned = value.strip().replace("\\", "/")
379
+ while cleaned.startswith("./"):
380
+ cleaned = cleaned[2:]
381
+ return cleaned
382
+
383
+
384
+ def _run_git_capture(
385
+ root: Path, args: list[str], *, text: bool = False
386
+ ) -> subprocess.CompletedProcess[Any] | None:
387
+ try:
388
+ return subprocess.run(
389
+ ["git", "-C", os.fspath(root), *args],
390
+ check=False,
391
+ capture_output=True,
392
+ text=text,
393
+ env=build_git_process_env(),
394
+ timeout=_READ_ONLY_GIT_TIMEOUT_S,
395
+ )
396
+ except (OSError, subprocess.TimeoutExpired):
397
+ return None
398
+
399
+
400
+ def _has_glob(value: str) -> bool:
401
+ return any(ch in value for ch in ["*", "?", "["])
402
+
403
+
404
+ def _strip_wrapping_punctuation(value: str) -> str:
405
+ cleaned = value.strip().strip("`'\"")
406
+ while cleaned and cleaned[-1] in {",", ";", ":", ".", ")", "]", "}"}:
407
+ cleaned = cleaned[:-1].rstrip()
408
+ while cleaned and cleaned[0] in {"(", "[", "{", ":"}:
409
+ cleaned = cleaned[1:].lstrip()
410
+ return cleaned
411
+
412
+
413
+ def normalize_repo_path_entry(
414
+ value: str,
415
+ *,
416
+ allow_extensionless_file: bool = False,
417
+ root: Path | None = None,
418
+ ) -> str | None:
419
+ cleaned = _normalize_path(_strip_wrapping_punctuation(value))
420
+ if not cleaned:
421
+ return None
422
+ if cleaned.startswith(("/", "../")):
423
+ return None
424
+ if "://" in cleaned or "\n" in cleaned or "\t" in cleaned:
425
+ return None
426
+ if " " in cleaned:
427
+ return None
428
+ if cleaned in {".", ".."}:
429
+ return None
430
+ if cleaned.endswith("/"):
431
+ cleaned = cleaned.rstrip("/") + "/**"
432
+ if cleaned in _SPECIAL_FILENAMES:
433
+ return cleaned
434
+ if _has_glob(cleaned):
435
+ return cleaned
436
+ existing_dir_pattern = _existing_directory_scope_pattern(cleaned, root=root)
437
+ if existing_dir_pattern is not None:
438
+ return existing_dir_pattern
439
+ if "/" in cleaned or "." in cleaned:
440
+ return cleaned
441
+ if (
442
+ allow_extensionless_file
443
+ and cleaned not in _BROAD_NON_PACKAGE_DIR_NAMES
444
+ and _EXTENSIONLESS_FILE_RE.fullmatch(cleaned)
445
+ ):
446
+ return cleaned
447
+ return None
448
+
449
+
450
+ def _existing_directory_scope_pattern(value: str, *, root: Path | None) -> str | None:
451
+ if root is None:
452
+ return None
453
+ cleaned = str(value or "").strip().replace("\\", "/").strip("/")
454
+ if not cleaned or _has_glob(cleaned):
455
+ return None
456
+ try:
457
+ root_abs = root.resolve()
458
+ candidate = (root_abs / cleaned).resolve()
459
+ candidate.relative_to(root_abs)
460
+ except (OSError, ValueError):
461
+ return None
462
+ if not candidate.is_dir():
463
+ return None
464
+ return cleaned + "/**"
465
+
466
+
467
+ def split_normalized_repo_path_list(
468
+ value: Any, *, root: Path | None = None
469
+ ) -> tuple[list[str], list[str]]:
470
+ if not isinstance(value, list):
471
+ return [], []
472
+ seen: set[str] = set()
473
+ out: list[str] = []
474
+ dropped: list[str] = []
475
+ for item in value:
476
+ raw = str(item).strip()
477
+ if not raw:
478
+ continue
479
+ normalized = normalize_repo_path_entry(
480
+ raw,
481
+ allow_extensionless_file=True,
482
+ root=root,
483
+ )
484
+ identity_key = _scope_pattern_identity_key(normalized) if normalized else ""
485
+ if not normalized or identity_key in seen:
486
+ if not normalized:
487
+ dropped.append(raw)
488
+ continue
489
+ seen.add(identity_key)
490
+ out.append(normalized)
491
+ return out, dropped
492
+
493
+
494
+ def normalize_repo_path_list(value: Any, *, root: Path | None = None) -> list[str]:
495
+ out, _ = split_normalized_repo_path_list(value, root=root)
496
+ return out
497
+
498
+
499
+ def is_explicit_repo_path_pattern(value: str) -> bool:
500
+ normalized = normalize_repo_path_entry(value, allow_extensionless_file=True)
501
+ if not normalized:
502
+ return False
503
+ return not _has_glob(normalized)
504
+
505
+
506
+ def extract_repo_path_hints(text: str) -> list[str]:
507
+ seen: set[str] = set()
508
+ hints: list[str] = []
509
+ for match in _PATH_HINT_RE.findall(text or ""):
510
+ normalized = normalize_repo_path_entry(match)
511
+ if not normalized:
512
+ continue
513
+ tail = normalized.rstrip("/").split("/")[-1]
514
+ if tail not in _SPECIAL_FILENAMES:
515
+ if "." not in tail:
516
+ continue
517
+ ext = tail.rsplit(".", 1)[1].lower()
518
+ if ext not in _INFERRED_FILE_EXTENSIONS:
519
+ continue
520
+ if (
521
+ "/" not in normalized
522
+ and ext in _INFERRED_SOURCE_FILE_EXTENSIONS
523
+ and not _single_segment_source_hint_looks_pathlike(tail)
524
+ ):
525
+ continue
526
+ head_segment = normalized.split("/", 1)[0]
527
+ if head_segment.isdigit():
528
+ continue
529
+ if normalized in seen:
530
+ continue
531
+ seen.add(normalized)
532
+ hints.append(normalized)
533
+ if "README.md" in seen and "README" in seen:
534
+ hints = [hint for hint in hints if hint != "README"]
535
+ return hints
536
+
537
+
538
+ def _single_segment_source_hint_looks_pathlike(tail: str) -> bool:
539
+ stem, _sep, _ext = tail.rpartition(".")
540
+ if not stem:
541
+ return False
542
+ if stem in {"__init__", "conftest"}:
543
+ return True
544
+ if stem.casefold() in {"setup", "manage", "index", "main", "app", "server", "client"}:
545
+ return True
546
+ # Standalone prose like "Next.js" is not a repo-relative path hint. Single-token inferred
547
+ # source filenames must look like conventional file names; slash-qualified paths are handled
548
+ # before this helper.
549
+ return stem == stem.casefold() and bool(re.fullmatch(r"[a-z0-9][a-z0-9_.-]*", stem))
550
+
551
+
552
+ def _evidence_fragment(context: str) -> str:
553
+ cleaned = " ".join(str(context or "").strip().split())
554
+ if len(cleaned) <= 180:
555
+ return cleaned
556
+ return cleaned[:177].rstrip() + "..."
557
+
558
+
559
+ def _forbidden_reason_for_path_context(
560
+ *,
561
+ context: str,
562
+ hint_start: int,
563
+ hint_end: int,
564
+ ) -> str | None:
565
+ before_hint = context[max(0, hint_start - _FORBIDDEN_PATH_MARKER_WINDOW_CHARS) : hint_start]
566
+ after_hint = context[hint_end : hint_end + _FORBIDDEN_PATH_MARKER_WINDOW_CHARS]
567
+ if _CONDITIONAL_FORBIDDEN_PATH_EXCEPTION_RE.search(after_hint) is not None:
568
+ return None
569
+ if (
570
+ _FORBIDDEN_PATH_DIRECT_PREFIX_RE.search(before_hint) is not None
571
+ or _FORBIDDEN_PATH_DIRECT_OBJECT_RE.search(before_hint) is not None
572
+ ):
573
+ return "direct_path_forbidden_instruction"
574
+ if _FORBIDDEN_PATH_SUFFIX_MARKER_RE.search(after_hint) is not None:
575
+ return "path_must_remain_unchanged"
576
+ return None
577
+
578
+
579
+ def extract_forbidden_repo_path_hint_records(text: str) -> list[ForbiddenPathHint]:
580
+ hints = extract_repo_path_hints(text)
581
+ if not hints:
582
+ return []
583
+ forbidden: list[ForbiddenPathHint] = []
584
+ seen: set[str] = set()
585
+ contexts = [fragment.strip() for fragment in _PATH_HINT_CONTEXT_SPLIT_RE.split(text or "")]
586
+ for hint in hints:
587
+ hint_key = hint.casefold()
588
+ for context in contexts:
589
+ context_cf = context.casefold()
590
+ hint_start = context_cf.find(hint_key)
591
+ if hint_start < 0:
592
+ continue
593
+ hint_end = hint_start + len(hint_key)
594
+ reason_code = _forbidden_reason_for_path_context(
595
+ context=context_cf,
596
+ hint_start=hint_start,
597
+ hint_end=hint_end,
598
+ )
599
+ if reason_code is None:
600
+ continue
601
+ identity_key = _scope_pattern_identity_key(hint).casefold()
602
+ if identity_key in seen:
603
+ break
604
+ seen.add(identity_key)
605
+ forbidden.append(
606
+ ForbiddenPathHint(
607
+ path=hint,
608
+ reason_code=reason_code,
609
+ evidence=_evidence_fragment(context),
610
+ )
611
+ )
612
+ break
613
+ paths = {item.path for item in forbidden}
614
+ if "README.md" in paths and "README" in paths:
615
+ forbidden = [item for item in forbidden if item.path != "README"]
616
+ return forbidden
617
+
618
+
619
+ def extract_forbidden_repo_path_hints(text: str) -> list[str]:
620
+ return [item.path for item in extract_forbidden_repo_path_hint_records(text)]
621
+
622
+
623
+ def _forbidden_path_identity_keys_for_task(task: dict[str, Any]) -> set[str]:
624
+ acceptance = task.get("acceptance_criteria") or []
625
+ acceptance_items = acceptance if isinstance(acceptance, list) else []
626
+ text = "\n".join(
627
+ [
628
+ str(task.get("title") or ""),
629
+ str(task.get("description") or ""),
630
+ *(str(item or "") for item in acceptance_items),
631
+ ]
632
+ )
633
+ return {
634
+ _scope_pattern_identity_key(path).casefold()
635
+ for path in extract_forbidden_repo_path_hints(text)
636
+ }
637
+
638
+
639
+ def normalize_scope_patterns(task: dict[str, Any], *, root: Path | None = None) -> list[str]:
640
+ expanded = _expand_support_file_patterns(
641
+ normalize_claimed_scope_patterns(task, root=root),
642
+ root=root,
643
+ )
644
+ forbidden = _forbidden_path_identity_keys_for_task(task)
645
+ if not forbidden:
646
+ return expanded
647
+ return [
648
+ item for item in expanded if _scope_pattern_identity_key(item).casefold() not in forbidden
649
+ ]
650
+
651
+
652
+ def normalize_claimed_scope_patterns(
653
+ task: dict[str, Any], *, root: Path | None = None
654
+ ) -> list[str]:
655
+ write_scope = normalize_repo_path_list(task.get("write_scope"), root=root)
656
+ estimated_files = normalize_repo_path_list(task.get("estimated_files"), root=root)
657
+ seen: set[str] = set()
658
+ combined: list[str] = []
659
+ for item in [*write_scope, *estimated_files]:
660
+ identity_key = _scope_pattern_identity_key(item)
661
+ if identity_key in seen:
662
+ continue
663
+ seen.add(identity_key)
664
+ combined.append(item)
665
+ return combined
666
+
667
+
668
+ def _scope_glob_variants(pattern: str) -> tuple[str, ...]:
669
+ variants = [pattern]
670
+ if "/**/" in pattern:
671
+ direct_child_variant = pattern.replace("/**/", "/")
672
+ if direct_child_variant != pattern:
673
+ variants.append(direct_child_variant)
674
+ return tuple(variants)
675
+
676
+
677
+ def _is_existing_directory_scope(*, pattern: str, root: Path | None) -> bool:
678
+ if root is None or _has_glob(pattern) or _is_root_readme_alias(pattern):
679
+ return False
680
+ try:
681
+ return (root.resolve() / pattern).is_dir()
682
+ except OSError:
683
+ return False
684
+
685
+
686
+ def _normalize_scope_match_pattern(value: str) -> str | None:
687
+ normalized = normalize_repo_path_entry(value, allow_extensionless_file=True)
688
+ if normalized:
689
+ return normalized
690
+ cleaned = _normalize_path(_strip_wrapping_punctuation(value))
691
+ if not cleaned:
692
+ return None
693
+ if cleaned.startswith(("/", "../")):
694
+ return None
695
+ if "://" in cleaned or "\n" in cleaned or "\t" in cleaned:
696
+ return None
697
+ if cleaned in {".", ".."}:
698
+ return None
699
+ if cleaned.endswith("/"):
700
+ cleaned = cleaned.rstrip("/") + "/**"
701
+ return cleaned
702
+
703
+
704
+ def scope_path_matches_pattern(
705
+ path: str,
706
+ pattern: str,
707
+ *,
708
+ root: Path | None = None,
709
+ ) -> bool:
710
+ normalized_path = _normalize_path(path).rstrip("/")
711
+ normalized_pattern = _normalize_scope_match_pattern(pattern)
712
+ if not normalized_path or not normalized_pattern:
713
+ return False
714
+ if normalized_path.startswith(("/", "../")):
715
+ return False
716
+ if _is_root_readme_alias_match(path=normalized_path, pattern=normalized_pattern):
717
+ return True
718
+ if _has_glob(normalized_pattern):
719
+ return any(
720
+ fnmatch.fnmatchcase(normalized_path, variant)
721
+ for variant in _scope_glob_variants(normalized_pattern)
722
+ )
723
+ if normalized_path == normalized_pattern:
724
+ return True
725
+ if _is_existing_directory_scope(pattern=normalized_pattern, root=root):
726
+ return normalized_path.startswith(normalized_pattern.rstrip("/") + "/")
727
+ return False
728
+
729
+
730
+ def _is_ignored_internal_path(path: str) -> bool:
731
+ for prefix in _IGNORED_INTERNAL_PREFIXES:
732
+ if path == prefix or path.startswith(prefix + "/"):
733
+ return True
734
+ return False
735
+
736
+
737
+ def is_agent_internal_scope_path(path: str) -> bool:
738
+ cleaned = _normalize_path(path)
739
+ return _is_ignored_internal_path(cleaned) or is_runtime_artifact_path(cleaned)
740
+
741
+
742
+ def is_internal_alysis_path(path: str) -> bool:
743
+ return is_agent_internal_scope_path(path)
744
+
745
+
746
+ def _is_python_file_path(path: str) -> bool:
747
+ return path.endswith(".py") and "/" in path
748
+
749
+
750
+ def _is_python_test_file(path: PurePosixPath) -> bool:
751
+ filename = path.name
752
+ return "tests" in path.parts[:-1] and (
753
+ filename.startswith("test_") or filename.endswith("_test.py")
754
+ )
755
+
756
+
757
+ def _supports_python_package_init(path: PurePosixPath) -> bool:
758
+ parent_name = path.parent.name
759
+ if parent_name in {"", "."}:
760
+ return False
761
+ if parent_name == "tests":
762
+ return True
763
+ return parent_name not in _BROAD_NON_PACKAGE_DIR_NAMES
764
+
765
+
766
+ def _is_rust_source_file_path(path: str) -> bool:
767
+ return path.endswith(".rs")
768
+
769
+
770
+ def _cargo_manifest_declares_workspace(manifest_path: Path) -> bool:
771
+ try:
772
+ text = manifest_path.read_text(encoding="utf-8")
773
+ except OSError:
774
+ return False
775
+ return _CARGO_WORKSPACE_RE.search(text) is not None
776
+
777
+
778
+ def _cargo_manifest_declares_package(manifest_path: Path) -> bool:
779
+ try:
780
+ text = manifest_path.read_text(encoding="utf-8")
781
+ except OSError:
782
+ return False
783
+ return _CARGO_PACKAGE_RE.search(text) is not None
784
+
785
+
786
+ def _nearest_cargo_manifest_dir(*, root: Path, path: PurePosixPath) -> Path | None:
787
+ root_resolved = root.resolve()
788
+ current = root_resolved.joinpath(
789
+ *[segment for segment in path.parent.parts if segment not in {"", "."}]
790
+ )
791
+ while True:
792
+ if (current / _CARGO_MANIFEST_FILENAME).is_file():
793
+ return current
794
+ if current == root_resolved:
795
+ return None
796
+ current = current.parent
797
+
798
+
799
+ def _workspace_cargo_manifest_dir(*, root: Path, manifest_dir: Path) -> Path | None:
800
+ root_resolved = root.resolve()
801
+ current = manifest_dir
802
+ while True:
803
+ manifest_path = current / _CARGO_MANIFEST_FILENAME
804
+ if manifest_path.is_file() and _cargo_manifest_declares_workspace(manifest_path):
805
+ return current
806
+ if current == root_resolved:
807
+ return None
808
+ current = current.parent
809
+
810
+
811
+ def _scope_concrete_lookup_path(path: str) -> PurePosixPath | None:
812
+ normalized = normalize_repo_path_entry(path, allow_extensionless_file=True)
813
+ if not normalized:
814
+ return None
815
+ pure_path = PurePosixPath(normalized)
816
+ if _is_rust_source_file_path(normalized):
817
+ return pure_path.parent
818
+
819
+ concrete_parts: list[str] = []
820
+ for segment in pure_path.parts:
821
+ if _has_glob(segment):
822
+ break
823
+ concrete_parts.append(segment)
824
+ if not concrete_parts:
825
+ return None
826
+
827
+ return PurePosixPath(*concrete_parts)
828
+
829
+
830
+ def _path_declares_package_manifest(*, root: Path, path: PurePosixPath) -> bool:
831
+ manifest_dir = root.resolve().joinpath(
832
+ *[segment for segment in path.parts if segment not in {"", "."}]
833
+ )
834
+ return _cargo_manifest_declares_package(manifest_dir / _CARGO_MANIFEST_FILENAME)
835
+
836
+
837
+ def _manifest_relative_scope_path(
838
+ *, root: Path, manifest_dir: Path, scope_path: PurePosixPath
839
+ ) -> PurePosixPath | None:
840
+ try:
841
+ manifest_rel = manifest_dir.relative_to(root.resolve()).as_posix()
842
+ except ValueError:
843
+ return None
844
+ if manifest_rel in {"", "."}:
845
+ return scope_path
846
+ try:
847
+ return scope_path.relative_to(PurePosixPath(manifest_rel))
848
+ except ValueError:
849
+ return None
850
+
851
+
852
+ def _rust_scope_lookup_dir(*, root: Path, path: str) -> PurePosixPath | None:
853
+ concrete_path = _scope_concrete_lookup_path(path)
854
+ if concrete_path is None:
855
+ return None
856
+ if _path_declares_package_manifest(root=root, path=concrete_path):
857
+ return concrete_path
858
+
859
+ manifest_dir = _nearest_cargo_manifest_dir(root=root, path=concrete_path / "_scope.rs")
860
+ if manifest_dir is None:
861
+ return None
862
+ manifest_path = manifest_dir / _CARGO_MANIFEST_FILENAME
863
+ if not _cargo_manifest_declares_package(manifest_path):
864
+ return None
865
+
866
+ relative_scope_path = _manifest_relative_scope_path(
867
+ root=root,
868
+ manifest_dir=manifest_dir,
869
+ scope_path=concrete_path,
870
+ )
871
+ if relative_scope_path is None or not relative_scope_path.parts:
872
+ return None
873
+ if relative_scope_path.parts[0] in _RUST_SCOPE_DIR_NAMES:
874
+ return concrete_path
875
+ return None
876
+
877
+
878
+ def _cargo_lock_support_patterns_for_path(*, root: Path, path: str) -> list[str]:
879
+ normalized = normalize_repo_path_entry(path, allow_extensionless_file=True)
880
+ if not normalized:
881
+ return []
882
+
883
+ lookup_dir = _rust_scope_lookup_dir(root=root, path=normalized)
884
+ if lookup_dir is None:
885
+ return []
886
+
887
+ manifest_dir = _nearest_cargo_manifest_dir(root=root, path=lookup_dir / "_scope.rs")
888
+ if manifest_dir is None:
889
+ return []
890
+
891
+ workspace_manifest_dir = _workspace_cargo_manifest_dir(root=root, manifest_dir=manifest_dir)
892
+ lock_parent = workspace_manifest_dir or manifest_dir
893
+ try:
894
+ relative_lock = lock_parent.relative_to(root.resolve()).as_posix()
895
+ except ValueError:
896
+ return []
897
+ if relative_lock in {"", "."}:
898
+ return [_CARGO_LOCK_FILENAME]
899
+ return [f"{relative_lock}/{_CARGO_LOCK_FILENAME}"]
900
+
901
+
902
+ def _cargo_lock_support_patterns_for_manifest(*, root: Path, path: str) -> list[str]:
903
+ normalized = normalize_repo_path_entry(path, allow_extensionless_file=True)
904
+ if not normalized:
905
+ return []
906
+ pure_path = PurePosixPath(normalized)
907
+ if pure_path.name != _CARGO_MANIFEST_FILENAME:
908
+ return []
909
+ manifest_dir = root.resolve().joinpath(
910
+ *[segment for segment in pure_path.parent.parts if segment not in {"", "."}]
911
+ )
912
+ manifest_path = manifest_dir / _CARGO_MANIFEST_FILENAME
913
+ if not manifest_path.is_file():
914
+ return []
915
+ if not (
916
+ _cargo_manifest_declares_package(manifest_path)
917
+ or _cargo_manifest_declares_workspace(manifest_path)
918
+ ):
919
+ return []
920
+ workspace_manifest_dir = _workspace_cargo_manifest_dir(root=root, manifest_dir=manifest_dir)
921
+ lock_parent = workspace_manifest_dir or manifest_dir
922
+ try:
923
+ relative_lock = lock_parent.relative_to(root.resolve()).as_posix()
924
+ except ValueError:
925
+ return []
926
+ if relative_lock in {"", "."}:
927
+ return [_CARGO_LOCK_FILENAME]
928
+ return [f"{relative_lock}/{_CARGO_LOCK_FILENAME}"]
929
+
930
+
931
+ _NODE_LOCKFILES_BY_PACKAGE_MANAGER = {
932
+ "npm": "package-lock.json",
933
+ "pnpm": "pnpm-lock.yaml",
934
+ "yarn": "yarn.lock",
935
+ "bun": "bun.lockb",
936
+ }
937
+ _NODE_LOCKFILE_NAMES = frozenset(_NODE_LOCKFILES_BY_PACKAGE_MANAGER.values())
938
+ _PYPROJECT_LOCKFILE_RULES = (
939
+ ("poetry.lock", "[tool.poetry]", "poetry"),
940
+ ("uv.lock", "[tool.uv]", "uv"),
941
+ ("pdm.lock", "[tool.pdm]", "pdm"),
942
+ )
943
+ # Package-manager lockfiles are the companion-expansion rules' business: those rules know
944
+ # which lockfile actually belongs to a declared manifest, so the generic sibling-artifact
945
+ # rule below must not second-guess them (a pnpm project seeing package-lock.json is a
946
+ # signal, not an adjacent change).
947
+ _MANAGED_LOCKFILE_NAMES = frozenset(
948
+ {
949
+ *_NODE_LOCKFILE_NAMES,
950
+ _CARGO_LOCK_FILENAME,
951
+ *(lock_name for lock_name, _marker, _tool in _PYPROJECT_LOCKFILE_RULES),
952
+ "Pipfile.lock",
953
+ "constraints.txt",
954
+ "requirements.lock",
955
+ "requirements.lock.txt",
956
+ }
957
+ )
958
+
959
+
960
+ def _package_manager_from_package_json(package_json: Path) -> str | None:
961
+ try:
962
+ import json
963
+
964
+ payload = json.loads(package_json.read_text(encoding="utf-8"))
965
+ except (OSError, ValueError):
966
+ return None
967
+ raw = str(payload.get("packageManager") or "").strip().casefold()
968
+ if not raw:
969
+ return None
970
+ return raw.split("@", 1)[0].strip() or None
971
+
972
+
973
+ def _node_lock_support_patterns_for_path(*, root: Path, path: str) -> list[str]:
974
+ normalized = normalize_repo_path_entry(path, allow_extensionless_file=True)
975
+ if not normalized:
976
+ return []
977
+ pure_path = PurePosixPath(normalized)
978
+ if pure_path.name != "package.json":
979
+ return []
980
+ package_dir = root.resolve().joinpath(
981
+ *[segment for segment in pure_path.parent.parts if segment not in {"", "."}]
982
+ )
983
+ package_json = package_dir / "package.json"
984
+ if not package_json.is_file():
985
+ return []
986
+ lock_name: str | None = None
987
+ manager = _package_manager_from_package_json(package_json)
988
+ if manager in _NODE_LOCKFILES_BY_PACKAGE_MANAGER:
989
+ lock_name = _NODE_LOCKFILES_BY_PACKAGE_MANAGER[manager]
990
+ else:
991
+ existing = sorted(name for name in _NODE_LOCKFILE_NAMES if (package_dir / name).is_file())
992
+ if len(existing) == 1:
993
+ lock_name = existing[0]
994
+ if lock_name is None:
995
+ return []
996
+ lock_path = pure_path.parent / lock_name
997
+ return [lock_path.as_posix()]
998
+
999
+
1000
+ def _pyproject_lock_support_patterns_for_path(*, root: Path, path: str) -> list[str]:
1001
+ normalized = normalize_repo_path_entry(path, allow_extensionless_file=True)
1002
+ if not normalized:
1003
+ return []
1004
+ pure_path = PurePosixPath(normalized)
1005
+ if pure_path.name != "pyproject.toml":
1006
+ return []
1007
+ project_dir = root.resolve().joinpath(
1008
+ *[segment for segment in pure_path.parent.parts if segment not in {"", "."}]
1009
+ )
1010
+ pyproject = project_dir / "pyproject.toml"
1011
+ if not pyproject.is_file():
1012
+ return []
1013
+ try:
1014
+ pyproject_text = pyproject.read_text(encoding="utf-8").casefold()
1015
+ except OSError:
1016
+ pyproject_text = ""
1017
+ support: list[str] = []
1018
+ for lock_name, marker, _tool_name in _PYPROJECT_LOCKFILE_RULES:
1019
+ lock_path = project_dir / lock_name
1020
+ marker_present = marker.casefold() in pyproject_text
1021
+ if not lock_path.is_file() and not marker_present:
1022
+ continue
1023
+ support.append((pure_path.parent / lock_name).as_posix())
1024
+ return support
1025
+
1026
+
1027
+ def _requirements_lock_support_patterns_for_path(*, root: Path, path: str) -> list[str]:
1028
+ normalized = normalize_repo_path_entry(path, allow_extensionless_file=True)
1029
+ if not normalized:
1030
+ return []
1031
+ pure_path = PurePosixPath(normalized)
1032
+ name = pure_path.name.casefold()
1033
+ if not (name.startswith("requirements") and name.endswith(".txt")):
1034
+ return []
1035
+ requirements_dir = root.resolve().joinpath(
1036
+ *[segment for segment in pure_path.parent.parts if segment not in {"", "."}]
1037
+ )
1038
+ support: list[str] = []
1039
+ for candidate in ("constraints.txt", "requirements.lock", "requirements.lock.txt"):
1040
+ if (requirements_dir / candidate).is_file():
1041
+ support.append((pure_path.parent / candidate).as_posix())
1042
+ return support
1043
+
1044
+
1045
+ def _companion_patterns_for_path(path: str, *, root: Path | None = None) -> list[str]:
1046
+ if root is None:
1047
+ return []
1048
+ normalized = normalize_repo_path_entry(path, allow_extensionless_file=True)
1049
+ if not normalized:
1050
+ return []
1051
+ companions: list[str] = []
1052
+ if _has_glob(normalized):
1053
+ companions.extend(_cargo_lock_support_patterns_for_path(root=root, path=normalized))
1054
+ return list(dict.fromkeys(companions))
1055
+ companions.extend(_cargo_lock_support_patterns_for_manifest(root=root, path=normalized))
1056
+ companions.extend(_cargo_lock_support_patterns_for_path(root=root, path=normalized))
1057
+ companions.extend(_node_lock_support_patterns_for_path(root=root, path=normalized))
1058
+ companions.extend(_pyproject_lock_support_patterns_for_path(root=root, path=normalized))
1059
+ companions.extend(_requirements_lock_support_patterns_for_path(root=root, path=normalized))
1060
+ return list(dict.fromkeys(companions))
1061
+
1062
+
1063
+ def companion_generated_paths_for(path: str, *, root: Path | None = None) -> list[str]:
1064
+ return _companion_patterns_for_path(path, root=root)
1065
+
1066
+
1067
+ def companion_expansions_for_patterns(
1068
+ patterns: list[str],
1069
+ *,
1070
+ root: Path | None = None,
1071
+ ) -> list[ScopeCompanionExpansion]:
1072
+ expansions: list[ScopeCompanionExpansion] = []
1073
+ seen: set[str] = set()
1074
+ for pattern in patterns:
1075
+ normalized = normalize_repo_path_entry(pattern, allow_extensionless_file=True)
1076
+ if not normalized:
1077
+ continue
1078
+ for companion in _companion_patterns_for_path(normalized, root=root):
1079
+ key = _scope_pattern_identity_key(companion).casefold()
1080
+ if key in seen:
1081
+ continue
1082
+ seen.add(key)
1083
+ expansions.append(
1084
+ ScopeCompanionExpansion(
1085
+ source_path=normalized,
1086
+ companion_path=companion,
1087
+ reason_code="ecosystem_companion",
1088
+ evidence=f"{companion} is a repo-aware companion for {normalized}",
1089
+ )
1090
+ )
1091
+ return expansions
1092
+
1093
+
1094
+ def _rust_entrypoint_support_patterns_for_path(*, root: Path, path: str) -> list[str]:
1095
+ normalized = normalize_repo_path_entry(path, allow_extensionless_file=True)
1096
+ if not normalized or not _is_rust_source_file_path(normalized):
1097
+ return []
1098
+ pure_path = PurePosixPath(normalized)
1099
+ if pure_path.name in _RUST_ENTRYPOINT_FILENAMES:
1100
+ return []
1101
+
1102
+ lookup_dir = _rust_scope_lookup_dir(root=root, path=normalized)
1103
+ if lookup_dir is None:
1104
+ return []
1105
+
1106
+ manifest_dir = _nearest_cargo_manifest_dir(root=root, path=lookup_dir / "_scope.rs")
1107
+ if manifest_dir is None:
1108
+ return []
1109
+ try:
1110
+ manifest_rel = manifest_dir.relative_to(root.resolve()).as_posix()
1111
+ except ValueError:
1112
+ return []
1113
+
1114
+ support_patterns: list[str] = []
1115
+ for filename in _RUST_ENTRYPOINT_FILENAMES:
1116
+ host_path = manifest_dir / "src" / filename
1117
+ if not host_path.is_file():
1118
+ continue
1119
+ rel_path = PurePosixPath("src", filename)
1120
+ if manifest_rel not in {"", "."}:
1121
+ rel_path = PurePosixPath(manifest_rel) / rel_path
1122
+ support_patterns.append(rel_path.as_posix())
1123
+ return support_patterns
1124
+
1125
+
1126
+ def _support_file_patterns_for_path(path: str, *, root: Path | None = None) -> list[str]:
1127
+ normalized = normalize_repo_path_entry(path, allow_extensionless_file=True)
1128
+ if not normalized:
1129
+ return []
1130
+
1131
+ support_patterns: list[str] = []
1132
+ if not _has_glob(normalized) and _is_python_file_path(normalized):
1133
+ pure_path = PurePosixPath(normalized)
1134
+ parent = pure_path.parent.as_posix()
1135
+ if parent not in {"", "."}:
1136
+ if pure_path.name != "__init__.py" and _supports_python_package_init(pure_path):
1137
+ support_patterns.append(f"{parent}/__init__.py")
1138
+ if _is_python_test_file(pure_path):
1139
+ support_patterns.append(f"{parent}/conftest.py")
1140
+
1141
+ if root is not None:
1142
+ support_patterns.extend(_companion_patterns_for_path(normalized, root=root))
1143
+ support_patterns.extend(
1144
+ _rust_entrypoint_support_patterns_for_path(root=root, path=normalized)
1145
+ )
1146
+ return support_patterns
1147
+
1148
+
1149
+ def _expand_support_file_patterns(patterns: list[str], *, root: Path | None = None) -> list[str]:
1150
+ seen: set[str] = set()
1151
+ expanded: list[str] = []
1152
+ for pattern in patterns:
1153
+ normalized = normalize_repo_path_entry(pattern, allow_extensionless_file=True)
1154
+ if not normalized:
1155
+ continue
1156
+ identity_key = _scope_pattern_identity_key(normalized)
1157
+ if identity_key not in seen:
1158
+ seen.add(identity_key)
1159
+ expanded.append(normalized)
1160
+ for support_pattern in _support_file_patterns_for_path(normalized, root=root):
1161
+ support_identity_key = _scope_pattern_identity_key(support_pattern)
1162
+ if support_identity_key in seen:
1163
+ continue
1164
+ seen.add(support_identity_key)
1165
+ expanded.append(support_pattern)
1166
+ return expanded
1167
+
1168
+
1169
+ def _is_root_readme_alias(value: str) -> bool:
1170
+ normalized = _normalize_path(value).rstrip("/")
1171
+ return "/" not in normalized and normalized in _README_ALIAS_FILENAMES
1172
+
1173
+
1174
+ def _is_root_readme_alias_match(*, path: str, pattern: str) -> bool:
1175
+ return _is_root_readme_alias(path) and _is_root_readme_alias(pattern)
1176
+
1177
+
1178
+ def _scope_pattern_identity_key(value: str) -> str:
1179
+ normalized = _normalize_path(value).rstrip("/")
1180
+ if _is_root_readme_alias(normalized):
1181
+ return "__readme_alias__"
1182
+ return normalized
1183
+
1184
+
1185
+ def ancestor_directory_scope_patterns(
1186
+ patterns: list[str], *, root: Path | None = None
1187
+ ) -> list[str]:
1188
+ seen: set[str] = set()
1189
+ ancestors: list[str] = []
1190
+ for pattern in _expand_support_file_patterns(patterns, root=root):
1191
+ normalized = normalize_repo_path_entry(pattern, allow_extensionless_file=True)
1192
+ if not normalized or _has_glob(normalized):
1193
+ continue
1194
+ parent = PurePosixPath(normalized).parent.as_posix()
1195
+ while parent not in {"", "."}:
1196
+ if parent not in seen:
1197
+ seen.add(parent)
1198
+ ancestors.append(parent)
1199
+ parent = PurePosixPath(parent).parent.as_posix()
1200
+ return ancestors
1201
+
1202
+
1203
+ def is_non_material_untracked_path(path: str) -> bool:
1204
+ normalized = _normalize_path(path).rstrip("/")
1205
+ if not normalized:
1206
+ return False
1207
+ pure = PurePosixPath(normalized)
1208
+ if any(part.endswith(".egg-info") for part in pure.parts):
1209
+ return True
1210
+ if len(pure.parts) != 1:
1211
+ return False
1212
+ name = pure.name.casefold()
1213
+ return (
1214
+ name in _NON_MATERIAL_ROOT_SCRATCH_FILENAMES
1215
+ or any(name.endswith(suffix) for suffix in _NON_MATERIAL_ROOT_SCRATCH_SUFFIXES)
1216
+ or _NON_MATERIAL_ROOT_SCRATCH_RE.fullmatch(name) is not None
1217
+ or _NON_MATERIAL_ROOT_STATE_FILE_RE.fullmatch(name) is not None
1218
+ )
1219
+
1220
+
1221
+ def is_untracked_source_path(path: str, *, root: Path | None = None) -> bool:
1222
+ normalized = _normalize_path(path).rstrip("/")
1223
+ if (
1224
+ not normalized
1225
+ or is_non_material_untracked_path(normalized)
1226
+ or is_runtime_artifact_path(normalized, root=root)
1227
+ ):
1228
+ return False
1229
+ return classify_path(normalized).kind in _UNTRACKED_SOURCE_KINDS
1230
+
1231
+
1232
+ def is_existing_test_layout_path(path: str) -> bool:
1233
+ normalized = _normalize_path(path).rstrip("/")
1234
+ if not normalized or normalized.startswith(("/", "../")):
1235
+ return False
1236
+ parsed = PurePosixPath(normalized)
1237
+ filename = parsed.name.casefold()
1238
+ directory_parts = {part.casefold() for part in parsed.parts[:-1]}
1239
+ return bool(
1240
+ directory_parts & {"tests", "testing"}
1241
+ or (filename.startswith("test_") and filename.endswith(".py"))
1242
+ or filename.endswith("_test.py")
1243
+ )
1244
+
1245
+
1246
+ def _is_known_alysis_scratch_path(path: str) -> bool:
1247
+ normalized = _normalize_path(path).rstrip("/")
1248
+ if not normalized:
1249
+ return False
1250
+ pure = PurePosixPath(normalized)
1251
+ if len(pure.parts) != 1:
1252
+ return False
1253
+ name = pure.name.casefold()
1254
+ return (
1255
+ name in _NON_MATERIAL_ROOT_SCRATCH_FILENAMES
1256
+ or any(name.endswith(suffix) for suffix in _NON_MATERIAL_ROOT_SCRATCH_SUFFIXES)
1257
+ or _NON_MATERIAL_ROOT_SCRATCH_RE.fullmatch(name) is not None
1258
+ )
1259
+
1260
+
1261
+ def _unique_artifact_destination(artifact_dir: Path, filename: str) -> Path:
1262
+ candidate = artifact_dir / filename
1263
+ if not candidate.exists():
1264
+ return candidate
1265
+ stem = candidate.stem
1266
+ suffix = candidate.suffix
1267
+ for index in range(1, 1000):
1268
+ indexed = artifact_dir / f"{stem}.{index}{suffix}"
1269
+ if not indexed.exists():
1270
+ return indexed
1271
+ return artifact_dir / f"{stem}.overflow{suffix}"
1272
+
1273
+
1274
+ def relocate_known_scratch_artifacts(
1275
+ *,
1276
+ root: Path,
1277
+ artifact_dir: Path,
1278
+ ) -> list[ScopeViolationDiagnostic]:
1279
+ diagnostics: list[ScopeViolationDiagnostic] = []
1280
+ artifact_dir.mkdir(parents=True, exist_ok=True)
1281
+ for rel in _git_untracked_leaf_files(root, include_non_material=True):
1282
+ normalized = _normalize_path(rel).rstrip("/")
1283
+ if not _is_known_alysis_scratch_path(normalized):
1284
+ continue
1285
+ source = (root.resolve() / normalized).resolve()
1286
+ try:
1287
+ source.relative_to(root.resolve())
1288
+ except ValueError:
1289
+ continue
1290
+ if not source.is_file():
1291
+ continue
1292
+ destination = _unique_artifact_destination(artifact_dir, PurePosixPath(normalized).name)
1293
+ try:
1294
+ shutil.move(os.fspath(source), os.fspath(destination))
1295
+ except OSError:
1296
+ continue
1297
+ diagnostics.append(
1298
+ ScopeViolationDiagnostic(
1299
+ path=normalized,
1300
+ classification=SCOPE_CLASS_SCRATCH_ARTIFACT,
1301
+ reason_code="moved_known_root_scratch_file",
1302
+ evidence=(
1303
+ f"Moved untracked diagnostic scratch file {normalized} to {destination.name}"
1304
+ ),
1305
+ recommended_action="recorded_in_artifacts",
1306
+ allowed=True,
1307
+ )
1308
+ )
1309
+ return diagnostics
1310
+
1311
+
1312
+ def _forbidden_path_identity_keys_for_text(text: str) -> set[str]:
1313
+ return {
1314
+ _scope_pattern_identity_key(item.path).casefold()
1315
+ for item in extract_forbidden_repo_path_hint_records(text)
1316
+ }
1317
+
1318
+
1319
+ def _forbidden_path_records_for_task(task: dict[str, Any] | None) -> list[ForbiddenPathHint]:
1320
+ if not isinstance(task, dict):
1321
+ return []
1322
+ acceptance = task.get("acceptance_criteria") or []
1323
+ acceptance_items = acceptance if isinstance(acceptance, list) else []
1324
+ text = "\n".join(
1325
+ [
1326
+ str(task.get("title") or ""),
1327
+ str(task.get("description") or ""),
1328
+ *(str(item or "") for item in acceptance_items),
1329
+ ]
1330
+ )
1331
+ return extract_forbidden_repo_path_hint_records(text)
1332
+
1333
+
1334
+ def _path_matches_any_pattern(
1335
+ path: str,
1336
+ patterns: list[str],
1337
+ *,
1338
+ root: Path | None = None,
1339
+ ) -> bool:
1340
+ return any(scope_path_matches_pattern(path, pattern, root=root) for pattern in patterns)
1341
+
1342
+
1343
+ def _companion_source_for_path(
1344
+ path: str,
1345
+ expansions: list[ScopeCompanionExpansion],
1346
+ *,
1347
+ root: Path | None = None,
1348
+ ) -> ScopeCompanionExpansion | None:
1349
+ for expansion in expansions:
1350
+ if scope_path_matches_pattern(path, expansion.companion_path, root=root):
1351
+ return expansion
1352
+ return None
1353
+
1354
+
1355
+ def is_protected_scope_path(path: str) -> bool:
1356
+ """True for paths no task scope may cover: agent-internal state, VCS metadata, escapes.
1357
+
1358
+ A protected path is never amendable. It blocks in strict mode no matter how plausible
1359
+ the change looks, which is the point: scope triage may relax the *plan*, never the
1360
+ workspace boundary.
1361
+ """
1362
+ cleaned = _normalize_path(path).rstrip("/")
1363
+ if not cleaned:
1364
+ return False
1365
+ if (
1366
+ cleaned.startswith("/")
1367
+ or cleaned == ".."
1368
+ or cleaned.startswith("../")
1369
+ or _WINDOWS_ABSOLUTE_PATH_RE.match(cleaned) is not None
1370
+ ):
1371
+ return True
1372
+ if is_agent_internal_scope_path(cleaned):
1373
+ return True
1374
+ parts = PurePosixPath(cleaned).parts
1375
+ return bool(parts) and parts[0] in _PROTECTED_SCOPE_PREFIXES
1376
+
1377
+
1378
+ def _static_glob_prefix_dir(pattern: str) -> str:
1379
+ """Directory portion of ``pattern`` before its first globbed segment.
1380
+
1381
+ ``src/**`` and ``src/*.py`` both anchor on ``src``; a bare ``**`` anchors on nothing
1382
+ and must not turn the whole repository into declared-scope territory.
1383
+ """
1384
+ parts = PurePosixPath(pattern).parts
1385
+ static: list[str] = []
1386
+ for part in parts:
1387
+ if _has_glob(part):
1388
+ break
1389
+ static.append(part)
1390
+ if len(static) == len(parts):
1391
+ # No globbed segment at all: the last element is the declared leaf, not a dir.
1392
+ static = static[:-1]
1393
+ return PurePosixPath(*static).as_posix() if static else ""
1394
+
1395
+
1396
+ def declared_scope_directories(allowed_patterns: list[str]) -> set[str]:
1397
+ """Directories the declared scope already reaches into.
1398
+
1399
+ A declared file implies its directory; a glob implies its static prefix. Both are the
1400
+ basis for calling a neighbouring path "adjacent" rather than unrelated.
1401
+ """
1402
+ directories: set[str] = set()
1403
+ for pattern in allowed_patterns:
1404
+ normalized = _normalize_path(pattern or "").rstrip("/")
1405
+ if not normalized:
1406
+ continue
1407
+ directory = (
1408
+ _static_glob_prefix_dir(normalized)
1409
+ if _has_glob(normalized)
1410
+ else PurePosixPath(normalized).parent.as_posix()
1411
+ )
1412
+ if directory and directory not in {".", "/"}:
1413
+ directories.add(directory)
1414
+ return directories
1415
+
1416
+
1417
+ def _path_is_under_declared_directory(path: str, directories: Collection[str]) -> str | None:
1418
+ parent = PurePosixPath(path).parent.as_posix()
1419
+ if parent in {"", "."}:
1420
+ return None
1421
+ for directory in directories:
1422
+ if parent == directory or parent.startswith(directory + "/"):
1423
+ return directory
1424
+ return None
1425
+
1426
+
1427
+ def _path_can_be_adjacent(path: str) -> bool:
1428
+ """Shared guard: hidden, escaping and vendored/built paths are never adjacent."""
1429
+ if not path or path.startswith(".") or path.startswith("../"):
1430
+ return False
1431
+ pure = PurePosixPath(path)
1432
+ if any(part in _NON_ADJACENT_DIR_NAMES for part in pure.parts):
1433
+ return False
1434
+ return "." in pure.name
1435
+
1436
+
1437
+ def _likely_legitimate_missing_scope(path: str, *, allowed_patterns: list[str]) -> bool:
1438
+ normalized = _normalize_path(path).rstrip("/")
1439
+ if not _path_can_be_adjacent(normalized):
1440
+ return False
1441
+ extension = PurePosixPath(normalized).name.rsplit(".", 1)[1].casefold()
1442
+ if extension not in _INFERRED_FILE_EXTENSIONS:
1443
+ return False
1444
+ return (
1445
+ _path_is_under_declared_directory(normalized, declared_scope_directories(allowed_patterns))
1446
+ is not None
1447
+ )
1448
+
1449
+
1450
+ def _filename_stem(name: str) -> str:
1451
+ return name.rsplit(".", 1)[0] if "." in name else name
1452
+
1453
+
1454
+ def _test_filename_subjects(name: str) -> set[str]:
1455
+ """Stems a test filename could be testing (``test_mod.py`` -> ``{mod}``)."""
1456
+ stem = _filename_stem(name)
1457
+ subjects: set[str] = set()
1458
+ for prefix in _TEST_FILENAME_STEM_PREFIXES:
1459
+ if stem.casefold().startswith(prefix):
1460
+ subjects.add(stem[len(prefix) :])
1461
+ for suffix in _TEST_FILENAME_STEM_SUFFIXES:
1462
+ if stem.casefold().endswith(suffix):
1463
+ subjects.add(stem[: -len(suffix)])
1464
+ return {item.casefold() for item in subjects if item}
1465
+
1466
+
1467
+ def _sibling_test_source(path: str, *, declared_files: Collection[str]) -> str | None:
1468
+ """Declared file this path is a test for, if it reads as one."""
1469
+ subjects = _test_filename_subjects(PurePosixPath(path).name)
1470
+ if not subjects:
1471
+ return None
1472
+ for declared in declared_files:
1473
+ declared_stem = _filename_stem(PurePosixPath(declared).name).casefold()
1474
+ if declared_stem and declared_stem in subjects:
1475
+ return declared
1476
+ return None
1477
+
1478
+
1479
+ def _generated_artifact_source(path: str, *, declared_files: Collection[str]) -> str | None:
1480
+ """Declared file this path is a generated sibling artifact of, if any."""
1481
+ pure = PurePosixPath(path)
1482
+ name = pure.name
1483
+ if name in _MANAGED_LOCKFILE_NAMES:
1484
+ return None
1485
+ looks_generated = name.casefold().endswith(_GENERATED_ARTIFACT_NAME_SUFFIXES) or any(
1486
+ marker in name.casefold() for marker in _GENERATED_ARTIFACT_NAME_MARKERS
1487
+ )
1488
+ if not looks_generated:
1489
+ return None
1490
+ for declared in declared_files:
1491
+ declared_pure = PurePosixPath(declared)
1492
+ if declared_pure.parent != pure.parent or declared_pure.name == name:
1493
+ continue
1494
+ stem = _filename_stem(declared_pure.name)
1495
+ if stem and name.startswith(stem) and name[len(stem) : len(stem) + 1] in {".", "_", "-"}:
1496
+ return declared
1497
+ return None
1498
+
1499
+
1500
+ def _declared_scope_files(allowed_patterns: list[str]) -> list[str]:
1501
+ return [
1502
+ _normalize_path(pattern).rstrip("/")
1503
+ for pattern in allowed_patterns
1504
+ if pattern and not _has_glob(pattern) and "." in PurePosixPath(pattern).name
1505
+ ]
1506
+
1507
+
1508
+ def _adjacent_scope_classification(
1509
+ path: str,
1510
+ *,
1511
+ allowed_patterns: list[str],
1512
+ scope_directories: Collection[str],
1513
+ declared_files: Collection[str],
1514
+ new_paths: Collection[str] | None,
1515
+ ) -> tuple[str, str, str | None] | None:
1516
+ """Classify ``path`` as adjacent to the declared scope, or return None.
1517
+
1518
+ Returns ``(reason_code, evidence, source_path)``. Rule order matters: the test and
1519
+ generated-artifact rules hold for edits as well as creations, while "new file in a
1520
+ declared directory" only holds for files the task actually created -- editing an
1521
+ existing neighbouring module is a scope question, not a bookkeeping gap.
1522
+ """
1523
+ if not _path_can_be_adjacent(path):
1524
+ return None
1525
+
1526
+ test_source = _sibling_test_source(path, declared_files=declared_files)
1527
+ if test_source is not None:
1528
+ return (
1529
+ SCOPE_ADJACENT_SIBLING_TEST,
1530
+ f"{path} is a sibling test file for declared scope path {test_source}",
1531
+ test_source,
1532
+ )
1533
+
1534
+ artifact_source = _generated_artifact_source(path, declared_files=declared_files)
1535
+ if artifact_source is not None:
1536
+ return (
1537
+ SCOPE_ADJACENT_GENERATED_ARTIFACT,
1538
+ f"{path} is a generated artifact of declared scope path {artifact_source}",
1539
+ artifact_source,
1540
+ )
1541
+
1542
+ covering_directory = _path_is_under_declared_directory(path, scope_directories)
1543
+ if covering_directory is None:
1544
+ return None
1545
+ extension = PurePosixPath(path).name.rsplit(".", 1)[1].casefold()
1546
+ if extension not in _INFERRED_FILE_EXTENSIONS:
1547
+ return None
1548
+ if new_paths is not None and path not in new_paths:
1549
+ return None
1550
+ created = "created" if new_paths is not None else "wrote"
1551
+ return (
1552
+ SCOPE_ADJACENT_NEW_FILE_IN_SCOPE_DIR,
1553
+ f"{path} was {created} under {covering_directory}/, already covered by write_scope "
1554
+ f"{allowed_patterns or ['(none)']}",
1555
+ None,
1556
+ )
1557
+
1558
+
1559
+ def suggested_scope_pattern_for(path: str) -> str:
1560
+ """Directory-level glob covering ``path`` (the path itself when it sits at the root).
1561
+
1562
+ Directory globs are what the planner is asked to write (see plan_validation R3/R4), so
1563
+ they are also what a scope-fix suggestion offers.
1564
+ """
1565
+ normalized = _normalize_path(path).rstrip("/")
1566
+ if not normalized:
1567
+ return ""
1568
+ parent = PurePosixPath(normalized).parent.as_posix()
1569
+ if parent in {"", "."}:
1570
+ return normalized
1571
+ return f"{parent}/**"
1572
+
1573
+
1574
+ def suggested_scope_patterns_for(paths: Collection[str], *, limit: int = 20) -> list[str]:
1575
+ """Deduplicated directory-level globs covering ``paths``, in first-seen order."""
1576
+ patterns: list[str] = []
1577
+ seen: set[str] = set()
1578
+ for path in paths:
1579
+ pattern = suggested_scope_pattern_for(path)
1580
+ if not pattern or pattern in seen:
1581
+ continue
1582
+ seen.add(pattern)
1583
+ patterns.append(pattern)
1584
+ if len(patterns) >= limit:
1585
+ break
1586
+ return patterns
1587
+
1588
+
1589
+ def apply_scope_amendments(
1590
+ task: dict[str, Any], amendments: Collection[ScopeAmendment]
1591
+ ) -> list[str]:
1592
+ """Add amendment patterns to ``task['write_scope']``; return what was actually added.
1593
+
1594
+ The task dict is the plan's own task object, so mutating it here is what makes the
1595
+ amendment outlive the run once the plan is saved.
1596
+ """
1597
+ if not amendments:
1598
+ return []
1599
+ existing_raw = task.get("write_scope")
1600
+ existing = (
1601
+ [str(item).strip() for item in existing_raw] if isinstance(existing_raw, list) else []
1602
+ )
1603
+ existing = [item for item in existing if item]
1604
+ known = {_scope_pattern_identity_key(item).casefold() for item in existing}
1605
+ added: list[str] = []
1606
+ for amendment in amendments:
1607
+ pattern = _normalize_path(amendment.pattern).rstrip("/")
1608
+ identity = _scope_pattern_identity_key(pattern).casefold()
1609
+ if not pattern or identity in known:
1610
+ continue
1611
+ known.add(identity)
1612
+ existing.append(pattern)
1613
+ added.append(pattern)
1614
+ if added:
1615
+ task["write_scope"] = existing
1616
+ return added
1617
+
1618
+
1619
+ def describe_scope_violations(
1620
+ diagnostics: Collection[ScopeViolationDiagnostic],
1621
+ *,
1622
+ limit: int = 20,
1623
+ ) -> list[str]:
1624
+ """One ``path (triage/classification): evidence`` line per blocking diagnostic.
1625
+
1626
+ Bounded, because this ends up inside an error string that ends up inside a report: a
1627
+ task that rewrote a thousand files should not produce a thousand-line failure message.
1628
+ """
1629
+ blocking = [item for item in diagnostics if not item.allowed]
1630
+ lines = [
1631
+ f"{item.path} ({item.triage}/{item.classification}): {item.evidence}"
1632
+ for item in blocking[:limit]
1633
+ ]
1634
+ if len(blocking) > limit:
1635
+ lines.append(f"+{len(blocking) - limit} more")
1636
+ return lines
1637
+
1638
+
1639
+ def assess_scope_changes(
1640
+ changed_files: list[str],
1641
+ allowed_patterns: list[str],
1642
+ *,
1643
+ task: dict[str, Any] | None = None,
1644
+ root: Path | None = None,
1645
+ extra_diagnostics: list[ScopeViolationDiagnostic] | None = None,
1646
+ amend_adjacent: bool = False,
1647
+ new_paths: Collection[str] | None = None,
1648
+ ) -> ScopeAssessment:
1649
+ """Classify every change against the declared scope.
1650
+
1651
+ With ``amend_adjacent`` set, changes that are plainly adjacent to the declared scope --
1652
+ a new file in a directory the scope already reaches, a sibling test file, a generated
1653
+ artifact of a declared file -- stop blocking and become :class:`ScopeAmendment` records
1654
+ the caller applies to the task. Protected paths block regardless, and genuinely
1655
+ unrelated paths keep blocking with a suggested scope patch attached.
1656
+
1657
+ ``new_paths`` narrows the "new file in a declared directory" rule to files the task
1658
+ actually created; pass ``None`` when creation cannot be determined and the rule falls
1659
+ back to covering edits too.
1660
+ """
1661
+ expanded_patterns = _expand_support_file_patterns(allowed_patterns, root=root)
1662
+ ancestor_dirs = set(ancestor_directory_scope_patterns(allowed_patterns, root=root))
1663
+ companion_expansions = companion_expansions_for_patterns(allowed_patterns, root=root)
1664
+ companion_patterns = [item.companion_path for item in companion_expansions]
1665
+ forbidden_records = _forbidden_path_records_for_task(task)
1666
+ forbidden_by_identity = {
1667
+ _scope_pattern_identity_key(item.path).casefold(): item for item in forbidden_records
1668
+ }
1669
+
1670
+ normalized: list[str] = []
1671
+ seen: set[str] = set()
1672
+ for raw in changed_files:
1673
+ rel = _normalize_path(raw).rstrip("/")
1674
+ if not rel or rel in seen or is_runtime_artifact_path(rel, root=root):
1675
+ continue
1676
+ seen.add(rel)
1677
+ normalized.append(rel)
1678
+
1679
+ scope_directories = declared_scope_directories(allowed_patterns)
1680
+ declared_files = _declared_scope_files(allowed_patterns)
1681
+ normalized_new_paths = (
1682
+ {_normalize_path(item).rstrip("/") for item in new_paths} if new_paths is not None else None
1683
+ )
1684
+
1685
+ diagnostics: list[ScopeViolationDiagnostic] = list(extra_diagnostics or [])
1686
+ blocking_paths: list[str] = []
1687
+ effective_changed_files: list[str] = []
1688
+ in_scope_paths: list[str] = []
1689
+ adjacent_paths: list[str] = []
1690
+ amendments: list[ScopeAmendment] = []
1691
+ for rel in normalized:
1692
+ identity_key = _scope_pattern_identity_key(rel).casefold()
1693
+ forbidden_record = forbidden_by_identity.get(identity_key)
1694
+ if forbidden_record is not None:
1695
+ diagnostics.append(
1696
+ ScopeViolationDiagnostic(
1697
+ path=rel,
1698
+ classification=SCOPE_CLASS_FORBIDDEN,
1699
+ reason_code=forbidden_record.reason_code,
1700
+ evidence=forbidden_record.evidence,
1701
+ recommended_action="reject_hard",
1702
+ allowed=False,
1703
+ )
1704
+ )
1705
+ blocking_paths.append(rel)
1706
+ effective_changed_files.append(rel)
1707
+ continue
1708
+ if is_protected_scope_path(rel):
1709
+ diagnostics.append(
1710
+ ScopeViolationDiagnostic(
1711
+ path=rel,
1712
+ classification=SCOPE_CLASS_PROTECTED,
1713
+ reason_code="protected_or_system_path",
1714
+ evidence=(
1715
+ f"{rel} is a protected agent-internal, version-control or "
1716
+ "out-of-workspace path that no task scope may cover"
1717
+ ),
1718
+ recommended_action="reject_hard",
1719
+ allowed=False,
1720
+ )
1721
+ )
1722
+ blocking_paths.append(rel)
1723
+ effective_changed_files.append(rel)
1724
+ continue
1725
+ if rel in ancestor_dirs:
1726
+ effective_changed_files.append(rel)
1727
+ continue
1728
+ companion = _companion_source_for_path(rel, companion_expansions, root=root)
1729
+ if companion is not None or _path_matches_any_pattern(rel, companion_patterns, root=root):
1730
+ source_path = companion.source_path if companion is not None else None
1731
+ diagnostics.append(
1732
+ ScopeViolationDiagnostic(
1733
+ path=rel,
1734
+ classification=SCOPE_CLASS_EXPECTED_COMPANION,
1735
+ reason_code="allowed_ecosystem_companion",
1736
+ evidence=(
1737
+ companion.evidence
1738
+ if companion is not None
1739
+ else f"{rel} matches an ecosystem companion rule"
1740
+ ),
1741
+ recommended_action="allow_and_record",
1742
+ allowed=True,
1743
+ source_path=source_path,
1744
+ )
1745
+ )
1746
+ effective_changed_files.append(rel)
1747
+ continue
1748
+ if _path_matches_any_pattern(rel, expanded_patterns, root=root):
1749
+ in_scope_paths.append(rel)
1750
+ effective_changed_files.append(rel)
1751
+ continue
1752
+ adjacency = _adjacent_scope_classification(
1753
+ rel,
1754
+ allowed_patterns=allowed_patterns,
1755
+ scope_directories=scope_directories,
1756
+ declared_files=declared_files,
1757
+ new_paths=normalized_new_paths,
1758
+ )
1759
+ if adjacency is not None and amend_adjacent:
1760
+ reason_code, evidence, source_path = adjacency
1761
+ amendments.append(
1762
+ ScopeAmendment(
1763
+ path=rel,
1764
+ # The exact path, not the directory glob: an amendment grants only what
1765
+ # the task actually earned. The glob is offered as planner advice.
1766
+ pattern=rel,
1767
+ reason_code=reason_code,
1768
+ evidence=evidence,
1769
+ suggested_pattern=suggested_scope_pattern_for(rel),
1770
+ source_path=source_path,
1771
+ )
1772
+ )
1773
+ adjacent_paths.append(rel)
1774
+ diagnostics.append(
1775
+ ScopeViolationDiagnostic(
1776
+ path=rel,
1777
+ classification=SCOPE_CLASS_ADJACENT,
1778
+ reason_code=reason_code,
1779
+ evidence=evidence,
1780
+ recommended_action="amend_scope_and_continue",
1781
+ allowed=True,
1782
+ source_path=source_path,
1783
+ suggested_pattern=suggested_scope_pattern_for(rel),
1784
+ )
1785
+ )
1786
+ effective_changed_files.append(rel)
1787
+ continue
1788
+ if _likely_legitimate_missing_scope(rel, allowed_patterns=allowed_patterns):
1789
+ classification = SCOPE_CLASS_LIKELY_MISSING_SCOPE
1790
+ reason_code = "same_area_code_path_outside_scope"
1791
+ recommended_action = "create_scope_delta_proposal"
1792
+ else:
1793
+ classification = SCOPE_CLASS_DANGEROUS_UNRELATED
1794
+ reason_code = "path_not_covered_by_task_scope"
1795
+ recommended_action = "reject_and_replan"
1796
+ diagnostics.append(
1797
+ ScopeViolationDiagnostic(
1798
+ path=rel,
1799
+ classification=classification,
1800
+ reason_code=reason_code,
1801
+ evidence=f"{rel} is not covered by allowed scope {allowed_patterns or ['(none)']}",
1802
+ recommended_action=recommended_action,
1803
+ allowed=False,
1804
+ suggested_pattern=suggested_scope_pattern_for(rel),
1805
+ )
1806
+ )
1807
+ blocking_paths.append(rel)
1808
+ effective_changed_files.append(rel)
1809
+
1810
+ return ScopeAssessment(
1811
+ ok=not blocking_paths,
1812
+ blocking_paths=blocking_paths,
1813
+ diagnostics=diagnostics,
1814
+ effective_changed_files=effective_changed_files,
1815
+ expanded_allowed_scope=expanded_patterns,
1816
+ companion_expansions=companion_expansions,
1817
+ in_scope_paths=in_scope_paths,
1818
+ adjacent_paths=adjacent_paths,
1819
+ amendments=amendments,
1820
+ # Protected paths are deliberately absent: no scope patch may legitimise them.
1821
+ suggested_scope_patterns=suggested_scope_patterns_for(
1822
+ [
1823
+ item.path
1824
+ for item in diagnostics
1825
+ if not item.allowed and item.triage == SCOPE_TRIAGE_UNRELATED
1826
+ ]
1827
+ ),
1828
+ )
1829
+
1830
+
1831
+ def check_scope(
1832
+ changed_files: list[str],
1833
+ allowed_patterns: list[str],
1834
+ *,
1835
+ root: Path | None = None,
1836
+ ) -> tuple[bool, list[str]]:
1837
+ assessment = assess_scope_changes(changed_files, allowed_patterns, root=root)
1838
+ return assessment.ok, assessment.blocking_paths
1839
+
1840
+
1841
+ def _git_untracked_leaf_files(root: Path, *, include_non_material: bool = False) -> list[str]:
1842
+ if shutil.which("git") is None:
1843
+ return []
1844
+ proc = _run_git_capture(
1845
+ root,
1846
+ ["ls-files", "--others", "--exclude-standard", "-z"],
1847
+ )
1848
+ if proc is None:
1849
+ return []
1850
+ if proc.returncode != 0:
1851
+ return []
1852
+
1853
+ stdout = proc.stdout
1854
+ raw_stdout = (
1855
+ stdout.encode("utf-8", errors="surrogateescape")
1856
+ if isinstance(stdout, str)
1857
+ else (stdout or b"")
1858
+ )
1859
+ seen: set[str] = set()
1860
+ changed: list[str] = []
1861
+ for item in raw_stdout.split(b"\0"):
1862
+ if not item:
1863
+ continue
1864
+ rel = _normalize_path(item.decode("utf-8", errors="surrogateescape").strip('"'))
1865
+ if not rel or rel in seen:
1866
+ continue
1867
+ if not include_non_material and is_non_material_untracked_path(rel):
1868
+ continue
1869
+ seen.add(rel)
1870
+ changed.append(rel)
1871
+ return changed
1872
+
1873
+
1874
+ def list_untracked_packaging_metadata_paths(root: Path) -> list[str]:
1875
+ return [
1876
+ rel
1877
+ for rel in _git_untracked_leaf_files(root, include_non_material=True)
1878
+ if is_non_material_untracked_path(rel)
1879
+ ]
1880
+
1881
+
1882
+ def list_changed_files_including_untracked(root: Path) -> list[str]:
1883
+ if shutil.which("git") is None:
1884
+ return []
1885
+ proc = _run_git_capture(root, ["status", "--porcelain"], text=True)
1886
+ if proc is None:
1887
+ return []
1888
+ if proc.returncode != 0:
1889
+ return []
1890
+ seen: set[str] = set()
1891
+ changed: list[str] = []
1892
+ for line in proc.stdout.splitlines():
1893
+ if len(line) < 4:
1894
+ continue
1895
+ if line.startswith("?? "):
1896
+ continue
1897
+ rel = line[3:].strip()
1898
+ if " -> " in rel:
1899
+ rel = rel.split(" -> ", 1)[1].strip()
1900
+ rel = _normalize_path(rel.strip('"'))
1901
+ if not rel or rel in seen:
1902
+ continue
1903
+ seen.add(rel)
1904
+ changed.append(rel)
1905
+ for rel in _git_untracked_leaf_files(root):
1906
+ if rel in seen:
1907
+ continue
1908
+ seen.add(rel)
1909
+ changed.append(rel)
1910
+ return changed
1911
+
1912
+
1913
+ def resolve_workspace_git_base(root: Path) -> str | None:
1914
+ """Return the current commit for use as a stable turn-local diff base."""
1915
+
1916
+ if shutil.which("git") is None:
1917
+ return None
1918
+ head = _run_git_capture(
1919
+ root,
1920
+ ["rev-parse", "--verify", "HEAD^{commit}"],
1921
+ text=True,
1922
+ )
1923
+ if head is None or head.returncode != 0:
1924
+ return None
1925
+ value = head.stdout.strip()
1926
+ return value or None
1927
+
1928
+
1929
+ def inspect_workspace_git_diff(
1930
+ root: Path,
1931
+ *,
1932
+ base_ref: str | None = None,
1933
+ ) -> WorkspaceGitDiffState:
1934
+ """Inspect tracked diff paths plus non-scratch untracked files.
1935
+
1936
+ ``available=False`` distinguishes an unavailable/non-git workspace from a
1937
+ successfully inspected clean worktree.
1938
+ """
1939
+
1940
+ if shutil.which("git") is None:
1941
+ return WorkspaceGitDiffState(available=False)
1942
+
1943
+ inside_worktree = _run_git_capture(
1944
+ root,
1945
+ ["rev-parse", "--is-inside-work-tree"],
1946
+ text=True,
1947
+ )
1948
+ if (
1949
+ inside_worktree is None
1950
+ or inside_worktree.returncode != 0
1951
+ or inside_worktree.stdout.strip().casefold() != "true"
1952
+ ):
1953
+ return WorkspaceGitDiffState(available=False)
1954
+
1955
+ requested_base = str(base_ref or "").strip()
1956
+ head = _run_git_capture(
1957
+ root,
1958
+ ["rev-parse", "--verify", requested_base or "HEAD"],
1959
+ text=True,
1960
+ )
1961
+ if head is None:
1962
+ return WorkspaceGitDiffState(available=False)
1963
+
1964
+ tracked_procs: list[subprocess.CompletedProcess[Any] | None]
1965
+ if head.returncode == 0:
1966
+ tracked_procs = [
1967
+ _run_git_capture(
1968
+ root,
1969
+ [
1970
+ "diff",
1971
+ "--name-only",
1972
+ "--no-renames",
1973
+ "-z",
1974
+ requested_base or "HEAD",
1975
+ "--",
1976
+ ],
1977
+ )
1978
+ ]
1979
+ else:
1980
+ tracked_procs = [
1981
+ _run_git_capture(root, ["ls-files", "--cached", "-z"]),
1982
+ _run_git_capture(
1983
+ root,
1984
+ ["diff", "--name-only", "--no-renames", "-z", "--"],
1985
+ ),
1986
+ ]
1987
+ untracked_proc = _run_git_capture(
1988
+ root,
1989
+ ["ls-files", "--others", "--exclude-standard", "-z"],
1990
+ )
1991
+ if (
1992
+ untracked_proc is None
1993
+ or untracked_proc.returncode != 0
1994
+ or any(proc is None or proc.returncode != 0 for proc in tracked_procs)
1995
+ ):
1996
+ return WorkspaceGitDiffState(available=False)
1997
+
1998
+ def _nul_paths(proc: subprocess.CompletedProcess[Any]) -> list[str]:
1999
+ stdout = proc.stdout
2000
+ raw_stdout = (
2001
+ stdout.encode("utf-8", errors="surrogateescape")
2002
+ if isinstance(stdout, str)
2003
+ else (stdout or b"")
2004
+ )
2005
+ return [
2006
+ normalized
2007
+ for item in raw_stdout.split(b"\0")
2008
+ if item
2009
+ if (
2010
+ normalized := _normalize_path(
2011
+ item.decode("utf-8", errors="surrogateescape").strip('"')
2012
+ )
2013
+ )
2014
+ ]
2015
+
2016
+ tracked_paths = sorted(
2017
+ {path for proc in tracked_procs if proc is not None for path in _nul_paths(proc)}
2018
+ )
2019
+ untracked_source_paths = sorted(
2020
+ {path for path in _nul_paths(untracked_proc) if is_untracked_source_path(path, root=root)}
2021
+ )
2022
+ return WorkspaceGitDiffState(
2023
+ available=True,
2024
+ tracked_diff_paths=tuple(tracked_paths),
2025
+ untracked_source_paths=tuple(untracked_source_paths),
2026
+ )
2027
+
2028
+
2029
+ def inspect_existing_test_edits(
2030
+ root: Path,
2031
+ *,
2032
+ base_ref: str | None = None,
2033
+ ) -> ExistingTestEditsState:
2034
+ """Return edits to test files that existed at the requested base commit.
2035
+
2036
+ Added files are intentionally excluded so agents may add regression coverage without
2037
+ rewriting the repository's existing test expectations.
2038
+ """
2039
+
2040
+ if shutil.which("git") is None:
2041
+ return ExistingTestEditsState(available=False)
2042
+
2043
+ inside_worktree = _run_git_capture(
2044
+ root,
2045
+ ["rev-parse", "--is-inside-work-tree"],
2046
+ text=True,
2047
+ )
2048
+ if (
2049
+ inside_worktree is None
2050
+ or inside_worktree.returncode != 0
2051
+ or inside_worktree.stdout.strip().casefold() != "true"
2052
+ ):
2053
+ return ExistingTestEditsState(available=False)
2054
+
2055
+ requested_base = str(base_ref or "").strip()
2056
+ head = _run_git_capture(
2057
+ root,
2058
+ ["rev-parse", "--verify", requested_base or "HEAD"],
2059
+ text=True,
2060
+ )
2061
+ if head is None or head.returncode != 0:
2062
+ return ExistingTestEditsState(available=False)
2063
+
2064
+ diff = _run_git_capture(
2065
+ root,
2066
+ [
2067
+ "diff",
2068
+ "--numstat",
2069
+ "--no-renames",
2070
+ "--diff-filter=MDTUXB",
2071
+ "-z",
2072
+ requested_base or "HEAD",
2073
+ "--",
2074
+ ],
2075
+ )
2076
+ if diff is None or diff.returncode != 0:
2077
+ return ExistingTestEditsState(available=False)
2078
+
2079
+ stdout = diff.stdout
2080
+ raw_stdout = (
2081
+ stdout.encode("utf-8", errors="surrogateescape")
2082
+ if isinstance(stdout, str)
2083
+ else (stdout or b"")
2084
+ )
2085
+ edits: list[ExistingTestEdit] = []
2086
+ for raw_record in raw_stdout.split(b"\0"):
2087
+ if not raw_record:
2088
+ continue
2089
+ parts = raw_record.split(b"\t", 2)
2090
+ if len(parts) != 3:
2091
+ continue
2092
+ added_raw, deleted_raw, path_raw = parts
2093
+ path = _normalize_path(path_raw.decode("utf-8", errors="surrogateescape").strip('"'))
2094
+ if not is_existing_test_layout_path(path):
2095
+ continue
2096
+
2097
+ def _line_count(value: bytes) -> int | None:
2098
+ try:
2099
+ return int(value)
2100
+ except ValueError:
2101
+ return None
2102
+
2103
+ edits.append(
2104
+ ExistingTestEdit(
2105
+ path=path,
2106
+ added_lines=_line_count(added_raw),
2107
+ deleted_lines=_line_count(deleted_raw),
2108
+ )
2109
+ )
2110
+
2111
+ return ExistingTestEditsState(
2112
+ available=True,
2113
+ edits=tuple(sorted(edits, key=lambda edit: edit.path)),
2114
+ )
2115
+
2116
+
2117
+ def restore_existing_test_paths(
2118
+ root: Path,
2119
+ *,
2120
+ base_ref: str,
2121
+ paths: Collection[str],
2122
+ ) -> bool:
2123
+ """Restore selected tracked test paths from a verified base commit."""
2124
+
2125
+ normalized_paths = sorted(
2126
+ {
2127
+ normalized
2128
+ for raw_path in paths
2129
+ if (normalized := _normalize_path(str(raw_path)).rstrip("/"))
2130
+ and is_existing_test_layout_path(normalized)
2131
+ }
2132
+ )
2133
+ requested_base = str(base_ref or "").strip()
2134
+ if not requested_base or not normalized_paths or shutil.which("git") is None:
2135
+ return False
2136
+ verified_base = _run_git_capture(
2137
+ root,
2138
+ ["rev-parse", "--verify", f"{requested_base}^{{commit}}"],
2139
+ text=True,
2140
+ )
2141
+ if verified_base is None or verified_base.returncode != 0:
2142
+ return False
2143
+ restore = _run_git_capture(
2144
+ root,
2145
+ ["checkout", verified_base.stdout.strip(), "--", *normalized_paths],
2146
+ text=True,
2147
+ )
2148
+ return restore is not None and restore.returncode == 0