booking-agent-cli 0.8.0__tar.gz → 0.9.0__tar.gz

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 (230) hide show
  1. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/PKG-INFO +2 -2
  2. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/README.md +1 -1
  3. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/pyproject.toml +1 -1
  4. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/src/booking_agent/__init__.py +1 -1
  5. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/src/booking_agent/backend/live.py +16 -6
  6. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/src/booking_agent/defaults.env +6 -0
  7. booking_agent_cli-0.9.0/src/booking_agent/faults.py +154 -0
  8. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/src/booking_agent/observability.py +11 -4
  9. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/src/booking_agent/ucp_profile.py +3 -2
  10. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/src/booking_agent_cli/__init__.py +1 -1
  11. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/src/booking_agent_cli/booking_summary.py +173 -0
  12. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/src/booking_agent_cli/bundled_skill.md +59 -58
  13. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/src/booking_agent_cli/calendar_event.py +4 -12
  14. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/src/booking_agent_cli/calendar_preference.py +9 -23
  15. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/src/booking_agent_cli/commands/flow.py +801 -107
  16. booking_agent_cli-0.9.0/src/booking_agent_cli/fault_report_schema.py +67 -0
  17. booking_agent_cli-0.9.0/src/booking_agent_cli/fault_reports.py +252 -0
  18. booking_agent_cli-0.9.0/src/booking_agent_cli/gates.py +82 -0
  19. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/src/booking_agent_cli/geo_eligibility.py +1 -1
  20. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/src/booking_agent_cli/identity.py +7 -0
  21. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/src/booking_agent_cli/main.py +15 -0
  22. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/src/booking_agent_cli/output.py +6 -0
  23. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/src/booking_agent_cli/session.py +58 -0
  24. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/src/booking_agent_cli/support_bundle_schema.py +3 -4
  25. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/src/booking_agent_cli/support_report.py +40 -8
  26. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/conftest.py +14 -0
  27. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/e2e/test_timezone_booking_e2e.py +6 -3
  28. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/e2e/test_ucp_skill_flow_e2e.py +40 -9
  29. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/integration/test_booking_rejection.py +11 -5
  30. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/integration/test_calendar_connector.py +1 -0
  31. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/integration/test_calendar_flow.py +37 -0
  32. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/integration/test_calendar_preference.py +5 -5
  33. booking_agent_cli-0.9.0/tests/integration/test_cli_enforced_gates.py +660 -0
  34. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/integration/test_declined_spt.py +1 -0
  35. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/integration/test_flow_gates.py +98 -69
  36. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/integration/test_home_visit_address.py +4 -3
  37. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/integration/test_install_integrity.py +2 -1
  38. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/integration/test_link_auth.py +34 -1
  39. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/integration/test_skill_integrity.py +2 -1
  40. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/integration/test_support_report.py +2 -2
  41. booking_agent_cli-0.9.0/tests/unit/test_fault_reports.py +275 -0
  42. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/.gitignore +0 -0
  43. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/src/booking_agent/authorization.py +0 -0
  44. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/src/booking_agent/backend/__init__.py +0 -0
  45. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/src/booking_agent/calendar/__init__.py +0 -0
  46. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/src/booking_agent/calendar/filter.py +0 -0
  47. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/src/booking_agent/calendar/google.py +0 -0
  48. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/src/booking_agent/calendar/oauth_listener.py +0 -0
  49. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/src/booking_agent/calendar/scope.py +0 -0
  50. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/src/booking_agent/circuit_breaker.py +0 -0
  51. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/src/booking_agent/config.py +0 -0
  52. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/src/booking_agent/discovery.py +0 -0
  53. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/src/booking_agent/headless.py +0 -0
  54. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/src/booking_agent/http_deadlines.py +0 -0
  55. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/src/booking_agent/http_retry.py +0 -0
  56. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/src/booking_agent/http_trace.py +0 -0
  57. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/src/booking_agent/merchant_pacing.py +0 -0
  58. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/src/booking_agent/models/__init__.py +0 -0
  59. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/src/booking_agent/models/platform.py +0 -0
  60. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/src/booking_agent/models/wire.py +0 -0
  61. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/src/booking_agent/namespace.py +0 -0
  62. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/src/booking_agent/platform_profile.py +0 -0
  63. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/src/booking_agent/pop.py +0 -0
  64. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/src/booking_agent/profile_cache.py +0 -0
  65. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/src/booking_agent/registry_url.py +0 -0
  66. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/src/booking_agent/rest_client.py +0 -0
  67. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/src/booking_agent/secure_file.py +0 -0
  68. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/src/booking_agent/settings.py +0 -0
  69. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/src/booking_agent/signals.py +0 -0
  70. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/src/booking_agent/step_timing.py +0 -0
  71. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/src/booking_agent/ucp_capabilities.py +0 -0
  72. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/src/booking_agent/ucp_checkout.py +0 -0
  73. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/src/booking_agent/ucp_negotiation.py +0 -0
  74. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/src/booking_agent/usp_methods.py +0 -0
  75. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/src/booking_agent_cli/activity_log.py +0 -0
  76. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/src/booking_agent_cli/booking_poll.py +0 -0
  77. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/src/booking_agent_cli/calendar_connection.py +0 -0
  78. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/src/booking_agent_cli/catalog.py +0 -0
  79. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/src/booking_agent_cli/checkout_prereq.py +0 -0
  80. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/src/booking_agent_cli/commands/__init__.py +0 -0
  81. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/src/booking_agent_cli/commands/config.py +0 -0
  82. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/src/booking_agent_cli/commands/lowlevel.py +0 -0
  83. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/src/booking_agent_cli/commands/runtime.py +0 -0
  84. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/src/booking_agent_cli/commands/update.py +0 -0
  85. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/src/booking_agent_cli/config_loader.py +0 -0
  86. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/src/booking_agent_cli/engine.py +0 -0
  87. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/src/booking_agent_cli/flow_timings.py +0 -0
  88. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/src/booking_agent_cli/geocode.py +0 -0
  89. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/src/booking_agent_cli/install_integrity.py +0 -0
  90. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/src/booking_agent_cli/link_cli.py +0 -0
  91. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/src/booking_agent_cli/payment_helpers.py +0 -0
  92. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/src/booking_agent_cli/payment_token.py +0 -0
  93. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/src/booking_agent_cli/provenance.py +0 -0
  94. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/src/booking_agent_cli/reschedule.py +0 -0
  95. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/src/booking_agent_cli/runtime.py +0 -0
  96. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/src/booking_agent_cli/runtime_prepare.py +0 -0
  97. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/src/booking_agent_cli/search_area.py +0 -0
  98. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/src/booking_agent_cli/skill_bundle.py +0 -0
  99. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/src/booking_agent_cli/skill_integrity.py +0 -0
  100. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/src/booking_agent_cli/timezone_context.py +0 -0
  101. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/src/booking_agent_cli/updates.py +0 -0
  102. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/.env.tests +0 -0
  103. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/e2e/__init__.py +0 -0
  104. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/e2e/test_cancel_reschedule_e2e.py +0 -0
  105. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/e2e/test_latency_soak.py +0 -0
  106. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/e2e/test_staging_registry_integration.py +0 -0
  107. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/e2e/test_ucp_native_e2e.py +0 -0
  108. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/e2e/ucp_native.py +0 -0
  109. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/integration/__init__.py +0 -0
  110. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/integration/test_activity_log.py +0 -0
  111. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/integration/test_at_rest_hygiene.py +0 -0
  112. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/integration/test_bulk_cancel.py +0 -0
  113. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/integration/test_calendar_connection.py +0 -0
  114. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/integration/test_checkout_signals.py +0 -0
  115. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/integration/test_circuit_breaker.py +0 -0
  116. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/integration/test_config.py +0 -0
  117. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/integration/test_correlation_flow.py +0 -0
  118. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/integration/test_discover_registry_rejection.py +0 -0
  119. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/integration/test_flow_evidence.py +0 -0
  120. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/integration/test_flow_reschedule.py +0 -0
  121. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/integration/test_flow_step_timings.py +0 -0
  122. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/integration/test_google_calendar_oauth.py +0 -0
  123. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/integration/test_http_trace.py +0 -0
  124. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/integration/test_identity_logout.py +0 -0
  125. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/integration/test_lazy_oauth.py +0 -0
  126. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/integration/test_link_cli_spt.py +0 -0
  127. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/integration/test_namespace.py +0 -0
  128. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/integration/test_oauth_listener.py +0 -0
  129. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/integration/test_oauth_metadata.py +0 -0
  130. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/integration/test_platform_key_pop.py +0 -0
  131. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/integration/test_platform_profile.py +0 -0
  132. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/integration/test_pop_keystore.py +0 -0
  133. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/integration/test_profile_cache.py +0 -0
  134. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/integration/test_regression_index.py +0 -0
  135. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/integration/test_release_hold_uses_delete.py +0 -0
  136. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/integration/test_request_retry_semantics.py +0 -0
  137. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/integration/test_runtime_prepare.py +0 -0
  138. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/integration/test_search_area.py +0 -0
  139. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/integration/test_session_compat.py +0 -0
  140. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/integration/test_session_retention.py +0 -0
  141. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/integration/test_skill_portability.py +0 -0
  142. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/integration/test_timezone_context.py +0 -0
  143. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/integration/test_ucp_cancel_result.py +0 -0
  144. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/integration/test_ucp_checkout_pop.py +0 -0
  145. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/integration/test_ucp_checkout_proof_wireup.py +0 -0
  146. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/integration/test_ucp_checkout_validate.py +0 -0
  147. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/integration/test_ucp_profile.py +0 -0
  148. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/integration/test_update_command.py +0 -0
  149. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/integration/test_wheel_contents.py +0 -0
  150. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/spec_schemas/SOURCE +0 -0
  151. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/spec_schemas/acp_booking_extension.json +0 -0
  152. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/spec_schemas/availability.json +0 -0
  153. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/spec_schemas/booking.json +0 -0
  154. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/spec_schemas/calendar_freebusy.json +0 -0
  155. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/spec_schemas/catalog.json +0 -0
  156. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/spec_schemas/paid_bookings.json +0 -0
  157. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/spec_schemas/profile.json +0 -0
  158. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/spec_schemas/registry.json +0 -0
  159. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/spec_schemas/rest_common.json +0 -0
  160. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/spec_schemas/usp.json +0 -0
  161. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/spec_schemas/waitlist.json +0 -0
  162. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/spec_schemas/webhook_event.json +0 -0
  163. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/support/__init__.py +0 -0
  164. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/support/fixtures/__init__.py +0 -0
  165. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/support/fixtures/profiles.py +0 -0
  166. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/support/fixtures/provenance/pypi_attestations-0.0.30-py3-none-any.whl.provenance.json +0 -0
  167. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/support/fixtures/services.py +0 -0
  168. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/support/mock/__init__.py +0 -0
  169. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/support/mock/backend.py +0 -0
  170. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/support/mock/link_cli.py +0 -0
  171. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/support/mock/mock_calendar.py +0 -0
  172. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/support/mock/pop_business.py +0 -0
  173. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/support/mock/pop_transport.py +0 -0
  174. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/support/mock/ucp_checkout_store.py +0 -0
  175. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/support/mock/ucp_server.py +0 -0
  176. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/support/offline_stack/__init__.py +0 -0
  177. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/support/offline_stack/stack.py +0 -0
  178. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/unit/__init__.py +0 -0
  179. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/unit/test_authorization_policy.py +0 -0
  180. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/unit/test_booking_poll_credentials.py +0 -0
  181. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/unit/test_booking_request_error.py +0 -0
  182. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/unit/test_calendar_filter.py +0 -0
  183. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/unit/test_catalog.py +0 -0
  184. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/unit/test_check_version.py +0 -0
  185. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/unit/test_conditional_writes.py +0 -0
  186. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/unit/test_delivery_address.py +0 -0
  187. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/unit/test_discovery.py +0 -0
  188. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/unit/test_geo_eligibility.py +0 -0
  189. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/unit/test_geocode.py +0 -0
  190. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/unit/test_headless.py +0 -0
  191. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/unit/test_http_deadlines.py +0 -0
  192. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/unit/test_http_retry.py +0 -0
  193. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/unit/test_live_search_registry.py +0 -0
  194. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/unit/test_merchant_pacing.py +0 -0
  195. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/unit/test_mock_support.py +0 -0
  196. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/unit/test_money_path_events.py +0 -0
  197. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/unit/test_nonce_from_pop_error_body.py +0 -0
  198. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/unit/test_observability.py +0 -0
  199. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/unit/test_outbound_member_names.py +0 -0
  200. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/unit/test_package_layering.py +0 -0
  201. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/unit/test_pay_at_service.py +0 -0
  202. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/unit/test_provenance.py +0 -0
  203. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/unit/test_registry_search_filters.py +0 -0
  204. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/unit/test_registry_search_pricing.py +0 -0
  205. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/unit/test_registry_search_responses.py +0 -0
  206. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/unit/test_registry_url.py +0 -0
  207. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/unit/test_release_notes.py +0 -0
  208. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/unit/test_rest_client.py +0 -0
  209. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/unit/test_scoped_method_authorization.py +0 -0
  210. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/unit/test_session_credentials.py +0 -0
  211. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/unit/test_spec_conformance.py +0 -0
  212. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/unit/test_step_timing.py +0 -0
  213. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/unit/test_ucp_capabilities.py +0 -0
  214. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/unit/test_ucp_complete_outcome.py +0 -0
  215. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/unit/test_ucp_create_price_guard.py +0 -0
  216. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/unit/test_ucp_negotiation.py +0 -0
  217. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/unit/test_updates.py +0 -0
  218. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/unit/test_wire_model_conformance.py +0 -0
  219. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/vectors/pop/001-jcs-nested-buyer.json +0 -0
  220. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/vectors/pop/002-jcs-meta-only.json +0 -0
  221. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/vectors/pop/003-jcs-unicode-empty-meta.json +0 -0
  222. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/vectors/pop/004-proof-issuance-no-ath.json +0 -0
  223. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/vectors/pop/005-proof-presentation-ath-nonce.json +0 -0
  224. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/vectors/pop/006-cross-key-replay.json +0 -0
  225. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/vectors/pop/007-downgrade-cnf-without-proof.json +0 -0
  226. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/vectors/pop/008-replayed-jti.json +0 -0
  227. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/vectors/pop/009-aud-mismatch.json +0 -0
  228. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/vectors/pop/010-alg-none.json +0 -0
  229. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/vectors/pop/README.md +0 -0
  230. {booking_agent_cli-0.8.0 → booking_agent_cli-0.9.0}/tests/vectors/pop/SOURCE +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: booking-agent-cli
3
- Version: 0.8.0
3
+ Version: 0.9.0
4
4
  Summary: The Booking Agent CLI: a stateful USP booking flow with hard gates, plus its USP protocol library.
5
5
  Project-URL: Homepage, https://github.com/wix/booking-agent
6
6
  Project-URL: Documentation, https://github.com/wix/booking-agent/blob/master/booking-agent/README.md
@@ -78,7 +78,7 @@ Each release also publishes that skill at an immutable `skill-v<version>` tag an
78
78
  the release workflow, so a harness can pin and verify the exact text it follows:
79
79
 
80
80
  ```bash
81
- curl -fsSLO https://raw.githubusercontent.com/wix/booking-agent/skill-v0.56.0/booking-agent/skills/booking-agent/SKILL.md
81
+ curl -fsSLO https://raw.githubusercontent.com/wix/booking-agent/skill-v0.57.0/booking-agent/skills/booking-agent/SKILL.md
82
82
  gh attestation verify SKILL.md --repo wix/booking-agent \
83
83
  --signer-workflow wix/booking-agent/.github/workflows/release.yml
84
84
  ```
@@ -36,7 +36,7 @@ Each release also publishes that skill at an immutable `skill-v<version>` tag an
36
36
  the release workflow, so a harness can pin and verify the exact text it follows:
37
37
 
38
38
  ```bash
39
- curl -fsSLO https://raw.githubusercontent.com/wix/booking-agent/skill-v0.56.0/booking-agent/skills/booking-agent/SKILL.md
39
+ curl -fsSLO https://raw.githubusercontent.com/wix/booking-agent/skill-v0.57.0/booking-agent/skills/booking-agent/SKILL.md
40
40
  gh attestation verify SKILL.md --repo wix/booking-agent \
41
41
  --signer-workflow wix/booking-agent/.github/workflows/release.yml
42
42
  ```
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "booking-agent-cli"
3
- version = "0.8.0"
3
+ version = "0.9.0"
4
4
  description = "The Booking Agent CLI: a stateful USP booking flow with hard gates, plus its USP protocol library."
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -3,4 +3,4 @@
3
3
  Ships in the booking-agent-cli wheel and never imports ``booking_agent_cli``.
4
4
  """
5
5
 
6
- __version__ = "0.8.0"
6
+ __version__ = "0.9.0"
@@ -33,6 +33,7 @@ from ..rest_client import (
33
33
  USPTokenStore,
34
34
  )
35
35
  from ..headless import attach_registry_hit, mark_live_catalog_service, service_from_registry_hit
36
+ from ..faults import record_silent_fault
36
37
  from ..authorization import BookingScopedCredential, evaluate, resolve_posture
37
38
  from ..pop import PopKey, PopKeyStore
38
39
  from ..ucp_profile import UcpNativeContext, fetch_and_resolve_business_profile
@@ -115,39 +116,41 @@ class LiveUSPBackend(USPBackend):
115
116
  decision=decision,
116
117
  )
117
118
 
119
+ # Discovery absorbs these failures, so they stay at debug level: a warning reaches
120
+ # stderr, which agent hosts merge into the stdout JSON they parse.
118
121
  @staticmethod
119
122
  def _log_profile_failure(profile_url: str, exc: BaseException) -> None:
120
123
  if is_rate_limited(exc):
121
- logger.warning(
124
+ logger.debug(
122
125
  "Profile resolution rate limited (HTTP 429) for %s",
123
126
  profile_url,
124
127
  )
125
128
  return
126
129
  if isinstance(exc, httpx.HTTPStatusError):
127
- logger.warning(
130
+ logger.debug(
128
131
  "Profile resolution HTTP %s for %s",
129
132
  exc.response.status_code,
130
133
  profile_url,
131
134
  )
132
135
  return
133
- logger.warning("Profile resolution failed for %s", profile_url)
136
+ logger.debug("Profile resolution failed for %s: %s: %s", profile_url, type(exc).__name__, exc)
134
137
 
135
138
  @staticmethod
136
139
  def _log_service_failure(service_id: str, exc: BaseException) -> None:
137
140
  if is_rate_limited(exc):
138
- logger.warning(
141
+ logger.debug(
139
142
  "Service catalog fetch rate limited (HTTP 429) for %s",
140
143
  service_id,
141
144
  )
142
145
  return
143
146
  if isinstance(exc, httpx.HTTPStatusError):
144
- logger.warning(
147
+ logger.debug(
145
148
  "Service catalog fetch HTTP %s for %s",
146
149
  exc.response.status_code,
147
150
  service_id,
148
151
  )
149
152
  return
150
- logger.warning("Service fetch failed: %s", service_id)
153
+ logger.debug("Service fetch failed: %s: %s", service_id, type(exc).__name__)
151
154
 
152
155
  # -- Registry-level ------------------------------------------------------
153
156
 
@@ -184,6 +187,9 @@ class LiveUSPBackend(USPBackend):
184
187
  self._log_profile_failure(profile_url, exc)
185
188
  if is_rate_limited(exc):
186
189
  raise
190
+ record_silent_fault(
191
+ "profile_resolve", exc, profile_url=profile_url, business_id=biz_id,
192
+ )
187
193
 
188
194
  async def search_registry(self, intent: ServiceSearchIntent) -> List[USPService]:
189
195
  if not self._cfg.usp_registry_url:
@@ -289,6 +295,10 @@ class LiveUSPBackend(USPBackend):
289
295
  if is_rate_limited(exc):
290
296
  rate_limited_exc = exc
291
297
  raise
298
+ record_silent_fault(
299
+ "catalog_enrich", exc, profile_url=profile_url,
300
+ business_id=biz_id, service_id=r.service_id,
301
+ )
292
302
  return attach_registry_hit(
293
303
  service_from_registry_hit(r), r, rank=rank,
294
304
  )
@@ -43,6 +43,12 @@ BOOKING_AGENT_DISCOVERY_CONCURRENCY=8
43
43
  BOOKING_AGENT_PROFILE_CACHE_TTL_SECONDS=900
44
44
  # 1 writes a redacted request log to <session dir>/http_trace.log.
45
45
  BOOKING_AGENT_HTTP_TRACE=0
46
+ # 1 writes money-path JSON event lines to stderr. Off by default because many
47
+ # agent hosts merge stderr into the stdout JSON they parse.
48
+ BOOKING_AGENT_STDERR_EVENTS=0
49
+ # 0 stops automatic fault reports: faults the user never saw, about a business's
50
+ # profile or catalog or the assistant's own code, sent with no user data.
51
+ BOOKING_AGENT_FAULT_REPORTS=1
46
52
 
47
53
  # --- Address -------------------------------------------------------------------
48
54
  BOOKING_AGENT_GEOCODER_URL=https://nominatim.openstreetmap.org/search
@@ -0,0 +1,154 @@
1
+ """Faults the client absorbed, so the user never saw them.
2
+
3
+ Code that catches an exception and carries on (a business profile that cannot be
4
+ read during discovery, a catalog entry that cannot be fetched) calls
5
+ ``record_silent_fault``. Warnings logged by the allowed modules during a command are
6
+ collected the same way. The CLI decides at the end of the command whether these
7
+ become automatic fault reports; nothing here does I/O.
8
+
9
+ Only structural facts are kept: the step, the exception type, a code-authored log
10
+ format string, and public identifiers of the business involved. Exception messages
11
+ and log arguments are never kept, because they can carry anything.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import logging
16
+ import re
17
+ from typing import Any, Dict, List, Optional
18
+ from urllib.parse import urlsplit
19
+
20
+ #: Steps whose outcome never depends on the user's own accounts or data.
21
+ STEPS = frozenset({
22
+ "registry_search",
23
+ "profile_resolve",
24
+ "catalog_enrich",
25
+ "availability_prefetch",
26
+ "platform_profile",
27
+ "cli",
28
+ })
29
+ #: Loggers whose warnings count as degraded results. Link, payment, identity, buyer
30
+ #: details, and Google Calendar code is not here: their faults stay on the consented path.
31
+ WARNING_LOGGERS = (
32
+ "booking_agent.backend.live",
33
+ "booking_agent.discovery",
34
+ "booking_agent.headless",
35
+ "booking_agent.ucp_profile",
36
+ "booking_agent.profile_cache",
37
+ )
38
+ _MAX_RECORDED = 50
39
+ _SLUG_RE = re.compile(r"[^a-z0-9]+")
40
+
41
+ _recorded: List[Dict[str, Any]] = []
42
+
43
+
44
+ def _reason(exc: Optional[BaseException]) -> Optional[str]:
45
+ if exc is None:
46
+ return None
47
+ response = getattr(exc, "response", None)
48
+ status = getattr(response, "status_code", None)
49
+ if isinstance(status, int):
50
+ return f"http_{status}"
51
+ from booking_agent.ucp_profile import UcpProfileValidationError
52
+
53
+ if isinstance(exc, UcpProfileValidationError) and exc.args and isinstance(exc.args[0], str):
54
+ # Validation messages are written in this package and name only profile fields.
55
+ slug = _SLUG_RE.sub("_", exc.args[0].lower()).strip("_")
56
+ return slug[:64] or None
57
+ return None
58
+
59
+
60
+ def _subject(profile_url: Optional[str], **ids: Any) -> Dict[str, Any]:
61
+ subject: Dict[str, Any] = {}
62
+ if profile_url:
63
+ try:
64
+ parts = urlsplit(profile_url)
65
+ except ValueError:
66
+ parts = None
67
+ if parts is not None and parts.scheme in ("http", "https") and parts.hostname:
68
+ subject["host"] = parts.hostname
69
+ subject["path"] = parts.path or "/"
70
+ for key, value in ids.items():
71
+ if value is not None:
72
+ subject[key] = value
73
+ return subject
74
+
75
+
76
+ def record_silent_fault(
77
+ step: str,
78
+ exc: Optional[BaseException] = None,
79
+ *,
80
+ profile_url: Optional[str] = None,
81
+ business_id: Optional[str] = None,
82
+ service_id: Optional[str] = None,
83
+ ) -> None:
84
+ """Note a fault this code absorbed. Never raises."""
85
+ try:
86
+ if len(_recorded) >= _MAX_RECORDED:
87
+ return
88
+ response = getattr(exc, "response", None)
89
+ status = getattr(response, "status_code", None)
90
+ _recorded.append({
91
+ "fault_class": "absorbed_exception",
92
+ "step": step if step in STEPS else "cli",
93
+ "exception_type": _qualname(exc),
94
+ "message_template": None,
95
+ "reason_code": _reason(exc),
96
+ "subject": _subject(
97
+ profile_url,
98
+ business_id=business_id,
99
+ service_ids=[service_id] if service_id else None,
100
+ http_status=status if isinstance(status, int) else None,
101
+ ),
102
+ })
103
+ except Exception:
104
+ pass
105
+
106
+
107
+ def _qualname(exc: Optional[BaseException]) -> Optional[str]:
108
+ if exc is None:
109
+ return None
110
+ cls = type(exc)
111
+ return f"{cls.__module__}.{cls.__qualname__}"
112
+
113
+
114
+ class _WarningCollector(logging.Handler):
115
+ """Keeps the format string of each warning; the formatted text is never stored."""
116
+
117
+ def __init__(self) -> None:
118
+ super().__init__(level=logging.WARNING)
119
+
120
+ def emit(self, record: logging.LogRecord) -> None:
121
+ try:
122
+ if not record.name.startswith(WARNING_LOGGERS) or len(_recorded) >= _MAX_RECORDED:
123
+ return
124
+ exc = record.exc_info[1] if record.exc_info else None
125
+ _recorded.append({
126
+ "fault_class": "degraded_result",
127
+ "step": "cli",
128
+ "exception_type": _qualname(exc),
129
+ "message_template": record.msg if isinstance(record.msg, str) else None,
130
+ "reason_code": None,
131
+ "subject": {"logger": record.name},
132
+ })
133
+ except Exception:
134
+ pass
135
+
136
+
137
+ _collector: Optional[_WarningCollector] = None
138
+
139
+
140
+ def install_warning_collector() -> None:
141
+ """Collect allowed warnings instead of printing them to stderr (idempotent)."""
142
+ global _collector
143
+ if _collector is not None:
144
+ return
145
+ _collector = _WarningCollector()
146
+ for name in ("booking_agent", "booking_agent_cli"):
147
+ logging.getLogger(name).addHandler(_collector)
148
+
149
+
150
+ def drain() -> List[Dict[str, Any]]:
151
+ """Every fault recorded so far, cleared."""
152
+ out = list(_recorded)
153
+ _recorded.clear()
154
+ return out
@@ -9,6 +9,8 @@ from typing import Any, Callable, Dict, Optional
9
9
 
10
10
  import httpx
11
11
 
12
+ from booking_agent import settings
13
+
12
14
  # Wire name for the flow join key. Optional on the USP HTTP binding (Track S of
13
15
  # spec issue #160): send when bound, never require it on responses, never fail a
14
16
  # call because a peer omitted or ignored it.
@@ -35,9 +37,14 @@ _FORBIDDEN_FIELD_KEYS = frozenset({
35
37
  })
36
38
 
37
39
  _correlation_id: ContextVar[Optional[str]] = ContextVar("booking_agent_correlation_id", default=None)
38
- _event_sink: Callable[[Dict[str, Any]], None] = lambda record: sys.stderr.write(
39
- json.dumps(record, default=str) + "\n"
40
- )
40
+
41
+
42
+ def _default_sink(record: Dict[str, Any]) -> None:
43
+ if settings.get_bool("BOOKING_AGENT_STDERR_EVENTS"):
44
+ sys.stderr.write(json.dumps(record, default=str) + "\n")
45
+
46
+
47
+ _event_sink: Callable[[Dict[str, Any]], None] = _default_sink
41
48
 
42
49
 
43
50
  def set_event_sink(sink: Callable[[Dict[str, Any]], None]) -> None:
@@ -49,7 +56,7 @@ def set_event_sink(sink: Callable[[Dict[str, Any]], None]) -> None:
49
56
  def reset_event_sink() -> None:
50
57
  """Restore the default stderr event sink."""
51
58
  global _event_sink
52
- _event_sink = lambda record: sys.stderr.write(json.dumps(record, default=str) + "\n")
59
+ _event_sink = _default_sink
53
60
 
54
61
 
55
62
  def new_correlation_id() -> str:
@@ -169,10 +169,11 @@ class UcpNativeContext(BaseModel):
169
169
  continue
170
170
  if "authorization" in binding:
171
171
  logger.warning(
172
- f"{SVC_SERVICES} binding declares a sibling 'authorization' member, "
172
+ "%s binding declares a sibling 'authorization' member, "
173
173
  "which USP prohibits; the policy MUST be published as "
174
174
  "config.authorization. Treating this business as publishing no "
175
- "policy."
175
+ "policy.",
176
+ SVC_SERVICES,
176
177
  )
177
178
  misplaced = True
178
179
  continue
@@ -1,3 +1,3 @@
1
1
  """booking_agent_cli: typer-based stateful CLI for USP booking flows."""
2
2
 
3
- __version__ = "0.8.0"
3
+ __version__ = "0.9.0"
@@ -6,6 +6,7 @@ address, and price.
6
6
  """
7
7
  from __future__ import annotations
8
8
 
9
+ import re
9
10
  from typing import Any, Dict, List, Optional
10
11
 
11
12
  from . import timezone_context as tz_context
@@ -114,6 +115,174 @@ def price_text(session: Any, price_info: Dict[str, Any]) -> Optional[str]:
114
115
  return f"{price}, charged now to your Link wallet"
115
116
 
116
117
 
118
+ _ISO_DURATION_RE = re.compile(
119
+ r"^P(?:(?P<days>\d+)D)?(?:T(?:(?P<hours>\d+)H)?(?:(?P<minutes>\d+)M)?)?$"
120
+ )
121
+ # Links a checkout returns that are actions, not terms the business publishes.
122
+ _ACTION_LINK_TYPES = frozenset({"continue"})
123
+
124
+
125
+ def _duration_text(value: Any) -> Optional[str]:
126
+ """``PT24H`` -> ``24 hours``; anything else is returned as the business wrote it."""
127
+ if not isinstance(value, str) or not value.strip():
128
+ return None
129
+ match = _ISO_DURATION_RE.match(value.strip().upper())
130
+ if not match or not any(match.groupdict().values()):
131
+ return value.strip()
132
+ parts = []
133
+ for unit in ("days", "hours", "minutes"):
134
+ amount = match.group(unit)
135
+ if amount and int(amount):
136
+ word = unit if int(amount) != 1 else unit[:-1]
137
+ parts.append(f"{int(amount)} {word}")
138
+ return " ".join(parts) or "0 minutes"
139
+
140
+
141
+ def _fee_text(amount: Any, live: Dict[str, Any]) -> Optional[str]:
142
+ if not isinstance(amount, int) or amount <= 0:
143
+ return None
144
+ from .catalog import _format_price_display
145
+
146
+ pricing = live.get("pricing") if isinstance(live.get("pricing"), dict) else {}
147
+ return _format_price_display(amount, pricing.get("currency")) or str(amount)
148
+
149
+
150
+ def service_policy_lines(live_service: Any) -> List[str]:
151
+ """The cancellation and rescheduling policies the business published for the service.
152
+
153
+ ``allowed`` is required, and a business with no policy configured still sends
154
+ ``allowed: true``, so a policy that is only ``allowed: true`` with no window, fee, or
155
+ limit says nothing the business chose and yields no line.
156
+ """
157
+ live = live_service if isinstance(live_service, dict) else {}
158
+ policies = live.get("policies") if isinstance(live.get("policies"), dict) else {}
159
+ lines: List[str] = []
160
+ cancel = policies.get("cancellation")
161
+ if isinstance(cancel, dict) and cancel.get("allowed") is not None:
162
+ if cancel.get("allowed") is False:
163
+ text = "Cancellation is not allowed"
164
+ else:
165
+ parts = []
166
+ free = _duration_text(cancel.get("free_cancellation_until"))
167
+ if free:
168
+ parts.append(f"free up to {free} before the appointment")
169
+ fee = _fee_text(cancel.get("late_cancellation_fee"), live)
170
+ if fee:
171
+ parts.append(f"a {fee} fee after that")
172
+ closed = _duration_text(cancel.get("no_cancellation_after"))
173
+ if closed:
174
+ parts.append(f"no cancellation within {closed} of the appointment")
175
+ text = f"Cancellation allowed, {', '.join(parts)}" if parts else None
176
+ if text:
177
+ lines.append(f"Cancellation policy: {text}.")
178
+ resched = policies.get("rescheduling")
179
+ if isinstance(resched, dict) and resched.get("allowed") is not None:
180
+ if resched.get("allowed") is False:
181
+ text = "Rescheduling is not allowed"
182
+ else:
183
+ parts = []
184
+ free = _duration_text(resched.get("free_reschedule_until"))
185
+ if free:
186
+ parts.append(f"free up to {free} before the appointment")
187
+ limit = resched.get("max_reschedules")
188
+ if isinstance(limit, int) and limit >= 0:
189
+ parts.append(f"at most {limit} time{'s' if limit != 1 else ''}")
190
+ fee = _fee_text(resched.get("fee"), live)
191
+ if fee:
192
+ parts.append(f"a {fee} fee")
193
+ text = f"Rescheduling allowed, {', '.join(parts)}" if parts else None
194
+ if text:
195
+ lines.append(f"Rescheduling policy: {text}.")
196
+ return lines
197
+
198
+
199
+ def _http_url(value: Any) -> Optional[str]:
200
+ if not isinstance(value, str):
201
+ return None
202
+ url = value.strip()
203
+ return url if url.startswith(("https://", "http://")) else None
204
+
205
+
206
+ def _link_label(kind: str, title: Any) -> str:
207
+ if isinstance(title, str) and title.strip():
208
+ return title.strip()
209
+ words = kind.replace("_", " ").replace("-", " ").strip()
210
+ return (words[:1].upper() + words[1:]) if words else "Link"
211
+
212
+
213
+ def extract_policy_links(links: Any) -> List[Dict[str, str]]:
214
+ """Terms and policy links from a checkout: no ``rel=continue`` actions, no duplicates."""
215
+ out: List[Dict[str, str]] = []
216
+ seen: set = set()
217
+ for link in links or []:
218
+ if not isinstance(link, dict):
219
+ continue
220
+ kind = str(link.get("type") or link.get("rel") or "").strip().lower()
221
+ if kind in _ACTION_LINK_TYPES:
222
+ continue
223
+ url = _http_url(link.get("url") or link.get("href"))
224
+ if url is None or url in seen:
225
+ continue
226
+ seen.add(url)
227
+ out.append({"type": kind or "link", "label": _link_label(kind, link.get("title")), "url": url})
228
+ return out
229
+
230
+
231
+ def _message_text(message: Dict[str, Any], zone: Optional[str]) -> Optional[str]:
232
+ description = message.get("description")
233
+ text = description.get("plain") if isinstance(description, dict) else None
234
+ for key in ("content", "text", "message", "title", "name"):
235
+ if isinstance(text, str) and text.strip():
236
+ break
237
+ text = message.get(key)
238
+ if not isinstance(text, str) or not text.strip():
239
+ return None
240
+ return tz_context.localize_instants_in_text(text.strip(), zone)
241
+
242
+
243
+ def collect_provider_policies(session: Any) -> Dict[str, Any]:
244
+ """Everything the business published for the user to see before the booking is final.
245
+
246
+ ``lines`` are the service's cancellation and rescheduling policies, the checkout's policy
247
+ texts, payment terms, and disclosures; ``links`` are the checkout's policy links (never
248
+ ``rel=continue``). ``digest`` covers ``lines`` only: the user accepts the lines with the
249
+ booking summary, and links a checkout adds later are shown without a new question, so a
250
+ checkout re-asks only when it adds lines. The business website the CLI derives from the
251
+ profile and any text the CLI writes are not included; with no lines ``digest`` is None.
252
+ """
253
+ zone = session.business.timezone if getattr(session, "business", None) else None
254
+ lines = service_policy_lines(getattr(session, "live_service", None))
255
+ links = extract_policy_links(getattr(session, "ucp_checkout_links", None))
256
+ seen_urls = {link["url"] for link in links}
257
+ for policy in getattr(session, "ucp_checkout_policies", None) or []:
258
+ if not isinstance(policy, dict):
259
+ continue
260
+ url = _http_url(policy.get("url") or policy.get("href"))
261
+ if url and url not in seen_urls:
262
+ seen_urls.add(url)
263
+ kind = str(policy.get("type") or "policy").strip().lower()
264
+ links.append({"type": kind, "label": _link_label(kind, policy.get("title")), "url": url})
265
+ text = _message_text(policy, zone)
266
+ if text and not url:
267
+ lines.append(f"Policy: {text}")
268
+ for schedule in getattr(session, "payment_schedules", None) or []:
269
+ if isinstance(schedule, dict):
270
+ text = _message_text({"description": schedule.get("description")}, zone)
271
+ if text:
272
+ lines.append(f"Payment terms: {text}")
273
+ for disclosure in getattr(session, "payment_disclosures", None) or []:
274
+ if isinstance(disclosure, dict):
275
+ text = _message_text(disclosure, zone)
276
+ if text:
277
+ lines.append(text)
278
+ digest = None
279
+ if lines:
280
+ from .gates import digest as _digest
281
+
282
+ digest = _digest({"lines": lines})
283
+ return {"lines": lines, "links": links, "digest": digest}
284
+
285
+
117
286
  def booking_recap_lines(
118
287
  session: Any, price_info: Dict[str, Any], *, dual: bool,
119
288
  ) -> List[str]:
@@ -131,6 +300,10 @@ def booking_recap_lines(
131
300
  ):
132
301
  if value:
133
302
  lines.append(f"{label}: {value}")
303
+ mismatch = getattr(session, "selection_mismatch", None)
304
+ if mismatch:
305
+ lines.append(f"Differs from your request: {mismatch}")
306
+ lines.extend(service_policy_lines(session.live_service))
134
307
  return lines
135
308
 
136
309