thth 2.4.0__tar.gz → 2.5.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 (280) hide show
  1. {thth-2.4.0 → thth-2.5.0}/PKG-INFO +20 -4
  2. {thth-2.4.0 → thth-2.5.0}/README.md +19 -3
  3. {thth-2.4.0 → thth-2.5.0}/mcp/server.py +61 -3
  4. {thth-2.4.0 → thth-2.5.0}/skills/thth/SKILL.md +10 -0
  5. thth-2.5.0/tests/test_analytics_comparison.py +213 -0
  6. thth-2.5.0/tests/test_analytics_report.py +115 -0
  7. {thth-2.4.0 → thth-2.5.0}/tests/test_board_running.py +35 -3
  8. {thth-2.4.0 → thth-2.5.0}/tests/test_cli_help.py +2 -2
  9. {thth-2.4.0 → thth-2.5.0}/tests/test_mcp.py +1 -1
  10. thth-2.5.0/tests/test_operations_handoff.py +156 -0
  11. thth-2.5.0/tests/test_report_checkout_import.py +36 -0
  12. thth-2.5.0/tests/test_report_http.py +366 -0
  13. thth-2.5.0/tests/test_report_isolation.py +131 -0
  14. thth-2.5.0/tests/test_report_service.py +107 -0
  15. thth-2.5.0/tests/test_study_report.py +169 -0
  16. thth-2.5.0/tests/test_x_metrics.py +156 -0
  17. thth-2.5.0/thth/VERSION +1 -0
  18. thth-2.5.0/thth/analytics_comparison.py +257 -0
  19. thth-2.5.0/thth/analytics_report.py +129 -0
  20. {thth-2.4.0 → thth-2.5.0}/thth/cli.py +33 -0
  21. {thth-2.4.0 → thth-2.5.0}/thth/measured.py +5 -1
  22. thth-2.5.0/thth/operations_handoff.py +231 -0
  23. thth-2.5.0/thth/report_http.py +337 -0
  24. thth-2.5.0/thth/report_isolation.py +128 -0
  25. thth-2.5.0/thth/report_service.py +102 -0
  26. {thth-2.4.0 → thth-2.5.0}/thth/selfupdate.py +6 -1
  27. thth-2.5.0/thth/study_report.py +201 -0
  28. thth-2.5.0/thth/x_metrics.py +158 -0
  29. thth-2.4.0/thth/VERSION +0 -1
  30. {thth-2.4.0 → thth-2.5.0}/.gitignore +0 -0
  31. {thth-2.4.0 → thth-2.5.0}/LICENSE +0 -0
  32. {thth-2.4.0 → thth-2.5.0}/accounts.example/bluesky.json +0 -0
  33. {thth-2.4.0 → thth-2.5.0}/accounts.example/mastodon.json +0 -0
  34. {thth-2.4.0 → thth-2.5.0}/accounts.example/threads.json +0 -0
  35. {thth-2.4.0 → thth-2.5.0}/bin/thth +0 -0
  36. {thth-2.4.0 → thth-2.5.0}/bin/thth-remote +0 -0
  37. {thth-2.4.0 → thth-2.5.0}/bin/thth-run +0 -0
  38. {thth-2.4.0 → thth-2.5.0}/pyproject.toml +0 -0
  39. {thth-2.4.0 → thth-2.5.0}/systemd/thth@.service +0 -0
  40. {thth-2.4.0 → thth-2.5.0}/tests/__init__.py +0 -0
  41. {thth-2.4.0 → thth-2.5.0}/tests/conftest.py +0 -0
  42. {thth-2.4.0 → thth-2.5.0}/tests/fixtures/build_injection_eval.py +0 -0
  43. {thth-2.4.0 → thth-2.5.0}/tests/fixtures/callback/worker_responses.json +0 -0
  44. {thth-2.4.0 → thth-2.5.0}/tests/fixtures/injection_eval.json +0 -0
  45. {thth-2.4.0 → thth-2.5.0}/tests/fixtures/umami-bile.md +0 -0
  46. {thth-2.4.0 → thth-2.5.0}/tests/helpers/__init__.py +0 -0
  47. {thth-2.4.0 → thth-2.5.0}/tests/helpers/fake_oauth_server.py +0 -0
  48. {thth-2.4.0 → thth-2.5.0}/tests/helpers/fake_push_adapter.py +0 -0
  49. {thth-2.4.0 → thth-2.5.0}/tests/helpers/fake_redirect_server.py +0 -0
  50. {thth-2.4.0 → thth-2.5.0}/tests/helpers/fake_search_adapter.py +0 -0
  51. {thth-2.4.0 → thth-2.5.0}/tests/helpers/fake_threads_reader.py +0 -0
  52. {thth-2.4.0 → thth-2.5.0}/tests/helpers/fake_threads_writes.py +0 -0
  53. {thth-2.4.0 → thth-2.5.0}/tests/helpers/hold_lock.py +0 -0
  54. {thth-2.4.0 → thth-2.5.0}/tests/helpers/hold_repo_lock.py +0 -0
  55. {thth-2.4.0 → thth-2.5.0}/tests/helpers/worker_probe.mjs +0 -0
  56. {thth-2.4.0 → thth-2.5.0}/tests/test_account_name_check.py +0 -0
  57. {thth-2.4.0 → thth-2.5.0}/tests/test_account_report.py +0 -0
  58. {thth-2.4.0 → thth-2.5.0}/tests/test_accounts_dir.py +0 -0
  59. {thth-2.4.0 → thth-2.5.0}/tests/test_adapter_boundary.py +0 -0
  60. {thth-2.4.0 → thth-2.5.0}/tests/test_after_cli.py +0 -0
  61. {thth-2.4.0 → thth-2.5.0}/tests/test_api_diagnostic.py +0 -0
  62. {thth-2.4.0 → thth-2.5.0}/tests/test_api_error_body.py +0 -0
  63. {thth-2.4.0 → thth-2.5.0}/tests/test_app_env_cli.py +0 -0
  64. {thth-2.4.0 → thth-2.5.0}/tests/test_approval.py +0 -0
  65. {thth-2.4.0 → thth-2.5.0}/tests/test_approve_cmd.py +0 -0
  66. {thth-2.4.0 → thth-2.5.0}/tests/test_ask.py +0 -0
  67. {thth-2.4.0 → thth-2.5.0}/tests/test_atlas_fifth_review.py +0 -0
  68. {thth-2.4.0 → thth-2.5.0}/tests/test_atlas_fourth_review.py +0 -0
  69. {thth-2.4.0 → thth-2.5.0}/tests/test_atlas_review.py +0 -0
  70. {thth-2.4.0 → thth-2.5.0}/tests/test_atlas_second_review.py +0 -0
  71. {thth-2.4.0 → thth-2.5.0}/tests/test_atlas_third_review.py +0 -0
  72. {thth-2.4.0 → thth-2.5.0}/tests/test_audit1_fixes.py +0 -0
  73. {thth-2.4.0 → thth-2.5.0}/tests/test_audit2_fixes.py +0 -0
  74. {thth-2.4.0 → thth-2.5.0}/tests/test_audit_b_counterexamples.py +0 -0
  75. {thth-2.4.0 → thth-2.5.0}/tests/test_audit_b_m1_semantics.py +0 -0
  76. {thth-2.4.0 → thth-2.5.0}/tests/test_audit_b_n1_depth.py +0 -0
  77. {thth-2.4.0 → thth-2.5.0}/tests/test_audit_b_r1_closure.py +0 -0
  78. {thth-2.4.0 → thth-2.5.0}/tests/test_audit_v201.py +0 -0
  79. {thth-2.4.0 → thth-2.5.0}/tests/test_audit_v23_details.py +0 -0
  80. {thth-2.4.0 → thth-2.5.0}/tests/test_auth.py +0 -0
  81. {thth-2.4.0 → thth-2.5.0}/tests/test_auth_identity_required.py +0 -0
  82. {thth-2.4.0 → thth-2.5.0}/tests/test_behind_remote.py +0 -0
  83. {thth-2.4.0 → thth-2.5.0}/tests/test_bluesky_adapter.py +0 -0
  84. {thth-2.4.0 → thth-2.5.0}/tests/test_bluesky_secret_scrub.py +0 -0
  85. {thth-2.4.0 → thth-2.5.0}/tests/test_board_needs_review.py +0 -0
  86. {thth-2.4.0 → thth-2.5.0}/tests/test_bundle.py +0 -0
  87. {thth-2.4.0 → thth-2.5.0}/tests/test_charcount.py +0 -0
  88. {thth-2.4.0 → thth-2.5.0}/tests/test_cli_entrypoint.py +0 -0
  89. {thth-2.4.0 → thth-2.5.0}/tests/test_cli_vm_path.py +0 -0
  90. {thth-2.4.0 → thth-2.5.0}/tests/test_collect.py +0 -0
  91. {thth-2.4.0 → thth-2.5.0}/tests/test_collect_engagements.py +0 -0
  92. {thth-2.4.0 → thth-2.5.0}/tests/test_collect_runs.py +0 -0
  93. {thth-2.4.0 → thth-2.5.0}/tests/test_collect_sent.py +0 -0
  94. {thth-2.4.0 → thth-2.5.0}/tests/test_comparability.py +0 -0
  95. {thth-2.4.0 → thth-2.5.0}/tests/test_control_chars_refused.py +0 -0
  96. {thth-2.4.0 → thth-2.5.0}/tests/test_conversation_paging.py +0 -0
  97. {thth-2.4.0 → thth-2.5.0}/tests/test_count_for.py +0 -0
  98. {thth-2.4.0 → thth-2.5.0}/tests/test_docs_links.py +0 -0
  99. {thth-2.4.0 → thth-2.5.0}/tests/test_doctor.py +0 -0
  100. {thth-2.4.0 → thth-2.5.0}/tests/test_doctor_all_permissions.py +0 -0
  101. {thth-2.4.0 → thth-2.5.0}/tests/test_doctor_next_step.py +0 -0
  102. {thth-2.4.0 → thth-2.5.0}/tests/test_duplicate_front_matter_keys.py +0 -0
  103. {thth-2.4.0 → thth-2.5.0}/tests/test_editorial_audit.py +0 -0
  104. {thth-2.4.0 → thth-2.5.0}/tests/test_editorial_n3_n4.py +0 -0
  105. {thth-2.4.0 → thth-2.5.0}/tests/test_engagements.py +0 -0
  106. {thth-2.4.0 → thth-2.5.0}/tests/test_external_acceptance.py +0 -0
  107. {thth-2.4.0 → thth-2.5.0}/tests/test_fake_api.py +0 -0
  108. {thth-2.4.0 → thth-2.5.0}/tests/test_form_spec.py +0 -0
  109. {thth-2.4.0 → thth-2.5.0}/tests/test_form_spec_cli.py +0 -0
  110. {thth-2.4.0 → thth-2.5.0}/tests/test_forms.py +0 -0
  111. {thth-2.4.0 → thth-2.5.0}/tests/test_fresh_install.py +0 -0
  112. {thth-2.4.0 → thth-2.5.0}/tests/test_front_matter_injection.py +0 -0
  113. {thth-2.4.0 → thth-2.5.0}/tests/test_healthcheck_notifications.py +0 -0
  114. {thth-2.4.0 → thth-2.5.0}/tests/test_hypotheses.py +0 -0
  115. {thth-2.4.0 → thth-2.5.0}/tests/test_hypothesis_boundaries.py +0 -0
  116. {thth-2.4.0 → thth-2.5.0}/tests/test_hypothesis_units.py +0 -0
  117. {thth-2.4.0 → thth-2.5.0}/tests/test_impact_cli.py +0 -0
  118. {thth-2.4.0 → thth-2.5.0}/tests/test_inbox_granted_scopes.py +0 -0
  119. {thth-2.4.0 → thth-2.5.0}/tests/test_incident_notifications.py +0 -0
  120. {thth-2.4.0 → thth-2.5.0}/tests/test_independent_thread_review.py +0 -0
  121. {thth-2.4.0 → thth-2.5.0}/tests/test_independent_topic_review.py +0 -0
  122. {thth-2.4.0 → thth-2.5.0}/tests/test_independent_topic_review_r2.py +0 -0
  123. {thth-2.4.0 → thth-2.5.0}/tests/test_inflight.py +0 -0
  124. {thth-2.4.0 → thth-2.5.0}/tests/test_jst.py +0 -0
  125. {thth-2.4.0 → thth-2.5.0}/tests/test_lint.py +0 -0
  126. {thth-2.4.0 → thth-2.5.0}/tests/test_list_projections.py +0 -0
  127. {thth-2.4.0 → thth-2.5.0}/tests/test_llm_trial.py +0 -0
  128. {thth-2.4.0 → thth-2.5.0}/tests/test_llm_trial_engage.py +0 -0
  129. {thth-2.4.0 → thth-2.5.0}/tests/test_lock.py +0 -0
  130. {thth-2.4.0 → thth-2.5.0}/tests/test_m2_independent.py +0 -0
  131. {thth-2.4.0 → thth-2.5.0}/tests/test_maintain.py +0 -0
  132. {thth-2.4.0 → thth-2.5.0}/tests/test_mastodon_adapter.py +0 -0
  133. {thth-2.4.0 → thth-2.5.0}/tests/test_mastodon_visibility.py +0 -0
  134. {thth-2.4.0 → thth-2.5.0}/tests/test_max_per_run.py +0 -0
  135. {thth-2.4.0 → thth-2.5.0}/tests/test_mcp_robustness.py +0 -0
  136. {thth-2.4.0 → thth-2.5.0}/tests/test_measured.py +0 -0
  137. {thth-2.4.0 → thth-2.5.0}/tests/test_media_accounts.py +0 -0
  138. {thth-2.4.0 → thth-2.5.0}/tests/test_media_next_steps.py +0 -0
  139. {thth-2.4.0 → thth-2.5.0}/tests/test_media_roundtrip.py +0 -0
  140. {thth-2.4.0 → thth-2.5.0}/tests/test_mismatch_diagnostics.py +0 -0
  141. {thth-2.4.0 → thth-2.5.0}/tests/test_n5_reply_null_element.py +0 -0
  142. {thth-2.4.0 → thth-2.5.0}/tests/test_n6_replies_incomplete_ledger.py +0 -0
  143. {thth-2.4.0 → thth-2.5.0}/tests/test_n7_doctor_error_body.py +0 -0
  144. {thth-2.4.0 → thth-2.5.0}/tests/test_no_limits.py +0 -0
  145. {thth-2.4.0 → thth-2.5.0}/tests/test_observation_provenance.py +0 -0
  146. {thth-2.4.0 → thth-2.5.0}/tests/test_packaging.py +0 -0
  147. {thth-2.4.0 → thth-2.5.0}/tests/test_preview.py +0 -0
  148. {thth-2.4.0 → thth-2.5.0}/tests/test_production_mode.py +0 -0
  149. {thth-2.4.0 → thth-2.5.0}/tests/test_pull_cli.py +0 -0
  150. {thth-2.4.0 → thth-2.5.0}/tests/test_queue.py +0 -0
  151. {thth-2.4.0 → thth-2.5.0}/tests/test_quiet_hours.py +0 -0
  152. {thth-2.4.0 → thth-2.5.0}/tests/test_r2_r4_r5.py +0 -0
  153. {thth-2.4.0 → thth-2.5.0}/tests/test_read_cli_bare_runtimeerror.py +0 -0
  154. {thth-2.4.0 → thth-2.5.0}/tests/test_reason_adoption.py +0 -0
  155. {thth-2.4.0 → thth-2.5.0}/tests/test_redact.py +0 -0
  156. {thth-2.4.0 → thth-2.5.0}/tests/test_redirect_token_leak.py +0 -0
  157. {thth-2.4.0 → thth-2.5.0}/tests/test_refresh.py +0 -0
  158. {thth-2.4.0 → thth-2.5.0}/tests/test_refresh_destination.py +0 -0
  159. {thth-2.4.0 → thth-2.5.0}/tests/test_rejudge_new.py +0 -0
  160. {thth-2.4.0 → thth-2.5.0}/tests/test_rejudge_r2_r3.py +0 -0
  161. {thth-2.4.0 → thth-2.5.0}/tests/test_replies.py +0 -0
  162. {thth-2.4.0 → thth-2.5.0}/tests/test_replies_refresh.py +0 -0
  163. {thth-2.4.0 → thth-2.5.0}/tests/test_repo_lock.py +0 -0
  164. {thth-2.4.0 → thth-2.5.0}/tests/test_repo_sync.py +0 -0
  165. {thth-2.4.0 → thth-2.5.0}/tests/test_reverse_audit.py +0 -0
  166. {thth-2.4.0 → thth-2.5.0}/tests/test_review_case_id_cluster_m2.py +0 -0
  167. {thth-2.4.0 → thth-2.5.0}/tests/test_review_case_id_cluster_r2.py +0 -0
  168. {thth-2.4.0 → thth-2.5.0}/tests/test_review_cli.py +0 -0
  169. {thth-2.4.0 → thth-2.5.0}/tests/test_review_evidence_account.py +0 -0
  170. {thth-2.4.0 → thth-2.5.0}/tests/test_review_form_spec_and_case_id.py +0 -0
  171. {thth-2.4.0 → thth-2.5.0}/tests/test_review_provenance.py +0 -0
  172. {thth-2.4.0 → thth-2.5.0}/tests/test_review_records.py +0 -0
  173. {thth-2.4.0 → thth-2.5.0}/tests/test_revoke.py +0 -0
  174. {thth-2.4.0 → thth-2.5.0}/tests/test_roundtrip.py +0 -0
  175. {thth-2.4.0 → thth-2.5.0}/tests/test_run_missing_env.py +0 -0
  176. {thth-2.4.0 → thth-2.5.0}/tests/test_run_record_failclosed.py +0 -0
  177. {thth-2.4.0 → thth-2.5.0}/tests/test_runs_collected.py +0 -0
  178. {thth-2.4.0 → thth-2.5.0}/tests/test_runs_read_rows.py +0 -0
  179. {thth-2.4.0 → thth-2.5.0}/tests/test_schedule_multiple.py +0 -0
  180. {thth-2.4.0 → thth-2.5.0}/tests/test_schedule_view.py +0 -0
  181. {thth-2.4.0 → thth-2.5.0}/tests/test_scopes_source_requested.py +0 -0
  182. {thth-2.4.0 → thth-2.5.0}/tests/test_search_engage_targets.py +0 -0
  183. {thth-2.4.0 → thth-2.5.0}/tests/test_search_media_routes.py +0 -0
  184. {thth-2.4.0 → thth-2.5.0}/tests/test_secret_value_redact.py +0 -0
  185. {thth-2.4.0 → thth-2.5.0}/tests/test_secrets_grep.py +0 -0
  186. {thth-2.4.0 → thth-2.5.0}/tests/test_security_small_fixes.py +0 -0
  187. {thth-2.4.0 → thth-2.5.0}/tests/test_select.py +0 -0
  188. {thth-2.4.0 → thth-2.5.0}/tests/test_selfupdate.py +0 -0
  189. {thth-2.4.0 → thth-2.5.0}/tests/test_send.py +0 -0
  190. {thth-2.4.0 → thth-2.5.0}/tests/test_send_cli.py +0 -0
  191. {thth-2.4.0 → thth-2.5.0}/tests/test_send_control_chars.py +0 -0
  192. {thth-2.4.0 → thth-2.5.0}/tests/test_send_media.py +0 -0
  193. {thth-2.4.0 → thth-2.5.0}/tests/test_send_visibility.py +0 -0
  194. {thth-2.4.0 → thth-2.5.0}/tests/test_sent_integrity.py +0 -0
  195. {thth-2.4.0 → thth-2.5.0}/tests/test_server_json.py +0 -0
  196. {thth-2.4.0 → thth-2.5.0}/tests/test_site.py +0 -0
  197. {thth-2.4.0 → thth-2.5.0}/tests/test_skill_doc.py +0 -0
  198. {thth-2.4.0 → thth-2.5.0}/tests/test_systemd.py +0 -0
  199. {thth-2.4.0 → thth-2.5.0}/tests/test_thread_publish.py +0 -0
  200. {thth-2.4.0 → thth-2.5.0}/tests/test_thread_read.py +0 -0
  201. {thth-2.4.0 → thth-2.5.0}/tests/test_threadrun.py +0 -0
  202. {thth-2.4.0 → thth-2.5.0}/tests/test_threads_read_permissions.py +0 -0
  203. {thth-2.4.0 → thth-2.5.0}/tests/test_threadshape.py +0 -0
  204. {thth-2.4.0 → thth-2.5.0}/tests/test_thth_root.py +0 -0
  205. {thth-2.4.0 → thth-2.5.0}/tests/test_token_exists.py +0 -0
  206. {thth-2.4.0 → thth-2.5.0}/tests/test_token_scopes_record.py +0 -0
  207. {thth-2.4.0 → thth-2.5.0}/tests/test_token_set.py +0 -0
  208. {thth-2.4.0 → thth-2.5.0}/tests/test_topic.py +0 -0
  209. {thth-2.4.0 → thth-2.5.0}/tests/test_topic_advice.py +0 -0
  210. {thth-2.4.0 → thth-2.5.0}/tests/test_topic_cli.py +0 -0
  211. {thth-2.4.0 → thth-2.5.0}/tests/test_topic_models.py +0 -0
  212. {thth-2.4.0 → thth-2.5.0}/tests/test_topic_review_fixes.py +0 -0
  213. {thth-2.4.0 → thth-2.5.0}/tests/test_topic_shelf_observers.py +0 -0
  214. {thth-2.4.0 → thth-2.5.0}/tests/test_topic_store.py +0 -0
  215. {thth-2.4.0 → thth-2.5.0}/tests/test_topics_record_concurrent.py +0 -0
  216. {thth-2.4.0 → thth-2.5.0}/tests/test_topics_tool.py +0 -0
  217. {thth-2.4.0 → thth-2.5.0}/tests/test_trial_frictions.py +0 -0
  218. {thth-2.4.0 → thth-2.5.0}/tests/test_v21b_approved_writes.py +0 -0
  219. {thth-2.4.0 → thth-2.5.0}/tests/test_value_domains_shown.py +0 -0
  220. {thth-2.4.0 → thth-2.5.0}/tests/test_version.py +0 -0
  221. {thth-2.4.0 → thth-2.5.0}/tests/test_where_cli.py +0 -0
  222. {thth-2.4.0 → thth-2.5.0}/tests/test_who_cli.py +0 -0
  223. {thth-2.4.0 → thth-2.5.0}/tests/test_writeback_rebase.py +0 -0
  224. {thth-2.4.0 → thth-2.5.0}/tests/test_writeback_validate.py +0 -0
  225. {thth-2.4.0 → thth-2.5.0}/thth/__init__.py +0 -0
  226. {thth-2.4.0 → thth-2.5.0}/thth/__main__.py +0 -0
  227. {thth-2.4.0 → thth-2.5.0}/thth/account_cli.py +0 -0
  228. {thth-2.4.0 → thth-2.5.0}/thth/account_report.py +0 -0
  229. {thth-2.4.0 → thth-2.5.0}/thth/accounts.py +0 -0
  230. {thth-2.4.0 → thth-2.5.0}/thth/adapters/__init__.py +0 -0
  231. {thth-2.4.0 → thth-2.5.0}/thth/adapters/base.py +0 -0
  232. {thth-2.4.0 → thth-2.5.0}/thth/adapters/bluesky.py +0 -0
  233. {thth-2.4.0 → thth-2.5.0}/thth/adapters/mastodon.py +0 -0
  234. {thth-2.4.0 → thth-2.5.0}/thth/adapters/threads.py +0 -0
  235. {thth-2.4.0 → thth-2.5.0}/thth/after_cli.py +0 -0
  236. {thth-2.4.0 → thth-2.5.0}/thth/api_diagnostic.py +0 -0
  237. {thth-2.4.0 → thth-2.5.0}/thth/appenv.py +0 -0
  238. {thth-2.4.0 → thth-2.5.0}/thth/approval.py +0 -0
  239. {thth-2.4.0 → thth-2.5.0}/thth/ask.py +0 -0
  240. {thth-2.4.0 → thth-2.5.0}/thth/ask_cli.py +0 -0
  241. {thth-2.4.0 → thth-2.5.0}/thth/bundle.py +0 -0
  242. {thth-2.4.0 → thth-2.5.0}/thth/collect.py +0 -0
  243. {thth-2.4.0 → thth-2.5.0}/thth/core.py +0 -0
  244. {thth-2.4.0 → thth-2.5.0}/thth/doctor.py +0 -0
  245. {thth-2.4.0 → thth-2.5.0}/thth/engagements.py +0 -0
  246. {thth-2.4.0 → thth-2.5.0}/thth/forms.py +0 -0
  247. {thth-2.4.0 → thth-2.5.0}/thth/healthcheck.py +0 -0
  248. {thth-2.4.0 → thth-2.5.0}/thth/httpsafe.py +0 -0
  249. {thth-2.4.0 → thth-2.5.0}/thth/incident.py +0 -0
  250. {thth-2.4.0 → thth-2.5.0}/thth/inflight.py +0 -0
  251. {thth-2.4.0 → thth-2.5.0}/thth/jst.py +0 -0
  252. {thth-2.4.0 → thth-2.5.0}/thth/lint.py +0 -0
  253. {thth-2.4.0 → thth-2.5.0}/thth/lock.py +0 -0
  254. {thth-2.4.0 → thth-2.5.0}/thth/maintain.py +0 -0
  255. {thth-2.4.0 → thth-2.5.0}/thth/oauth.py +0 -0
  256. {thth-2.4.0 → thth-2.5.0}/thth/postid.py +0 -0
  257. {thth-2.4.0 → thth-2.5.0}/thth/queuefile.py +0 -0
  258. {thth-2.4.0 → thth-2.5.0}/thth/redact.py +0 -0
  259. {thth-2.4.0 → thth-2.5.0}/thth/replies.py +0 -0
  260. {thth-2.4.0 → thth-2.5.0}/thth/report.py +0 -0
  261. {thth-2.4.0 → thth-2.5.0}/thth/retract_cli.py +0 -0
  262. {thth-2.4.0 → thth-2.5.0}/thth/runs.py +0 -0
  263. {thth-2.4.0 → thth-2.5.0}/thth/scopes.py +0 -0
  264. {thth-2.4.0 → thth-2.5.0}/thth/secrets_fs.py +0 -0
  265. {thth-2.4.0 → thth-2.5.0}/thth/select.py +0 -0
  266. {thth-2.4.0 → thth-2.5.0}/thth/sent.py +0 -0
  267. {thth-2.4.0 → thth-2.5.0}/thth/systemd_gen.py +0 -0
  268. {thth-2.4.0 → thth-2.5.0}/thth/thread_read.py +0 -0
  269. {thth-2.4.0 → thth-2.5.0}/thth/threadrun.py +0 -0
  270. {thth-2.4.0 → thth-2.5.0}/thth/threads_read_cli.py +0 -0
  271. {thth-2.4.0 → thth-2.5.0}/thth/threadshape.py +0 -0
  272. {thth-2.4.0 → thth-2.5.0}/thth/threadthrow.py +0 -0
  273. {thth-2.4.0 → thth-2.5.0}/thth/topic_advice.py +0 -0
  274. {thth-2.4.0 → thth-2.5.0}/thth/topic_cli.py +0 -0
  275. {thth-2.4.0 → thth-2.5.0}/thth/topic_models.py +0 -0
  276. {thth-2.4.0 → thth-2.5.0}/thth/topic_store.py +0 -0
  277. {thth-2.4.0 → thth-2.5.0}/thth/topics.py +0 -0
  278. {thth-2.4.0 → thth-2.5.0}/thth/where_cli.py +0 -0
  279. {thth-2.4.0 → thth-2.5.0}/thth/who_cli.py +0 -0
  280. {thth-2.4.0 → thth-2.5.0}/thth/writeback.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: thth
3
- Version: 2.4.0
3
+ Version: 2.5.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
@@ -33,7 +33,7 @@ watchtower(`~/Developer/watchtower`)の隣に同じ流儀で並べる。watc
33
33
 
34
34
  ## 版
35
35
 
36
- このソースの版は **2.4.0**。現在の設計は [自分の泉](docs/設計_自分の泉_2026-09-16.md)、導入は [自分の Meta アプリで動かす](docs/導入_自分のMetaアプリで動かす.md)、文書の索引は [docs/README.md](docs/README.md)。
36
+ このソースの版は **2.5.0**。現在の設計は [自分の泉](docs/設計_自分の泉_2026-09-16.md)、導入は [自分の Meta アプリで動かす](docs/導入_自分のMetaアプリで動かす.md)、文書の索引は [docs/README.md](docs/README.md)。
37
37
 
38
38
  版の正本は `thth/VERSION`(`server.json` はテストで一致を強制)。開発中の `main` には未配布の変更も含まれます。インストール済みの版は `thth --version`、VM の版と revision は `thth board` で確認してください。
39
39
 
@@ -45,7 +45,7 @@ watchtower(`~/Developer/watchtower`)の隣に同じ流儀で並べる。watc
45
45
 
46
46
  ## 実装と運用の状態(2026-09-18)
47
47
 
48
- 2.4.0 の実装 revision の全件テストは **2452 件**(`python -m pytest tests/ -q -n auto -p no:cacheprovider`・1 skip・rc=0)。開発中の変更の検証は対象 revision の CI を参照してください。
48
+ 2.5.0 の実装 revision の全件テストは **2690 件**(`python -m pytest tests/ -q -n auto -p no:cacheprovider`・1 skip・rc=0)。開発中の変更の検証は対象 revision の CI を参照してください。
49
49
 
50
50
  **媒体**: Threads(稼働)・Bluesky・Mastodon(同席用の台帳あり・未稼働)。
51
51
 
@@ -59,6 +59,13 @@ timer(systemd・`thth systemd` で生成)は10分ごとに実行し、投稿
59
59
 
60
60
  **トピックの棚**(`thth topics`): 観測者ごとに並ぶ・打ち消し `retract-note`・`history`。
61
61
 
62
+ ### v2.5.0 の変更
63
+
64
+ - 読み取り専用のレポート 3 本: `analytics-report`(活動のスナップショット・`--compare-previous` で隣接期間の比較)・`handoff-report`(ローカル運用記録の引継ぎ)・`study-report`(施策の宣言と本人の観測の結合)。MCP に `analytics_report`・`operations_handoff`・`study_report`。数値は期間・母数・欠測・根拠を連れて歩き、因果や推奨は出しません。
65
+ - `serve-reports`: 専用環境向けの非公開レポート HTTP(Unix socket 既定・service credential・読むだけ)。**開発版**で、人の認証・TLS・一般提供は含みません。[限界](docs/非公開レポートHTTP_v1.md)。
66
+ - X の本人公開指標の純粋な変換関数(API・投稿・台帳には未接続)。
67
+ - 独立監査(P2 4・P3 7)とその直し: Unix socket 既定・要求全体の 10 秒 deadline・分離検査の走査を読取 dir に限定・期間比較の母集団から時刻不一致の返信を除外・git 無しでも import 可。
68
+
62
69
  ### v2.4.0 の変更
63
70
 
64
71
  - 利用者・管理者への停止/復旧メールと、元原稿の運用記録。設定・秘密は repo 外に保存します。導入時は [共通通知手順](docs/停止通知と運用記録.md) を実施します。
@@ -87,7 +94,7 @@ timer(systemd・`thth systemd` で生成)は10分ごとに実行し、投稿
87
94
  - **英語の文書**: [README.en.md](README.en.md)・[docs/usage.en.md](docs/usage.en.md)・[llms.txt](llms.txt)。
88
95
  - **skill**: `skills/thth/SKILL.md`(wheel にも入る)。
89
96
 
90
- - **動くもの**(`thth --help` の全 36 サブコマンド): `lint`・`preview`・`approve`・`account`・`revoke`・`posts`・`replies`・`measured`・`threads`・`after`・`topics`・`forms`・`queue`・`schedule`・`throw`・`run`・`systemd`・`board`・`collect`・`pull`・`auth`・`refresh`・`maintain`・`send`・`doctor`・`app`・`token`・`ask`・`mentions`・`profile`・`thread`・`where`・`who`・`retract`・`location`・`notifications`。
97
+ - **動くもの**(`thth --help` の全 40 サブコマンド): `lint`・`preview`・`approve`・`account`・`revoke`・`posts`・`replies`・`measured`・`threads`・`after`・`analytics-report`・`study-report`・`handoff-report`・`serve-reports`・`topics`・`forms`・`queue`・`schedule`・`throw`・`run`・`systemd`・`board`・`collect`・`pull`・`auth`・`refresh`・`maintain`・`send`・`doctor`・`app`・`token`・`ask`・`mentions`・`profile`・`thread`・`where`・`who`・`retract`・`location`・`notifications`。
91
98
  - **最初の本番投稿の記録**: 2026-09-09、@aoking に疎通確認を 1 本(`17916074118445631`)。
92
99
  - **未着手**: X・Facebook ページ・Instagram の各アダプタ。トピック検索の権限(tester には降りない)。泉のサーバ(v2-5)。
93
100
  - **権限の制約**: tester に降りる scope は 5 つ。削除はできない。
@@ -98,3 +105,12 @@ timer(systemd・`thth systemd` で生成)は10分ごとに実行し、投稿
98
105
  registry は「この PyPI の名前を名乗ってよいのは誰か」を、**配布物の README にこの 1 行があるか**で確かめる(設計 v2-4 §3・一次資料は quickstart・**L2**)。だから消さないこと——消すと登録(`mcp-publisher publish`)が通らなくなる。形(`server.json` と版の一致)は `tests/test_server_json.py` が見張る。
99
106
 
100
107
  mcp-name: io.github.aokings/thth
108
+
109
+ ## v3 基盤(2.5.0 から)
110
+
111
+ `study-report` は明示した施策の宣言と本人の投稿観測を結ぶ読み取り専用レポートです。採用者の本人確認や因果効果は主張しません。[使い方と契約](docs/施策レポート_v1.md)。
112
+
113
+ `analytics-report` はローカル台帳から期間・母数・欠測・根拠を揃えたスナップショットを返します。
114
+ [形式と使い方](docs/分析レポート_v1.md)。
115
+
116
+ `handoff-report` / MCP `operations_handoff` はローカル運用状態・通知未処理を鮮度の制約付きで返します。[形式と使い方](docs/運用引継ぎレポート_v1.md)。
@@ -9,7 +9,7 @@ watchtower(`~/Developer/watchtower`)の隣に同じ流儀で並べる。watc
9
9
 
10
10
  ## 版
11
11
 
12
- このソースの版は **2.4.0**。現在の設計は [自分の泉](docs/設計_自分の泉_2026-09-16.md)、導入は [自分の Meta アプリで動かす](docs/導入_自分のMetaアプリで動かす.md)、文書の索引は [docs/README.md](docs/README.md)。
12
+ このソースの版は **2.5.0**。現在の設計は [自分の泉](docs/設計_自分の泉_2026-09-16.md)、導入は [自分の Meta アプリで動かす](docs/導入_自分のMetaアプリで動かす.md)、文書の索引は [docs/README.md](docs/README.md)。
13
13
 
14
14
  版の正本は `thth/VERSION`(`server.json` はテストで一致を強制)。開発中の `main` には未配布の変更も含まれます。インストール済みの版は `thth --version`、VM の版と revision は `thth board` で確認してください。
15
15
 
@@ -21,7 +21,7 @@ watchtower(`~/Developer/watchtower`)の隣に同じ流儀で並べる。watc
21
21
 
22
22
  ## 実装と運用の状態(2026-09-18)
23
23
 
24
- 2.4.0 の実装 revision の全件テストは **2452 件**(`python -m pytest tests/ -q -n auto -p no:cacheprovider`・1 skip・rc=0)。開発中の変更の検証は対象 revision の CI を参照してください。
24
+ 2.5.0 の実装 revision の全件テストは **2690 件**(`python -m pytest tests/ -q -n auto -p no:cacheprovider`・1 skip・rc=0)。開発中の変更の検証は対象 revision の CI を参照してください。
25
25
 
26
26
  **媒体**: Threads(稼働)・Bluesky・Mastodon(同席用の台帳あり・未稼働)。
27
27
 
@@ -35,6 +35,13 @@ timer(systemd・`thth systemd` で生成)は10分ごとに実行し、投稿
35
35
 
36
36
  **トピックの棚**(`thth topics`): 観測者ごとに並ぶ・打ち消し `retract-note`・`history`。
37
37
 
38
+ ### v2.5.0 の変更
39
+
40
+ - 読み取り専用のレポート 3 本: `analytics-report`(活動のスナップショット・`--compare-previous` で隣接期間の比較)・`handoff-report`(ローカル運用記録の引継ぎ)・`study-report`(施策の宣言と本人の観測の結合)。MCP に `analytics_report`・`operations_handoff`・`study_report`。数値は期間・母数・欠測・根拠を連れて歩き、因果や推奨は出しません。
41
+ - `serve-reports`: 専用環境向けの非公開レポート HTTP(Unix socket 既定・service credential・読むだけ)。**開発版**で、人の認証・TLS・一般提供は含みません。[限界](docs/非公開レポートHTTP_v1.md)。
42
+ - X の本人公開指標の純粋な変換関数(API・投稿・台帳には未接続)。
43
+ - 独立監査(P2 4・P3 7)とその直し: Unix socket 既定・要求全体の 10 秒 deadline・分離検査の走査を読取 dir に限定・期間比較の母集団から時刻不一致の返信を除外・git 無しでも import 可。
44
+
38
45
  ### v2.4.0 の変更
39
46
 
40
47
  - 利用者・管理者への停止/復旧メールと、元原稿の運用記録。設定・秘密は repo 外に保存します。導入時は [共通通知手順](docs/停止通知と運用記録.md) を実施します。
@@ -63,7 +70,7 @@ timer(systemd・`thth systemd` で生成)は10分ごとに実行し、投稿
63
70
  - **英語の文書**: [README.en.md](README.en.md)・[docs/usage.en.md](docs/usage.en.md)・[llms.txt](llms.txt)。
64
71
  - **skill**: `skills/thth/SKILL.md`(wheel にも入る)。
65
72
 
66
- - **動くもの**(`thth --help` の全 36 サブコマンド): `lint`・`preview`・`approve`・`account`・`revoke`・`posts`・`replies`・`measured`・`threads`・`after`・`topics`・`forms`・`queue`・`schedule`・`throw`・`run`・`systemd`・`board`・`collect`・`pull`・`auth`・`refresh`・`maintain`・`send`・`doctor`・`app`・`token`・`ask`・`mentions`・`profile`・`thread`・`where`・`who`・`retract`・`location`・`notifications`。
73
+ - **動くもの**(`thth --help` の全 40 サブコマンド): `lint`・`preview`・`approve`・`account`・`revoke`・`posts`・`replies`・`measured`・`threads`・`after`・`analytics-report`・`study-report`・`handoff-report`・`serve-reports`・`topics`・`forms`・`queue`・`schedule`・`throw`・`run`・`systemd`・`board`・`collect`・`pull`・`auth`・`refresh`・`maintain`・`send`・`doctor`・`app`・`token`・`ask`・`mentions`・`profile`・`thread`・`where`・`who`・`retract`・`location`・`notifications`。
67
74
  - **最初の本番投稿の記録**: 2026-09-09、@aoking に疎通確認を 1 本(`17916074118445631`)。
68
75
  - **未着手**: X・Facebook ページ・Instagram の各アダプタ。トピック検索の権限(tester には降りない)。泉のサーバ(v2-5)。
69
76
  - **権限の制約**: tester に降りる scope は 5 つ。削除はできない。
@@ -74,3 +81,12 @@ timer(systemd・`thth systemd` で生成)は10分ごとに実行し、投稿
74
81
  registry は「この PyPI の名前を名乗ってよいのは誰か」を、**配布物の README にこの 1 行があるか**で確かめる(設計 v2-4 §3・一次資料は quickstart・**L2**)。だから消さないこと——消すと登録(`mcp-publisher publish`)が通らなくなる。形(`server.json` と版の一致)は `tests/test_server_json.py` が見張る。
75
82
 
76
83
  mcp-name: io.github.aokings/thth
84
+
85
+ ## v3 基盤(2.5.0 から)
86
+
87
+ `study-report` は明示した施策の宣言と本人の投稿観測を結ぶ読み取り専用レポートです。採用者の本人確認や因果効果は主張しません。[使い方と契約](docs/施策レポート_v1.md)。
88
+
89
+ `analytics-report` はローカル台帳から期間・母数・欠測・根拠を揃えたスナップショットを返します。
90
+ [形式と使い方](docs/分析レポート_v1.md)。
91
+
92
+ `handoff-report` / MCP `operations_handoff` はローカル運用状態・通知未処理を鮮度の制約付きで返します。[形式と使い方](docs/運用引継ぎレポート_v1.md)。
@@ -139,6 +139,34 @@ TOOLS = [
139
139
  "required": ["account", "topic"],
140
140
  },
141
141
  },
142
+ {
143
+ "name": "operations_handoff",
144
+ "description": "ローカル運用記録の状態・承認待ち・inflight・通知未処理を根拠と鮮度の制約付きで返す。同期・承認・再送はしない",
145
+ "inputSchema": {"type": "object", "properties": {
146
+ "account": {"type": "string"}, "project": {"type": "string"}}},
147
+ },
148
+ {
149
+ "name": "study_report",
150
+ "description": "ローカル施策JSONの未検証の採用宣言と本人の観測を結ぶ。proposedは集計せず、adoptedも因果効果や投稿承認を証明しない。読むだけ",
151
+ "inputSchema": {
152
+ "type": "object", "required": ["file"],
153
+ "properties": {"file": {"type": "string"}, "min_n": {"type": "integer"}},
154
+ },
155
+ },
156
+ {
157
+ "name": "analytics_report",
158
+ "description": "自分の活動のスナップショットを期間・母数・欠測・根拠つきで返す。ローカル台帳を読むだけ。compare_previous=trueで前期間比較。推奨は含まない",
159
+ "inputSchema": {
160
+ "type": "object",
161
+ "properties": {
162
+ "compare_previous": {"type": "boolean", "description": "直前の同じ日数との比較(既定false)"},
163
+ "account": {"type": "string"},
164
+ "project": {"type": "string"},
165
+ "window_days": {"type": "integer", "description": "直近何日(既定7)"},
166
+ "min_n": {"type": "integer", "description": "中央値の最小母数(既定5)"},
167
+ },
168
+ },
169
+ },
142
170
  {
143
171
  # **名前と説明文がそのまま売り文句**(設計「自分の泉」§2.2)。
144
172
  # 実装が 1 語でも足したら設計書でなく実装を戻す(§2 の頭書きそのまま)。
@@ -412,14 +440,14 @@ def validate_arguments(name: str, arguments) -> dict:
412
440
  f"CLI の旗と区別できないので受け取りません")
413
441
  if key == "file":
414
442
  check_file_argument(name, value)
415
- if name in ("after_you_posted", "where_to_appear", "who_is_this") \
443
+ if name in ("operations_handoff", "analytics_report", "after_you_posted", "where_to_appear", "who_is_this") \
416
444
  and not arguments.get("account") \
417
445
  and not arguments.get("project"):
418
446
  # **`account` か `project` のどちらか必須**(`required` は AND の意味しか
419
447
  # 持てないので、ここで OR を見る・T2-3 発注書・T3-3 も同じ形)。
420
448
  raise ToolInputError(
421
449
  f"{name}: account か project のどちらかが要ります")
422
- if name == "after_you_posted" and arguments.get("account") \
450
+ if name in ("operations_handoff", "analytics_report", "after_you_posted") and arguments.get("account") \
423
451
  and arguments.get("project"):
424
452
  raise ToolInputError(
425
453
  f"{name}: account と project は同時に指定できません")
@@ -503,6 +531,36 @@ def call_tool(name: str, arguments: dict | None) -> dict:
503
531
  args.append("--json")
504
532
  proc = run_cli(args)
505
533
  text = proc.stdout
534
+ elif name == "operations_handoff":
535
+ args = ["handoff-report"]
536
+ if arguments.get("project"):
537
+ args += ["--project", arguments["project"]]
538
+ else:
539
+ args.append(arguments["account"])
540
+ args.append("--json")
541
+ proc = run_cli(args)
542
+ text = proc.stdout
543
+ elif name == "study_report":
544
+ args = ["study-report", arguments["file"]]
545
+ if arguments.get("min_n") is not None:
546
+ args += ["--min-n", str(arguments["min_n"])]
547
+ args.append("--json")
548
+ proc = run_cli(args)
549
+ text = proc.stdout
550
+ elif name == "analytics_report":
551
+ args = ["analytics-report"]
552
+ if arguments.get("project"):
553
+ args += ["--project", arguments["project"]]
554
+ else:
555
+ args.append(arguments["account"])
556
+ for key in ("window_days", "min_n"):
557
+ if arguments.get(key) is not None:
558
+ args += ["--" + key.replace("_", "-"), str(arguments[key])]
559
+ if arguments.get("compare_previous"):
560
+ args.append("--compare-previous")
561
+ args.append("--json")
562
+ proc = run_cli(args)
563
+ text = proc.stdout
506
564
  elif name == "after_you_posted":
507
565
  # **`before_you_post` と同じ型**(発注 T0-2): CLI を `--json` で呼ぶだけ。
508
566
  args = ["after"]
@@ -579,7 +637,7 @@ def call_tool(name: str, arguments: dict | None) -> dict:
579
637
  return {"content": [{"type": "text", "text": f"unknown tool: {name}"}], "isError": True}
580
638
 
581
639
  if name.startswith("thth_topic_") or name in (
582
- "before_you_post", "after_you_posted", "thread_read", "where_to_appear",
640
+ "before_you_post", "after_you_posted", "analytics_report", "operations_handoff", "study_report", "thread_read", "where_to_appear",
583
641
  "who_is_this"):
584
642
  # **新しい道具は exit 1 も isError**(設計 §7)。lint の exit 1(検査結果)
585
643
  # とは意味が違う——こちらは stale_context・不正な候補比較で、
@@ -127,6 +127,16 @@ MCP `after_you_posted`。**24h の刻みが無ければ `null`**(0 と混ぜ
127
127
  - `thth doctor <account>` が「いま投稿できる状態か」を最初から最後まで言う。
128
128
  - 使い方の全文: `docs/使い方_プロジェクトのセッション向け_2026-09-09.md`。
129
129
 
130
+ ## 運用と分析のレポート(v3系・2.5.0 から)
131
+
132
+ 2.4.0 以前には無い入口を含む。接続先のhelp/tools一覧にある場合だけ使う。
133
+
134
+ - 再開時は `handoff-report` / MCP `operations_handoff`。ローカルの停止・要確認・通知記録を読む。`waiting`をtimer正常や投稿成功と解釈しない。
135
+ - `analytics-report` / MCP `analytics_report` は期間・母数・欠測・根拠付きsnapshot。生成時刻とデータ更新時刻を混同しない。
136
+ - 前期間比較は `--compare-previous` / `compare_previous: true`。投稿後24〜30時間未満の実観測で比較し、欠測をゼロにしない。
137
+ - 施策メモの振り返りは `study-report` / `study_report`。採用宣言と観測を分け、提案を自動で採用済みにしない。投稿の承認には使えない。
138
+ - 観測差を因果効果と断定しない。accountをまたいだ合算・順位を作らない。詳しい読み順は `docs/手順_LLM_分析と運用の引継ぎ.md`。
139
+
130
140
  ## 停止通知と運用状況(開発版 main)
131
141
 
132
142
  新規 account は利用者メール・管理者メール・TLS SMTP を `thth notifications config` で設定し、`status` → `test` を実行する。両宛先の受信箱で到達、account 専用 HEALTHCHECK_URL の missed-ping 通知を確認してから production/scheduled を有効にする。既存 account にも同じ設定を追加する。詳細は `docs/停止通知と運用記録.md`。
@@ -0,0 +1,213 @@
1
+ """Comparison contract: actual elapsed time, account isolation, incomplete samples."""
2
+ import datetime
3
+ import json
4
+ from pathlib import Path
5
+
6
+ import pytest
7
+
8
+ from thth import analytics_report, after_cli, cli, jst
9
+ from tests.test_after_cli import _insight_path, _write_ndjson, _seed_engagement, _load_server_module
10
+
11
+ NOW = jst.parse("2026-09-18T12:00:00+09:00")
12
+
13
+
14
+ def seed(account, post_id, posted, *, age=25, value=10, marks=None, extra=None):
15
+ posted = jst.parse(posted) if isinstance(posted, str) else posted
16
+ row = {"account": account["name"], "post_id": post_id, "posted_at": jst.iso(posted),
17
+ "collected_at": jst.iso(posted + datetime.timedelta(hours=age)),
18
+ "age_hours": 999, "marks": [24] if marks is None else marks,
19
+ "reply_to": None, "metrics": {"views": value, "likes": 0, "replies": 0}}
20
+ row.update(extra or {})
21
+ _write_ndjson(_insight_path(account, post_id), [row])
22
+ return row
23
+
24
+
25
+ def report(account, **kwargs):
26
+ return analytics_report.answer(account["name"], now=NOW, compare_previous=True, **kwargs)
27
+
28
+
29
+ def test_half_open_zero_delta_and_read_only(isolated_account_factory):
30
+ a = isolated_account_factory()
31
+ seed(a, "previous-start", NOW - datetime.timedelta(days=14), value=0)
32
+ seed(a, "current-start", NOW - datetime.timedelta(days=7), value=5)
33
+ seed(a, "end-excluded", NOW)
34
+ seed(a, "too-old", NOW - datetime.timedelta(days=14, seconds=1))
35
+ root = Path(a["repo_dir"])
36
+ before = {str(p): p.read_bytes() for p in root.rglob("*") if p.is_file()}
37
+ r = report(a, min_n=1)
38
+ p = r["by_account"][a["name"]]["posts"]
39
+ assert p["previous"]["n_total"] == p["current"]["n_total"] == 1
40
+ assert p["previous"]["metrics"]["views"]["median"] == 0
41
+ assert p["comparison"]["views"]["absolute_median_change"] == 5
42
+ assert p["previous"]["evidence"][0]["observation"]["actual_age_hours"] == 25
43
+ assert r["data_updated_at"] == jst.iso(NOW - datetime.timedelta(days=7) + datetime.timedelta(hours=25))
44
+ assert r["data_updated_at_scope"] == "selected_observations"
45
+ assert before == {str(p): p.read_bytes() for p in root.rglob("*") if p.is_file()}
46
+ assert not r["periods"]["previous"]["end_inclusive"]
47
+ markdown = analytics_report.render_markdown(r)
48
+ assert json.loads("\n".join(s[4:] for s in markdown.splitlines() if s.startswith(" "))) == r
49
+
50
+
51
+ @pytest.mark.parametrize("age,marks,extra,reason", [
52
+ (23.99, [24], {}, "premature_observation"),
53
+ (30, [24], {}, "late_observation"),
54
+ (25, [1, 24], {}, "missing_or_collapsed_24h_mark"),
55
+ (25, [], {}, "missing_or_collapsed_24h_mark"),
56
+ (25, [24], {"collected_at": "bad"}, "invalid_collected_at"),
57
+ (25, [24], {"collected_at": "2026-09-18T13:00:00+09:00"}, "future_observation"),
58
+ (25, [24], {"collected_at": "2026-09-12T13:00:00"}, "invalid_collected_at"),
59
+ ])
60
+ def test_rejected_observations(isolated_account_factory, age, marks, extra, reason):
61
+ a = isolated_account_factory()
62
+ seed(a, "p", NOW - datetime.timedelta(days=3), age=age, marks=marks, extra=extra)
63
+ p = report(a, min_n=1)["by_account"][a["name"]]["posts"]["current"]
64
+ assert (p["n_total"], p["n_eligible"], p["n_missing"], p["n_incomplete"]) == (1, 0, 1, 1)
65
+ assert p["metrics"]["views"]["median"] is None
66
+ assert p["evidence"][0]["rejected_observations"] == {reason: 1}
67
+ assert p["data_updated_at"] is None
68
+
69
+
70
+ def test_immature_and_sparse_are_not_zero(isolated_account_factory):
71
+ a = isolated_account_factory()
72
+ seed(a, "new", NOW - datetime.timedelta(hours=1), age=0.5)
73
+ seed(a, "old", NOW - datetime.timedelta(days=8), value=0)
74
+ r = report(a, min_n=2)
75
+ p = r["by_account"][a["name"]]["posts"]
76
+ assert p["current"]["n_immature"] == 1
77
+ assert p["previous"]["metrics"]["views"]["n_eligible"] == 1
78
+ assert p["comparison"]["views"]["absolute_median_change"] is None
79
+ assert not p["comparison"]["views"]["comparable"]
80
+
81
+
82
+ @pytest.mark.parametrize("value", [None, True, -1, float("nan"), float("inf"), "3", 10**1000])
83
+ def test_invalid_metric_does_not_become_zero(isolated_account_factory, value):
84
+ a = isolated_account_factory()
85
+ seed(a, "p", NOW - datetime.timedelta(days=3), value=value)
86
+ p = report(a, min_n=1)["by_account"][a["name"]]["posts"]["current"]
87
+ assert p["n_eligible"] == 1 # time-eligible does not imply every metric exists
88
+ assert p["metrics"]["views"] == {"n_total": 1, "n_eligible": 0, "n_missing": 1, "median": None}
89
+ json.dumps(p, allow_nan=False)
90
+
91
+
92
+ def test_earliest_actual_time_and_one_per_post(isolated_account_factory):
93
+ a = isolated_account_factory()
94
+ posted = NOW - datetime.timedelta(days=3)
95
+ later = seed(a, "p", posted, age=26, value=30)
96
+ earlier = dict(later, collected_at=(posted + datetime.timedelta(hours=24)).astimezone(datetime.timezone.utc).isoformat(), metrics={"likes": 3})
97
+ _write_ndjson(_insight_path(a, "p"), [later, earlier, earlier])
98
+ p = report(a, min_n=1)["by_account"][a["name"]]["posts"]["current"]
99
+ assert p["n_total"] == p["n_eligible"] == 1
100
+ assert p["metrics"]["views"]["n_eligible"] == 0 # never substitute later metric
101
+ assert p["metrics"]["likes"]["median"] == 3
102
+ assert p["evidence"][0]["observation"]["actual_age_hours"] == 24
103
+
104
+
105
+ def test_ownership_reply_population_and_no_fallback(isolated_account_factory, monkeypatch):
106
+ a = isolated_account_factory()
107
+ posted = NOW - datetime.timedelta(days=3)
108
+ seed(a, "foreign", posted, extra={"account": "elsewhere"})
109
+ seed(a, "unknown", posted, extra={"account": None})
110
+ seed(a, "ambiguous-root", posted, extra={"source": "sent"})
111
+ seed(a, "reply", posted, extra={"metrics": {"views": 4}})
112
+ _seed_engagement(a, "reply", posted_at=jst.iso(posted))
113
+ _seed_engagement(a, "unmeasured", posted_at=jst.iso(posted))
114
+ monkeypatch.setattr(after_cli, "_replies_back_from_ledger", lambda *a: pytest.fail("no fallback"))
115
+ n = report(a, min_n=1)["by_account"][a["name"]]
116
+ assert n["posts"]["current"]["n_total"] == 0
117
+ assert n["engagements"]["current"]["n_total"] == 2
118
+ assert n["engagements"]["current"]["metrics"]["replies"]["n_eligible"] == 0
119
+ assert n["excluded_records"]["unknown_ownership"] == 1
120
+
121
+
122
+ def test_project_isolation_and_broken_ledger(isolated_account_factory):
123
+ a = isolated_account_factory(name="one", project="p")
124
+ b = isolated_account_factory(name="two", project="p")
125
+ seed(a, "a", NOW - datetime.timedelta(days=3), value=0)
126
+ seed(b, "b", NOW - datetime.timedelta(days=3), value=10)
127
+ Path(_insight_path(a, "broken")).write_text("[broken")
128
+ r = analytics_report.answer(project="p", now=NOW, min_n=1, compare_previous=True)
129
+ assert r["by_account"]["one"]["posts"]["current"]["metrics"]["views"]["median"] == 0
130
+ assert r["by_account"]["two"]["posts"]["current"]["metrics"]["views"]["median"] == 10
131
+ assert r["by_account"]["one"]["incomplete_sources"]["measured_files"] == 1
132
+ assert "posts" not in r
133
+
134
+
135
+ def test_cli_mcp_boolean_contract(isolated_account_factory, monkeypatch, capsys):
136
+ a = isolated_account_factory()
137
+ monkeypatch.setattr(analytics_report.jst, "now_jst", lambda: NOW)
138
+ assert cli.main(["analytics-report", a["name"], "--compare-previous", "--json"]) == 0
139
+ assert json.loads(capsys.readouterr().out) == report(a)
140
+ server = _load_server_module()
141
+ result = server.call_tool("analytics_report", {"account": a["name"], "compare_previous": True})
142
+ assert not result["isError"]
143
+ assert json.loads(result["content"][0]["text"])["report_type"] == "period_comparison"
144
+ with pytest.raises(server.ToolInputError):
145
+ server.validate_arguments("analytics_report", {"account": a["name"], "compare_previous": "true"})
146
+ with pytest.raises(after_cli.AfterError):
147
+ report(a, window_days=10**100)
148
+ with pytest.raises(after_cli.AfterError):
149
+ analytics_report.answer(a["name"], compare_previous=1)
150
+
151
+
152
+ @pytest.mark.parametrize("change", [{"reply_to": "parent"}, {"source": "sent"}, {"source": "unknown"}])
153
+ def test_conflicting_row_root_classification_excluded(isolated_account_factory, change):
154
+ a = isolated_account_factory()
155
+ row = seed(a, "p", NOW - datetime.timedelta(days=3), age=1, marks=[1])
156
+ later = dict(row, collected_at=jst.iso(NOW - datetime.timedelta(days=2)), marks=[24], **change)
157
+ _write_ndjson(_insight_path(a, "p"), [row, later])
158
+ node = report(a, min_n=1)["by_account"][a["name"]]
159
+ assert node["posts"]["current"]["n_total"] == 0
160
+ assert node["excluded_records"]["conflicting_root_classification"] == 1
161
+
162
+
163
+ def test_conflicting_row_posted_time_rejected(isolated_account_factory):
164
+ a = isolated_account_factory()
165
+ row = seed(a, "p", NOW - datetime.timedelta(days=3), age=1, marks=[1])
166
+ later = dict(row, collected_at=jst.iso(NOW - datetime.timedelta(days=2)), marks=[24],
167
+ posted_at=jst.iso(NOW - datetime.timedelta(days=4)))
168
+ _write_ndjson(_insight_path(a, "p"), [row, later])
169
+ p = report(a, min_n=1)["by_account"][a["name"]]["posts"]["current"]
170
+ assert p["n_eligible"] == 0
171
+ assert p["evidence"][0]["rejected_observations"]["invalid_or_conflicting_posted_at"] == 1
172
+
173
+
174
+ def test_duplicate_reply_and_conflicting_dates(isolated_account_factory):
175
+ a = isolated_account_factory()
176
+ posted = NOW - datetime.timedelta(days=3)
177
+ for _ in range(2):
178
+ _seed_engagement(a, "same", posted_at=jst.iso(posted))
179
+ _seed_engagement(a, "conflict", posted_at=jst.iso(posted))
180
+ _seed_engagement(a, "conflict", posted_at=jst.iso(posted - datetime.timedelta(days=1)))
181
+ node = report(a)["by_account"][a["name"]]
182
+ assert node["engagements"]["current"]["n_total"] == 1
183
+ assert node["excluded_records"]["invalid_or_conflicting_engagement_posted_at"] == 1
184
+
185
+
186
+ def test_large_finite_metrics_stay_json_finite(isolated_account_factory):
187
+ a = isolated_account_factory()
188
+ seed(a, "p1", NOW - datetime.timedelta(days=3), value=1e308)
189
+ seed(a, "p2", NOW - datetime.timedelta(days=4), value=1e308)
190
+ r = report(a, min_n=2)
191
+ assert r["by_account"][a["name"]]["posts"]["current"]["metrics"]["views"]["median"] == 1e308
192
+ json.dumps(r, allow_nan=False)
193
+
194
+
195
+ def test_malformed_loader_data_is_error_not_empty_population(isolated_account_factory):
196
+ a = isolated_account_factory()
197
+ seed(a, "p", NOW - datetime.timedelta(days=3), extra={"metrics": [1]})
198
+ with pytest.raises(after_cli.AfterError, match="台帳の形式"):
199
+ report(a)
200
+
201
+
202
+ def test_conflicting_measured_reply_time_is_excluded_from_population(isolated_account_factory):
203
+ a = isolated_account_factory()
204
+ posted = NOW - datetime.timedelta(days=3)
205
+ seed(a, "reply-conflict", posted, extra={"reply_to": "parent"})
206
+ _seed_engagement(a, "reply-conflict", posted_at=jst.iso(posted - datetime.timedelta(days=1)))
207
+ node = report(a)["by_account"][a["name"]]
208
+ assert node["excluded_records"]["conflicting_measured_engagement_posted_at"] == 1
209
+ for kind in ("posts", "engagements"):
210
+ for period in ("previous", "current"):
211
+ population = node[kind][period]
212
+ assert population["n_total"] == population["n_missing"] == population["n_incomplete"] == 0
213
+ assert population["evidence"] == []
@@ -0,0 +1,115 @@
1
+ """Versioned snapshot: calculation parity, provenance and read-only boundaries."""
2
+ import datetime
3
+ import json
4
+ from pathlib import Path
5
+
6
+ import pytest
7
+
8
+ from thth import after_cli, analytics_report, cli, jst
9
+ from tests.test_after_cli import (_load_server_module, _seed_measured, _seed_engagement,
10
+ _insight_path)
11
+
12
+ NOW = jst.parse("2026-09-17T08:00:00+09:00")
13
+
14
+
15
+ def test_boundaries_evidence_missing_and_no_writes(isolated_account_factory):
16
+ account = isolated_account_factory()
17
+ for name, time, marks in [
18
+ ("start", "2026-09-10T08:00:00+09:00", (24,)),
19
+ ("end", "2026-09-17T08:00:00+09:00", ()),
20
+ ("old", "2026-09-10T07:59:59+09:00", (24,)),
21
+ ("future", "2026-09-17T08:00:01+09:00", (24,)),
22
+ ]:
23
+ _seed_measured(account, name, posted_at=time, marks=marks)
24
+ _seed_engagement(account, "reply", posted_at="2026-09-12T08:00:00+09:00")
25
+ queue = Path(account["repo_dir"]) / "docs/sns/queue/protected.md"
26
+ queue.parent.mkdir(parents=True, exist_ok=True)
27
+ queue.write_text("---\napproved: true\n---\nPRIVATE BODY")
28
+ before = {p: p.read_bytes() for p in Path(account["repo_dir"]).rglob("*") if p.is_file()}
29
+ result = analytics_report.answer(account["name"], now=NOW)
30
+ after = {p: p.read_bytes() for p in Path(account["repo_dir"]).rglob("*") if p.is_file()}
31
+ assert before == after
32
+ node = result["by_account"][account["name"]]
33
+ original = after_cli.answer(account["name"], now=NOW, window_days=7)
34
+ for key in ("posts", "engagements", "comparable", "cannot_say"):
35
+ assert node[key] == original[key]
36
+ assert {p["post_id"] for p in node["posts"]["by_post"]} == {"start", "end"}
37
+ assert node["posts"]["views_24h"] == {"median": None, "n": 1}
38
+ end = next(p for p in node["posts"]["by_post"] if p["post_id"] == "end")
39
+ assert end["views_24h"] is None and end["covered"] is False
40
+ assert node["engagements"]["by_branch"][0]["post_id"] == "reply"
41
+ assert "one_thing_to_change" not in node
42
+ assert "updated" not in node["provenance"]
43
+ assert result["data_updated_at"] is None and result["data_updated_at_reason"]
44
+ assert result["period"]["start_inclusive"] and result["period"]["end_inclusive"]
45
+ assert result["period"]["start"] == "2026-09-10T08:00:00+09:00"
46
+ assert result["generated_at"] == "2026-09-17T08:00:00+09:00"
47
+ text = json.dumps(result)
48
+ assert "PRIVATE BODY" not in text
49
+ assert '"username"' not in text and '"text"' not in text
50
+ markdown = analytics_report.render_markdown(result)
51
+ recovered = "\n".join(line[4:] for line in markdown.splitlines() if line.startswith(" "))
52
+ assert json.loads(recovered) == result
53
+
54
+
55
+ def test_project_separation_and_unknown_project(isolated_account_factory):
56
+ one = isolated_account_factory(name="one", project="same")
57
+ two = isolated_account_factory(name="two", project="same")
58
+ isolated_account_factory(name="outside", project="other")
59
+ _seed_measured(one, "P1", metrics={"views": 0})
60
+ _seed_measured(two, "P2", metrics={"views": 100})
61
+ result = analytics_report.answer(project="same", min_n=1, now=NOW)
62
+ assert set(result["by_account"]) == {"one", "two"}
63
+ assert result["by_account"]["one"]["posts"]["views_24h"]["median"] == 0
64
+ assert result["by_account"]["two"]["posts"]["views_24h"]["median"] == 100
65
+ assert "posts" not in result
66
+ with pytest.raises(after_cli.AfterError, match="account"):
67
+ analytics_report.answer(project="missing", now=NOW)
68
+
69
+
70
+ def test_broken_ledger_is_not_silently_healthy(isolated_account_factory):
71
+ account = isolated_account_factory()
72
+ _seed_measured(account, "good")
73
+ path = Path(_insight_path(account, "bad"))
74
+ path.write_text("{broken json\n")
75
+ node = analytics_report.answer(account["name"], now=NOW)["by_account"][account["name"]]
76
+ assert any("読めない実測台帳" in line for line in node["cannot_say"])
77
+
78
+
79
+ @pytest.mark.parametrize("kwargs", [{}, {"account_name": "a", "project": "p"},
80
+ {"account_name": ""}, {"project": " "}, {"account_name": "a", "window_days": True},
81
+ {"account_name": "a", "min_n": False}, {"account_name": "a", "window_days": 0},
82
+ {"account_name": "a", "min_n": 1.5}, {"account_name": "a", "window_days": 10**100},
83
+ {"account_name": "a", "now": datetime.datetime(2026, 9, 17)}])
84
+ def test_invalid_input(kwargs):
85
+ with pytest.raises(after_cli.AfterError):
86
+ analytics_report.answer(**kwargs)
87
+
88
+
89
+ def test_cli_and_mcp_same_payload(isolated_account_factory, monkeypatch, capsys):
90
+ account = isolated_account_factory()
91
+ _seed_measured(account, "root")
92
+ monkeypatch.setattr(analytics_report.jst, "now_jst", lambda: NOW)
93
+ assert cli.main(["analytics-report", account["name"], "--json"]) == 0
94
+ cli_result = json.loads(capsys.readouterr().out)
95
+ assert cli.main(["analytics-report", account["name"]]) == 0
96
+ assert capsys.readouterr().out == analytics_report.render_markdown(cli_result) + "\n"
97
+ server = _load_server_module()
98
+ result = server.call_tool("analytics_report", {"account": account["name"]})
99
+ assert not result["isError"]
100
+ payload = json.loads(result["content"][0]["text"])
101
+ expected = analytics_report.answer(account["name"], now=jst.parse(payload["generated_at"]))
102
+ assert payload == expected
103
+ assert cli.main(["analytics-report", "--project", "missing", "--json"]) == 2
104
+ assert capsys.readouterr().out == ""
105
+ assert server.call_tool("analytics_report", {"project": "missing"})["isError"]
106
+ assert server.call_tool("analytics_report", {"account": "missing"})["isError"]
107
+
108
+
109
+ @pytest.mark.parametrize("args", [{}, {"account": "a", "project": "p"},
110
+ {"account": "a", "window_days": True}, {"account": "a", "min_n": False},
111
+ {"account": "a", "secret": "not-valid"}])
112
+ def test_mcp_input_boundary(args):
113
+ server = _load_server_module()
114
+ with pytest.raises(server.ToolInputError):
115
+ server.validate_arguments("analytics_report", args)
@@ -49,6 +49,39 @@ def _hold(account_name, seconds, which="account"):
49
49
  return holder
50
50
 
51
51
 
52
+ def _hold_until_released(account_name):
53
+ """Keep the lock until the parent closes stdin, regardless of CLI latency."""
54
+ code = """
55
+ import sys
56
+ from thth import accounts, lock
57
+ held = lock.AccountLock(accounts.account_lock_path_for(sys.argv[1]))
58
+ held.acquire()
59
+ try:
60
+ print("locked", flush=True)
61
+ sys.stdin.read()
62
+ finally:
63
+ held.release()
64
+ """
65
+ holder = subprocess.Popen(
66
+ [sys.executable, "-c", code, account_name],
67
+ stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
68
+ text=True, env=dict(os.environ),
69
+ cwd=os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
70
+ assert holder.stdout.readline().strip() == "locked"
71
+ return holder
72
+
73
+
74
+ def _release(holder):
75
+ try:
76
+ # communicate closes stdin: that EOF is the explicit release event.
77
+ stdout, stderr = holder.communicate(timeout=5)
78
+ except subprocess.TimeoutExpired:
79
+ holder.kill()
80
+ holder.communicate(timeout=5)
81
+ raise
82
+ assert holder.returncode == 0, stdout + stderr
83
+
84
+
52
85
  def _stop(holder):
53
86
  holder.terminate()
54
87
  holder.wait(timeout=5)
@@ -146,7 +179,7 @@ def test_boardはロックを奪わない(isolated_account):
146
179
  name = isolated_account["name"]
147
180
  lock_path = accounts_mod.account_lock_path_for(name)
148
181
 
149
- holder = _hold(name, 3)
182
+ holder = _hold_until_released(name)
150
183
  try:
151
184
  for _ in range(3):
152
185
  run_thth(["board"])
@@ -156,9 +189,8 @@ def test_boardはロックを奪わない(isolated_account):
156
189
  blocked = run_thth(["throw", name])
157
190
  assert blocked.returncode == 1, blocked.stdout + blocked.stderr
158
191
  finally:
159
- holder.wait(timeout=15)
192
+ _release(holder)
160
193
 
161
- time.sleep(0.2)
162
194
  # 放したあとは通る(board が握りっぱなしにしていない証拠)。
163
195
  assert lock_mod.AccountLock.holder_pid(lock_path) is None
164
196
  ok = run_thth(["throw", name])
@@ -60,13 +60,13 @@ def _readme_ja_commands() -> list:
60
60
  text = (REPO_ROOT / "README.md").read_text(encoding="utf-8")
61
61
  m = re.search(r"動くもの.*?: (.+?)。\n", text)
62
62
  assert m, "README.md に「動くもの」の行が見つかりません"
63
- return re.findall(r"`([a-z_]+)`", m.group(1))
63
+ return re.findall(r"`([a-z_-]+)`", m.group(1))
64
64
 
65
65
 
66
66
  def _readme_en_commands() -> list:
67
67
  """README.en.md の Commands 節、`- \\`name\\` — ...` の形の行から名前を拾う。"""
68
68
  text = (REPO_ROOT / "README.en.md").read_text(encoding="utf-8")
69
- return re.findall(r"^- `([a-z_]+)` — ", text, flags=re.MULTILINE)
69
+ return re.findall(r"^- `([a-z_-]+)` — ", text, flags=re.MULTILINE)
70
70
 
71
71
 
72
72
  def test_READMEの動くものの列挙はthth_helpの選択肢と一致する():