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,2217 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ import hashlib
5
+ import json
6
+ import re
7
+ import shlex
8
+ from collections.abc import Callable
9
+ from dataclasses import dataclass, field
10
+ from enum import StrEnum
11
+ from pathlib import Path, PurePosixPath, PureWindowsPath
12
+ from typing import Any
13
+
14
+ from ..config import normalize_verify_command_list
15
+ from ..repo_scan import RepoScanResult
16
+ from ..runtime_artifacts import is_runtime_artifact_path
17
+ from ..verification_command_analysis import (
18
+ CheckerEntrypointFingerprint,
19
+ analyze_verification_command,
20
+ )
21
+ from .turn_contract import (
22
+ MAX_EXPECTATIONS,
23
+ MIN_EXPECTED_OUTPUT_LITERAL_LEN,
24
+ Expectation,
25
+ ExpectationKind,
26
+ )
27
+ from .verification_commands import _matching_effective_verification_commands
28
+
29
+
30
+ class AcceptanceCriterionKind(StrEnum):
31
+ REQUIRED_ARTIFACT_PATH = "required_artifact_path"
32
+ CONTENT_FORMAT_SCHEMA = "content_format_schema"
33
+ EXPLICIT_COMMAND_IO = "explicit_command_io"
34
+ FUNCTIONAL_API_PROTOCOL = "functional_api_protocol"
35
+ PERSISTENT_SERVICE = "persistent_service"
36
+ DEPENDENCY_VERSION = "dependency_version"
37
+ PRESERVATION_UNCHANGED_PATH = "preservation_unchanged_path"
38
+ THRESHOLD = "threshold"
39
+ EXPLICIT_HOST_USER_VERIFICATION_COMMAND = "explicit_host_user_verification_command"
40
+ PREEXISTING_REPO_CHECK_SURFACE = "preexisting_repo_check_surface"
41
+ REFERENCE_PATH = "reference_path"
42
+
43
+
44
+ class AcceptanceCriterionStatus(StrEnum):
45
+ UNVERIFIED = "UNVERIFIED"
46
+ PASSED = "PASSED"
47
+ FAILED = "FAILED"
48
+ BLOCKED = "BLOCKED"
49
+ NOT_APPLICABLE = "NOT_APPLICABLE"
50
+
51
+
52
+ class AcceptanceCriterionSource(StrEnum):
53
+ USER_INSTRUCTION = "user_instruction"
54
+ TASK_BRIEF = "task_brief"
55
+ PLANNING_CONSTRAINT = "planning_constraint"
56
+ HOST_VERIFICATION = "host_verification"
57
+ REPO_SCAN = "repo_scan"
58
+
59
+
60
+ class EvidenceOrigin(StrEnum):
61
+ HOST_AUTHORITATIVE = "HOST_AUTHORITATIVE"
62
+ USER_EXPLICIT = "USER_EXPLICIT"
63
+ PREEXISTING_REPO_NATIVE = "PREEXISTING_REPO_NATIVE"
64
+ PREEXISTING_TASK_CHECKER = "PREEXISTING_TASK_CHECKER"
65
+ DIRECT_BLACK_BOX = "DIRECT_BLACK_BOX"
66
+ SELF_AUTHORED = "SELF_AUTHORED"
67
+ AD_HOC_OBSERVATION = "AD_HOC_OBSERVATION"
68
+
69
+
70
+ class AcceptancePathKind(StrEnum):
71
+ WORKSPACE_RELATIVE = "WORKSPACE_RELATIVE"
72
+ ABSOLUTE_WITHIN_WORKSPACE = "ABSOLUTE_WITHIN_WORKSPACE"
73
+ ABSOLUTE_EXTERNAL = "ABSOLUTE_EXTERNAL"
74
+ UNRESOLVED = "UNRESOLVED"
75
+
76
+
77
+ class AcceptancePathRole(StrEnum):
78
+ REQUIRED_OUTPUT = "required_output"
79
+ EXISTING_INPUT = "existing_input"
80
+ PRESERVATION_TARGET = "preservation_target"
81
+ VERIFICATION_CHECKER = "verification_checker"
82
+ UNKNOWN_REFERENCE = "unknown_reference"
83
+
84
+
85
+ class AcceptanceCriterionConfidence(StrEnum):
86
+ AUTHORITATIVE = "AUTHORITATIVE"
87
+ EXPLICIT = "EXPLICIT"
88
+ DERIVED_HIGH_CONFIDENCE = "DERIVED_HIGH_CONFIDENCE"
89
+ HEURISTIC = "HEURISTIC"
90
+
91
+
92
+ class AcceptanceCriterionEnforcement(StrEnum):
93
+ HARD = "HARD"
94
+ ADVISORY = "ADVISORY"
95
+
96
+
97
+ @dataclass(frozen=True)
98
+ class AcceptancePathRef:
99
+ raw_text: str
100
+ display_path: str
101
+ path_kind: AcceptancePathKind
102
+ role: AcceptancePathRole
103
+ workspace_relative_path: str = ""
104
+ absolute_path: str = ""
105
+ clause: str = ""
106
+
107
+ def as_payload(self) -> dict[str, Any]:
108
+ return {
109
+ "raw_text": self.raw_text,
110
+ "display_path": self.display_path,
111
+ "path_kind": self.path_kind.value,
112
+ "role": self.role.value,
113
+ "workspace_relative_path": self.workspace_relative_path,
114
+ "absolute_path": self.absolute_path,
115
+ "clause": self.clause,
116
+ }
117
+
118
+
119
+ @dataclass(frozen=True)
120
+ class AcceptanceThreshold:
121
+ metric: str
122
+ operator: str
123
+ value: float
124
+ unit: str = ""
125
+
126
+ def as_payload(self) -> dict[str, Any]:
127
+ return {
128
+ "metric": self.metric,
129
+ "operator": self.operator,
130
+ "value": self.value,
131
+ "unit": self.unit,
132
+ }
133
+
134
+
135
+ @dataclass
136
+ class AcceptanceEvidence:
137
+ evidence_id: str
138
+ origin: EvidenceOrigin
139
+ summary: str
140
+ passed: bool | None = None
141
+ command: str = ""
142
+ paths: tuple[str, ...] = tuple()
143
+ criterion_ids: tuple[str, ...] = tuple()
144
+ category: str = ""
145
+
146
+ def as_payload(self) -> dict[str, Any]:
147
+ return {
148
+ "id": self.evidence_id,
149
+ "origin": self.origin.value,
150
+ "summary": self.summary,
151
+ "passed": self.passed,
152
+ "command": self.command,
153
+ "paths": list(self.paths),
154
+ "criterion_ids": list(self.criterion_ids),
155
+ "category": self.category,
156
+ }
157
+
158
+
159
+ @dataclass
160
+ class AcceptanceCriterion:
161
+ criterion_id: str
162
+ kind: AcceptanceCriterionKind
163
+ source: AcceptanceCriterionSource | str
164
+ description: str
165
+ paths: tuple[str, ...] = tuple()
166
+ path_refs: tuple[AcceptancePathRef, ...] = tuple()
167
+ commands: tuple[str, ...] = tuple()
168
+ ports: tuple[int, ...] = tuple()
169
+ thresholds: tuple[AcceptanceThreshold, ...] = tuple()
170
+ required: bool = True
171
+ status: AcceptanceCriterionStatus = AcceptanceCriterionStatus.UNVERIFIED
172
+ evidence_ids: list[str] = field(default_factory=list)
173
+ service_ids: list[str] = field(default_factory=list)
174
+ failure_summary: str = ""
175
+ required_for_finalization: bool = True
176
+ confidence: AcceptanceCriterionConfidence = AcceptanceCriterionConfidence.EXPLICIT
177
+ enforcement: AcceptanceCriterionEnforcement = AcceptanceCriterionEnforcement.HARD
178
+
179
+ def add_evidence(
180
+ self,
181
+ evidence_id: str,
182
+ *,
183
+ status: AcceptanceCriterionStatus | None = None,
184
+ summary: str = "",
185
+ ) -> None:
186
+ if evidence_id not in self.evidence_ids:
187
+ self.evidence_ids.append(evidence_id)
188
+ if status is not None:
189
+ self.status = status
190
+ if summary:
191
+ self.failure_summary = summary
192
+
193
+ def as_payload(self) -> dict[str, Any]:
194
+ return {
195
+ "id": self.criterion_id,
196
+ "kind": self.kind.value,
197
+ "source": str(getattr(self.source, "value", self.source)),
198
+ "description": self.description,
199
+ "paths": list(self.paths),
200
+ "path_refs": [path_ref.as_payload() for path_ref in self.path_refs],
201
+ "commands": list(self.commands),
202
+ "ports": list(self.ports),
203
+ "thresholds": [threshold.as_payload() for threshold in self.thresholds],
204
+ "required": self.required,
205
+ "status": self.status.value,
206
+ "evidence_ids": list(self.evidence_ids),
207
+ "service_ids": list(self.service_ids),
208
+ "failure_summary": self.failure_summary,
209
+ "required_for_finalization": self.required_for_finalization,
210
+ "confidence": self.confidence.value,
211
+ "enforcement": self.enforcement.value,
212
+ }
213
+
214
+
215
+ @dataclass(frozen=True)
216
+ class AcceptanceWorkspaceSnapshot:
217
+ preexisting_paths: frozenset[str] = frozenset()
218
+ preexisting_test_paths: frozenset[str] = frozenset()
219
+ preexisting_checker_paths: frozenset[str] = frozenset()
220
+ preexisting_checker_fingerprints: tuple[CheckerEntrypointFingerprint, ...] = tuple()
221
+ preexisting_verify_commands: tuple[str, ...] = tuple()
222
+
223
+ def as_payload(self) -> dict[str, Any]:
224
+ return {
225
+ "preexisting_paths": sorted(self.preexisting_paths)[:200],
226
+ "preexisting_test_paths": sorted(self.preexisting_test_paths)[:200],
227
+ "preexisting_checker_paths": sorted(self.preexisting_checker_paths)[:200],
228
+ "preexisting_checker_fingerprints": [
229
+ item.as_payload() for item in self.preexisting_checker_fingerprints[:200]
230
+ ],
231
+ "preexisting_verify_commands": list(self.preexisting_verify_commands),
232
+ }
233
+
234
+
235
+ @dataclass
236
+ class AcceptanceContract:
237
+ criteria: list[AcceptanceCriterion] = field(default_factory=list)
238
+ evidence: list[AcceptanceEvidence] = field(default_factory=list)
239
+ snapshot: AcceptanceWorkspaceSnapshot = field(default_factory=AcceptanceWorkspaceSnapshot)
240
+ allowed_output_paths: set[str] = field(default_factory=set)
241
+ path_refs: list[AcceptancePathRef] = field(default_factory=list)
242
+ # Turn-contract v2 (step 4): concrete, checkable expectations extracted from the
243
+ # task text (expected-output literals, named loci, named behaviors). Empty is
244
+ # valid; the completion gate demands a disposition per expectation.
245
+ expectations: list[Expectation] = field(default_factory=list)
246
+
247
+ def next_evidence_id(self) -> str:
248
+ return f"ev{len(self.evidence) + 1:03d}"
249
+
250
+ def add_evidence(
251
+ self,
252
+ *,
253
+ origin: EvidenceOrigin,
254
+ summary: str,
255
+ passed: bool | None = None,
256
+ command: str = "",
257
+ paths: tuple[str, ...] = tuple(),
258
+ criterion_ids: tuple[str, ...] = tuple(),
259
+ category: str = "",
260
+ ) -> AcceptanceEvidence:
261
+ evidence = AcceptanceEvidence(
262
+ evidence_id=self.next_evidence_id(),
263
+ origin=origin,
264
+ summary=summary,
265
+ passed=passed,
266
+ command=command,
267
+ paths=tuple(paths),
268
+ criterion_ids=tuple(criterion_ids),
269
+ category=category,
270
+ )
271
+ self.evidence.append(evidence)
272
+ return evidence
273
+
274
+ def required_criteria(self) -> list[AcceptanceCriterion]:
275
+ return [
276
+ criterion
277
+ for criterion in self.criteria
278
+ if criterion.required and criterion.required_for_finalization
279
+ ]
280
+
281
+ def status_counts(self) -> dict[str, int]:
282
+ counts = {status.value: 0 for status in AcceptanceCriterionStatus}
283
+ for criterion in self.criteria:
284
+ counts[criterion.status.value] = counts.get(criterion.status.value, 0) + 1
285
+ return counts
286
+
287
+ def problem_names(self) -> list[str]:
288
+ problems: list[str] = []
289
+ required = self.required_criteria()
290
+ if any(item.status == AcceptanceCriterionStatus.FAILED for item in required):
291
+ problems.append("acceptance_criteria_failed")
292
+ if any(item.status == AcceptanceCriterionStatus.BLOCKED for item in required):
293
+ problems.append("acceptance_evidence_insufficient")
294
+ if any(
295
+ item.status == AcceptanceCriterionStatus.UNVERIFIED
296
+ and item.kind != AcceptanceCriterionKind.PREEXISTING_REPO_CHECK_SURFACE
297
+ for item in required
298
+ ):
299
+ problems.append("acceptance_criteria_unverified")
300
+ if any(
301
+ item.status == AcceptanceCriterionStatus.FAILED
302
+ and item.kind == AcceptanceCriterionKind.PRESERVATION_UNCHANGED_PATH
303
+ for item in required
304
+ ):
305
+ problems.append("unexpected_scope_changes")
306
+ return list(dict.fromkeys(problems))
307
+
308
+ def failure_summaries(self) -> list[str]:
309
+ return [
310
+ f"{criterion.criterion_id}: {criterion.failure_summary or criterion.description}"
311
+ for criterion in self.required_criteria()
312
+ if criterion.status
313
+ in {
314
+ AcceptanceCriterionStatus.FAILED,
315
+ AcceptanceCriterionStatus.BLOCKED,
316
+ AcceptanceCriterionStatus.UNVERIFIED,
317
+ }
318
+ ]
319
+
320
+ def as_payload(self) -> dict[str, Any]:
321
+ return {
322
+ "criteria": [criterion.as_payload() for criterion in self.criteria],
323
+ "evidence": [evidence.as_payload() for evidence in self.evidence[-20:]],
324
+ "snapshot": self.snapshot.as_payload(),
325
+ "allowed_output_paths": sorted(self.allowed_output_paths),
326
+ "path_refs": [path_ref.as_payload() for path_ref in self.path_refs],
327
+ "expectations": [expectation.as_payload() for expectation in self.expectations],
328
+ "status_counts": self.status_counts(),
329
+ "problems": self.problem_names(),
330
+ "failure_summaries": self.failure_summaries()[:10],
331
+ }
332
+
333
+
334
+ _BACKTICK_COMMAND_RE = re.compile(r"`([^`\n]+)`")
335
+ _PATH_RE = re.compile(
336
+ r"(?<![\w.-])("
337
+ r"(?:[A-Za-z]:[\\/]|[\\/]{1,2}|\.{1,2}[\\/])?"
338
+ r"(?:[A-Za-z0-9_.-]+[\\/])+[A-Za-z0-9_.-]+(?:\.[A-Za-z0-9_.-]+)?|"
339
+ r"(?:\.{1,2}[\\/])?[A-Za-z0-9_.-]+\."
340
+ r"(?:py|js|ts|tsx|jsx|json|toml|yaml|yml|txt|md|html|css|csv|xml|sql|sh|go|rs|java|rb|php|out|expected|actual|bin)"
341
+ r")(?=$|[\s,;:!?\]\)}]|[.](?:\s|$))"
342
+ )
343
+ _PORT_RE = re.compile(r"\bport\s+([1-9][0-9]{1,4})\b", re.I)
344
+ _THRESHOLD_RE = re.compile(
345
+ r"\b(?P<metric>accuracy|score|coverage|latency|runtime|time|duration|size|memory|throughput|performance)\b"
346
+ r"[^.\n]{0,80}?"
347
+ r"(?P<op>>=|<=|>|<|at least|at most|under|below|above|over|less than|more than)\s*"
348
+ r"(?P<value>[0-9]+(?:\.[0-9]+)?)\s*(?P<unit>%|ms|s|sec|seconds|mb|kb|fps|x)?",
349
+ re.I,
350
+ )
351
+ _NUMBER_RE = re.compile(r"[-+]?[0-9]+(?:\.[0-9]+)?")
352
+ _FORMAT_RE = re.compile(r"\b(json|yaml|csv|xml|html|markdown|schema|format)\b", re.I)
353
+ _EXPLICIT_FORMAT_RE = re.compile(
354
+ r"\b(?:valid|well-formed|as|in|format(?:ted)?\s+as|schema(?:\s+of)?|must\s+be|should\s+be)\s+"
355
+ r"(json|yaml|csv|xml|html|markdown)\b|"
356
+ r"\b(json|yaml|csv|xml|html|markdown)\s+(?:format|schema|file|document|output)\b",
357
+ re.I,
358
+ )
359
+ _SERVICE_RE = re.compile(
360
+ r"\b(?:keep|remain|stay)\s+(?:the\s+)?(?:server|service|process|daemon)?\s*running\b|"
361
+ r"\b(?:persistent|background)\s+(?:server|service|process|daemon)\b|"
362
+ r"\blisten(?:ing)?\s+on\s+port\b",
363
+ re.I,
364
+ )
365
+ _PRESERVE_RE = re.compile(
366
+ r"\b(?:do\s+not|don't|dont|without|never|leave|keep)\s+"
367
+ r"(?:modify|change|touch|overwrite|alter|remove|delete|unchanged)\b[^\n]*",
368
+ re.I,
369
+ )
370
+ _ONLY_RE = re.compile(r"\b(?:only|just)\s+(?:write|create|modify|change|touch)\b[^.\n]*", re.I)
371
+ _OUTPUT_ROLE_RE = re.compile(
372
+ r"\b(?:save|write|create|produce|output|generate|emit|export|move\s+to|put\s+in|store)\b",
373
+ re.I,
374
+ )
375
+ _INPUT_ROLE_RE = re.compile(
376
+ r"\b(?:read\s+from|input|initial|reference|source|given\s+at|provided\s+at|load\s+from|using)\b",
377
+ re.I,
378
+ )
379
+ _PRESERVATION_ROLE_RE = re.compile(
380
+ r"\b(?:do\s+not|don't|dont|without|never|leave|keep|preserve)\b"
381
+ r"[^;\n]{0,80}?"
382
+ r"\b(?:modify|change|touch|overwrite|alter|remove|delete|unchanged|intact)\b|"
383
+ r"\b(?:preserve|keep)\b[^;\n]{0,80}?\b(?:unchanged|intact)\b",
384
+ re.I,
385
+ )
386
+ _CHECKER_ROLE_RE = re.compile(
387
+ r"\b(?:test|check|verify|validator|validation|compare|diff|cmp)\b", re.I
388
+ )
389
+ _COMMAND_INTRO_RE = re.compile(
390
+ r"\b(?:run|execute|test\s+with|verify\s+with|validate\s+with|check\s+with|install\s+with|"
391
+ r"using\s+command|command|shell|terminal|bash)\b",
392
+ re.I,
393
+ )
394
+ _PYTHON_SNIPPET_INTRO_RE = re.compile(
395
+ r"\b(?:python|py)\b.{0,40}\b(?:snippet|code|validation|check|assert|execute|run)\b|"
396
+ r"\b(?:snippet|code|validation|check|assert|execute|run)\b.{0,40}\b(?:python|py)\b",
397
+ re.I,
398
+ )
399
+ _COMMAND_HEADS = {
400
+ "bash",
401
+ "cargo",
402
+ "cmp",
403
+ "curl",
404
+ "diff",
405
+ "go",
406
+ "just",
407
+ "make",
408
+ "node",
409
+ "npm",
410
+ "pnpm",
411
+ "pytest",
412
+ "python",
413
+ "python3",
414
+ "py",
415
+ "sh",
416
+ "uv",
417
+ "yarn",
418
+ }
419
+ _CHECK_PATH_MARKERS = {"check", "checks", "test", "tests", "verify", "validation", "validator"}
420
+
421
+
422
+ def build_acceptance_contract(
423
+ *,
424
+ root: Path,
425
+ instruction: str,
426
+ authoritative_verification_commands: list[str] | None = None,
427
+ effective_verification_commands: list[str] | None = None,
428
+ task_brief: str = "",
429
+ repo_scan: RepoScanResult | None = None,
430
+ planning_constraints: Any | None = None,
431
+ ) -> AcceptanceContract:
432
+ snapshot = capture_acceptance_workspace_snapshot(
433
+ root=root,
434
+ repo_scan=repo_scan,
435
+ authoritative_verification_commands=authoritative_verification_commands,
436
+ effective_verification_commands=effective_verification_commands,
437
+ )
438
+ texts = [str(instruction or "").strip(), str(task_brief or "").strip()]
439
+ texts = [item for item in texts if item]
440
+ criteria: list[AcceptanceCriterion] = []
441
+ allowed_output_paths: set[str] = set()
442
+ path_refs = _extract_path_refs(root=root, texts=texts)
443
+
444
+ for path_ref in path_refs:
445
+ if path_ref.role == AcceptancePathRole.REQUIRED_OUTPUT:
446
+ if path_ref.workspace_relative_path:
447
+ allowed_output_paths.add(path_ref.workspace_relative_path)
448
+ criteria.append(
449
+ _criterion(
450
+ criteria,
451
+ kind=AcceptanceCriterionKind.REQUIRED_ARTIFACT_PATH,
452
+ source=AcceptanceCriterionSource.USER_INSTRUCTION,
453
+ description=f"Required output path: {path_ref.display_path}",
454
+ paths=_legacy_paths_from_refs((path_ref,)),
455
+ path_refs=(path_ref,),
456
+ confidence=AcceptanceCriterionConfidence.EXPLICIT,
457
+ enforcement=AcceptanceCriterionEnforcement.HARD,
458
+ )
459
+ )
460
+ elif path_ref.role == AcceptancePathRole.PRESERVATION_TARGET:
461
+ criteria.append(
462
+ _criterion(
463
+ criteria,
464
+ kind=AcceptanceCriterionKind.PRESERVATION_UNCHANGED_PATH,
465
+ source=AcceptanceCriterionSource.USER_INSTRUCTION,
466
+ description=f"Preserve unchanged: {path_ref.display_path}",
467
+ paths=_legacy_paths_from_refs((path_ref,)),
468
+ path_refs=(path_ref,),
469
+ confidence=AcceptanceCriterionConfidence.EXPLICIT,
470
+ enforcement=AcceptanceCriterionEnforcement.HARD,
471
+ )
472
+ )
473
+ elif path_ref.role in {
474
+ AcceptancePathRole.EXISTING_INPUT,
475
+ AcceptancePathRole.VERIFICATION_CHECKER,
476
+ AcceptancePathRole.UNKNOWN_REFERENCE,
477
+ }:
478
+ criteria.append(
479
+ _criterion(
480
+ criteria,
481
+ kind=AcceptanceCriterionKind.REFERENCE_PATH,
482
+ source=AcceptanceCriterionSource.USER_INSTRUCTION,
483
+ description=f"Path reference: {path_ref.display_path}",
484
+ paths=_legacy_paths_from_refs((path_ref,)),
485
+ path_refs=(path_ref,),
486
+ confidence=AcceptanceCriterionConfidence.HEURISTIC,
487
+ enforcement=AcceptanceCriterionEnforcement.ADVISORY,
488
+ )
489
+ )
490
+
491
+ for path_ref, fmt, explicit in _extract_path_scoped_formats(path_refs, texts=texts):
492
+ confidence = (
493
+ AcceptanceCriterionConfidence.EXPLICIT
494
+ if explicit
495
+ else AcceptanceCriterionConfidence.HEURISTIC
496
+ )
497
+ enforcement = (
498
+ AcceptanceCriterionEnforcement.HARD
499
+ if explicit and path_ref.role == AcceptancePathRole.REQUIRED_OUTPUT
500
+ else AcceptanceCriterionEnforcement.ADVISORY
501
+ )
502
+ criteria.append(
503
+ _criterion(
504
+ criteria,
505
+ kind=AcceptanceCriterionKind.CONTENT_FORMAT_SCHEMA,
506
+ source=AcceptanceCriterionSource.USER_INSTRUCTION,
507
+ description=f"{fmt.upper()} format requirement for {path_ref.display_path}",
508
+ paths=_legacy_paths_from_refs((path_ref,)),
509
+ path_refs=(path_ref,),
510
+ confidence=confidence,
511
+ enforcement=enforcement,
512
+ )
513
+ )
514
+
515
+ for command in _extract_explicit_commands(texts):
516
+ criteria.append(
517
+ _criterion(
518
+ criteria,
519
+ kind=AcceptanceCriterionKind.EXPLICIT_COMMAND_IO,
520
+ source=AcceptanceCriterionSource.USER_INSTRUCTION,
521
+ description=f"Explicit command must pass: {command}",
522
+ commands=(command,),
523
+ confidence=AcceptanceCriterionConfidence.EXPLICIT,
524
+ enforcement=AcceptanceCriterionEnforcement.HARD,
525
+ )
526
+ )
527
+
528
+ for threshold in _extract_thresholds(texts):
529
+ criteria.append(
530
+ _criterion(
531
+ criteria,
532
+ kind=AcceptanceCriterionKind.THRESHOLD,
533
+ source=AcceptanceCriterionSource.USER_INSTRUCTION,
534
+ description=(
535
+ f"Threshold: {threshold.metric} {threshold.operator} "
536
+ f"{threshold.value:g}{threshold.unit}"
537
+ ),
538
+ thresholds=(threshold,),
539
+ confidence=AcceptanceCriterionConfidence.EXPLICIT,
540
+ enforcement=AcceptanceCriterionEnforcement.HARD,
541
+ )
542
+ )
543
+
544
+ for port in _extract_ports(texts):
545
+ criteria.append(
546
+ _criterion(
547
+ criteria,
548
+ kind=AcceptanceCriterionKind.FUNCTIONAL_API_PROTOCOL,
549
+ source=AcceptanceCriterionSource.USER_INSTRUCTION,
550
+ description=f"Protocol/API behavior on port {port}",
551
+ ports=(port,),
552
+ confidence=AcceptanceCriterionConfidence.EXPLICIT,
553
+ enforcement=AcceptanceCriterionEnforcement.HARD,
554
+ )
555
+ )
556
+
557
+ if any(_SERVICE_RE.search(text) for text in texts):
558
+ criteria.append(
559
+ _criterion(
560
+ criteria,
561
+ kind=AcceptanceCriterionKind.PERSISTENT_SERVICE,
562
+ source=AcceptanceCriterionSource.USER_INSTRUCTION,
563
+ description="Persistent service must survive finalization",
564
+ ports=tuple(_extract_ports(texts)),
565
+ confidence=AcceptanceCriterionConfidence.EXPLICIT,
566
+ enforcement=AcceptanceCriterionEnforcement.HARD,
567
+ )
568
+ )
569
+
570
+ if any(_ONLY_RE.search(text) for text in texts) and allowed_output_paths:
571
+ criteria.append(
572
+ _criterion(
573
+ criteria,
574
+ kind=AcceptanceCriterionKind.PRESERVATION_UNCHANGED_PATH,
575
+ source=AcceptanceCriterionSource.USER_INSTRUCTION,
576
+ description="No unexpected material paths outside requested outputs",
577
+ paths=tuple(sorted(allowed_output_paths)),
578
+ path_refs=tuple(
579
+ ref
580
+ for ref in path_refs
581
+ if ref.role == AcceptancePathRole.REQUIRED_OUTPUT
582
+ and ref.workspace_relative_path in allowed_output_paths
583
+ ),
584
+ confidence=AcceptanceCriterionConfidence.EXPLICIT,
585
+ enforcement=AcceptanceCriterionEnforcement.HARD,
586
+ )
587
+ )
588
+
589
+ for command in normalize_verify_command_list(authoritative_verification_commands or []):
590
+ criteria.append(
591
+ _criterion(
592
+ criteria,
593
+ kind=AcceptanceCriterionKind.EXPLICIT_HOST_USER_VERIFICATION_COMMAND,
594
+ source=AcceptanceCriterionSource.HOST_VERIFICATION,
595
+ description=f"Host verification command must pass: {command}",
596
+ commands=(command,),
597
+ confidence=AcceptanceCriterionConfidence.AUTHORITATIVE,
598
+ enforcement=AcceptanceCriterionEnforcement.HARD,
599
+ )
600
+ )
601
+
602
+ for command in normalize_verify_command_list(effective_verification_commands or []):
603
+ if command in {item for criterion in criteria for item in criterion.commands}:
604
+ continue
605
+ source = (
606
+ AcceptanceCriterionSource.REPO_SCAN
607
+ if command in snapshot.preexisting_verify_commands
608
+ else AcceptanceCriterionSource.HOST_VERIFICATION
609
+ )
610
+ criteria.append(
611
+ _criterion(
612
+ criteria,
613
+ kind=AcceptanceCriterionKind.PREEXISTING_REPO_CHECK_SURFACE,
614
+ source=source,
615
+ description=f"Pre-existing verification surface: {command}",
616
+ commands=(command,),
617
+ confidence=AcceptanceCriterionConfidence.DERIVED_HIGH_CONFIDENCE,
618
+ enforcement=AcceptanceCriterionEnforcement.ADVISORY,
619
+ )
620
+ )
621
+
622
+ for item in _planning_constraint_criteria(criteria, planning_constraints):
623
+ criteria.append(item)
624
+
625
+ residual = _residual_functional_requirement(texts)
626
+ if residual:
627
+ criteria.append(
628
+ _criterion(
629
+ criteria,
630
+ kind=AcceptanceCriterionKind.FUNCTIONAL_API_PROTOCOL,
631
+ source=AcceptanceCriterionSource.USER_INSTRUCTION,
632
+ description=f"Functional requirement context: {residual}",
633
+ confidence=AcceptanceCriterionConfidence.HEURISTIC,
634
+ enforcement=AcceptanceCriterionEnforcement.ADVISORY,
635
+ )
636
+ )
637
+
638
+ return AcceptanceContract(
639
+ criteria=_dedupe_criteria(criteria),
640
+ snapshot=snapshot,
641
+ allowed_output_paths=allowed_output_paths,
642
+ path_refs=path_refs,
643
+ expectations=extract_task_expectations(texts=texts, path_refs=path_refs),
644
+ )
645
+
646
+
647
+ def capture_acceptance_workspace_snapshot(
648
+ *,
649
+ root: Path,
650
+ repo_scan: RepoScanResult | None = None,
651
+ authoritative_verification_commands: list[str] | None = None,
652
+ effective_verification_commands: list[str] | None = None,
653
+ ) -> AcceptanceWorkspaceSnapshot:
654
+ preexisting_paths: set[str] = set()
655
+ preexisting_test_paths: set[str] = set()
656
+ preexisting_checker_paths: set[str] = set()
657
+ if repo_scan is not None:
658
+ for raw_path in [
659
+ *(str(item.get("path") or "") for item in repo_scan.manifests),
660
+ *repo_scan.readme_paths,
661
+ *repo_scan.observed_paths,
662
+ ]:
663
+ path = _normalize_rel_path(raw_path)
664
+ if path:
665
+ preexisting_paths.add(path)
666
+ if _is_test_or_checker_path(path):
667
+ preexisting_test_paths.add(path)
668
+ root = root.resolve()
669
+ visited = 0
670
+ for candidate in _iter_bounded_existing_paths(root):
671
+ visited += 1
672
+ if visited > 600:
673
+ break
674
+ preexisting_paths.add(candidate)
675
+ if _is_test_or_checker_path(candidate):
676
+ preexisting_test_paths.add(candidate)
677
+ if _is_checker_path(candidate):
678
+ preexisting_checker_paths.add(candidate)
679
+ for command in normalize_verify_command_list(
680
+ [
681
+ *normalize_verify_command_list(authoritative_verification_commands or []),
682
+ *normalize_verify_command_list(effective_verification_commands or []),
683
+ ]
684
+ ):
685
+ analysis = analyze_verification_command(command, trusted=True, workspace_root=root)
686
+ for path in analysis.checker_entrypoint_paths:
687
+ normalized_path = _normalize_rel_path(path)
688
+ if normalized_path and (root / normalized_path).exists():
689
+ preexisting_checker_paths.add(normalized_path)
690
+ preexisting_test_paths.add(normalized_path)
691
+ preexisting_paths.add(normalized_path)
692
+ checker_fingerprints = tuple(
693
+ _fingerprint_checker_path(root=root, relpath=path)
694
+ for path in sorted(preexisting_checker_paths)
695
+ )
696
+ return AcceptanceWorkspaceSnapshot(
697
+ preexisting_paths=frozenset(preexisting_paths),
698
+ preexisting_test_paths=frozenset(preexisting_test_paths),
699
+ preexisting_checker_paths=frozenset(preexisting_checker_paths),
700
+ preexisting_checker_fingerprints=checker_fingerprints,
701
+ preexisting_verify_commands=tuple(
702
+ normalize_verify_command_list(
703
+ repo_scan.likely_test_commands if repo_scan is not None else []
704
+ )
705
+ ),
706
+ )
707
+
708
+
709
+ def record_acceptance_tool_effect(
710
+ *,
711
+ contract: AcceptanceContract | None,
712
+ root: Path,
713
+ tool_name: str,
714
+ arguments: dict[str, Any],
715
+ status: str,
716
+ result: dict[str, Any],
717
+ touched_paths: set[str],
718
+ known_verification_commands: list[str] | None = None,
719
+ verification_authoritative: bool = False,
720
+ evidence_category: str = "",
721
+ evidence_allowed: bool | None = None,
722
+ ) -> None:
723
+ if contract is None:
724
+ return
725
+ normalized_tool = str(tool_name or "").strip().lower()
726
+ command = _observed_command(tool_name=normalized_tool, arguments=arguments, result=result)
727
+ command_passed = _command_passed(status=status, result=result)
728
+ origin = classify_evidence_origin(
729
+ contract=contract,
730
+ root=root,
731
+ command=command,
732
+ touched_paths=touched_paths,
733
+ known_verification_commands=known_verification_commands,
734
+ verification_authoritative=verification_authoritative,
735
+ )
736
+ criterion_ids: list[str] = []
737
+ if command:
738
+ criterion_ids.extend(
739
+ _update_command_and_threshold_criteria(
740
+ contract=contract,
741
+ command=command,
742
+ output=_tool_output(result),
743
+ passed=command_passed,
744
+ origin=origin,
745
+ )
746
+ )
747
+ criterion_ids.extend(
748
+ _update_path_criteria(
749
+ contract=contract,
750
+ root=root,
751
+ touched_paths=touched_paths,
752
+ status=status,
753
+ )
754
+ )
755
+ if normalized_tool in {
756
+ "shell_service_start",
757
+ "shell_service_status",
758
+ "workspace_preview_start",
759
+ }:
760
+ criterion_ids.extend(
761
+ _update_durable_service_criteria(
762
+ contract=contract,
763
+ result=result,
764
+ )
765
+ )
766
+ elif normalized_tool == "shell_background":
767
+ # persist=true routes shell_background through the durable-service
768
+ # manager, so its result carries durable ownership and is durable
769
+ # evidence. A plain background start produces no such payload and is
770
+ # still blocked exactly as before.
771
+ durable_criterion_ids = _update_durable_service_criteria(
772
+ contract=contract,
773
+ result=result,
774
+ )
775
+ criterion_ids.extend(
776
+ durable_criterion_ids
777
+ if durable_criterion_ids
778
+ else _block_session_owned_service_criteria(contract=contract)
779
+ )
780
+ if not criterion_ids and normalized_tool in {"verify_run", "shell_run"}:
781
+ criterion_ids.extend(
782
+ _update_repo_surface_criteria(
783
+ contract=contract,
784
+ command=command,
785
+ passed=command_passed,
786
+ evidence_allowed=evidence_allowed,
787
+ known_verification_commands=known_verification_commands,
788
+ origin=origin,
789
+ )
790
+ )
791
+ if command or touched_paths or criterion_ids:
792
+ evidence = contract.add_evidence(
793
+ origin=origin,
794
+ summary=_evidence_summary(command=command, touched_paths=touched_paths),
795
+ passed=command_passed,
796
+ command=command,
797
+ paths=tuple(sorted(touched_paths)),
798
+ criterion_ids=tuple(sorted(set(criterion_ids))),
799
+ category=evidence_category,
800
+ )
801
+ for criterion in contract.criteria:
802
+ if criterion.criterion_id in criterion_ids and evidence.evidence_id not in (
803
+ criterion.evidence_ids
804
+ ):
805
+ criterion.evidence_ids.append(evidence.evidence_id)
806
+
807
+
808
+ def finalize_acceptance_contract(
809
+ *,
810
+ contract: AcceptanceContract | None,
811
+ root: Path,
812
+ touched_paths: set[str],
813
+ durable_service_status: Callable[[str], dict[str, Any]] | None = None,
814
+ ) -> None:
815
+ if contract is None:
816
+ return
817
+ for criterion in contract.criteria:
818
+ if criterion.kind == AcceptanceCriterionKind.REQUIRED_ARTIFACT_PATH:
819
+ missing = _missing_required_output_paths(criterion=criterion, root=root)
820
+ if missing:
821
+ criterion.status = AcceptanceCriterionStatus.UNVERIFIED
822
+ criterion.failure_summary = "Required output path is missing: " + ", ".join(missing)
823
+ elif criterion.status == AcceptanceCriterionStatus.UNVERIFIED:
824
+ criterion.status = AcceptanceCriterionStatus.PASSED
825
+ elif criterion.kind == AcceptanceCriterionKind.PRESERVATION_UNCHANGED_PATH:
826
+ if "outside requested outputs" in criterion.description.casefold():
827
+ unexpected = [
828
+ path
829
+ for path in sorted(touched_paths)
830
+ if _path_is_material_for_scope(path, root=root)
831
+ and not _path_matches_any(path, criterion.paths)
832
+ ]
833
+ if unexpected:
834
+ criterion.status = AcceptanceCriterionStatus.FAILED
835
+ criterion.failure_summary = "Unexpected material path changed: " + ", ".join(
836
+ unexpected[:8]
837
+ )
838
+ elif criterion.status == AcceptanceCriterionStatus.UNVERIFIED:
839
+ criterion.status = AcceptanceCriterionStatus.PASSED
840
+ else:
841
+ changed = [
842
+ path
843
+ for path in sorted(touched_paths)
844
+ if _path_matches_any(path, _workspace_paths_for_criterion(criterion))
845
+ ]
846
+ if changed:
847
+ criterion.status = AcceptanceCriterionStatus.FAILED
848
+ criterion.failure_summary = "Preservation path changed: " + ", ".join(changed)
849
+ elif criterion.status == AcceptanceCriterionStatus.UNVERIFIED:
850
+ criterion.status = AcceptanceCriterionStatus.PASSED
851
+ elif criterion.kind == AcceptanceCriterionKind.PERSISTENT_SERVICE:
852
+ if criterion.service_ids:
853
+ _finalize_persistent_service_criterion(
854
+ criterion=criterion,
855
+ durable_service_status=durable_service_status,
856
+ )
857
+ elif criterion.status == AcceptanceCriterionStatus.PASSED:
858
+ criterion.status = AcceptanceCriterionStatus.BLOCKED
859
+ criterion.failure_summary = (
860
+ "Same-session service evidence is not durable-service evidence"
861
+ )
862
+ elif (
863
+ criterion.kind == AcceptanceCriterionKind.FUNCTIONAL_API_PROTOCOL
864
+ and criterion.service_ids
865
+ ):
866
+ _finalize_persistent_service_criterion(
867
+ criterion=criterion,
868
+ durable_service_status=durable_service_status,
869
+ )
870
+ elif criterion.kind == AcceptanceCriterionKind.CONTENT_FORMAT_SCHEMA:
871
+ _finalize_content_format_criterion(criterion=criterion, root=root)
872
+
873
+
874
+ def classify_evidence_origin(
875
+ *,
876
+ contract: AcceptanceContract,
877
+ command: str,
878
+ touched_paths: set[str],
879
+ root: Path | None = None,
880
+ known_verification_commands: list[str] | None = None,
881
+ verification_authoritative: bool = False,
882
+ ) -> EvidenceOrigin:
883
+ command = _normalize_command(command)
884
+ if command and _command_references_mutable_preexisting_checker(
885
+ command,
886
+ contract=contract,
887
+ root=root,
888
+ ):
889
+ return EvidenceOrigin.SELF_AUTHORED
890
+ if command and verification_authoritative:
891
+ matches = _matching_effective_verification_commands(
892
+ observed_command=command,
893
+ effective_verification_commands=known_verification_commands,
894
+ )
895
+ if matches:
896
+ return EvidenceOrigin.HOST_AUTHORITATIVE
897
+ if command and any(
898
+ _commands_equivalent(command, candidate)
899
+ for criterion in contract.criteria
900
+ if criterion.kind == AcceptanceCriterionKind.EXPLICIT_COMMAND_IO
901
+ for candidate in criterion.commands
902
+ ):
903
+ return EvidenceOrigin.USER_EXPLICIT
904
+ if touched_paths and any(
905
+ _is_self_authored_check_path(path, contract) for path in touched_paths
906
+ ):
907
+ return EvidenceOrigin.SELF_AUTHORED
908
+ if command and _command_references_self_authored_check(command, contract):
909
+ return EvidenceOrigin.SELF_AUTHORED
910
+ if command and _is_direct_black_box_command(command):
911
+ return EvidenceOrigin.DIRECT_BLACK_BOX
912
+ if command and _command_references_preexisting_checker(command, contract):
913
+ return EvidenceOrigin.PREEXISTING_TASK_CHECKER
914
+ if command and (
915
+ command in contract.snapshot.preexisting_verify_commands
916
+ or bool(
917
+ _matching_effective_verification_commands(
918
+ observed_command=command,
919
+ effective_verification_commands=contract.snapshot.preexisting_verify_commands,
920
+ )
921
+ )
922
+ ):
923
+ return EvidenceOrigin.PREEXISTING_REPO_NATIVE
924
+ return EvidenceOrigin.AD_HOC_OBSERVATION
925
+
926
+
927
+ def acceptance_contract_problem_payload(contract: AcceptanceContract | None) -> dict[str, Any]:
928
+ if contract is None:
929
+ return {
930
+ "acceptance_status_counts": {},
931
+ "acceptance_problems": [],
932
+ "acceptance_failure_summaries": [],
933
+ }
934
+ return {
935
+ "acceptance_status_counts": contract.status_counts(),
936
+ "acceptance_problems": contract.problem_names(),
937
+ "acceptance_failure_summaries": contract.failure_summaries()[:10],
938
+ "acceptance_contract": contract.as_payload(),
939
+ }
940
+
941
+
942
+ def _criterion(
943
+ existing: list[AcceptanceCriterion],
944
+ *,
945
+ kind: AcceptanceCriterionKind,
946
+ source: AcceptanceCriterionSource | str,
947
+ description: str,
948
+ paths: tuple[str, ...] = tuple(),
949
+ path_refs: tuple[AcceptancePathRef, ...] = tuple(),
950
+ commands: tuple[str, ...] = tuple(),
951
+ ports: tuple[int, ...] = tuple(),
952
+ thresholds: tuple[AcceptanceThreshold, ...] = tuple(),
953
+ required: bool | None = None,
954
+ required_for_finalization: bool | None = None,
955
+ confidence: AcceptanceCriterionConfidence = AcceptanceCriterionConfidence.EXPLICIT,
956
+ enforcement: AcceptanceCriterionEnforcement = AcceptanceCriterionEnforcement.HARD,
957
+ ) -> AcceptanceCriterion:
958
+ is_hard = enforcement == AcceptanceCriterionEnforcement.HARD
959
+ resolved_required = is_hard if required is None else bool(required)
960
+ resolved_required_for_finalization = (
961
+ is_hard if required_for_finalization is None else bool(required_for_finalization)
962
+ )
963
+ return AcceptanceCriterion(
964
+ criterion_id=f"ac{len(existing) + 1:03d}",
965
+ kind=kind,
966
+ source=source,
967
+ description=" ".join(str(description or "").split())[:500],
968
+ paths=tuple(_normalize_rel_path(path) for path in paths if _normalize_rel_path(path)),
969
+ path_refs=tuple(path_refs),
970
+ commands=tuple(_normalize_command(command) for command in commands if command.strip()),
971
+ ports=tuple(ports),
972
+ thresholds=tuple(thresholds),
973
+ required=resolved_required,
974
+ required_for_finalization=resolved_required_for_finalization,
975
+ confidence=confidence,
976
+ enforcement=enforcement,
977
+ )
978
+
979
+
980
+ def _dedupe_criteria(criteria: list[AcceptanceCriterion]) -> list[AcceptanceCriterion]:
981
+ seen: set[tuple[Any, ...]] = set()
982
+ out: list[AcceptanceCriterion] = []
983
+ for criterion in criteria:
984
+ key = (
985
+ criterion.kind.value,
986
+ criterion.description.casefold(),
987
+ criterion.paths,
988
+ criterion.commands,
989
+ criterion.ports,
990
+ tuple(
991
+ (item.metric, item.operator, item.value, item.unit) for item in criterion.thresholds
992
+ ),
993
+ )
994
+ if key in seen:
995
+ continue
996
+ seen.add(key)
997
+ criterion.criterion_id = f"ac{len(out) + 1:03d}"
998
+ out.append(criterion)
999
+ return out
1000
+
1001
+
1002
+ def _iter_clauses(texts: list[str]) -> list[str]:
1003
+ clauses: list[str] = []
1004
+ boundary = re.compile(
1005
+ r"\n+|;|,(?=\s*(?:and\s+)?(?:save|write|create|produce|output|generate|emit|move|"
1006
+ r"read|input|initial|reference|source|given|do\s+not|don't|dont|without|never|"
1007
+ r"leave|keep|preserve|run|verify|check)\b)|"
1008
+ r"\band\s+(?=(?:save|write|create|produce|output|generate|emit|move|read|"
1009
+ r"input|initial|reference|source|given|do\s+not|don't|dont|without|never|"
1010
+ r"leave|keep|preserve|run|verify|check)\b)|"
1011
+ r"(?<=[.!?])\s+(?=[A-Z])",
1012
+ re.I,
1013
+ )
1014
+ for text in texts:
1015
+ for clause in boundary.split(str(text or "")):
1016
+ normalized = " ".join(clause.split())
1017
+ if normalized:
1018
+ clauses.append(normalized)
1019
+ return clauses
1020
+
1021
+
1022
+ def _clause_path_role(clause: str) -> AcceptancePathRole:
1023
+ if _PRESERVATION_ROLE_RE.search(clause):
1024
+ return AcceptancePathRole.PRESERVATION_TARGET
1025
+ if _OUTPUT_ROLE_RE.search(clause):
1026
+ return AcceptancePathRole.REQUIRED_OUTPUT
1027
+ if _INPUT_ROLE_RE.search(clause):
1028
+ return AcceptancePathRole.EXISTING_INPUT
1029
+ if _CHECKER_ROLE_RE.search(clause):
1030
+ return AcceptancePathRole.VERIFICATION_CHECKER
1031
+ return AcceptancePathRole.UNKNOWN_REFERENCE
1032
+
1033
+
1034
+ def _clean_path_token(path: str) -> str:
1035
+ cleaned = str(path or "").strip().strip("`'\"").replace("\\", "/")
1036
+ cleaned = cleaned.rstrip(".,;:!?)]}")
1037
+ while cleaned.startswith("./"):
1038
+ cleaned = cleaned[2:]
1039
+ return cleaned
1040
+
1041
+
1042
+ def _extract_path_refs(*, root: Path, texts: list[str]) -> list[AcceptancePathRef]:
1043
+ refs: list[AcceptancePathRef] = []
1044
+ seen: set[tuple[str, str, str]] = set()
1045
+ for clause in _iter_clauses(texts):
1046
+ role = _clause_path_role(clause)
1047
+ for match in _PATH_RE.finditer(clause):
1048
+ path_ref = _resolve_acceptance_path(
1049
+ root=root,
1050
+ raw_text=match.group(1),
1051
+ role=role,
1052
+ clause=clause,
1053
+ )
1054
+ if path_ref is None:
1055
+ continue
1056
+ if (
1057
+ path_ref.role == AcceptancePathRole.UNKNOWN_REFERENCE
1058
+ and not _looks_like_explicit_artifact_path(path_ref.display_path)
1059
+ and path_ref.path_kind != AcceptancePathKind.ABSOLUTE_EXTERNAL
1060
+ ):
1061
+ continue
1062
+ key = (
1063
+ path_ref.display_path.casefold(),
1064
+ path_ref.role.value,
1065
+ path_ref.path_kind.value,
1066
+ )
1067
+ if key in seen:
1068
+ continue
1069
+ seen.add(key)
1070
+ refs.append(path_ref)
1071
+ return refs[:40]
1072
+
1073
+
1074
+ def _resolve_acceptance_path(
1075
+ *,
1076
+ root: Path,
1077
+ raw_text: str,
1078
+ role: AcceptancePathRole,
1079
+ clause: str,
1080
+ ) -> AcceptancePathRef | None:
1081
+ cleaned = _clean_path_token(raw_text)
1082
+ if not cleaned or cleaned.startswith("-"):
1083
+ return None
1084
+ pure = PurePosixPath(cleaned)
1085
+ if ".." in pure.parts:
1086
+ return AcceptancePathRef(
1087
+ raw_text=raw_text,
1088
+ display_path=cleaned,
1089
+ path_kind=AcceptancePathKind.UNRESOLVED,
1090
+ role=role,
1091
+ clause=clause,
1092
+ )
1093
+ absolute = _classify_absolute_acceptance_path(cleaned=cleaned, root=root)
1094
+ if absolute is not None:
1095
+ rel, absolute_path = absolute
1096
+ if rel is None:
1097
+ return AcceptancePathRef(
1098
+ raw_text=raw_text,
1099
+ display_path=cleaned,
1100
+ path_kind=AcceptancePathKind.ABSOLUTE_EXTERNAL,
1101
+ role=role,
1102
+ absolute_path=absolute_path,
1103
+ clause=clause,
1104
+ )
1105
+ return AcceptancePathRef(
1106
+ raw_text=raw_text,
1107
+ display_path=rel or ".",
1108
+ path_kind=AcceptancePathKind.ABSOLUTE_WITHIN_WORKSPACE,
1109
+ role=role,
1110
+ workspace_relative_path=rel,
1111
+ absolute_path=absolute_path,
1112
+ clause=clause,
1113
+ )
1114
+ normalized = _normalize_rel_path(cleaned)
1115
+ if not normalized:
1116
+ return None
1117
+ candidate = Path(root).expanduser().resolve(strict=False) / normalized
1118
+ return AcceptancePathRef(
1119
+ raw_text=raw_text,
1120
+ display_path=normalized,
1121
+ path_kind=AcceptancePathKind.WORKSPACE_RELATIVE,
1122
+ role=role,
1123
+ workspace_relative_path=normalized,
1124
+ absolute_path=candidate.as_posix(),
1125
+ clause=clause,
1126
+ )
1127
+
1128
+
1129
+ def _classify_absolute_acceptance_path(
1130
+ *,
1131
+ cleaned: str,
1132
+ root: Path,
1133
+ ) -> tuple[str | None, str] | None:
1134
+ """Classify absolute paths without applying host-OS semantics to user text.
1135
+
1136
+ ``Path('/usr/local/bin/tool').resolve()`` on Windows silently prefixes the
1137
+ current drive, while ``Path('C:/workspace/file')`` is not absolute on POSIX.
1138
+ Acceptance paths can describe either style regardless of the host, so compare
1139
+ them with their matching pure-path model and preserve external spelling.
1140
+ """
1141
+
1142
+ root_text = _clean_path_token(str(root))
1143
+ windows_candidate = PureWindowsPath(cleaned)
1144
+ if windows_candidate.is_absolute() and windows_candidate.drive:
1145
+ absolute_path = windows_candidate.as_posix()
1146
+ windows_root = PureWindowsPath(root_text)
1147
+ if windows_root.is_absolute() and windows_root.drive:
1148
+ try:
1149
+ rel = windows_candidate.relative_to(windows_root).as_posix()
1150
+ except ValueError:
1151
+ rel = None
1152
+ return rel, absolute_path
1153
+ return None, absolute_path
1154
+
1155
+ if not cleaned.startswith("/"):
1156
+ return None
1157
+
1158
+ posix_candidate = PurePosixPath(cleaned)
1159
+ posix_root = PurePosixPath(root_text)
1160
+ absolute_path = posix_candidate.as_posix()
1161
+ if posix_root.is_absolute():
1162
+ try:
1163
+ rel = posix_candidate.relative_to(posix_root).as_posix()
1164
+ except ValueError:
1165
+ rel = None
1166
+ return rel, absolute_path
1167
+ return None, absolute_path
1168
+
1169
+
1170
+ def _legacy_paths_from_refs(path_refs: tuple[AcceptancePathRef, ...]) -> tuple[str, ...]:
1171
+ paths: list[str] = []
1172
+ for path_ref in path_refs:
1173
+ if path_ref.workspace_relative_path:
1174
+ paths.append(path_ref.workspace_relative_path)
1175
+ elif path_ref.path_kind == AcceptancePathKind.ABSOLUTE_EXTERNAL:
1176
+ paths.append(path_ref.display_path)
1177
+ return tuple(dict.fromkeys(paths))
1178
+
1179
+
1180
+ def _extract_path_scoped_formats(
1181
+ path_refs: list[AcceptancePathRef],
1182
+ *,
1183
+ texts: list[str],
1184
+ ) -> list[tuple[AcceptancePathRef, str, bool]]:
1185
+ out: list[tuple[AcceptancePathRef, str, bool]] = []
1186
+ seen: set[tuple[str, str]] = set()
1187
+ output_refs = [
1188
+ path_ref for path_ref in path_refs if path_ref.role == AcceptancePathRole.REQUIRED_OUTPUT
1189
+ ]
1190
+ extension_formats = {
1191
+ ".json": "json",
1192
+ ".yaml": "yaml",
1193
+ ".yml": "yaml",
1194
+ ".csv": "csv",
1195
+ ".xml": "xml",
1196
+ ".html": "html",
1197
+ ".md": "markdown",
1198
+ }
1199
+ for path_ref in path_refs:
1200
+ if path_ref.role != AcceptancePathRole.REQUIRED_OUTPUT:
1201
+ continue
1202
+ explicit_format = _explicit_format_for_clause(path_ref.clause)
1203
+ if explicit_format:
1204
+ key = (path_ref.display_path.casefold(), explicit_format)
1205
+ if key not in seen:
1206
+ seen.add(key)
1207
+ out.append((path_ref, explicit_format, True))
1208
+ continue
1209
+ suffix = PurePosixPath(path_ref.display_path).suffix.casefold()
1210
+ inferred = extension_formats.get(suffix)
1211
+ if inferred:
1212
+ key = (path_ref.display_path.casefold(), inferred)
1213
+ if key not in seen:
1214
+ seen.add(key)
1215
+ out.append((path_ref, inferred, False))
1216
+ if len(output_refs) == 1:
1217
+ for clause in _iter_clauses(texts):
1218
+ if _PATH_RE.search(clause):
1219
+ continue
1220
+ if not re.search(r"\b(?:output|result|artifact|file)\b", clause, re.I):
1221
+ continue
1222
+ explicit_format = _explicit_format_for_clause(clause)
1223
+ if not explicit_format:
1224
+ continue
1225
+ path_ref = output_refs[0]
1226
+ key = (path_ref.display_path.casefold(), explicit_format)
1227
+ if key not in seen:
1228
+ seen.add(key)
1229
+ out.append((path_ref, explicit_format, True))
1230
+ return out
1231
+
1232
+
1233
+ def _explicit_format_for_clause(clause: str) -> str:
1234
+ for match in _EXPLICIT_FORMAT_RE.finditer(clause or ""):
1235
+ value = (match.group(1) or match.group(2) or "").casefold()
1236
+ if value:
1237
+ return value
1238
+ return ""
1239
+
1240
+
1241
+ def _extract_paths(text: str) -> list[str]:
1242
+ out: list[str] = []
1243
+ seen: set[str] = set()
1244
+ for path_ref in _extract_path_refs(root=Path("."), texts=[text]):
1245
+ path = path_ref.workspace_relative_path or path_ref.display_path
1246
+ if not path or path.startswith("-") or ".." in PurePosixPath(path).parts:
1247
+ continue
1248
+ if not _looks_like_explicit_artifact_path(path):
1249
+ continue
1250
+ if path.casefold() in seen:
1251
+ continue
1252
+ seen.add(path.casefold())
1253
+ out.append(path)
1254
+ return out[:24]
1255
+
1256
+
1257
+ def _extract_explicit_commands(texts: list[str]) -> list[str]:
1258
+ commands: list[str] = []
1259
+ seen: set[str] = set()
1260
+ for text in texts:
1261
+ for match in _BACKTICK_COMMAND_RE.finditer(text):
1262
+ candidate = _normalize_command(match.group(1))
1263
+ context = str(text or "")[max(0, match.start() - 80) : match.start()]
1264
+ python_snippet = _python_interpreter_snippet_command(candidate, context=context)
1265
+ if python_snippet:
1266
+ candidate = python_snippet
1267
+ elif not candidate or not _looks_like_command(candidate, context=context):
1268
+ continue
1269
+ key = candidate.casefold()
1270
+ if key in seen:
1271
+ continue
1272
+ seen.add(key)
1273
+ commands.append(candidate)
1274
+ return commands[:8]
1275
+
1276
+
1277
+ def extract_explicit_acceptance_commands(*texts: str) -> list[str]:
1278
+ return _extract_explicit_commands([str(text or "") for text in texts if str(text or "")])
1279
+
1280
+
1281
+ # ---------------------------------------------------------------------------
1282
+ # Turn-contract v2 (step 4): expectation extraction.
1283
+ #
1284
+ # A zero-new-regex projection of the derivation's EXISTING extracted signals into
1285
+ # the contract-v2 expectations schema. Backtick literals the task shows as desired
1286
+ # output become ``expected_output`` expectations; files the task points at as the
1287
+ # fix site become ``named_locus`` expectations. This adds no new NL/keyword/regex
1288
+ # heuristic over task text — it reuses ``_BACKTICK_COMMAND_RE``, the existing
1289
+ # command/path classifiers, and the already-computed path refs. Precision over
1290
+ # recall: capped, deduped, and limited to signals a later mechanical check can
1291
+ # confirm (an ``expected_output`` literal observed in a run, or an edited locus).
1292
+ # ---------------------------------------------------------------------------
1293
+
1294
+ # A named locus is a file the task tells us to create or modify. UNKNOWN_REFERENCE
1295
+ # is deliberately excluded: the role classifier cannot tell "fix the bug in X"
1296
+ # (an edit site) from "count the lines in X" (a read-only input), so treating every
1297
+ # bare file mention as an edit target would spuriously demand editing inputs.
1298
+ # Precision over recall — a REQUIRED_OUTPUT path is unambiguously an edit target.
1299
+ _EXPECTATION_LOCUS_ROLES = {
1300
+ AcceptancePathRole.REQUIRED_OUTPUT,
1301
+ }
1302
+
1303
+
1304
+ def _expectation_span_is_path_like(span: str) -> bool:
1305
+ # A forward slash is the path separator used throughout; a bare backslash is
1306
+ # NOT treated as a path signal because LaTeX/math literals (``\dagger``) — a
1307
+ # common expected_output shape — carry backslashes. Windows-style ``dir\file``
1308
+ # paths still resolve via the explicit-artifact-path extension check below.
1309
+ token = span.strip().strip("`'\"")
1310
+ if "/" in token:
1311
+ return True
1312
+ return _looks_like_explicit_artifact_path(token)
1313
+
1314
+
1315
+ def _expectation_span_is_symbol_like(span: str) -> bool:
1316
+ token = span.strip()
1317
+ if not token or any(char.isspace() for char in token):
1318
+ return False
1319
+ core = token[:-2] if token.endswith("()") else token
1320
+ parts = [part for part in core.split(".") if part]
1321
+ return bool(parts) and all(part.isidentifier() for part in parts)
1322
+
1323
+
1324
+ def _expectation_source_window(text: str, start: int, end: int) -> str:
1325
+ window = str(text or "")[max(0, start - 60) : end + 60]
1326
+ return " ".join(window.split())[:280]
1327
+
1328
+
1329
+ def extract_task_expectations(
1330
+ *,
1331
+ texts: list[str],
1332
+ path_refs: list[AcceptancePathRef],
1333
+ ) -> list[Expectation]:
1334
+ """Extract concrete, checkable expectations from the task text (contract v2).
1335
+
1336
+ Empty is valid (many tasks name no such literal or locus). Deterministic.
1337
+ """
1338
+ expectations: list[Expectation] = []
1339
+ seen: set[tuple[str, str]] = set()
1340
+
1341
+ def _add(kind: ExpectationKind, raw_text: str, source_quote: str) -> None:
1342
+ text = str(raw_text or "").strip()
1343
+ if not text:
1344
+ return
1345
+ key = (kind.value, " ".join(text.split()).casefold())
1346
+ if key in seen:
1347
+ return
1348
+ seen.add(key)
1349
+ expectations.append(
1350
+ Expectation(
1351
+ expectation_id=f"exp{len(expectations) + 1:03d}",
1352
+ kind=kind,
1353
+ text=text,
1354
+ source_quote=" ".join(str(source_quote or "").split())[:280],
1355
+ )
1356
+ )
1357
+
1358
+ # named_locus: files the task points at as the fix site / subject of the change
1359
+ # (a create/modify target or a bare reference — not a read-only input, a
1360
+ # preservation target, or a checker path, which editing would not confirm).
1361
+ for path_ref in path_refs:
1362
+ if path_ref.role not in _EXPECTATION_LOCUS_ROLES:
1363
+ continue
1364
+ locus = path_ref.workspace_relative_path or path_ref.display_path
1365
+ _add(ExpectationKind.NAMED_LOCUS, locus, path_ref.clause)
1366
+
1367
+ # expected_output: inline backtick literals shown as desired output — excluding
1368
+ # commands (already command criteria), paths (already loci) and bare identifiers
1369
+ # (code references, never runtime output).
1370
+ for text in texts:
1371
+ for match in _BACKTICK_COMMAND_RE.finditer(str(text or "")):
1372
+ span = match.group(1).strip()
1373
+ if len(span) < MIN_EXPECTED_OUTPUT_LITERAL_LEN:
1374
+ continue
1375
+ context = str(text)[max(0, match.start() - 80) : match.start()]
1376
+ if _looks_like_command(span, context=context):
1377
+ continue
1378
+ if _expectation_span_is_path_like(span):
1379
+ continue
1380
+ if _expectation_span_is_symbol_like(span):
1381
+ continue
1382
+ _add(
1383
+ ExpectationKind.EXPECTED_OUTPUT,
1384
+ span,
1385
+ _expectation_source_window(text, match.start(), match.end()),
1386
+ )
1387
+
1388
+ return expectations[:MAX_EXPECTATIONS]
1389
+
1390
+
1391
+ def _extract_thresholds(texts: list[str]) -> list[AcceptanceThreshold]:
1392
+ thresholds: list[AcceptanceThreshold] = []
1393
+ for text in texts:
1394
+ for match in _THRESHOLD_RE.finditer(text):
1395
+ operator = match.group("op").casefold()
1396
+ operator = {
1397
+ "at least": ">=",
1398
+ "above": ">",
1399
+ "over": ">",
1400
+ "more than": ">",
1401
+ "at most": "<=",
1402
+ "under": "<",
1403
+ "below": "<",
1404
+ "less than": "<",
1405
+ }.get(operator, operator)
1406
+ thresholds.append(
1407
+ AcceptanceThreshold(
1408
+ metric=match.group("metric").casefold(),
1409
+ operator=operator,
1410
+ value=float(match.group("value")),
1411
+ unit=str(match.group("unit") or ""),
1412
+ )
1413
+ )
1414
+ return thresholds[:8]
1415
+
1416
+
1417
+ def _extract_ports(texts: list[str]) -> list[int]:
1418
+ ports: list[int] = []
1419
+ seen: set[int] = set()
1420
+ for text in texts:
1421
+ for match in _PORT_RE.finditer(text):
1422
+ port = int(match.group(1))
1423
+ if 0 < port <= 65535 and port not in seen:
1424
+ seen.add(port)
1425
+ ports.append(port)
1426
+ return ports[:8]
1427
+
1428
+
1429
+ def _extract_formats(texts: list[str]) -> list[str]:
1430
+ formats: list[str] = []
1431
+ seen: set[str] = set()
1432
+ for text in texts:
1433
+ for match in _FORMAT_RE.finditer(text):
1434
+ value = match.group(1).casefold()
1435
+ if value in seen:
1436
+ continue
1437
+ seen.add(value)
1438
+ formats.append(value)
1439
+ return formats[:8]
1440
+
1441
+
1442
+ def _residual_functional_requirement(texts: list[str]) -> str:
1443
+ combined = " ".join(" ".join(text.split()) for text in texts if text.strip())
1444
+ if not combined:
1445
+ return ""
1446
+ return combined[:280]
1447
+
1448
+
1449
+ def _path_appears_preserved(path: str, texts: list[str]) -> bool:
1450
+ for text in texts:
1451
+ for match in _PRESERVE_RE.finditer(text):
1452
+ if path in _extract_paths(match.group(0)):
1453
+ return True
1454
+ return False
1455
+
1456
+
1457
+ def _planning_constraint_criteria(
1458
+ existing: list[AcceptanceCriterion],
1459
+ planning_constraints: Any | None,
1460
+ ) -> list[AcceptanceCriterion]:
1461
+ out: list[AcceptanceCriterion] = []
1462
+ if planning_constraints is None:
1463
+ return out
1464
+ for attr in ("forbidden_roots", "decoy_roots", "unrelated_roots"):
1465
+ for item in getattr(planning_constraints, attr, ()) or ():
1466
+ path = _normalize_rel_path(str(getattr(item, "path", "") or ""))
1467
+ if not path:
1468
+ continue
1469
+ out.append(
1470
+ _criterion(
1471
+ [*existing, *out],
1472
+ kind=AcceptanceCriterionKind.PRESERVATION_UNCHANGED_PATH,
1473
+ source=AcceptanceCriterionSource.PLANNING_CONSTRAINT,
1474
+ description=f"Planning constraint preserves blocked scope: {path}",
1475
+ paths=(path,),
1476
+ )
1477
+ )
1478
+ return out
1479
+
1480
+
1481
+ def _iter_bounded_existing_paths(root: Path) -> list[str]:
1482
+ out: list[str] = []
1483
+ skip = {
1484
+ ".git",
1485
+ ".hg",
1486
+ ".svn",
1487
+ ".venv",
1488
+ "__pycache__",
1489
+ "node_modules",
1490
+ "target",
1491
+ ".pytest_cache",
1492
+ ".ruff_cache",
1493
+ }
1494
+ stack = [(root, 0)]
1495
+ while stack and len(out) < 600:
1496
+ current, depth = stack.pop()
1497
+ try:
1498
+ entries = sorted(current.iterdir(), key=lambda item: item.name)
1499
+ except OSError:
1500
+ continue
1501
+ for entry in entries:
1502
+ if entry.name in skip:
1503
+ continue
1504
+ try:
1505
+ rel = entry.relative_to(root).as_posix()
1506
+ except ValueError:
1507
+ continue
1508
+ out.append(rel)
1509
+ if entry.is_dir() and depth < 3:
1510
+ stack.append((entry, depth + 1))
1511
+ if len(out) >= 600:
1512
+ break
1513
+ return out
1514
+
1515
+
1516
+ def _normalize_rel_path(path: str) -> str:
1517
+ cleaned = str(path or "").strip().replace("\\", "/")
1518
+ cleaned = cleaned.rstrip(".,;:!?)]}")
1519
+ while cleaned.startswith("./"):
1520
+ cleaned = cleaned[2:]
1521
+ return cleaned
1522
+
1523
+
1524
+ def _looks_like_explicit_artifact_path(path: str) -> bool:
1525
+ pure = PurePosixPath(path)
1526
+ name = pure.name
1527
+ if not name:
1528
+ return False
1529
+ if "." in name:
1530
+ return True
1531
+ return name in {
1532
+ "Dockerfile",
1533
+ "Gemfile",
1534
+ "Makefile",
1535
+ "Procfile",
1536
+ "Rakefile",
1537
+ }
1538
+
1539
+
1540
+ def _normalize_command(command: str) -> str:
1541
+ return " ".join(str(command or "").strip().split())
1542
+
1543
+
1544
+ def _python_interpreter_snippet_command(command: str, *, context: str) -> str:
1545
+ if not _PYTHON_SNIPPET_INTRO_RE.search(context or ""):
1546
+ return ""
1547
+ if "\n" in command or "\r" in command:
1548
+ return ""
1549
+ lowered = command.casefold()
1550
+ if not (
1551
+ lowered.startswith(("from ", "import ", "assert "))
1552
+ or "; assert " in lowered
1553
+ or lowered.startswith(("print(", "raise "))
1554
+ ):
1555
+ return ""
1556
+ try:
1557
+ ast.parse(command, mode="exec")
1558
+ except SyntaxError:
1559
+ return ""
1560
+ return "python -c " + shlex.quote(command)
1561
+
1562
+
1563
+ def _looks_like_command(command: str, *, context: str = "") -> bool:
1564
+ try:
1565
+ parts = shlex.split(command)
1566
+ except ValueError:
1567
+ return False
1568
+ if not parts:
1569
+ return False
1570
+ if _COMMAND_INTRO_RE.search(context or ""):
1571
+ return True
1572
+ head = Path(parts[0]).name.casefold()
1573
+ if head in _COMMAND_HEADS:
1574
+ return True
1575
+ if "/" in parts[0] and not parts[0].startswith("-"):
1576
+ return True
1577
+ if parts[0].startswith("./") and len(parts) >= 1:
1578
+ return True
1579
+ return False
1580
+
1581
+
1582
+ def _is_test_or_checker_path(path: str) -> bool:
1583
+ pure = PurePosixPath(path)
1584
+ parts = {part.casefold() for part in pure.parts}
1585
+ name = pure.name.casefold()
1586
+ return (
1587
+ name.startswith("test_")
1588
+ or name.endswith("_test.py")
1589
+ or name.endswith(".test.js")
1590
+ or name.endswith(".spec.js")
1591
+ or bool(parts & _CHECK_PATH_MARKERS)
1592
+ )
1593
+
1594
+
1595
+ def _is_checker_path(path: str) -> bool:
1596
+ pure = PurePosixPath(path)
1597
+ parts = {part.casefold() for part in pure.parts}
1598
+ stem = pure.stem.casefold()
1599
+ return bool(parts & {"checks", "verify", "validation"}) or any(
1600
+ marker in stem for marker in ("check", "verify", "validate")
1601
+ )
1602
+
1603
+
1604
+ def _is_self_authored_check_path(path: str, contract: AcceptanceContract) -> bool:
1605
+ normalized = _normalize_rel_path(path)
1606
+ return _is_test_or_checker_path(normalized) and (
1607
+ normalized not in contract.snapshot.preexisting_test_paths
1608
+ and normalized not in contract.snapshot.preexisting_checker_paths
1609
+ )
1610
+
1611
+
1612
+ def _command_references_self_authored_check(command: str, contract: AcceptanceContract) -> bool:
1613
+ return any(
1614
+ _is_self_authored_check_path(path, contract) for path in _command_path_tokens(command)
1615
+ )
1616
+
1617
+
1618
+ def _command_references_preexisting_checker(command: str, contract: AcceptanceContract) -> bool:
1619
+ return any(
1620
+ path in contract.snapshot.preexisting_test_paths
1621
+ or path in contract.snapshot.preexisting_checker_paths
1622
+ for path in _command_path_tokens(command)
1623
+ )
1624
+
1625
+
1626
+ def _command_references_mutable_preexisting_checker(
1627
+ command: str,
1628
+ *,
1629
+ contract: AcceptanceContract,
1630
+ root: Path | None,
1631
+ ) -> bool:
1632
+ if root is None:
1633
+ return False
1634
+ fingerprints = {
1635
+ _normalize_rel_path(item.display_path): item
1636
+ for item in contract.snapshot.preexisting_checker_fingerprints
1637
+ }
1638
+ for path in _command_path_tokens(command):
1639
+ if path not in contract.snapshot.preexisting_checker_paths:
1640
+ continue
1641
+ before = fingerprints.get(path)
1642
+ if before is None:
1643
+ return True
1644
+ after = _fingerprint_checker_path(root=root, relpath=path)
1645
+ if (
1646
+ before.resolved_path != after.resolved_path
1647
+ or before.is_regular_file != after.is_regular_file
1648
+ or before.size != after.size
1649
+ or before.sha256 != after.sha256
1650
+ ):
1651
+ return True
1652
+ return False
1653
+
1654
+
1655
+ def _fingerprint_checker_path(
1656
+ *,
1657
+ root: Path,
1658
+ relpath: str,
1659
+ max_bytes: int = 2_000_000,
1660
+ ) -> CheckerEntrypointFingerprint:
1661
+ display_path = _normalize_rel_path(relpath)
1662
+ candidate = (root / display_path).resolve(strict=False)
1663
+ try:
1664
+ resolved = candidate.resolve(strict=True)
1665
+ stat = resolved.stat()
1666
+ except OSError:
1667
+ return CheckerEntrypointFingerprint(
1668
+ display_path=display_path,
1669
+ resolved_path=str(candidate),
1670
+ is_regular_file=False,
1671
+ size=None,
1672
+ sha256=None,
1673
+ )
1674
+ if not resolved.is_file() or stat.st_size > max_bytes:
1675
+ return CheckerEntrypointFingerprint(
1676
+ display_path=display_path,
1677
+ resolved_path=str(resolved),
1678
+ is_regular_file=resolved.is_file(),
1679
+ size=int(stat.st_size),
1680
+ sha256=None,
1681
+ )
1682
+ digest = hashlib.sha256()
1683
+ with resolved.open("rb") as handle:
1684
+ for chunk in iter(lambda: handle.read(65536), b""):
1685
+ digest.update(chunk)
1686
+ return CheckerEntrypointFingerprint(
1687
+ display_path=display_path,
1688
+ resolved_path=str(resolved),
1689
+ is_regular_file=True,
1690
+ size=int(stat.st_size),
1691
+ sha256=digest.hexdigest(),
1692
+ )
1693
+
1694
+
1695
+ def _command_path_tokens(command: str) -> list[str]:
1696
+ try:
1697
+ parts = shlex.split(command)
1698
+ except ValueError:
1699
+ return []
1700
+ return [
1701
+ _normalize_rel_path(part)
1702
+ for part in parts
1703
+ if "/" in part or "." in PurePosixPath(part).name
1704
+ ]
1705
+
1706
+
1707
+ def _is_direct_black_box_command(command: str) -> bool:
1708
+ try:
1709
+ parts = shlex.split(command)
1710
+ except ValueError:
1711
+ return False
1712
+ if not parts:
1713
+ return False
1714
+ return Path(parts[0]).name.casefold() in {"diff", "cmp", "curl"}
1715
+
1716
+
1717
+ def _observed_command(
1718
+ *,
1719
+ tool_name: str,
1720
+ arguments: dict[str, Any],
1721
+ result: dict[str, Any],
1722
+ ) -> str:
1723
+ if tool_name == "verify_run":
1724
+ commands = result.get("commands")
1725
+ if isinstance(commands, list) and len(commands) == 1:
1726
+ return _normalize_command(str(commands[0]))
1727
+ return ""
1728
+ if tool_name == "shell_run":
1729
+ return _normalize_command(str(result.get("effective_cmd") or arguments.get("cmd") or ""))
1730
+ return ""
1731
+
1732
+
1733
+ def _command_passed(*, status: str, result: dict[str, Any]) -> bool | None:
1734
+ if status == "failed":
1735
+ return False
1736
+ if "all_passed" in result:
1737
+ return bool(result.get("all_passed"))
1738
+ exit_code = result.get("exit_code")
1739
+ if isinstance(exit_code, int):
1740
+ return exit_code == 0
1741
+ return None
1742
+
1743
+
1744
+ def _tool_output(result: dict[str, Any]) -> str:
1745
+ parts = [
1746
+ str(result.get("stdout") or ""),
1747
+ str(result.get("stderr") or ""),
1748
+ str(result.get("output") or ""),
1749
+ str(result.get("output_preview") or ""),
1750
+ ]
1751
+ command_results = result.get("command_results")
1752
+ if isinstance(command_results, list):
1753
+ for item in command_results:
1754
+ if isinstance(item, dict):
1755
+ parts.append(str(item.get("output_preview") or item.get("output") or ""))
1756
+ return "\n".join(part for part in parts if part)
1757
+
1758
+
1759
+ def _update_command_and_threshold_criteria(
1760
+ *,
1761
+ contract: AcceptanceContract,
1762
+ command: str,
1763
+ output: str,
1764
+ passed: bool | None,
1765
+ origin: EvidenceOrigin,
1766
+ ) -> list[str]:
1767
+ matched: list[str] = []
1768
+ for criterion in contract.criteria:
1769
+ if criterion.kind == AcceptanceCriterionKind.EXPLICIT_COMMAND_IO and any(
1770
+ _commands_equivalent(command, candidate) for candidate in criterion.commands
1771
+ ):
1772
+ status = (
1773
+ AcceptanceCriterionStatus.PASSED
1774
+ if passed is True
1775
+ else AcceptanceCriterionStatus.FAILED
1776
+ if passed is False
1777
+ else AcceptanceCriterionStatus.UNVERIFIED
1778
+ )
1779
+ criterion.status = status
1780
+ if status == AcceptanceCriterionStatus.FAILED:
1781
+ criterion.failure_summary = f"Explicit command failed: {command}"
1782
+ matched.append(criterion.criterion_id)
1783
+ elif criterion.kind == AcceptanceCriterionKind.THRESHOLD:
1784
+ status, summary = _evaluate_thresholds(
1785
+ thresholds=criterion.thresholds,
1786
+ output=output,
1787
+ passed=passed,
1788
+ origin=origin,
1789
+ )
1790
+ if status is not None:
1791
+ _apply_status_from_evidence(
1792
+ criterion=criterion,
1793
+ status=status,
1794
+ summary=summary,
1795
+ origin=origin,
1796
+ )
1797
+ matched.append(criterion.criterion_id)
1798
+ elif criterion.kind == AcceptanceCriterionKind.PERSISTENT_SERVICE and passed is True:
1799
+ criterion.status = AcceptanceCriterionStatus.BLOCKED
1800
+ criterion.failure_summary = (
1801
+ "Same-session command succeeded but does not prove durable service lifetime"
1802
+ )
1803
+ matched.append(criterion.criterion_id)
1804
+ elif (
1805
+ criterion.kind == AcceptanceCriterionKind.REQUIRED_ARTIFACT_PATH
1806
+ and passed is True
1807
+ and origin
1808
+ in {
1809
+ EvidenceOrigin.HOST_AUTHORITATIVE,
1810
+ EvidenceOrigin.USER_EXPLICIT,
1811
+ EvidenceOrigin.PREEXISTING_TASK_CHECKER,
1812
+ EvidenceOrigin.DIRECT_BLACK_BOX,
1813
+ }
1814
+ and _external_or_unresolved_output_is_mentioned(
1815
+ criterion=criterion,
1816
+ command=command,
1817
+ output=output,
1818
+ )
1819
+ ):
1820
+ criterion.status = AcceptanceCriterionStatus.PASSED
1821
+ criterion.failure_summary = ""
1822
+ matched.append(criterion.criterion_id)
1823
+ return matched
1824
+
1825
+
1826
+ def _update_durable_service_criteria(
1827
+ *,
1828
+ contract: AcceptanceContract,
1829
+ result: dict[str, Any],
1830
+ ) -> list[str]:
1831
+ matched: list[str] = []
1832
+ service_id = str(result.get("service_id") or "").strip()
1833
+ durable_payload = str(result.get("ownership") or "") == "DURABLE_SERVICE" and service_id
1834
+ if not durable_payload:
1835
+ return matched
1836
+ for criterion in contract.criteria:
1837
+ if criterion.kind == AcceptanceCriterionKind.FUNCTIONAL_API_PROTOCOL:
1838
+ if not criterion.ports:
1839
+ continue
1840
+ elif criterion.kind != AcceptanceCriterionKind.PERSISTENT_SERVICE:
1841
+ continue
1842
+ if service_id not in criterion.service_ids:
1843
+ criterion.service_ids.append(service_id)
1844
+ if _durable_service_satisfies_criterion(criterion=criterion, payload=result):
1845
+ criterion.status = AcceptanceCriterionStatus.PASSED
1846
+ criterion.failure_summary = ""
1847
+ else:
1848
+ criterion.status = AcceptanceCriterionStatus.BLOCKED
1849
+ criterion.failure_summary = _durable_service_failure_summary(result)
1850
+ matched.append(criterion.criterion_id)
1851
+ return matched
1852
+
1853
+
1854
+ def _block_session_owned_service_criteria(*, contract: AcceptanceContract) -> list[str]:
1855
+ matched: list[str] = []
1856
+ for criterion in contract.criteria:
1857
+ if criterion.kind != AcceptanceCriterionKind.PERSISTENT_SERVICE:
1858
+ continue
1859
+ criterion.status = AcceptanceCriterionStatus.BLOCKED
1860
+ criterion.failure_summary = (
1861
+ "shell_background is session-owned and is reaped on AgentSession.close; "
1862
+ "use shell_service_start for durable-service evidence"
1863
+ )
1864
+ matched.append(criterion.criterion_id)
1865
+ return matched
1866
+
1867
+
1868
+ def _update_repo_surface_criteria(
1869
+ *,
1870
+ contract: AcceptanceContract,
1871
+ command: str,
1872
+ passed: bool | None,
1873
+ evidence_allowed: bool | None,
1874
+ known_verification_commands: list[str] | None,
1875
+ origin: EvidenceOrigin,
1876
+ ) -> list[str]:
1877
+ matched: list[str] = []
1878
+ if not command:
1879
+ return matched
1880
+ for criterion in contract.criteria:
1881
+ if criterion.kind not in {
1882
+ AcceptanceCriterionKind.PREEXISTING_REPO_CHECK_SURFACE,
1883
+ AcceptanceCriterionKind.EXPLICIT_HOST_USER_VERIFICATION_COMMAND,
1884
+ }:
1885
+ continue
1886
+ commands = criterion.commands or tuple(known_verification_commands or ())
1887
+ if not any(_commands_equivalent(command, candidate) for candidate in commands):
1888
+ continue
1889
+ if origin == EvidenceOrigin.SELF_AUTHORED:
1890
+ criterion.status = AcceptanceCriterionStatus.BLOCKED
1891
+ criterion.failure_summary = (
1892
+ "Self-authored or mutable_authoritative_checker evidence is supplemental "
1893
+ "for this criterion"
1894
+ )
1895
+ elif evidence_allowed is False:
1896
+ criterion.status = AcceptanceCriterionStatus.BLOCKED
1897
+ criterion.failure_summary = "Verification evidence was supplemental or unsafe"
1898
+ elif passed is True:
1899
+ criterion.status = AcceptanceCriterionStatus.PASSED
1900
+ elif passed is False:
1901
+ criterion.status = AcceptanceCriterionStatus.FAILED
1902
+ criterion.failure_summary = f"Verification command failed: {command}"
1903
+ matched.append(criterion.criterion_id)
1904
+ return matched
1905
+
1906
+
1907
+ def _workspace_paths_for_criterion(criterion: AcceptanceCriterion) -> tuple[str, ...]:
1908
+ paths: list[str] = []
1909
+ if criterion.path_refs:
1910
+ for path_ref in criterion.path_refs:
1911
+ if path_ref.workspace_relative_path:
1912
+ paths.append(path_ref.workspace_relative_path)
1913
+ return tuple(dict.fromkeys(paths))
1914
+ return tuple(path for path in criterion.paths if path and not path.startswith("/"))
1915
+
1916
+
1917
+ def _missing_required_output_paths(*, criterion: AcceptanceCriterion, root: Path) -> list[str]:
1918
+ missing: list[str] = []
1919
+ workspace_paths = _workspace_paths_for_criterion(criterion)
1920
+ for path in workspace_paths:
1921
+ if not (root / path).exists():
1922
+ missing.append(path)
1923
+ external_refs = [
1924
+ path_ref
1925
+ for path_ref in criterion.path_refs
1926
+ if path_ref.path_kind == AcceptancePathKind.ABSOLUTE_EXTERNAL
1927
+ and path_ref.role == AcceptancePathRole.REQUIRED_OUTPUT
1928
+ ]
1929
+ for path_ref in external_refs:
1930
+ if criterion.status != AcceptanceCriterionStatus.PASSED:
1931
+ missing.append(f"{path_ref.display_path} (external output requires trusted evidence)")
1932
+ unresolved_refs = [
1933
+ path_ref
1934
+ for path_ref in criterion.path_refs
1935
+ if path_ref.path_kind == AcceptancePathKind.UNRESOLVED
1936
+ and path_ref.role == AcceptancePathRole.REQUIRED_OUTPUT
1937
+ ]
1938
+ for path_ref in unresolved_refs:
1939
+ if criterion.status != AcceptanceCriterionStatus.PASSED:
1940
+ missing.append(f"{path_ref.display_path} (unresolved output path)")
1941
+ if not criterion.path_refs:
1942
+ missing.extend(path for path in criterion.paths if path and not (root / path).exists())
1943
+ return list(dict.fromkeys(missing))
1944
+
1945
+
1946
+ def _update_path_criteria(
1947
+ *,
1948
+ contract: AcceptanceContract,
1949
+ root: Path,
1950
+ touched_paths: set[str],
1951
+ status: str,
1952
+ ) -> list[str]:
1953
+ matched: list[str] = []
1954
+ for criterion in contract.criteria:
1955
+ if criterion.kind != AcceptanceCriterionKind.REQUIRED_ARTIFACT_PATH:
1956
+ continue
1957
+ paths = _workspace_paths_for_criterion(criterion)
1958
+ if not paths:
1959
+ continue
1960
+ if all((root / path).exists() for path in paths):
1961
+ criterion.status = AcceptanceCriterionStatus.PASSED
1962
+ matched.append(criterion.criterion_id)
1963
+ elif status == "failed" and any(_path_matches_any(path, paths) for path in touched_paths):
1964
+ criterion.status = AcceptanceCriterionStatus.FAILED
1965
+ criterion.failure_summary = "Attempted output path update failed"
1966
+ matched.append(criterion.criterion_id)
1967
+ return matched
1968
+
1969
+
1970
+ def _finalize_persistent_service_criterion(
1971
+ *,
1972
+ criterion: AcceptanceCriterion,
1973
+ durable_service_status: Callable[[str], dict[str, Any]] | None,
1974
+ ) -> None:
1975
+ if durable_service_status is None:
1976
+ criterion.status = AcceptanceCriterionStatus.BLOCKED
1977
+ criterion.failure_summary = "Durable service status recheck is unavailable"
1978
+ return
1979
+ failures: list[str] = []
1980
+ for service_id in list(dict.fromkeys(criterion.service_ids)):
1981
+ try:
1982
+ payload = durable_service_status(service_id)
1983
+ except Exception as exc: # noqa: BLE001
1984
+ failures.append(f"{service_id}: status recheck failed: {exc}")
1985
+ continue
1986
+ if _durable_service_satisfies_criterion(criterion=criterion, payload=payload):
1987
+ criterion.status = AcceptanceCriterionStatus.PASSED
1988
+ criterion.failure_summary = ""
1989
+ return
1990
+ failures.append(f"{service_id}: {_durable_service_failure_summary(payload)}")
1991
+ criterion.status = AcceptanceCriterionStatus.BLOCKED
1992
+ criterion.failure_summary = "Durable service readiness recheck failed: " + "; ".join(
1993
+ failures[:4]
1994
+ )
1995
+
1996
+
1997
+ def _durable_service_satisfies_criterion(
1998
+ *,
1999
+ criterion: AcceptanceCriterion,
2000
+ payload: dict[str, Any],
2001
+ ) -> bool:
2002
+ if str(payload.get("ownership") or "") != "DURABLE_SERVICE":
2003
+ return False
2004
+ if str(payload.get("status") or "").casefold() != "running":
2005
+ return False
2006
+ if payload.get("alive") is not True:
2007
+ return False
2008
+ readiness = payload.get("readiness") if isinstance(payload.get("readiness"), dict) else {}
2009
+ if str(readiness.get("status") or "").casefold() != "ready":
2010
+ return False
2011
+ if criterion.ports:
2012
+ if str(readiness.get("type") or "").casefold() != "tcp":
2013
+ return False
2014
+ try:
2015
+ port = int(readiness.get("port") or 0)
2016
+ except (TypeError, ValueError):
2017
+ return False
2018
+ if port not in set(criterion.ports):
2019
+ return False
2020
+ return True
2021
+
2022
+
2023
+ def _durable_service_failure_summary(payload: dict[str, Any]) -> str:
2024
+ service_id = str(payload.get("service_id") or "?")
2025
+ status = str(payload.get("status") or "?")
2026
+ alive = payload.get("alive")
2027
+ readiness = payload.get("readiness") if isinstance(payload.get("readiness"), dict) else {}
2028
+ readiness_status = str(readiness.get("status") or "?")
2029
+ readiness_type = str(readiness.get("type") or "?")
2030
+ detail = str(readiness.get("detail") or "").strip()
2031
+ suffix = f": {detail}" if detail else ""
2032
+ return (
2033
+ f"Durable service {service_id} is not ready "
2034
+ f"(status={status}, alive={alive}, readiness={readiness_type}/{readiness_status})"
2035
+ f"{suffix}"
2036
+ )
2037
+
2038
+
2039
+ def _evaluate_thresholds(
2040
+ *,
2041
+ thresholds: tuple[AcceptanceThreshold, ...],
2042
+ output: str,
2043
+ passed: bool | None,
2044
+ origin: EvidenceOrigin,
2045
+ ) -> tuple[AcceptanceCriterionStatus | None, str]:
2046
+ if not thresholds:
2047
+ return None, ""
2048
+ numbers = [float(match.group(0)) for match in _NUMBER_RE.finditer(output or "")]
2049
+ if not numbers:
2050
+ if passed is False:
2051
+ return AcceptanceCriterionStatus.FAILED, "Threshold command failed"
2052
+ return None, ""
2053
+ threshold = thresholds[0]
2054
+ measured = _median(numbers)
2055
+ if (
2056
+ threshold.metric in {"latency", "runtime", "time", "duration", "performance"}
2057
+ and len(numbers) < 2
2058
+ ):
2059
+ return (
2060
+ AcceptanceCriterionStatus.BLOCKED,
2061
+ "Performance threshold has insufficient repeated samples",
2062
+ )
2063
+ ok = _compare(measured, threshold.operator, threshold.value)
2064
+ if ok:
2065
+ if origin == EvidenceOrigin.SELF_AUTHORED:
2066
+ return (
2067
+ AcceptanceCriterionStatus.BLOCKED,
2068
+ "Self-authored threshold evidence is supplemental without independent coverage",
2069
+ )
2070
+ return AcceptanceCriterionStatus.PASSED, ""
2071
+ return (
2072
+ AcceptanceCriterionStatus.FAILED,
2073
+ f"Measured {threshold.metric} {measured:g}{threshold.unit} misses "
2074
+ f"{threshold.operator} {threshold.value:g}{threshold.unit}",
2075
+ )
2076
+
2077
+
2078
+ def _external_or_unresolved_output_is_mentioned(
2079
+ *,
2080
+ criterion: AcceptanceCriterion,
2081
+ command: str,
2082
+ output: str,
2083
+ ) -> bool:
2084
+ haystack = f"{command}\n{output}".casefold()
2085
+ for path_ref in criterion.path_refs:
2086
+ if path_ref.path_kind not in {
2087
+ AcceptancePathKind.ABSOLUTE_EXTERNAL,
2088
+ AcceptancePathKind.UNRESOLVED,
2089
+ }:
2090
+ continue
2091
+ if path_ref.display_path.casefold() in haystack or path_ref.raw_text.casefold() in haystack:
2092
+ return True
2093
+ return False
2094
+
2095
+
2096
+ def _compare(measured: float, operator: str, target: float) -> bool:
2097
+ if operator == ">=":
2098
+ return measured >= target
2099
+ if operator == ">":
2100
+ return measured > target
2101
+ if operator == "<=":
2102
+ return measured <= target
2103
+ if operator == "<":
2104
+ return measured < target
2105
+ return False
2106
+
2107
+
2108
+ def _median(values: list[float]) -> float:
2109
+ ordered = sorted(values)
2110
+ mid = len(ordered) // 2
2111
+ if len(ordered) % 2:
2112
+ return ordered[mid]
2113
+ return (ordered[mid - 1] + ordered[mid]) / 2.0
2114
+
2115
+
2116
+ def _apply_status_from_evidence(
2117
+ *,
2118
+ criterion: AcceptanceCriterion,
2119
+ status: AcceptanceCriterionStatus,
2120
+ summary: str,
2121
+ origin: EvidenceOrigin,
2122
+ ) -> None:
2123
+ if (
2124
+ criterion.status == AcceptanceCriterionStatus.FAILED
2125
+ and origin == EvidenceOrigin.SELF_AUTHORED
2126
+ and status != AcceptanceCriterionStatus.FAILED
2127
+ ):
2128
+ return
2129
+ criterion.status = status
2130
+ criterion.failure_summary = summary
2131
+
2132
+
2133
+ def _finalize_content_format_criterion(
2134
+ *,
2135
+ criterion: AcceptanceCriterion,
2136
+ root: Path,
2137
+ ) -> None:
2138
+ if not criterion.required:
2139
+ if criterion.status == AcceptanceCriterionStatus.UNVERIFIED:
2140
+ criterion.status = AcceptanceCriterionStatus.NOT_APPLICABLE
2141
+ return
2142
+ if not criterion.paths:
2143
+ criterion.status = AcceptanceCriterionStatus.BLOCKED
2144
+ criterion.failure_summary = "Format/schema requirement has no concrete output path"
2145
+ return
2146
+ paths = _workspace_paths_for_criterion(criterion)
2147
+ external = [
2148
+ path_ref.display_path
2149
+ for path_ref in criterion.path_refs
2150
+ if path_ref.path_kind == AcceptancePathKind.ABSOLUTE_EXTERNAL
2151
+ ]
2152
+ if external and not paths:
2153
+ if criterion.enforcement == AcceptanceCriterionEnforcement.HARD:
2154
+ criterion.status = AcceptanceCriterionStatus.BLOCKED
2155
+ criterion.failure_summary = (
2156
+ "External output format requires trusted command evidence: " + ", ".join(external)
2157
+ )
2158
+ elif criterion.status == AcceptanceCriterionStatus.UNVERIFIED:
2159
+ criterion.status = AcceptanceCriterionStatus.NOT_APPLICABLE
2160
+ return
2161
+ missing = [path for path in paths if not (root / path).exists()]
2162
+ if missing:
2163
+ criterion.status = AcceptanceCriterionStatus.UNVERIFIED
2164
+ criterion.failure_summary = "Format output path is missing: " + ", ".join(missing)
2165
+ return
2166
+ lowered = criterion.description.casefold()
2167
+ if "json" in lowered:
2168
+ invalid: list[str] = []
2169
+ for path in paths:
2170
+ candidate = root / path
2171
+ try:
2172
+ json.loads(candidate.read_text(encoding="utf-8"))
2173
+ except (OSError, json.JSONDecodeError):
2174
+ invalid.append(path)
2175
+ if invalid:
2176
+ criterion.status = AcceptanceCriterionStatus.FAILED
2177
+ criterion.failure_summary = "Invalid JSON output: " + ", ".join(invalid)
2178
+ return
2179
+ if criterion.status == AcceptanceCriterionStatus.UNVERIFIED:
2180
+ criterion.status = AcceptanceCriterionStatus.PASSED
2181
+
2182
+
2183
+ def _commands_equivalent(left: str, right: str) -> bool:
2184
+ left_norm = _normalize_command(left).casefold()
2185
+ right_norm = _normalize_command(right).casefold()
2186
+ if not left_norm or not right_norm:
2187
+ return False
2188
+ if left_norm == right_norm:
2189
+ return True
2190
+ return bool(
2191
+ _matching_effective_verification_commands(
2192
+ observed_command=left_norm,
2193
+ effective_verification_commands=[right_norm],
2194
+ )
2195
+ )
2196
+
2197
+
2198
+ def _evidence_summary(*, command: str, touched_paths: set[str]) -> str:
2199
+ if command:
2200
+ return f"Executed command: {command}"
2201
+ if touched_paths:
2202
+ return "Touched paths: " + ", ".join(sorted(touched_paths)[:8])
2203
+ return "Observed tool result"
2204
+
2205
+
2206
+ def _path_matches_any(path: str, roots: tuple[str, ...]) -> bool:
2207
+ normalized = _normalize_rel_path(path).casefold()
2208
+ for root in roots:
2209
+ root_norm = _normalize_rel_path(root).casefold()
2210
+ if normalized == root_norm or normalized.startswith(root_norm.rstrip("/") + "/"):
2211
+ return True
2212
+ return False
2213
+
2214
+
2215
+ def _path_is_material_for_scope(path: str, *, root: Path) -> bool:
2216
+ normalized = _normalize_rel_path(path)
2217
+ return bool(normalized) and not is_runtime_artifact_path(normalized, root=root)