thth 2.4.0__tar.gz → 2.7.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 (289) hide show
  1. {thth-2.4.0 → thth-2.7.0}/PKG-INFO +34 -4
  2. {thth-2.4.0 → thth-2.7.0}/README.md +33 -3
  3. {thth-2.4.0 → thth-2.7.0}/mcp/server.py +74 -3
  4. {thth-2.4.0 → thth-2.7.0}/skills/thth/SKILL.md +10 -0
  5. thth-2.7.0/tests/test_27_acceptance_fixes.py +45 -0
  6. thth-2.7.0/tests/test_analytics_comparison.py +213 -0
  7. thth-2.7.0/tests/test_analytics_report.py +134 -0
  8. {thth-2.4.0 → thth-2.7.0}/tests/test_board_running.py +35 -3
  9. {thth-2.4.0 → thth-2.7.0}/tests/test_cli_help.py +2 -2
  10. {thth-2.4.0 → thth-2.7.0}/tests/test_collect_sent.py +2 -1
  11. {thth-2.4.0 → thth-2.7.0}/tests/test_mcp.py +1 -1
  12. thth-2.7.0/tests/test_operations_handoff.py +156 -0
  13. thth-2.7.0/tests/test_report_checkout_import.py +36 -0
  14. thth-2.7.0/tests/test_report_http.py +366 -0
  15. thth-2.7.0/tests/test_report_isolation.py +131 -0
  16. thth-2.7.0/tests/test_report_service.py +107 -0
  17. thth-2.7.0/tests/test_study_report.py +170 -0
  18. thth-2.7.0/tests/test_v31_analysis.py +219 -0
  19. thth-2.7.0/tests/test_v31_collection.py +181 -0
  20. thth-2.7.0/tests/test_x_metrics.py +156 -0
  21. thth-2.7.0/thth/VERSION +1 -0
  22. thth-2.7.0/thth/analytics_comparison.py +343 -0
  23. thth-2.7.0/thth/analytics_report.py +166 -0
  24. thth-2.7.0/thth/analytics_shapes.py +51 -0
  25. thth-2.7.0/thth/analytics_threads.py +95 -0
  26. {thth-2.4.0 → thth-2.7.0}/thth/cli.py +37 -0
  27. {thth-2.4.0 → thth-2.7.0}/thth/collect.py +21 -6
  28. thth-2.7.0/thth/collection_context.py +58 -0
  29. thth-2.7.0/thth/collection_status.py +59 -0
  30. thth-2.7.0/thth/handoff_cursor.py +168 -0
  31. {thth-2.4.0 → thth-2.7.0}/thth/measured.py +5 -1
  32. thth-2.7.0/thth/operations_handoff.py +265 -0
  33. thth-2.7.0/thth/report_details.py +51 -0
  34. thth-2.7.0/thth/report_http.py +337 -0
  35. thth-2.7.0/thth/report_isolation.py +128 -0
  36. thth-2.7.0/thth/report_service.py +106 -0
  37. {thth-2.4.0 → thth-2.7.0}/thth/selfupdate.py +6 -1
  38. thth-2.7.0/thth/study_report.py +218 -0
  39. thth-2.7.0/thth/x_metrics.py +158 -0
  40. thth-2.4.0/thth/VERSION +0 -1
  41. {thth-2.4.0 → thth-2.7.0}/.gitignore +0 -0
  42. {thth-2.4.0 → thth-2.7.0}/LICENSE +0 -0
  43. {thth-2.4.0 → thth-2.7.0}/accounts.example/bluesky.json +0 -0
  44. {thth-2.4.0 → thth-2.7.0}/accounts.example/mastodon.json +0 -0
  45. {thth-2.4.0 → thth-2.7.0}/accounts.example/threads.json +0 -0
  46. {thth-2.4.0 → thth-2.7.0}/bin/thth +0 -0
  47. {thth-2.4.0 → thth-2.7.0}/bin/thth-remote +0 -0
  48. {thth-2.4.0 → thth-2.7.0}/bin/thth-run +0 -0
  49. {thth-2.4.0 → thth-2.7.0}/pyproject.toml +0 -0
  50. {thth-2.4.0 → thth-2.7.0}/systemd/thth@.service +0 -0
  51. {thth-2.4.0 → thth-2.7.0}/tests/__init__.py +0 -0
  52. {thth-2.4.0 → thth-2.7.0}/tests/conftest.py +0 -0
  53. {thth-2.4.0 → thth-2.7.0}/tests/fixtures/build_injection_eval.py +0 -0
  54. {thth-2.4.0 → thth-2.7.0}/tests/fixtures/callback/worker_responses.json +0 -0
  55. {thth-2.4.0 → thth-2.7.0}/tests/fixtures/injection_eval.json +0 -0
  56. {thth-2.4.0 → thth-2.7.0}/tests/fixtures/umami-bile.md +0 -0
  57. {thth-2.4.0 → thth-2.7.0}/tests/helpers/__init__.py +0 -0
  58. {thth-2.4.0 → thth-2.7.0}/tests/helpers/fake_oauth_server.py +0 -0
  59. {thth-2.4.0 → thth-2.7.0}/tests/helpers/fake_push_adapter.py +0 -0
  60. {thth-2.4.0 → thth-2.7.0}/tests/helpers/fake_redirect_server.py +0 -0
  61. {thth-2.4.0 → thth-2.7.0}/tests/helpers/fake_search_adapter.py +0 -0
  62. {thth-2.4.0 → thth-2.7.0}/tests/helpers/fake_threads_reader.py +0 -0
  63. {thth-2.4.0 → thth-2.7.0}/tests/helpers/fake_threads_writes.py +0 -0
  64. {thth-2.4.0 → thth-2.7.0}/tests/helpers/hold_lock.py +0 -0
  65. {thth-2.4.0 → thth-2.7.0}/tests/helpers/hold_repo_lock.py +0 -0
  66. {thth-2.4.0 → thth-2.7.0}/tests/helpers/worker_probe.mjs +0 -0
  67. {thth-2.4.0 → thth-2.7.0}/tests/test_account_name_check.py +0 -0
  68. {thth-2.4.0 → thth-2.7.0}/tests/test_account_report.py +0 -0
  69. {thth-2.4.0 → thth-2.7.0}/tests/test_accounts_dir.py +0 -0
  70. {thth-2.4.0 → thth-2.7.0}/tests/test_adapter_boundary.py +0 -0
  71. {thth-2.4.0 → thth-2.7.0}/tests/test_after_cli.py +0 -0
  72. {thth-2.4.0 → thth-2.7.0}/tests/test_api_diagnostic.py +0 -0
  73. {thth-2.4.0 → thth-2.7.0}/tests/test_api_error_body.py +0 -0
  74. {thth-2.4.0 → thth-2.7.0}/tests/test_app_env_cli.py +0 -0
  75. {thth-2.4.0 → thth-2.7.0}/tests/test_approval.py +0 -0
  76. {thth-2.4.0 → thth-2.7.0}/tests/test_approve_cmd.py +0 -0
  77. {thth-2.4.0 → thth-2.7.0}/tests/test_ask.py +0 -0
  78. {thth-2.4.0 → thth-2.7.0}/tests/test_atlas_fifth_review.py +0 -0
  79. {thth-2.4.0 → thth-2.7.0}/tests/test_atlas_fourth_review.py +0 -0
  80. {thth-2.4.0 → thth-2.7.0}/tests/test_atlas_review.py +0 -0
  81. {thth-2.4.0 → thth-2.7.0}/tests/test_atlas_second_review.py +0 -0
  82. {thth-2.4.0 → thth-2.7.0}/tests/test_atlas_third_review.py +0 -0
  83. {thth-2.4.0 → thth-2.7.0}/tests/test_audit1_fixes.py +0 -0
  84. {thth-2.4.0 → thth-2.7.0}/tests/test_audit2_fixes.py +0 -0
  85. {thth-2.4.0 → thth-2.7.0}/tests/test_audit_b_counterexamples.py +0 -0
  86. {thth-2.4.0 → thth-2.7.0}/tests/test_audit_b_m1_semantics.py +0 -0
  87. {thth-2.4.0 → thth-2.7.0}/tests/test_audit_b_n1_depth.py +0 -0
  88. {thth-2.4.0 → thth-2.7.0}/tests/test_audit_b_r1_closure.py +0 -0
  89. {thth-2.4.0 → thth-2.7.0}/tests/test_audit_v201.py +0 -0
  90. {thth-2.4.0 → thth-2.7.0}/tests/test_audit_v23_details.py +0 -0
  91. {thth-2.4.0 → thth-2.7.0}/tests/test_auth.py +0 -0
  92. {thth-2.4.0 → thth-2.7.0}/tests/test_auth_identity_required.py +0 -0
  93. {thth-2.4.0 → thth-2.7.0}/tests/test_behind_remote.py +0 -0
  94. {thth-2.4.0 → thth-2.7.0}/tests/test_bluesky_adapter.py +0 -0
  95. {thth-2.4.0 → thth-2.7.0}/tests/test_bluesky_secret_scrub.py +0 -0
  96. {thth-2.4.0 → thth-2.7.0}/tests/test_board_needs_review.py +0 -0
  97. {thth-2.4.0 → thth-2.7.0}/tests/test_bundle.py +0 -0
  98. {thth-2.4.0 → thth-2.7.0}/tests/test_charcount.py +0 -0
  99. {thth-2.4.0 → thth-2.7.0}/tests/test_cli_entrypoint.py +0 -0
  100. {thth-2.4.0 → thth-2.7.0}/tests/test_cli_vm_path.py +0 -0
  101. {thth-2.4.0 → thth-2.7.0}/tests/test_collect.py +0 -0
  102. {thth-2.4.0 → thth-2.7.0}/tests/test_collect_engagements.py +0 -0
  103. {thth-2.4.0 → thth-2.7.0}/tests/test_collect_runs.py +0 -0
  104. {thth-2.4.0 → thth-2.7.0}/tests/test_comparability.py +0 -0
  105. {thth-2.4.0 → thth-2.7.0}/tests/test_control_chars_refused.py +0 -0
  106. {thth-2.4.0 → thth-2.7.0}/tests/test_conversation_paging.py +0 -0
  107. {thth-2.4.0 → thth-2.7.0}/tests/test_count_for.py +0 -0
  108. {thth-2.4.0 → thth-2.7.0}/tests/test_docs_links.py +0 -0
  109. {thth-2.4.0 → thth-2.7.0}/tests/test_doctor.py +0 -0
  110. {thth-2.4.0 → thth-2.7.0}/tests/test_doctor_all_permissions.py +0 -0
  111. {thth-2.4.0 → thth-2.7.0}/tests/test_doctor_next_step.py +0 -0
  112. {thth-2.4.0 → thth-2.7.0}/tests/test_duplicate_front_matter_keys.py +0 -0
  113. {thth-2.4.0 → thth-2.7.0}/tests/test_editorial_audit.py +0 -0
  114. {thth-2.4.0 → thth-2.7.0}/tests/test_editorial_n3_n4.py +0 -0
  115. {thth-2.4.0 → thth-2.7.0}/tests/test_engagements.py +0 -0
  116. {thth-2.4.0 → thth-2.7.0}/tests/test_external_acceptance.py +0 -0
  117. {thth-2.4.0 → thth-2.7.0}/tests/test_fake_api.py +0 -0
  118. {thth-2.4.0 → thth-2.7.0}/tests/test_form_spec.py +0 -0
  119. {thth-2.4.0 → thth-2.7.0}/tests/test_form_spec_cli.py +0 -0
  120. {thth-2.4.0 → thth-2.7.0}/tests/test_forms.py +0 -0
  121. {thth-2.4.0 → thth-2.7.0}/tests/test_fresh_install.py +0 -0
  122. {thth-2.4.0 → thth-2.7.0}/tests/test_front_matter_injection.py +0 -0
  123. {thth-2.4.0 → thth-2.7.0}/tests/test_healthcheck_notifications.py +0 -0
  124. {thth-2.4.0 → thth-2.7.0}/tests/test_hypotheses.py +0 -0
  125. {thth-2.4.0 → thth-2.7.0}/tests/test_hypothesis_boundaries.py +0 -0
  126. {thth-2.4.0 → thth-2.7.0}/tests/test_hypothesis_units.py +0 -0
  127. {thth-2.4.0 → thth-2.7.0}/tests/test_impact_cli.py +0 -0
  128. {thth-2.4.0 → thth-2.7.0}/tests/test_inbox_granted_scopes.py +0 -0
  129. {thth-2.4.0 → thth-2.7.0}/tests/test_incident_notifications.py +0 -0
  130. {thth-2.4.0 → thth-2.7.0}/tests/test_independent_thread_review.py +0 -0
  131. {thth-2.4.0 → thth-2.7.0}/tests/test_independent_topic_review.py +0 -0
  132. {thth-2.4.0 → thth-2.7.0}/tests/test_independent_topic_review_r2.py +0 -0
  133. {thth-2.4.0 → thth-2.7.0}/tests/test_inflight.py +0 -0
  134. {thth-2.4.0 → thth-2.7.0}/tests/test_jst.py +0 -0
  135. {thth-2.4.0 → thth-2.7.0}/tests/test_lint.py +0 -0
  136. {thth-2.4.0 → thth-2.7.0}/tests/test_list_projections.py +0 -0
  137. {thth-2.4.0 → thth-2.7.0}/tests/test_llm_trial.py +0 -0
  138. {thth-2.4.0 → thth-2.7.0}/tests/test_llm_trial_engage.py +0 -0
  139. {thth-2.4.0 → thth-2.7.0}/tests/test_lock.py +0 -0
  140. {thth-2.4.0 → thth-2.7.0}/tests/test_m2_independent.py +0 -0
  141. {thth-2.4.0 → thth-2.7.0}/tests/test_maintain.py +0 -0
  142. {thth-2.4.0 → thth-2.7.0}/tests/test_mastodon_adapter.py +0 -0
  143. {thth-2.4.0 → thth-2.7.0}/tests/test_mastodon_visibility.py +0 -0
  144. {thth-2.4.0 → thth-2.7.0}/tests/test_max_per_run.py +0 -0
  145. {thth-2.4.0 → thth-2.7.0}/tests/test_mcp_robustness.py +0 -0
  146. {thth-2.4.0 → thth-2.7.0}/tests/test_measured.py +0 -0
  147. {thth-2.4.0 → thth-2.7.0}/tests/test_media_accounts.py +0 -0
  148. {thth-2.4.0 → thth-2.7.0}/tests/test_media_next_steps.py +0 -0
  149. {thth-2.4.0 → thth-2.7.0}/tests/test_media_roundtrip.py +0 -0
  150. {thth-2.4.0 → thth-2.7.0}/tests/test_mismatch_diagnostics.py +0 -0
  151. {thth-2.4.0 → thth-2.7.0}/tests/test_n5_reply_null_element.py +0 -0
  152. {thth-2.4.0 → thth-2.7.0}/tests/test_n6_replies_incomplete_ledger.py +0 -0
  153. {thth-2.4.0 → thth-2.7.0}/tests/test_n7_doctor_error_body.py +0 -0
  154. {thth-2.4.0 → thth-2.7.0}/tests/test_no_limits.py +0 -0
  155. {thth-2.4.0 → thth-2.7.0}/tests/test_observation_provenance.py +0 -0
  156. {thth-2.4.0 → thth-2.7.0}/tests/test_packaging.py +0 -0
  157. {thth-2.4.0 → thth-2.7.0}/tests/test_preview.py +0 -0
  158. {thth-2.4.0 → thth-2.7.0}/tests/test_production_mode.py +0 -0
  159. {thth-2.4.0 → thth-2.7.0}/tests/test_pull_cli.py +0 -0
  160. {thth-2.4.0 → thth-2.7.0}/tests/test_queue.py +0 -0
  161. {thth-2.4.0 → thth-2.7.0}/tests/test_quiet_hours.py +0 -0
  162. {thth-2.4.0 → thth-2.7.0}/tests/test_r2_r4_r5.py +0 -0
  163. {thth-2.4.0 → thth-2.7.0}/tests/test_read_cli_bare_runtimeerror.py +0 -0
  164. {thth-2.4.0 → thth-2.7.0}/tests/test_reason_adoption.py +0 -0
  165. {thth-2.4.0 → thth-2.7.0}/tests/test_redact.py +0 -0
  166. {thth-2.4.0 → thth-2.7.0}/tests/test_redirect_token_leak.py +0 -0
  167. {thth-2.4.0 → thth-2.7.0}/tests/test_refresh.py +0 -0
  168. {thth-2.4.0 → thth-2.7.0}/tests/test_refresh_destination.py +0 -0
  169. {thth-2.4.0 → thth-2.7.0}/tests/test_rejudge_new.py +0 -0
  170. {thth-2.4.0 → thth-2.7.0}/tests/test_rejudge_r2_r3.py +0 -0
  171. {thth-2.4.0 → thth-2.7.0}/tests/test_replies.py +0 -0
  172. {thth-2.4.0 → thth-2.7.0}/tests/test_replies_refresh.py +0 -0
  173. {thth-2.4.0 → thth-2.7.0}/tests/test_repo_lock.py +0 -0
  174. {thth-2.4.0 → thth-2.7.0}/tests/test_repo_sync.py +0 -0
  175. {thth-2.4.0 → thth-2.7.0}/tests/test_reverse_audit.py +0 -0
  176. {thth-2.4.0 → thth-2.7.0}/tests/test_review_case_id_cluster_m2.py +0 -0
  177. {thth-2.4.0 → thth-2.7.0}/tests/test_review_case_id_cluster_r2.py +0 -0
  178. {thth-2.4.0 → thth-2.7.0}/tests/test_review_cli.py +0 -0
  179. {thth-2.4.0 → thth-2.7.0}/tests/test_review_evidence_account.py +0 -0
  180. {thth-2.4.0 → thth-2.7.0}/tests/test_review_form_spec_and_case_id.py +0 -0
  181. {thth-2.4.0 → thth-2.7.0}/tests/test_review_provenance.py +0 -0
  182. {thth-2.4.0 → thth-2.7.0}/tests/test_review_records.py +0 -0
  183. {thth-2.4.0 → thth-2.7.0}/tests/test_revoke.py +0 -0
  184. {thth-2.4.0 → thth-2.7.0}/tests/test_roundtrip.py +0 -0
  185. {thth-2.4.0 → thth-2.7.0}/tests/test_run_missing_env.py +0 -0
  186. {thth-2.4.0 → thth-2.7.0}/tests/test_run_record_failclosed.py +0 -0
  187. {thth-2.4.0 → thth-2.7.0}/tests/test_runs_collected.py +0 -0
  188. {thth-2.4.0 → thth-2.7.0}/tests/test_runs_read_rows.py +0 -0
  189. {thth-2.4.0 → thth-2.7.0}/tests/test_schedule_multiple.py +0 -0
  190. {thth-2.4.0 → thth-2.7.0}/tests/test_schedule_view.py +0 -0
  191. {thth-2.4.0 → thth-2.7.0}/tests/test_scopes_source_requested.py +0 -0
  192. {thth-2.4.0 → thth-2.7.0}/tests/test_search_engage_targets.py +0 -0
  193. {thth-2.4.0 → thth-2.7.0}/tests/test_search_media_routes.py +0 -0
  194. {thth-2.4.0 → thth-2.7.0}/tests/test_secret_value_redact.py +0 -0
  195. {thth-2.4.0 → thth-2.7.0}/tests/test_secrets_grep.py +0 -0
  196. {thth-2.4.0 → thth-2.7.0}/tests/test_security_small_fixes.py +0 -0
  197. {thth-2.4.0 → thth-2.7.0}/tests/test_select.py +0 -0
  198. {thth-2.4.0 → thth-2.7.0}/tests/test_selfupdate.py +0 -0
  199. {thth-2.4.0 → thth-2.7.0}/tests/test_send.py +0 -0
  200. {thth-2.4.0 → thth-2.7.0}/tests/test_send_cli.py +0 -0
  201. {thth-2.4.0 → thth-2.7.0}/tests/test_send_control_chars.py +0 -0
  202. {thth-2.4.0 → thth-2.7.0}/tests/test_send_media.py +0 -0
  203. {thth-2.4.0 → thth-2.7.0}/tests/test_send_visibility.py +0 -0
  204. {thth-2.4.0 → thth-2.7.0}/tests/test_sent_integrity.py +0 -0
  205. {thth-2.4.0 → thth-2.7.0}/tests/test_server_json.py +0 -0
  206. {thth-2.4.0 → thth-2.7.0}/tests/test_site.py +0 -0
  207. {thth-2.4.0 → thth-2.7.0}/tests/test_skill_doc.py +0 -0
  208. {thth-2.4.0 → thth-2.7.0}/tests/test_systemd.py +0 -0
  209. {thth-2.4.0 → thth-2.7.0}/tests/test_thread_publish.py +0 -0
  210. {thth-2.4.0 → thth-2.7.0}/tests/test_thread_read.py +0 -0
  211. {thth-2.4.0 → thth-2.7.0}/tests/test_threadrun.py +0 -0
  212. {thth-2.4.0 → thth-2.7.0}/tests/test_threads_read_permissions.py +0 -0
  213. {thth-2.4.0 → thth-2.7.0}/tests/test_threadshape.py +0 -0
  214. {thth-2.4.0 → thth-2.7.0}/tests/test_thth_root.py +0 -0
  215. {thth-2.4.0 → thth-2.7.0}/tests/test_token_exists.py +0 -0
  216. {thth-2.4.0 → thth-2.7.0}/tests/test_token_scopes_record.py +0 -0
  217. {thth-2.4.0 → thth-2.7.0}/tests/test_token_set.py +0 -0
  218. {thth-2.4.0 → thth-2.7.0}/tests/test_topic.py +0 -0
  219. {thth-2.4.0 → thth-2.7.0}/tests/test_topic_advice.py +0 -0
  220. {thth-2.4.0 → thth-2.7.0}/tests/test_topic_cli.py +0 -0
  221. {thth-2.4.0 → thth-2.7.0}/tests/test_topic_models.py +0 -0
  222. {thth-2.4.0 → thth-2.7.0}/tests/test_topic_review_fixes.py +0 -0
  223. {thth-2.4.0 → thth-2.7.0}/tests/test_topic_shelf_observers.py +0 -0
  224. {thth-2.4.0 → thth-2.7.0}/tests/test_topic_store.py +0 -0
  225. {thth-2.4.0 → thth-2.7.0}/tests/test_topics_record_concurrent.py +0 -0
  226. {thth-2.4.0 → thth-2.7.0}/tests/test_topics_tool.py +0 -0
  227. {thth-2.4.0 → thth-2.7.0}/tests/test_trial_frictions.py +0 -0
  228. {thth-2.4.0 → thth-2.7.0}/tests/test_v21b_approved_writes.py +0 -0
  229. {thth-2.4.0 → thth-2.7.0}/tests/test_value_domains_shown.py +0 -0
  230. {thth-2.4.0 → thth-2.7.0}/tests/test_version.py +0 -0
  231. {thth-2.4.0 → thth-2.7.0}/tests/test_where_cli.py +0 -0
  232. {thth-2.4.0 → thth-2.7.0}/tests/test_who_cli.py +0 -0
  233. {thth-2.4.0 → thth-2.7.0}/tests/test_writeback_rebase.py +0 -0
  234. {thth-2.4.0 → thth-2.7.0}/tests/test_writeback_validate.py +0 -0
  235. {thth-2.4.0 → thth-2.7.0}/thth/__init__.py +0 -0
  236. {thth-2.4.0 → thth-2.7.0}/thth/__main__.py +0 -0
  237. {thth-2.4.0 → thth-2.7.0}/thth/account_cli.py +0 -0
  238. {thth-2.4.0 → thth-2.7.0}/thth/account_report.py +0 -0
  239. {thth-2.4.0 → thth-2.7.0}/thth/accounts.py +0 -0
  240. {thth-2.4.0 → thth-2.7.0}/thth/adapters/__init__.py +0 -0
  241. {thth-2.4.0 → thth-2.7.0}/thth/adapters/base.py +0 -0
  242. {thth-2.4.0 → thth-2.7.0}/thth/adapters/bluesky.py +0 -0
  243. {thth-2.4.0 → thth-2.7.0}/thth/adapters/mastodon.py +0 -0
  244. {thth-2.4.0 → thth-2.7.0}/thth/adapters/threads.py +0 -0
  245. {thth-2.4.0 → thth-2.7.0}/thth/after_cli.py +0 -0
  246. {thth-2.4.0 → thth-2.7.0}/thth/api_diagnostic.py +0 -0
  247. {thth-2.4.0 → thth-2.7.0}/thth/appenv.py +0 -0
  248. {thth-2.4.0 → thth-2.7.0}/thth/approval.py +0 -0
  249. {thth-2.4.0 → thth-2.7.0}/thth/ask.py +0 -0
  250. {thth-2.4.0 → thth-2.7.0}/thth/ask_cli.py +0 -0
  251. {thth-2.4.0 → thth-2.7.0}/thth/bundle.py +0 -0
  252. {thth-2.4.0 → thth-2.7.0}/thth/core.py +0 -0
  253. {thth-2.4.0 → thth-2.7.0}/thth/doctor.py +0 -0
  254. {thth-2.4.0 → thth-2.7.0}/thth/engagements.py +0 -0
  255. {thth-2.4.0 → thth-2.7.0}/thth/forms.py +0 -0
  256. {thth-2.4.0 → thth-2.7.0}/thth/healthcheck.py +0 -0
  257. {thth-2.4.0 → thth-2.7.0}/thth/httpsafe.py +0 -0
  258. {thth-2.4.0 → thth-2.7.0}/thth/incident.py +0 -0
  259. {thth-2.4.0 → thth-2.7.0}/thth/inflight.py +0 -0
  260. {thth-2.4.0 → thth-2.7.0}/thth/jst.py +0 -0
  261. {thth-2.4.0 → thth-2.7.0}/thth/lint.py +0 -0
  262. {thth-2.4.0 → thth-2.7.0}/thth/lock.py +0 -0
  263. {thth-2.4.0 → thth-2.7.0}/thth/maintain.py +0 -0
  264. {thth-2.4.0 → thth-2.7.0}/thth/oauth.py +0 -0
  265. {thth-2.4.0 → thth-2.7.0}/thth/postid.py +0 -0
  266. {thth-2.4.0 → thth-2.7.0}/thth/queuefile.py +0 -0
  267. {thth-2.4.0 → thth-2.7.0}/thth/redact.py +0 -0
  268. {thth-2.4.0 → thth-2.7.0}/thth/replies.py +0 -0
  269. {thth-2.4.0 → thth-2.7.0}/thth/report.py +0 -0
  270. {thth-2.4.0 → thth-2.7.0}/thth/retract_cli.py +0 -0
  271. {thth-2.4.0 → thth-2.7.0}/thth/runs.py +0 -0
  272. {thth-2.4.0 → thth-2.7.0}/thth/scopes.py +0 -0
  273. {thth-2.4.0 → thth-2.7.0}/thth/secrets_fs.py +0 -0
  274. {thth-2.4.0 → thth-2.7.0}/thth/select.py +0 -0
  275. {thth-2.4.0 → thth-2.7.0}/thth/sent.py +0 -0
  276. {thth-2.4.0 → thth-2.7.0}/thth/systemd_gen.py +0 -0
  277. {thth-2.4.0 → thth-2.7.0}/thth/thread_read.py +0 -0
  278. {thth-2.4.0 → thth-2.7.0}/thth/threadrun.py +0 -0
  279. {thth-2.4.0 → thth-2.7.0}/thth/threads_read_cli.py +0 -0
  280. {thth-2.4.0 → thth-2.7.0}/thth/threadshape.py +0 -0
  281. {thth-2.4.0 → thth-2.7.0}/thth/threadthrow.py +0 -0
  282. {thth-2.4.0 → thth-2.7.0}/thth/topic_advice.py +0 -0
  283. {thth-2.4.0 → thth-2.7.0}/thth/topic_cli.py +0 -0
  284. {thth-2.4.0 → thth-2.7.0}/thth/topic_models.py +0 -0
  285. {thth-2.4.0 → thth-2.7.0}/thth/topic_store.py +0 -0
  286. {thth-2.4.0 → thth-2.7.0}/thth/topics.py +0 -0
  287. {thth-2.4.0 → thth-2.7.0}/thth/where_cli.py +0 -0
  288. {thth-2.4.0 → thth-2.7.0}/thth/who_cli.py +0 -0
  289. {thth-2.4.0 → thth-2.7.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.7.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.7.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.7.0 の実装 revision `f331e8c` の全件テストは **2743 件**(`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,27 @@ timer(systemd・`thth systemd` で生成)は10分ごとに実行し、投稿
59
59
 
60
60
  **トピックの棚**(`thth topics`): 観測者ごとに並ぶ・打ち消し `retract-note`・`history`。
61
61
 
62
+ ### v2.7.0 の変更
63
+
64
+ - 指標採集に720時間(30日)を追加し、対象窓を刻みから38日と計算。正常時は指標6回、返信は従来の5回です。返信の採集窓は独立して維持します。
65
+ - 新しい観測に、採取時刻以前48時間以内で最も新しい日次フォロワー数を文脈として添えます。`followers_count_at` と `staleness_hours` を含み、追加APIや過去行の後補完はありません。該当しない場合は `context_reason` に理由を残します。
66
+ - Threads・Bluesky・Mastodonの刻みを揃え、媒体にないviewsはnullのまま維持。[合成検証と限界](docs/検証_v31_C実装_2026-09-18.md)。実SNS APIでの30日後採集は未確認です。
67
+ - C3・C4は実装保留。[独立規約照合報告](docs/照合_MetaPlatformTerms_枝と語の観測_2026-09-18_Codex.md)の未確認事項とApp Reviewの結果を踏まえて別途判断します。
68
+
69
+ ### v2.6.0 の変更
70
+
71
+ - 既存台帳から刻み別の値、中央値の IQR・min・max、枝別の帰結、採集成否と鮮度、層別比較、採集時刻を上限とした枝の形を追加。既存キーは維持し `schema_version=1` のままです。
72
+ - `cannot_say_details` にコードと既存の説明文を併記。施策レポートには未成熟投稿が24時間に達する予定を添え、採集保証とは分けます。
73
+ - `handoff-report --since-last-read` で保存済みsnapshotとの差分。cursor保存は CLI の `--mark-read --by 名前` を明示したときだけ。MCP・HTTPは読むだけです。
74
+ - API呼出を増やさない分析の改良です。VM5accountの読取検証と限界は [検証記録](docs/検証_v31_A実装_2026-09-18.md) に記載。Bluesky/Mastodon の非空実台帳と実採用施策の検証は未確認です。
75
+
76
+ ### v2.5.0 の変更
77
+
78
+ - 読み取り専用のレポート 3 本: `analytics-report`(活動のスナップショット・`--compare-previous` で隣接期間の比較)・`handoff-report`(ローカル運用記録の引継ぎ)・`study-report`(施策の宣言と本人の観測の結合)。MCP に `analytics_report`・`operations_handoff`・`study_report`。数値は期間・母数・欠測・根拠を連れて歩き、因果や推奨は出しません。
79
+ - `serve-reports`: 専用環境向けの非公開レポート HTTP(Unix socket 既定・service credential・読むだけ)。**開発版**で、人の認証・TLS・一般提供は含みません。[限界](docs/非公開レポートHTTP_v1.md)。
80
+ - X の本人公開指標の純粋な変換関数(API・投稿・台帳には未接続)。
81
+ - 独立監査(P2 4・P3 7)とその直し: Unix socket 既定・要求全体の 10 秒 deadline・分離検査の走査を読取 dir に限定・期間比較の母集団から時刻不一致の返信を除外・git 無しでも import 可。
82
+
62
83
  ### v2.4.0 の変更
63
84
 
64
85
  - 利用者・管理者への停止/復旧メールと、元原稿の運用記録。設定・秘密は repo 外に保存します。導入時は [共通通知手順](docs/停止通知と運用記録.md) を実施します。
@@ -87,7 +108,7 @@ timer(systemd・`thth systemd` で生成)は10分ごとに実行し、投稿
87
108
  - **英語の文書**: [README.en.md](README.en.md)・[docs/usage.en.md](docs/usage.en.md)・[llms.txt](llms.txt)。
88
109
  - **skill**: `skills/thth/SKILL.md`(wheel にも入る)。
89
110
 
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`。
111
+ - **動くもの**(`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
112
  - **最初の本番投稿の記録**: 2026-09-09、@aoking に疎通確認を 1 本(`17916074118445631`)。
92
113
  - **未着手**: X・Facebook ページ・Instagram の各アダプタ。トピック検索の権限(tester には降りない)。泉のサーバ(v2-5)。
93
114
  - **権限の制約**: tester に降りる scope は 5 つ。削除はできない。
@@ -98,3 +119,12 @@ timer(systemd・`thth systemd` で生成)は10分ごとに実行し、投稿
98
119
  registry は「この PyPI の名前を名乗ってよいのは誰か」を、**配布物の README にこの 1 行があるか**で確かめる(設計 v2-4 §3・一次資料は quickstart・**L2**)。だから消さないこと——消すと登録(`mcp-publisher publish`)が通らなくなる。形(`server.json` と版の一致)は `tests/test_server_json.py` が見張る。
99
120
 
100
121
  mcp-name: io.github.aokings/thth
122
+
123
+ ## v3 基盤(2.5.0 から)
124
+
125
+ `study-report` は明示した施策の宣言と本人の投稿観測を結ぶ読み取り専用レポートです。採用者の本人確認や因果効果は主張しません。[使い方と契約](docs/施策レポート_v1.md)。
126
+
127
+ `analytics-report` はローカル台帳から期間・母数・欠測・根拠を揃えたスナップショットを返します。
128
+ [形式と使い方](docs/分析レポート_v1.md)。
129
+
130
+ `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.7.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.7.0 の実装 revision `f331e8c` の全件テストは **2743 件**(`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,27 @@ timer(systemd・`thth systemd` で生成)は10分ごとに実行し、投稿
35
35
 
36
36
  **トピックの棚**(`thth topics`): 観測者ごとに並ぶ・打ち消し `retract-note`・`history`。
37
37
 
38
+ ### v2.7.0 の変更
39
+
40
+ - 指標採集に720時間(30日)を追加し、対象窓を刻みから38日と計算。正常時は指標6回、返信は従来の5回です。返信の採集窓は独立して維持します。
41
+ - 新しい観測に、採取時刻以前48時間以内で最も新しい日次フォロワー数を文脈として添えます。`followers_count_at` と `staleness_hours` を含み、追加APIや過去行の後補完はありません。該当しない場合は `context_reason` に理由を残します。
42
+ - Threads・Bluesky・Mastodonの刻みを揃え、媒体にないviewsはnullのまま維持。[合成検証と限界](docs/検証_v31_C実装_2026-09-18.md)。実SNS APIでの30日後採集は未確認です。
43
+ - C3・C4は実装保留。[独立規約照合報告](docs/照合_MetaPlatformTerms_枝と語の観測_2026-09-18_Codex.md)の未確認事項とApp Reviewの結果を踏まえて別途判断します。
44
+
45
+ ### v2.6.0 の変更
46
+
47
+ - 既存台帳から刻み別の値、中央値の IQR・min・max、枝別の帰結、採集成否と鮮度、層別比較、採集時刻を上限とした枝の形を追加。既存キーは維持し `schema_version=1` のままです。
48
+ - `cannot_say_details` にコードと既存の説明文を併記。施策レポートには未成熟投稿が24時間に達する予定を添え、採集保証とは分けます。
49
+ - `handoff-report --since-last-read` で保存済みsnapshotとの差分。cursor保存は CLI の `--mark-read --by 名前` を明示したときだけ。MCP・HTTPは読むだけです。
50
+ - API呼出を増やさない分析の改良です。VM5accountの読取検証と限界は [検証記録](docs/検証_v31_A実装_2026-09-18.md) に記載。Bluesky/Mastodon の非空実台帳と実採用施策の検証は未確認です。
51
+
52
+ ### v2.5.0 の変更
53
+
54
+ - 読み取り専用のレポート 3 本: `analytics-report`(活動のスナップショット・`--compare-previous` で隣接期間の比較)・`handoff-report`(ローカル運用記録の引継ぎ)・`study-report`(施策の宣言と本人の観測の結合)。MCP に `analytics_report`・`operations_handoff`・`study_report`。数値は期間・母数・欠測・根拠を連れて歩き、因果や推奨は出しません。
55
+ - `serve-reports`: 専用環境向けの非公開レポート HTTP(Unix socket 既定・service credential・読むだけ)。**開発版**で、人の認証・TLS・一般提供は含みません。[限界](docs/非公開レポートHTTP_v1.md)。
56
+ - X の本人公開指標の純粋な変換関数(API・投稿・台帳には未接続)。
57
+ - 独立監査(P2 4・P3 7)とその直し: Unix socket 既定・要求全体の 10 秒 deadline・分離検査の走査を読取 dir に限定・期間比較の母集団から時刻不一致の返信を除外・git 無しでも import 可。
58
+
38
59
  ### v2.4.0 の変更
39
60
 
40
61
  - 利用者・管理者への停止/復旧メールと、元原稿の運用記録。設定・秘密は repo 外に保存します。導入時は [共通通知手順](docs/停止通知と運用記録.md) を実施します。
@@ -63,7 +84,7 @@ timer(systemd・`thth systemd` で生成)は10分ごとに実行し、投稿
63
84
  - **英語の文書**: [README.en.md](README.en.md)・[docs/usage.en.md](docs/usage.en.md)・[llms.txt](llms.txt)。
64
85
  - **skill**: `skills/thth/SKILL.md`(wheel にも入る)。
65
86
 
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`。
87
+ - **動くもの**(`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
88
  - **最初の本番投稿の記録**: 2026-09-09、@aoking に疎通確認を 1 本(`17916074118445631`)。
68
89
  - **未着手**: X・Facebook ページ・Instagram の各アダプタ。トピック検索の権限(tester には降りない)。泉のサーバ(v2-5)。
69
90
  - **権限の制約**: tester に降りる scope は 5 つ。削除はできない。
@@ -74,3 +95,12 @@ timer(systemd・`thth systemd` で生成)は10分ごとに実行し、投稿
74
95
  registry は「この PyPI の名前を名乗ってよいのは誰か」を、**配布物の README にこの 1 行があるか**で確かめる(設計 v2-4 §3・一次資料は quickstart・**L2**)。だから消さないこと——消すと登録(`mcp-publisher publish`)が通らなくなる。形(`server.json` と版の一致)は `tests/test_server_json.py` が見張る。
75
96
 
76
97
  mcp-name: io.github.aokings/thth
98
+
99
+ ## v3 基盤(2.5.0 から)
100
+
101
+ `study-report` は明示した施策の宣言と本人の投稿観測を結ぶ読み取り専用レポートです。採用者の本人確認や因果効果は主張しません。[使い方と契約](docs/施策レポート_v1.md)。
102
+
103
+ `analytics-report` はローカル台帳から期間・母数・欠測・根拠を揃えたスナップショットを返します。
104
+ [形式と使い方](docs/分析レポート_v1.md)。
105
+
106
+ `handoff-report` / MCP `operations_handoff` はローカル運用状態・通知未処理を鮮度の制約付きで返します。[形式と使い方](docs/運用引継ぎレポート_v1.md)。
@@ -139,6 +139,36 @@ 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
+ "since_last_read": {"type": "boolean"}}},
148
+ },
149
+ {
150
+ "name": "study_report",
151
+ "description": "ローカル施策JSONの未検証の採用宣言と本人の観測を結ぶ。proposedは集計せず、adoptedも因果効果や投稿承認を証明しない。読むだけ",
152
+ "inputSchema": {
153
+ "type": "object", "required": ["file"],
154
+ "properties": {"file": {"type": "string"}, "min_n": {"type": "integer"}},
155
+ },
156
+ },
157
+ {
158
+ "name": "analytics_report",
159
+ "description": "自分の活動のスナップショットを期間・母数・欠測・根拠つきで返す。ローカル台帳を読むだけ。compare_previous=trueで前期間比較。推奨は含まない",
160
+ "inputSchema": {
161
+ "type": "object",
162
+ "properties": {
163
+ "by": {"type": "string", "enum": ["kind", "hour_band", "topic"]},
164
+ "compare_previous": {"type": "boolean", "description": "直前の同じ日数との比較(既定false)"},
165
+ "account": {"type": "string"},
166
+ "project": {"type": "string"},
167
+ "window_days": {"type": "integer", "description": "直近何日(既定7)"},
168
+ "min_n": {"type": "integer", "description": "中央値の最小母数(既定5)"},
169
+ },
170
+ },
171
+ },
142
172
  {
143
173
  # **名前と説明文がそのまま売り文句**(設計「自分の泉」§2.2)。
144
174
  # 実装が 1 語でも足したら設計書でなく実装を戻す(§2 の頭書きそのまま)。
@@ -369,6 +399,11 @@ def validate_arguments(name: str, arguments) -> dict:
369
399
  raise ToolInputError(
370
400
  f"{name}: arguments は object で渡してください"
371
401
  f"(受け取った: {type(arguments).__name__})")
402
+ if name == "analytics_report" and "by" in arguments:
403
+ if arguments["by"] not in ("kind", "hour_band", "topic") or arguments.get("compare_previous") is not True:
404
+ raise ToolInputError("analytics_report: by は compare_previous=true と kind/hour_band/topic が必要です")
405
+ if name == "operations_handoff" and "since_last_read" in arguments and type(arguments["since_last_read"]) is not bool:
406
+ raise ToolInputError("operations_handoff: since_last_read は boolean です")
372
407
  props = schema.get("properties") or {}
373
408
  for key in schema.get("required") or []:
374
409
  if arguments.get(key) is None:
@@ -412,14 +447,14 @@ def validate_arguments(name: str, arguments) -> dict:
412
447
  f"CLI の旗と区別できないので受け取りません")
413
448
  if key == "file":
414
449
  check_file_argument(name, value)
415
- if name in ("after_you_posted", "where_to_appear", "who_is_this") \
450
+ if name in ("operations_handoff", "analytics_report", "after_you_posted", "where_to_appear", "who_is_this") \
416
451
  and not arguments.get("account") \
417
452
  and not arguments.get("project"):
418
453
  # **`account` か `project` のどちらか必須**(`required` は AND の意味しか
419
454
  # 持てないので、ここで OR を見る・T2-3 発注書・T3-3 も同じ形)。
420
455
  raise ToolInputError(
421
456
  f"{name}: account か project のどちらかが要ります")
422
- if name == "after_you_posted" and arguments.get("account") \
457
+ if name in ("operations_handoff", "analytics_report", "after_you_posted") and arguments.get("account") \
423
458
  and arguments.get("project"):
424
459
  raise ToolInputError(
425
460
  f"{name}: account と project は同時に指定できません")
@@ -503,6 +538,42 @@ def call_tool(name: str, arguments: dict | None) -> dict:
503
538
  args.append("--json")
504
539
  proc = run_cli(args)
505
540
  text = proc.stdout
541
+ elif name == "operations_handoff":
542
+ args = ["handoff-report"]
543
+ if "mark_read" in arguments or "by" in arguments:
544
+ raise ValueError("operations_handoff is read-only")
545
+ if arguments.get("since_last_read"):
546
+ args.append("--since-last-read")
547
+ if arguments.get("project"):
548
+ args += ["--project", arguments["project"]]
549
+ else:
550
+ args.append(arguments["account"])
551
+ args.append("--json")
552
+ proc = run_cli(args)
553
+ text = proc.stdout
554
+ elif name == "study_report":
555
+ args = ["study-report", arguments["file"]]
556
+ if arguments.get("min_n") is not None:
557
+ args += ["--min-n", str(arguments["min_n"])]
558
+ args.append("--json")
559
+ proc = run_cli(args)
560
+ text = proc.stdout
561
+ elif name == "analytics_report":
562
+ args = ["analytics-report"]
563
+ if arguments.get("project"):
564
+ args += ["--project", arguments["project"]]
565
+ else:
566
+ args.append(arguments["account"])
567
+ for key in ("window_days", "min_n"):
568
+ if arguments.get(key) is not None:
569
+ args += ["--" + key.replace("_", "-"), str(arguments[key])]
570
+ if arguments.get("compare_previous"):
571
+ args.append("--compare-previous")
572
+ if arguments.get("by"):
573
+ args.extend(["--by", arguments["by"]])
574
+ args.append("--json")
575
+ proc = run_cli(args)
576
+ text = proc.stdout
506
577
  elif name == "after_you_posted":
507
578
  # **`before_you_post` と同じ型**(発注 T0-2): CLI を `--json` で呼ぶだけ。
508
579
  args = ["after"]
@@ -579,7 +650,7 @@ def call_tool(name: str, arguments: dict | None) -> dict:
579
650
  return {"content": [{"type": "text", "text": f"unknown tool: {name}"}], "isError": True}
580
651
 
581
652
  if name.startswith("thth_topic_") or name in (
582
- "before_you_post", "after_you_posted", "thread_read", "where_to_appear",
653
+ "before_you_post", "after_you_posted", "analytics_report", "operations_handoff", "study_report", "thread_read", "where_to_appear",
583
654
  "who_is_this"):
584
655
  # **新しい道具は exit 1 も isError**(設計 §7)。lint の exit 1(検査結果)
585
656
  # とは意味が違う——こちらは 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,45 @@
1
+ import datetime as dt
2
+ import json
3
+ from pathlib import Path
4
+ from thth import collection_context, jst
5
+
6
+ NOW=jst.parse('2026-10-01T01:00:00+09:00')
7
+
8
+ def daily(value,age,**extra):
9
+ return {'account':'a','date':'ignored','collected_at':jst.iso(NOW-dt.timedelta(hours=age)),
10
+ 'metrics':{'followers_count':value},**extra}
11
+
12
+ def test_recent_daily_across_month_and_date_ignored(tmp_path):
13
+ (tmp_path/'a-2026-09.ndjson').write_text(json.dumps(daily(120,2,date='2026-09-29')))
14
+ value=collection_context.followers('a',tmp_path,NOW)
15
+ assert value=={'followers_count':120,'followers_count_at':'2026-09-30T23:00:00+09:00',
16
+ 'staleness_hours':2.0,'source':'insights_account'}
17
+ (tmp_path/'a-2026-10.ndjson').write_text(json.dumps(daily(121,1.234)))
18
+ value=collection_context.followers('a',tmp_path,NOW)
19
+ assert value['followers_count']==121 and value['staleness_hours']==1.23
20
+
21
+ def test_recent_daily_inclusive_48h_and_no_old_fallback(tmp_path):
22
+ p=tmp_path/'a-2026-09.ndjson'
23
+ p.write_text(json.dumps(daily(0,48)))
24
+ assert collection_context.followers('a',tmp_path,NOW)['followers_count']==0
25
+ p.write_text(json.dumps(daily(2,49)))
26
+ assert collection_context.followers('a',tmp_path,NOW) is None
27
+ p.write_text('\n'.join(map(json.dumps,[daily(2,3),daily(True,1)])))
28
+ assert collection_context.followers('a',tmp_path,NOW) is None
29
+
30
+ def test_recent_daily_collect_reason_and_no_retrofit(tmp_path,isolated_account_factory,monkeypatch):
31
+ from tests.test_v31_collection import setup_route,Adapter,POSTED,daily_path,daily_row
32
+ from thth import collect,accounts
33
+ a=setup_route(tmp_path,isolated_account_factory,monkeypatch,'sent')
34
+ now=POSTED+dt.timedelta(hours=1)
35
+ p=daily_path(a)
36
+ p.write_text(json.dumps(daily_row(a,9,collected_at=jst.iso(now-dt.timedelta(hours=49)))))
37
+ collect.collect_once(a['name'],adapter=Adapter(),now=now,log=lambda _:None)
38
+ out=Path(accounts.data_dirs(a,a['name'])['insights_posts'])/'P.ndjson'
39
+ before=out.read_bytes()
40
+ assert json.loads(before)['context'] is None
41
+ assert json.loads(before)['context_reason']=='no_recent_daily_record'
42
+ p.write_text(json.dumps(daily_row(a,0,collected_at=jst.iso(now))))
43
+ collect.collect_once(a['name'],adapter=Adapter(),now=POSTED+dt.timedelta(hours=6),log=lambda _:None)
44
+ assert out.read_bytes().startswith(before)
45
+ assert json.loads(out.read_text().splitlines()[-1])['context']['followers_count']==0
@@ -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, "iqr": None, "min": None, "max": None, "spread_reason": "below_min_n"}
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"] == []