thth 2.3.0__tar.gz → 2.3.2__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 (253) hide show
  1. {thth-2.3.0 → thth-2.3.2}/PKG-INFO +3 -3
  2. {thth-2.3.0 → thth-2.3.2}/README.md +2 -2
  3. {thth-2.3.0 → thth-2.3.2}/mcp/server.py +11 -1
  4. {thth-2.3.0 → thth-2.3.2}/tests/test_adapter_boundary.py +49 -0
  5. thth-2.3.2/tests/test_behind_remote.py +137 -0
  6. thth-2.3.2/tests/test_cli_vm_path.py +80 -0
  7. {thth-2.3.0 → thth-2.3.2}/tests/test_engagements.py +41 -0
  8. thth-2.3.2/tests/test_pull_cli.py +96 -0
  9. {thth-2.3.0 → thth-2.3.2}/tests/test_send.py +114 -0
  10. {thth-2.3.0 → thth-2.3.2}/tests/test_send_cli.py +50 -0
  11. {thth-2.3.0 → thth-2.3.2}/tests/test_thread_read.py +125 -3
  12. {thth-2.3.0 → thth-2.3.2}/tests/test_threads_read_permissions.py +6 -3
  13. thth-2.3.2/thth/VERSION +1 -0
  14. {thth-2.3.0 → thth-2.3.2}/thth/adapters/base.py +36 -0
  15. {thth-2.3.0 → thth-2.3.2}/thth/cli.py +289 -1
  16. {thth-2.3.0 → thth-2.3.2}/thth/core.py +100 -25
  17. {thth-2.3.0 → thth-2.3.2}/thth/runs.py +9 -1
  18. {thth-2.3.0 → thth-2.3.2}/thth/thread_read.py +80 -9
  19. {thth-2.3.0 → thth-2.3.2}/thth/topic_cli.py +24 -0
  20. {thth-2.3.0 → thth-2.3.2}/thth/writeback.py +66 -0
  21. thth-2.3.0/thth/VERSION +0 -1
  22. {thth-2.3.0 → thth-2.3.2}/.gitignore +0 -0
  23. {thth-2.3.0 → thth-2.3.2}/LICENSE +0 -0
  24. {thth-2.3.0 → thth-2.3.2}/accounts.example/bluesky.json +0 -0
  25. {thth-2.3.0 → thth-2.3.2}/accounts.example/mastodon.json +0 -0
  26. {thth-2.3.0 → thth-2.3.2}/accounts.example/threads.json +0 -0
  27. {thth-2.3.0 → thth-2.3.2}/bin/thth +0 -0
  28. {thth-2.3.0 → thth-2.3.2}/bin/thth-remote +0 -0
  29. {thth-2.3.0 → thth-2.3.2}/bin/thth-run +0 -0
  30. {thth-2.3.0 → thth-2.3.2}/pyproject.toml +0 -0
  31. {thth-2.3.0 → thth-2.3.2}/skills/thth/SKILL.md +0 -0
  32. {thth-2.3.0 → thth-2.3.2}/systemd/thth@.service +0 -0
  33. {thth-2.3.0 → thth-2.3.2}/tests/__init__.py +0 -0
  34. {thth-2.3.0 → thth-2.3.2}/tests/conftest.py +0 -0
  35. {thth-2.3.0 → thth-2.3.2}/tests/fixtures/build_injection_eval.py +0 -0
  36. {thth-2.3.0 → thth-2.3.2}/tests/fixtures/callback/worker_responses.json +0 -0
  37. {thth-2.3.0 → thth-2.3.2}/tests/fixtures/injection_eval.json +0 -0
  38. {thth-2.3.0 → thth-2.3.2}/tests/fixtures/umami-bile.md +0 -0
  39. {thth-2.3.0 → thth-2.3.2}/tests/helpers/__init__.py +0 -0
  40. {thth-2.3.0 → thth-2.3.2}/tests/helpers/fake_oauth_server.py +0 -0
  41. {thth-2.3.0 → thth-2.3.2}/tests/helpers/fake_push_adapter.py +0 -0
  42. {thth-2.3.0 → thth-2.3.2}/tests/helpers/fake_redirect_server.py +0 -0
  43. {thth-2.3.0 → thth-2.3.2}/tests/helpers/fake_search_adapter.py +0 -0
  44. {thth-2.3.0 → thth-2.3.2}/tests/helpers/fake_threads_reader.py +0 -0
  45. {thth-2.3.0 → thth-2.3.2}/tests/helpers/fake_threads_writes.py +0 -0
  46. {thth-2.3.0 → thth-2.3.2}/tests/helpers/hold_lock.py +0 -0
  47. {thth-2.3.0 → thth-2.3.2}/tests/helpers/hold_repo_lock.py +0 -0
  48. {thth-2.3.0 → thth-2.3.2}/tests/helpers/worker_probe.mjs +0 -0
  49. {thth-2.3.0 → thth-2.3.2}/tests/test_account_name_check.py +0 -0
  50. {thth-2.3.0 → thth-2.3.2}/tests/test_account_report.py +0 -0
  51. {thth-2.3.0 → thth-2.3.2}/tests/test_accounts_dir.py +0 -0
  52. {thth-2.3.0 → thth-2.3.2}/tests/test_after_cli.py +0 -0
  53. {thth-2.3.0 → thth-2.3.2}/tests/test_api_error_body.py +0 -0
  54. {thth-2.3.0 → thth-2.3.2}/tests/test_app_env_cli.py +0 -0
  55. {thth-2.3.0 → thth-2.3.2}/tests/test_approval.py +0 -0
  56. {thth-2.3.0 → thth-2.3.2}/tests/test_approve_cmd.py +0 -0
  57. {thth-2.3.0 → thth-2.3.2}/tests/test_ask.py +0 -0
  58. {thth-2.3.0 → thth-2.3.2}/tests/test_atlas_fifth_review.py +0 -0
  59. {thth-2.3.0 → thth-2.3.2}/tests/test_atlas_fourth_review.py +0 -0
  60. {thth-2.3.0 → thth-2.3.2}/tests/test_atlas_review.py +0 -0
  61. {thth-2.3.0 → thth-2.3.2}/tests/test_atlas_second_review.py +0 -0
  62. {thth-2.3.0 → thth-2.3.2}/tests/test_atlas_third_review.py +0 -0
  63. {thth-2.3.0 → thth-2.3.2}/tests/test_audit1_fixes.py +0 -0
  64. {thth-2.3.0 → thth-2.3.2}/tests/test_audit2_fixes.py +0 -0
  65. {thth-2.3.0 → thth-2.3.2}/tests/test_audit_b_counterexamples.py +0 -0
  66. {thth-2.3.0 → thth-2.3.2}/tests/test_audit_b_m1_semantics.py +0 -0
  67. {thth-2.3.0 → thth-2.3.2}/tests/test_audit_b_n1_depth.py +0 -0
  68. {thth-2.3.0 → thth-2.3.2}/tests/test_audit_b_r1_closure.py +0 -0
  69. {thth-2.3.0 → thth-2.3.2}/tests/test_audit_v201.py +0 -0
  70. {thth-2.3.0 → thth-2.3.2}/tests/test_audit_v23_details.py +0 -0
  71. {thth-2.3.0 → thth-2.3.2}/tests/test_auth.py +0 -0
  72. {thth-2.3.0 → thth-2.3.2}/tests/test_auth_identity_required.py +0 -0
  73. {thth-2.3.0 → thth-2.3.2}/tests/test_bluesky_adapter.py +0 -0
  74. {thth-2.3.0 → thth-2.3.2}/tests/test_bluesky_secret_scrub.py +0 -0
  75. {thth-2.3.0 → thth-2.3.2}/tests/test_board_needs_review.py +0 -0
  76. {thth-2.3.0 → thth-2.3.2}/tests/test_board_running.py +0 -0
  77. {thth-2.3.0 → thth-2.3.2}/tests/test_bundle.py +0 -0
  78. {thth-2.3.0 → thth-2.3.2}/tests/test_charcount.py +0 -0
  79. {thth-2.3.0 → thth-2.3.2}/tests/test_cli_entrypoint.py +0 -0
  80. {thth-2.3.0 → thth-2.3.2}/tests/test_cli_help.py +0 -0
  81. {thth-2.3.0 → thth-2.3.2}/tests/test_collect.py +0 -0
  82. {thth-2.3.0 → thth-2.3.2}/tests/test_collect_runs.py +0 -0
  83. {thth-2.3.0 → thth-2.3.2}/tests/test_collect_sent.py +0 -0
  84. {thth-2.3.0 → thth-2.3.2}/tests/test_comparability.py +0 -0
  85. {thth-2.3.0 → thth-2.3.2}/tests/test_control_chars_refused.py +0 -0
  86. {thth-2.3.0 → thth-2.3.2}/tests/test_conversation_paging.py +0 -0
  87. {thth-2.3.0 → thth-2.3.2}/tests/test_count_for.py +0 -0
  88. {thth-2.3.0 → thth-2.3.2}/tests/test_docs_links.py +0 -0
  89. {thth-2.3.0 → thth-2.3.2}/tests/test_doctor.py +0 -0
  90. {thth-2.3.0 → thth-2.3.2}/tests/test_doctor_all_permissions.py +0 -0
  91. {thth-2.3.0 → thth-2.3.2}/tests/test_doctor_next_step.py +0 -0
  92. {thth-2.3.0 → thth-2.3.2}/tests/test_duplicate_front_matter_keys.py +0 -0
  93. {thth-2.3.0 → thth-2.3.2}/tests/test_editorial_audit.py +0 -0
  94. {thth-2.3.0 → thth-2.3.2}/tests/test_editorial_n3_n4.py +0 -0
  95. {thth-2.3.0 → thth-2.3.2}/tests/test_external_acceptance.py +0 -0
  96. {thth-2.3.0 → thth-2.3.2}/tests/test_fake_api.py +0 -0
  97. {thth-2.3.0 → thth-2.3.2}/tests/test_form_spec.py +0 -0
  98. {thth-2.3.0 → thth-2.3.2}/tests/test_form_spec_cli.py +0 -0
  99. {thth-2.3.0 → thth-2.3.2}/tests/test_forms.py +0 -0
  100. {thth-2.3.0 → thth-2.3.2}/tests/test_fresh_install.py +0 -0
  101. {thth-2.3.0 → thth-2.3.2}/tests/test_front_matter_injection.py +0 -0
  102. {thth-2.3.0 → thth-2.3.2}/tests/test_hypotheses.py +0 -0
  103. {thth-2.3.0 → thth-2.3.2}/tests/test_hypothesis_boundaries.py +0 -0
  104. {thth-2.3.0 → thth-2.3.2}/tests/test_hypothesis_units.py +0 -0
  105. {thth-2.3.0 → thth-2.3.2}/tests/test_impact_cli.py +0 -0
  106. {thth-2.3.0 → thth-2.3.2}/tests/test_inbox_granted_scopes.py +0 -0
  107. {thth-2.3.0 → thth-2.3.2}/tests/test_independent_thread_review.py +0 -0
  108. {thth-2.3.0 → thth-2.3.2}/tests/test_independent_topic_review.py +0 -0
  109. {thth-2.3.0 → thth-2.3.2}/tests/test_independent_topic_review_r2.py +0 -0
  110. {thth-2.3.0 → thth-2.3.2}/tests/test_inflight.py +0 -0
  111. {thth-2.3.0 → thth-2.3.2}/tests/test_jst.py +0 -0
  112. {thth-2.3.0 → thth-2.3.2}/tests/test_lint.py +0 -0
  113. {thth-2.3.0 → thth-2.3.2}/tests/test_list_projections.py +0 -0
  114. {thth-2.3.0 → thth-2.3.2}/tests/test_llm_trial.py +0 -0
  115. {thth-2.3.0 → thth-2.3.2}/tests/test_llm_trial_engage.py +0 -0
  116. {thth-2.3.0 → thth-2.3.2}/tests/test_lock.py +0 -0
  117. {thth-2.3.0 → thth-2.3.2}/tests/test_m2_independent.py +0 -0
  118. {thth-2.3.0 → thth-2.3.2}/tests/test_maintain.py +0 -0
  119. {thth-2.3.0 → thth-2.3.2}/tests/test_mastodon_adapter.py +0 -0
  120. {thth-2.3.0 → thth-2.3.2}/tests/test_mastodon_visibility.py +0 -0
  121. {thth-2.3.0 → thth-2.3.2}/tests/test_max_per_run.py +0 -0
  122. {thth-2.3.0 → thth-2.3.2}/tests/test_mcp.py +0 -0
  123. {thth-2.3.0 → thth-2.3.2}/tests/test_mcp_robustness.py +0 -0
  124. {thth-2.3.0 → thth-2.3.2}/tests/test_measured.py +0 -0
  125. {thth-2.3.0 → thth-2.3.2}/tests/test_media_accounts.py +0 -0
  126. {thth-2.3.0 → thth-2.3.2}/tests/test_media_next_steps.py +0 -0
  127. {thth-2.3.0 → thth-2.3.2}/tests/test_media_roundtrip.py +0 -0
  128. {thth-2.3.0 → thth-2.3.2}/tests/test_mismatch_diagnostics.py +0 -0
  129. {thth-2.3.0 → thth-2.3.2}/tests/test_n5_reply_null_element.py +0 -0
  130. {thth-2.3.0 → thth-2.3.2}/tests/test_n6_replies_incomplete_ledger.py +0 -0
  131. {thth-2.3.0 → thth-2.3.2}/tests/test_n7_doctor_error_body.py +0 -0
  132. {thth-2.3.0 → thth-2.3.2}/tests/test_no_limits.py +0 -0
  133. {thth-2.3.0 → thth-2.3.2}/tests/test_observation_provenance.py +0 -0
  134. {thth-2.3.0 → thth-2.3.2}/tests/test_packaging.py +0 -0
  135. {thth-2.3.0 → thth-2.3.2}/tests/test_preview.py +0 -0
  136. {thth-2.3.0 → thth-2.3.2}/tests/test_production_mode.py +0 -0
  137. {thth-2.3.0 → thth-2.3.2}/tests/test_queue.py +0 -0
  138. {thth-2.3.0 → thth-2.3.2}/tests/test_quiet_hours.py +0 -0
  139. {thth-2.3.0 → thth-2.3.2}/tests/test_r2_r4_r5.py +0 -0
  140. {thth-2.3.0 → thth-2.3.2}/tests/test_reason_adoption.py +0 -0
  141. {thth-2.3.0 → thth-2.3.2}/tests/test_redact.py +0 -0
  142. {thth-2.3.0 → thth-2.3.2}/tests/test_redirect_token_leak.py +0 -0
  143. {thth-2.3.0 → thth-2.3.2}/tests/test_refresh.py +0 -0
  144. {thth-2.3.0 → thth-2.3.2}/tests/test_refresh_destination.py +0 -0
  145. {thth-2.3.0 → thth-2.3.2}/tests/test_rejudge_new.py +0 -0
  146. {thth-2.3.0 → thth-2.3.2}/tests/test_rejudge_r2_r3.py +0 -0
  147. {thth-2.3.0 → thth-2.3.2}/tests/test_replies.py +0 -0
  148. {thth-2.3.0 → thth-2.3.2}/tests/test_replies_refresh.py +0 -0
  149. {thth-2.3.0 → thth-2.3.2}/tests/test_repo_lock.py +0 -0
  150. {thth-2.3.0 → thth-2.3.2}/tests/test_repo_sync.py +0 -0
  151. {thth-2.3.0 → thth-2.3.2}/tests/test_reverse_audit.py +0 -0
  152. {thth-2.3.0 → thth-2.3.2}/tests/test_review_case_id_cluster_m2.py +0 -0
  153. {thth-2.3.0 → thth-2.3.2}/tests/test_review_case_id_cluster_r2.py +0 -0
  154. {thth-2.3.0 → thth-2.3.2}/tests/test_review_cli.py +0 -0
  155. {thth-2.3.0 → thth-2.3.2}/tests/test_review_evidence_account.py +0 -0
  156. {thth-2.3.0 → thth-2.3.2}/tests/test_review_form_spec_and_case_id.py +0 -0
  157. {thth-2.3.0 → thth-2.3.2}/tests/test_review_provenance.py +0 -0
  158. {thth-2.3.0 → thth-2.3.2}/tests/test_review_records.py +0 -0
  159. {thth-2.3.0 → thth-2.3.2}/tests/test_revoke.py +0 -0
  160. {thth-2.3.0 → thth-2.3.2}/tests/test_roundtrip.py +0 -0
  161. {thth-2.3.0 → thth-2.3.2}/tests/test_run_missing_env.py +0 -0
  162. {thth-2.3.0 → thth-2.3.2}/tests/test_run_record_failclosed.py +0 -0
  163. {thth-2.3.0 → thth-2.3.2}/tests/test_runs_collected.py +0 -0
  164. {thth-2.3.0 → thth-2.3.2}/tests/test_runs_read_rows.py +0 -0
  165. {thth-2.3.0 → thth-2.3.2}/tests/test_schedule_multiple.py +0 -0
  166. {thth-2.3.0 → thth-2.3.2}/tests/test_schedule_view.py +0 -0
  167. {thth-2.3.0 → thth-2.3.2}/tests/test_scopes_source_requested.py +0 -0
  168. {thth-2.3.0 → thth-2.3.2}/tests/test_search_engage_targets.py +0 -0
  169. {thth-2.3.0 → thth-2.3.2}/tests/test_secret_value_redact.py +0 -0
  170. {thth-2.3.0 → thth-2.3.2}/tests/test_secrets_grep.py +0 -0
  171. {thth-2.3.0 → thth-2.3.2}/tests/test_security_small_fixes.py +0 -0
  172. {thth-2.3.0 → thth-2.3.2}/tests/test_select.py +0 -0
  173. {thth-2.3.0 → thth-2.3.2}/tests/test_selfupdate.py +0 -0
  174. {thth-2.3.0 → thth-2.3.2}/tests/test_send_media.py +0 -0
  175. {thth-2.3.0 → thth-2.3.2}/tests/test_send_visibility.py +0 -0
  176. {thth-2.3.0 → thth-2.3.2}/tests/test_sent_integrity.py +0 -0
  177. {thth-2.3.0 → thth-2.3.2}/tests/test_server_json.py +0 -0
  178. {thth-2.3.0 → thth-2.3.2}/tests/test_site.py +0 -0
  179. {thth-2.3.0 → thth-2.3.2}/tests/test_skill_doc.py +0 -0
  180. {thth-2.3.0 → thth-2.3.2}/tests/test_systemd.py +0 -0
  181. {thth-2.3.0 → thth-2.3.2}/tests/test_thread_publish.py +0 -0
  182. {thth-2.3.0 → thth-2.3.2}/tests/test_threadrun.py +0 -0
  183. {thth-2.3.0 → thth-2.3.2}/tests/test_threadshape.py +0 -0
  184. {thth-2.3.0 → thth-2.3.2}/tests/test_thth_root.py +0 -0
  185. {thth-2.3.0 → thth-2.3.2}/tests/test_token_exists.py +0 -0
  186. {thth-2.3.0 → thth-2.3.2}/tests/test_token_scopes_record.py +0 -0
  187. {thth-2.3.0 → thth-2.3.2}/tests/test_token_set.py +0 -0
  188. {thth-2.3.0 → thth-2.3.2}/tests/test_topic.py +0 -0
  189. {thth-2.3.0 → thth-2.3.2}/tests/test_topic_advice.py +0 -0
  190. {thth-2.3.0 → thth-2.3.2}/tests/test_topic_cli.py +0 -0
  191. {thth-2.3.0 → thth-2.3.2}/tests/test_topic_models.py +0 -0
  192. {thth-2.3.0 → thth-2.3.2}/tests/test_topic_review_fixes.py +0 -0
  193. {thth-2.3.0 → thth-2.3.2}/tests/test_topic_shelf_observers.py +0 -0
  194. {thth-2.3.0 → thth-2.3.2}/tests/test_topic_store.py +0 -0
  195. {thth-2.3.0 → thth-2.3.2}/tests/test_topics_record_concurrent.py +0 -0
  196. {thth-2.3.0 → thth-2.3.2}/tests/test_topics_tool.py +0 -0
  197. {thth-2.3.0 → thth-2.3.2}/tests/test_trial_frictions.py +0 -0
  198. {thth-2.3.0 → thth-2.3.2}/tests/test_v21b_approved_writes.py +0 -0
  199. {thth-2.3.0 → thth-2.3.2}/tests/test_value_domains_shown.py +0 -0
  200. {thth-2.3.0 → thth-2.3.2}/tests/test_version.py +0 -0
  201. {thth-2.3.0 → thth-2.3.2}/tests/test_where_cli.py +0 -0
  202. {thth-2.3.0 → thth-2.3.2}/tests/test_who_cli.py +0 -0
  203. {thth-2.3.0 → thth-2.3.2}/tests/test_writeback_rebase.py +0 -0
  204. {thth-2.3.0 → thth-2.3.2}/tests/test_writeback_validate.py +0 -0
  205. {thth-2.3.0 → thth-2.3.2}/thth/__init__.py +0 -0
  206. {thth-2.3.0 → thth-2.3.2}/thth/__main__.py +0 -0
  207. {thth-2.3.0 → thth-2.3.2}/thth/account_cli.py +0 -0
  208. {thth-2.3.0 → thth-2.3.2}/thth/account_report.py +0 -0
  209. {thth-2.3.0 → thth-2.3.2}/thth/accounts.py +0 -0
  210. {thth-2.3.0 → thth-2.3.2}/thth/adapters/__init__.py +0 -0
  211. {thth-2.3.0 → thth-2.3.2}/thth/adapters/bluesky.py +0 -0
  212. {thth-2.3.0 → thth-2.3.2}/thth/adapters/mastodon.py +0 -0
  213. {thth-2.3.0 → thth-2.3.2}/thth/adapters/threads.py +0 -0
  214. {thth-2.3.0 → thth-2.3.2}/thth/after_cli.py +0 -0
  215. {thth-2.3.0 → thth-2.3.2}/thth/appenv.py +0 -0
  216. {thth-2.3.0 → thth-2.3.2}/thth/approval.py +0 -0
  217. {thth-2.3.0 → thth-2.3.2}/thth/ask.py +0 -0
  218. {thth-2.3.0 → thth-2.3.2}/thth/ask_cli.py +0 -0
  219. {thth-2.3.0 → thth-2.3.2}/thth/bundle.py +0 -0
  220. {thth-2.3.0 → thth-2.3.2}/thth/collect.py +0 -0
  221. {thth-2.3.0 → thth-2.3.2}/thth/doctor.py +0 -0
  222. {thth-2.3.0 → thth-2.3.2}/thth/engagements.py +0 -0
  223. {thth-2.3.0 → thth-2.3.2}/thth/forms.py +0 -0
  224. {thth-2.3.0 → thth-2.3.2}/thth/httpsafe.py +0 -0
  225. {thth-2.3.0 → thth-2.3.2}/thth/inflight.py +0 -0
  226. {thth-2.3.0 → thth-2.3.2}/thth/jst.py +0 -0
  227. {thth-2.3.0 → thth-2.3.2}/thth/lint.py +0 -0
  228. {thth-2.3.0 → thth-2.3.2}/thth/lock.py +0 -0
  229. {thth-2.3.0 → thth-2.3.2}/thth/maintain.py +0 -0
  230. {thth-2.3.0 → thth-2.3.2}/thth/measured.py +0 -0
  231. {thth-2.3.0 → thth-2.3.2}/thth/oauth.py +0 -0
  232. {thth-2.3.0 → thth-2.3.2}/thth/postid.py +0 -0
  233. {thth-2.3.0 → thth-2.3.2}/thth/queuefile.py +0 -0
  234. {thth-2.3.0 → thth-2.3.2}/thth/redact.py +0 -0
  235. {thth-2.3.0 → thth-2.3.2}/thth/replies.py +0 -0
  236. {thth-2.3.0 → thth-2.3.2}/thth/report.py +0 -0
  237. {thth-2.3.0 → thth-2.3.2}/thth/retract_cli.py +0 -0
  238. {thth-2.3.0 → thth-2.3.2}/thth/scopes.py +0 -0
  239. {thth-2.3.0 → thth-2.3.2}/thth/secrets_fs.py +0 -0
  240. {thth-2.3.0 → thth-2.3.2}/thth/select.py +0 -0
  241. {thth-2.3.0 → thth-2.3.2}/thth/selfupdate.py +0 -0
  242. {thth-2.3.0 → thth-2.3.2}/thth/sent.py +0 -0
  243. {thth-2.3.0 → thth-2.3.2}/thth/systemd_gen.py +0 -0
  244. {thth-2.3.0 → thth-2.3.2}/thth/threadrun.py +0 -0
  245. {thth-2.3.0 → thth-2.3.2}/thth/threads_read_cli.py +0 -0
  246. {thth-2.3.0 → thth-2.3.2}/thth/threadshape.py +0 -0
  247. {thth-2.3.0 → thth-2.3.2}/thth/threadthrow.py +0 -0
  248. {thth-2.3.0 → thth-2.3.2}/thth/topic_advice.py +0 -0
  249. {thth-2.3.0 → thth-2.3.2}/thth/topic_models.py +0 -0
  250. {thth-2.3.0 → thth-2.3.2}/thth/topic_store.py +0 -0
  251. {thth-2.3.0 → thth-2.3.2}/thth/topics.py +0 -0
  252. {thth-2.3.0 → thth-2.3.2}/thth/where_cli.py +0 -0
  253. {thth-2.3.0 → thth-2.3.2}/thth/who_cli.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.2
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.2**。全件テスト **2310 件**(`python -m pytest tests/ -q -n auto -p no:cacheprovider`・1 skip・rc=0)。
49
49
 
50
50
  **媒体**: Threads(稼働)・Bluesky・Mastodon(同席用の台帳あり・未稼働)。
51
51
 
@@ -79,7 +79,7 @@ timer(systemd・`thth systemd` で生成)で毎時投稿、返信の採集
79
79
  - **英語の文書**: [README.en.md](README.en.md)・[docs/usage.en.md](docs/usage.en.md)・[llms.txt](llms.txt)。
80
80
  - **skill**: `skills/thth/SKILL.md`(wheel にも入る)。
81
81
 
82
- - **動くもの**(`thth --help` の全 34 サブコマンド): `lint`・`preview`・`approve`・`account`・`revoke`・`posts`・`replies`・`measured`・`threads`・`after`・`topics`・`forms`・`queue`・`schedule`・`throw`・`run`・`systemd`・`board`・`collect`・`auth`・`refresh`・`maintain`・`send`・`doctor`・`app`・`token`・`ask`・`mentions`・`profile`・`thread`・`where`・`who`・`retract`・`location`。
82
+ - **動くもの**(`thth --help` の全 35 サブコマンド): `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`。
83
83
  - **最初の本番投稿の記録**: 2026-09-09、@aoking に疎通確認を 1 本(`17916074118445631`)。
84
84
  - **未着手**: X・Facebook ページ・Instagram の各アダプタ。トピック検索の権限(tester には降りない)。泉のサーバ(v2-5)。
85
85
  - **権限の制約**: tester に降りる scope は 5 つ。削除はできない。
@@ -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.2**。全件テスト **2310 件**(`python -m pytest tests/ -q -n auto -p no:cacheprovider`・1 skip・rc=0)。
25
25
 
26
26
  **媒体**: Threads(稼働)・Bluesky・Mastodon(同席用の台帳あり・未稼働)。
27
27
 
@@ -55,7 +55,7 @@ timer(systemd・`thth systemd` で生成)で毎時投稿、返信の採集
55
55
  - **英語の文書**: [README.en.md](README.en.md)・[docs/usage.en.md](docs/usage.en.md)・[llms.txt](llms.txt)。
56
56
  - **skill**: `skills/thth/SKILL.md`(wheel にも入る)。
57
57
 
58
- - **動くもの**(`thth --help` の全 34 サブコマンド): `lint`・`preview`・`approve`・`account`・`revoke`・`posts`・`replies`・`measured`・`threads`・`after`・`topics`・`forms`・`queue`・`schedule`・`throw`・`run`・`systemd`・`board`・`collect`・`auth`・`refresh`・`maintain`・`send`・`doctor`・`app`・`token`・`ask`・`mentions`・`profile`・`thread`・`where`・`who`・`retract`・`location`。
58
+ - **動くもの**(`thth --help` の全 35 サブコマンド): `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`。
59
59
  - **最初の本番投稿の記録**: 2026-09-09、@aoking に疎通確認を 1 本(`17916074118445631`)。
60
60
  - **未着手**: X・Facebook ページ・Instagram の各アダプタ。トピック検索の権限(tester には降りない)。泉のサーバ(v2-5)。
61
61
  - **権限の制約**: tester に降りる scope は 5 つ。削除はできない。
@@ -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"
@@ -0,0 +1,137 @@
1
+ """`writeback.behind_remote()` と `thth queue`/`schedule`/`board` の遅れ表示
2
+ (発注 T8-2・kopicha 続報)。
3
+
4
+ `thth approve`(と `revoke`・`throw`・`collect`・`retract`)は既に
5
+ `writeback.sync_repo()` を通しているが、**読むだけの口**(`queue`・
6
+ `schedule`・`board`)は通していない。push した直後に読むだけの口を見ると、
7
+ timer の `collect` が pull するまでの間、まだ取り込み前の一覧が出る
8
+ ——**読むだけの口が勝手に pull するのは避ける**のが設計の芯なので、
9
+ ここでは「遅れていることを言う」だけを確かめる(取り込みは T8-3 の
10
+ `thth pull` か `thth approve` が行う)。
11
+ """
12
+ from __future__ import annotations
13
+
14
+ from tests.conftest import init_git_pair, run_git, run_thth, write_queue_file
15
+ from thth import writeback
16
+
17
+
18
+ def test_behind_remoteは追いついていれば0(tmp_path):
19
+ pair = init_git_pair(tmp_path / "a", seed_content="", seed_name=".gitkeep")
20
+ info = writeback.behind_remote(pair["work"])
21
+ assert info == {"behind": 0, "ahead": 0, "head": info["head"],
22
+ "fetched_at": info["fetched_at"], "reason": None}
23
+ assert info["head"]
24
+ assert info["reason"] is None
25
+
26
+
27
+ def test_behind_remoteはfetchだけでpullしない(tmp_path):
28
+ """**pull はしない**——fetch のあと、work の作業ツリー(HEAD)は動かない。"""
29
+ pair = init_git_pair(tmp_path / "a", seed_content="旧", seed_name="a.md")
30
+ head_before = run_git(pair["work"], ["rev-parse", "HEAD"]).stdout.strip()
31
+
32
+ run_git(pair["seed"], ["commit", "--allow-empty", "-m", "second"])
33
+ run_git(pair["seed"], ["push"])
34
+
35
+ info = writeback.behind_remote(pair["work"])
36
+ assert info["behind"] == 1
37
+ assert info["ahead"] == 0
38
+
39
+ head_after = run_git(pair["work"], ["rev-parse", "HEAD"]).stdout.strip()
40
+ assert head_after == head_before, "fetch だけのはずが HEAD が動いた(pull してしまった)"
41
+
42
+
43
+ def test_behind_remoteはfetchできないとNoneとreason(tmp_path):
44
+ broken = tmp_path / "broken"
45
+ run_git_init = __import__("subprocess").run(
46
+ ["git", "init", "-b", "main", str(broken)], capture_output=True, text=True)
47
+ assert run_git_init.returncode == 0
48
+ run_git(str(broken), ["remote", "add", "origin", "/does/not/exist/repo.git"])
49
+ run_git(str(broken), ["commit", "--allow-empty", "-m", "x"])
50
+
51
+ info = writeback.behind_remote(str(broken))
52
+ assert info["behind"] is None
53
+ assert info["ahead"] is None
54
+ assert info["reason"]
55
+ assert "0" != info["reason"] # **0 と混ぜない**: reason があるのに behind: 0 ではない
56
+
57
+
58
+ def test_queueは遅れているときに先頭に1行(isolated_account_factory, tmp_path):
59
+ pair = init_git_pair(tmp_path / "acct", seed_content="", seed_name=".gitkeep")
60
+ account = isolated_account_factory(repo_dir=pair["work"])
61
+ write_queue_file(account["queue_dir"], "a.md",
62
+ fm_overrides={"status": "draft", "account": account["name"]})
63
+
64
+ # **`seed` を origin の最新に合わせてから**新しい commit を積む(`work` の
65
+ # `write_queue_file()` push で origin が進んでいるため、合わせないと
66
+ # `seed` の push 自体が non-fast-forward で失敗する)。
67
+ run_git(pair["seed"], ["pull"])
68
+ run_git(pair["seed"], ["commit", "--allow-empty", "-m", "second"])
69
+ run_git(pair["seed"], ["push"])
70
+
71
+ result = run_thth(["queue", account["name"]])
72
+ assert result.returncode == 0, result.stderr
73
+ assert "remote に 1 commit 分の新しいものがあります" in result.stdout
74
+ assert f"thth pull {account['name']}" in result.stdout
75
+
76
+
77
+ def test_queueは遅れていなければ何も出さない(isolated_account_factory, tmp_path):
78
+ pair = init_git_pair(tmp_path / "acct", seed_content="", seed_name=".gitkeep")
79
+ account = isolated_account_factory(repo_dir=pair["work"])
80
+ write_queue_file(account["queue_dir"], "a.md",
81
+ fm_overrides={"status": "draft", "account": account["name"]})
82
+
83
+ result = run_thth(["queue", account["name"]])
84
+ assert result.returncode == 0, result.stderr
85
+ assert "remote に" not in result.stdout
86
+ assert "thth pull" not in result.stdout
87
+
88
+
89
+ def test_queueのjsonにrepoが乗る(isolated_account_factory, tmp_path):
90
+ pair = init_git_pair(tmp_path / "acct", seed_content="", seed_name=".gitkeep")
91
+ account = isolated_account_factory(repo_dir=pair["work"])
92
+ write_queue_file(account["queue_dir"], "a.md",
93
+ fm_overrides={"status": "draft", "account": account["name"]})
94
+
95
+ result = run_thth(["queue", account["name"], "--json"])
96
+ assert result.returncode == 0, result.stderr
97
+ import json
98
+ out = json.loads(result.stdout)
99
+ assert out[account["name"]]["repo"]["behind"] == 0
100
+
101
+
102
+ def test_scheduleは遅れているときに先頭に1行(isolated_account_factory, tmp_path):
103
+ pair = init_git_pair(tmp_path / "acct", seed_content="", seed_name=".gitkeep")
104
+ account = isolated_account_factory(repo_dir=pair["work"])
105
+ write_queue_file(account["queue_dir"], "a.md",
106
+ fm_overrides={"status": "draft", "account": account["name"],
107
+ "publish_at": "2026-09-20T08:00:00+09:00"})
108
+
109
+ run_git(pair["seed"], ["pull"])
110
+ run_git(pair["seed"], ["commit", "--allow-empty", "-m", "second"])
111
+ run_git(pair["seed"], ["push"])
112
+
113
+ result = run_thth(["schedule", account["name"]])
114
+ assert result.returncode == 0, result.stderr
115
+ assert "remote に 1 commit 分の新しいものがあります" in result.stdout
116
+
117
+
118
+ def test_boardは遅れているaccountの行の下に1行(isolated_account_factory, tmp_path):
119
+ pair = init_git_pair(tmp_path / "acct", seed_content="", seed_name=".gitkeep")
120
+ account = isolated_account_factory(repo_dir=pair["work"])
121
+
122
+ run_git(pair["seed"], ["commit", "--allow-empty", "-m", "second"])
123
+ run_git(pair["seed"], ["push"])
124
+
125
+ result = run_thth(["board"])
126
+ assert result.returncode == 0, result.stderr
127
+ assert "repo が 1 commit 遅れています" in result.stdout
128
+ assert f"thth pull {account['name']}" in result.stdout
129
+
130
+
131
+ def test_boardは遅れていなければ何も出さない(isolated_account_factory, tmp_path):
132
+ pair = init_git_pair(tmp_path / "acct", seed_content="", seed_name=".gitkeep")
133
+ isolated_account_factory(repo_dir=pair["work"])
134
+
135
+ result = run_thth(["board"])
136
+ assert result.returncode == 0, result.stderr
137
+ assert "repo が" not in result.stdout
@@ -0,0 +1,80 @@
1
+ """VM に無いパスを渡したときの断り方(発注 T8-1・kopicha 続報)。
2
+
3
+ `~/.local/bin/thth` は Mac から VM へ ssh する薄いラッパで、引数はそのまま
4
+ VM 側の `thth` に渡る。**Mac 側の相対パスも絶対パスも VM には無い**ので、
5
+ いままでは `FileNotFoundError` の素の traceback が出ていた(`--article
6
+ /tmp/x.json` で踏んだのと同じ根)。
7
+
8
+ ファイルを受け取る全ての引数(`lint`・`approve`・`revoke`・`preview`・
9
+ `send --text-file`・`topics suggest` の queue ファイル・`--article`・
10
+ `--proposal`・`--input`・`--draft`・`--revised-draft`)が、共通の
11
+ `cli._require_vm_path()` を通って rc=2・案内つきで断ることを確かめる。
12
+ 存在するパスは従来どおり動くことも確かめる(回帰)。
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import os
17
+
18
+ from tests.conftest import run_thth, write_queue_file
19
+
20
+ MISSING = "/tmp/thth-t8-does-not-exist-まさかここには無い.md"
21
+ MISSING_JSON = "/tmp/thth-t8-does-not-exist-まさかここには無い.json"
22
+
23
+
24
+ def _assert_vm_refusal(result, *, rc=2):
25
+ combined = result.stdout + result.stderr
26
+ assert result.returncode == rc, f"rc={result.returncode}: {combined}"
27
+ assert "VM" in combined, combined
28
+ assert "thth queue" in combined, combined
29
+ assert "Traceback" not in result.stderr, result.stderr
30
+
31
+
32
+ def test_lintにVMに無いパスを渡すとrc2で案内する():
33
+ result = run_thth(["lint", MISSING])
34
+ _assert_vm_refusal(result)
35
+
36
+
37
+ def test_approveにVMに無いパスを渡すとrc2で案内する():
38
+ result = run_thth(["approve", MISSING])
39
+ _assert_vm_refusal(result)
40
+
41
+
42
+ def test_sendのtext_fileにVMに無いパスを渡すとrc2で案内する(isolated_account):
43
+ result = run_thth(["send", isolated_account["name"], "--text-file", MISSING])
44
+ _assert_vm_refusal(result)
45
+
46
+
47
+ def test_topics_suggestのarticleにVMに無いパスを渡すとrc2で案内する(isolated_account):
48
+ queue_path = write_queue_file(
49
+ isolated_account["queue_dir"], "q.md",
50
+ body="## threads\n\nhello https://example.test/x\n",
51
+ fm_overrides={"status": "draft", "account": isolated_account["name"]})
52
+ result = run_thth(["topics", "suggest", queue_path, "--article", MISSING_JSON])
53
+ _assert_vm_refusal(result)
54
+
55
+
56
+ def test_revokeにVMに無いパスを渡すとrc2で案内する():
57
+ result = run_thth(["revoke", MISSING, "--by", "test"])
58
+ _assert_vm_refusal(result)
59
+
60
+
61
+ def test_previewにVMに無いパスを渡すとrc2で案内する():
62
+ result = run_thth(["preview", MISSING])
63
+ _assert_vm_refusal(result)
64
+
65
+
66
+ def test_存在するパスは従来どおり動く(isolated_account):
67
+ """回帰: 存在するパスには案内を出さない(発注書「存在するパスは従来どおり」)。"""
68
+ queue_path = write_queue_file(
69
+ isolated_account["queue_dir"], "ok.md",
70
+ body="## threads\n\nこんにちは https://example.test/x\n",
71
+ fm_overrides={"status": "draft", "account": isolated_account["name"]})
72
+ assert os.path.exists(queue_path)
73
+
74
+ result = run_thth(["lint", queue_path])
75
+ assert "VM" not in (result.stdout + result.stderr)
76
+ assert "thth queue" not in (result.stdout + result.stderr)
77
+
78
+ result = run_thth(["preview", queue_path])
79
+ assert result.returncode == 0, result.stderr
80
+ assert "こんにちは" in result.stdout
@@ -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")
@@ -0,0 +1,96 @@
1
+ """`thth pull <account|--project P>`(発注 T8-3・kopicha 続報)。
2
+
3
+ 読むだけの口(`queue`・`schedule`・`board`。T8-2)は遅れを**言うだけ**で
4
+ pull しない。この道具は、その案内から誘導される**明示の**取り込み口——
5
+ `writeback.sync_repo()` を呼ぶだけで、`thth approve` が既に通しているのと
6
+ 同じ関数・同じ repo ロックを使う。
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import json
11
+
12
+ from tests.conftest import init_git_pair, run_git, run_thth
13
+
14
+
15
+ def test_pullはffして取り込む(isolated_account_factory, tmp_path):
16
+ pair = init_git_pair(tmp_path / "acct", seed_content="旧", seed_name="a.md")
17
+ account = isolated_account_factory(repo_dir=pair["work"])
18
+ head_before = run_git(pair["work"], ["rev-parse", "--short", "HEAD"]).stdout.strip()
19
+
20
+ run_git(pair["seed"], ["commit", "--allow-empty", "-m", "second"])
21
+ run_git(pair["seed"], ["push"])
22
+ head_after_remote = run_git(pair["seed"], ["rev-parse", "--short", "HEAD"]).stdout.strip()
23
+
24
+ result = run_thth(["pull", account["name"]])
25
+ assert result.returncode == 0, result.stderr
26
+ assert f"取り込みました: {head_before} → {head_after_remote}(1 commit)" in result.stdout
27
+
28
+ # 本当に取り込まれている(work の HEAD が動いた)。
29
+ head_now = run_git(pair["work"], ["rev-parse", "--short", "HEAD"]).stdout.strip()
30
+ assert head_now == head_after_remote
31
+
32
+
33
+ def test_pullはすでに最新なら何も変えない(isolated_account_factory, tmp_path):
34
+ pair = init_git_pair(tmp_path / "acct", seed_content="", seed_name=".gitkeep")
35
+ account = isolated_account_factory(repo_dir=pair["work"])
36
+ head_before = run_git(pair["work"], ["rev-parse", "--short", "HEAD"]).stdout.strip()
37
+
38
+ result = run_thth(["pull", account["name"]])
39
+ assert result.returncode == 0, result.stderr
40
+ assert "すでに最新です" in result.stdout
41
+
42
+ head_after = run_git(pair["work"], ["rev-parse", "--short", "HEAD"]).stdout.strip()
43
+ assert head_after == head_before
44
+
45
+
46
+ def test_pullのjsonはbehind_remoteの形(isolated_account_factory, tmp_path):
47
+ pair = init_git_pair(tmp_path / "acct", seed_content="", seed_name=".gitkeep")
48
+ account = isolated_account_factory(repo_dir=pair["work"])
49
+
50
+ result = run_thth(["pull", account["name"], "--json"])
51
+ assert result.returncode == 0, result.stderr
52
+ out = json.loads(result.stdout)
53
+ assert len(out) == 1
54
+ row = out[0]
55
+ assert row["account"] == account["name"]
56
+ assert row["ok"] is True
57
+ assert set(row["repo"].keys()) == {"behind", "ahead", "head", "fetched_at", "reason"}
58
+ assert row["repo"]["behind"] == 0
59
+
60
+
61
+ def test_pullのprojectは同じrepoを共有する2accountで1回だけ引く(
62
+ isolated_account_factory, tmp_path):
63
+ pair = init_git_pair(tmp_path / "acct", seed_content="旧", seed_name="a.md")
64
+ account_a = isolated_account_factory("a-threads", repo_dir=pair["work"], project="shared")
65
+ account_b = isolated_account_factory("b-threads", repo_dir=pair["work"], project="shared")
66
+ assert account_a["repo_dir"] == account_b["repo_dir"]
67
+
68
+ run_git(pair["seed"], ["commit", "--allow-empty", "-m", "second"])
69
+ run_git(pair["seed"], ["push"])
70
+
71
+ result = run_thth(["pull", "--project", "shared", "--json"])
72
+ assert result.returncode == 0, result.stderr
73
+ out = json.loads(result.stdout)
74
+ # **重複なく 1 回だけ**——2 account だが repo は 1 つ。
75
+ assert len(out) == 1
76
+ assert out[0]["repo"]["behind"] == 0
77
+
78
+
79
+ def test_pullは失敗をsync_repoの理由のままrc1(isolated_account_factory, tmp_path):
80
+ account = isolated_account_factory(repo_dir=str(tmp_path / "no-such-repo-dir"))
81
+
82
+ result = run_thth(["pull", account["name"]])
83
+ assert result.returncode == 1, result.stdout
84
+ assert "取り込めませんでした" in result.stderr or "見当たりません" in result.stderr
85
+
86
+
87
+ def test_pullはaccountもprojectも無ければ断る(isolated_account_factory):
88
+ result = run_thth(["pull"])
89
+ assert result.returncode == 2
90
+ assert "account" in result.stderr
91
+
92
+
93
+ def test_pullは未知のprojectを断る(isolated_account_factory):
94
+ result = run_thth(["pull", "--project", "no-such-project"])
95
+ assert result.returncode == 2
96
+ assert "no-such-project" in result.stderr
@@ -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"]) == []