thth 2.0.1__tar.gz → 2.2.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 (243) hide show
  1. {thth-2.0.1 → thth-2.2.0}/PKG-INFO +4 -4
  2. {thth-2.0.1 → thth-2.2.0}/README.md +3 -3
  3. {thth-2.0.1 → thth-2.2.0}/mcp/server.py +16 -4
  4. {thth-2.0.1 → thth-2.2.0}/tests/fixtures/callback/worker_responses.json +15 -4
  5. {thth-2.0.1 → thth-2.2.0}/tests/helpers/fake_oauth_server.py +12 -1
  6. thth-2.2.0/tests/helpers/fake_search_adapter.py +78 -0
  7. thth-2.2.0/tests/helpers/fake_threads_writes.py +139 -0
  8. {thth-2.0.1 → thth-2.2.0}/tests/helpers/hold_lock.py +13 -2
  9. {thth-2.0.1 → thth-2.2.0}/tests/helpers/worker_probe.mjs +1 -0
  10. {thth-2.0.1 → thth-2.2.0}/tests/test_adapter_boundary.py +26 -8
  11. thth-2.2.0/tests/test_auth_identity_required.py +152 -0
  12. {thth-2.0.1 → thth-2.2.0}/tests/test_board_running.py +65 -3
  13. thth-2.2.0/tests/test_collect_runs.py +132 -0
  14. thth-2.2.0/tests/test_control_chars_refused.py +210 -0
  15. thth-2.2.0/tests/test_count_for.py +158 -0
  16. thth-2.2.0/tests/test_doctor_all_permissions.py +427 -0
  17. thth-2.2.0/tests/test_duplicate_front_matter_keys.py +176 -0
  18. thth-2.2.0/tests/test_inbox_granted_scopes.py +463 -0
  19. {thth-2.0.1 → thth-2.2.0}/tests/test_mastodon_adapter.py +35 -2
  20. thth-2.2.0/tests/test_mastodon_visibility.py +92 -0
  21. {thth-2.0.1 → thth-2.2.0}/tests/test_mcp_robustness.py +89 -0
  22. thth-2.2.0/tests/test_scopes_source_requested.py +122 -0
  23. thth-2.2.0/tests/test_search_engage_targets.py +259 -0
  24. thth-2.2.0/tests/test_secret_value_redact.py +234 -0
  25. {thth-2.0.1 → thth-2.2.0}/tests/test_send_visibility.py +3 -1
  26. {thth-2.0.1 → thth-2.2.0}/tests/test_site.py +71 -41
  27. thth-2.2.0/tests/test_threads_read_permissions.py +598 -0
  28. thth-2.2.0/tests/test_token_scopes_record.py +156 -0
  29. thth-2.2.0/tests/test_v21b_approved_writes.py +662 -0
  30. thth-2.2.0/thth/VERSION +1 -0
  31. {thth-2.0.1 → thth-2.2.0}/thth/account_report.py +27 -3
  32. {thth-2.0.1 → thth-2.2.0}/thth/adapters/__init__.py +13 -1
  33. {thth-2.0.1 → thth-2.2.0}/thth/adapters/base.py +133 -0
  34. {thth-2.0.1 → thth-2.2.0}/thth/adapters/bluesky.py +11 -2
  35. {thth-2.0.1 → thth-2.2.0}/thth/adapters/mastodon.py +44 -7
  36. thth-2.2.0/thth/adapters/threads.py +1240 -0
  37. {thth-2.0.1 → thth-2.2.0}/thth/approval.py +87 -5
  38. {thth-2.0.1 → thth-2.2.0}/thth/bundle.py +53 -11
  39. {thth-2.0.1 → thth-2.2.0}/thth/cli.py +92 -10
  40. {thth-2.0.1 → thth-2.2.0}/thth/collect.py +163 -11
  41. {thth-2.0.1 → thth-2.2.0}/thth/core.py +43 -8
  42. {thth-2.0.1 → thth-2.2.0}/thth/doctor.py +70 -2
  43. {thth-2.0.1 → thth-2.2.0}/thth/lint.py +67 -1
  44. {thth-2.0.1 → thth-2.2.0}/thth/oauth.py +86 -3
  45. {thth-2.0.1 → thth-2.2.0}/thth/queuefile.py +81 -5
  46. {thth-2.0.1 → thth-2.2.0}/thth/redact.py +46 -0
  47. {thth-2.0.1 → thth-2.2.0}/thth/report.py +40 -0
  48. thth-2.2.0/thth/retract_cli.py +350 -0
  49. {thth-2.0.1 → thth-2.2.0}/thth/runs.py +5 -1
  50. {thth-2.0.1 → thth-2.2.0}/thth/select.py +29 -2
  51. {thth-2.0.1 → thth-2.2.0}/thth/sent.py +31 -0
  52. thth-2.2.0/thth/threads_read_cli.py +581 -0
  53. {thth-2.0.1 → thth-2.2.0}/thth/writeback.py +42 -0
  54. thth-2.0.1/thth/VERSION +0 -1
  55. thth-2.0.1/thth/adapters/threads.py +0 -603
  56. {thth-2.0.1 → thth-2.2.0}/.gitignore +0 -0
  57. {thth-2.0.1 → thth-2.2.0}/LICENSE +0 -0
  58. {thth-2.0.1 → thth-2.2.0}/accounts.example/bluesky.json +0 -0
  59. {thth-2.0.1 → thth-2.2.0}/accounts.example/mastodon.json +0 -0
  60. {thth-2.0.1 → thth-2.2.0}/accounts.example/threads.json +0 -0
  61. {thth-2.0.1 → thth-2.2.0}/bin/thth +0 -0
  62. {thth-2.0.1 → thth-2.2.0}/bin/thth-remote +0 -0
  63. {thth-2.0.1 → thth-2.2.0}/bin/thth-run +0 -0
  64. {thth-2.0.1 → thth-2.2.0}/pyproject.toml +0 -0
  65. {thth-2.0.1 → thth-2.2.0}/skills/thth/SKILL.md +0 -0
  66. {thth-2.0.1 → thth-2.2.0}/systemd/thth@.service +0 -0
  67. {thth-2.0.1 → thth-2.2.0}/tests/__init__.py +0 -0
  68. {thth-2.0.1 → thth-2.2.0}/tests/conftest.py +0 -0
  69. {thth-2.0.1 → thth-2.2.0}/tests/fixtures/build_injection_eval.py +0 -0
  70. {thth-2.0.1 → thth-2.2.0}/tests/fixtures/injection_eval.json +0 -0
  71. {thth-2.0.1 → thth-2.2.0}/tests/fixtures/umami-bile.md +0 -0
  72. {thth-2.0.1 → thth-2.2.0}/tests/helpers/__init__.py +0 -0
  73. {thth-2.0.1 → thth-2.2.0}/tests/helpers/fake_push_adapter.py +0 -0
  74. {thth-2.0.1 → thth-2.2.0}/tests/helpers/fake_redirect_server.py +0 -0
  75. {thth-2.0.1 → thth-2.2.0}/tests/helpers/fake_threads_reader.py +0 -0
  76. {thth-2.0.1 → thth-2.2.0}/tests/helpers/hold_repo_lock.py +0 -0
  77. {thth-2.0.1 → thth-2.2.0}/tests/helpers/share_ledger.py +0 -0
  78. {thth-2.0.1 → thth-2.2.0}/tests/test_account_name_check.py +0 -0
  79. {thth-2.0.1 → thth-2.2.0}/tests/test_account_report.py +0 -0
  80. {thth-2.0.1 → thth-2.2.0}/tests/test_accounts_dir.py +0 -0
  81. {thth-2.0.1 → thth-2.2.0}/tests/test_api_error_body.py +0 -0
  82. {thth-2.0.1 → thth-2.2.0}/tests/test_app_env_cli.py +0 -0
  83. {thth-2.0.1 → thth-2.2.0}/tests/test_approval.py +0 -0
  84. {thth-2.0.1 → thth-2.2.0}/tests/test_approve_cmd.py +0 -0
  85. {thth-2.0.1 → thth-2.2.0}/tests/test_ask.py +0 -0
  86. {thth-2.0.1 → thth-2.2.0}/tests/test_atlas_fifth_review.py +0 -0
  87. {thth-2.0.1 → thth-2.2.0}/tests/test_atlas_fourth_review.py +0 -0
  88. {thth-2.0.1 → thth-2.2.0}/tests/test_atlas_review.py +0 -0
  89. {thth-2.0.1 → thth-2.2.0}/tests/test_atlas_second_review.py +0 -0
  90. {thth-2.0.1 → thth-2.2.0}/tests/test_atlas_third_review.py +0 -0
  91. {thth-2.0.1 → thth-2.2.0}/tests/test_audit1_fixes.py +0 -0
  92. {thth-2.0.1 → thth-2.2.0}/tests/test_audit2_fixes.py +0 -0
  93. {thth-2.0.1 → thth-2.2.0}/tests/test_audit_b_counterexamples.py +0 -0
  94. {thth-2.0.1 → thth-2.2.0}/tests/test_audit_b_m1_semantics.py +0 -0
  95. {thth-2.0.1 → thth-2.2.0}/tests/test_audit_b_n1_depth.py +0 -0
  96. {thth-2.0.1 → thth-2.2.0}/tests/test_audit_b_r1_closure.py +0 -0
  97. {thth-2.0.1 → thth-2.2.0}/tests/test_audit_v201.py +0 -0
  98. {thth-2.0.1 → thth-2.2.0}/tests/test_audit_v23_details.py +0 -0
  99. {thth-2.0.1 → thth-2.2.0}/tests/test_auth.py +0 -0
  100. {thth-2.0.1 → thth-2.2.0}/tests/test_bluesky_adapter.py +0 -0
  101. {thth-2.0.1 → thth-2.2.0}/tests/test_bluesky_secret_scrub.py +0 -0
  102. {thth-2.0.1 → thth-2.2.0}/tests/test_board_needs_review.py +0 -0
  103. {thth-2.0.1 → thth-2.2.0}/tests/test_bundle.py +0 -0
  104. {thth-2.0.1 → thth-2.2.0}/tests/test_charcount.py +0 -0
  105. {thth-2.0.1 → thth-2.2.0}/tests/test_cli_entrypoint.py +0 -0
  106. {thth-2.0.1 → thth-2.2.0}/tests/test_collect.py +0 -0
  107. {thth-2.0.1 → thth-2.2.0}/tests/test_collect_sent.py +0 -0
  108. {thth-2.0.1 → thth-2.2.0}/tests/test_comparability.py +0 -0
  109. {thth-2.0.1 → thth-2.2.0}/tests/test_conversation_paging.py +0 -0
  110. {thth-2.0.1 → thth-2.2.0}/tests/test_docs_links.py +0 -0
  111. {thth-2.0.1 → thth-2.2.0}/tests/test_doctor.py +0 -0
  112. {thth-2.0.1 → thth-2.2.0}/tests/test_doctor_next_step.py +0 -0
  113. {thth-2.0.1 → thth-2.2.0}/tests/test_editorial_audit.py +0 -0
  114. {thth-2.0.1 → thth-2.2.0}/tests/test_editorial_n3_n4.py +0 -0
  115. {thth-2.0.1 → thth-2.2.0}/tests/test_external_acceptance.py +0 -0
  116. {thth-2.0.1 → thth-2.2.0}/tests/test_fake_api.py +0 -0
  117. {thth-2.0.1 → thth-2.2.0}/tests/test_form_spec.py +0 -0
  118. {thth-2.0.1 → thth-2.2.0}/tests/test_form_spec_cli.py +0 -0
  119. {thth-2.0.1 → thth-2.2.0}/tests/test_forms.py +0 -0
  120. {thth-2.0.1 → thth-2.2.0}/tests/test_fresh_install.py +0 -0
  121. {thth-2.0.1 → thth-2.2.0}/tests/test_front_matter_injection.py +0 -0
  122. {thth-2.0.1 → thth-2.2.0}/tests/test_hypotheses.py +0 -0
  123. {thth-2.0.1 → thth-2.2.0}/tests/test_hypothesis_boundaries.py +0 -0
  124. {thth-2.0.1 → thth-2.2.0}/tests/test_hypothesis_units.py +0 -0
  125. {thth-2.0.1 → thth-2.2.0}/tests/test_impact_cli.py +0 -0
  126. {thth-2.0.1 → thth-2.2.0}/tests/test_independent_thread_review.py +0 -0
  127. {thth-2.0.1 → thth-2.2.0}/tests/test_independent_topic_review.py +0 -0
  128. {thth-2.0.1 → thth-2.2.0}/tests/test_independent_topic_review_r2.py +0 -0
  129. {thth-2.0.1 → thth-2.2.0}/tests/test_inflight.py +0 -0
  130. {thth-2.0.1 → thth-2.2.0}/tests/test_jst.py +0 -0
  131. {thth-2.0.1 → thth-2.2.0}/tests/test_lint.py +0 -0
  132. {thth-2.0.1 → thth-2.2.0}/tests/test_list_projections.py +0 -0
  133. {thth-2.0.1 → thth-2.2.0}/tests/test_llm_trial.py +0 -0
  134. {thth-2.0.1 → thth-2.2.0}/tests/test_lock.py +0 -0
  135. {thth-2.0.1 → thth-2.2.0}/tests/test_m2_independent.py +0 -0
  136. {thth-2.0.1 → thth-2.2.0}/tests/test_maintain.py +0 -0
  137. {thth-2.0.1 → thth-2.2.0}/tests/test_max_per_run.py +0 -0
  138. {thth-2.0.1 → thth-2.2.0}/tests/test_mcp.py +0 -0
  139. {thth-2.0.1 → thth-2.2.0}/tests/test_measured.py +0 -0
  140. {thth-2.0.1 → thth-2.2.0}/tests/test_media_accounts.py +0 -0
  141. {thth-2.0.1 → thth-2.2.0}/tests/test_media_next_steps.py +0 -0
  142. {thth-2.0.1 → thth-2.2.0}/tests/test_media_roundtrip.py +0 -0
  143. {thth-2.0.1 → thth-2.2.0}/tests/test_mismatch_diagnostics.py +0 -0
  144. {thth-2.0.1 → thth-2.2.0}/tests/test_n5_reply_null_element.py +0 -0
  145. {thth-2.0.1 → thth-2.2.0}/tests/test_n6_replies_incomplete_ledger.py +0 -0
  146. {thth-2.0.1 → thth-2.2.0}/tests/test_n7_doctor_error_body.py +0 -0
  147. {thth-2.0.1 → thth-2.2.0}/tests/test_no_limits.py +0 -0
  148. {thth-2.0.1 → thth-2.2.0}/tests/test_observation_provenance.py +0 -0
  149. {thth-2.0.1 → thth-2.2.0}/tests/test_packaging.py +0 -0
  150. {thth-2.0.1 → thth-2.2.0}/tests/test_preview.py +0 -0
  151. {thth-2.0.1 → thth-2.2.0}/tests/test_production_mode.py +0 -0
  152. {thth-2.0.1 → thth-2.2.0}/tests/test_queue.py +0 -0
  153. {thth-2.0.1 → thth-2.2.0}/tests/test_quiet_hours.py +0 -0
  154. {thth-2.0.1 → thth-2.2.0}/tests/test_r2_r4_r5.py +0 -0
  155. {thth-2.0.1 → thth-2.2.0}/tests/test_reason_adoption.py +0 -0
  156. {thth-2.0.1 → thth-2.2.0}/tests/test_redact.py +0 -0
  157. {thth-2.0.1 → thth-2.2.0}/tests/test_redirect_token_leak.py +0 -0
  158. {thth-2.0.1 → thth-2.2.0}/tests/test_refresh.py +0 -0
  159. {thth-2.0.1 → thth-2.2.0}/tests/test_refresh_destination.py +0 -0
  160. {thth-2.0.1 → thth-2.2.0}/tests/test_rejudge_new.py +0 -0
  161. {thth-2.0.1 → thth-2.2.0}/tests/test_rejudge_r2_r3.py +0 -0
  162. {thth-2.0.1 → thth-2.2.0}/tests/test_replies.py +0 -0
  163. {thth-2.0.1 → thth-2.2.0}/tests/test_replies_refresh.py +0 -0
  164. {thth-2.0.1 → thth-2.2.0}/tests/test_repo_lock.py +0 -0
  165. {thth-2.0.1 → thth-2.2.0}/tests/test_repo_sync.py +0 -0
  166. {thth-2.0.1 → thth-2.2.0}/tests/test_reverse_audit.py +0 -0
  167. {thth-2.0.1 → thth-2.2.0}/tests/test_review_case_id_cluster_m2.py +0 -0
  168. {thth-2.0.1 → thth-2.2.0}/tests/test_review_case_id_cluster_r2.py +0 -0
  169. {thth-2.0.1 → thth-2.2.0}/tests/test_review_cli.py +0 -0
  170. {thth-2.0.1 → thth-2.2.0}/tests/test_review_evidence_account.py +0 -0
  171. {thth-2.0.1 → thth-2.2.0}/tests/test_review_form_spec_and_case_id.py +0 -0
  172. {thth-2.0.1 → thth-2.2.0}/tests/test_review_provenance.py +0 -0
  173. {thth-2.0.1 → thth-2.2.0}/tests/test_review_records.py +0 -0
  174. {thth-2.0.1 → thth-2.2.0}/tests/test_revoke.py +0 -0
  175. {thth-2.0.1 → thth-2.2.0}/tests/test_roundtrip.py +0 -0
  176. {thth-2.0.1 → thth-2.2.0}/tests/test_run_missing_env.py +0 -0
  177. {thth-2.0.1 → thth-2.2.0}/tests/test_run_record_failclosed.py +0 -0
  178. {thth-2.0.1 → thth-2.2.0}/tests/test_runs_collected.py +0 -0
  179. {thth-2.0.1 → thth-2.2.0}/tests/test_schedule_multiple.py +0 -0
  180. {thth-2.0.1 → thth-2.2.0}/tests/test_schedule_view.py +0 -0
  181. {thth-2.0.1 → thth-2.2.0}/tests/test_secrets_grep.py +0 -0
  182. {thth-2.0.1 → thth-2.2.0}/tests/test_security_small_fixes.py +0 -0
  183. {thth-2.0.1 → thth-2.2.0}/tests/test_select.py +0 -0
  184. {thth-2.0.1 → thth-2.2.0}/tests/test_selfupdate.py +0 -0
  185. {thth-2.0.1 → thth-2.2.0}/tests/test_send.py +0 -0
  186. {thth-2.0.1 → thth-2.2.0}/tests/test_send_cli.py +0 -0
  187. {thth-2.0.1 → thth-2.2.0}/tests/test_send_media.py +0 -0
  188. {thth-2.0.1 → thth-2.2.0}/tests/test_sent_integrity.py +0 -0
  189. {thth-2.0.1 → thth-2.2.0}/tests/test_server_json.py +0 -0
  190. {thth-2.0.1 → thth-2.2.0}/tests/test_share.py +0 -0
  191. {thth-2.0.1 → thth-2.2.0}/tests/test_share_safety.py +0 -0
  192. {thth-2.0.1 → thth-2.2.0}/tests/test_share_sync_concurrent.py +0 -0
  193. {thth-2.0.1 → thth-2.2.0}/tests/test_systemd.py +0 -0
  194. {thth-2.0.1 → thth-2.2.0}/tests/test_thread_publish.py +0 -0
  195. {thth-2.0.1 → thth-2.2.0}/tests/test_threadrun.py +0 -0
  196. {thth-2.0.1 → thth-2.2.0}/tests/test_threadshape.py +0 -0
  197. {thth-2.0.1 → thth-2.2.0}/tests/test_thth_root.py +0 -0
  198. {thth-2.0.1 → thth-2.2.0}/tests/test_token_exists.py +0 -0
  199. {thth-2.0.1 → thth-2.2.0}/tests/test_token_set.py +0 -0
  200. {thth-2.0.1 → thth-2.2.0}/tests/test_topic.py +0 -0
  201. {thth-2.0.1 → thth-2.2.0}/tests/test_topic_advice.py +0 -0
  202. {thth-2.0.1 → thth-2.2.0}/tests/test_topic_cli.py +0 -0
  203. {thth-2.0.1 → thth-2.2.0}/tests/test_topic_models.py +0 -0
  204. {thth-2.0.1 → thth-2.2.0}/tests/test_topic_review_fixes.py +0 -0
  205. {thth-2.0.1 → thth-2.2.0}/tests/test_topic_shelf_observers.py +0 -0
  206. {thth-2.0.1 → thth-2.2.0}/tests/test_topic_store.py +0 -0
  207. {thth-2.0.1 → thth-2.2.0}/tests/test_topics_record_concurrent.py +0 -0
  208. {thth-2.0.1 → thth-2.2.0}/tests/test_topics_tool.py +0 -0
  209. {thth-2.0.1 → thth-2.2.0}/tests/test_trial_frictions.py +0 -0
  210. {thth-2.0.1 → thth-2.2.0}/tests/test_value_domains_shown.py +0 -0
  211. {thth-2.0.1 → thth-2.2.0}/tests/test_version.py +0 -0
  212. {thth-2.0.1 → thth-2.2.0}/tests/test_writeback_rebase.py +0 -0
  213. {thth-2.0.1 → thth-2.2.0}/tests/test_writeback_validate.py +0 -0
  214. {thth-2.0.1 → thth-2.2.0}/thth/__init__.py +0 -0
  215. {thth-2.0.1 → thth-2.2.0}/thth/__main__.py +0 -0
  216. {thth-2.0.1 → thth-2.2.0}/thth/account_cli.py +0 -0
  217. {thth-2.0.1 → thth-2.2.0}/thth/accounts.py +0 -0
  218. {thth-2.0.1 → thth-2.2.0}/thth/appenv.py +0 -0
  219. {thth-2.0.1 → thth-2.2.0}/thth/ask.py +0 -0
  220. {thth-2.0.1 → thth-2.2.0}/thth/ask_cli.py +0 -0
  221. {thth-2.0.1 → thth-2.2.0}/thth/forms.py +0 -0
  222. {thth-2.0.1 → thth-2.2.0}/thth/httpsafe.py +0 -0
  223. {thth-2.0.1 → thth-2.2.0}/thth/inflight.py +0 -0
  224. {thth-2.0.1 → thth-2.2.0}/thth/jst.py +0 -0
  225. {thth-2.0.1 → thth-2.2.0}/thth/lock.py +0 -0
  226. {thth-2.0.1 → thth-2.2.0}/thth/maintain.py +0 -0
  227. {thth-2.0.1 → thth-2.2.0}/thth/measured.py +0 -0
  228. {thth-2.0.1 → thth-2.2.0}/thth/postid.py +0 -0
  229. {thth-2.0.1 → thth-2.2.0}/thth/replies.py +0 -0
  230. {thth-2.0.1 → thth-2.2.0}/thth/scopes.py +0 -0
  231. {thth-2.0.1 → thth-2.2.0}/thth/secrets_fs.py +0 -0
  232. {thth-2.0.1 → thth-2.2.0}/thth/selfupdate.py +0 -0
  233. {thth-2.0.1 → thth-2.2.0}/thth/share.py +0 -0
  234. {thth-2.0.1 → thth-2.2.0}/thth/share_cli.py +0 -0
  235. {thth-2.0.1 → thth-2.2.0}/thth/systemd_gen.py +0 -0
  236. {thth-2.0.1 → thth-2.2.0}/thth/threadrun.py +0 -0
  237. {thth-2.0.1 → thth-2.2.0}/thth/threadshape.py +0 -0
  238. {thth-2.0.1 → thth-2.2.0}/thth/threadthrow.py +0 -0
  239. {thth-2.0.1 → thth-2.2.0}/thth/topic_advice.py +0 -0
  240. {thth-2.0.1 → thth-2.2.0}/thth/topic_cli.py +0 -0
  241. {thth-2.0.1 → thth-2.2.0}/thth/topic_models.py +0 -0
  242. {thth-2.0.1 → thth-2.2.0}/thth/topic_store.py +0 -0
  243. {thth-2.0.1 → thth-2.2.0}/thth/topics.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: thth
3
- Version: 2.0.1
3
+ Version: 2.2.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
@@ -45,7 +45,7 @@ watchtower(`~/Developer/watchtower`)の隣に同じ流儀で並べる。watc
45
45
 
46
46
  ## いまの状態(2026-09-13)
47
47
 
48
- 版 **2.0.1**。全件テスト **1990 件**(`python -m pytest tests/ -q -n auto -p no:cacheprovider`・1 skip・rc=0)。
48
+ 版 **2.2.0**。全件テスト **2196 件**(`python -m pytest tests/ -q -n auto -p no:cacheprovider`・1 skip・rc=0)。
49
49
 
50
50
  **媒体**: Threads(稼働)・Bluesky・Mastodon(同席用の台帳あり・未稼働)。
51
51
 
@@ -55,7 +55,7 @@ timer(systemd・`thth systemd` で生成)で毎時投稿、返信の採集
55
55
 
56
56
  **入口は 4 つ**: 厚い CLI(`bin/thth`・`python -m thth`)、薄い MCP(`mcp/server.py`・読み取りと
57
57
  同席の投稿・`before_you_post`)、timer、そして `pip install`(`thth`・`thth-mcp` の entry point・
58
- **依存 0**。ただし **PyPI への登録と upload は masaru の手で、まだしていない**)。
58
+ **依存 0**。PyPI に公開済み・MCP registry に `io.github.aokings/thth` として登録済み)。
59
59
 
60
60
  **トピックの棚**(`thth topics`): 観測者ごとに並ぶ・打ち消し `retract-note`・`history`。
61
61
 
@@ -87,7 +87,7 @@ timer(systemd・`thth systemd` で生成)で毎時投稿、返信の採集
87
87
  - **最初の本番投稿の記録**: 2026-09-09、@aoking に疎通確認を 1 本(`17916074118445631`)。
88
88
  - **未着手**: X・Facebook ページ・Instagram の各アダプタ。トピック検索の権限(tester には降りない)。泉のサーバ(v2-5)。
89
89
  - **権限の制約**: tester に降りる scope は 5 つ。削除はできない。
90
- - **masaru の手が要るもの**: PyPI の登録と upload、repo を public にする切替と `LICENSE`、MCP registry への登録、tag と `release` を進める操作。
90
+ - **masaru の手が要るもの**: repo を public にする切替と `LICENSE`、tag と `release` を進める操作(PyPI と MCP registry は 2.0.1 まで公開済み)。
91
91
 
92
92
  ## MCP registry
93
93
 
@@ -21,7 +21,7 @@ watchtower(`~/Developer/watchtower`)の隣に同じ流儀で並べる。watc
21
21
 
22
22
  ## いまの状態(2026-09-13)
23
23
 
24
- 版 **2.0.1**。全件テスト **1990 件**(`python -m pytest tests/ -q -n auto -p no:cacheprovider`・1 skip・rc=0)。
24
+ 版 **2.2.0**。全件テスト **2196 件**(`python -m pytest tests/ -q -n auto -p no:cacheprovider`・1 skip・rc=0)。
25
25
 
26
26
  **媒体**: Threads(稼働)・Bluesky・Mastodon(同席用の台帳あり・未稼働)。
27
27
 
@@ -31,7 +31,7 @@ timer(systemd・`thth systemd` で生成)で毎時投稿、返信の採集
31
31
 
32
32
  **入口は 4 つ**: 厚い CLI(`bin/thth`・`python -m thth`)、薄い MCP(`mcp/server.py`・読み取りと
33
33
  同席の投稿・`before_you_post`)、timer、そして `pip install`(`thth`・`thth-mcp` の entry point・
34
- **依存 0**。ただし **PyPI への登録と upload は masaru の手で、まだしていない**)。
34
+ **依存 0**。PyPI に公開済み・MCP registry に `io.github.aokings/thth` として登録済み)。
35
35
 
36
36
  **トピックの棚**(`thth topics`): 観測者ごとに並ぶ・打ち消し `retract-note`・`history`。
37
37
 
@@ -63,7 +63,7 @@ timer(systemd・`thth systemd` で生成)で毎時投稿、返信の採集
63
63
  - **最初の本番投稿の記録**: 2026-09-09、@aoking に疎通確認を 1 本(`17916074118445631`)。
64
64
  - **未着手**: X・Facebook ページ・Instagram の各アダプタ。トピック検索の権限(tester には降りない)。泉のサーバ(v2-5)。
65
65
  - **権限の制約**: tester に降りる scope は 5 つ。削除はできない。
66
- - **masaru の手が要るもの**: PyPI の登録と upload、repo を public にする切替と `LICENSE`、MCP registry への登録、tag と `release` を進める操作。
66
+ - **masaru の手が要るもの**: repo を public にする切替と `LICENSE`、tag と `release` を進める操作(PyPI と MCP registry は 2.0.1 まで公開済み)。
67
67
 
68
68
  ## MCP registry
69
69
 
@@ -198,7 +198,12 @@ def ledger_roots() -> list:
198
198
  cfg = accounts_mod.load_account(account)
199
199
  except Exception: # noqa: BLE001(1 本壊れていても続ける)
200
200
  continue
201
- repo_dir = cfg.get("repo_dir")
201
+ # **正規の函数を通す**(監査 2026-09-16)。ここが台帳の `repo_dir` を
202
+ # 生のまま `os.path.realpath()` していたので、相対パスは**この
203
+ # プロセスの cwd** 基準で畳まれていた。`thth/accounts.py::resolved_repo_dir()`
204
+ # は `$THTH_ROOT` 基準——**同じ台帳で許可する場所が MCP だけ食い違って
205
+ # いた**(cwd は systemd 経由なら `/`、手で打てば人それぞれ)。
206
+ repo_dir = accounts_mod.resolved_repo_dir(cfg)
202
207
  if repo_dir:
203
208
  roots.append(os.path.realpath(repo_dir))
204
209
  return roots
@@ -429,9 +434,16 @@ def main() -> None:
429
434
  continue
430
435
  try:
431
436
  req = json.loads(line)
432
- except json.JSONDecodeError:
433
- # id が判らないので返しようがない(JSON-RPC も id:null を許すが、
434
- # 従来どおり黙って次の行へ)。
437
+ except (ValueError, RecursionError):
438
+ # **`json.JSONDecodeError` だけでは足りない**(監査 2026-09-16)。
439
+ # `json.JSONDecodeError` は `ValueError` の子だが、Python 3.12 は
440
+ # 4300 桁を超える整数で別の `ValueError`
441
+ # ("Exceeds the limit (4300 digits) for integer string conversion")
442
+ # を上げる——`except json.JSONDecodeError` だけではここが漏れて
443
+ # プロセスが死ぬ。深い入れ子(`[` の連続)の `RecursionError` も同じ
444
+ # 筋で、ここでしか捕まえられない。**id が判らないので返しようがない**
445
+ # (JSON-RPC も id:null を許すが、従来どおり黙って次の行へ・方針は
446
+ # 変えない)。
435
447
  continue
436
448
  if isinstance(req, list):
437
449
  # **batch(配列)は受けない。** 受けたふりをして 1 件目だけ処理する
@@ -8,7 +8,7 @@
8
8
  "referrer-policy": "no-referrer",
9
9
  "x-robots-tag": "noindex, nofollow"
10
10
  },
11
- "body": "<!doctype html>\n<html lang=\"ja\"><head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n<meta name=\"robots\" content=\"noindex,nofollow,noarchive\">\n<title>THTH 認可の受け口</title>\n<style>\n :root { color-scheme: light dark; }\n body { margin:0; min-height:100vh; display:flex; align-items:center; justify-content:center;\n font: 16px/1.7 -apple-system, BlinkMacSystemFont, \"Hiragino Sans\", \"Noto Sans JP\", sans-serif;\n background:#faf9f7; color:#1a1a1a; padding:24px; }\n @media (prefers-color-scheme: dark) { body { background:#16161a; color:#eee; } }\n main { max-width: 640px; width:100%; }\n h1 { font-size:1.15rem; font-weight:600; margin:0 0 4px; letter-spacing:.02em; }\n .sub { font-size:.85rem; opacity:.65; margin:0 0 24px; }\n .code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size:.95rem;\n word-break:break-all; background:rgba(127,127,127,.12); border-radius:10px;\n padding:16px; margin:0 0 12px; user-select:all; }\n button { font:inherit; font-size:.9rem; padding:9px 18px; border-radius:8px; cursor:pointer;\n border:1px solid rgba(127,127,127,.4); background:transparent; color:inherit; }\n button:hover { background:rgba(127,127,127,.12); }\n .note { font-size:.85rem; opacity:.7; margin-top:20px; }\n .err { color:#b3261e; } @media (prefers-color-scheme: dark) { .err { color:#f2b8b5; } }\n</style></head><body><main><h1>THTH</h1>\n <p class=\"sub\">Threads の認可の受け口です。ここを直接開いても何もありません。</p>\n <p class=\"note\">ターミナルで <code>thth auth &lt;account&gt;</code> を実行すると、\n 認可 URL が表示されます。承認するとこのページに戻ってきます。</p></main></body></html>"
11
+ "body": "<!doctype html>\n<html lang=\"ja\"><head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n<meta name=\"robots\" content=\"noindex,nofollow,noarchive\">\n<title>THTH 認可の受け口</title>\n<style>\n :root { color-scheme: light dark; }\n body { margin:0; min-height:100vh; display:flex; align-items:center; justify-content:center;\n font: 16px/1.7 -apple-system, BlinkMacSystemFont, \"Hiragino Sans\", \"Noto Sans JP\", sans-serif;\n background:#faf9f7; color:#1a1a1a; padding:24px; }\n @media (prefers-color-scheme: dark) { body { background:#16161a; color:#eee; } }\n main { max-width: 640px; width:100%; }\n h1 { font-size:1.15rem; font-weight:600; margin:0 0 4px; letter-spacing:.02em; }\n .sub { font-size:.85rem; opacity:.65; margin:0 0 24px; }\n .code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size:.95rem;\n word-break:break-all; background:rgba(127,127,127,.12); border-radius:10px;\n padding:16px; margin:0 0 12px; user-select:all; }\n button { font:inherit; font-size:.9rem; padding:9px 18px; border-radius:8px; cursor:pointer;\n border:1px solid rgba(127,127,127,.4); background:transparent; color:inherit; }\n button:hover { background:rgba(127,127,127,.12); }\n .note { font-size:.85rem; opacity:.7; margin-top:20px; }\n .err { color:#b3261e; } @media (prefers-color-scheme: dark) { .err { color:#f2b8b5; } }\n</style></head><body><main><h1>THTH</h1>\n <p class=\"sub\">Threads の認可の受け口です。ここを直接開いても何もありません。</p>\n <p class=\"note\">ターミナルで <code>thth auth &lt;account&gt;</code> を実行すると、\n 認可 URL が表示されます。承認するとこのページに戻ってくるので、\n 表示された <strong>URL 全体</strong>をターミナルに貼ります。</p></main></body></html>"
12
12
  },
13
13
  {
14
14
  "url": "https://thth.me/callback",
@@ -19,7 +19,18 @@
19
19
  "referrer-policy": "no-referrer",
20
20
  "x-robots-tag": "noindex, nofollow"
21
21
  },
22
- "body": "<!doctype html>\n<html lang=\"ja\"><head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n<meta name=\"robots\" content=\"noindex,nofollow,noarchive\">\n<title>THTH 認可の受け口</title>\n<style>\n :root { color-scheme: light dark; }\n body { margin:0; min-height:100vh; display:flex; align-items:center; justify-content:center;\n font: 16px/1.7 -apple-system, BlinkMacSystemFont, \"Hiragino Sans\", \"Noto Sans JP\", sans-serif;\n background:#faf9f7; color:#1a1a1a; padding:24px; }\n @media (prefers-color-scheme: dark) { body { background:#16161a; color:#eee; } }\n main { max-width: 640px; width:100%; }\n h1 { font-size:1.15rem; font-weight:600; margin:0 0 4px; letter-spacing:.02em; }\n .sub { font-size:.85rem; opacity:.65; margin:0 0 24px; }\n .code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size:.95rem;\n word-break:break-all; background:rgba(127,127,127,.12); border-radius:10px;\n padding:16px; margin:0 0 12px; user-select:all; }\n button { font:inherit; font-size:.9rem; padding:9px 18px; border-radius:8px; cursor:pointer;\n border:1px solid rgba(127,127,127,.4); background:transparent; color:inherit; }\n button:hover { background:rgba(127,127,127,.12); }\n .note { font-size:.85rem; opacity:.7; margin-top:20px; }\n .err { color:#b3261e; } @media (prefers-color-scheme: dark) { .err { color:#f2b8b5; } }\n</style></head><body><main><h1>THTH</h1>\n <p class=\"sub\">Threads の認可の受け口です。ここを直接開いても何もありません。</p>\n <p class=\"note\">ターミナルで <code>thth auth &lt;account&gt;</code> を実行すると、\n 認可 URL が表示されます。承認するとこのページに戻ってきます。</p></main></body></html>"
22
+ "body": "<!doctype html>\n<html lang=\"ja\"><head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n<meta name=\"robots\" content=\"noindex,nofollow,noarchive\">\n<title>THTH 認可の受け口</title>\n<style>\n :root { color-scheme: light dark; }\n body { margin:0; min-height:100vh; display:flex; align-items:center; justify-content:center;\n font: 16px/1.7 -apple-system, BlinkMacSystemFont, \"Hiragino Sans\", \"Noto Sans JP\", sans-serif;\n background:#faf9f7; color:#1a1a1a; padding:24px; }\n @media (prefers-color-scheme: dark) { body { background:#16161a; color:#eee; } }\n main { max-width: 640px; width:100%; }\n h1 { font-size:1.15rem; font-weight:600; margin:0 0 4px; letter-spacing:.02em; }\n .sub { font-size:.85rem; opacity:.65; margin:0 0 24px; }\n .code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size:.95rem;\n word-break:break-all; background:rgba(127,127,127,.12); border-radius:10px;\n padding:16px; margin:0 0 12px; user-select:all; }\n button { font:inherit; font-size:.9rem; padding:9px 18px; border-radius:8px; cursor:pointer;\n border:1px solid rgba(127,127,127,.4); background:transparent; color:inherit; }\n button:hover { background:rgba(127,127,127,.12); }\n .note { font-size:.85rem; opacity:.7; margin-top:20px; }\n .err { color:#b3261e; } @media (prefers-color-scheme: dark) { .err { color:#f2b8b5; } }\n</style></head><body><main><h1>THTH</h1>\n <p class=\"sub\">Threads の認可の受け口です。ここを直接開いても何もありません。</p>\n <p class=\"note\">ターミナルで <code>thth auth &lt;account&gt;</code> を実行すると、\n 認可 URL が表示されます。承認するとこのページに戻ってくるので、\n 表示された <strong>URL 全体</strong>をターミナルに貼ります。</p></main></body></html>"
23
+ },
24
+ {
25
+ "url": "https://thth.me/callback/?code=FAKE-CODE-abc123&state=FAKE-STATE-xyz",
26
+ "status": 200,
27
+ "headers": {
28
+ "cache-control": "no-store",
29
+ "content-type": "text/html; charset=utf-8",
30
+ "referrer-policy": "no-referrer",
31
+ "x-robots-tag": "noindex, nofollow"
32
+ },
33
+ "body": "<!doctype html>\n<html lang=\"ja\"><head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n<meta name=\"robots\" content=\"noindex,nofollow,noarchive\">\n<title>THTH 認可の受け口</title>\n<style>\n :root { color-scheme: light dark; }\n body { margin:0; min-height:100vh; display:flex; align-items:center; justify-content:center;\n font: 16px/1.7 -apple-system, BlinkMacSystemFont, \"Hiragino Sans\", \"Noto Sans JP\", sans-serif;\n background:#faf9f7; color:#1a1a1a; padding:24px; }\n @media (prefers-color-scheme: dark) { body { background:#16161a; color:#eee; } }\n main { max-width: 640px; width:100%; }\n h1 { font-size:1.15rem; font-weight:600; margin:0 0 4px; letter-spacing:.02em; }\n .sub { font-size:.85rem; opacity:.65; margin:0 0 24px; }\n .code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size:.95rem;\n word-break:break-all; background:rgba(127,127,127,.12); border-radius:10px;\n padding:16px; margin:0 0 12px; user-select:all; }\n button { font:inherit; font-size:.9rem; padding:9px 18px; border-radius:8px; cursor:pointer;\n border:1px solid rgba(127,127,127,.4); background:transparent; color:inherit; }\n button:hover { background:rgba(127,127,127,.12); }\n .note { font-size:.85rem; opacity:.7; margin-top:20px; }\n .err { color:#b3261e; } @media (prefers-color-scheme: dark) { .err { color:#f2b8b5; } }\n</style></head><body><main><h1>認可コードを受け取りました</h1>\n <p class=\"sub\">下の <strong>URL 全体</strong>をターミナルに貼ってください\n (<code>code</code> だけでは足りません)。1 時間で切れる使い捨てです。</p>\n <p class=\"code\" id=\"c\">https://thth.me/callback/?code=FAKE-CODE-abc123&amp;state=FAKE-STATE-xyz</p>\n <button id=\"b\">コピー</button>\n \n <p class=\"note\">この画面は撮らないでください。貼り終えたら閉じて構いません。</p>\n <script>\n // アドレスバーからコードを消す(撮影・肩越しの覗き見への備え)。\n try { history.replaceState(null, \"\", location.pathname); } catch (e) {}\n document.getElementById(\"b\").onclick = async () => {\n try {\n await navigator.clipboard.writeText(document.getElementById(\"c\").textContent);\n document.getElementById(\"b\").textContent = \"コピーしました\";\n } catch (e) { document.getElementById(\"b\").textContent = \"手で選んでコピーしてください\"; }\n };\n </script></main></body></html>"
23
34
  },
24
35
  {
25
36
  "url": "https://thth.me/callback/?code=FAKE-CODE-abc123",
@@ -30,7 +41,7 @@
30
41
  "referrer-policy": "no-referrer",
31
42
  "x-robots-tag": "noindex, nofollow"
32
43
  },
33
- "body": "<!doctype html>\n<html lang=\"ja\"><head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n<meta name=\"robots\" content=\"noindex,nofollow,noarchive\">\n<title>THTH 認可の受け口</title>\n<style>\n :root { color-scheme: light dark; }\n body { margin:0; min-height:100vh; display:flex; align-items:center; justify-content:center;\n font: 16px/1.7 -apple-system, BlinkMacSystemFont, \"Hiragino Sans\", \"Noto Sans JP\", sans-serif;\n background:#faf9f7; color:#1a1a1a; padding:24px; }\n @media (prefers-color-scheme: dark) { body { background:#16161a; color:#eee; } }\n main { max-width: 640px; width:100%; }\n h1 { font-size:1.15rem; font-weight:600; margin:0 0 4px; letter-spacing:.02em; }\n .sub { font-size:.85rem; opacity:.65; margin:0 0 24px; }\n .code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size:.95rem;\n word-break:break-all; background:rgba(127,127,127,.12); border-radius:10px;\n padding:16px; margin:0 0 12px; user-select:all; }\n button { font:inherit; font-size:.9rem; padding:9px 18px; border-radius:8px; cursor:pointer;\n border:1px solid rgba(127,127,127,.4); background:transparent; color:inherit; }\n button:hover { background:rgba(127,127,127,.12); }\n .note { font-size:.85rem; opacity:.7; margin-top:20px; }\n .err { color:#b3261e; } @media (prefers-color-scheme: dark) { .err { color:#f2b8b5; } }\n</style></head><body><main><h1>認可コードを受け取りました</h1>\n <p class=\"sub\">下の文字列をターミナルに貼ってください。1 時間で切れる使い捨てです。</p>\n <p class=\"code\" id=\"c\">FAKE-CODE-abc123</p>\n <button id=\"b\">コピー</button>\n <p class=\"note\">この画面は撮らないでください。貼り終えたら閉じて構いません。</p>\n <script>\n // アドレスバーからコードを消す(撮影・肩越しの覗き見への備え)。\n try { history.replaceState(null, \"\", location.pathname); } catch (e) {}\n document.getElementById(\"b\").onclick = async () => {\n try {\n await navigator.clipboard.writeText(document.getElementById(\"c\").textContent);\n document.getElementById(\"b\").textContent = \"コピーしました\";\n } catch (e) { document.getElementById(\"b\").textContent = \"手で選んでコピーしてください\"; }\n };\n </script></main></body></html>"
44
+ "body": "<!doctype html>\n<html lang=\"ja\"><head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n<meta name=\"robots\" content=\"noindex,nofollow,noarchive\">\n<title>THTH 認可の受け口</title>\n<style>\n :root { color-scheme: light dark; }\n body { margin:0; min-height:100vh; display:flex; align-items:center; justify-content:center;\n font: 16px/1.7 -apple-system, BlinkMacSystemFont, \"Hiragino Sans\", \"Noto Sans JP\", sans-serif;\n background:#faf9f7; color:#1a1a1a; padding:24px; }\n @media (prefers-color-scheme: dark) { body { background:#16161a; color:#eee; } }\n main { max-width: 640px; width:100%; }\n h1 { font-size:1.15rem; font-weight:600; margin:0 0 4px; letter-spacing:.02em; }\n .sub { font-size:.85rem; opacity:.65; margin:0 0 24px; }\n .code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size:.95rem;\n word-break:break-all; background:rgba(127,127,127,.12); border-radius:10px;\n padding:16px; margin:0 0 12px; user-select:all; }\n button { font:inherit; font-size:.9rem; padding:9px 18px; border-radius:8px; cursor:pointer;\n border:1px solid rgba(127,127,127,.4); background:transparent; color:inherit; }\n button:hover { background:rgba(127,127,127,.12); }\n .note { font-size:.85rem; opacity:.7; margin-top:20px; }\n .err { color:#b3261e; } @media (prefers-color-scheme: dark) { .err { color:#f2b8b5; } }\n</style></head><body><main><h1>認可コードを受け取りました</h1>\n <p class=\"sub\">下の <strong>URL 全体</strong>をターミナルに貼ってください\n (<code>code</code> だけでは足りません)。1 時間で切れる使い捨てです。</p>\n <p class=\"code\" id=\"c\">https://thth.me/callback/?code=FAKE-CODE-abc123</p>\n <button id=\"b\">コピー</button>\n <p class=\"note err\">state が付いていません。この戻りは <code>thth auth</code> に\n 受け付けられません(もう一度 <code>thth auth</code> から始めてください)。</p>\n <p class=\"note\">この画面は撮らないでください。貼り終えたら閉じて構いません。</p>\n <script>\n // アドレスバーからコードを消す(撮影・肩越しの覗き見への備え)。\n try { history.replaceState(null, \"\", location.pathname); } catch (e) {}\n document.getElementById(\"b\").onclick = async () => {\n try {\n await navigator.clipboard.writeText(document.getElementById(\"c\").textContent);\n document.getElementById(\"b\").textContent = \"コピーしました\";\n } catch (e) { document.getElementById(\"b\").textContent = \"手で選んでコピーしてください\"; }\n };\n </script></main></body></html>"
34
45
  },
35
46
  {
36
47
  "url": "https://thth.me/callback/?error=access_denied&error_description=The+user+denied+your+request",
@@ -52,7 +63,7 @@
52
63
  "referrer-policy": "no-referrer",
53
64
  "x-robots-tag": "noindex, nofollow"
54
65
  },
55
- "body": "<!doctype html>\n<html lang=\"ja\"><head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n<meta name=\"robots\" content=\"noindex,nofollow,noarchive\">\n<title>THTH 認可の受け口</title>\n<style>\n :root { color-scheme: light dark; }\n body { margin:0; min-height:100vh; display:flex; align-items:center; justify-content:center;\n font: 16px/1.7 -apple-system, BlinkMacSystemFont, \"Hiragino Sans\", \"Noto Sans JP\", sans-serif;\n background:#faf9f7; color:#1a1a1a; padding:24px; }\n @media (prefers-color-scheme: dark) { body { background:#16161a; color:#eee; } }\n main { max-width: 640px; width:100%; }\n h1 { font-size:1.15rem; font-weight:600; margin:0 0 4px; letter-spacing:.02em; }\n .sub { font-size:.85rem; opacity:.65; margin:0 0 24px; }\n .code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size:.95rem;\n word-break:break-all; background:rgba(127,127,127,.12); border-radius:10px;\n padding:16px; margin:0 0 12px; user-select:all; }\n button { font:inherit; font-size:.9rem; padding:9px 18px; border-radius:8px; cursor:pointer;\n border:1px solid rgba(127,127,127,.4); background:transparent; color:inherit; }\n button:hover { background:rgba(127,127,127,.12); }\n .note { font-size:.85rem; opacity:.7; margin-top:20px; }\n .err { color:#b3261e; } @media (prefers-color-scheme: dark) { .err { color:#f2b8b5; } }\n</style></head><body><main><h1>認可コードを受け取りました</h1>\n <p class=\"sub\">下の文字列をターミナルに貼ってください。1 時間で切れる使い捨てです。</p>\n <p class=\"code\" id=\"c\">&lt;script&gt;</p>\n <button id=\"b\">コピー</button>\n <p class=\"note\">この画面は撮らないでください。貼り終えたら閉じて構いません。</p>\n <script>\n // アドレスバーからコードを消す(撮影・肩越しの覗き見への備え)。\n try { history.replaceState(null, \"\", location.pathname); } catch (e) {}\n document.getElementById(\"b\").onclick = async () => {\n try {\n await navigator.clipboard.writeText(document.getElementById(\"c\").textContent);\n document.getElementById(\"b\").textContent = \"コピーしました\";\n } catch (e) { document.getElementById(\"b\").textContent = \"手で選んでコピーしてください\"; }\n };\n </script></main></body></html>"
66
+ "body": "<!doctype html>\n<html lang=\"ja\"><head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n<meta name=\"robots\" content=\"noindex,nofollow,noarchive\">\n<title>THTH 認可の受け口</title>\n<style>\n :root { color-scheme: light dark; }\n body { margin:0; min-height:100vh; display:flex; align-items:center; justify-content:center;\n font: 16px/1.7 -apple-system, BlinkMacSystemFont, \"Hiragino Sans\", \"Noto Sans JP\", sans-serif;\n background:#faf9f7; color:#1a1a1a; padding:24px; }\n @media (prefers-color-scheme: dark) { body { background:#16161a; color:#eee; } }\n main { max-width: 640px; width:100%; }\n h1 { font-size:1.15rem; font-weight:600; margin:0 0 4px; letter-spacing:.02em; }\n .sub { font-size:.85rem; opacity:.65; margin:0 0 24px; }\n .code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size:.95rem;\n word-break:break-all; background:rgba(127,127,127,.12); border-radius:10px;\n padding:16px; margin:0 0 12px; user-select:all; }\n button { font:inherit; font-size:.9rem; padding:9px 18px; border-radius:8px; cursor:pointer;\n border:1px solid rgba(127,127,127,.4); background:transparent; color:inherit; }\n button:hover { background:rgba(127,127,127,.12); }\n .note { font-size:.85rem; opacity:.7; margin-top:20px; }\n .err { color:#b3261e; } @media (prefers-color-scheme: dark) { .err { color:#f2b8b5; } }\n</style></head><body><main><h1>認可コードを受け取りました</h1>\n <p class=\"sub\">下の <strong>URL 全体</strong>をターミナルに貼ってください\n (<code>code</code> だけでは足りません)。1 時間で切れる使い捨てです。</p>\n <p class=\"code\" id=\"c\">https://thth.me/callback/?code=%3Cscript%3E</p>\n <button id=\"b\">コピー</button>\n <p class=\"note err\">state が付いていません。この戻りは <code>thth auth</code> に\n 受け付けられません(もう一度 <code>thth auth</code> から始めてください)。</p>\n <p class=\"note\">この画面は撮らないでください。貼り終えたら閉じて構いません。</p>\n <script>\n // アドレスバーからコードを消す(撮影・肩越しの覗き見への備え)。\n try { history.replaceState(null, \"\", location.pathname); } catch (e) {}\n document.getElementById(\"b\").onclick = async () => {\n try {\n await navigator.clipboard.writeText(document.getElementById(\"c\").textContent);\n document.getElementById(\"b\").textContent = \"コピーしました\";\n } catch (e) { document.getElementById(\"b\").textContent = \"手で選んでコピーしてください\"; }\n };\n </script></main></body></html>"
56
67
  },
57
68
  {
58
69
  "url": "https://thth.me/deauthorize",
@@ -6,7 +6,9 @@
6
6
  - "long"(GET /access_token)
7
7
  - "me"(GET /v1.0/me)
8
8
  - "refresh"(GET /refresh_access_token)
9
- 値は "ok" | "4xx" | "5xx" | "no_token"。
9
+ - "debug"(GET /v1.0/debug_token・既定は "absent"=404。"ok" で `data.scopes` に
10
+ `debug_scopes` を返す。`thth auth` が scope の記録元を切り替える試験用)
11
+ 値は "ok" | "4xx" | "5xx" | "no_token"("debug" だけ "absent" も)。
10
12
  """
11
13
  from __future__ import annotations
12
14
 
@@ -24,6 +26,7 @@ class _Handler(http.server.BaseHTTPRequestHandler):
24
26
  refreshed_token = "REFRESHED-SECRET-TOKEN"
25
27
  me_payload = {"id": "999999", "username": "nigamilab"}
26
28
  expires_in = 5184000
29
+ debug_scopes = ["threads_basic", "threads_content_publish"]
27
30
 
28
31
  def _respond_json(self, status: int, payload: dict) -> None:
29
32
  body = json.dumps(payload).encode("utf-8")
@@ -70,6 +73,14 @@ class _Handler(http.server.BaseHTTPRequestHandler):
70
73
  self._respond_for(self.behavior.get("refresh", "ok"),
71
74
  {"access_token": self.refreshed_token, "token_type": "bearer",
72
75
  "expires_in": self.expires_in})
76
+ elif parsed.path == "/v1.0/debug_token":
77
+ mode = self.behavior.get("debug", "absent")
78
+ if mode == "absent":
79
+ self._respond_json(404, {"error": "not found"})
80
+ else:
81
+ self._respond_for(mode, {"data": {"is_valid": True,
82
+ "scopes": list(self.debug_scopes),
83
+ "user_id": "999999"}})
73
84
  else:
74
85
  self._respond_json(404, {"error": "not found"})
75
86
 
@@ -0,0 +1,78 @@
1
+ """**偽の検索できる媒体**(設計 v2 §4.4 の受け入れ・**tests の中だけ**)。
2
+
3
+ 本物の API を 1 つも叩かずに `thth topics <account> --search <語>` の一覧を試す。
4
+ 確かめたいのは 3 つ:
5
+
6
+ - 一覧が **post_id・permalink・投稿ごとの返信・「もう返した」印**を指せる。
7
+ - **返信の数を返さない媒体**(`has_replies` すら無い)で `—` / `null` になる
8
+ (**`n/a` を 0 と混ぜない**)。
9
+ - 本文は画面の先頭 60 字だけで、**ディスクに 1 バイトも落ちない**。
10
+
11
+ `REGISTRY` に 1 行足すだけで通る(境界の証明でもある・§4.2)。
12
+ """
13
+ from __future__ import annotations
14
+
15
+ from thth.adapters import base
16
+
17
+ MEDIUM = "searchfake"
18
+
19
+ # **本文はここにしか無い**——テストはこの綴りがディスクに無いことを走査で確かめる。
20
+ TEXT_A = "偽の検索本文その一・WKPL3A"
21
+ TEXT_B = "偽の検索本文その二・WKPL3B"
22
+ TEXT_LONG = "長" * 80 + "・WKPL3C"
23
+
24
+ # `has_replies` を返す媒体(Threads と同じ形。**数は返らない**)。
25
+ ROWS_HAS_REPLIES = [
26
+ {"message_id": "POST7QXA1", "username": "alice", "text": TEXT_A,
27
+ "timestamp": "2026-09-13T10:00:00+0000", "permalink": "https://fake/POST7QXA1",
28
+ "medium": MEDIUM, "author_key": base.author_key(MEDIUM, "alice"),
29
+ "is_reply": False, "has_replies": True, "topic_tag": "お茶"},
30
+ {"message_id": "POST7QXA2", "username": "bob", "text": TEXT_B,
31
+ "timestamp": "2026-09-14T01:00:00+0000", "permalink": "https://fake/POST7QXA2",
32
+ "medium": MEDIUM, "author_key": base.author_key(MEDIUM, "bob"),
33
+ "is_reply": False, "has_replies": False, "topic_tag": None},
34
+ {"message_id": "POST7QXA3", "username": "carol", "text": TEXT_LONG,
35
+ "timestamp": "2026-09-12T00:00:00+0000", "permalink": "https://fake/POST7QXA3",
36
+ "medium": MEDIUM, "author_key": base.author_key(MEDIUM, "carol"),
37
+ "is_reply": False, "has_replies": True, "topic_tag": "お茶"},
38
+ ]
39
+
40
+ # **返信のことを何も返さない媒体**(数も真偽も無い)。`—` / `null` になる。
41
+ ROWS_SILENT = [
42
+ {"message_id": "POST7QXB1", "username": "alice", "text": TEXT_A,
43
+ "timestamp": "2026-09-13T10:00:00+0000", "permalink": "https://fake/POST7QXB1",
44
+ "medium": MEDIUM, "author_key": base.author_key(MEDIUM, "alice")},
45
+ ]
46
+
47
+ # **数を返す媒体**(`replies` に整数)。数があればその数を出す。
48
+ ROWS_WITH_COUNT = [
49
+ {"message_id": "POST7QXC1", "username": "alice", "text": TEXT_A,
50
+ "timestamp": "2026-09-13T10:00:00+0000", "permalink": "https://fake/POST7QXC1",
51
+ "medium": MEDIUM, "author_key": base.author_key(MEDIUM, "alice"),
52
+ "replies": 12, "has_replies": True},
53
+ ]
54
+
55
+
56
+ class FakeSearchAdapter(base.Adapter):
57
+ """`keyword_search` を持つ媒体。**投稿はしない**(この試験に要らない)。"""
58
+
59
+ CAPABILITIES = frozenset({"keyword_search"})
60
+ # `.token` を置かずに試せるようにする(在るかどうかだけを見る口なので)。
61
+ TOKEN_KEYS: tuple = ()
62
+
63
+ rows: list = ROWS_HAS_REPLIES
64
+ calls: list = []
65
+
66
+ def __init__(self, *, rows=None):
67
+ self.rows = list(rows if rows is not None else type(self).rows)
68
+
69
+ @classmethod
70
+ def from_account(cls, account_cfg: dict, token: dict):
71
+ return cls()
72
+
73
+ def keyword_search(self, q: str, *, search_type: str = "TOP", limit: int = 25) -> list:
74
+ type(self).calls.append((q, search_type, limit))
75
+ return [dict(r) for r in self.rows]
76
+
77
+ def whoami(self) -> dict:
78
+ return {"user_id": "searchfake-1", "username": "searchfake"}
@@ -0,0 +1,139 @@
1
+ """v2.1-B(承認を通る書き込みの口)用の偽 Threads API(`http.server`)。
2
+
3
+ 本物の `graph.threads.net` には一切触れない。受けた要求を**種類ごとに数える**
4
+ ——特に **DELETE を受けた回数**(`deletes`)が、取り下げのテストの証拠になる。
5
+
6
+ `behavior`:
7
+ - "create" : "ok" | "permission"(403・`error.message` に permission)| "4xx"
8
+ - "publish" : "ok" | "5xx"
9
+ - "delete" : "ok" | "permission" | "4xx" | "no_success"(200 だが success 無し)
10
+ - "location" : "ok" | "permission" | "empty"
11
+ - "recent" : "ok" | "absent"(404。URL を引けない経路の試験用)
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import contextlib
16
+ import http.server
17
+ import json
18
+ import threading
19
+ import urllib.parse
20
+
21
+ PERMISSION_BODY = {"error": {"message": "(#10) Application does not have permission "
22
+ "for this action", "type": "OAuthException",
23
+ "code": 10}}
24
+
25
+
26
+ class _Handler(http.server.BaseHTTPRequestHandler):
27
+ behavior: dict = {}
28
+ counter = [1]
29
+ create_params: list
30
+ deletes: list # DELETE を受けたパス(access_token は落として記録)
31
+ locations: list # /location_search に届いた params
32
+ permalink = "https://www.threads.net/@fake/post/ABC"
33
+
34
+ def _respond_json(self, status: int, payload) -> None:
35
+ body = json.dumps(payload).encode("utf-8")
36
+ self.send_response(status)
37
+ self.send_header("Content-Type", "application/json")
38
+ self.send_header("Content-Length", str(len(body)))
39
+ self.end_headers()
40
+ self.wfile.write(body)
41
+
42
+ def do_POST(self) -> None: # noqa: N802
43
+ length = int(self.headers.get("Content-Length") or 0)
44
+ raw = self.rfile.read(length) if length else b""
45
+ parsed = urllib.parse.urlparse(self.path)
46
+ if parsed.path.endswith("/threads_publish"):
47
+ mode = self.behavior.get("publish", "ok")
48
+ if mode == "ok":
49
+ _id = str(self.counter[0])
50
+ self.counter[0] += 1
51
+ self._respond_json(200, {"id": _id})
52
+ else:
53
+ self._respond_json(500, {"error": {"message": "server error (fake)"}})
54
+ return
55
+ if parsed.path.endswith("/threads"):
56
+ params = {k: v[0] for k, v in urllib.parse.parse_qs(raw.decode("utf-8")).items()}
57
+ params.pop("access_token", None)
58
+ self.__class__.create_params.append(params)
59
+ mode = self.behavior.get("create", "ok")
60
+ if mode == "ok":
61
+ _id = str(self.counter[0])
62
+ self.counter[0] += 1
63
+ self._respond_json(200, {"id": _id})
64
+ elif mode == "permission":
65
+ self._respond_json(403, PERMISSION_BODY)
66
+ else:
67
+ self._respond_json(400, {"error": {"message": "bad request (fake)"}})
68
+ return
69
+ self._respond_json(404, {"error": {"message": "not found"}})
70
+
71
+ def do_DELETE(self) -> None: # noqa: N802
72
+ parsed = urllib.parse.urlparse(self.path)
73
+ self.__class__.deletes.append(parsed.path)
74
+ mode = self.behavior.get("delete", "ok")
75
+ media_id = parsed.path.rsplit("/", 1)[-1]
76
+ if mode == "ok":
77
+ self._respond_json(200, {"success": True, "deleted_id": media_id})
78
+ elif mode == "permission":
79
+ self._respond_json(403, PERMISSION_BODY)
80
+ elif mode == "no_success":
81
+ self._respond_json(200, {"deleted_id": media_id})
82
+ else:
83
+ self._respond_json(400, {"error": {"message": "bad request (fake)"}})
84
+
85
+ def do_GET(self) -> None: # noqa: N802
86
+ parsed = urllib.parse.urlparse(self.path)
87
+ params = {k: v[0] for k, v in urllib.parse.parse_qs(parsed.query).items()}
88
+ params.pop("access_token", None)
89
+ if parsed.path == "/v1.0/location_search":
90
+ self.__class__.locations.append(params)
91
+ mode = self.behavior.get("location", "ok")
92
+ if mode == "permission":
93
+ self._respond_json(403, PERMISSION_BODY)
94
+ elif mode == "empty":
95
+ self._respond_json(200, {"data": []})
96
+ else:
97
+ self._respond_json(200, {"data": [
98
+ {"id": "101", "name": "渋谷駅", "city": "渋谷区", "country": "JP"},
99
+ {"id": "102", "name": "渋谷駅前", "city": "渋谷区", "country": "JP"},
100
+ {"id": "103", "name": "渋谷ヒカリエ", "city": "渋谷区", "country": "JP"},
101
+ {"id": "104", "name": "渋谷スクランブル", "city": "渋谷区", "country": "JP"},
102
+ {"id": "105", "name": "渋谷パルコ", "city": "渋谷区", "country": "JP"},
103
+ {"id": "106", "name": "渋谷 6 件目(切り詰め確認)", "city": "渋谷区",
104
+ "country": "JP"},
105
+ ]})
106
+ return
107
+ if parsed.path.endswith("/threads"):
108
+ if self.behavior.get("recent", "ok") == "absent":
109
+ self._respond_json(404, {"error": {"message": "not found"}})
110
+ else:
111
+ self._respond_json(200, {"data": [
112
+ {"id": "777", "permalink": self.permalink,
113
+ "timestamp": "2026-09-14T10:00:00+0000", "text": "出した本文"}]})
114
+ return
115
+ self._respond_json(404, {"error": {"message": "not found"}})
116
+
117
+ def log_message(self, format, *args): # noqa: A002
118
+ pass
119
+
120
+
121
+ class _ServerURL(str):
122
+ handler_cls: type
123
+
124
+
125
+ @contextlib.contextmanager
126
+ def fake_threads_writes_server(behavior: dict | None = None):
127
+ handler_cls = type("Handler", (_Handler,),
128
+ {"behavior": dict(behavior or {}), "counter": [1],
129
+ "create_params": [], "deletes": [], "locations": []})
130
+ server = http.server.HTTPServer(("127.0.0.1", 0), handler_cls)
131
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
132
+ thread.start()
133
+ try:
134
+ url = _ServerURL(f"http://127.0.0.1:{server.server_port}")
135
+ url.handler_cls = handler_cls
136
+ yield url
137
+ finally:
138
+ server.shutdown()
139
+ thread.join(timeout=5)
@@ -2,6 +2,12 @@
2
2
  """テスト専用: `thth.core` が使うのと同じロックファイルを確保して N 秒保持する。
3
3
  `test_20260909_direct_throw_bypassed_lock`(受け入れ 9)で、別プロセスが
4
4
  `thth throw` を直に叩いてもロックを踏むことを確認するための「保持者」役。
5
+
6
+ hold_lock.py <account> <秒> … account のロック(`thth run` が握る側)
7
+ hold_lock.py <account> <秒> repo … **repo のロック**(`thth collect` が握る側)
8
+
9
+ repo の側は `thth board` の「いま collect が走っています」を確かめるのに使う
10
+ (引継ぎ 2026-09-15 §3-D)。
5
11
  """
6
12
  import os
7
13
  import sys
@@ -16,8 +22,13 @@ from thth import lock as lock_mod # noqa: E402
16
22
  def main() -> None:
17
23
  account_name = sys.argv[1]
18
24
  seconds = float(sys.argv[2])
19
- state_dir = accounts_mod.state_dir_for(account_name)
20
- lock_path = os.path.join(state_dir, "lock")
25
+ which = sys.argv[3] if len(sys.argv) > 3 else "account"
26
+ if which == "repo":
27
+ repo_dir = accounts_mod.load_account(account_name)["repo_dir"]
28
+ lock_path = accounts_mod.repo_lock_path_for(repo_dir)
29
+ else:
30
+ state_dir = accounts_mod.state_dir_for(account_name)
31
+ lock_path = os.path.join(state_dir, "lock")
21
32
  account_lock = lock_mod.AccountLock(lock_path)
22
33
  account_lock.acquire()
23
34
  sys.stdout.write("locked\n")
@@ -19,6 +19,7 @@ if (!modulePath) {
19
19
  const URLS = [
20
20
  "https://thth.me/callback/",
21
21
  "https://thth.me/callback",
22
+ "https://thth.me/callback/?code=FAKE-CODE-abc123&state=FAKE-STATE-xyz",
22
23
  "https://thth.me/callback/?code=FAKE-CODE-abc123",
23
24
  "https://thth.me/callback/?error=access_denied&error_description=The+user+denied+your+request",
24
25
  "https://thth.me/callback/?code=%3Cscript%3E&x=1",
@@ -92,9 +92,12 @@ def test_TB0_capabilitiesは実体を作らずに引ける():
92
92
  # 呼ばない(以前は毎回 `errors` に積んでいた・T0 の残件)。
93
93
  # `recent_posts` は F2 の配線(2026-09-13)で足した語。**3 媒体とも持つ**が、
94
94
  # 宛先は媒体が決める(`account_report.fetch_posts()` はこの語で塞ぐだけ)。
95
+ # `keyword_search`・`mentions`・`profile_lookup`・`inbox` は v2.1-A(設計 v2
96
+ # §4.3・2026-09-14)で足した語。Threads の 11 権限を使う読み取りの口 3 つと、
97
+ # 言及を `inbox` に流す配管(v2-3 の芽がそのまま受け皿)。
95
98
  assert threads_mod.ThreadsAdapter.capabilities() == {
96
99
  "topic", "link_preview", "views", "quota", "refresh", "recent_posts",
97
- "account_insights"}
100
+ "account_insights", "keyword_search", "mentions", "profile_lookup", "inbox"}
98
101
 
99
102
 
100
103
  def test_F3_metrics_ofは新しい形だけを受ける():
@@ -303,10 +306,17 @@ def test_TB1_二度走らせても増えない(tmp_path, isolated_account_factor
303
306
 
304
307
 
305
308
  def test_TB1_inboxを持たない媒体には聞かない(tmp_path, isolated_account_factory):
306
- """Threads には `inbox` を呼ばない(`capabilities()` に無い)。"""
307
- account = isolated_account_factory()
309
+ """`inbox` を持たない媒体には呼ばない(`capabilities()` に無い)。
310
+
311
+ **Bluesky で見る**(2026-09-14・v2.1-A)。以前は Threads で見ていたが、Threads は
312
+ 言及(`threads_manage_mentions`)を `inbox` に流すようになった(設計 v2 §4.3)。
313
+ Threads のままだと `inbox()` が呼ばれて `AssertionError` が `collect` の
314
+ `errors` に飲まれ、**呼ばれているのに通る**——見たい性質が見えなくなる。
315
+ """
316
+ from thth.adapters import bluesky as bluesky_mod
317
+ account = isolated_account_factory(media="bluesky")
308
318
 
309
- class _呼ばれたら失敗(threads_mod.ThreadsAdapter):
319
+ class _呼ばれたら失敗(bluesky_mod.BlueskyAdapter):
310
320
  def __init__(self):
311
321
  pass
312
322
 
@@ -316,7 +326,9 @@ def test_TB1_inboxを持たない媒体には聞かない(tmp_path, isolated_acc
316
326
  def account_insights(self, user_id, *, since, until):
317
327
  return {}
318
328
 
319
- collect_mod.collect_once(account["name"], adapter=_呼ばれたら失敗())
329
+ assert "inbox" not in _呼ばれたら失敗.capabilities()
330
+ result = collect_mod.collect_once(account["name"], adapter=_呼ばれたら失敗())
331
+ assert not any(e.startswith("inbox") for e in result["errors"]), result["errors"]
320
332
  assert not os.path.isdir(os.path.join(account["repo_dir"], "data", "sns", "inbox"))
321
333
 
322
334
 
@@ -589,7 +601,11 @@ def test_TB5_threadsの出力は現行と同じ(tmp_path, monkeypatch, isolated_
589
601
  monkeypatch.setattr(doctor_mod, "_get", fake_get)
590
602
  report = doctor_mod.diagnose(account["name"])
591
603
 
592
- assert 叩いた == [
604
+ # **probe を移す前からある 6 行は、口も並びもそのまま**。2026-09-14 に残り
605
+ # 7 権限の行を**この後ろに**足した(`tests/test_doctor_all_permissions.py`)
606
+ # ので、ここは「先頭 6 つが現行と同じ」を固定する(増えた分は許す)。
607
+ 既存 = 6
608
+ assert 叩いた[:既存] == [
593
609
  "/v1.0/me",
594
610
  "/v1.0/999999/threads",
595
611
  "/v1.0/999999/threads_publishing_limit",
@@ -597,7 +613,7 @@ def test_TB5_threadsの出力は現行と同じ(tmp_path, monkeypatch, isolated_
597
613
  "/v1.0/999999/threads_insights",
598
614
  "/v1.0/POST123/conversation",
599
615
  ], 叩いた
600
- assert [p["permission"] for p in report["probes"]] == [
616
+ assert [p["permission"] for p in report["probes"]][:既存] == [
601
617
  "threads_basic", "threads_basic", "threads_content_publish",
602
618
  "threads_manage_insights", "threads_manage_insights", "threads_read_replies",
603
619
  ]
@@ -605,7 +621,9 @@ def test_TB5_threadsの出力は現行と同じ(tmp_path, monkeypatch, isolated_
605
621
  # 人向けの表示(`run_doctor`)が使う鍵。**足すのはよいが、消さない。**
606
622
  assert {"label", "permission", "key", "ok", "detail"} <= set(p)
607
623
  assert "body" not in p, "**本文を返り値に残さない**(値が漏れる)"
608
- assert all(p["ok"] is True for p in report["probes"])
624
+ # 読み取りの口が無い権限(`ok=None`)が後ろに並ぶので、**叩いた行は全部○**。
625
+ assert all(p["ok"] is True for p in report["probes"][:既存])
626
+ assert all(p["ok"] is True for p in report["probes"] if p["ok"] is not None)
609
627
 
610
628
 
611
629
  def test_TB5_投稿が無ければ返信のprobeは判定不能のまま(tmp_path, monkeypatch,