thth 3.8.2__tar.gz → 3.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 (587) hide show
  1. {thth-3.8.2 → thth-3.9.0}/PKG-INFO +1 -1
  2. {thth-3.8.2 → thth-3.9.0}/skills/thth/SKILL.md +2 -0
  3. thth-3.9.0/tests/test_v390_docs.py +51 -0
  4. thth-3.9.0/tests/test_v390_per_post_clicks.py +138 -0
  5. thth-3.9.0/tests/test_v390_where_rate.py +93 -0
  6. thth-3.9.0/tests/test_v390_where_suggest.py +217 -0
  7. thth-3.9.0/thth/VERSION +1 -0
  8. thth-3.9.0/thth/analytics_clicks.py +211 -0
  9. {thth-3.8.2 → thth-3.9.0}/thth/analytics_goals.py +17 -1
  10. {thth-3.8.2 → thth-3.9.0}/thth/analytics_report.py +8 -0
  11. {thth-3.8.2 → thth-3.9.0}/thth/cli.py +5 -0
  12. {thth-3.8.2 → thth-3.9.0}/thth/click_attribution.py +70 -0
  13. {thth-3.8.2 → thth-3.9.0}/thth/where_cli.py +163 -8
  14. thth-3.9.0/thth/where_suggest.py +232 -0
  15. thth-3.8.2/thth/VERSION +0 -1
  16. {thth-3.8.2 → thth-3.9.0}/.gitignore +0 -0
  17. {thth-3.8.2 → thth-3.9.0}/LICENSE +0 -0
  18. {thth-3.8.2 → thth-3.9.0}/README.md +0 -0
  19. {thth-3.8.2 → thth-3.9.0}/accounts.example/bluesky.json +0 -0
  20. {thth-3.8.2 → thth-3.9.0}/accounts.example/mastodon.json +0 -0
  21. {thth-3.8.2 → thth-3.9.0}/accounts.example/threads.json +0 -0
  22. {thth-3.8.2 → thth-3.9.0}/accounts.example/x.json +0 -0
  23. {thth-3.8.2 → thth-3.9.0}/bin/thth +0 -0
  24. {thth-3.8.2 → thth-3.9.0}/bin/thth-remote +0 -0
  25. {thth-3.8.2 → thth-3.9.0}/bin/thth-run +0 -0
  26. {thth-3.8.2 → thth-3.9.0}/mcp/server.py +0 -0
  27. {thth-3.8.2 → thth-3.9.0}/pyproject.toml +0 -0
  28. {thth-3.8.2 → thth-3.9.0}/skills/thth-admin/SKILL.md +0 -0
  29. {thth-3.8.2 → thth-3.9.0}/systemd/thth@.service +0 -0
  30. {thth-3.8.2 → thth-3.9.0}/tests/__init__.py +0 -0
  31. {thth-3.8.2 → thth-3.9.0}/tests/conftest.py +0 -0
  32. {thth-3.8.2 → thth-3.9.0}/tests/coordination_snapshot.py +0 -0
  33. {thth-3.8.2 → thth-3.9.0}/tests/fixtures/GraphemeBreakTest-17.0.0.txt +0 -0
  34. {thth-3.8.2 → thth-3.9.0}/tests/fixtures/build_injection_eval.py +0 -0
  35. {thth-3.8.2 → thth-3.9.0}/tests/fixtures/callback/worker_responses.json +0 -0
  36. {thth-3.8.2 → thth-3.9.0}/tests/fixtures/injection_eval.json +0 -0
  37. {thth-3.8.2 → thth-3.9.0}/tests/fixtures/umami-bile.md +0 -0
  38. {thth-3.8.2 → thth-3.9.0}/tests/helpers/__init__.py +0 -0
  39. {thth-3.8.2 → thth-3.9.0}/tests/helpers/fake_oauth_server.py +0 -0
  40. {thth-3.8.2 → thth-3.9.0}/tests/helpers/fake_push_adapter.py +0 -0
  41. {thth-3.8.2 → thth-3.9.0}/tests/helpers/fake_redirect_server.py +0 -0
  42. {thth-3.8.2 → thth-3.9.0}/tests/helpers/fake_search_adapter.py +0 -0
  43. {thth-3.8.2 → thth-3.9.0}/tests/helpers/fake_threads_reader.py +0 -0
  44. {thth-3.8.2 → thth-3.9.0}/tests/helpers/fake_threads_status.py +0 -0
  45. {thth-3.8.2 → thth-3.9.0}/tests/helpers/fake_threads_writes.py +0 -0
  46. {thth-3.8.2 → thth-3.9.0}/tests/helpers/fake_x.py +0 -0
  47. {thth-3.8.2 → thth-3.9.0}/tests/helpers/hold_lock.py +0 -0
  48. {thth-3.8.2 → thth-3.9.0}/tests/helpers/hold_repo_lock.py +0 -0
  49. {thth-3.8.2 → thth-3.9.0}/tests/helpers/worker_probe.mjs +0 -0
  50. {thth-3.8.2 → thth-3.9.0}/tests/test_27_acceptance_fixes.py +0 -0
  51. {thth-3.8.2 → thth-3.9.0}/tests/test_account_name_check.py +0 -0
  52. {thth-3.8.2 → thth-3.9.0}/tests/test_account_report.py +0 -0
  53. {thth-3.8.2 → thth-3.9.0}/tests/test_accounts_dir.py +0 -0
  54. {thth-3.8.2 → thth-3.9.0}/tests/test_adapter_boundary.py +0 -0
  55. {thth-3.8.2 → thth-3.9.0}/tests/test_admin_http_mcp.py +0 -0
  56. {thth-3.8.2 → thth-3.9.0}/tests/test_admin_log.py +0 -0
  57. {thth-3.8.2 → thth-3.9.0}/tests/test_admin_notifications.py +0 -0
  58. {thth-3.8.2 → thth-3.9.0}/tests/test_admin_repairs.py +0 -0
  59. {thth-3.8.2 → thth-3.9.0}/tests/test_admin_report.py +0 -0
  60. {thth-3.8.2 → thth-3.9.0}/tests/test_after_cli.py +0 -0
  61. {thth-3.8.2 → thth-3.9.0}/tests/test_analytics_comparison.py +0 -0
  62. {thth-3.8.2 → thth-3.9.0}/tests/test_analytics_report.py +0 -0
  63. {thth-3.8.2 → thth-3.9.0}/tests/test_api_diagnostic.py +0 -0
  64. {thth-3.8.2 → thth-3.9.0}/tests/test_api_error_body.py +0 -0
  65. {thth-3.8.2 → thth-3.9.0}/tests/test_app_env_cli.py +0 -0
  66. {thth-3.8.2 → thth-3.9.0}/tests/test_approval.py +0 -0
  67. {thth-3.8.2 → thth-3.9.0}/tests/test_approve_cmd.py +0 -0
  68. {thth-3.8.2 → thth-3.9.0}/tests/test_ask.py +0 -0
  69. {thth-3.8.2 → thth-3.9.0}/tests/test_atlas_fifth_review.py +0 -0
  70. {thth-3.8.2 → thth-3.9.0}/tests/test_atlas_fourth_review.py +0 -0
  71. {thth-3.8.2 → thth-3.9.0}/tests/test_atlas_review.py +0 -0
  72. {thth-3.8.2 → thth-3.9.0}/tests/test_atlas_second_review.py +0 -0
  73. {thth-3.8.2 → thth-3.9.0}/tests/test_atlas_third_review.py +0 -0
  74. {thth-3.8.2 → thth-3.9.0}/tests/test_audit1_fixes.py +0 -0
  75. {thth-3.8.2 → thth-3.9.0}/tests/test_audit2_fixes.py +0 -0
  76. {thth-3.8.2 → thth-3.9.0}/tests/test_audit_b_counterexamples.py +0 -0
  77. {thth-3.8.2 → thth-3.9.0}/tests/test_audit_b_m1_semantics.py +0 -0
  78. {thth-3.8.2 → thth-3.9.0}/tests/test_audit_b_n1_depth.py +0 -0
  79. {thth-3.8.2 → thth-3.9.0}/tests/test_audit_b_r1_closure.py +0 -0
  80. {thth-3.8.2 → thth-3.9.0}/tests/test_audit_v201.py +0 -0
  81. {thth-3.8.2 → thth-3.9.0}/tests/test_audit_v23_details.py +0 -0
  82. {thth-3.8.2 → thth-3.9.0}/tests/test_auth.py +0 -0
  83. {thth-3.8.2 → thth-3.9.0}/tests/test_auth_identity_required.py +0 -0
  84. {thth-3.8.2 → thth-3.9.0}/tests/test_behind_remote.py +0 -0
  85. {thth-3.8.2 → thth-3.9.0}/tests/test_bluesky_adapter.py +0 -0
  86. {thth-3.8.2 → thth-3.9.0}/tests/test_bluesky_secret_scrub.py +0 -0
  87. {thth-3.8.2 → thth-3.9.0}/tests/test_board_needs_review.py +0 -0
  88. {thth-3.8.2 → thth-3.9.0}/tests/test_board_running.py +0 -0
  89. {thth-3.8.2 → thth-3.9.0}/tests/test_bundle.py +0 -0
  90. {thth-3.8.2 → thth-3.9.0}/tests/test_charcount.py +0 -0
  91. {thth-3.8.2 → thth-3.9.0}/tests/test_cli_entrypoint.py +0 -0
  92. {thth-3.8.2 → thth-3.9.0}/tests/test_cli_help.py +0 -0
  93. {thth-3.8.2 → thth-3.9.0}/tests/test_cli_vm_path.py +0 -0
  94. {thth-3.8.2 → thth-3.9.0}/tests/test_collect.py +0 -0
  95. {thth-3.8.2 → thth-3.9.0}/tests/test_collect_engagements.py +0 -0
  96. {thth-3.8.2 → thth-3.9.0}/tests/test_collect_runs.py +0 -0
  97. {thth-3.8.2 → thth-3.9.0}/tests/test_collect_sent.py +0 -0
  98. {thth-3.8.2 → thth-3.9.0}/tests/test_comparability.py +0 -0
  99. {thth-3.8.2 → thth-3.9.0}/tests/test_control_chars_refused.py +0 -0
  100. {thth-3.8.2 → thth-3.9.0}/tests/test_conversation_paging.py +0 -0
  101. {thth-3.8.2 → thth-3.9.0}/tests/test_count_for.py +0 -0
  102. {thth-3.8.2 → thth-3.9.0}/tests/test_docs_links.py +0 -0
  103. {thth-3.8.2 → thth-3.9.0}/tests/test_doctor.py +0 -0
  104. {thth-3.8.2 → thth-3.9.0}/tests/test_doctor_all_permissions.py +0 -0
  105. {thth-3.8.2 → thth-3.9.0}/tests/test_doctor_next_step.py +0 -0
  106. {thth-3.8.2 → thth-3.9.0}/tests/test_duplicate_front_matter_keys.py +0 -0
  107. {thth-3.8.2 → thth-3.9.0}/tests/test_editorial_audit.py +0 -0
  108. {thth-3.8.2 → thth-3.9.0}/tests/test_editorial_n3_n4.py +0 -0
  109. {thth-3.8.2 → thth-3.9.0}/tests/test_engagements.py +0 -0
  110. {thth-3.8.2 → thth-3.9.0}/tests/test_external_acceptance.py +0 -0
  111. {thth-3.8.2 → thth-3.9.0}/tests/test_fake_api.py +0 -0
  112. {thth-3.8.2 → thth-3.9.0}/tests/test_form_spec.py +0 -0
  113. {thth-3.8.2 → thth-3.9.0}/tests/test_form_spec_cli.py +0 -0
  114. {thth-3.8.2 → thth-3.9.0}/tests/test_forms.py +0 -0
  115. {thth-3.8.2 → thth-3.9.0}/tests/test_fresh_install.py +0 -0
  116. {thth-3.8.2 → thth-3.9.0}/tests/test_front_matter_injection.py +0 -0
  117. {thth-3.8.2 → thth-3.9.0}/tests/test_healthcheck_notifications.py +0 -0
  118. {thth-3.8.2 → thth-3.9.0}/tests/test_hypotheses.py +0 -0
  119. {thth-3.8.2 → thth-3.9.0}/tests/test_hypothesis_boundaries.py +0 -0
  120. {thth-3.8.2 → thth-3.9.0}/tests/test_hypothesis_units.py +0 -0
  121. {thth-3.8.2 → thth-3.9.0}/tests/test_impact_cli.py +0 -0
  122. {thth-3.8.2 → thth-3.9.0}/tests/test_inbox_granted_scopes.py +0 -0
  123. {thth-3.8.2 → thth-3.9.0}/tests/test_incident_notifications.py +0 -0
  124. {thth-3.8.2 → thth-3.9.0}/tests/test_independent_thread_review.py +0 -0
  125. {thth-3.8.2 → thth-3.9.0}/tests/test_independent_topic_review.py +0 -0
  126. {thth-3.8.2 → thth-3.9.0}/tests/test_independent_topic_review_r2.py +0 -0
  127. {thth-3.8.2 → thth-3.9.0}/tests/test_inflight.py +0 -0
  128. {thth-3.8.2 → thth-3.9.0}/tests/test_inflight_tag_body_hash.py +0 -0
  129. {thth-3.8.2 → thth-3.9.0}/tests/test_jst.py +0 -0
  130. {thth-3.8.2 → thth-3.9.0}/tests/test_lint.py +0 -0
  131. {thth-3.8.2 → thth-3.9.0}/tests/test_list_projections.py +0 -0
  132. {thth-3.8.2 → thth-3.9.0}/tests/test_llm_trial.py +0 -0
  133. {thth-3.8.2 → thth-3.9.0}/tests/test_llm_trial_engage.py +0 -0
  134. {thth-3.8.2 → thth-3.9.0}/tests/test_lock.py +0 -0
  135. {thth-3.8.2 → thth-3.9.0}/tests/test_m2_independent.py +0 -0
  136. {thth-3.8.2 → thth-3.9.0}/tests/test_maintain.py +0 -0
  137. {thth-3.8.2 → thth-3.9.0}/tests/test_mastodon_adapter.py +0 -0
  138. {thth-3.8.2 → thth-3.9.0}/tests/test_mastodon_visibility.py +0 -0
  139. {thth-3.8.2 → thth-3.9.0}/tests/test_max_per_run.py +0 -0
  140. {thth-3.8.2 → thth-3.9.0}/tests/test_mcp.py +0 -0
  141. {thth-3.8.2 → thth-3.9.0}/tests/test_mcp_robustness.py +0 -0
  142. {thth-3.8.2 → thth-3.9.0}/tests/test_measured.py +0 -0
  143. {thth-3.8.2 → thth-3.9.0}/tests/test_media_accounts.py +0 -0
  144. {thth-3.8.2 → thth-3.9.0}/tests/test_media_next_steps.py +0 -0
  145. {thth-3.8.2 → thth-3.9.0}/tests/test_media_roundtrip.py +0 -0
  146. {thth-3.8.2 → thth-3.9.0}/tests/test_mismatch_diagnostics.py +0 -0
  147. {thth-3.8.2 → thth-3.9.0}/tests/test_n5_reply_null_element.py +0 -0
  148. {thth-3.8.2 → thth-3.9.0}/tests/test_n6_replies_incomplete_ledger.py +0 -0
  149. {thth-3.8.2 → thth-3.9.0}/tests/test_n7_doctor_error_body.py +0 -0
  150. {thth-3.8.2 → thth-3.9.0}/tests/test_no_limits.py +0 -0
  151. {thth-3.8.2 → thth-3.9.0}/tests/test_observation_provenance.py +0 -0
  152. {thth-3.8.2 → thth-3.9.0}/tests/test_operations_handoff.py +0 -0
  153. {thth-3.8.2 → thth-3.9.0}/tests/test_packaging.py +0 -0
  154. {thth-3.8.2 → thth-3.9.0}/tests/test_preview.py +0 -0
  155. {thth-3.8.2 → thth-3.9.0}/tests/test_production_mode.py +0 -0
  156. {thth-3.8.2 → thth-3.9.0}/tests/test_pull_cli.py +0 -0
  157. {thth-3.8.2 → thth-3.9.0}/tests/test_queue.py +0 -0
  158. {thth-3.8.2 → thth-3.9.0}/tests/test_quiet_hours.py +0 -0
  159. {thth-3.8.2 → thth-3.9.0}/tests/test_r2_r4_r5.py +0 -0
  160. {thth-3.8.2 → thth-3.9.0}/tests/test_read_cli_bare_runtimeerror.py +0 -0
  161. {thth-3.8.2 → thth-3.9.0}/tests/test_reason_adoption.py +0 -0
  162. {thth-3.8.2 → thth-3.9.0}/tests/test_redact.py +0 -0
  163. {thth-3.8.2 → thth-3.9.0}/tests/test_redirect_token_leak.py +0 -0
  164. {thth-3.8.2 → thth-3.9.0}/tests/test_refresh.py +0 -0
  165. {thth-3.8.2 → thth-3.9.0}/tests/test_refresh_destination.py +0 -0
  166. {thth-3.8.2 → thth-3.9.0}/tests/test_rejudge_new.py +0 -0
  167. {thth-3.8.2 → thth-3.9.0}/tests/test_rejudge_r2_r3.py +0 -0
  168. {thth-3.8.2 → thth-3.9.0}/tests/test_replies.py +0 -0
  169. {thth-3.8.2 → thth-3.9.0}/tests/test_replies_refresh.py +0 -0
  170. {thth-3.8.2 → thth-3.9.0}/tests/test_repo_lock.py +0 -0
  171. {thth-3.8.2 → thth-3.9.0}/tests/test_repo_sync.py +0 -0
  172. {thth-3.8.2 → thth-3.9.0}/tests/test_report_checkout_import.py +0 -0
  173. {thth-3.8.2 → thth-3.9.0}/tests/test_report_http.py +0 -0
  174. {thth-3.8.2 → thth-3.9.0}/tests/test_report_isolation.py +0 -0
  175. {thth-3.8.2 → thth-3.9.0}/tests/test_report_service.py +0 -0
  176. {thth-3.8.2 → thth-3.9.0}/tests/test_reverse_audit.py +0 -0
  177. {thth-3.8.2 → thth-3.9.0}/tests/test_review_case_id_cluster_m2.py +0 -0
  178. {thth-3.8.2 → thth-3.9.0}/tests/test_review_case_id_cluster_r2.py +0 -0
  179. {thth-3.8.2 → thth-3.9.0}/tests/test_review_cli.py +0 -0
  180. {thth-3.8.2 → thth-3.9.0}/tests/test_review_evidence_account.py +0 -0
  181. {thth-3.8.2 → thth-3.9.0}/tests/test_review_form_spec_and_case_id.py +0 -0
  182. {thth-3.8.2 → thth-3.9.0}/tests/test_review_provenance.py +0 -0
  183. {thth-3.8.2 → thth-3.9.0}/tests/test_review_records.py +0 -0
  184. {thth-3.8.2 → thth-3.9.0}/tests/test_revoke.py +0 -0
  185. {thth-3.8.2 → thth-3.9.0}/tests/test_roundtrip.py +0 -0
  186. {thth-3.8.2 → thth-3.9.0}/tests/test_run_missing_env.py +0 -0
  187. {thth-3.8.2 → thth-3.9.0}/tests/test_run_record_failclosed.py +0 -0
  188. {thth-3.8.2 → thth-3.9.0}/tests/test_runs_collected.py +0 -0
  189. {thth-3.8.2 → thth-3.9.0}/tests/test_runs_read_rows.py +0 -0
  190. {thth-3.8.2 → thth-3.9.0}/tests/test_schedule_multiple.py +0 -0
  191. {thth-3.8.2 → thth-3.9.0}/tests/test_schedule_view.py +0 -0
  192. {thth-3.8.2 → thth-3.9.0}/tests/test_scopes_source_requested.py +0 -0
  193. {thth-3.8.2 → thth-3.9.0}/tests/test_search_engage_targets.py +0 -0
  194. {thth-3.8.2 → thth-3.9.0}/tests/test_search_media_routes.py +0 -0
  195. {thth-3.8.2 → thth-3.9.0}/tests/test_secret_value_redact.py +0 -0
  196. {thth-3.8.2 → thth-3.9.0}/tests/test_secrets_grep.py +0 -0
  197. {thth-3.8.2 → thth-3.9.0}/tests/test_security_small_fixes.py +0 -0
  198. {thth-3.8.2 → thth-3.9.0}/tests/test_select.py +0 -0
  199. {thth-3.8.2 → thth-3.9.0}/tests/test_selfupdate.py +0 -0
  200. {thth-3.8.2 → thth-3.9.0}/tests/test_send.py +0 -0
  201. {thth-3.8.2 → thth-3.9.0}/tests/test_send_cli.py +0 -0
  202. {thth-3.8.2 → thth-3.9.0}/tests/test_send_control_chars.py +0 -0
  203. {thth-3.8.2 → thth-3.9.0}/tests/test_send_media.py +0 -0
  204. {thth-3.8.2 → thth-3.9.0}/tests/test_send_visibility.py +0 -0
  205. {thth-3.8.2 → thth-3.9.0}/tests/test_sent_integrity.py +0 -0
  206. {thth-3.8.2 → thth-3.9.0}/tests/test_server_json.py +0 -0
  207. {thth-3.8.2 → thth-3.9.0}/tests/test_site.py +0 -0
  208. {thth-3.8.2 → thth-3.9.0}/tests/test_skill_doc.py +0 -0
  209. {thth-3.8.2 → thth-3.9.0}/tests/test_study_report.py +0 -0
  210. {thth-3.8.2 → thth-3.9.0}/tests/test_systemd.py +0 -0
  211. {thth-3.8.2 → thth-3.9.0}/tests/test_thread_publish.py +0 -0
  212. {thth-3.8.2 → thth-3.9.0}/tests/test_thread_read.py +0 -0
  213. {thth-3.8.2 → thth-3.9.0}/tests/test_threadrun.py +0 -0
  214. {thth-3.8.2 → thth-3.9.0}/tests/test_threads_read_permissions.py +0 -0
  215. {thth-3.8.2 → thth-3.9.0}/tests/test_threadshape.py +0 -0
  216. {thth-3.8.2 → thth-3.9.0}/tests/test_thth_root.py +0 -0
  217. {thth-3.8.2 → thth-3.9.0}/tests/test_token_exists.py +0 -0
  218. {thth-3.8.2 → thth-3.9.0}/tests/test_token_scopes_record.py +0 -0
  219. {thth-3.8.2 → thth-3.9.0}/tests/test_token_set.py +0 -0
  220. {thth-3.8.2 → thth-3.9.0}/tests/test_topic.py +0 -0
  221. {thth-3.8.2 → thth-3.9.0}/tests/test_topic_advice.py +0 -0
  222. {thth-3.8.2 → thth-3.9.0}/tests/test_topic_cli.py +0 -0
  223. {thth-3.8.2 → thth-3.9.0}/tests/test_topic_models.py +0 -0
  224. {thth-3.8.2 → thth-3.9.0}/tests/test_topic_review_fixes.py +0 -0
  225. {thth-3.8.2 → thth-3.9.0}/tests/test_topic_shelf_observers.py +0 -0
  226. {thth-3.8.2 → thth-3.9.0}/tests/test_topic_store.py +0 -0
  227. {thth-3.8.2 → thth-3.9.0}/tests/test_topics_record_concurrent.py +0 -0
  228. {thth-3.8.2 → thth-3.9.0}/tests/test_topics_tool.py +0 -0
  229. {thth-3.8.2 → thth-3.9.0}/tests/test_trial_frictions.py +0 -0
  230. {thth-3.8.2 → thth-3.9.0}/tests/test_v210_admin_transaction.py +0 -0
  231. {thth-3.8.2 → thth-3.9.0}/tests/test_v210_approve_account.py +0 -0
  232. {thth-3.8.2 → thth-3.9.0}/tests/test_v210_json_contract.py +0 -0
  233. {thth-3.8.2 → thth-3.9.0}/tests/test_v210_length.py +0 -0
  234. {thth-3.8.2 → thth-3.9.0}/tests/test_v210_lock_wait.py +0 -0
  235. {thth-3.8.2 → thth-3.9.0}/tests/test_v210_mastodon_scopes.py +0 -0
  236. {thth-3.8.2 → thth-3.9.0}/tests/test_v210_mentions.py +0 -0
  237. {thth-3.8.2 → thth-3.9.0}/tests/test_v210_post_urls.py +0 -0
  238. {thth-3.8.2 → thth-3.9.0}/tests/test_v210_repo_paths.py +0 -0
  239. {thth-3.8.2 → thth-3.9.0}/tests/test_v210_reposts.py +0 -0
  240. {thth-3.8.2 → thth-3.9.0}/tests/test_v210_study_add.py +0 -0
  241. {thth-3.8.2 → thth-3.9.0}/tests/test_v210_tool_version.py +0 -0
  242. {thth-3.8.2 → thth-3.9.0}/tests/test_v210_unanswered.py +0 -0
  243. {thth-3.8.2 → thth-3.9.0}/tests/test_v210_unanswered_scope.py +0 -0
  244. {thth-3.8.2 → thth-3.9.0}/tests/test_v210_where_arguments.py +0 -0
  245. {thth-3.8.2 → thth-3.9.0}/tests/test_v210_where_filters.py +0 -0
  246. {thth-3.8.2 → thth-3.9.0}/tests/test_v210_who_arguments.py +0 -0
  247. {thth-3.8.2 → thth-3.9.0}/tests/test_v211_auth_integration.py +0 -0
  248. {thth-3.8.2 → thth-3.9.0}/tests/test_v211_authflow.py +0 -0
  249. {thth-3.8.2 → thth-3.9.0}/tests/test_v211_bluesky_stdin.py +0 -0
  250. {thth-3.8.2 → thth-3.9.0}/tests/test_v211_doctor_binding.py +0 -0
  251. {thth-3.8.2 → thth-3.9.0}/tests/test_v211_mastodon_auth.py +0 -0
  252. {thth-3.8.2 → thth-3.9.0}/tests/test_v211_x_auth.py +0 -0
  253. {thth-3.8.2 → thth-3.9.0}/tests/test_v212_approval_relay.py +0 -0
  254. {thth-3.8.2 → thth-3.9.0}/tests/test_v212_ask_coordination.py +0 -0
  255. {thth-3.8.2 → thth-3.9.0}/tests/test_v212_budget.py +0 -0
  256. {thth-3.8.2 → thth-3.9.0}/tests/test_v212_budget_auth.py +0 -0
  257. {thth-3.8.2 → thth-3.9.0}/tests/test_v212_budget_mcp.py +0 -0
  258. {thth-3.8.2 → thth-3.9.0}/tests/test_v212_deletion.py +0 -0
  259. {thth-3.8.2 → thth-3.9.0}/tests/test_v212_job_completion_fault.py +0 -0
  260. {thth-3.8.2 → thth-3.9.0}/tests/test_v212_leave.py +0 -0
  261. {thth-3.8.2 → thth-3.9.0}/tests/test_v212_leave_gate.py +0 -0
  262. {thth-3.8.2 → thth-3.9.0}/tests/test_v212_leave_providers.py +0 -0
  263. {thth-3.8.2 → thth-3.9.0}/tests/test_v212_leave_token_safety.py +0 -0
  264. {thth-3.8.2 → thth-3.9.0}/tests/test_v212_mastodon_guide.py +0 -0
  265. {thth-3.8.2 → thth-3.9.0}/tests/test_v212_mcp_compat.py +0 -0
  266. {thth-3.8.2 → thth-3.9.0}/tests/test_v212_missing_token.py +0 -0
  267. {thth-3.8.2 → thth-3.9.0}/tests/test_v212_read_coordination.py +0 -0
  268. {thth-3.8.2 → thth-3.9.0}/tests/test_v212_server_writes.py +0 -0
  269. {thth-3.8.2 → thth-3.9.0}/tests/test_v212_token_parent.py +0 -0
  270. {thth-3.8.2 → thth-3.9.0}/tests/test_v212_write_storage.py +0 -0
  271. {thth-3.8.2 → thth-3.9.0}/tests/test_v2131_send_dry_run.py +0 -0
  272. {thth-3.8.2 → thth-3.9.0}/tests/test_v2131_threads_publish_wait.py +0 -0
  273. {thth-3.8.2 → thth-3.9.0}/tests/test_v2132_send_inflight_hint.py +0 -0
  274. {thth-3.8.2 → thth-3.9.0}/tests/test_v2132_threads_create_timeout.py +0 -0
  275. {thth-3.8.2 → thth-3.9.0}/tests/test_v2132_threads_create_unknown.py +0 -0
  276. {thth-3.8.2 → thth-3.9.0}/tests/test_v213_acceptance_fixes.py +0 -0
  277. {thth-3.8.2 → thth-3.9.0}/tests/test_v213_adts_asf.py +0 -0
  278. {thth-3.8.2 → thth-3.9.0}/tests/test_v213_approval_attachments.py +0 -0
  279. {thth-3.8.2 → thth-3.9.0}/tests/test_v213_attachment_kinds.py +0 -0
  280. {thth-3.8.2 → thth-3.9.0}/tests/test_v213_bluesky_external.py +0 -0
  281. {thth-3.8.2 → thth-3.9.0}/tests/test_v213_bluesky_media.py +0 -0
  282. {thth-3.8.2 → thth-3.9.0}/tests/test_v213_bluesky_metadata.py +0 -0
  283. {thth-3.8.2 → thth-3.9.0}/tests/test_v213_bluesky_metadata_uri.py +0 -0
  284. {thth-3.8.2 → thth-3.9.0}/tests/test_v213_bluesky_metadata_video.py +0 -0
  285. {thth-3.8.2 → thth-3.9.0}/tests/test_v213_bluesky_record.py +0 -0
  286. {thth-3.8.2 → thth-3.9.0}/tests/test_v213_bluesky_record_video.py +0 -0
  287. {thth-3.8.2 → thth-3.9.0}/tests/test_v213_bluesky_video.py +0 -0
  288. {thth-3.8.2 → thth-3.9.0}/tests/test_v213_bluesky_webm_named.py +0 -0
  289. {thth-3.8.2 → thth-3.9.0}/tests/test_v213_bmff_typed_metadata.py +0 -0
  290. {thth-3.8.2 → thth-3.9.0}/tests/test_v213_budget_lock.py +0 -0
  291. {thth-3.8.2 → thth-3.9.0}/tests/test_v213_capabilities.py +0 -0
  292. {thth-3.8.2 → thth-3.9.0}/tests/test_v213_carryover_timeout.py +0 -0
  293. {thth-3.8.2 → thth-3.9.0}/tests/test_v213_graphemes.py +0 -0
  294. {thth-3.8.2 → thth-3.9.0}/tests/test_v213_mastodon_auth_media.py +0 -0
  295. {thth-3.8.2 → thth-3.9.0}/tests/test_v213_mastodon_bmff_audio.py +0 -0
  296. {thth-3.8.2 → thth-3.9.0}/tests/test_v213_mastodon_flac.py +0 -0
  297. {thth-3.8.2 → thth-3.9.0}/tests/test_v213_mastodon_gif.py +0 -0
  298. {thth-3.8.2 → thth-3.9.0}/tests/test_v213_mastodon_link.py +0 -0
  299. {thth-3.8.2 → thth-3.9.0}/tests/test_v213_mastodon_media.py +0 -0
  300. {thth-3.8.2 → thth-3.9.0}/tests/test_v213_mastodon_mp3.py +0 -0
  301. {thth-3.8.2 → thth-3.9.0}/tests/test_v213_mastodon_ogg_opus.py +0 -0
  302. {thth-3.8.2 → thth-3.9.0}/tests/test_v213_mastodon_ogg_vorbis.py +0 -0
  303. {thth-3.8.2 → thth-3.9.0}/tests/test_v213_mastodon_poll.py +0 -0
  304. {thth-3.8.2 → thth-3.9.0}/tests/test_v213_mastodon_primary_size_boundary.py +0 -0
  305. {thth-3.8.2 → thth-3.9.0}/tests/test_v213_mastodon_quote.py +0 -0
  306. {thth-3.8.2 → thth-3.9.0}/tests/test_v213_mastodon_thumbnail.py +0 -0
  307. {thth-3.8.2 → thth-3.9.0}/tests/test_v213_mastodon_wav.py +0 -0
  308. {thth-3.8.2 → thth-3.9.0}/tests/test_v213_mastodon_webm_audio.py +0 -0
  309. {thth-3.8.2 → thth-3.9.0}/tests/test_v213_mastodon_webm_video.py +0 -0
  310. {thth-3.8.2 → thth-3.9.0}/tests/test_v213_media_cleanup.py +0 -0
  311. {thth-3.8.2 → thth-3.9.0}/tests/test_v213_media_formats.py +0 -0
  312. {thth-3.8.2 → thth-3.9.0}/tests/test_v213_media_foundation.py +0 -0
  313. {thth-3.8.2 → thth-3.9.0}/tests/test_v213_media_prepared.py +0 -0
  314. {thth-3.8.2 → thth-3.9.0}/tests/test_v213_media_relay.py +0 -0
  315. {thth-3.8.2 → thth-3.9.0}/tests/test_v213_media_relay_refusals.py +0 -0
  316. {thth-3.8.2 → thth-3.9.0}/tests/test_v213_media_upload.py +0 -0
  317. {thth-3.8.2 → thth-3.9.0}/tests/test_v213_receipt_deadline.py +0 -0
  318. {thth-3.8.2 → thth-3.9.0}/tests/test_v213_review_followups.py +0 -0
  319. {thth-3.8.2 → thth-3.9.0}/tests/test_v213_stage810_audit.py +0 -0
  320. {thth-3.8.2 → thth-3.9.0}/tests/test_v213_static_diagnostics.py +0 -0
  321. {thth-3.8.2 → thth-3.9.0}/tests/test_v213_stop_observation.py +0 -0
  322. {thth-3.8.2 → thth-3.9.0}/tests/test_v213_systemd_worker.py +0 -0
  323. {thth-3.8.2 → thth-3.9.0}/tests/test_v213_threads_ghost.py +0 -0
  324. {thth-3.8.2 → thth-3.9.0}/tests/test_v213_threads_gif.py +0 -0
  325. {thth-3.8.2 → thth-3.9.0}/tests/test_v213_threads_link.py +0 -0
  326. {thth-3.8.2 → thth-3.9.0}/tests/test_v213_threads_media.py +0 -0
  327. {thth-3.8.2 → thth-3.9.0}/tests/test_v213_threads_media_spoiler.py +0 -0
  328. {thth-3.8.2 → thth-3.9.0}/tests/test_v213_threads_poll.py +0 -0
  329. {thth-3.8.2 → thth-3.9.0}/tests/test_v213_threads_quote.py +0 -0
  330. {thth-3.8.2 → thth-3.9.0}/tests/test_v213_threads_quote_media.py +0 -0
  331. {thth-3.8.2 → thth-3.9.0}/tests/test_v213_threads_replies.py +0 -0
  332. {thth-3.8.2 → thth-3.9.0}/tests/test_v213_threads_text.py +0 -0
  333. {thth-3.8.2 → thth-3.9.0}/tests/test_v213_threads_text_spoiler.py +0 -0
  334. {thth-3.8.2 → thth-3.9.0}/tests/test_v213_threads_video.py +0 -0
  335. {thth-3.8.2 → thth-3.9.0}/tests/test_v213_transport_review.py +0 -0
  336. {thth-3.8.2 → thth-3.9.0}/tests/test_v213_video_typed_metadata.py +0 -0
  337. {thth-3.8.2 → thth-3.9.0}/tests/test_v213_vorbis_comment_boundary.py +0 -0
  338. {thth-3.8.2 → thth-3.9.0}/tests/test_v213_webm_schema_boundary.py +0 -0
  339. {thth-3.8.2 → thth-3.9.0}/tests/test_v2141_draft_lint_reason.py +0 -0
  340. {thth-3.8.2 → thth-3.9.0}/tests/test_v2141_managed_repo_umask.py +0 -0
  341. {thth-3.8.2 → thth-3.9.0}/tests/test_v214_release_signing.py +0 -0
  342. {thth-3.8.2 → thth-3.9.0}/tests/test_v214_x_adapter.py +0 -0
  343. {thth-3.8.2 → thth-3.9.0}/tests/test_v214_x_auth.py +0 -0
  344. {thth-3.8.2 → thth-3.9.0}/tests/test_v214_x_media.py +0 -0
  345. {thth-3.8.2 → thth-3.9.0}/tests/test_v214_x_posts_budget.py +0 -0
  346. {thth-3.8.2 → thth-3.9.0}/tests/test_v21b_approved_writes.py +0 -0
  347. {thth-3.8.2 → thth-3.9.0}/tests/test_v28_tags.py +0 -0
  348. {thth-3.8.2 → thth-3.9.0}/tests/test_v310_morning.py +0 -0
  349. {thth-3.8.2 → thth-3.9.0}/tests/test_v310_search_timeout.py +0 -0
  350. {thth-3.8.2 → thth-3.9.0}/tests/test_v310_watch_words.py +0 -0
  351. {thth-3.8.2 → thth-3.9.0}/tests/test_v311_leave_same_user.py +0 -0
  352. {thth-3.8.2 → thth-3.9.0}/tests/test_v311_mastodon_retract.py +0 -0
  353. {thth-3.8.2 → thth-3.9.0}/tests/test_v311_morning_overdue.py +0 -0
  354. {thth-3.8.2 → thth-3.9.0}/tests/test_v311_morning_own_exclusion.py +0 -0
  355. {thth-3.8.2 → thth-3.9.0}/tests/test_v311_morning_unanswered_window.py +0 -0
  356. {thth-3.8.2 → thth-3.9.0}/tests/test_v311_notifications_guidance.py +0 -0
  357. {thth-3.8.2 → thth-3.9.0}/tests/test_v311_replies_owned.py +0 -0
  358. {thth-3.8.2 → thth-3.9.0}/tests/test_v312_leave_shared_token.py +0 -0
  359. {thth-3.8.2 → thth-3.9.0}/tests/test_v312_own_handles_stopped.py +0 -0
  360. {thth-3.8.2 → thth-3.9.0}/tests/test_v312_report_admin.py +0 -0
  361. {thth-3.8.2 → thth-3.9.0}/tests/test_v312_report_channel.py +0 -0
  362. {thth-3.8.2 → thth-3.9.0}/tests/test_v312_report_inbox.py +0 -0
  363. {thth-3.8.2 → thth-3.9.0}/tests/test_v312_report_mcp_user.py +0 -0
  364. {thth-3.8.2 → thth-3.9.0}/tests/test_v31_analysis.py +0 -0
  365. {thth-3.8.2 → thth-3.9.0}/tests/test_v31_collection.py +0 -0
  366. {thth-3.8.2 → thth-3.9.0}/tests/test_v320_reply_to_file_display.py +0 -0
  367. {thth-3.8.2 → thth-3.9.0}/tests/test_v320_reply_to_file_lint.py +0 -0
  368. {thth-3.8.2 → thth-3.9.0}/tests/test_v320_reply_to_file_resolve.py +0 -0
  369. {thth-3.8.2 → thth-3.9.0}/tests/test_v320_report_fixes.py +0 -0
  370. {thth-3.8.2 → thth-3.9.0}/tests/test_v330_fingerprint_version.py +0 -0
  371. {thth-3.8.2 → thth-3.9.0}/tests/test_v330_held.py +0 -0
  372. {thth-3.8.2 → thth-3.9.0}/tests/test_v330_held_names.py +0 -0
  373. {thth-3.8.2 → thth-3.9.0}/tests/test_v330_last_refusals.py +0 -0
  374. {thth-3.8.2 → thth-3.9.0}/tests/test_v330_notification_route.py +0 -0
  375. {thth-3.8.2 → thth-3.9.0}/tests/test_v330_observe.py +0 -0
  376. {thth-3.8.2 → thth-3.9.0}/tests/test_v330_report_friction.py +0 -0
  377. {thth-3.8.2 → thth-3.9.0}/tests/test_v330_report_words.py +0 -0
  378. {thth-3.8.2 → thth-3.9.0}/tests/test_v330_reports_effect.py +0 -0
  379. {thth-3.8.2 → thth-3.9.0}/tests/test_v331_inflight_cli.py +0 -0
  380. {thth-3.8.2 → thth-3.9.0}/tests/test_v331_listing_resolve.py +0 -0
  381. {thth-3.8.2 → thth-3.9.0}/tests/test_v331_outbox_retention.py +0 -0
  382. {thth-3.8.2 → thth-3.9.0}/tests/test_v331_publish_status.py +0 -0
  383. {thth-3.8.2 → thth-3.9.0}/tests/test_v331_self_resolve.py +0 -0
  384. {thth-3.8.2 → thth-3.9.0}/tests/test_v340_plaza_cli.py +0 -0
  385. {thth-3.8.2 → thth-3.9.0}/tests/test_v340_plaza_docs.py +0 -0
  386. {thth-3.8.2 → thth-3.9.0}/tests/test_v340_plaza_fields.py +0 -0
  387. {thth-3.8.2 → thth-3.9.0}/tests/test_v340_plaza_mcp.py +0 -0
  388. {thth-3.8.2 → thth-3.9.0}/tests/test_v340_plaza_morning.py +0 -0
  389. {thth-3.8.2 → thth-3.9.0}/tests/test_v340_plaza_observe.py +0 -0
  390. {thth-3.8.2 → thth-3.9.0}/tests/test_v340_plaza_open.py +0 -0
  391. {thth-3.8.2 → thth-3.9.0}/tests/test_v340_plaza_open_confirm.py +0 -0
  392. {thth-3.8.2 → thth-3.9.0}/tests/test_v340_plaza_reply.py +0 -0
  393. {thth-3.8.2 → thth-3.9.0}/tests/test_v340_plaza_store.py +0 -0
  394. {thth-3.8.2 → thth-3.9.0}/tests/test_v350_map_docs.py +0 -0
  395. {thth-3.8.2 → thth-3.9.0}/tests/test_v350_map_nodes.py +0 -0
  396. {thth-3.8.2 → thth-3.9.0}/tests/test_v350_map_observe.py +0 -0
  397. {thth-3.8.2 → thth-3.9.0}/tests/test_v350_map_plaza.py +0 -0
  398. {thth-3.8.2 → thth-3.9.0}/tests/test_v350_map_retention.py +0 -0
  399. {thth-3.8.2 → thth-3.9.0}/tests/test_v350_map_self.py +0 -0
  400. {thth-3.8.2 → thth-3.9.0}/tests/test_v350_map_show.py +0 -0
  401. {thth-3.8.2 → thth-3.9.0}/tests/test_v350_map_world.py +0 -0
  402. {thth-3.8.2 → thth-3.9.0}/tests/test_v351_leave_timers.py +0 -0
  403. {thth-3.8.2 → thth-3.9.0}/tests/test_v351_plaza_open_holes.py +0 -0
  404. {thth-3.8.2 → thth-3.9.0}/tests/test_v351_who_owned.py +0 -0
  405. {thth-3.8.2 → thth-3.9.0}/tests/test_v360_goal_analytics.py +0 -0
  406. {thth-3.8.2 → thth-3.9.0}/tests/test_v360_goal_docs.py +0 -0
  407. {thth-3.8.2 → thth-3.9.0}/tests/test_v360_goal_observe_map.py +0 -0
  408. {thth-3.8.2 → thth-3.9.0}/tests/test_v360_goal_plaza.py +0 -0
  409. {thth-3.8.2 → thth-3.9.0}/tests/test_v360_goal_record.py +0 -0
  410. {thth-3.8.2 → thth-3.9.0}/tests/test_v360_goal_send.py +0 -0
  411. {thth-3.8.2 → thth-3.9.0}/tests/test_v360_report_timeline.py +0 -0
  412. {thth-3.8.2 → thth-3.9.0}/tests/test_v370_account_self_healing.py +0 -0
  413. {thth-3.8.2 → thth-3.9.0}/tests/test_v370_approve_pending.py +0 -0
  414. {thth-3.8.2 → thth-3.9.0}/tests/test_v370_click_attribution.py +0 -0
  415. {thth-3.8.2 → thth-3.9.0}/tests/test_v370_collection_stale_basis.py +0 -0
  416. {thth-3.8.2 → thth-3.9.0}/tests/test_v370_docs.py +0 -0
  417. {thth-3.8.2 → thth-3.9.0}/tests/test_v370_follow_comparison.py +0 -0
  418. {thth-3.8.2 → thth-3.9.0}/tests/test_v370_local_path.py +0 -0
  419. {thth-3.8.2 → thth-3.9.0}/tests/test_v370_weekly_goals.py +0 -0
  420. {thth-3.8.2 → thth-3.9.0}/tests/test_v370_where_aggregate.py +0 -0
  421. {thth-3.8.2 → thth-3.9.0}/tests/test_v370_where_also.py +0 -0
  422. {thth-3.8.2 → thth-3.9.0}/tests/test_v370_where_zero.py +0 -0
  423. {thth-3.8.2 → thth-3.9.0}/tests/test_v380_docs.py +0 -0
  424. {thth-3.8.2 → thth-3.9.0}/tests/test_v380_plaza_digest.py +0 -0
  425. {thth-3.8.2 → thth-3.9.0}/tests/test_v380_plaza_from.py +0 -0
  426. {thth-3.8.2 → thth-3.9.0}/tests/test_v380_plaza_mcp.py +0 -0
  427. {thth-3.8.2 → thth-3.9.0}/tests/test_v380_plaza_moments.py +0 -0
  428. {thth-3.8.2 → thth-3.9.0}/tests/test_v380_plaza_owner.py +0 -0
  429. {thth-3.8.2 → thth-3.9.0}/tests/test_v380_plaza_received.py +0 -0
  430. {thth-3.8.2 → thth-3.9.0}/tests/test_v380_plaza_triggers.py +0 -0
  431. {thth-3.8.2 → thth-3.9.0}/tests/test_v381_plaza_visibility_default.py +0 -0
  432. {thth-3.8.2 → thth-3.9.0}/tests/test_v382_confirm_file.py +0 -0
  433. {thth-3.8.2 → thth-3.9.0}/tests/test_v382_first_stage_not_refusal.py +0 -0
  434. {thth-3.8.2 → thth-3.9.0}/tests/test_v382_reader_lock_and_push_pending.py +0 -0
  435. {thth-3.8.2 → thth-3.9.0}/tests/test_v382_sync_without_fetch_head.py +0 -0
  436. {thth-3.8.2 → thth-3.9.0}/tests/test_value_domains_shown.py +0 -0
  437. {thth-3.8.2 → thth-3.9.0}/tests/test_version.py +0 -0
  438. {thth-3.8.2 → thth-3.9.0}/tests/test_where_cli.py +0 -0
  439. {thth-3.8.2 → thth-3.9.0}/tests/test_who_cli.py +0 -0
  440. {thth-3.8.2 → thth-3.9.0}/tests/test_writeback_rebase.py +0 -0
  441. {thth-3.8.2 → thth-3.9.0}/tests/test_writeback_validate.py +0 -0
  442. {thth-3.8.2 → thth-3.9.0}/tests/test_x_metrics.py +0 -0
  443. {thth-3.8.2 → thth-3.9.0}/thth/__init__.py +0 -0
  444. {thth-3.8.2 → thth-3.9.0}/thth/__main__.py +0 -0
  445. {thth-3.8.2 → thth-3.9.0}/thth/_grapheme_data.py +0 -0
  446. {thth-3.8.2 → thth-3.9.0}/thth/account_cli.py +0 -0
  447. {thth-3.8.2 → thth-3.9.0}/thth/account_report.py +0 -0
  448. {thth-3.8.2 → thth-3.9.0}/thth/accounts.py +0 -0
  449. {thth-3.8.2 → thth-3.9.0}/thth/adapters/__init__.py +0 -0
  450. {thth-3.8.2 → thth-3.9.0}/thth/adapters/auth_mastodon.py +0 -0
  451. {thth-3.8.2 → thth-3.9.0}/thth/adapters/auth_threads.py +0 -0
  452. {thth-3.8.2 → thth-3.9.0}/thth/adapters/auth_x.py +0 -0
  453. {thth-3.8.2 → thth-3.9.0}/thth/adapters/base.py +0 -0
  454. {thth-3.8.2 → thth-3.9.0}/thth/adapters/bluesky.py +0 -0
  455. {thth-3.8.2 → thth-3.9.0}/thth/adapters/bluesky_media.py +0 -0
  456. {thth-3.8.2 → thth-3.9.0}/thth/adapters/bluesky_video.py +0 -0
  457. {thth-3.8.2 → thth-3.9.0}/thth/adapters/mastodon.py +0 -0
  458. {thth-3.8.2 → thth-3.9.0}/thth/adapters/mastodon_media.py +0 -0
  459. {thth-3.8.2 → thth-3.9.0}/thth/adapters/threads.py +0 -0
  460. {thth-3.8.2 → thth-3.9.0}/thth/adapters/threads_media.py +0 -0
  461. {thth-3.8.2 → thth-3.9.0}/thth/adapters/x.py +0 -0
  462. {thth-3.8.2 → thth-3.9.0}/thth/adapters/x_media.py +0 -0
  463. {thth-3.8.2 → thth-3.9.0}/thth/admin_log.py +0 -0
  464. {thth-3.8.2 → thth-3.9.0}/thth/admin_notifications.py +0 -0
  465. {thth-3.8.2 → thth-3.9.0}/thth/admin_report.py +0 -0
  466. {thth-3.8.2 → thth-3.9.0}/thth/after_cli.py +0 -0
  467. {thth-3.8.2 → thth-3.9.0}/thth/analytics_comparison.py +0 -0
  468. {thth-3.8.2 → thth-3.9.0}/thth/analytics_shapes.py +0 -0
  469. {thth-3.8.2 → thth-3.9.0}/thth/analytics_threads.py +0 -0
  470. {thth-3.8.2 → thth-3.9.0}/thth/analytics_weekly.py +0 -0
  471. {thth-3.8.2 → thth-3.9.0}/thth/api_diagnostic.py +0 -0
  472. {thth-3.8.2 → thth-3.9.0}/thth/appconfig.py +0 -0
  473. {thth-3.8.2 → thth-3.9.0}/thth/appenv.py +0 -0
  474. {thth-3.8.2 → thth-3.9.0}/thth/approval.py +0 -0
  475. {thth-3.8.2 → thth-3.9.0}/thth/approval_jobs.py +0 -0
  476. {thth-3.8.2 → thth-3.9.0}/thth/approval_relay.py +0 -0
  477. {thth-3.8.2 → thth-3.9.0}/thth/approve_pending.py +0 -0
  478. {thth-3.8.2 → thth-3.9.0}/thth/ask.py +0 -0
  479. {thth-3.8.2 → thth-3.9.0}/thth/ask_cli.py +0 -0
  480. {thth-3.8.2 → thth-3.9.0}/thth/audioformats.py +0 -0
  481. {thth-3.8.2 → thth-3.9.0}/thth/authclients.py +0 -0
  482. {thth-3.8.2 → thth-3.9.0}/thth/authflow.py +0 -0
  483. {thth-3.8.2 → thth-3.9.0}/thth/authpassword.py +0 -0
  484. {thth-3.8.2 → thth-3.9.0}/thth/bluesky_metadata.py +0 -0
  485. {thth-3.8.2 → thth-3.9.0}/thth/budget_x.py +0 -0
  486. {thth-3.8.2 → thth-3.9.0}/thth/budget_x_posts.py +0 -0
  487. {thth-3.8.2 → thth-3.9.0}/thth/bundle.py +0 -0
  488. {thth-3.8.2 → thth-3.9.0}/thth/collect.py +0 -0
  489. {thth-3.8.2 → thth-3.9.0}/thth/collection_context.py +0 -0
  490. {thth-3.8.2 → thth-3.9.0}/thth/collection_status.py +0 -0
  491. {thth-3.8.2 → thth-3.9.0}/thth/core.py +0 -0
  492. {thth-3.8.2 → thth-3.9.0}/thth/deletion.py +0 -0
  493. {thth-3.8.2 → thth-3.9.0}/thth/doctor.py +0 -0
  494. {thth-3.8.2 → thth-3.9.0}/thth/ebmlaudio.py +0 -0
  495. {thth-3.8.2 → thth-3.9.0}/thth/ebmlvideo.py +0 -0
  496. {thth-3.8.2 → thth-3.9.0}/thth/engagements.py +0 -0
  497. {thth-3.8.2 → thth-3.9.0}/thth/fingerprint_golden.py +0 -0
  498. {thth-3.8.2 → thth-3.9.0}/thth/flacformats.py +0 -0
  499. {thth-3.8.2 → thth-3.9.0}/thth/forms.py +0 -0
  500. {thth-3.8.2 → thth-3.9.0}/thth/goals.py +0 -0
  501. {thth-3.8.2 → thth-3.9.0}/thth/graphemes.py +0 -0
  502. {thth-3.8.2 → thth-3.9.0}/thth/handoff_cursor.py +0 -0
  503. {thth-3.8.2 → thth-3.9.0}/thth/healthcheck.py +0 -0
  504. {thth-3.8.2 → thth-3.9.0}/thth/httpsafe.py +0 -0
  505. {thth-3.8.2 → thth-3.9.0}/thth/incident.py +0 -0
  506. {thth-3.8.2 → thth-3.9.0}/thth/inflight.py +0 -0
  507. {thth-3.8.2 → thth-3.9.0}/thth/inflight_cli.py +0 -0
  508. {thth-3.8.2 → thth-3.9.0}/thth/inflight_resolve.py +0 -0
  509. {thth-3.8.2 → thth-3.9.0}/thth/jst.py +0 -0
  510. {thth-3.8.2 → thth-3.9.0}/thth/leave.py +0 -0
  511. {thth-3.8.2 → thth-3.9.0}/thth/leave_gate.py +0 -0
  512. {thth-3.8.2 → thth-3.9.0}/thth/lint.py +0 -0
  513. {thth-3.8.2 → thth-3.9.0}/thth/lock.py +0 -0
  514. {thth-3.8.2 → thth-3.9.0}/thth/maintain.py +0 -0
  515. {thth-3.8.2 → thth-3.9.0}/thth/managed_repo.py +0 -0
  516. {thth-3.8.2 → thth-3.9.0}/thth/map_cli.py +0 -0
  517. {thth-3.8.2 → thth-3.9.0}/thth/map_store.py +0 -0
  518. {thth-3.8.2 → thth-3.9.0}/thth/map_view.py +0 -0
  519. {thth-3.8.2 → thth-3.9.0}/thth/map_world.py +0 -0
  520. {thth-3.8.2 → thth-3.9.0}/thth/measured.py +0 -0
  521. {thth-3.8.2 → thth-3.9.0}/thth/media.py +0 -0
  522. {thth-3.8.2 → thth-3.9.0}/thth/media_capabilities.py +0 -0
  523. {thth-3.8.2 → thth-3.9.0}/thth/media_cleanup.py +0 -0
  524. {thth-3.8.2 → thth-3.9.0}/thth/media_delivery.py +0 -0
  525. {thth-3.8.2 → thth-3.9.0}/thth/media_relay.py +0 -0
  526. {thth-3.8.2 → thth-3.9.0}/thth/media_uploads.py +0 -0
  527. {thth-3.8.2 → thth-3.9.0}/thth/mediaformats.py +0 -0
  528. {thth-3.8.2 → thth-3.9.0}/thth/morning.py +0 -0
  529. {thth-3.8.2 → thth-3.9.0}/thth/mpeg_audio.py +0 -0
  530. {thth-3.8.2 → thth-3.9.0}/thth/notification_route.py +0 -0
  531. {thth-3.8.2 → thth-3.9.0}/thth/oauth.py +0 -0
  532. {thth-3.8.2 → thth-3.9.0}/thth/oggformats.py +0 -0
  533. {thth-3.8.2 → thth-3.9.0}/thth/operations_handoff.py +0 -0
  534. {thth-3.8.2 → thth-3.9.0}/thth/plaza.py +0 -0
  535. {thth-3.8.2 → thth-3.9.0}/thth/plaza_cli.py +0 -0
  536. {thth-3.8.2 → thth-3.9.0}/thth/plaza_from.py +0 -0
  537. {thth-3.8.2 → thth-3.9.0}/thth/plaza_moments.py +0 -0
  538. {thth-3.8.2 → thth-3.9.0}/thth/plaza_observe.py +0 -0
  539. {thth-3.8.2 → thth-3.9.0}/thth/plaza_reads.py +0 -0
  540. {thth-3.8.2 → thth-3.9.0}/thth/plaza_redact.py +0 -0
  541. {thth-3.8.2 → thth-3.9.0}/thth/postid.py +0 -0
  542. {thth-3.8.2 → thth-3.9.0}/thth/private_store.py +0 -0
  543. {thth-3.8.2 → thth-3.9.0}/thth/queuefile.py +0 -0
  544. {thth-3.8.2 → thth-3.9.0}/thth/read_coordination.py +0 -0
  545. {thth-3.8.2 → thth-3.9.0}/thth/read_window.py +0 -0
  546. {thth-3.8.2 → thth-3.9.0}/thth/redact.py +0 -0
  547. {thth-3.8.2 → thth-3.9.0}/thth/refusals.py +0 -0
  548. {thth-3.8.2 → thth-3.9.0}/thth/replies.py +0 -0
  549. {thth-3.8.2 → thth-3.9.0}/thth/report.py +0 -0
  550. {thth-3.8.2 → thth-3.9.0}/thth/report_details.py +0 -0
  551. {thth-3.8.2 → thth-3.9.0}/thth/report_http.py +0 -0
  552. {thth-3.8.2 → thth-3.9.0}/thth/report_inbox.py +0 -0
  553. {thth-3.8.2 → thth-3.9.0}/thth/report_isolation.py +0 -0
  554. {thth-3.8.2 → thth-3.9.0}/thth/report_service.py +0 -0
  555. {thth-3.8.2 → thth-3.9.0}/thth/report_timeline.py +0 -0
  556. {thth-3.8.2 → thth-3.9.0}/thth/retract_cli.py +0 -0
  557. {thth-3.8.2 → thth-3.9.0}/thth/runs.py +0 -0
  558. {thth-3.8.2 → thth-3.9.0}/thth/scopes.py +0 -0
  559. {thth-3.8.2 → thth-3.9.0}/thth/secrets_fs.py +0 -0
  560. {thth-3.8.2 → thth-3.9.0}/thth/select.py +0 -0
  561. {thth-3.8.2 → thth-3.9.0}/thth/selfupdate.py +0 -0
  562. {thth-3.8.2 → thth-3.9.0}/thth/sent.py +0 -0
  563. {thth-3.8.2 → thth-3.9.0}/thth/server_files.py +0 -0
  564. {thth-3.8.2 → thth-3.9.0}/thth/server_writes.py +0 -0
  565. {thth-3.8.2 → thth-3.9.0}/thth/stop_observation.py +0 -0
  566. {thth-3.8.2 → thth-3.9.0}/thth/study_cli.py +0 -0
  567. {thth-3.8.2 → thth-3.9.0}/thth/study_report.py +0 -0
  568. {thth-3.8.2 → thth-3.9.0}/thth/systemd_gen.py +0 -0
  569. {thth-3.8.2 → thth-3.9.0}/thth/tags.py +0 -0
  570. {thth-3.8.2 → thth-3.9.0}/thth/thread_read.py +0 -0
  571. {thth-3.8.2 → thth-3.9.0}/thth/threadrun.py +0 -0
  572. {thth-3.8.2 → thth-3.9.0}/thth/threads_read_cli.py +0 -0
  573. {thth-3.8.2 → thth-3.9.0}/thth/threadshape.py +0 -0
  574. {thth-3.8.2 → thth-3.9.0}/thth/threadthrow.py +0 -0
  575. {thth-3.8.2 → thth-3.9.0}/thth/timer_cleanup.py +0 -0
  576. {thth-3.8.2 → thth-3.9.0}/thth/tool_version.py +0 -0
  577. {thth-3.8.2 → thth-3.9.0}/thth/topic_advice.py +0 -0
  578. {thth-3.8.2 → thth-3.9.0}/thth/topic_cli.py +0 -0
  579. {thth-3.8.2 → thth-3.9.0}/thth/topic_models.py +0 -0
  580. {thth-3.8.2 → thth-3.9.0}/thth/topic_store.py +0 -0
  581. {thth-3.8.2 → thth-3.9.0}/thth/topics.py +0 -0
  582. {thth-3.8.2 → thth-3.9.0}/thth/unanswered.py +0 -0
  583. {thth-3.8.2 → thth-3.9.0}/thth/vorbisformats.py +0 -0
  584. {thth-3.8.2 → thth-3.9.0}/thth/watch_cli.py +0 -0
  585. {thth-3.8.2 → thth-3.9.0}/thth/who_cli.py +0 -0
  586. {thth-3.8.2 → thth-3.9.0}/thth/writeback.py +0 -0
  587. {thth-3.8.2 → thth-3.9.0}/thth/x_metrics.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: thth
3
- Version: 3.8.2
3
+ Version: 3.9.0
4
4
  Summary: 承認済みの投稿だけを投げる門(Threads・Bluesky・Mastodon)。人が承認し、記録は利用者の git に残る。
5
5
  Project-URL: Homepage, https://thth.me
6
6
  Project-URL: Source, https://github.com/aokings/thth
@@ -19,6 +19,8 @@ description: 投稿する前・返信を書く前・出したあと・絡みに
19
19
 
20
20
  **click はリンク先を投稿ごとに分けると投稿単位で測れる**(3.7.0)。あるリンク先を前後 72 時間で 1 本の投稿しか使っていなければ、`clicks_by_url` のそのリンク先の投稿日を含む 3 暦日のクリックを投稿のものとして出す(`basis: unique_url_72h`・`window: post_day_plus_2`・クリック率は clicks_72h / views_24h)。同じリンク先を前後 72 時間に 2 本以上で使う・リンクが無い・プロフィールのリンク(台帳の `profile_links`)と同じなら `url_shared_72h`・`no_link`・`profile_link` で言わない。lint は同じリンク先を前後 72 時間に使う click の原稿を `warning: url_shared_72h` で知らせる(断らない・同じ日でもリンク先が違えば測れる)。**本文に札(utm)を付けて書き換えない**——リンク先を分ける。follow の投稿が毎日出ていれば `no_comparison_days`(事実だけ・推奨しない)。週の表は `thth analytics-report <account> --weekly-goals --weeks 6`(観察の表)。承認は 1 段目だけでは出ない——**確定するまで出ません**(queue・board・observe に「確定待ち」、publish_at まで 3 時間を切ると observe の次の一手に `confirm_due` と確定の命令)。`thth account` は「run を止めるもの」と「run が自分で直すもの」(repo が遅れているだけ)を分け、出られない原稿(held)は別の段(「投稿できます」のまま)。連投も段ごとのリンク先が記録に残り、click の照合に入る。`thth lint`・`preview`・`approve` は手元の Mac のパス(`…/docs/sns/queue/<file>`)を VM のパスに読み替える。`thth where` は `--also <語>`(同じ結果を絞るだけ・追加の検索なし)・0 件は `zero_or_filtered`(無いのか受け付けなかったのか区別できない)・`--aggregate [--lexicon <file>]`(数と時刻だけ・本文も username も返さない・保存しない)。
21
21
 
22
+ **検索の語は道具が数えて候補を出し、クリックは目的に依らず投稿ごとに見る**(3.9.0)。`thth analytics-report <account> --per-post-clicks [--since 30d]` は goal を問わず、一意のリンク先の投稿(`unique_url_72h`)の 72h のクリック・クリック率・窓の後(参考)・窓の前(`clicks_before_post`)を並べる——goal の層とは混ぜない(unrecorded の投稿もリンク先が記録にあれば並ぶ)。**窓の前と後は窓の和に足さない**。`clicks_before_post` が 1 以上なら「THTH を通していない投稿か Threads の外のクリックが混ざっている可能性」の 1 行(原因は言わない)。`--by goal` の click にも同じ欄。`thth where <account> <語…> --also <語>` は語ごとの当たり率(取れた件数・also に合った件数)を高い順に並べ、0 件の語に「also に合った投稿が 0 件(直近 n 日)」。`--suggest` は語の候補: 自分のデータ(地図の点・原稿の topic と goal・反応の多かった topic)が先、次に also に合った投稿の本文で 2 投稿以上に出た語(その場の人が書いた語・上位 10)。**語と数だけで、本文・username・post_id は出ない・保存しない**(MCP の `where_to_appear` には無い)。`--aggregate` の `rate_by_span` は返った投稿の期間で割った速さ(n と期間の時間つき・n < 20 は `rough`)。要求数に届かず期間が短いと `more_may_exist`(これで全部かは区別できない)。
23
+
22
24
  段だけを読み直したいときは `thth handoff-report <account> --since-last-read --json` を読む。`tool.changed_since_last_read` が真なら、手元の `tool.notes_root_local_hint` を手掛かりに `tool.release_notes` の相対名を読んでから作業する。既読の記録は `--mark-read --by <名前>` の明示時だけ。`tool.notes_reason` が `notes_directory_unavailable` なら、このインストールには読める docs がない。空の `release_notes` を「変更なし」と解釈しない。
23
25
 
24
26
  添付を付ける前に `tool.capabilities` を読む(媒体ごとの「出せるもの」表。`thth doctor` にも同じ表が出る。`unverified` は「非対応」ではない)。
@@ -0,0 +1,51 @@
1
+ """3.9.0 文書と skill(設計 3.9.0 §A〜§C)。
2
+
3
+ 見るのは:
4
+ - skill と使い方の文書に 3.9.0 の段(--per-post-clicks・clicks_before_post・also の当たり率・
5
+ --suggest は語と数だけで保存しない・rate_by_span・more_may_exist)。
6
+ - リリースノートの題(「— 下書き」の有無は固定しない)と、各 project のセッション向けの 5 行、
7
+ 材料の報告 id。
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+
13
+ ROOT = Path(__file__).resolve().parent.parent
14
+ REPORT_IDS = ("r20260924-8b8d37b5", "r20260924-9d3a6e1a", "r20260924-a1ba1829")
15
+
16
+
17
+ def test_skillに3_9_0の段():
18
+ skill = (ROOT / "skills" / "thth" / "SKILL.md").read_text(encoding="utf-8")
19
+ marker = "**検索の語は道具が数えて候補を出し、クリックは目的に依らず投稿ごとに見る**(3.9.0)"
20
+ assert marker in skill
21
+ section = skill.split(marker)[1].split("\n\n")[0]
22
+ for text in ("--per-post-clicks", "unique_url_72h", "goal の層とは混ぜない", "clicks_before_post",
23
+ "**窓の前と後は窓の和に足さない**", "--also", "also に合った投稿が 0 件",
24
+ "--suggest", "**語と数だけで、本文・username・post_id は出ない・保存しない**",
25
+ "where_to_appear", "rate_by_span", "rough", "more_may_exist"):
26
+ assert text in section, text
27
+
28
+
29
+ def test_使い方の文書に3_9_0の段():
30
+ doc = (ROOT / "docs" / "使い方_プロジェクトのセッション向け_2026-09-09.md").read_text(encoding="utf-8")
31
+ heading = "## 3.9.0: 検索の語選びと、クリック・速さの見え方"
32
+ assert heading in doc
33
+ section = doc.split(heading)[1].split("\n## ")[0]
34
+ for text in ("--per-post-clicks", "unrecorded", "clicks_before_post", "**窓の前と後は窓の和に足しません**",
35
+ "原因は言えません", "--also", "also に合った投稿が 0 件(直近 n 日)", "--suggest",
36
+ "その場の人が書いた語", "**語と数だけで、本文・username・post_id・author_key は出ません。"
37
+ "候補も当たり率も保存しません**", "rate_by_span", "rough: true", "more_may_exist",
38
+ "一次資料で未確認"):
39
+ assert text in section, text
40
+
41
+
42
+ def test_リリースノートの題とセッション向けの5行と材料の報告id():
43
+ note = (ROOT / "docs" / "リリースノート_3.9.0_2026-09-24.md").read_text(encoding="utf-8")
44
+ assert note.startswith("# リリースノート 3.9.0(検索の語選びと、クリック・速さの見え方)")
45
+ section = note.split("## 各 project のセッション向け")[1].split("\n## ")[0]
46
+ lines = [line for line in section.splitlines() if line[:2] in ("1.", "2.", "3.", "4.", "5.")]
47
+ assert len(lines) == 5
48
+ assert "--per-post-clicks" in lines[0] and "clicks_before_post" in lines[1]
49
+ assert "--also" in lines[2] and "--suggest" in lines[3] and "rate_by_span" in lines[4]
50
+ for report_id in REPORT_IDS:
51
+ assert report_id in note, report_id
@@ -0,0 +1,138 @@
1
+ """3.9.0 §A 投稿ごとのクリックを目的に依らず(`analytics-report <account> --per-post-clicks`)。
2
+
3
+ - goal を問わず、unique_url_72h を満たす投稿を並べる(unrecorded でも出る)。
4
+ **goal の層(`--by goal`)とは混ぜない**。
5
+ - 各投稿と全体に `clicks_before_post`(投稿日より前 3 暦日のそのリンク先のクリック)。
6
+ **窓の和(clicks_72h)には足さない**。1 以上なら静的な 1 行(原因は言わない)。
7
+ - `--by goal` の click にも同じ欄。
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import json
12
+
13
+ from tests.test_v370_click_attribution import _clicks, _daily, _post, _yard, _by_id, _at
14
+ from thth import analytics_clicks, cli, jst
15
+
16
+ NOW = jst.parse("2026-09-24T12:00:00+09:00")
17
+ URL_U = "https://example.com/u"
18
+
19
+
20
+ def _fixture(account):
21
+ # U: goal を書く口が無かった頃の投稿(sent に goal の欄が無い=unrecorded)。
22
+ _post(account, "U", _at("15T09:00:00"), text=f"記事 {URL_U}", goal=None, views=100)
23
+ _post(account, "R", _at("10T09:00:00"), text="https://example.com/r", goal="reach", views=50)
24
+ _post(account, "C", _at("19T09:00:00"), text="https://example.com/c", goal="click", views=40)
25
+ _post(account, "N", _at("20T09:00:00"), text="リンクなし", goal="reach")
26
+ rows = [("14", _clicks({URL_U: 1})), # 窓の前(投稿日の前日)
27
+ ("15", _clicks({URL_U: 5, "https://example.com/r": 1})),
28
+ ("16", _clicks({URL_U: 4})), ("17", _clicks({URL_U: 3})),
29
+ ("18", _clicks({URL_U: 2})), ("19", _clicks({URL_U: 2, "https://example.com/c": 6})),
30
+ ("20", _clicks({"https://example.com/c": 1})), ("21", {"clicks": 0}),
31
+ ("10", _clicks({"https://example.com/r": 2})), ("11", {"clicks": 0}),
32
+ ("12", _clicks({"https://example.com/r": 1})), ("13", {"clicks": 0})]
33
+ _daily(account, rows)
34
+
35
+
36
+ def test_goalを問わず並べる_unrecordedでも出る_goalの層と混ぜない(isolated_account_factory):
37
+ account = isolated_account_factory()
38
+ _fixture(account)
39
+ payload = analytics_clicks.answer(account["name"], since="30d", now=NOW)
40
+ assert payload["report_type"] == "per_post_clicks" and payload["population"] == "all_goals"
41
+ ids = [row["post_id"] for row in payload["posts"]]
42
+ assert ids == ["R", "U", "C"], "reach・unrecorded・click のどれも並ぶ(投稿日時の順)"
43
+ assert payload["not_attributable"] == {"no_link": 1}
44
+ assert payload["summary"]["n_posts"] == 4 and payload["summary"]["n_attributable"] == 3
45
+ # 層に分けない・行に goal を持たない。
46
+ assert "strata" not in payload and "by_goal" not in payload
47
+ assert all("goal" not in row for row in payload["posts"])
48
+ # `--by goal` の click の層は click の投稿だけのまま(per-post-clicks を混ぜない)。
49
+ _payload, yard = _yard(account, now=NOW, window_days=14)
50
+ assert set(_by_id(yard)) == {"C"}
51
+
52
+
53
+ def test_clicks_before_postは窓の和に足さない_静的な1行(isolated_account_factory):
54
+ account = isolated_account_factory()
55
+ _fixture(account)
56
+ payload = analytics_clicks.answer(account["name"], since="30d", now=NOW)
57
+ row = {r["post_id"]: r for r in payload["posts"]}["U"]
58
+ assert row["window_days"] == ["2026-09-15", "2026-09-16", "2026-09-17"]
59
+ assert row["clicks_72h"] == 12, "5+4+3(前日の 1 は足さない)"
60
+ assert row["clicks_before_post"] == 1
61
+ assert row["before_post"]["days"] == ["2026-09-12", "2026-09-13", "2026-09-14"]
62
+ assert row["before_post"]["days_counted"] == 3 and row["before_post"]["days_missing"] == 0
63
+ # 窓の後(参考): 18〜21 日(2+2+0+0)。22・23 日は日次に行が無い(欠測)・24 日は閉じていない。
64
+ assert row["clicks_after_window"] == 4
65
+ assert row["after_window"]["days_missing"] == 2 and row["after_window"]["days_not_closed"] == 1
66
+ assert row["window_share"] == {
67
+ "value": round(12 / 17, 4), "numerator": 12, "denominator": 17,
68
+ "basis": "clicks_72h / (clicks_before_post + clicks_72h + clicks_after_window)",
69
+ "reason": None}
70
+ assert row["click_rate"] == 0.12 and row["rate_basis"] == "clicks_72h / views_24h"
71
+ s = payload["summary"]
72
+ assert s["clicks_before_post"]["sum"] == 1
73
+ assert payload["notes"] == [{"code": "clicks_before_post_present",
74
+ "message": analytics_clicks.BEFORE_PRESENT_NOTE}]
75
+ assert "原因は言えません" in analytics_clicks.BEFORE_PRESENT_NOTE
76
+ text = analytics_clicks.render_markdown(payload)
77
+ assert "窓の前 1(窓には足さない)" in text and "clicks_before_post_present" in text
78
+
79
+
80
+ def test_窓の和の合計は窓の前を含まない(isolated_account_factory):
81
+ account = isolated_account_factory()
82
+ _fixture(account)
83
+ payload = analytics_clicks.answer(account["name"], since="30d", now=NOW)
84
+ by = {r["post_id"]: r for r in payload["posts"]}
85
+ assert by["R"]["clicks_72h"] == 2 + 0 + 1 and by["C"]["clicks_72h"] == 6 + 1 + 0
86
+ assert payload["summary"]["clicks_72h"]["sum"] == 12 + 3 + 7
87
+ assert payload["summary"]["clicks_72h"]["median"] == 7
88
+
89
+
90
+ def test_同じリンク先のほかの投稿の窓の日は窓の前にも後にも数えない(isolated_account_factory):
91
+ account = isolated_account_factory()
92
+ url = "https://example.com/x"
93
+ _post(account, "X1", _at("10T09:00:00"), text=url)
94
+ _post(account, "X2", _at("13T12:00:00"), text=url) # 75 時間後(共有ではない)
95
+ _daily(account, [(f"{d:02d}", _clicks({url: 1})) for d in range(7, 24)])
96
+ payload = analytics_clicks.answer(account["name"], since="30d", now=NOW)
97
+ by = {r["post_id"]: r for r in payload["posts"]}
98
+ before = by["X2"]["before_post"]
99
+ assert before["days"] == ["2026-09-10", "2026-09-11", "2026-09-12"]
100
+ assert before["days_excluded_other_post_window"] == 3
101
+ assert by["X2"]["clicks_before_post"] is None and before["reason"] == "no_countable_day"
102
+ after = by["X1"]["after_window"]
103
+ assert after["days_excluded_other_post_window"] == 3, "13〜15 日は X2 の窓"
104
+ assert by["X1"]["clicks_after_window"] == 4, "16〜19 日"
105
+ assert by["X1"]["clicks_before_post"] == 3, "7〜9 日"
106
+
107
+
108
+ def test_by_goalのclickにもclicks_before_post(isolated_account_factory):
109
+ account = isolated_account_factory()
110
+ url = "https://example.com/c"
111
+ _post(account, "C", _at("19T09:00:00"), text=url)
112
+ _daily(account, [("18", _clicks({url: 2})), ("19", _clicks({url: 5})),
113
+ ("20", _clicks({url: 1})), ("21", {"clicks": 0})])
114
+ payload, yard = _yard(account, now=NOW, window_days=14)
115
+ row = _by_id(yard)["C"]
116
+ assert row["clicks_72h"] == 6 and row["clicks_before_post"] == 2
117
+ assert yard["per_post"]["clicks_72h"]["median"] == 6, "窓の前は窓の和に入らない"
118
+ before = yard["clicks_before_post"]
119
+ assert before["sum"] == 2 and before["n"] == 1 and before["denominator"] == 1
120
+ assert before["code"] == "clicks_before_post_present"
121
+ from thth import analytics_report
122
+ assert "窓の前のクリック 2(窓には足さない)" in analytics_report.render_markdown(payload)
123
+
124
+
125
+ def test_CLIのper_post_clicksとsinceの組み合わせ(isolated_account_factory, capsys):
126
+ account = isolated_account_factory()
127
+ _fixture(account)
128
+ name = account["name"]
129
+ assert cli.main(["analytics-report", name, "--per-post-clicks", "--since", "30d", "--json"]) == 0
130
+ out = json.loads(capsys.readouterr().out)
131
+ assert out["report_type"] == "per_post_clicks" and out["period"]["since"] == "30d"
132
+ assert cli.main(["analytics-report", name, "--per-post-clicks"]) == 0
133
+ assert "# 投稿ごとのクリック" in capsys.readouterr().out
134
+ assert cli.main(["analytics-report", name, "--since", "30d"]) == 2
135
+ assert cli.main(["analytics-report", name, "--per-post-clicks", "--compare-previous",
136
+ "--by", "goal"]) == 2
137
+ assert cli.main(["analytics-report", "--project", "p", "--per-post-clicks"]) == 2
138
+ assert cli.main(["analytics-report", name, "--per-post-clicks", "--since", "abc"]) == 2
@@ -0,0 +1,93 @@
1
+ """3.9.0 §C `--aggregate` の速さ(`rate_by_span`・`rough`・`more_may_exist`)。
2
+
3
+ - `per_day = n / max(期間の日数, 1)` は、期間が 1 日より短いとどの語も「n 件/日」に揃って
4
+ 見える。`rate_by_span` は返った投稿の期間(最古〜最新)の日数で割る(底上げしない)。
5
+ 分母(n・期間の時間)を添え、n < 20 は `rough: true`。
6
+ - 返った件数が要求数に届かず、期間が 24 時間未満で n が 5 以上なら
7
+ `cannot_say: more_may_exist`(これで全部か、もっとあるかは区別できない)。
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import datetime
12
+ import json
13
+
14
+ import pytest
15
+
16
+ from thth import accounts, adapters, cli, jst, where_cli
17
+
18
+ BASE = jst.parse("2026-09-24T06:00:00+09:00")
19
+
20
+
21
+ def _rows(n, hours, base=BASE):
22
+ step = hours / (n - 1) if n > 1 else 0
23
+ return [{"message_id": f"m{i}", "author_key": f"{i:016x}", "username": f"u{i}",
24
+ "timestamp": jst.iso(base + datetime.timedelta(hours=step * i)), "text": "本文"}
25
+ for i in range(n)]
26
+
27
+
28
+ @pytest.fixture
29
+ def search(isolated_account_factory, monkeypatch):
30
+ isolated_account_factory("one", media="threads")
31
+ monkeypatch.setattr(accounts, "load_token", lambda cfg: {"access_token": "FAKE"})
32
+ table = {}
33
+
34
+ class Fake:
35
+ def keyword_search(self, word, **kwargs):
36
+ return [dict(row) for row in table[word]]
37
+ monkeypatch.setattr(adapters, "make_adapter", lambda *a: Fake())
38
+ return table
39
+
40
+
41
+ def _agg(result, word):
42
+ return result["by_account"]["one"]["by_word"][word]["aggregate"]
43
+
44
+
45
+ def test_rate_by_spanは期間そのもので割る_per_dayと揃わない(search):
46
+ search["にぎやか"] = _rows(10, 6) # 6 時間に 10 件
47
+ search["静か"] = _rows(10, 60) # 60 時間に 10 件
48
+ result = where_cli.answer(account_name="one", words=["にぎやか", "静か"], aggregate_mode=True,
49
+ now=BASE + datetime.timedelta(days=3))
50
+ busy, calm = _agg(result, "にぎやか"), _agg(result, "静か")
51
+ assert busy["per_day"] == 10.0, "従前の per_day は 1 日で底上げ"
52
+ assert busy["rate_by_span"] == {"value": 40.0, "unit": "per_day", "n": 10, "span_hours": 6.0,
53
+ "basis": "n ÷ 返った投稿の期間(最古〜最新)の日数",
54
+ "rough": True, "rough_below_n": 20, "reason": None}
55
+ assert calm["rate_by_span"]["value"] == 4.0 and calm["rate_by_span"]["span_hours"] == 60.0
56
+ assert calm["per_day"] == 4.0
57
+
58
+
59
+ def test_roughはn20未満_期間が0や時刻なしはnullと理由():
60
+ assert where_cli.rate_by_span(20, [BASE, BASE + datetime.timedelta(hours=48)])["rough"] is False
61
+ same = where_cli.rate_by_span(3, [BASE, BASE])
62
+ assert same["value"] is None and same["reason"] == "span_zero"
63
+ none = where_cli.rate_by_span(0, [])
64
+ assert none["value"] is None and none["reason"] == "no_timestamp" and none["span_hours"] is None
65
+
66
+
67
+ def test_要求数に届かず期間が短いとmore_may_exist(search):
68
+ search["短い"] = _rows(10, 6)
69
+ search["長い"] = _rows(10, 30)
70
+ search["少ない"] = _rows(4, 2)
71
+ result = where_cli.answer(account_name="one", words=["短い", "長い", "少ない"], limit=25,
72
+ aggregate_mode=True, now=BASE + datetime.timedelta(days=3))
73
+ (item,) = _agg(result, "短い")["cannot_say"]
74
+ assert item["code"] == "more_may_exist"
75
+ assert item["message"] == "これで全部か、もっとあるかは区別できません"
76
+ assert "一次資料で未確認" in item["unverified"]
77
+ assert item["n_returned"] == 10 and item["requested_limit"] == 25 and item["span_hours"] == 6.0
78
+ assert _agg(result, "長い")["cannot_say"] == [], "期間が 24 時間以上"
79
+ assert _agg(result, "少ない")["cannot_say"] == [], "n が 5 未満"
80
+ full = where_cli.answer(account_name="one", words=["短い"], limit=10, aggregate_mode=True,
81
+ now=BASE + datetime.timedelta(days=3))
82
+ assert _agg(full, "短い")["cannot_say"] == [], "要求数に届いている"
83
+
84
+
85
+ def test_画面に期間あたりと分母とmore_may_exist(search, capsys):
86
+ search["短い"] = _rows(6, 5, base=jst.now_jst() - datetime.timedelta(hours=6))
87
+ assert cli.main(["where", "one", "短い", "--aggregate"]) == 0
88
+ out = capsys.readouterr().out
89
+ assert "期間あたり=28.8/日(n=6・期間 5.0 時間" in out and "粗い(n<20)" in out
90
+ assert "これで全部か、もっとあるかは区別できません(more_may_exist" in out
91
+ assert cli.main(["where", "one", "短い", "--aggregate", "--json"]) == 0
92
+ agg = json.loads(capsys.readouterr().out)["by_account"]["one"]["by_word"]["短い"]["aggregate"]
93
+ assert agg["rate_by_span"]["n"] == 6 and agg["rate_by_span"]["span_hours"] == 5.0
@@ -0,0 +1,217 @@
1
+ """3.9.0 §B 検索の語の候補(`thth where … --also … --suggest`)。
2
+
3
+ - `--also` と一緒なら語ごとの「取れた件数・also に合った件数・当たり率」を当たり率の順に。
4
+ 合った件数 0 の語に「also に合った投稿が 0 件(直近 n 日)」。
5
+ - `--suggest`: also に合った投稿の本文から語の候補を上位 10 まで(漢字・カタカナ・英字の
6
+ 2〜12 字の連なり・検索語と also の語と数字だけの語を除く・2 投稿以上)。
7
+ **本文・username・post_id・author_key は出さない**。**保存しない**。
8
+ - 自分の台帳からの候補(地図の点・原稿の topic・反応の多かった topic)を先頭に。
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import datetime
13
+ import json
14
+ import os
15
+
16
+ import pytest
17
+
18
+ from tests.conftest import write_queue_file
19
+ from tests.test_after_cli import _insight_path, _write_ndjson
20
+ from thth import accounts, adapters, cli, jst, map_store, runs, where_cli, where_suggest
21
+
22
+ NOW = jst.parse("2026-09-24T12:00:00+09:00")
23
+ STAMP = "2026-09-23T00:00:00Z"
24
+ LONG = "スペシャルティコーヒーロースター" # 16 字——12 字を超える連なりは語にしない
25
+
26
+ COFFEE = [
27
+ {"message_id": "POSTID111", "author_key": "a1a1a1a1a1a1a1a1", "username": "roaster",
28
+ "timestamp": STAMP,
29
+ "text": f"深煎りの珈琲豆をハンドドリップで淹れた {LONG} https://x.example/beans"},
30
+ {"message_id": "POSTID222", "author_key": "b2b2b2b2b2b2b2b2", "username": "USERNAMEB",
31
+ "timestamp": STAMP, "text": f"珈琲豆は深煎りが好き。ハンドドリップ最高 2026 {LONG}"},
32
+ {"message_id": "POSTID333", "author_key": "c3c3c3c3c3c3c3c3", "username": "USERNAMEC",
33
+ "timestamp": STAMP, "text": "浅煎りの豆もハンドドリップで。roasterの店 2026 @USERNAMEB"},
34
+ {"message_id": "POSTID444", "author_key": "d4d4d4d4d4d4d4d4", "username": "USERNAMED",
35
+ "timestamp": STAMP, "text": "紅茶の話"},
36
+ ]
37
+ TEA = [
38
+ {"message_id": "POSTID444", "author_key": "d4d4d4d4d4d4d4d4", "username": "USERNAMED",
39
+ "timestamp": STAMP, "text": "紅茶の話"},
40
+ {"message_id": "POSTID555", "author_key": "e5e5e5e5e5e5e5e5", "username": "USERNAMEE",
41
+ "timestamp": STAMP, "text": "紅茶とケーキ"},
42
+ ]
43
+ SECRETS = ([row["text"] for row in COFFEE + TEA] + [row["message_id"] for row in COFFEE + TEA]
44
+ + [row["author_key"] for row in COFFEE + TEA]
45
+ + [row["username"] for row in COFFEE + TEA] + ["USERNAMEB".casefold(), "POSTID",
46
+ "x.example"])
47
+
48
+
49
+ @pytest.fixture
50
+ def account(isolated_account_factory, monkeypatch, thth_root):
51
+ made = isolated_account_factory("one", media="threads", project="cafe")
52
+ monkeypatch.setattr(accounts, "load_token", lambda cfg: {"access_token": "FAKE"})
53
+ calls = []
54
+
55
+ class Fake:
56
+ def keyword_search(self, word, **kwargs):
57
+ calls.append(word)
58
+ return [dict(row) for row in (COFFEE if word == "珈琲" else TEA)]
59
+ monkeypatch.setattr(adapters, "make_adapter", lambda *a: Fake())
60
+ return {"calls": calls, "root": thth_root, "made": made}
61
+
62
+
63
+ def _answer(**kw):
64
+ return where_cli.answer(account_name="one", words=["珈琲", "紅茶"], now=NOW,
65
+ also=["豆", "ハンドドリップ"], **kw)
66
+
67
+
68
+ def test_当たり率の順に並べ_0件の語に知らせる(account):
69
+ result = _answer(since="7d")
70
+ hits = result["by_account"]["one"]["also_hits"]
71
+ assert [row["word"] for row in hits] == ["珈琲", "紅茶"], "当たり率の高い順"
72
+ assert hits[0] == {"word": "珈琲", "n_fetched": 4, "n_matched": 3, "hit_rate": 0.75,
73
+ "denominator": 4, "reason": None}
74
+ assert hits[1]["n_matched"] == 0 and hits[1]["hit_rate"] == 0.0
75
+ assert hits[1]["zero_note"] == "also に合った投稿が 0 件(直近 7 日)"
76
+ assert account["calls"] == ["珈琲", "紅茶"], "also の語で検索しない"
77
+ assert "suggest" not in result["by_account"]["one"], "--suggest が無ければ候補は出さない"
78
+
79
+
80
+ def test_当たり率の期間と取れなかった語():
81
+ rows = where_suggest.also_hits([("a", 0, 0), ("b", 10, 1), ("c", 4, 2)],
82
+ period_days={"a": None, "b": 3})
83
+ assert [row["word"] for row in rows] == ["c", "b", "a"]
84
+ assert rows[2]["hit_rate"] is None and rows[2]["reason"] == "no_posts_fetched"
85
+ assert rows[2]["zero_note"] == "also に合った投稿が 0 件(期間は不明)"
86
+ stamps = [{"timestamp": "2026-09-21T13:00:00+09:00"}]
87
+ assert where_cli._period_days(stamps, None, NOW) == 3
88
+ assert where_cli._period_days([], None, NOW) is None
89
+
90
+
91
+ def test_suggestは語と数だけ_検索語とalsoの語と数字と長い連なりと名前を除く(account):
92
+ result = _answer(suggest=True)
93
+ search = result["by_account"]["one"]["suggest"]["from_search"]
94
+ assert search["label"] == "その場の人が書いた語"
95
+ assert search["candidates"] == [{"word": "深煎", "n_posts": 2, "denominator": 3},
96
+ {"word": "珈琲豆", "n_posts": 2, "denominator": 3}]
97
+ words = {row["word"] for row in search["candidates"]}
98
+ assert "ハンドドリップ" not in words, "also の語は除く"
99
+ assert "珈琲" not in words and "2026" not in words and LONG not in words
100
+ assert "roaster" not in words, "取れた投稿の username と同じ語は候補にしない"
101
+ assert "浅煎" not in words and "最高" not in words, "1 投稿にしか出ない語は出さない"
102
+ text = json.dumps(result["by_account"]["one"]["suggest"], ensure_ascii=False)
103
+ for secret in SECRETS:
104
+ assert secret not in text, secret
105
+
106
+
107
+ def test_aggregateとsuggestの画面にも本文や名前を出さない(account, capsys):
108
+ assert cli.main(["where", "one", "珈琲", "紅茶", "--also", "豆", "--also", "ハンドドリップ",
109
+ "--aggregate", "--suggest", "--json"]) == 0
110
+ raw = capsys.readouterr().out
111
+ for secret in SECRETS:
112
+ assert secret not in raw, secret
113
+ result = json.loads(raw)
114
+ assert result["suggest"] is True and result["suggest_storage"] == "display_only"
115
+ assert cli.main(["where", "one", "珈琲", "紅茶", "--also", "豆", "--also", "ハンドドリップ",
116
+ "--aggregate", "--suggest"]) == 0
117
+ out = capsys.readouterr().out
118
+ for secret in SECRETS:
119
+ assert secret not in out, secret
120
+ assert "語の候補(その場の人が書いた語)" in out and "深煎 2/3 投稿" in out
121
+ assert "also の当たり率(高い順)" in out and "also に合った投稿が 0 件" in out
122
+ assert "候補は表示だけで、道具は保存しません" in out
123
+
124
+
125
+ def _snapshot(root):
126
+ out = {}
127
+ for base, _dirs, files in os.walk(root):
128
+ for name in files:
129
+ path = os.path.join(base, name)
130
+ with open(path, "rb") as f:
131
+ out[path] = f.read()
132
+ return out
133
+
134
+
135
+ def test_suggestは保存しない_runsは語と件数の1行だけ(account, capsys):
136
+ before = _snapshot(account["root"])
137
+ assert cli.main(["where", "one", "珈琲", "紅茶", "--also", "豆", "--also", "ハンドドリップ",
138
+ "--suggest", "--json"]) == 0
139
+ capsys.readouterr()
140
+ after = _snapshot(account["root"])
141
+ changed = {path for path in after if before.get(path) != after[path]
142
+ and not path.endswith(".lock")}
143
+ assert changed and all(os.path.basename(path).startswith("runs-") for path in changed), \
144
+ sorted(changed)
145
+ (path,) = changed
146
+ with open(path, encoding="utf-8") as f:
147
+ text = f.read()
148
+ last = json.loads(text.splitlines()[-1])
149
+ assert last["action"] == "where_to_appear" and last["words"] == ["珈琲", "紅茶"]
150
+ assert last["n"] == 3, "also に合った件数(珈琲 3・紅茶 0)"
151
+ assert runs.read_runs(accounts.state_dir_for("one"))[-1]["n"] == 3
152
+ for word in ("深煎", "珈琲豆", "suggest", "hit_rate", "also"):
153
+ assert word not in text, word
154
+
155
+
156
+ def test_自分の台帳からの候補を先頭に(account, capsys):
157
+ made = account["made"]
158
+ map_store.add_node("cafe", "焙煎", by="masaru")
159
+ map_store.add_node("cafe", "珈琲", by="masaru") # 検索語は候補にしない
160
+ for index, (topic, goal) in enumerate((("水出し", "click"), ("水出し", "reach"),
161
+ ("器", None))):
162
+ fm = {"account": "one", "status": "draft", "topic": topic, "publish_at": None}
163
+ if goal:
164
+ fm["goal"] = goal
165
+ write_queue_file(made["queue_dir"], f"d{index}.md", commit=False, fm_overrides=fm)
166
+ posted = NOW - datetime.timedelta(days=3)
167
+ for post_id, topic, likes in (("M1", "ラテアート", 9), ("M2", "ラテアート", 5),
168
+ ("M3", "器", 1), ("M4", "無反応", 0)):
169
+ _write_ndjson(_insight_path(made, post_id), [{
170
+ "account": "one", "post_id": post_id, "posted_at": jst.iso(posted),
171
+ "collected_at": jst.iso(posted + datetime.timedelta(hours=25)), "age_hours": 25,
172
+ "marks": [24], "reply_to": None, "topic": topic,
173
+ "metrics": {"views": 10, "likes": likes, "replies": 1 if likes else 0,
174
+ "reposts": 0}}])
175
+ result = _answer(suggest=True)
176
+ mine = result["by_account"]["one"]["suggest"]["from_self"]
177
+ assert mine["label"] == "自分のデータ"
178
+ words = [row["word"] for row in mine["candidates"]]
179
+ assert words == ["焙煎", "水出し", "器", "ラテアート"], words
180
+ by = {row["word"]: row for row in mine["candidates"]}
181
+ assert by["焙煎"]["sources"] == ["map_node"]
182
+ assert by["水出し"]["draft"] == {"n": 2, "goals": {"click": 1, "reach": 1}}
183
+ assert by["器"]["sources"] == ["draft_topic", "reacted_topic"]
184
+ assert by["ラテアート"]["reacted"] == {"median_likes_plus_replies_24h": 8.0,
185
+ "n_observed": 2, "n_posts": 2}
186
+ assert "無反応" not in words and "珈琲" not in words
187
+ suggest = result["by_account"]["one"]["suggest"]
188
+ assert list(suggest) == ["from_self", "from_search", "storage", "storage_note"], "自分のデータが先"
189
+ assert cli.main(["where", "one", "珈琲", "--also", "豆", "--suggest"]) == 0
190
+ out = capsys.readouterr().out
191
+ assert out.index("語の候補(自分のデータ)") < out.index("語の候補(その場の人が書いた語)")
192
+
193
+
194
+ def test_alsoが無ければその場の候補は出さない(account):
195
+ result = where_cli.answer(account_name="one", words=["珈琲"], now=NOW, suggest=True)
196
+ search = result["by_account"]["one"]["suggest"]["from_search"]
197
+ assert search["candidates"] == [] and search["reason"] == "no_also"
198
+ assert "also_hits" not in result["by_account"]["one"]
199
+ with pytest.raises(where_cli.WhereError):
200
+ where_cli.answer(account_name="one", words=["珈琲"], now=NOW, suggest="yes")
201
+
202
+
203
+ def test_語の切り出しは静的な方法():
204
+ assert where_suggest.tokens("IPhone15で撮ったラテアート。2026年 @someone https://a.example/x") \
205
+ == {"iphone15", "ラテアート"}
206
+ assert where_suggest.tokens("ーー あ") == set()
207
+ assert where_suggest.tokens(None) == set()
208
+
209
+
210
+ def test_MCPのwhere_to_appearには足さない():
211
+ path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "mcp", "server.py")
212
+ with open(path, encoding="utf-8") as f:
213
+ source = f.read()
214
+ assert "--suggest" not in source and "also_hits" not in source
215
+ start = source.index('"name": "where_to_appear"')
216
+ schema = source[start:source.index('"name": "who_is_this"')]
217
+ assert "suggest" not in schema and "also" not in schema
@@ -0,0 +1 @@
1
+ 3.9.0