thth 2.5.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.5.0 → thth-2.7.0}/PKG-INFO +17 -3
  2. {thth-2.5.0 → thth-2.7.0}/README.md +16 -2
  3. {thth-2.5.0 → thth-2.7.0}/mcp/server.py +14 -1
  4. thth-2.7.0/tests/test_27_acceptance_fixes.py +45 -0
  5. {thth-2.5.0 → thth-2.7.0}/tests/test_analytics_comparison.py +1 -1
  6. {thth-2.5.0 → thth-2.7.0}/tests/test_analytics_report.py +22 -3
  7. {thth-2.5.0 → thth-2.7.0}/tests/test_collect_sent.py +2 -1
  8. {thth-2.5.0 → thth-2.7.0}/tests/test_study_report.py +2 -1
  9. thth-2.7.0/tests/test_v31_analysis.py +219 -0
  10. thth-2.7.0/tests/test_v31_collection.py +181 -0
  11. thth-2.7.0/thth/VERSION +1 -0
  12. {thth-2.5.0 → thth-2.7.0}/thth/analytics_comparison.py +97 -11
  13. {thth-2.5.0 → thth-2.7.0}/thth/analytics_report.py +41 -4
  14. thth-2.7.0/thth/analytics_shapes.py +51 -0
  15. thth-2.7.0/thth/analytics_threads.py +95 -0
  16. {thth-2.5.0 → thth-2.7.0}/thth/cli.py +4 -0
  17. {thth-2.5.0 → thth-2.7.0}/thth/collect.py +21 -6
  18. thth-2.7.0/thth/collection_context.py +58 -0
  19. thth-2.7.0/thth/collection_status.py +59 -0
  20. thth-2.7.0/thth/handoff_cursor.py +168 -0
  21. {thth-2.5.0 → thth-2.7.0}/thth/operations_handoff.py +39 -5
  22. thth-2.7.0/thth/report_details.py +51 -0
  23. {thth-2.5.0 → thth-2.7.0}/thth/report_service.py +7 -3
  24. {thth-2.5.0 → thth-2.7.0}/thth/study_report.py +18 -1
  25. thth-2.5.0/thth/VERSION +0 -1
  26. {thth-2.5.0 → thth-2.7.0}/.gitignore +0 -0
  27. {thth-2.5.0 → thth-2.7.0}/LICENSE +0 -0
  28. {thth-2.5.0 → thth-2.7.0}/accounts.example/bluesky.json +0 -0
  29. {thth-2.5.0 → thth-2.7.0}/accounts.example/mastodon.json +0 -0
  30. {thth-2.5.0 → thth-2.7.0}/accounts.example/threads.json +0 -0
  31. {thth-2.5.0 → thth-2.7.0}/bin/thth +0 -0
  32. {thth-2.5.0 → thth-2.7.0}/bin/thth-remote +0 -0
  33. {thth-2.5.0 → thth-2.7.0}/bin/thth-run +0 -0
  34. {thth-2.5.0 → thth-2.7.0}/pyproject.toml +0 -0
  35. {thth-2.5.0 → thth-2.7.0}/skills/thth/SKILL.md +0 -0
  36. {thth-2.5.0 → thth-2.7.0}/systemd/thth@.service +0 -0
  37. {thth-2.5.0 → thth-2.7.0}/tests/__init__.py +0 -0
  38. {thth-2.5.0 → thth-2.7.0}/tests/conftest.py +0 -0
  39. {thth-2.5.0 → thth-2.7.0}/tests/fixtures/build_injection_eval.py +0 -0
  40. {thth-2.5.0 → thth-2.7.0}/tests/fixtures/callback/worker_responses.json +0 -0
  41. {thth-2.5.0 → thth-2.7.0}/tests/fixtures/injection_eval.json +0 -0
  42. {thth-2.5.0 → thth-2.7.0}/tests/fixtures/umami-bile.md +0 -0
  43. {thth-2.5.0 → thth-2.7.0}/tests/helpers/__init__.py +0 -0
  44. {thth-2.5.0 → thth-2.7.0}/tests/helpers/fake_oauth_server.py +0 -0
  45. {thth-2.5.0 → thth-2.7.0}/tests/helpers/fake_push_adapter.py +0 -0
  46. {thth-2.5.0 → thth-2.7.0}/tests/helpers/fake_redirect_server.py +0 -0
  47. {thth-2.5.0 → thth-2.7.0}/tests/helpers/fake_search_adapter.py +0 -0
  48. {thth-2.5.0 → thth-2.7.0}/tests/helpers/fake_threads_reader.py +0 -0
  49. {thth-2.5.0 → thth-2.7.0}/tests/helpers/fake_threads_writes.py +0 -0
  50. {thth-2.5.0 → thth-2.7.0}/tests/helpers/hold_lock.py +0 -0
  51. {thth-2.5.0 → thth-2.7.0}/tests/helpers/hold_repo_lock.py +0 -0
  52. {thth-2.5.0 → thth-2.7.0}/tests/helpers/worker_probe.mjs +0 -0
  53. {thth-2.5.0 → thth-2.7.0}/tests/test_account_name_check.py +0 -0
  54. {thth-2.5.0 → thth-2.7.0}/tests/test_account_report.py +0 -0
  55. {thth-2.5.0 → thth-2.7.0}/tests/test_accounts_dir.py +0 -0
  56. {thth-2.5.0 → thth-2.7.0}/tests/test_adapter_boundary.py +0 -0
  57. {thth-2.5.0 → thth-2.7.0}/tests/test_after_cli.py +0 -0
  58. {thth-2.5.0 → thth-2.7.0}/tests/test_api_diagnostic.py +0 -0
  59. {thth-2.5.0 → thth-2.7.0}/tests/test_api_error_body.py +0 -0
  60. {thth-2.5.0 → thth-2.7.0}/tests/test_app_env_cli.py +0 -0
  61. {thth-2.5.0 → thth-2.7.0}/tests/test_approval.py +0 -0
  62. {thth-2.5.0 → thth-2.7.0}/tests/test_approve_cmd.py +0 -0
  63. {thth-2.5.0 → thth-2.7.0}/tests/test_ask.py +0 -0
  64. {thth-2.5.0 → thth-2.7.0}/tests/test_atlas_fifth_review.py +0 -0
  65. {thth-2.5.0 → thth-2.7.0}/tests/test_atlas_fourth_review.py +0 -0
  66. {thth-2.5.0 → thth-2.7.0}/tests/test_atlas_review.py +0 -0
  67. {thth-2.5.0 → thth-2.7.0}/tests/test_atlas_second_review.py +0 -0
  68. {thth-2.5.0 → thth-2.7.0}/tests/test_atlas_third_review.py +0 -0
  69. {thth-2.5.0 → thth-2.7.0}/tests/test_audit1_fixes.py +0 -0
  70. {thth-2.5.0 → thth-2.7.0}/tests/test_audit2_fixes.py +0 -0
  71. {thth-2.5.0 → thth-2.7.0}/tests/test_audit_b_counterexamples.py +0 -0
  72. {thth-2.5.0 → thth-2.7.0}/tests/test_audit_b_m1_semantics.py +0 -0
  73. {thth-2.5.0 → thth-2.7.0}/tests/test_audit_b_n1_depth.py +0 -0
  74. {thth-2.5.0 → thth-2.7.0}/tests/test_audit_b_r1_closure.py +0 -0
  75. {thth-2.5.0 → thth-2.7.0}/tests/test_audit_v201.py +0 -0
  76. {thth-2.5.0 → thth-2.7.0}/tests/test_audit_v23_details.py +0 -0
  77. {thth-2.5.0 → thth-2.7.0}/tests/test_auth.py +0 -0
  78. {thth-2.5.0 → thth-2.7.0}/tests/test_auth_identity_required.py +0 -0
  79. {thth-2.5.0 → thth-2.7.0}/tests/test_behind_remote.py +0 -0
  80. {thth-2.5.0 → thth-2.7.0}/tests/test_bluesky_adapter.py +0 -0
  81. {thth-2.5.0 → thth-2.7.0}/tests/test_bluesky_secret_scrub.py +0 -0
  82. {thth-2.5.0 → thth-2.7.0}/tests/test_board_needs_review.py +0 -0
  83. {thth-2.5.0 → thth-2.7.0}/tests/test_board_running.py +0 -0
  84. {thth-2.5.0 → thth-2.7.0}/tests/test_bundle.py +0 -0
  85. {thth-2.5.0 → thth-2.7.0}/tests/test_charcount.py +0 -0
  86. {thth-2.5.0 → thth-2.7.0}/tests/test_cli_entrypoint.py +0 -0
  87. {thth-2.5.0 → thth-2.7.0}/tests/test_cli_help.py +0 -0
  88. {thth-2.5.0 → thth-2.7.0}/tests/test_cli_vm_path.py +0 -0
  89. {thth-2.5.0 → thth-2.7.0}/tests/test_collect.py +0 -0
  90. {thth-2.5.0 → thth-2.7.0}/tests/test_collect_engagements.py +0 -0
  91. {thth-2.5.0 → thth-2.7.0}/tests/test_collect_runs.py +0 -0
  92. {thth-2.5.0 → thth-2.7.0}/tests/test_comparability.py +0 -0
  93. {thth-2.5.0 → thth-2.7.0}/tests/test_control_chars_refused.py +0 -0
  94. {thth-2.5.0 → thth-2.7.0}/tests/test_conversation_paging.py +0 -0
  95. {thth-2.5.0 → thth-2.7.0}/tests/test_count_for.py +0 -0
  96. {thth-2.5.0 → thth-2.7.0}/tests/test_docs_links.py +0 -0
  97. {thth-2.5.0 → thth-2.7.0}/tests/test_doctor.py +0 -0
  98. {thth-2.5.0 → thth-2.7.0}/tests/test_doctor_all_permissions.py +0 -0
  99. {thth-2.5.0 → thth-2.7.0}/tests/test_doctor_next_step.py +0 -0
  100. {thth-2.5.0 → thth-2.7.0}/tests/test_duplicate_front_matter_keys.py +0 -0
  101. {thth-2.5.0 → thth-2.7.0}/tests/test_editorial_audit.py +0 -0
  102. {thth-2.5.0 → thth-2.7.0}/tests/test_editorial_n3_n4.py +0 -0
  103. {thth-2.5.0 → thth-2.7.0}/tests/test_engagements.py +0 -0
  104. {thth-2.5.0 → thth-2.7.0}/tests/test_external_acceptance.py +0 -0
  105. {thth-2.5.0 → thth-2.7.0}/tests/test_fake_api.py +0 -0
  106. {thth-2.5.0 → thth-2.7.0}/tests/test_form_spec.py +0 -0
  107. {thth-2.5.0 → thth-2.7.0}/tests/test_form_spec_cli.py +0 -0
  108. {thth-2.5.0 → thth-2.7.0}/tests/test_forms.py +0 -0
  109. {thth-2.5.0 → thth-2.7.0}/tests/test_fresh_install.py +0 -0
  110. {thth-2.5.0 → thth-2.7.0}/tests/test_front_matter_injection.py +0 -0
  111. {thth-2.5.0 → thth-2.7.0}/tests/test_healthcheck_notifications.py +0 -0
  112. {thth-2.5.0 → thth-2.7.0}/tests/test_hypotheses.py +0 -0
  113. {thth-2.5.0 → thth-2.7.0}/tests/test_hypothesis_boundaries.py +0 -0
  114. {thth-2.5.0 → thth-2.7.0}/tests/test_hypothesis_units.py +0 -0
  115. {thth-2.5.0 → thth-2.7.0}/tests/test_impact_cli.py +0 -0
  116. {thth-2.5.0 → thth-2.7.0}/tests/test_inbox_granted_scopes.py +0 -0
  117. {thth-2.5.0 → thth-2.7.0}/tests/test_incident_notifications.py +0 -0
  118. {thth-2.5.0 → thth-2.7.0}/tests/test_independent_thread_review.py +0 -0
  119. {thth-2.5.0 → thth-2.7.0}/tests/test_independent_topic_review.py +0 -0
  120. {thth-2.5.0 → thth-2.7.0}/tests/test_independent_topic_review_r2.py +0 -0
  121. {thth-2.5.0 → thth-2.7.0}/tests/test_inflight.py +0 -0
  122. {thth-2.5.0 → thth-2.7.0}/tests/test_jst.py +0 -0
  123. {thth-2.5.0 → thth-2.7.0}/tests/test_lint.py +0 -0
  124. {thth-2.5.0 → thth-2.7.0}/tests/test_list_projections.py +0 -0
  125. {thth-2.5.0 → thth-2.7.0}/tests/test_llm_trial.py +0 -0
  126. {thth-2.5.0 → thth-2.7.0}/tests/test_llm_trial_engage.py +0 -0
  127. {thth-2.5.0 → thth-2.7.0}/tests/test_lock.py +0 -0
  128. {thth-2.5.0 → thth-2.7.0}/tests/test_m2_independent.py +0 -0
  129. {thth-2.5.0 → thth-2.7.0}/tests/test_maintain.py +0 -0
  130. {thth-2.5.0 → thth-2.7.0}/tests/test_mastodon_adapter.py +0 -0
  131. {thth-2.5.0 → thth-2.7.0}/tests/test_mastodon_visibility.py +0 -0
  132. {thth-2.5.0 → thth-2.7.0}/tests/test_max_per_run.py +0 -0
  133. {thth-2.5.0 → thth-2.7.0}/tests/test_mcp.py +0 -0
  134. {thth-2.5.0 → thth-2.7.0}/tests/test_mcp_robustness.py +0 -0
  135. {thth-2.5.0 → thth-2.7.0}/tests/test_measured.py +0 -0
  136. {thth-2.5.0 → thth-2.7.0}/tests/test_media_accounts.py +0 -0
  137. {thth-2.5.0 → thth-2.7.0}/tests/test_media_next_steps.py +0 -0
  138. {thth-2.5.0 → thth-2.7.0}/tests/test_media_roundtrip.py +0 -0
  139. {thth-2.5.0 → thth-2.7.0}/tests/test_mismatch_diagnostics.py +0 -0
  140. {thth-2.5.0 → thth-2.7.0}/tests/test_n5_reply_null_element.py +0 -0
  141. {thth-2.5.0 → thth-2.7.0}/tests/test_n6_replies_incomplete_ledger.py +0 -0
  142. {thth-2.5.0 → thth-2.7.0}/tests/test_n7_doctor_error_body.py +0 -0
  143. {thth-2.5.0 → thth-2.7.0}/tests/test_no_limits.py +0 -0
  144. {thth-2.5.0 → thth-2.7.0}/tests/test_observation_provenance.py +0 -0
  145. {thth-2.5.0 → thth-2.7.0}/tests/test_operations_handoff.py +0 -0
  146. {thth-2.5.0 → thth-2.7.0}/tests/test_packaging.py +0 -0
  147. {thth-2.5.0 → thth-2.7.0}/tests/test_preview.py +0 -0
  148. {thth-2.5.0 → thth-2.7.0}/tests/test_production_mode.py +0 -0
  149. {thth-2.5.0 → thth-2.7.0}/tests/test_pull_cli.py +0 -0
  150. {thth-2.5.0 → thth-2.7.0}/tests/test_queue.py +0 -0
  151. {thth-2.5.0 → thth-2.7.0}/tests/test_quiet_hours.py +0 -0
  152. {thth-2.5.0 → thth-2.7.0}/tests/test_r2_r4_r5.py +0 -0
  153. {thth-2.5.0 → thth-2.7.0}/tests/test_read_cli_bare_runtimeerror.py +0 -0
  154. {thth-2.5.0 → thth-2.7.0}/tests/test_reason_adoption.py +0 -0
  155. {thth-2.5.0 → thth-2.7.0}/tests/test_redact.py +0 -0
  156. {thth-2.5.0 → thth-2.7.0}/tests/test_redirect_token_leak.py +0 -0
  157. {thth-2.5.0 → thth-2.7.0}/tests/test_refresh.py +0 -0
  158. {thth-2.5.0 → thth-2.7.0}/tests/test_refresh_destination.py +0 -0
  159. {thth-2.5.0 → thth-2.7.0}/tests/test_rejudge_new.py +0 -0
  160. {thth-2.5.0 → thth-2.7.0}/tests/test_rejudge_r2_r3.py +0 -0
  161. {thth-2.5.0 → thth-2.7.0}/tests/test_replies.py +0 -0
  162. {thth-2.5.0 → thth-2.7.0}/tests/test_replies_refresh.py +0 -0
  163. {thth-2.5.0 → thth-2.7.0}/tests/test_repo_lock.py +0 -0
  164. {thth-2.5.0 → thth-2.7.0}/tests/test_repo_sync.py +0 -0
  165. {thth-2.5.0 → thth-2.7.0}/tests/test_report_checkout_import.py +0 -0
  166. {thth-2.5.0 → thth-2.7.0}/tests/test_report_http.py +0 -0
  167. {thth-2.5.0 → thth-2.7.0}/tests/test_report_isolation.py +0 -0
  168. {thth-2.5.0 → thth-2.7.0}/tests/test_report_service.py +0 -0
  169. {thth-2.5.0 → thth-2.7.0}/tests/test_reverse_audit.py +0 -0
  170. {thth-2.5.0 → thth-2.7.0}/tests/test_review_case_id_cluster_m2.py +0 -0
  171. {thth-2.5.0 → thth-2.7.0}/tests/test_review_case_id_cluster_r2.py +0 -0
  172. {thth-2.5.0 → thth-2.7.0}/tests/test_review_cli.py +0 -0
  173. {thth-2.5.0 → thth-2.7.0}/tests/test_review_evidence_account.py +0 -0
  174. {thth-2.5.0 → thth-2.7.0}/tests/test_review_form_spec_and_case_id.py +0 -0
  175. {thth-2.5.0 → thth-2.7.0}/tests/test_review_provenance.py +0 -0
  176. {thth-2.5.0 → thth-2.7.0}/tests/test_review_records.py +0 -0
  177. {thth-2.5.0 → thth-2.7.0}/tests/test_revoke.py +0 -0
  178. {thth-2.5.0 → thth-2.7.0}/tests/test_roundtrip.py +0 -0
  179. {thth-2.5.0 → thth-2.7.0}/tests/test_run_missing_env.py +0 -0
  180. {thth-2.5.0 → thth-2.7.0}/tests/test_run_record_failclosed.py +0 -0
  181. {thth-2.5.0 → thth-2.7.0}/tests/test_runs_collected.py +0 -0
  182. {thth-2.5.0 → thth-2.7.0}/tests/test_runs_read_rows.py +0 -0
  183. {thth-2.5.0 → thth-2.7.0}/tests/test_schedule_multiple.py +0 -0
  184. {thth-2.5.0 → thth-2.7.0}/tests/test_schedule_view.py +0 -0
  185. {thth-2.5.0 → thth-2.7.0}/tests/test_scopes_source_requested.py +0 -0
  186. {thth-2.5.0 → thth-2.7.0}/tests/test_search_engage_targets.py +0 -0
  187. {thth-2.5.0 → thth-2.7.0}/tests/test_search_media_routes.py +0 -0
  188. {thth-2.5.0 → thth-2.7.0}/tests/test_secret_value_redact.py +0 -0
  189. {thth-2.5.0 → thth-2.7.0}/tests/test_secrets_grep.py +0 -0
  190. {thth-2.5.0 → thth-2.7.0}/tests/test_security_small_fixes.py +0 -0
  191. {thth-2.5.0 → thth-2.7.0}/tests/test_select.py +0 -0
  192. {thth-2.5.0 → thth-2.7.0}/tests/test_selfupdate.py +0 -0
  193. {thth-2.5.0 → thth-2.7.0}/tests/test_send.py +0 -0
  194. {thth-2.5.0 → thth-2.7.0}/tests/test_send_cli.py +0 -0
  195. {thth-2.5.0 → thth-2.7.0}/tests/test_send_control_chars.py +0 -0
  196. {thth-2.5.0 → thth-2.7.0}/tests/test_send_media.py +0 -0
  197. {thth-2.5.0 → thth-2.7.0}/tests/test_send_visibility.py +0 -0
  198. {thth-2.5.0 → thth-2.7.0}/tests/test_sent_integrity.py +0 -0
  199. {thth-2.5.0 → thth-2.7.0}/tests/test_server_json.py +0 -0
  200. {thth-2.5.0 → thth-2.7.0}/tests/test_site.py +0 -0
  201. {thth-2.5.0 → thth-2.7.0}/tests/test_skill_doc.py +0 -0
  202. {thth-2.5.0 → thth-2.7.0}/tests/test_systemd.py +0 -0
  203. {thth-2.5.0 → thth-2.7.0}/tests/test_thread_publish.py +0 -0
  204. {thth-2.5.0 → thth-2.7.0}/tests/test_thread_read.py +0 -0
  205. {thth-2.5.0 → thth-2.7.0}/tests/test_threadrun.py +0 -0
  206. {thth-2.5.0 → thth-2.7.0}/tests/test_threads_read_permissions.py +0 -0
  207. {thth-2.5.0 → thth-2.7.0}/tests/test_threadshape.py +0 -0
  208. {thth-2.5.0 → thth-2.7.0}/tests/test_thth_root.py +0 -0
  209. {thth-2.5.0 → thth-2.7.0}/tests/test_token_exists.py +0 -0
  210. {thth-2.5.0 → thth-2.7.0}/tests/test_token_scopes_record.py +0 -0
  211. {thth-2.5.0 → thth-2.7.0}/tests/test_token_set.py +0 -0
  212. {thth-2.5.0 → thth-2.7.0}/tests/test_topic.py +0 -0
  213. {thth-2.5.0 → thth-2.7.0}/tests/test_topic_advice.py +0 -0
  214. {thth-2.5.0 → thth-2.7.0}/tests/test_topic_cli.py +0 -0
  215. {thth-2.5.0 → thth-2.7.0}/tests/test_topic_models.py +0 -0
  216. {thth-2.5.0 → thth-2.7.0}/tests/test_topic_review_fixes.py +0 -0
  217. {thth-2.5.0 → thth-2.7.0}/tests/test_topic_shelf_observers.py +0 -0
  218. {thth-2.5.0 → thth-2.7.0}/tests/test_topic_store.py +0 -0
  219. {thth-2.5.0 → thth-2.7.0}/tests/test_topics_record_concurrent.py +0 -0
  220. {thth-2.5.0 → thth-2.7.0}/tests/test_topics_tool.py +0 -0
  221. {thth-2.5.0 → thth-2.7.0}/tests/test_trial_frictions.py +0 -0
  222. {thth-2.5.0 → thth-2.7.0}/tests/test_v21b_approved_writes.py +0 -0
  223. {thth-2.5.0 → thth-2.7.0}/tests/test_value_domains_shown.py +0 -0
  224. {thth-2.5.0 → thth-2.7.0}/tests/test_version.py +0 -0
  225. {thth-2.5.0 → thth-2.7.0}/tests/test_where_cli.py +0 -0
  226. {thth-2.5.0 → thth-2.7.0}/tests/test_who_cli.py +0 -0
  227. {thth-2.5.0 → thth-2.7.0}/tests/test_writeback_rebase.py +0 -0
  228. {thth-2.5.0 → thth-2.7.0}/tests/test_writeback_validate.py +0 -0
  229. {thth-2.5.0 → thth-2.7.0}/tests/test_x_metrics.py +0 -0
  230. {thth-2.5.0 → thth-2.7.0}/thth/__init__.py +0 -0
  231. {thth-2.5.0 → thth-2.7.0}/thth/__main__.py +0 -0
  232. {thth-2.5.0 → thth-2.7.0}/thth/account_cli.py +0 -0
  233. {thth-2.5.0 → thth-2.7.0}/thth/account_report.py +0 -0
  234. {thth-2.5.0 → thth-2.7.0}/thth/accounts.py +0 -0
  235. {thth-2.5.0 → thth-2.7.0}/thth/adapters/__init__.py +0 -0
  236. {thth-2.5.0 → thth-2.7.0}/thth/adapters/base.py +0 -0
  237. {thth-2.5.0 → thth-2.7.0}/thth/adapters/bluesky.py +0 -0
  238. {thth-2.5.0 → thth-2.7.0}/thth/adapters/mastodon.py +0 -0
  239. {thth-2.5.0 → thth-2.7.0}/thth/adapters/threads.py +0 -0
  240. {thth-2.5.0 → thth-2.7.0}/thth/after_cli.py +0 -0
  241. {thth-2.5.0 → thth-2.7.0}/thth/api_diagnostic.py +0 -0
  242. {thth-2.5.0 → thth-2.7.0}/thth/appenv.py +0 -0
  243. {thth-2.5.0 → thth-2.7.0}/thth/approval.py +0 -0
  244. {thth-2.5.0 → thth-2.7.0}/thth/ask.py +0 -0
  245. {thth-2.5.0 → thth-2.7.0}/thth/ask_cli.py +0 -0
  246. {thth-2.5.0 → thth-2.7.0}/thth/bundle.py +0 -0
  247. {thth-2.5.0 → thth-2.7.0}/thth/core.py +0 -0
  248. {thth-2.5.0 → thth-2.7.0}/thth/doctor.py +0 -0
  249. {thth-2.5.0 → thth-2.7.0}/thth/engagements.py +0 -0
  250. {thth-2.5.0 → thth-2.7.0}/thth/forms.py +0 -0
  251. {thth-2.5.0 → thth-2.7.0}/thth/healthcheck.py +0 -0
  252. {thth-2.5.0 → thth-2.7.0}/thth/httpsafe.py +0 -0
  253. {thth-2.5.0 → thth-2.7.0}/thth/incident.py +0 -0
  254. {thth-2.5.0 → thth-2.7.0}/thth/inflight.py +0 -0
  255. {thth-2.5.0 → thth-2.7.0}/thth/jst.py +0 -0
  256. {thth-2.5.0 → thth-2.7.0}/thth/lint.py +0 -0
  257. {thth-2.5.0 → thth-2.7.0}/thth/lock.py +0 -0
  258. {thth-2.5.0 → thth-2.7.0}/thth/maintain.py +0 -0
  259. {thth-2.5.0 → thth-2.7.0}/thth/measured.py +0 -0
  260. {thth-2.5.0 → thth-2.7.0}/thth/oauth.py +0 -0
  261. {thth-2.5.0 → thth-2.7.0}/thth/postid.py +0 -0
  262. {thth-2.5.0 → thth-2.7.0}/thth/queuefile.py +0 -0
  263. {thth-2.5.0 → thth-2.7.0}/thth/redact.py +0 -0
  264. {thth-2.5.0 → thth-2.7.0}/thth/replies.py +0 -0
  265. {thth-2.5.0 → thth-2.7.0}/thth/report.py +0 -0
  266. {thth-2.5.0 → thth-2.7.0}/thth/report_http.py +0 -0
  267. {thth-2.5.0 → thth-2.7.0}/thth/report_isolation.py +0 -0
  268. {thth-2.5.0 → thth-2.7.0}/thth/retract_cli.py +0 -0
  269. {thth-2.5.0 → thth-2.7.0}/thth/runs.py +0 -0
  270. {thth-2.5.0 → thth-2.7.0}/thth/scopes.py +0 -0
  271. {thth-2.5.0 → thth-2.7.0}/thth/secrets_fs.py +0 -0
  272. {thth-2.5.0 → thth-2.7.0}/thth/select.py +0 -0
  273. {thth-2.5.0 → thth-2.7.0}/thth/selfupdate.py +0 -0
  274. {thth-2.5.0 → thth-2.7.0}/thth/sent.py +0 -0
  275. {thth-2.5.0 → thth-2.7.0}/thth/systemd_gen.py +0 -0
  276. {thth-2.5.0 → thth-2.7.0}/thth/thread_read.py +0 -0
  277. {thth-2.5.0 → thth-2.7.0}/thth/threadrun.py +0 -0
  278. {thth-2.5.0 → thth-2.7.0}/thth/threads_read_cli.py +0 -0
  279. {thth-2.5.0 → thth-2.7.0}/thth/threadshape.py +0 -0
  280. {thth-2.5.0 → thth-2.7.0}/thth/threadthrow.py +0 -0
  281. {thth-2.5.0 → thth-2.7.0}/thth/topic_advice.py +0 -0
  282. {thth-2.5.0 → thth-2.7.0}/thth/topic_cli.py +0 -0
  283. {thth-2.5.0 → thth-2.7.0}/thth/topic_models.py +0 -0
  284. {thth-2.5.0 → thth-2.7.0}/thth/topic_store.py +0 -0
  285. {thth-2.5.0 → thth-2.7.0}/thth/topics.py +0 -0
  286. {thth-2.5.0 → thth-2.7.0}/thth/where_cli.py +0 -0
  287. {thth-2.5.0 → thth-2.7.0}/thth/who_cli.py +0 -0
  288. {thth-2.5.0 → thth-2.7.0}/thth/writeback.py +0 -0
  289. {thth-2.5.0 → thth-2.7.0}/thth/x_metrics.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: thth
3
- Version: 2.5.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.5.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.5.0 の実装 revision の全件テストは **2690 件**(`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,20 @@ 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
+
62
76
  ### v2.5.0 の変更
63
77
 
64
78
  - 読み取り専用のレポート 3 本: `analytics-report`(活動のスナップショット・`--compare-previous` で隣接期間の比較)・`handoff-report`(ローカル運用記録の引継ぎ)・`study-report`(施策の宣言と本人の観測の結合)。MCP に `analytics_report`・`operations_handoff`・`study_report`。数値は期間・母数・欠測・根拠を連れて歩き、因果や推奨は出しません。
@@ -9,7 +9,7 @@ watchtower(`~/Developer/watchtower`)の隣に同じ流儀で並べる。watc
9
9
 
10
10
  ## 版
11
11
 
12
- このソースの版は **2.5.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.5.0 の実装 revision の全件テストは **2690 件**(`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,20 @@ 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
+
38
52
  ### v2.5.0 の変更
39
53
 
40
54
  - 読み取り専用のレポート 3 本: `analytics-report`(活動のスナップショット・`--compare-previous` で隣接期間の比較)・`handoff-report`(ローカル運用記録の引継ぎ)・`study-report`(施策の宣言と本人の観測の結合)。MCP に `analytics_report`・`operations_handoff`・`study_report`。数値は期間・母数・欠測・根拠を連れて歩き、因果や推奨は出しません。
@@ -143,7 +143,8 @@ TOOLS = [
143
143
  "name": "operations_handoff",
144
144
  "description": "ローカル運用記録の状態・承認待ち・inflight・通知未処理を根拠と鮮度の制約付きで返す。同期・承認・再送はしない",
145
145
  "inputSchema": {"type": "object", "properties": {
146
- "account": {"type": "string"}, "project": {"type": "string"}}},
146
+ "account": {"type": "string"}, "project": {"type": "string"},
147
+ "since_last_read": {"type": "boolean"}}},
147
148
  },
148
149
  {
149
150
  "name": "study_report",
@@ -159,6 +160,7 @@ TOOLS = [
159
160
  "inputSchema": {
160
161
  "type": "object",
161
162
  "properties": {
163
+ "by": {"type": "string", "enum": ["kind", "hour_band", "topic"]},
162
164
  "compare_previous": {"type": "boolean", "description": "直前の同じ日数との比較(既定false)"},
163
165
  "account": {"type": "string"},
164
166
  "project": {"type": "string"},
@@ -397,6 +399,11 @@ def validate_arguments(name: str, arguments) -> dict:
397
399
  raise ToolInputError(
398
400
  f"{name}: arguments は object で渡してください"
399
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 です")
400
407
  props = schema.get("properties") or {}
401
408
  for key in schema.get("required") or []:
402
409
  if arguments.get(key) is None:
@@ -533,6 +540,10 @@ def call_tool(name: str, arguments: dict | None) -> dict:
533
540
  text = proc.stdout
534
541
  elif name == "operations_handoff":
535
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")
536
547
  if arguments.get("project"):
537
548
  args += ["--project", arguments["project"]]
538
549
  else:
@@ -558,6 +569,8 @@ def call_tool(name: str, arguments: dict | None) -> dict:
558
569
  args += ["--" + key.replace("_", "-"), str(arguments[key])]
559
570
  if arguments.get("compare_previous"):
560
571
  args.append("--compare-previous")
572
+ if arguments.get("by"):
573
+ args.extend(["--by", arguments["by"]])
561
574
  args.append("--json")
562
575
  proc = run_cli(args)
563
576
  text = proc.stdout
@@ -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
@@ -85,7 +85,7 @@ def test_invalid_metric_does_not_become_zero(isolated_account_factory, value):
85
85
  seed(a, "p", NOW - datetime.timedelta(days=3), value=value)
86
86
  p = report(a, min_n=1)["by_account"][a["name"]]["posts"]["current"]
87
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}
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
89
  json.dumps(p, allow_nan=False)
90
90
 
91
91
 
@@ -32,9 +32,19 @@ def test_boundaries_evidence_missing_and_no_writes(isolated_account_factory):
32
32
  node = result["by_account"][account["name"]]
33
33
  original = after_cli.answer(account["name"], now=NOW, window_days=7)
34
34
  for key in ("posts", "engagements", "comparable", "cannot_say"):
35
- assert node[key] == original[key]
35
+ def legacy_projection(actual, original):
36
+ if isinstance(original, dict):
37
+ return {k: legacy_projection(actual[k], v) for k, v in original.items()}
38
+ if isinstance(original, list):
39
+ assert len(actual) == len(original)
40
+ return [legacy_projection(a, b) for a, b in zip(actual, original)]
41
+ return actual
42
+ actual = node[key]
43
+ if key == "cannot_say":
44
+ actual = [x for x in actual if not x.startswith("collection_records_")]
45
+ assert legacy_projection(actual, original[key]) == original[key]
36
46
  assert {p["post_id"] for p in node["posts"]["by_post"]} == {"start", "end"}
37
- assert node["posts"]["views_24h"] == {"median": None, "n": 1}
47
+ assert node["posts"]["views_24h"] == {"median": None, "n": 1, "iqr": None, "min": None, "max": None, "spread_reason": "below_min_n"}
38
48
  end = next(p for p in node["posts"]["by_post"] if p["post_id"] == "end")
39
49
  assert end["views_24h"] is None and end["covered"] is False
40
50
  assert node["engagements"]["by_branch"][0]["post_id"] == "reply"
@@ -46,7 +56,16 @@ def test_boundaries_evidence_missing_and_no_writes(isolated_account_factory):
46
56
  assert result["generated_at"] == "2026-09-17T08:00:00+09:00"
47
57
  text = json.dumps(result)
48
58
  assert "PRIVATE BODY" not in text
49
- assert '"username"' not in text and '"text"' not in text
59
+ assert '"username"' not in text
60
+ def assert_text_only_in_details(value, details=False):
61
+ if isinstance(value, dict):
62
+ assert "text" not in value or details
63
+ for key, child in value.items():
64
+ assert_text_only_in_details(child, key == "cannot_say_details")
65
+ elif isinstance(value, list):
66
+ for child in value:
67
+ assert_text_only_in_details(child, details)
68
+ assert_text_only_in_details(result)
50
69
  markdown = analytics_report.render_markdown(result)
51
70
  recovered = "\n".join(line[4:] for line in markdown.splitlines() if line.startswith(" "))
52
71
  assert json.loads(recovered) == result
@@ -220,7 +220,8 @@ def test_d_従来の台帳は置き場も行の中身も変わらない(tmp_path
220
220
  assert set(rows[0]) == {
221
221
  "post_id", "file", "source", "account", "medium", "topic", "reply_to",
222
222
  "text_length", "has_link", "collected_at", "posted_at", "age_hours",
223
- "marks", "metrics"}, sorted(rows[0])
223
+ "marks", "metrics", "context", "context_reason"}, sorted(rows[0])
224
+ assert rows[0]["context"] is None
224
225
  assert rows[0]["source"] == "queue"
225
226
  assert rows[0]["file"] == "a.md"
226
227
  assert rows[0]["age_hours"] == 2.0
@@ -149,7 +149,8 @@ def test_broken_source_immature_and_sparse_remain_unknown(isolated_account_facto
149
149
  assert r["observations"]["changed"]["n_immature"] == 1
150
150
  assert r["observations"]["baseline"]["metrics"]["views"]["n_eligible"] == 1
151
151
  assert all(v["absolute_median_change"] is None for v in r["comparison"].values())
152
- assert len(r["cannot_say"]) == 2
152
+ assert len(r["cannot_say"]) == 3
153
+ assert r["cannot_say"][-1] == "forecast_assumes_collection_runs"
153
154
 
154
155
 
155
156
  def test_invalid_cli_is_error_without_input_disclosure(isolated_account_factory, capsys):
@@ -0,0 +1,219 @@
1
+ import datetime as dt
2
+ from thth import analytics_comparison as c, jst
3
+
4
+ NOW = jst.parse('2026-09-18T12:00:00+09:00')
5
+ POSTED = NOW - dt.timedelta(days=3)
6
+
7
+ def row(mark, age, value=0):
8
+ return {'posted_at': jst.iso(POSTED), 'collected_at': jst.iso(POSTED + dt.timedelta(hours=age)),
9
+ 'marks': [mark], 'metrics': {'likes': value}}
10
+
11
+ def test_a1_strict_marks():
12
+ post = {'rows': [row(1, 1.25, 8), row(1, 1, 0), row(6, 7.5), row(24, 25, 9)]}
13
+ result = c._marks_population([('p', POSTED, post)], POSTED, NOW, NOW, 1)
14
+ assert result['by_mark']['1']['metrics']['likes']['median'] == 0
15
+ assert result['by_mark']['1']['metrics']['views']['median'] is None
16
+ assert result['marks_by_post'][0]['marks']['6'] is None
17
+ assert result['by_mark']['24']['metrics']['likes']['median'] == 9
18
+ assert result['by_mark']['168']['n_eligible'] == 0
19
+ assert c._marks_population([('p', POSTED, post)], POSTED, NOW, NOW, 2)['by_mark']['1']['metrics']['likes']['median'] is None
20
+
21
+ def test_a1_reject_collapsed_future_conflicting():
22
+ rows = [row(1, 1), row(1, 2), row(1, 1)]
23
+ rows[0]['marks_collapsed'] = True
24
+ rows[2]['posted_at'] = jst.iso(POSTED-dt.timedelta(hours=1))
25
+ assert c._observation({'rows': rows}, POSTED, NOW, 1)[0] is None
26
+ assert c._observation({'rows': [row(168, 168)]}, POSTED, NOW, 168)[0] is None
27
+
28
+ def test_a5_spread_gate_and_odd_center():
29
+ assert c._spread([1, 2, 3, 4], 5) == {'iqr': None, 'min': None, 'max': None, 'spread_reason': 'below_min_n'}
30
+ assert c._spread([0], 1) == {'iqr': None, 'min': 0, 'max': 0, 'spread_reason': 'too_few_for_quartiles'}
31
+ assert c._spread([1, 2, 3], 1)['iqr'] is None
32
+ assert c._spread([1, 2, 50, 80, 100], 1)['iqr'] == 88.5
33
+ assert c._spread([1, 2, 3, 4], 1)['iqr'] == 2
34
+
35
+ def test_a8_details_preserve_order_text():
36
+ from thth.report_details import attach
37
+ messages = ['timer_health_unknown', '自分の投稿の 24h views が欠測: 2 本', '未知の説明']
38
+ result = attach({'cannot_say': list(messages)})
39
+ assert result['cannot_say'] == messages
40
+ assert result['cannot_say_details'] == [
41
+ {'code': 'timer_health_unknown', 'text': messages[0]},
42
+ {'code': 'post_views_missing', 'text': messages[1]},
43
+ {'code': 'report_limitation', 'text': messages[2]}]
44
+
45
+ def test_a1_malformed_mark_types():
46
+ for mark in (1, 6, 24, 72, 168):
47
+ for bad in (True, False, str(mark), float(mark)):
48
+ value = row(mark, mark)
49
+ value['marks'] = [bad]
50
+ assert c._observation({'rows': [value]}, POSTED, NOW + dt.timedelta(days=10), mark)[0] is None
51
+
52
+ def test_a2_reaction_identity_and_missing(monkeypatch):
53
+ from thth import analytics_threads as t
54
+ ledger = {'broken': [], 'unreadable_accounts': [], 'fetches': [], 'replies': []}
55
+ monkeypatch.setattr(t.replies, 'load', lambda *a, **k: ledger)
56
+ e = {'post_id': 'mine', 'author_key': '0123456789abcdef'}
57
+ assert t.reaction('a', 'threads', e, POSTED, NOW) == (None, None)
58
+ ledger['fetches'] = [{'post_id':'mine', 'collected_at': jst.iso(NOW)}]
59
+ assert t.reaction('a', 'threads', e, POSTED, NOW) == (False, None)
60
+ ledger['replies'] = [{'post_id':'mine', 'collected_at': jst.iso(NOW), 'timestamp': jst.iso(POSTED+dt.timedelta(hours=2)), 'own': False, 'replied_to': {'id':'mine'}, 'author_key': e['author_key'], 'text':'SECRET'}]
61
+ assert t.reaction('a', 'threads', e, POSTED, NOW) == (True, 2)
62
+ ledger['replies'][0]['replied_to'] = {'id': 'someone_else'}
63
+ assert t.reaction('a', 'threads', e, POSTED, NOW) == (False, None)
64
+
65
+ def test_a3_skip_not_success(tmp_path, monkeypatch):
66
+ import json
67
+ from thth import collection_status as s
68
+ monkeypatch.setattr(s.accounts, 'state_dir_for', lambda name: tmp_path)
69
+ assert s.summarize('a', NOW)[0] is None
70
+ p = tmp_path/'runs-2026-09.ndjson'
71
+ rows = [{'account':'a', 'mode':'collect', 'action':'collect', 'run_id':'collect-'+jst.iso(POSTED), 'status':'ok','collected':0},
72
+ {'account':'a', 'mode':'collect', 'action':'collect', 'run_id':'collect-'+jst.iso(NOW), 'status':'ok','collected':None}]
73
+ p.write_text('\n'.join(map(json.dumps,rows)))
74
+ value, reasons = s.summarize('a', NOW)
75
+ assert value['last_success_at'] == jst.iso(POSTED)
76
+ assert value['last_attempt_ok'] is None
77
+ rows[-1]['collected'] = True
78
+ p.write_text('\n'.join(map(json.dumps,rows)))
79
+ assert s.summarize('a', NOW)[0]['last_attempt_ok'] is None
80
+
81
+ def test_a6_unknown_stratum_reconciles(isolated_account_factory):
82
+ from tests.test_analytics_comparison import seed, report
83
+ from thth import analytics_report
84
+ a = isolated_account_factory()
85
+ seed(a, 'p', NOW-dt.timedelta(days=3), value=0)
86
+ result = analytics_report.answer(a['name'], now=NOW, compare_previous=True, by='kind', min_n=1)
87
+ group = result['by_account'][a['name']]['posts']
88
+ assert group['stratified']['strata']['unknown']['current']['n_total'] == 1
89
+ assert group['stratified']['reconciliation']['current'] == {'sum_n_total':1,'n_total':1}
90
+ assert group['stratified']['strata']['unknown']['current']['metrics']['views']['median'] == 0
91
+
92
+ def test_a7_collected_time_bounds_history(monkeypatch):
93
+ from thth import analytics_shapes as s
94
+ early = POSTED+dt.timedelta(hours=23)
95
+ late = POSTED+dt.timedelta(hours=25)
96
+ ledger = {'broken': [], 'fetches':[{'post_id':'p', 'collected_at':jst.iso(early)}, {'post_id':'p', 'collected_at':jst.iso(late)}], 'replies':[
97
+ {'post_id':'p','id':'r','own':False,'username':'private','text':'SECRET','replied_to':{'id':'p'},
98
+ 'timestamp':jst.iso(POSTED+dt.timedelta(hours=1)), 'collected_at':jst.iso(late)}]}
99
+ monkeypatch.setattr(s.replies,'load',lambda *a,**k:ledger)
100
+ value=s.shape_at('a','p',POSTED,NOW)
101
+ assert value['24']['replies_total']==0
102
+ assert value['72']['replies_total']==1
103
+ assert value['168'] is None
104
+ assert 'SECRET' not in str(value) and 'private' not in str(value)
105
+ ledger['fetches']=[]
106
+ assert s.shape_at('a','p',POSTED,NOW)['24'] is None
107
+
108
+ def test_a9_forecast_only_immature():
109
+ from thth.study_report import _eligibility_forecast
110
+ late=NOW-dt.timedelta(hours=1)
111
+ value=_eligibility_forecast({'evidence':[
112
+ {'status':'immature','posted_at':jst.iso(NOW-dt.timedelta(hours=2))},
113
+ {'status':'immature','posted_at':jst.iso(late)},
114
+ {'status':'incomplete','posted_at':jst.iso(POSTED)}]})
115
+ assert value=={'posts_immature':2, 'all_eligible_at':jst.iso(late+dt.timedelta(hours=24)), 'basis':'posted_at_plus_24h'}
116
+ assert _eligibility_forecast({'evidence':[]}) is None
117
+
118
+ def test_a4_explicit_cli_write_and_readonly_delta(isolated_account_factory, monkeypatch, capsys):
119
+ import json
120
+ from pathlib import Path
121
+ from types import SimpleNamespace
122
+ from thth import operations_handoff as h, handoff_cursor as cursor, accounts
123
+ from tests.test_operations_handoff import seed
124
+ a=isolated_account_factory()
125
+ name=a['name']
126
+ monkeypatch.setattr(h.jst,'now_jst',lambda:NOW)
127
+ seed(a,'draft.md','draft')
128
+ path=Path(accounts.state_dir_for(name))/'handoff_cursor.json'
129
+ args=SimpleNamespace(account=name,project=None,json=True,mark_read=False,by=None,since_last_read=True)
130
+ assert h.cmd_handoff_report(args)==0
131
+ assert not path.exists()
132
+ args.mark_read=True
133
+ assert h.cmd_handoff_report(args)==2
134
+ assert not path.exists()
135
+ args.by='reader'
136
+ assert h.cmd_handoff_report(args)==0
137
+ saved=path.read_bytes()
138
+ node=h.answer(name,now=NOW,since_last_read=True)['by_account'][name]
139
+ assert node['changes_since']['changes']==[]
140
+ assert 'no_previous_session_cursor' not in node['cannot_say']
141
+ seed(a,'draft2.md','draft')
142
+ node=h.answer(name,now=NOW,since_last_read=True)['by_account'][name]
143
+ assert next(r for r in node['changes_since']['changes'] if r['field']=='queue_counts.draft')['delta']==1
144
+ assert path.read_bytes()==saved
145
+ path.unlink()
146
+ path.symlink_to(path.parent/'outside')
147
+ assert cursor.read(name,NOW)[1]=='cursor_unreadable'
148
+ args.mark_read=True
149
+ assert h.cmd_handoff_report(args)==2
150
+ assert not (path.parent/'outside').exists()
151
+
152
+ def test_a4_unknown_counts_not_zero():
153
+ from thth.handoff_cursor import changes
154
+ import copy
155
+ base={'queue_counts':dict.fromkeys(['draft','approved_waiting','overdue','malformed','unattributed_malformed']),
156
+ 'inflight':{'present':None,'since':None},'notification_last_event_id':None,
157
+ 'notification_recorded_state':None,'run_last_attempt_at':None,'run_recorded_state':None,
158
+ 'last_post_observed_at':None,'sent_count':None}
159
+ current=copy.deepcopy(base);current['queue_counts']['draft']=0
160
+ assert changes(base,current)==[{'field':'queue_counts.draft','previous':None,'current':0,'delta':None}]
161
+
162
+ def test_a4_ancestor_swap_does_not_redirect(tmp_path, monkeypatch):
163
+ import os
164
+ from thth import handoff_cursor as cursor
165
+ from pathlib import Path
166
+ state=tmp_path/'state';state.mkdir()
167
+ (state/'a').mkdir()
168
+ outside=tmp_path/'outside';outside.mkdir();(outside/'a').mkdir()
169
+ original=tmp_path/'original'
170
+ monkeypatch.setattr(cursor.accounts,'state_dir_for',lambda name:state/name)
171
+ real_open=os.open
172
+ swapped=False
173
+ def race(path, flags, *args, **kwargs):
174
+ nonlocal swapped
175
+ # The state descriptor must already be held when its child is opened.
176
+ if path=='a' and not swapped:
177
+ state.rename(original)
178
+ state.symlink_to(outside,target_is_directory=True)
179
+ swapped=True
180
+ return real_open(path,flags,*args,**kwargs)
181
+ monkeypatch.setattr(cursor.os,'open',race)
182
+ fd=cursor._directory('a',create=True)
183
+ try:
184
+ child=real_open('proof',os.O_CREAT|os.O_WRONLY,0o600,dir_fd=fd)
185
+ os.close(child)
186
+ finally:os.close(fd)
187
+ assert swapped
188
+ assert (original/'a'/'proof').exists()
189
+ assert not (outside/'a'/'proof').exists()
190
+
191
+
192
+ def test_a4_read_mode_limitations(isolated_account_factory):
193
+ from thth import operations_handoff as h
194
+ name=isolated_account_factory()['name']
195
+ assert h.answer(name,now=NOW)['limitations'][0]=='ローカル保存記録の現在の読み取り。前回セッション以降の差分ではない'
196
+ assert h.answer(name,now=NOW,since_last_read=True)['limitations'][0]=='保存済みsnapshotと現在の値の差分。間に起きた全イベントを復元するものではない'
197
+
198
+ def test_a4_ancestor_swap_before_open_is_rejected(tmp_path, monkeypatch):
199
+ import os
200
+ import pytest
201
+ from thth import handoff_cursor as cursor
202
+ state=tmp_path/'state';state.mkdir()
203
+ outside=tmp_path/'outside';outside.mkdir();(outside/'a').mkdir()
204
+ monkeypatch.setattr(cursor.accounts,'state_dir_for',lambda name:state/name)
205
+ real_open=os.open
206
+ swapped=False
207
+ def race(path, flags, *args, **kwargs):
208
+ nonlocal swapped
209
+ if path=='state' and not swapped:
210
+ state.rename(tmp_path/'original')
211
+ state.symlink_to(outside,target_is_directory=True)
212
+ swapped=True
213
+ return real_open(path,flags,*args,**kwargs)
214
+ monkeypatch.setattr(cursor.os,'open',race)
215
+ with pytest.raises(OSError):
216
+ fd=cursor._directory('a',create=True)
217
+ os.close(fd)
218
+ assert swapped
219
+ assert list((outside/'a').iterdir())==[]