thth 2.3.0__tar.gz → 2.3.1__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 (250) hide show
  1. {thth-2.3.0 → thth-2.3.1}/PKG-INFO +2 -2
  2. {thth-2.3.0 → thth-2.3.1}/README.md +1 -1
  3. {thth-2.3.0 → thth-2.3.1}/mcp/server.py +11 -1
  4. {thth-2.3.0 → thth-2.3.1}/tests/test_adapter_boundary.py +49 -0
  5. {thth-2.3.0 → thth-2.3.1}/tests/test_engagements.py +41 -0
  6. {thth-2.3.0 → thth-2.3.1}/tests/test_send.py +114 -0
  7. {thth-2.3.0 → thth-2.3.1}/tests/test_send_cli.py +50 -0
  8. {thth-2.3.0 → thth-2.3.1}/tests/test_thread_read.py +125 -3
  9. {thth-2.3.0 → thth-2.3.1}/tests/test_threads_read_permissions.py +6 -3
  10. thth-2.3.1/thth/VERSION +1 -0
  11. {thth-2.3.0 → thth-2.3.1}/thth/adapters/base.py +36 -0
  12. {thth-2.3.0 → thth-2.3.1}/thth/cli.py +20 -0
  13. {thth-2.3.0 → thth-2.3.1}/thth/core.py +100 -25
  14. {thth-2.3.0 → thth-2.3.1}/thth/runs.py +9 -1
  15. {thth-2.3.0 → thth-2.3.1}/thth/thread_read.py +80 -9
  16. thth-2.3.0/thth/VERSION +0 -1
  17. {thth-2.3.0 → thth-2.3.1}/.gitignore +0 -0
  18. {thth-2.3.0 → thth-2.3.1}/LICENSE +0 -0
  19. {thth-2.3.0 → thth-2.3.1}/accounts.example/bluesky.json +0 -0
  20. {thth-2.3.0 → thth-2.3.1}/accounts.example/mastodon.json +0 -0
  21. {thth-2.3.0 → thth-2.3.1}/accounts.example/threads.json +0 -0
  22. {thth-2.3.0 → thth-2.3.1}/bin/thth +0 -0
  23. {thth-2.3.0 → thth-2.3.1}/bin/thth-remote +0 -0
  24. {thth-2.3.0 → thth-2.3.1}/bin/thth-run +0 -0
  25. {thth-2.3.0 → thth-2.3.1}/pyproject.toml +0 -0
  26. {thth-2.3.0 → thth-2.3.1}/skills/thth/SKILL.md +0 -0
  27. {thth-2.3.0 → thth-2.3.1}/systemd/thth@.service +0 -0
  28. {thth-2.3.0 → thth-2.3.1}/tests/__init__.py +0 -0
  29. {thth-2.3.0 → thth-2.3.1}/tests/conftest.py +0 -0
  30. {thth-2.3.0 → thth-2.3.1}/tests/fixtures/build_injection_eval.py +0 -0
  31. {thth-2.3.0 → thth-2.3.1}/tests/fixtures/callback/worker_responses.json +0 -0
  32. {thth-2.3.0 → thth-2.3.1}/tests/fixtures/injection_eval.json +0 -0
  33. {thth-2.3.0 → thth-2.3.1}/tests/fixtures/umami-bile.md +0 -0
  34. {thth-2.3.0 → thth-2.3.1}/tests/helpers/__init__.py +0 -0
  35. {thth-2.3.0 → thth-2.3.1}/tests/helpers/fake_oauth_server.py +0 -0
  36. {thth-2.3.0 → thth-2.3.1}/tests/helpers/fake_push_adapter.py +0 -0
  37. {thth-2.3.0 → thth-2.3.1}/tests/helpers/fake_redirect_server.py +0 -0
  38. {thth-2.3.0 → thth-2.3.1}/tests/helpers/fake_search_adapter.py +0 -0
  39. {thth-2.3.0 → thth-2.3.1}/tests/helpers/fake_threads_reader.py +0 -0
  40. {thth-2.3.0 → thth-2.3.1}/tests/helpers/fake_threads_writes.py +0 -0
  41. {thth-2.3.0 → thth-2.3.1}/tests/helpers/hold_lock.py +0 -0
  42. {thth-2.3.0 → thth-2.3.1}/tests/helpers/hold_repo_lock.py +0 -0
  43. {thth-2.3.0 → thth-2.3.1}/tests/helpers/worker_probe.mjs +0 -0
  44. {thth-2.3.0 → thth-2.3.1}/tests/test_account_name_check.py +0 -0
  45. {thth-2.3.0 → thth-2.3.1}/tests/test_account_report.py +0 -0
  46. {thth-2.3.0 → thth-2.3.1}/tests/test_accounts_dir.py +0 -0
  47. {thth-2.3.0 → thth-2.3.1}/tests/test_after_cli.py +0 -0
  48. {thth-2.3.0 → thth-2.3.1}/tests/test_api_error_body.py +0 -0
  49. {thth-2.3.0 → thth-2.3.1}/tests/test_app_env_cli.py +0 -0
  50. {thth-2.3.0 → thth-2.3.1}/tests/test_approval.py +0 -0
  51. {thth-2.3.0 → thth-2.3.1}/tests/test_approve_cmd.py +0 -0
  52. {thth-2.3.0 → thth-2.3.1}/tests/test_ask.py +0 -0
  53. {thth-2.3.0 → thth-2.3.1}/tests/test_atlas_fifth_review.py +0 -0
  54. {thth-2.3.0 → thth-2.3.1}/tests/test_atlas_fourth_review.py +0 -0
  55. {thth-2.3.0 → thth-2.3.1}/tests/test_atlas_review.py +0 -0
  56. {thth-2.3.0 → thth-2.3.1}/tests/test_atlas_second_review.py +0 -0
  57. {thth-2.3.0 → thth-2.3.1}/tests/test_atlas_third_review.py +0 -0
  58. {thth-2.3.0 → thth-2.3.1}/tests/test_audit1_fixes.py +0 -0
  59. {thth-2.3.0 → thth-2.3.1}/tests/test_audit2_fixes.py +0 -0
  60. {thth-2.3.0 → thth-2.3.1}/tests/test_audit_b_counterexamples.py +0 -0
  61. {thth-2.3.0 → thth-2.3.1}/tests/test_audit_b_m1_semantics.py +0 -0
  62. {thth-2.3.0 → thth-2.3.1}/tests/test_audit_b_n1_depth.py +0 -0
  63. {thth-2.3.0 → thth-2.3.1}/tests/test_audit_b_r1_closure.py +0 -0
  64. {thth-2.3.0 → thth-2.3.1}/tests/test_audit_v201.py +0 -0
  65. {thth-2.3.0 → thth-2.3.1}/tests/test_audit_v23_details.py +0 -0
  66. {thth-2.3.0 → thth-2.3.1}/tests/test_auth.py +0 -0
  67. {thth-2.3.0 → thth-2.3.1}/tests/test_auth_identity_required.py +0 -0
  68. {thth-2.3.0 → thth-2.3.1}/tests/test_bluesky_adapter.py +0 -0
  69. {thth-2.3.0 → thth-2.3.1}/tests/test_bluesky_secret_scrub.py +0 -0
  70. {thth-2.3.0 → thth-2.3.1}/tests/test_board_needs_review.py +0 -0
  71. {thth-2.3.0 → thth-2.3.1}/tests/test_board_running.py +0 -0
  72. {thth-2.3.0 → thth-2.3.1}/tests/test_bundle.py +0 -0
  73. {thth-2.3.0 → thth-2.3.1}/tests/test_charcount.py +0 -0
  74. {thth-2.3.0 → thth-2.3.1}/tests/test_cli_entrypoint.py +0 -0
  75. {thth-2.3.0 → thth-2.3.1}/tests/test_cli_help.py +0 -0
  76. {thth-2.3.0 → thth-2.3.1}/tests/test_collect.py +0 -0
  77. {thth-2.3.0 → thth-2.3.1}/tests/test_collect_runs.py +0 -0
  78. {thth-2.3.0 → thth-2.3.1}/tests/test_collect_sent.py +0 -0
  79. {thth-2.3.0 → thth-2.3.1}/tests/test_comparability.py +0 -0
  80. {thth-2.3.0 → thth-2.3.1}/tests/test_control_chars_refused.py +0 -0
  81. {thth-2.3.0 → thth-2.3.1}/tests/test_conversation_paging.py +0 -0
  82. {thth-2.3.0 → thth-2.3.1}/tests/test_count_for.py +0 -0
  83. {thth-2.3.0 → thth-2.3.1}/tests/test_docs_links.py +0 -0
  84. {thth-2.3.0 → thth-2.3.1}/tests/test_doctor.py +0 -0
  85. {thth-2.3.0 → thth-2.3.1}/tests/test_doctor_all_permissions.py +0 -0
  86. {thth-2.3.0 → thth-2.3.1}/tests/test_doctor_next_step.py +0 -0
  87. {thth-2.3.0 → thth-2.3.1}/tests/test_duplicate_front_matter_keys.py +0 -0
  88. {thth-2.3.0 → thth-2.3.1}/tests/test_editorial_audit.py +0 -0
  89. {thth-2.3.0 → thth-2.3.1}/tests/test_editorial_n3_n4.py +0 -0
  90. {thth-2.3.0 → thth-2.3.1}/tests/test_external_acceptance.py +0 -0
  91. {thth-2.3.0 → thth-2.3.1}/tests/test_fake_api.py +0 -0
  92. {thth-2.3.0 → thth-2.3.1}/tests/test_form_spec.py +0 -0
  93. {thth-2.3.0 → thth-2.3.1}/tests/test_form_spec_cli.py +0 -0
  94. {thth-2.3.0 → thth-2.3.1}/tests/test_forms.py +0 -0
  95. {thth-2.3.0 → thth-2.3.1}/tests/test_fresh_install.py +0 -0
  96. {thth-2.3.0 → thth-2.3.1}/tests/test_front_matter_injection.py +0 -0
  97. {thth-2.3.0 → thth-2.3.1}/tests/test_hypotheses.py +0 -0
  98. {thth-2.3.0 → thth-2.3.1}/tests/test_hypothesis_boundaries.py +0 -0
  99. {thth-2.3.0 → thth-2.3.1}/tests/test_hypothesis_units.py +0 -0
  100. {thth-2.3.0 → thth-2.3.1}/tests/test_impact_cli.py +0 -0
  101. {thth-2.3.0 → thth-2.3.1}/tests/test_inbox_granted_scopes.py +0 -0
  102. {thth-2.3.0 → thth-2.3.1}/tests/test_independent_thread_review.py +0 -0
  103. {thth-2.3.0 → thth-2.3.1}/tests/test_independent_topic_review.py +0 -0
  104. {thth-2.3.0 → thth-2.3.1}/tests/test_independent_topic_review_r2.py +0 -0
  105. {thth-2.3.0 → thth-2.3.1}/tests/test_inflight.py +0 -0
  106. {thth-2.3.0 → thth-2.3.1}/tests/test_jst.py +0 -0
  107. {thth-2.3.0 → thth-2.3.1}/tests/test_lint.py +0 -0
  108. {thth-2.3.0 → thth-2.3.1}/tests/test_list_projections.py +0 -0
  109. {thth-2.3.0 → thth-2.3.1}/tests/test_llm_trial.py +0 -0
  110. {thth-2.3.0 → thth-2.3.1}/tests/test_llm_trial_engage.py +0 -0
  111. {thth-2.3.0 → thth-2.3.1}/tests/test_lock.py +0 -0
  112. {thth-2.3.0 → thth-2.3.1}/tests/test_m2_independent.py +0 -0
  113. {thth-2.3.0 → thth-2.3.1}/tests/test_maintain.py +0 -0
  114. {thth-2.3.0 → thth-2.3.1}/tests/test_mastodon_adapter.py +0 -0
  115. {thth-2.3.0 → thth-2.3.1}/tests/test_mastodon_visibility.py +0 -0
  116. {thth-2.3.0 → thth-2.3.1}/tests/test_max_per_run.py +0 -0
  117. {thth-2.3.0 → thth-2.3.1}/tests/test_mcp.py +0 -0
  118. {thth-2.3.0 → thth-2.3.1}/tests/test_mcp_robustness.py +0 -0
  119. {thth-2.3.0 → thth-2.3.1}/tests/test_measured.py +0 -0
  120. {thth-2.3.0 → thth-2.3.1}/tests/test_media_accounts.py +0 -0
  121. {thth-2.3.0 → thth-2.3.1}/tests/test_media_next_steps.py +0 -0
  122. {thth-2.3.0 → thth-2.3.1}/tests/test_media_roundtrip.py +0 -0
  123. {thth-2.3.0 → thth-2.3.1}/tests/test_mismatch_diagnostics.py +0 -0
  124. {thth-2.3.0 → thth-2.3.1}/tests/test_n5_reply_null_element.py +0 -0
  125. {thth-2.3.0 → thth-2.3.1}/tests/test_n6_replies_incomplete_ledger.py +0 -0
  126. {thth-2.3.0 → thth-2.3.1}/tests/test_n7_doctor_error_body.py +0 -0
  127. {thth-2.3.0 → thth-2.3.1}/tests/test_no_limits.py +0 -0
  128. {thth-2.3.0 → thth-2.3.1}/tests/test_observation_provenance.py +0 -0
  129. {thth-2.3.0 → thth-2.3.1}/tests/test_packaging.py +0 -0
  130. {thth-2.3.0 → thth-2.3.1}/tests/test_preview.py +0 -0
  131. {thth-2.3.0 → thth-2.3.1}/tests/test_production_mode.py +0 -0
  132. {thth-2.3.0 → thth-2.3.1}/tests/test_queue.py +0 -0
  133. {thth-2.3.0 → thth-2.3.1}/tests/test_quiet_hours.py +0 -0
  134. {thth-2.3.0 → thth-2.3.1}/tests/test_r2_r4_r5.py +0 -0
  135. {thth-2.3.0 → thth-2.3.1}/tests/test_reason_adoption.py +0 -0
  136. {thth-2.3.0 → thth-2.3.1}/tests/test_redact.py +0 -0
  137. {thth-2.3.0 → thth-2.3.1}/tests/test_redirect_token_leak.py +0 -0
  138. {thth-2.3.0 → thth-2.3.1}/tests/test_refresh.py +0 -0
  139. {thth-2.3.0 → thth-2.3.1}/tests/test_refresh_destination.py +0 -0
  140. {thth-2.3.0 → thth-2.3.1}/tests/test_rejudge_new.py +0 -0
  141. {thth-2.3.0 → thth-2.3.1}/tests/test_rejudge_r2_r3.py +0 -0
  142. {thth-2.3.0 → thth-2.3.1}/tests/test_replies.py +0 -0
  143. {thth-2.3.0 → thth-2.3.1}/tests/test_replies_refresh.py +0 -0
  144. {thth-2.3.0 → thth-2.3.1}/tests/test_repo_lock.py +0 -0
  145. {thth-2.3.0 → thth-2.3.1}/tests/test_repo_sync.py +0 -0
  146. {thth-2.3.0 → thth-2.3.1}/tests/test_reverse_audit.py +0 -0
  147. {thth-2.3.0 → thth-2.3.1}/tests/test_review_case_id_cluster_m2.py +0 -0
  148. {thth-2.3.0 → thth-2.3.1}/tests/test_review_case_id_cluster_r2.py +0 -0
  149. {thth-2.3.0 → thth-2.3.1}/tests/test_review_cli.py +0 -0
  150. {thth-2.3.0 → thth-2.3.1}/tests/test_review_evidence_account.py +0 -0
  151. {thth-2.3.0 → thth-2.3.1}/tests/test_review_form_spec_and_case_id.py +0 -0
  152. {thth-2.3.0 → thth-2.3.1}/tests/test_review_provenance.py +0 -0
  153. {thth-2.3.0 → thth-2.3.1}/tests/test_review_records.py +0 -0
  154. {thth-2.3.0 → thth-2.3.1}/tests/test_revoke.py +0 -0
  155. {thth-2.3.0 → thth-2.3.1}/tests/test_roundtrip.py +0 -0
  156. {thth-2.3.0 → thth-2.3.1}/tests/test_run_missing_env.py +0 -0
  157. {thth-2.3.0 → thth-2.3.1}/tests/test_run_record_failclosed.py +0 -0
  158. {thth-2.3.0 → thth-2.3.1}/tests/test_runs_collected.py +0 -0
  159. {thth-2.3.0 → thth-2.3.1}/tests/test_runs_read_rows.py +0 -0
  160. {thth-2.3.0 → thth-2.3.1}/tests/test_schedule_multiple.py +0 -0
  161. {thth-2.3.0 → thth-2.3.1}/tests/test_schedule_view.py +0 -0
  162. {thth-2.3.0 → thth-2.3.1}/tests/test_scopes_source_requested.py +0 -0
  163. {thth-2.3.0 → thth-2.3.1}/tests/test_search_engage_targets.py +0 -0
  164. {thth-2.3.0 → thth-2.3.1}/tests/test_secret_value_redact.py +0 -0
  165. {thth-2.3.0 → thth-2.3.1}/tests/test_secrets_grep.py +0 -0
  166. {thth-2.3.0 → thth-2.3.1}/tests/test_security_small_fixes.py +0 -0
  167. {thth-2.3.0 → thth-2.3.1}/tests/test_select.py +0 -0
  168. {thth-2.3.0 → thth-2.3.1}/tests/test_selfupdate.py +0 -0
  169. {thth-2.3.0 → thth-2.3.1}/tests/test_send_media.py +0 -0
  170. {thth-2.3.0 → thth-2.3.1}/tests/test_send_visibility.py +0 -0
  171. {thth-2.3.0 → thth-2.3.1}/tests/test_sent_integrity.py +0 -0
  172. {thth-2.3.0 → thth-2.3.1}/tests/test_server_json.py +0 -0
  173. {thth-2.3.0 → thth-2.3.1}/tests/test_site.py +0 -0
  174. {thth-2.3.0 → thth-2.3.1}/tests/test_skill_doc.py +0 -0
  175. {thth-2.3.0 → thth-2.3.1}/tests/test_systemd.py +0 -0
  176. {thth-2.3.0 → thth-2.3.1}/tests/test_thread_publish.py +0 -0
  177. {thth-2.3.0 → thth-2.3.1}/tests/test_threadrun.py +0 -0
  178. {thth-2.3.0 → thth-2.3.1}/tests/test_threadshape.py +0 -0
  179. {thth-2.3.0 → thth-2.3.1}/tests/test_thth_root.py +0 -0
  180. {thth-2.3.0 → thth-2.3.1}/tests/test_token_exists.py +0 -0
  181. {thth-2.3.0 → thth-2.3.1}/tests/test_token_scopes_record.py +0 -0
  182. {thth-2.3.0 → thth-2.3.1}/tests/test_token_set.py +0 -0
  183. {thth-2.3.0 → thth-2.3.1}/tests/test_topic.py +0 -0
  184. {thth-2.3.0 → thth-2.3.1}/tests/test_topic_advice.py +0 -0
  185. {thth-2.3.0 → thth-2.3.1}/tests/test_topic_cli.py +0 -0
  186. {thth-2.3.0 → thth-2.3.1}/tests/test_topic_models.py +0 -0
  187. {thth-2.3.0 → thth-2.3.1}/tests/test_topic_review_fixes.py +0 -0
  188. {thth-2.3.0 → thth-2.3.1}/tests/test_topic_shelf_observers.py +0 -0
  189. {thth-2.3.0 → thth-2.3.1}/tests/test_topic_store.py +0 -0
  190. {thth-2.3.0 → thth-2.3.1}/tests/test_topics_record_concurrent.py +0 -0
  191. {thth-2.3.0 → thth-2.3.1}/tests/test_topics_tool.py +0 -0
  192. {thth-2.3.0 → thth-2.3.1}/tests/test_trial_frictions.py +0 -0
  193. {thth-2.3.0 → thth-2.3.1}/tests/test_v21b_approved_writes.py +0 -0
  194. {thth-2.3.0 → thth-2.3.1}/tests/test_value_domains_shown.py +0 -0
  195. {thth-2.3.0 → thth-2.3.1}/tests/test_version.py +0 -0
  196. {thth-2.3.0 → thth-2.3.1}/tests/test_where_cli.py +0 -0
  197. {thth-2.3.0 → thth-2.3.1}/tests/test_who_cli.py +0 -0
  198. {thth-2.3.0 → thth-2.3.1}/tests/test_writeback_rebase.py +0 -0
  199. {thth-2.3.0 → thth-2.3.1}/tests/test_writeback_validate.py +0 -0
  200. {thth-2.3.0 → thth-2.3.1}/thth/__init__.py +0 -0
  201. {thth-2.3.0 → thth-2.3.1}/thth/__main__.py +0 -0
  202. {thth-2.3.0 → thth-2.3.1}/thth/account_cli.py +0 -0
  203. {thth-2.3.0 → thth-2.3.1}/thth/account_report.py +0 -0
  204. {thth-2.3.0 → thth-2.3.1}/thth/accounts.py +0 -0
  205. {thth-2.3.0 → thth-2.3.1}/thth/adapters/__init__.py +0 -0
  206. {thth-2.3.0 → thth-2.3.1}/thth/adapters/bluesky.py +0 -0
  207. {thth-2.3.0 → thth-2.3.1}/thth/adapters/mastodon.py +0 -0
  208. {thth-2.3.0 → thth-2.3.1}/thth/adapters/threads.py +0 -0
  209. {thth-2.3.0 → thth-2.3.1}/thth/after_cli.py +0 -0
  210. {thth-2.3.0 → thth-2.3.1}/thth/appenv.py +0 -0
  211. {thth-2.3.0 → thth-2.3.1}/thth/approval.py +0 -0
  212. {thth-2.3.0 → thth-2.3.1}/thth/ask.py +0 -0
  213. {thth-2.3.0 → thth-2.3.1}/thth/ask_cli.py +0 -0
  214. {thth-2.3.0 → thth-2.3.1}/thth/bundle.py +0 -0
  215. {thth-2.3.0 → thth-2.3.1}/thth/collect.py +0 -0
  216. {thth-2.3.0 → thth-2.3.1}/thth/doctor.py +0 -0
  217. {thth-2.3.0 → thth-2.3.1}/thth/engagements.py +0 -0
  218. {thth-2.3.0 → thth-2.3.1}/thth/forms.py +0 -0
  219. {thth-2.3.0 → thth-2.3.1}/thth/httpsafe.py +0 -0
  220. {thth-2.3.0 → thth-2.3.1}/thth/inflight.py +0 -0
  221. {thth-2.3.0 → thth-2.3.1}/thth/jst.py +0 -0
  222. {thth-2.3.0 → thth-2.3.1}/thth/lint.py +0 -0
  223. {thth-2.3.0 → thth-2.3.1}/thth/lock.py +0 -0
  224. {thth-2.3.0 → thth-2.3.1}/thth/maintain.py +0 -0
  225. {thth-2.3.0 → thth-2.3.1}/thth/measured.py +0 -0
  226. {thth-2.3.0 → thth-2.3.1}/thth/oauth.py +0 -0
  227. {thth-2.3.0 → thth-2.3.1}/thth/postid.py +0 -0
  228. {thth-2.3.0 → thth-2.3.1}/thth/queuefile.py +0 -0
  229. {thth-2.3.0 → thth-2.3.1}/thth/redact.py +0 -0
  230. {thth-2.3.0 → thth-2.3.1}/thth/replies.py +0 -0
  231. {thth-2.3.0 → thth-2.3.1}/thth/report.py +0 -0
  232. {thth-2.3.0 → thth-2.3.1}/thth/retract_cli.py +0 -0
  233. {thth-2.3.0 → thth-2.3.1}/thth/scopes.py +0 -0
  234. {thth-2.3.0 → thth-2.3.1}/thth/secrets_fs.py +0 -0
  235. {thth-2.3.0 → thth-2.3.1}/thth/select.py +0 -0
  236. {thth-2.3.0 → thth-2.3.1}/thth/selfupdate.py +0 -0
  237. {thth-2.3.0 → thth-2.3.1}/thth/sent.py +0 -0
  238. {thth-2.3.0 → thth-2.3.1}/thth/systemd_gen.py +0 -0
  239. {thth-2.3.0 → thth-2.3.1}/thth/threadrun.py +0 -0
  240. {thth-2.3.0 → thth-2.3.1}/thth/threads_read_cli.py +0 -0
  241. {thth-2.3.0 → thth-2.3.1}/thth/threadshape.py +0 -0
  242. {thth-2.3.0 → thth-2.3.1}/thth/threadthrow.py +0 -0
  243. {thth-2.3.0 → thth-2.3.1}/thth/topic_advice.py +0 -0
  244. {thth-2.3.0 → thth-2.3.1}/thth/topic_cli.py +0 -0
  245. {thth-2.3.0 → thth-2.3.1}/thth/topic_models.py +0 -0
  246. {thth-2.3.0 → thth-2.3.1}/thth/topic_store.py +0 -0
  247. {thth-2.3.0 → thth-2.3.1}/thth/topics.py +0 -0
  248. {thth-2.3.0 → thth-2.3.1}/thth/where_cli.py +0 -0
  249. {thth-2.3.0 → thth-2.3.1}/thth/who_cli.py +0 -0
  250. {thth-2.3.0 → thth-2.3.1}/thth/writeback.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: thth
3
- Version: 2.3.0
3
+ Version: 2.3.1
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
@@ -45,7 +45,7 @@ watchtower(`~/Developer/watchtower`)の隣に同じ流儀で並べる。watc
45
45
 
46
46
  ## いまの状態(2026-09-13)
47
47
 
48
- 版 **2.3.0**。全件テスト **2274 件**(`python -m pytest tests/ -q -n auto -p no:cacheprovider`・1 skip・rc=0)。
48
+ 版 **2.3.1**。全件テスト **2287 件**(`python -m pytest tests/ -q -n auto -p no:cacheprovider`・1 skip・rc=0)。
49
49
 
50
50
  **媒体**: Threads(稼働)・Bluesky・Mastodon(同席用の台帳あり・未稼働)。
51
51
 
@@ -21,7 +21,7 @@ watchtower(`~/Developer/watchtower`)の隣に同じ流儀で並べる。watc
21
21
 
22
22
  ## いまの状態(2026-09-13)
23
23
 
24
- 版 **2.3.0**。全件テスト **2274 件**(`python -m pytest tests/ -q -n auto -p no:cacheprovider`・1 skip・rc=0)。
24
+ 版 **2.3.1**。全件テスト **2287 件**(`python -m pytest tests/ -q -n auto -p no:cacheprovider`・1 skip・rc=0)。
25
25
 
26
26
  **媒体**: Threads(稼働)・Bluesky・Mastodon(同席用の台帳あり・未稼働)。
27
27
 
@@ -180,7 +180,17 @@ TOOLS = [
180
180
  "type": "object",
181
181
  "properties": {
182
182
  "account": {"type": "string", "description": "account 名"},
183
- "post_id": {"type": "string", "description": "枝の根の post_id"},
183
+ "post_id": {
184
+ "type": "string",
185
+ "description": (
186
+ "枝の根の post_id。各 message の already_replied は"
187
+ " 3 値(T7-3): object(source 付き。ledger=絡みの台帳/"
188
+ "queue=queue の下書き/thread=枝の中の自分の返信)=返した"
189
+ "/false=台帳・queue・枝のどこにも見当たらない"
190
+ "(「返していない」の確定ではない)"
191
+ "/null=台帳か queue が読めず判らない"
192
+ "(理由は provenance.ledgers_unreadable)"),
193
+ },
184
194
  "since": {"type": "string", "description": "この時刻以降だけ"},
185
195
  "max_messages": {"type": "integer",
186
196
  "description": "読む上限(既定 200・上限 1000)"},
@@ -204,6 +204,55 @@ def test_TB0_conversationはmediumとauthor_keyを足すが既存の鍵は変え
204
204
  assert row["author_key"] == adapter_base.author_key("threads", "someone")
205
205
 
206
206
 
207
+ # --- T7-1: normalize_message() ----------------------------------------------
208
+ # `thth/thread_read.py`が`conversation()`・`fetch_post()`の生の行を読む直前に
209
+ # 通す変換(設計「自分の泉」§2.1)。**`conversation()`自身は変えない**——ここは
210
+ # 変換の単体テストだけ(`thread_read`側の配線は`tests/test_thread_read.py`)。
211
+
212
+
213
+ def test_T71_normalize_messageはidをmessage_idに写しreplied_toのdictをidに開く():
214
+ row = {"id": "R1", "username": "someone", "text": "やあ",
215
+ "timestamp": "2026-09-12T10:00:00+0000",
216
+ "replied_to": {"id": "P1"}, "root_post": {"id": "P0"}}
217
+ out = adapter_base.normalize_message(row, medium="threads")
218
+ assert out["message_id"] == "R1"
219
+ assert out["replied_to"] == "P1"
220
+ assert out["root_post"] == "P0"
221
+ assert out["author_key"] == adapter_base.author_key("threads", "someone")
222
+ # 他の鍵はそのまま素通し。
223
+ assert out["text"] == "やあ" and out["timestamp"] == "2026-09-12T10:00:00+0000"
224
+ # 元の行は書き換えない(コピーを返す)。
225
+ assert "message_id" not in row
226
+
227
+
228
+ def test_T71_normalize_messageはreplied_toが文字列ならそのまま無ければNoneのまま():
229
+ already_str = adapter_base.normalize_message(
230
+ {"message_id": "R2", "username": "u", "replied_to": "P1", "root_post": "P1"},
231
+ medium="bluesky")
232
+ assert already_str["replied_to"] == "P1" and already_str["root_post"] == "P1"
233
+
234
+ 無し = adapter_base.normalize_message(
235
+ {"id": "R3", "username": "u"}, medium="mastodon")
236
+ assert 無し.get("replied_to") is None and 無し.get("root_post") is None
237
+ assert 無し["message_id"] == "R3"
238
+
239
+
240
+ def test_T71_normalize_messageは既にmessage_idやauthor_keyがあれば変えない():
241
+ already = {"message_id": "R4", "id": "RAW4", "username": "someone",
242
+ "author_key": "0" * 16, "replied_to": {"id": "P1"}}
243
+ out = adapter_base.normalize_message(already, medium="threads")
244
+ assert out["message_id"] == "R4" # `id` ではなく既存の値のまま
245
+ assert out["author_key"] == "0" * 16 # 上書きしない
246
+
247
+
248
+ def test_T71_normalize_messageは冪等():
249
+ row = {"id": "R5", "username": "someone", "replied_to": {"id": "P1"},
250
+ "root_post": {"id": "P1"}}
251
+ once = adapter_base.normalize_message(row, medium="threads")
252
+ twice = adapter_base.normalize_message(once, medium="threads")
253
+ assert once == twice
254
+
255
+
207
256
  def _select_1件(tmp_path, *, media: str, topic: str, char_limit=None):
208
257
  """`select` に 1 本だけ流して、落ちた理由を返す(時刻の関門より手前を見る)。"""
209
258
  path = tmp_path / "a.md"
@@ -21,6 +21,7 @@ from thth import accounts as accounts_mod
21
21
  from thth import core
22
22
  from thth import engagements as engagements_mod
23
23
  from thth import lint as lint_mod
24
+ from thth import runs as runs_mod
24
25
 
25
26
 
26
27
  def _row(**over):
@@ -151,6 +152,46 @@ def test_c_reply_to付きの原稿を出すと台帳に1行_author_keyも写る(
151
152
  assert forbidden not in row, row
152
153
 
153
154
 
155
+ # --- T7-2: queue の門(`_throw_chosen`)も best-effort に `fetch_post` を試す ---
156
+ # kopicha の実物の所見が挙げた「queue の門でも同じ best-effort を入れる」の側。
157
+ # `fake_threads_server`(`tests/test_fake_api.py`)は POST しか持たない偽サーバ
158
+ # なので、`fetch_post()` の `GET /v1.0/<reply_to>` は 501 になる——「読めなかった」
159
+ # を自然に再現できる(`AdapterError` に化けて `author_key` は `None` のまま・
160
+ # 公開は止まらない)。
161
+
162
+
163
+ def test_c_reply_to_author_key省略でもfetch_postをbesteffortに試し失敗してもauthor_keyはnullのまま(
164
+ isolated_account_factory):
165
+ account = isolated_account_factory(production=True)
166
+ text = make_queue_text(fm_overrides={"reply_to": "THIRDPARTY777", "topic": None})
167
+ os.makedirs(account["queue_dir"], exist_ok=True)
168
+ path = os.path.join(account["queue_dir"], "with-reply-no-key.md")
169
+ with open(path, "w", encoding="utf-8") as f:
170
+ f.write(text)
171
+ commit_and_push_path(path, message="test: with-reply-no-key")
172
+
173
+ with fake_threads_server({"create": "ok", "publish": "ok"}) as base_url:
174
+ def factory(_cfg, _token):
175
+ return _adapter(base_url)
176
+
177
+ result = core.throw_once(account["name"], production_flag=True,
178
+ adapter_factory=factory, now=NOW)
179
+
180
+ assert result.exit_code == 0 and result.action == "post", result
181
+ cfg = accounts_mod.load_account(account["name"])
182
+ rows = engagements_mod.records(cfg, account["name"])
183
+ assert len(rows) == 1, rows
184
+ assert rows[0]["author_key"] is None # 偽サーバに GET が無いので best-effort は失敗する
185
+ assert rows[0]["reply_to"] == "THIRDPARTY777"
186
+
187
+ state_dir = accounts_mod.state_dir_for(account["name"])
188
+ run_rows = [r for r in runs_mod.read_runs(state_dir) if r.get("post_id") == result.post_id]
189
+ assert len(run_rows) == 1, run_rows
190
+ assert run_rows[0]["status"] == "ok"
191
+ assert run_rows[0]["engagement_write_failed"] is False
192
+ assert run_rows[0]["engagement_author_lookup_failed"] is True
193
+
194
+
154
195
  def test_c_reply_to無しなら0行(isolated_account_factory):
155
196
  account = isolated_account_factory(production=True)
156
197
  write_queue_file(account["queue_dir"], "no-reply.md")
@@ -11,7 +11,10 @@ import os
11
11
  from thth import accounts as accounts_mod
12
12
  from thth import approval as approval_mod
13
13
  from thth import core
14
+ from thth import engagements as engagements_mod
14
15
  from thth import inflight as inflight_mod
16
+ from thth import runs as runs_mod
17
+ from thth.adapters import base as adapter_base
15
18
 
16
19
 
17
20
  def _digest_for(account_name: str, text: str, *, reply_to=None, topic=None) -> str:
@@ -136,3 +139,114 @@ def test_inflightが残っていれば何もしない(isolated_account_factory):
136
139
  r = core.send_once(account["name"], text="本文", production_flag=True,
137
140
  adapter_factory=lambda *_: _Spy())
138
141
  assert r.exit_code == 1 and r.action == "inflight"
142
+
143
+
144
+ # --- T7-2: `send --reply-to` も絡みの台帳に 1 行書く(設計「自分の泉」§4) --------
145
+ # kopicha の実物の所見: `_throw_chosen()`(queue の門)は絡みの台帳に書くが、
146
+ # 同席の様態(`send_once()`)には配線が無く、`thth send --reply-to <id>` で
147
+ # 出した返信は台帳に残らなかった。
148
+
149
+
150
+ class _SpyWithFetch:
151
+ """`publish()` に加えて `fetch_post()` を持つ偽アダプタ(T7-2)。
152
+
153
+ `reply_to_author_key` を渡さないときの best-effort 経路を確かめる。
154
+ """
155
+
156
+ def __init__(self, *, fetch_row=None, fetch_error=None):
157
+ self.fetch_calls: list = []
158
+ self._fetch_row = fetch_row
159
+ self._fetch_error = fetch_error
160
+
161
+ def publish(self, post, *, dry_run, on_container_created=None):
162
+ return adapter_base.PublishResult(
163
+ post_id="ENG1", url=None, ts="2026-09-16T10:00:00+09:00")
164
+
165
+ def fetch_post(self, post_id):
166
+ self.fetch_calls.append(post_id)
167
+ if self._fetch_error is not None:
168
+ raise self._fetch_error
169
+ return self._fetch_row
170
+
171
+
172
+ def test_replyToがあれば絡みの台帳に1行書く_author_keyは明示した値を使う(isolated_account_factory):
173
+ account = isolated_account_factory(production=True)
174
+ cfg = accounts_mod.load_account(account["name"])
175
+ spy = _SpyWithFetch()
176
+ digest = _digest_for(account["name"], "返信です", reply_to="R1")
177
+ r = core.send_once(
178
+ account["name"], text="返信です", reply_to="R1", reply_to_root="ROOT1",
179
+ reply_to_author_key="a" * 16, found_by="manual",
180
+ production_flag=True, confirm=digest, adapter_factory=lambda *_: spy)
181
+ assert r.exit_code == 0 and r.post_id == "ENG1"
182
+
183
+ # 明示した author_key があるので fetch_post は best-effort でも叩かない。
184
+ assert spy.fetch_calls == []
185
+
186
+ rows = engagements_mod.records(cfg, account["name"])
187
+ assert len(rows) == 1, rows
188
+ row = rows[0]
189
+ assert row["post_id"] == "ENG1" and row["reply_to"] == "R1"
190
+ assert row["root_post"] == "ROOT1"
191
+ assert row["author_key"] == "a" * 16
192
+ assert row["found_by"] == "manual"
193
+ assert row["account"] == account["name"] and row["medium"] == "threads"
194
+
195
+
196
+ def test_reply_to_author_keyが無ければfetch_postでbesteffortに埋める(isolated_account_factory):
197
+ account = isolated_account_factory(production=True)
198
+ cfg = accounts_mod.load_account(account["name"])
199
+ expected_key = adapter_base.author_key("threads", "someone")
200
+ spy = _SpyWithFetch(fetch_row={"message_id": "R1", "username": "someone",
201
+ "author_key": expected_key})
202
+ digest = _digest_for(account["name"], "返信です", reply_to="R1")
203
+ r = core.send_once(
204
+ account["name"], text="返信です", reply_to="R1",
205
+ production_flag=True, confirm=digest, adapter_factory=lambda *_: spy)
206
+ assert r.exit_code == 0
207
+ assert spy.fetch_calls == ["R1"] # 1 回だけ叩く
208
+
209
+ rows = engagements_mod.records(cfg, account["name"])
210
+ assert len(rows) == 1
211
+ assert rows[0]["author_key"] == expected_key
212
+ # best-effort が成功しているので、runs に失敗の印は立たない。
213
+ state_dir = accounts_mod.state_dir_for(account["name"])
214
+ run_rows = [r for r in runs_mod.read_runs(state_dir) if r.get("post_id") == "ENG1"]
215
+ assert len(run_rows) == 1
216
+ assert run_rows[0]["engagement_write_failed"] is False
217
+ assert run_rows[0]["engagement_author_lookup_failed"] is False
218
+
219
+
220
+ def test_fetch_postが失敗してもauthor_keyがnullになるだけで公開は成功する(isolated_account_factory):
221
+ account = isolated_account_factory(production=True)
222
+ cfg = accounts_mod.load_account(account["name"])
223
+ spy = _SpyWithFetch(fetch_error=adapter_base.AdapterError("投稿の取得: HTTP 400 不明"))
224
+ digest = _digest_for(account["name"], "返信です", reply_to="R1")
225
+ r = core.send_once(
226
+ account["name"], text="返信です", reply_to="R1",
227
+ production_flag=True, confirm=digest, adapter_factory=lambda *_: spy)
228
+ assert r.exit_code == 0, "author_key が埋まらなくても公開は止めない"
229
+ assert spy.fetch_calls == ["R1"]
230
+
231
+ rows = engagements_mod.records(cfg, account["name"])
232
+ assert len(rows) == 1
233
+ assert rows[0]["author_key"] is None # 「見当たらない」であって公開の失敗ではない
234
+
235
+ state_dir = accounts_mod.state_dir_for(account["name"])
236
+ run_rows = [r for r in runs_mod.read_runs(state_dir) if r.get("post_id") == "ENG1"]
237
+ assert len(run_rows) == 1
238
+ assert run_rows[0]["status"] == "ok"
239
+ assert run_rows[0]["engagement_write_failed"] is False
240
+ assert run_rows[0]["engagement_author_lookup_failed"] is True
241
+
242
+
243
+ def test_reply_toが無ければ絡みの台帳には何も書かない(isolated_account_factory):
244
+ account = isolated_account_factory(production=True)
245
+ cfg = accounts_mod.load_account(account["name"])
246
+ spy = _SpyWithFetch()
247
+ digest = _digest_for(account["name"], "独り言です")
248
+ r = core.send_once(account["name"], text="独り言です",
249
+ production_flag=True, confirm=digest, adapter_factory=lambda *_: spy)
250
+ assert r.exit_code == 0
251
+ assert spy.fetch_calls == []
252
+ assert engagements_mod.records(cfg, account["name"]) == []
@@ -7,6 +7,7 @@
7
7
  """
8
8
  from __future__ import annotations
9
9
 
10
+ import json
10
11
  import re
11
12
 
12
13
  from tests.conftest import run_thth
@@ -58,3 +59,52 @@ def test_違うconfirmの本番は拒否される(isolated_account_factory, tmp_
58
59
  ["send", account["name"], "--text-file", str(text_path),
59
60
  "--production", "--confirm", "0" * 12], env=env)
60
61
  assert prod.returncode == 1
62
+
63
+
64
+ # --- T7-2: `send --reply-to` が絡みの台帳に残り、`after`・`who` から拾える -------
65
+ # kopicha の実物の所見: `thth send --reply-to <id>` で出した返信が絡みの台帳に
66
+ # 残らず、`after --reply-to`・`who` の「→」が埋まらなかった。偽 Threads(POST
67
+ # だけの `fake_threads_server`)越しに実際に送り、ディスク上の台帳・`thth after`・
68
+ # `thth who` で拾えることを end-to-end で確かめる。
69
+
70
+ REPLY_TO_ID = "R1"
71
+ REPLY_ROOT_ID = "ROOT1"
72
+ REPLY_AUTHOR_KEY = "b" * 16
73
+
74
+
75
+ def test_replyToで送るとafterとwhoで拾える(isolated_account_factory, tmp_path):
76
+ account = isolated_account_factory(production=True)
77
+ text_path = tmp_path / "body.txt"
78
+ text_path.write_text("お返事です\n", encoding="utf-8")
79
+
80
+ dry = run_thth(["send", account["name"], "--text-file", str(text_path),
81
+ "--reply-to", REPLY_TO_ID])
82
+ assert dry.returncode == 0
83
+ m = re.search(r"digest: (\S+)", dry.stdout)
84
+ assert m is not None
85
+ digest = m.group(1)
86
+
87
+ with fake_threads_server({"create": "ok", "publish": "ok"}) as base_url:
88
+ env = {"THTH_THREADS_BASE_URL": base_url, "THTH_THREADS_WAIT_SECONDS": "0"}
89
+ prod = run_thth(
90
+ ["send", account["name"], "--text-file", str(text_path),
91
+ "--reply-to", REPLY_TO_ID, "--reply-to-root", REPLY_ROOT_ID,
92
+ "--reply-to-author-key", REPLY_AUTHOR_KEY, "--found-by", "manual",
93
+ "--production", "--confirm", digest], env=env)
94
+ assert prod.returncode == 0, prod.stdout + prod.stderr
95
+ assert "投稿しました" in prod.stdout
96
+
97
+ after = run_thth(["after", account["name"], "--reply-to", REPLY_TO_ID, "--json"])
98
+ assert after.returncode == 0, after.stdout + after.stderr
99
+ payload = json.loads(after.stdout)
100
+ assert payload["engagements"]["n"] == 1
101
+ branch = payload["engagements"]["by_branch"][0]
102
+ assert branch["reply_to"] == REPLY_TO_ID
103
+ assert branch["root"] == REPLY_ROOT_ID
104
+ assert branch["author_key"] == REPLY_AUTHOR_KEY
105
+
106
+ who = run_thth(["who", account["name"], REPLY_AUTHOR_KEY, "--json"])
107
+ assert who.returncode == 0, who.stdout + who.stderr
108
+ who_payload = json.loads(who.stdout)
109
+ roles = [t["role"] for t in who_payload["threads"]]
110
+ assert "i_replied_to_them" in roles, who_payload
@@ -185,13 +185,67 @@ def test_already_repliedは絡みの台帳とqueueの両方から引く(bsky_acc
185
185
  result = json.loads(r.stdout)
186
186
  by_id = {m["message_id"]: m for m in result["messages"]}
187
187
 
188
+ # T7-3: object には `source`(ledger/queue/thread のどこで見つかったか)が付く。
188
189
  assert by_id[R1_URI]["already_replied"] == {
189
190
  "post_id": "at://" + DID + "/app.bsky.feed.post/myreply1",
190
- "at": "2026-09-16T05:00:00+09:00"}
191
- assert by_id[R4_URI]["already_replied"] == {"status": "draft"}
192
- # まだ絡んでいない相手には無い。
191
+ "at": "2026-09-16T05:00:00+09:00", "source": "ledger"}
192
+ assert by_id[R4_URI]["already_replied"] == {"status": "draft", "source": "queue"}
193
+ # まだ絡んでいない相手には`False`(台帳・queueは両方読めているので
194
+ # 「見当たらない」と言い切れる・「返していない」の確定ではない・T7-3)。
195
+ assert by_id[R3_URI]["already_replied"] is False
196
+ assert by_id[R5_URI]["already_replied"] is False
197
+
198
+
199
+ # --- T7-3: already_replied を枝の中の自分の返信からも埋める(3 値の意味) -----
200
+ # 設計「自分の泉」§2.1・T7-3 発注書「その枝に is_own: true の返信があり、
201
+ # その replied_to がこの message なら、台帳に無くても already_replied を
202
+ # 埋める」。`false`=見当たらない(返していない、の確定ではない)/
203
+ # `null`=台帳か queue が読めず判らない、の 2 つも別々に確かめる。
204
+
205
+
206
+ def test_台帳queueに無くても枝の中の自分の返信からalready_repliedが埋まる(bsky_account):
207
+ account, service = bsky_account
208
+ # 台帳にも queue にも何も無い状態(このテストは追加しない)。
209
+ r = _cli(account["name"], ROOT_URI, service=service)
210
+ assert r.returncode == 0, r.stdout + r.stderr
211
+ result = json.loads(r.stdout)
212
+ by_id = {m["message_id"]: m for m in result["messages"]}
213
+
214
+ # R2(自分)は R1(bob)への返信——台帳に無くても枝そのものから拾う。
215
+ assert by_id[R1_URI]["already_replied"] == {
216
+ "post_id": R2_URI, "at": "2026-09-16T02:00:00.000Z", "source": "thread"}
217
+ # R2・R3・R4・R5 には自分からの返信がぶら下がっていない
218
+ # ——台帳・queue も空なので`False`(見当たらない。「返していない」の確定ではない)。
219
+ assert by_id[R2_URI]["already_replied"] is False
220
+ assert by_id[R3_URI]["already_replied"] is False
221
+ assert by_id[R4_URI]["already_replied"] is False
222
+ assert by_id[R5_URI]["already_replied"] is False
223
+
224
+
225
+ def test_台帳が読めなければ見当たらなくてもFalseでなくNoneになる(bsky_account, tmp_path):
226
+ account, service = bsky_account
227
+ cfg = accounts_mod.load_account(account["name"])
228
+ eng_dir = accounts_mod.data_dirs(cfg, account["name"])["engagements"]
229
+ os.makedirs(eng_dir, exist_ok=True)
230
+ # 壊れた ndjson(JSON として読めない行)を 1 本置く——
231
+ # `engagements.load()` はこのファイルを `broken` として中身を捨てる
232
+ # (`thth/engagements.py::_read_ndjson()` と同じ流儀)。
233
+ with open(os.path.join(eng_dir, "2026-09.ndjson"), "w", encoding="utf-8") as f:
234
+ f.write("これは JSON ではありません\n")
235
+
236
+ r = _cli(account["name"], ROOT_URI, service=service)
237
+ assert r.returncode == 0, r.stdout + r.stderr
238
+ result = json.loads(r.stdout)
239
+ by_id = {m["message_id"]: m for m in result["messages"]}
240
+
241
+ # R1 は枝の中の自分の返信(R2)があるので、台帳が読めなくても object のまま
242
+ # (見つかったものを取り消さない)。
243
+ assert by_id[R1_URI]["already_replied"]["source"] == "thread"
244
+ # どこにも見当たらない行は、`False`(見当たらない)ではなく`None`
245
+ # (「台帳が読めないので判らない」・T7-3 規約)。
193
246
  assert by_id[R3_URI]["already_replied"] is None
194
247
  assert by_id[R5_URI]["already_replied"] is None
248
+ assert any("絡みの台帳" in reason for reason in result["provenance"]["ledgers_unreadable"])
195
249
 
196
250
 
197
251
  def test_max_messagesを超えたら新しい側を切りcontinue_fromを返す(bsky_account):
@@ -268,6 +322,74 @@ def test_you_and_themにlast_reactionがある(bsky_account):
268
322
  assert you_and_them[dave_key]["last_reaction"] is None
269
323
 
270
324
 
325
+ # --- T7-1: Threadsの偽サーバで枝を読む(conversation()の生の行をMessageの形に揃える) ---
326
+ # `ThreadsAdapter.conversation()`は生の行(`id`・`replied_to: {"id": …}`・
327
+ # `root_post: {"id": …}`)を返す(返信の台帳との互換のため adapter 自身は
328
+ # 変えない・設計「自分の泉」T7-1発注書)。`thread_read`は`normalize_message()`
329
+ # を通してから読むので、Threadsでも`message_id`・`depth`・`already_replied`が
330
+ # ちゃんと埋まることをここで確かめる(T1-2はBluesky・Mastodonの偽サーバだけで
331
+ # 枝を確かめていた・Threadsは400の経路だけだった、という所見の穴)。
332
+
333
+ C1_ID, C2_ID, C3_ID = "C1", "C2", "C3"
334
+
335
+
336
+ def _threads_conversation_rows():
337
+ """根(`OTHER_POST_ID`・alice)→ C1(bob)→ C2(nigamilab・自分)→ C3(carol)。"""
338
+ return [
339
+ {"id": C1_ID, "username": "bob", "text": "bobの返信",
340
+ "timestamp": "2026-09-16T01:00:00+0000",
341
+ "replied_to": {"id": OTHER_POST_ID}, "root_post": {"id": OTHER_POST_ID},
342
+ "permalink": "https://t/c1", "has_replies": True, "is_reply": True},
343
+ {"id": C2_ID, "username": "nigamilab", "text": "自分の返信",
344
+ "timestamp": "2026-09-16T02:00:00+0000",
345
+ "replied_to": {"id": C1_ID}, "root_post": {"id": OTHER_POST_ID},
346
+ "permalink": "https://t/c2", "has_replies": True, "is_reply": True},
347
+ {"id": C3_ID, "username": "carol", "text": "孫の返信",
348
+ "timestamp": "2026-09-16T03:00:00+0000",
349
+ "replied_to": {"id": C2_ID}, "root_post": {"id": OTHER_POST_ID},
350
+ "permalink": "https://t/c3", "has_replies": False, "is_reply": True},
351
+ ]
352
+
353
+
354
+ def test_Threadsの枝も根から時刻順でdepthとis_ownつきで返る(isolated_account_factory, tmp_path):
355
+ token_path = str(tmp_path / "threads.token")
356
+ _write_token(token_path, {"access_token": "FAKE-SECRET", "user_id": "999999",
357
+ "username": "nigamilab", "scopes": None,
358
+ "obtained_at": "2026-09-16T09:00:00+09:00"})
359
+ account = isolated_account_factory(
360
+ "nigamilab-threads-branch-test", media="threads", handle="nigamilab",
361
+ token=token_path, production=False)
362
+
363
+ with _server(conversation_rows=_threads_conversation_rows()) as (base_url, requests):
364
+ r = run_thth(["thread", account["name"], OTHER_POST_ID, "--json"],
365
+ env={"THTH_THREADS_BASE_URL": base_url})
366
+ assert r.returncode == 0, r.stdout + r.stderr
367
+ result = json.loads(r.stdout)
368
+
369
+ assert result["root"]["post_id"] == OTHER_POST_ID
370
+ assert result["root"]["username"] == "alice"
371
+
372
+ ids = [m["message_id"] for m in result["messages"]]
373
+ assert ids == [C1_ID, C2_ID, C3_ID], ids # Threadsの生の`id`から写った
374
+
375
+ by_id = {m["message_id"]: m for m in result["messages"]}
376
+ assert by_id[C1_ID]["depth"] == 1 and by_id[C1_ID]["replied_to"] == OTHER_POST_ID
377
+ assert by_id[C2_ID]["depth"] == 2 and by_id[C2_ID]["replied_to"] == C1_ID
378
+ assert by_id[C3_ID]["depth"] == 3 and by_id[C3_ID]["replied_to"] == C2_ID
379
+
380
+ # `replied_to`は`{"id": …}`のdictではなく、id文字列に開かれていること。
381
+ for m in result["messages"]:
382
+ assert isinstance(m["replied_to"], str)
383
+
384
+ assert by_id[C2_ID]["is_own"] is True
385
+ assert by_id[C1_ID]["is_own"] is False
386
+ assert by_id[C3_ID]["is_own"] is False
387
+ assert by_id[C2_ID]["author_key"] is not None
388
+
389
+ counts = result["counts"]
390
+ assert counts["messages"] == 3 and counts["own"] == 1 and counts["participants"] == 4
391
+
392
+
271
393
  def test_Threadsで他人の根が400ならPermissionMissingのままrc1(isolated_account_factory, tmp_path):
272
394
  with _server({f"/{OTHER_POST_ID}": "permission"}) as (base_url, requests):
273
395
  token_path = str(tmp_path / "threads.token")
@@ -80,6 +80,8 @@ class _ReadOnlyThreads(http.server.BaseHTTPRequestHandler):
80
80
  """
81
81
  behavior: dict = {}
82
82
  requests: list = []
83
+ # `/conversation` が返す行(既定は空・T7-1 の枝テストで差し替える)。
84
+ conversation_rows: list = []
83
85
 
84
86
  def _json(self, status, payload):
85
87
  body = json.dumps(payload).encode("utf-8")
@@ -121,7 +123,7 @@ class _ReadOnlyThreads(http.server.BaseHTTPRequestHandler):
121
123
  if p.endswith("/insights"):
122
124
  return self._json(200, {"data": [{"name": "views", "values": [{"value": 7}]}]})
123
125
  if p.endswith("/conversation"):
124
- return self._json(200, {"data": []})
126
+ return self._json(200, {"data": self.conversation_rows})
125
127
  if p == f"/v1.0/{OTHER_POST_ID}":
126
128
  return self._json(200, OTHER_POST)
127
129
  return self._json(200, {"data": []})
@@ -138,10 +140,11 @@ class _ReadOnlyThreads(http.server.BaseHTTPRequestHandler):
138
140
 
139
141
 
140
142
  @contextlib.contextmanager
141
- def _server(behavior=None):
143
+ def _server(behavior=None, *, conversation_rows=None):
142
144
  requests: list = []
143
145
  handler = type("H", (_ReadOnlyThreads,), {"behavior": dict(behavior or {}),
144
- "requests": requests})
146
+ "requests": requests,
147
+ "conversation_rows": list(conversation_rows or [])})
145
148
  server = http.server.HTTPServer(("127.0.0.1", 0), handler)
146
149
  thread = threading.Thread(target=server.serve_forever, daemon=True)
147
150
  thread.start()
@@ -0,0 +1 @@
1
+ 2.3.1
@@ -158,6 +158,42 @@ def author_key(medium: str, username: str | None) -> str | None:
158
158
  return digest[:16]
159
159
 
160
160
 
161
+ def normalize_message(row: dict, *, medium: str) -> dict:
162
+ """媒体の生の行を`Message`の形(`message_id`・`replied_to`・`root_post`は
163
+ id の文字列)に揃える(T7-1・設計「自分の泉」§2.1)。
164
+
165
+ **`conversation()`自身の戻りは変えない**——`collect`・`threadshape`・
166
+ `replies`の台帳(ndjson)は媒体ごとの生の鍵(Threadsなら`id`・
167
+ `replied_to: {"id": …}`)をそのまま読むので、そこを揃えると既存の台帳の
168
+ 互換が壊れる(設計「自分の泉」T7-1発注書)。ここは`thread_read`など
169
+ 「読んで見せる」口が、adapterの生の行を使う直前にだけ通す変換。
170
+
171
+ やること(3 つ):
172
+ - `message_id`が無ければ`id`から写す(あれば触らない)。
173
+ - `replied_to`・`root_post`が dict(Threadsの`{"id": …}`)なら`.get("id")`
174
+ を、文字列ならそのまま、無ければ`None`のまま。
175
+ - `author_key`が無ければ`author_key(medium, username)`で埋める。
176
+
177
+ 他の鍵(`text`・`username`・`timestamp`・`permalink`・`has_replies`・
178
+ `is_reply`等)はそのまま素通しする。
179
+
180
+ **冪等**: 既に`Message`の形の行(Bluesky・Mastodonの`conversation()`・
181
+ Threadsの`_message_row()`・`fetch_post()`の戻り)を渡しても同じ行が返る
182
+ ——2 回通しても壊れない。
183
+ """
184
+ out = dict(row)
185
+ if not out.get("message_id"):
186
+ out["message_id"] = out.get("id")
187
+ for key in ("replied_to", "root_post"):
188
+ value = out.get(key)
189
+ if isinstance(value, dict):
190
+ out[key] = value.get("id")
191
+ # 文字列・None はそのまま(既に揃っている・またはそもそも無い)。
192
+ if not out.get("author_key"):
193
+ out["author_key"] = author_key(medium, out.get("username"))
194
+ return out
195
+
196
+
161
197
  @dataclasses.dataclass
162
198
  class Message:
163
199
  """会話の 1 行(設計 v2 §4.2。旧 `Reply` の改名)。
@@ -26,6 +26,7 @@ from . import where_cli as where_cli_mod
26
26
  from . import who_cli as who_cli_mod
27
27
  from . import collect as collect_mod
28
28
  from . import core
29
+ from . import engagements as engagements_mod
29
30
  from . import jst
30
31
  from . import lint as lint_mod
31
32
  from . import lock as lock_mod
@@ -2173,6 +2174,13 @@ def cmd_send(args) -> int:
2173
2174
  **`--confirm`**(外部レビュー §1b・受け入れ 6): dry-run(`--production` を
2174
2175
  付けない実行)が表示する短い digest を、`--production` のときに
2175
2176
  `--confirm <digest>` として渡す。省略・不一致はどちらも送らない。
2177
+
2178
+ **`--reply-to-author-key`・`--reply-to-root`・`--found-by`**(T7-2・設計
2179
+ 「自分の泉」§4): `--reply-to` で返信として出すとき、絡みの台帳(誰に・
2180
+ どの枝へ絡みに行ったか)に残す任意項目。queue の front-matter
2181
+ `reply_to_author_key`・`reply_to_root`・`found_by` と同じ意味。
2182
+ `--reply-to-author-key` を省略すると、`reply_to` の投稿を 1 回
2183
+ best-effort に読みに行って埋める(失敗しても送信は止めない)。
2176
2184
  """
2177
2185
  import sys as _sys
2178
2186
  from . import core as core_mod
@@ -2183,6 +2191,8 @@ def cmd_send(args) -> int:
2183
2191
  text = _sys.stdin.read()
2184
2192
  result = core_mod.send_once(
2185
2193
  args.account, text=text, topic=args.topic, reply_to=args.reply_to,
2194
+ reply_to_root=args.reply_to_root, reply_to_author_key=args.reply_to_author_key,
2195
+ found_by=args.found_by,
2186
2196
  production_flag=args.production, confirm=args.confirm, log=print)
2187
2197
  return result.exit_code
2188
2198
 
@@ -2709,6 +2719,16 @@ def build_parser() -> argparse.ArgumentParser:
2709
2719
  help="本文のファイル。省略時は標準入力から読む")
2710
2720
  p_send.add_argument("--topic", default=None)
2711
2721
  p_send.add_argument("--reply-to", dest="reply_to", default=None)
2722
+ # T7-2(設計「自分の泉」§4): queue の front-matter と同じ意味の 3 つ。
2723
+ # `--reply-to` があるときだけ絡みの台帳に 1 行残る。
2724
+ p_send.add_argument("--reply-to-author-key", dest="reply_to_author_key", default=None,
2725
+ help="相手の仮名(16 進 16 桁)。省略すると reply_to の投稿を"
2726
+ "best-effort に読みに行って埋める(失敗しても送信は止めない)")
2727
+ p_send.add_argument("--reply-to-root", dest="reply_to_root", default=None,
2728
+ help="この返信がぶら下がる枝の根の post_id(判れば)")
2729
+ p_send.add_argument("--found-by", dest="found_by", default=None,
2730
+ choices=sorted(engagements_mod.FOUND_BY_VALUES),
2731
+ help="絡みに行った先をどう見つけたか(where_to_appear/manual/mention)")
2712
2732
  p_send.add_argument("--production", action="store_true",
2713
2733
  help="本番で出す(台帳 production: true が無ければ dry-run のまま)")
2714
2734
  p_send.add_argument("--confirm", default=None,