thth 2.7.0__tar.gz → 2.9.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 (301) hide show
  1. {thth-2.7.0 → thth-2.9.0}/PKG-INFO +17 -3
  2. {thth-2.7.0 → thth-2.9.0}/README.md +16 -2
  3. {thth-2.7.0 → thth-2.9.0}/accounts.example/bluesky.json +1 -0
  4. {thth-2.7.0 → thth-2.9.0}/accounts.example/mastodon.json +1 -0
  5. {thth-2.7.0 → thth-2.9.0}/mcp/server.py +53 -5
  6. thth-2.9.0/skills/thth-admin/SKILL.md +14 -0
  7. {thth-2.7.0 → thth-2.9.0}/tests/test_accounts_dir.py +22 -22
  8. thth-2.9.0/tests/test_admin_http_mcp.py +60 -0
  9. thth-2.9.0/tests/test_admin_log.py +121 -0
  10. thth-2.9.0/tests/test_admin_notifications.py +30 -0
  11. thth-2.9.0/tests/test_admin_repairs.py +258 -0
  12. thth-2.9.0/tests/test_admin_report.py +154 -0
  13. {thth-2.7.0 → thth-2.9.0}/tests/test_analytics_comparison.py +21 -0
  14. {thth-2.7.0 → thth-2.9.0}/tests/test_auth.py +31 -31
  15. {thth-2.7.0 → thth-2.9.0}/tests/test_auth_identity_required.py +5 -5
  16. {thth-2.7.0 → thth-2.9.0}/tests/test_bundle.py +18 -0
  17. {thth-2.7.0 → thth-2.9.0}/tests/test_collect.py +33 -0
  18. {thth-2.7.0 → thth-2.9.0}/tests/test_collect_sent.py +1 -1
  19. {thth-2.7.0 → thth-2.9.0}/tests/test_fresh_install.py +6 -6
  20. {thth-2.7.0 → thth-2.9.0}/tests/test_incident_notifications.py +1 -1
  21. thth-2.9.0/tests/test_inflight_tag_body_hash.py +36 -0
  22. {thth-2.7.0 → thth-2.9.0}/tests/test_mastodon_adapter.py +1 -0
  23. {thth-2.7.0 → thth-2.9.0}/tests/test_packaging.py +2 -2
  24. {thth-2.7.0 → thth-2.9.0}/tests/test_select.py +14 -1
  25. {thth-2.7.0 → thth-2.9.0}/tests/test_thread_publish.py +67 -1
  26. {thth-2.7.0 → thth-2.9.0}/tests/test_token_scopes_record.py +4 -4
  27. {thth-2.7.0 → thth-2.9.0}/tests/test_token_set.py +18 -18
  28. thth-2.9.0/tests/test_v28_tags.py +247 -0
  29. {thth-2.7.0 → thth-2.9.0}/tests/test_where_cli.py +7 -1
  30. thth-2.9.0/thth/VERSION +1 -0
  31. {thth-2.7.0 → thth-2.9.0}/thth/account_cli.py +36 -6
  32. {thth-2.7.0 → thth-2.9.0}/thth/account_report.py +7 -1
  33. {thth-2.7.0 → thth-2.9.0}/thth/adapters/base.py +1 -0
  34. {thth-2.7.0 → thth-2.9.0}/thth/adapters/bluesky.py +133 -4
  35. {thth-2.7.0 → thth-2.9.0}/thth/adapters/mastodon.py +96 -2
  36. thth-2.9.0/thth/admin_log.py +260 -0
  37. thth-2.9.0/thth/admin_notifications.py +45 -0
  38. thth-2.9.0/thth/admin_report.py +512 -0
  39. {thth-2.7.0 → thth-2.9.0}/thth/analytics_comparison.py +41 -2
  40. {thth-2.7.0 → thth-2.9.0}/thth/analytics_report.py +2 -2
  41. {thth-2.7.0 → thth-2.9.0}/thth/approval.py +9 -0
  42. {thth-2.7.0 → thth-2.9.0}/thth/bundle.py +22 -3
  43. {thth-2.7.0 → thth-2.9.0}/thth/cli.py +21 -9
  44. {thth-2.7.0 → thth-2.9.0}/thth/collect.py +12 -0
  45. {thth-2.7.0 → thth-2.9.0}/thth/core.py +62 -25
  46. {thth-2.7.0 → thth-2.9.0}/thth/doctor.py +62 -0
  47. {thth-2.7.0 → thth-2.9.0}/thth/handoff_cursor.py +31 -2
  48. {thth-2.7.0 → thth-2.9.0}/thth/incident.py +6 -3
  49. {thth-2.7.0 → thth-2.9.0}/thth/lint.py +8 -3
  50. {thth-2.7.0 → thth-2.9.0}/thth/measured.py +2 -0
  51. {thth-2.7.0 → thth-2.9.0}/thth/oauth.py +66 -6
  52. {thth-2.7.0 → thth-2.9.0}/thth/report.py +13 -7
  53. {thth-2.7.0 → thth-2.9.0}/thth/report_http.py +15 -6
  54. {thth-2.7.0 → thth-2.9.0}/thth/report_isolation.py +51 -5
  55. {thth-2.7.0 → thth-2.9.0}/thth/report_service.py +27 -2
  56. {thth-2.7.0 → thth-2.9.0}/thth/select.py +12 -2
  57. thth-2.9.0/thth/tags.py +124 -0
  58. {thth-2.7.0 → thth-2.9.0}/thth/threads_read_cli.py +23 -2
  59. {thth-2.7.0 → thth-2.9.0}/thth/threadthrow.py +6 -3
  60. {thth-2.7.0 → thth-2.9.0}/thth/where_cli.py +37 -3
  61. thth-2.7.0/thth/VERSION +0 -1
  62. {thth-2.7.0 → thth-2.9.0}/.gitignore +0 -0
  63. {thth-2.7.0 → thth-2.9.0}/LICENSE +0 -0
  64. {thth-2.7.0 → thth-2.9.0}/accounts.example/threads.json +0 -0
  65. {thth-2.7.0 → thth-2.9.0}/bin/thth +0 -0
  66. {thth-2.7.0 → thth-2.9.0}/bin/thth-remote +0 -0
  67. {thth-2.7.0 → thth-2.9.0}/bin/thth-run +0 -0
  68. {thth-2.7.0 → thth-2.9.0}/pyproject.toml +0 -0
  69. {thth-2.7.0 → thth-2.9.0}/skills/thth/SKILL.md +0 -0
  70. {thth-2.7.0 → thth-2.9.0}/systemd/thth@.service +0 -0
  71. {thth-2.7.0 → thth-2.9.0}/tests/__init__.py +0 -0
  72. {thth-2.7.0 → thth-2.9.0}/tests/conftest.py +0 -0
  73. {thth-2.7.0 → thth-2.9.0}/tests/fixtures/build_injection_eval.py +0 -0
  74. {thth-2.7.0 → thth-2.9.0}/tests/fixtures/callback/worker_responses.json +0 -0
  75. {thth-2.7.0 → thth-2.9.0}/tests/fixtures/injection_eval.json +0 -0
  76. {thth-2.7.0 → thth-2.9.0}/tests/fixtures/umami-bile.md +0 -0
  77. {thth-2.7.0 → thth-2.9.0}/tests/helpers/__init__.py +0 -0
  78. {thth-2.7.0 → thth-2.9.0}/tests/helpers/fake_oauth_server.py +0 -0
  79. {thth-2.7.0 → thth-2.9.0}/tests/helpers/fake_push_adapter.py +0 -0
  80. {thth-2.7.0 → thth-2.9.0}/tests/helpers/fake_redirect_server.py +0 -0
  81. {thth-2.7.0 → thth-2.9.0}/tests/helpers/fake_search_adapter.py +0 -0
  82. {thth-2.7.0 → thth-2.9.0}/tests/helpers/fake_threads_reader.py +0 -0
  83. {thth-2.7.0 → thth-2.9.0}/tests/helpers/fake_threads_writes.py +0 -0
  84. {thth-2.7.0 → thth-2.9.0}/tests/helpers/hold_lock.py +0 -0
  85. {thth-2.7.0 → thth-2.9.0}/tests/helpers/hold_repo_lock.py +0 -0
  86. {thth-2.7.0 → thth-2.9.0}/tests/helpers/worker_probe.mjs +0 -0
  87. {thth-2.7.0 → thth-2.9.0}/tests/test_27_acceptance_fixes.py +0 -0
  88. {thth-2.7.0 → thth-2.9.0}/tests/test_account_name_check.py +0 -0
  89. {thth-2.7.0 → thth-2.9.0}/tests/test_account_report.py +0 -0
  90. {thth-2.7.0 → thth-2.9.0}/tests/test_adapter_boundary.py +0 -0
  91. {thth-2.7.0 → thth-2.9.0}/tests/test_after_cli.py +0 -0
  92. {thth-2.7.0 → thth-2.9.0}/tests/test_analytics_report.py +0 -0
  93. {thth-2.7.0 → thth-2.9.0}/tests/test_api_diagnostic.py +0 -0
  94. {thth-2.7.0 → thth-2.9.0}/tests/test_api_error_body.py +0 -0
  95. {thth-2.7.0 → thth-2.9.0}/tests/test_app_env_cli.py +0 -0
  96. {thth-2.7.0 → thth-2.9.0}/tests/test_approval.py +0 -0
  97. {thth-2.7.0 → thth-2.9.0}/tests/test_approve_cmd.py +0 -0
  98. {thth-2.7.0 → thth-2.9.0}/tests/test_ask.py +0 -0
  99. {thth-2.7.0 → thth-2.9.0}/tests/test_atlas_fifth_review.py +0 -0
  100. {thth-2.7.0 → thth-2.9.0}/tests/test_atlas_fourth_review.py +0 -0
  101. {thth-2.7.0 → thth-2.9.0}/tests/test_atlas_review.py +0 -0
  102. {thth-2.7.0 → thth-2.9.0}/tests/test_atlas_second_review.py +0 -0
  103. {thth-2.7.0 → thth-2.9.0}/tests/test_atlas_third_review.py +0 -0
  104. {thth-2.7.0 → thth-2.9.0}/tests/test_audit1_fixes.py +0 -0
  105. {thth-2.7.0 → thth-2.9.0}/tests/test_audit2_fixes.py +0 -0
  106. {thth-2.7.0 → thth-2.9.0}/tests/test_audit_b_counterexamples.py +0 -0
  107. {thth-2.7.0 → thth-2.9.0}/tests/test_audit_b_m1_semantics.py +0 -0
  108. {thth-2.7.0 → thth-2.9.0}/tests/test_audit_b_n1_depth.py +0 -0
  109. {thth-2.7.0 → thth-2.9.0}/tests/test_audit_b_r1_closure.py +0 -0
  110. {thth-2.7.0 → thth-2.9.0}/tests/test_audit_v201.py +0 -0
  111. {thth-2.7.0 → thth-2.9.0}/tests/test_audit_v23_details.py +0 -0
  112. {thth-2.7.0 → thth-2.9.0}/tests/test_behind_remote.py +0 -0
  113. {thth-2.7.0 → thth-2.9.0}/tests/test_bluesky_adapter.py +0 -0
  114. {thth-2.7.0 → thth-2.9.0}/tests/test_bluesky_secret_scrub.py +0 -0
  115. {thth-2.7.0 → thth-2.9.0}/tests/test_board_needs_review.py +0 -0
  116. {thth-2.7.0 → thth-2.9.0}/tests/test_board_running.py +0 -0
  117. {thth-2.7.0 → thth-2.9.0}/tests/test_charcount.py +0 -0
  118. {thth-2.7.0 → thth-2.9.0}/tests/test_cli_entrypoint.py +0 -0
  119. {thth-2.7.0 → thth-2.9.0}/tests/test_cli_help.py +0 -0
  120. {thth-2.7.0 → thth-2.9.0}/tests/test_cli_vm_path.py +0 -0
  121. {thth-2.7.0 → thth-2.9.0}/tests/test_collect_engagements.py +0 -0
  122. {thth-2.7.0 → thth-2.9.0}/tests/test_collect_runs.py +0 -0
  123. {thth-2.7.0 → thth-2.9.0}/tests/test_comparability.py +0 -0
  124. {thth-2.7.0 → thth-2.9.0}/tests/test_control_chars_refused.py +0 -0
  125. {thth-2.7.0 → thth-2.9.0}/tests/test_conversation_paging.py +0 -0
  126. {thth-2.7.0 → thth-2.9.0}/tests/test_count_for.py +0 -0
  127. {thth-2.7.0 → thth-2.9.0}/tests/test_docs_links.py +0 -0
  128. {thth-2.7.0 → thth-2.9.0}/tests/test_doctor.py +0 -0
  129. {thth-2.7.0 → thth-2.9.0}/tests/test_doctor_all_permissions.py +0 -0
  130. {thth-2.7.0 → thth-2.9.0}/tests/test_doctor_next_step.py +0 -0
  131. {thth-2.7.0 → thth-2.9.0}/tests/test_duplicate_front_matter_keys.py +0 -0
  132. {thth-2.7.0 → thth-2.9.0}/tests/test_editorial_audit.py +0 -0
  133. {thth-2.7.0 → thth-2.9.0}/tests/test_editorial_n3_n4.py +0 -0
  134. {thth-2.7.0 → thth-2.9.0}/tests/test_engagements.py +0 -0
  135. {thth-2.7.0 → thth-2.9.0}/tests/test_external_acceptance.py +0 -0
  136. {thth-2.7.0 → thth-2.9.0}/tests/test_fake_api.py +0 -0
  137. {thth-2.7.0 → thth-2.9.0}/tests/test_form_spec.py +0 -0
  138. {thth-2.7.0 → thth-2.9.0}/tests/test_form_spec_cli.py +0 -0
  139. {thth-2.7.0 → thth-2.9.0}/tests/test_forms.py +0 -0
  140. {thth-2.7.0 → thth-2.9.0}/tests/test_front_matter_injection.py +0 -0
  141. {thth-2.7.0 → thth-2.9.0}/tests/test_healthcheck_notifications.py +0 -0
  142. {thth-2.7.0 → thth-2.9.0}/tests/test_hypotheses.py +0 -0
  143. {thth-2.7.0 → thth-2.9.0}/tests/test_hypothesis_boundaries.py +0 -0
  144. {thth-2.7.0 → thth-2.9.0}/tests/test_hypothesis_units.py +0 -0
  145. {thth-2.7.0 → thth-2.9.0}/tests/test_impact_cli.py +0 -0
  146. {thth-2.7.0 → thth-2.9.0}/tests/test_inbox_granted_scopes.py +0 -0
  147. {thth-2.7.0 → thth-2.9.0}/tests/test_independent_thread_review.py +0 -0
  148. {thth-2.7.0 → thth-2.9.0}/tests/test_independent_topic_review.py +0 -0
  149. {thth-2.7.0 → thth-2.9.0}/tests/test_independent_topic_review_r2.py +0 -0
  150. {thth-2.7.0 → thth-2.9.0}/tests/test_inflight.py +0 -0
  151. {thth-2.7.0 → thth-2.9.0}/tests/test_jst.py +0 -0
  152. {thth-2.7.0 → thth-2.9.0}/tests/test_lint.py +0 -0
  153. {thth-2.7.0 → thth-2.9.0}/tests/test_list_projections.py +0 -0
  154. {thth-2.7.0 → thth-2.9.0}/tests/test_llm_trial.py +0 -0
  155. {thth-2.7.0 → thth-2.9.0}/tests/test_llm_trial_engage.py +0 -0
  156. {thth-2.7.0 → thth-2.9.0}/tests/test_lock.py +0 -0
  157. {thth-2.7.0 → thth-2.9.0}/tests/test_m2_independent.py +0 -0
  158. {thth-2.7.0 → thth-2.9.0}/tests/test_maintain.py +0 -0
  159. {thth-2.7.0 → thth-2.9.0}/tests/test_mastodon_visibility.py +0 -0
  160. {thth-2.7.0 → thth-2.9.0}/tests/test_max_per_run.py +0 -0
  161. {thth-2.7.0 → thth-2.9.0}/tests/test_mcp.py +0 -0
  162. {thth-2.7.0 → thth-2.9.0}/tests/test_mcp_robustness.py +0 -0
  163. {thth-2.7.0 → thth-2.9.0}/tests/test_measured.py +0 -0
  164. {thth-2.7.0 → thth-2.9.0}/tests/test_media_accounts.py +0 -0
  165. {thth-2.7.0 → thth-2.9.0}/tests/test_media_next_steps.py +0 -0
  166. {thth-2.7.0 → thth-2.9.0}/tests/test_media_roundtrip.py +0 -0
  167. {thth-2.7.0 → thth-2.9.0}/tests/test_mismatch_diagnostics.py +0 -0
  168. {thth-2.7.0 → thth-2.9.0}/tests/test_n5_reply_null_element.py +0 -0
  169. {thth-2.7.0 → thth-2.9.0}/tests/test_n6_replies_incomplete_ledger.py +0 -0
  170. {thth-2.7.0 → thth-2.9.0}/tests/test_n7_doctor_error_body.py +0 -0
  171. {thth-2.7.0 → thth-2.9.0}/tests/test_no_limits.py +0 -0
  172. {thth-2.7.0 → thth-2.9.0}/tests/test_observation_provenance.py +0 -0
  173. {thth-2.7.0 → thth-2.9.0}/tests/test_operations_handoff.py +0 -0
  174. {thth-2.7.0 → thth-2.9.0}/tests/test_preview.py +0 -0
  175. {thth-2.7.0 → thth-2.9.0}/tests/test_production_mode.py +0 -0
  176. {thth-2.7.0 → thth-2.9.0}/tests/test_pull_cli.py +0 -0
  177. {thth-2.7.0 → thth-2.9.0}/tests/test_queue.py +0 -0
  178. {thth-2.7.0 → thth-2.9.0}/tests/test_quiet_hours.py +0 -0
  179. {thth-2.7.0 → thth-2.9.0}/tests/test_r2_r4_r5.py +0 -0
  180. {thth-2.7.0 → thth-2.9.0}/tests/test_read_cli_bare_runtimeerror.py +0 -0
  181. {thth-2.7.0 → thth-2.9.0}/tests/test_reason_adoption.py +0 -0
  182. {thth-2.7.0 → thth-2.9.0}/tests/test_redact.py +0 -0
  183. {thth-2.7.0 → thth-2.9.0}/tests/test_redirect_token_leak.py +0 -0
  184. {thth-2.7.0 → thth-2.9.0}/tests/test_refresh.py +0 -0
  185. {thth-2.7.0 → thth-2.9.0}/tests/test_refresh_destination.py +0 -0
  186. {thth-2.7.0 → thth-2.9.0}/tests/test_rejudge_new.py +0 -0
  187. {thth-2.7.0 → thth-2.9.0}/tests/test_rejudge_r2_r3.py +0 -0
  188. {thth-2.7.0 → thth-2.9.0}/tests/test_replies.py +0 -0
  189. {thth-2.7.0 → thth-2.9.0}/tests/test_replies_refresh.py +0 -0
  190. {thth-2.7.0 → thth-2.9.0}/tests/test_repo_lock.py +0 -0
  191. {thth-2.7.0 → thth-2.9.0}/tests/test_repo_sync.py +0 -0
  192. {thth-2.7.0 → thth-2.9.0}/tests/test_report_checkout_import.py +0 -0
  193. {thth-2.7.0 → thth-2.9.0}/tests/test_report_http.py +0 -0
  194. {thth-2.7.0 → thth-2.9.0}/tests/test_report_isolation.py +0 -0
  195. {thth-2.7.0 → thth-2.9.0}/tests/test_report_service.py +0 -0
  196. {thth-2.7.0 → thth-2.9.0}/tests/test_reverse_audit.py +0 -0
  197. {thth-2.7.0 → thth-2.9.0}/tests/test_review_case_id_cluster_m2.py +0 -0
  198. {thth-2.7.0 → thth-2.9.0}/tests/test_review_case_id_cluster_r2.py +0 -0
  199. {thth-2.7.0 → thth-2.9.0}/tests/test_review_cli.py +0 -0
  200. {thth-2.7.0 → thth-2.9.0}/tests/test_review_evidence_account.py +0 -0
  201. {thth-2.7.0 → thth-2.9.0}/tests/test_review_form_spec_and_case_id.py +0 -0
  202. {thth-2.7.0 → thth-2.9.0}/tests/test_review_provenance.py +0 -0
  203. {thth-2.7.0 → thth-2.9.0}/tests/test_review_records.py +0 -0
  204. {thth-2.7.0 → thth-2.9.0}/tests/test_revoke.py +0 -0
  205. {thth-2.7.0 → thth-2.9.0}/tests/test_roundtrip.py +0 -0
  206. {thth-2.7.0 → thth-2.9.0}/tests/test_run_missing_env.py +0 -0
  207. {thth-2.7.0 → thth-2.9.0}/tests/test_run_record_failclosed.py +0 -0
  208. {thth-2.7.0 → thth-2.9.0}/tests/test_runs_collected.py +0 -0
  209. {thth-2.7.0 → thth-2.9.0}/tests/test_runs_read_rows.py +0 -0
  210. {thth-2.7.0 → thth-2.9.0}/tests/test_schedule_multiple.py +0 -0
  211. {thth-2.7.0 → thth-2.9.0}/tests/test_schedule_view.py +0 -0
  212. {thth-2.7.0 → thth-2.9.0}/tests/test_scopes_source_requested.py +0 -0
  213. {thth-2.7.0 → thth-2.9.0}/tests/test_search_engage_targets.py +0 -0
  214. {thth-2.7.0 → thth-2.9.0}/tests/test_search_media_routes.py +0 -0
  215. {thth-2.7.0 → thth-2.9.0}/tests/test_secret_value_redact.py +0 -0
  216. {thth-2.7.0 → thth-2.9.0}/tests/test_secrets_grep.py +0 -0
  217. {thth-2.7.0 → thth-2.9.0}/tests/test_security_small_fixes.py +0 -0
  218. {thth-2.7.0 → thth-2.9.0}/tests/test_selfupdate.py +0 -0
  219. {thth-2.7.0 → thth-2.9.0}/tests/test_send.py +0 -0
  220. {thth-2.7.0 → thth-2.9.0}/tests/test_send_cli.py +0 -0
  221. {thth-2.7.0 → thth-2.9.0}/tests/test_send_control_chars.py +0 -0
  222. {thth-2.7.0 → thth-2.9.0}/tests/test_send_media.py +0 -0
  223. {thth-2.7.0 → thth-2.9.0}/tests/test_send_visibility.py +0 -0
  224. {thth-2.7.0 → thth-2.9.0}/tests/test_sent_integrity.py +0 -0
  225. {thth-2.7.0 → thth-2.9.0}/tests/test_server_json.py +0 -0
  226. {thth-2.7.0 → thth-2.9.0}/tests/test_site.py +0 -0
  227. {thth-2.7.0 → thth-2.9.0}/tests/test_skill_doc.py +0 -0
  228. {thth-2.7.0 → thth-2.9.0}/tests/test_study_report.py +0 -0
  229. {thth-2.7.0 → thth-2.9.0}/tests/test_systemd.py +0 -0
  230. {thth-2.7.0 → thth-2.9.0}/tests/test_thread_read.py +0 -0
  231. {thth-2.7.0 → thth-2.9.0}/tests/test_threadrun.py +0 -0
  232. {thth-2.7.0 → thth-2.9.0}/tests/test_threads_read_permissions.py +0 -0
  233. {thth-2.7.0 → thth-2.9.0}/tests/test_threadshape.py +0 -0
  234. {thth-2.7.0 → thth-2.9.0}/tests/test_thth_root.py +0 -0
  235. {thth-2.7.0 → thth-2.9.0}/tests/test_token_exists.py +0 -0
  236. {thth-2.7.0 → thth-2.9.0}/tests/test_topic.py +0 -0
  237. {thth-2.7.0 → thth-2.9.0}/tests/test_topic_advice.py +0 -0
  238. {thth-2.7.0 → thth-2.9.0}/tests/test_topic_cli.py +0 -0
  239. {thth-2.7.0 → thth-2.9.0}/tests/test_topic_models.py +0 -0
  240. {thth-2.7.0 → thth-2.9.0}/tests/test_topic_review_fixes.py +0 -0
  241. {thth-2.7.0 → thth-2.9.0}/tests/test_topic_shelf_observers.py +0 -0
  242. {thth-2.7.0 → thth-2.9.0}/tests/test_topic_store.py +0 -0
  243. {thth-2.7.0 → thth-2.9.0}/tests/test_topics_record_concurrent.py +0 -0
  244. {thth-2.7.0 → thth-2.9.0}/tests/test_topics_tool.py +0 -0
  245. {thth-2.7.0 → thth-2.9.0}/tests/test_trial_frictions.py +0 -0
  246. {thth-2.7.0 → thth-2.9.0}/tests/test_v21b_approved_writes.py +0 -0
  247. {thth-2.7.0 → thth-2.9.0}/tests/test_v31_analysis.py +0 -0
  248. {thth-2.7.0 → thth-2.9.0}/tests/test_v31_collection.py +0 -0
  249. {thth-2.7.0 → thth-2.9.0}/tests/test_value_domains_shown.py +0 -0
  250. {thth-2.7.0 → thth-2.9.0}/tests/test_version.py +0 -0
  251. {thth-2.7.0 → thth-2.9.0}/tests/test_who_cli.py +0 -0
  252. {thth-2.7.0 → thth-2.9.0}/tests/test_writeback_rebase.py +0 -0
  253. {thth-2.7.0 → thth-2.9.0}/tests/test_writeback_validate.py +0 -0
  254. {thth-2.7.0 → thth-2.9.0}/tests/test_x_metrics.py +0 -0
  255. {thth-2.7.0 → thth-2.9.0}/thth/__init__.py +0 -0
  256. {thth-2.7.0 → thth-2.9.0}/thth/__main__.py +0 -0
  257. {thth-2.7.0 → thth-2.9.0}/thth/accounts.py +0 -0
  258. {thth-2.7.0 → thth-2.9.0}/thth/adapters/__init__.py +0 -0
  259. {thth-2.7.0 → thth-2.9.0}/thth/adapters/threads.py +0 -0
  260. {thth-2.7.0 → thth-2.9.0}/thth/after_cli.py +0 -0
  261. {thth-2.7.0 → thth-2.9.0}/thth/analytics_shapes.py +0 -0
  262. {thth-2.7.0 → thth-2.9.0}/thth/analytics_threads.py +0 -0
  263. {thth-2.7.0 → thth-2.9.0}/thth/api_diagnostic.py +0 -0
  264. {thth-2.7.0 → thth-2.9.0}/thth/appenv.py +0 -0
  265. {thth-2.7.0 → thth-2.9.0}/thth/ask.py +0 -0
  266. {thth-2.7.0 → thth-2.9.0}/thth/ask_cli.py +0 -0
  267. {thth-2.7.0 → thth-2.9.0}/thth/collection_context.py +0 -0
  268. {thth-2.7.0 → thth-2.9.0}/thth/collection_status.py +0 -0
  269. {thth-2.7.0 → thth-2.9.0}/thth/engagements.py +0 -0
  270. {thth-2.7.0 → thth-2.9.0}/thth/forms.py +0 -0
  271. {thth-2.7.0 → thth-2.9.0}/thth/healthcheck.py +0 -0
  272. {thth-2.7.0 → thth-2.9.0}/thth/httpsafe.py +0 -0
  273. {thth-2.7.0 → thth-2.9.0}/thth/inflight.py +0 -0
  274. {thth-2.7.0 → thth-2.9.0}/thth/jst.py +0 -0
  275. {thth-2.7.0 → thth-2.9.0}/thth/lock.py +0 -0
  276. {thth-2.7.0 → thth-2.9.0}/thth/maintain.py +0 -0
  277. {thth-2.7.0 → thth-2.9.0}/thth/operations_handoff.py +0 -0
  278. {thth-2.7.0 → thth-2.9.0}/thth/postid.py +0 -0
  279. {thth-2.7.0 → thth-2.9.0}/thth/queuefile.py +0 -0
  280. {thth-2.7.0 → thth-2.9.0}/thth/redact.py +0 -0
  281. {thth-2.7.0 → thth-2.9.0}/thth/replies.py +0 -0
  282. {thth-2.7.0 → thth-2.9.0}/thth/report_details.py +0 -0
  283. {thth-2.7.0 → thth-2.9.0}/thth/retract_cli.py +0 -0
  284. {thth-2.7.0 → thth-2.9.0}/thth/runs.py +0 -0
  285. {thth-2.7.0 → thth-2.9.0}/thth/scopes.py +0 -0
  286. {thth-2.7.0 → thth-2.9.0}/thth/secrets_fs.py +0 -0
  287. {thth-2.7.0 → thth-2.9.0}/thth/selfupdate.py +0 -0
  288. {thth-2.7.0 → thth-2.9.0}/thth/sent.py +0 -0
  289. {thth-2.7.0 → thth-2.9.0}/thth/study_report.py +0 -0
  290. {thth-2.7.0 → thth-2.9.0}/thth/systemd_gen.py +0 -0
  291. {thth-2.7.0 → thth-2.9.0}/thth/thread_read.py +0 -0
  292. {thth-2.7.0 → thth-2.9.0}/thth/threadrun.py +0 -0
  293. {thth-2.7.0 → thth-2.9.0}/thth/threadshape.py +0 -0
  294. {thth-2.7.0 → thth-2.9.0}/thth/topic_advice.py +0 -0
  295. {thth-2.7.0 → thth-2.9.0}/thth/topic_cli.py +0 -0
  296. {thth-2.7.0 → thth-2.9.0}/thth/topic_models.py +0 -0
  297. {thth-2.7.0 → thth-2.9.0}/thth/topic_store.py +0 -0
  298. {thth-2.7.0 → thth-2.9.0}/thth/topics.py +0 -0
  299. {thth-2.7.0 → thth-2.9.0}/thth/who_cli.py +0 -0
  300. {thth-2.7.0 → thth-2.9.0}/thth/writeback.py +0 -0
  301. {thth-2.7.0 → thth-2.9.0}/thth/x_metrics.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: thth
3
- Version: 2.7.0
3
+ Version: 2.9.0
4
4
  Summary: 承認済みの投稿だけを投げる門(Threads・Bluesky・Mastodon)。人が承認し、記録は利用者の git に残る。
5
5
  Project-URL: Homepage, https://thth.me
6
6
  Project-URL: Source, https://github.com/aokings/thth
@@ -33,7 +33,7 @@ watchtower(`~/Developer/watchtower`)の隣に同じ流儀で並べる。watc
33
33
 
34
34
  ## 版
35
35
 
36
- このソースの版は **2.7.0**。現在の設計は [自分の泉](docs/設計_自分の泉_2026-09-16.md)、導入は [自分の Meta アプリで動かす](docs/導入_自分のMetaアプリで動かす.md)、文書の索引は [docs/README.md](docs/README.md)。
36
+ このソースの版は **2.9.0**。現在の設計は [自分の泉](docs/設計_自分の泉_2026-09-16.md)、導入は [自分の Meta アプリで動かす](docs/導入_自分のMetaアプリで動かす.md)、文書の索引は [docs/README.md](docs/README.md)。
37
37
 
38
38
  版の正本は `thth/VERSION`(`server.json` はテストで一致を強制)。開発中の `main` には未配布の変更も含まれます。インストール済みの版は `thth --version`、VM の版と revision は `thth board` で確認してください。
39
39
 
@@ -59,6 +59,12 @@ timer(systemd・`thth systemd` で生成)は10分ごとに実行し、投稿
59
59
 
60
60
  **トピックの棚**(`thth topics`): 観測者ごとに並ぶ・打ち消し `retract-note`・`history`。
61
61
 
62
+ ### v2.8.0: Bluesky/Mastodon のタグ
63
+
64
+ 台帳の `hashtags: true` では本文のタグと `topic` を使えます。`topic: 茶` は公開本文の末尾に `#茶` を付け、同じタグが既にあれば重ねません。`hashtags: false` ではタグを追加せず、`topic` が効かないことを警告します。`max_hashtags` は公開本文のタグの上限(省略時 3、0 以上の整数)です。承認の指紋はタグを含む公開本文を使うため、以前の本文で承認した原稿は `approval_stale` になり、再確認・再承認が必要です。
65
+
66
+ `where`/`topics --search` の JSON は `by_tag` にタグ検索・観測の結果を分けます。ローカルの期間比較は `thth analytics-report <account> --compare-previous --by tag --json`。API で確認できたタグだけを使い、未観測はタグなしと混ぜません。媒体をまたぐ合算はしません。[利用手順](docs/使い方_プロジェクトのセッション向け_2026-09-09.md)・[2.8.0 リリースノート](docs/リリースノート_2.8.0_2026-09-19.md)。
67
+
62
68
  ### v2.7.0 の変更
63
69
 
64
70
  - 指標採集に720時間(30日)を追加し、対象窓を刻みから38日と計算。正常時は指標6回、返信は従来の5回です。返信の採集窓は独立して維持します。
@@ -108,7 +114,7 @@ timer(systemd・`thth systemd` で生成)は10分ごとに実行し、投稿
108
114
  - **英語の文書**: [README.en.md](README.en.md)・[docs/usage.en.md](docs/usage.en.md)・[llms.txt](llms.txt)。
109
115
  - **skill**: `skills/thth/SKILL.md`(wheel にも入る)。
110
116
 
111
- - **動くもの**(`thth --help` の全 40 サブコマンド): `lint`・`preview`・`approve`・`account`・`revoke`・`posts`・`replies`・`measured`・`threads`・`after`・`analytics-report`・`study-report`・`handoff-report`・`serve-reports`・`topics`・`forms`・`queue`・`schedule`・`throw`・`run`・`systemd`・`board`・`collect`・`pull`・`auth`・`refresh`・`maintain`・`send`・`doctor`・`app`・`token`・`ask`・`mentions`・`profile`・`thread`・`where`・`who`・`retract`・`location`・`notifications`。
117
+ - **動くもの**(`thth --help` の全 41 サブコマンド): `lint`・`preview`・`approve`・`account`・`revoke`・`posts`・`replies`・`measured`・`threads`・`after`・`analytics-report`・`study-report`・`handoff-report`・`serve-reports`・`topics`・`forms`・`queue`・`schedule`・`throw`・`run`・`systemd`・`board`・`collect`・`pull`・`auth`・`refresh`・`maintain`・`send`・`doctor`・`app`・`token`・`ask`・`mentions`・`profile`・`thread`・`where`・`who`・`retract`・`location`・`notifications`・`admin`。
112
118
  - **最初の本番投稿の記録**: 2026-09-09、@aoking に疎通確認を 1 本(`17916074118445631`)。
113
119
  - **未着手**: X・Facebook ページ・Instagram の各アダプタ。トピック検索の権限(tester には降りない)。泉のサーバ(v2-5)。
114
120
  - **権限の制約**: tester に降りる scope は 5 つ。削除はできない。
@@ -128,3 +134,11 @@ mcp-name: io.github.aokings/thth
128
134
  [形式と使い方](docs/分析レポート_v1.md)。
129
135
 
130
136
  `handoff-report` / MCP `operations_handoff` はローカル運用状態・通知未処理を鮮度の制約付きで返します。[形式と使い方](docs/運用引継ぎレポート_v1.md)。
137
+
138
+ ## 管理者の読み口(2.9.0)
139
+
140
+ `thth admin inventory|account|log|tokens|timers|release|diff` は秘密値を出さない読み取り専用レポートです。開始時は `thth admin diff --since-last-read`、変化した account は `thth admin account <name>`、週1回 `thth admin tokens`。`--json` で構造化結果を取得できます。既読 cursor の保存は `admin diff --since-last-read --mark-read --by <名前>` の明示時だけです。VM の `admin timers` は観測を `state/_admin/timers.json` に保存します。`doctor <name>` と admin の明示的な `--probe` は秘密を除いた診断記録を `state/<name>/doctor.json` に保存します。HTTP/MCP は時刻付きの記録を読むだけで、観測・保存を実行しません。
141
+
142
+ `account add`(`--force` の上書きも)・`auth`・`token set`・`token revoke` には `--by <名前>` が必須です。既存の作成来歴は上書きしません。`token revoke` はローカルcredential削除でありリモート側の認可取消ではありません。選定した管理変更は既定で管理者へ通知し、台帳の `notify_admin_on_change: false` で停止できます。
143
+
144
+ 認証付きMCPの6道具とHTTPのadmin scopeは [管理者skill](skills/thth-admin/SKILL.md) を参照してください。管理レポートから承認・投稿・設定変更は行いません。ログ追記後のfsync失敗は変更を保持して `durability_unconfirmed`、部分追記は `outcome_uncertain` と報告します。再試行前に台帳とログを確認してください。
@@ -9,7 +9,7 @@ watchtower(`~/Developer/watchtower`)の隣に同じ流儀で並べる。watc
9
9
 
10
10
  ## 版
11
11
 
12
- このソースの版は **2.7.0**。現在の設計は [自分の泉](docs/設計_自分の泉_2026-09-16.md)、導入は [自分の Meta アプリで動かす](docs/導入_自分のMetaアプリで動かす.md)、文書の索引は [docs/README.md](docs/README.md)。
12
+ このソースの版は **2.9.0**。現在の設計は [自分の泉](docs/設計_自分の泉_2026-09-16.md)、導入は [自分の Meta アプリで動かす](docs/導入_自分のMetaアプリで動かす.md)、文書の索引は [docs/README.md](docs/README.md)。
13
13
 
14
14
  版の正本は `thth/VERSION`(`server.json` はテストで一致を強制)。開発中の `main` には未配布の変更も含まれます。インストール済みの版は `thth --version`、VM の版と revision は `thth board` で確認してください。
15
15
 
@@ -35,6 +35,12 @@ timer(systemd・`thth systemd` で生成)は10分ごとに実行し、投稿
35
35
 
36
36
  **トピックの棚**(`thth topics`): 観測者ごとに並ぶ・打ち消し `retract-note`・`history`。
37
37
 
38
+ ### v2.8.0: Bluesky/Mastodon のタグ
39
+
40
+ 台帳の `hashtags: true` では本文のタグと `topic` を使えます。`topic: 茶` は公開本文の末尾に `#茶` を付け、同じタグが既にあれば重ねません。`hashtags: false` ではタグを追加せず、`topic` が効かないことを警告します。`max_hashtags` は公開本文のタグの上限(省略時 3、0 以上の整数)です。承認の指紋はタグを含む公開本文を使うため、以前の本文で承認した原稿は `approval_stale` になり、再確認・再承認が必要です。
41
+
42
+ `where`/`topics --search` の JSON は `by_tag` にタグ検索・観測の結果を分けます。ローカルの期間比較は `thth analytics-report <account> --compare-previous --by tag --json`。API で確認できたタグだけを使い、未観測はタグなしと混ぜません。媒体をまたぐ合算はしません。[利用手順](docs/使い方_プロジェクトのセッション向け_2026-09-09.md)・[2.8.0 リリースノート](docs/リリースノート_2.8.0_2026-09-19.md)。
43
+
38
44
  ### v2.7.0 の変更
39
45
 
40
46
  - 指標採集に720時間(30日)を追加し、対象窓を刻みから38日と計算。正常時は指標6回、返信は従来の5回です。返信の採集窓は独立して維持します。
@@ -84,7 +90,7 @@ timer(systemd・`thth systemd` で生成)は10分ごとに実行し、投稿
84
90
  - **英語の文書**: [README.en.md](README.en.md)・[docs/usage.en.md](docs/usage.en.md)・[llms.txt](llms.txt)。
85
91
  - **skill**: `skills/thth/SKILL.md`(wheel にも入る)。
86
92
 
87
- - **動くもの**(`thth --help` の全 40 サブコマンド): `lint`・`preview`・`approve`・`account`・`revoke`・`posts`・`replies`・`measured`・`threads`・`after`・`analytics-report`・`study-report`・`handoff-report`・`serve-reports`・`topics`・`forms`・`queue`・`schedule`・`throw`・`run`・`systemd`・`board`・`collect`・`pull`・`auth`・`refresh`・`maintain`・`send`・`doctor`・`app`・`token`・`ask`・`mentions`・`profile`・`thread`・`where`・`who`・`retract`・`location`・`notifications`。
93
+ - **動くもの**(`thth --help` の全 41 サブコマンド): `lint`・`preview`・`approve`・`account`・`revoke`・`posts`・`replies`・`measured`・`threads`・`after`・`analytics-report`・`study-report`・`handoff-report`・`serve-reports`・`topics`・`forms`・`queue`・`schedule`・`throw`・`run`・`systemd`・`board`・`collect`・`pull`・`auth`・`refresh`・`maintain`・`send`・`doctor`・`app`・`token`・`ask`・`mentions`・`profile`・`thread`・`where`・`who`・`retract`・`location`・`notifications`・`admin`。
88
94
  - **最初の本番投稿の記録**: 2026-09-09、@aoking に疎通確認を 1 本(`17916074118445631`)。
89
95
  - **未着手**: X・Facebook ページ・Instagram の各アダプタ。トピック検索の権限(tester には降りない)。泉のサーバ(v2-5)。
90
96
  - **権限の制約**: tester に降りる scope は 5 つ。削除はできない。
@@ -104,3 +110,11 @@ mcp-name: io.github.aokings/thth
104
110
  [形式と使い方](docs/分析レポート_v1.md)。
105
111
 
106
112
  `handoff-report` / MCP `operations_handoff` はローカル運用状態・通知未処理を鮮度の制約付きで返します。[形式と使い方](docs/運用引継ぎレポート_v1.md)。
113
+
114
+ ## 管理者の読み口(2.9.0)
115
+
116
+ `thth admin inventory|account|log|tokens|timers|release|diff` は秘密値を出さない読み取り専用レポートです。開始時は `thth admin diff --since-last-read`、変化した account は `thth admin account <name>`、週1回 `thth admin tokens`。`--json` で構造化結果を取得できます。既読 cursor の保存は `admin diff --since-last-read --mark-read --by <名前>` の明示時だけです。VM の `admin timers` は観測を `state/_admin/timers.json` に保存します。`doctor <name>` と admin の明示的な `--probe` は秘密を除いた診断記録を `state/<name>/doctor.json` に保存します。HTTP/MCP は時刻付きの記録を読むだけで、観測・保存を実行しません。
117
+
118
+ `account add`(`--force` の上書きも)・`auth`・`token set`・`token revoke` には `--by <名前>` が必須です。既存の作成来歴は上書きしません。`token revoke` はローカルcredential削除でありリモート側の認可取消ではありません。選定した管理変更は既定で管理者へ通知し、台帳の `notify_admin_on_change: false` で停止できます。
119
+
120
+ 認証付きMCPの6道具とHTTPのadmin scopeは [管理者skill](skills/thth-admin/SKILL.md) を参照してください。管理レポートから承認・投稿・設定変更は行いません。ログ追記後のfsync失敗は変更を保持して `durability_unconfirmed`、部分追記は `outcome_uncertain` と報告します。再試行前に台帳とログを確認してください。
@@ -14,6 +14,7 @@
14
14
  "tick_minutes": 10,
15
15
  "collect_days": 14,
16
16
  "hashtags": false,
17
+ "max_hashtags": 3,
17
18
  "stale_days": 7,
18
19
  "env": "~/.config/thth/demo-bluesky.env",
19
20
  "token": "~/.config/thth/demo-bluesky.token",
@@ -14,6 +14,7 @@
14
14
  "tick_minutes": 10,
15
15
  "collect_days": 14,
16
16
  "hashtags": false,
17
+ "max_hashtags": 3,
17
18
  "stale_days": 7,
18
19
  "env": "~/.config/thth/demo-mastodon.env",
19
20
  "token": "~/.config/thth/demo-mastodon.token",
@@ -160,7 +160,7 @@ TOOLS = [
160
160
  "inputSchema": {
161
161
  "type": "object",
162
162
  "properties": {
163
- "by": {"type": "string", "enum": ["kind", "hour_band", "topic"]},
163
+ "by": {"type": "string", "enum": ["kind", "hour_band", "topic", "tag"]},
164
164
  "compare_previous": {"type": "boolean", "description": "直前の同じ日数との比較(既定false)"},
165
165
  "account": {"type": "string"},
166
166
  "project": {"type": "string"},
@@ -390,7 +390,7 @@ def validate_arguments(name: str, arguments) -> dict:
390
390
  **型が合っていること**(知らない鍵も断る——綴りを間違えたまま既定値で
391
391
  動いたことにしない)。判断はしない(中身の意味は CLI の仕事)。
392
392
  """
393
- schema = _schema_for(name)
393
+ schema = next((tool["inputSchema"] for tool in ADMIN_TOOLS if tool["name"] == name), None) or _schema_for(name)
394
394
  if schema is None:
395
395
  return {} # 知らない道具は `call_tool()` が断る
396
396
  if arguments is None:
@@ -400,8 +400,8 @@ def validate_arguments(name: str, arguments) -> dict:
400
400
  f"{name}: arguments は object で渡してください"
401
401
  f"(受け取った: {type(arguments).__name__})")
402
402
  if name == "analytics_report" and "by" in arguments:
403
- if arguments["by"] not in ("kind", "hour_band", "topic") or arguments.get("compare_previous") is not True:
404
- raise ToolInputError("analytics_report: by は compare_previous=true と kind/hour_band/topic が必要です")
403
+ if arguments["by"] not in ("kind", "hour_band", "topic", "tag") or arguments.get("compare_previous") is not True:
404
+ raise ToolInputError("analytics_report: by は compare_previous=true と kind/hour_band/topic/tag が必要です")
405
405
  if name == "operations_handoff" and "since_last_read" in arguments and type(arguments["since_last_read"]) is not bool:
406
406
  raise ToolInputError("operations_handoff: since_last_read は boolean です")
407
407
  props = schema.get("properties") or {}
@@ -495,9 +495,57 @@ def _topic_stdin(arguments: dict, *, with_proposal: bool) -> tuple:
495
495
  return args, json.dumps(payload, ensure_ascii=False)
496
496
 
497
497
 
498
+ ADMIN_TOOLS = [
499
+ {"name": "thth_admin_" + name, "description": "Read-only administrator " + name,
500
+ "inputSchema": {"type": "object", "properties":
501
+ ({"account": {"type": "string"}, "limit": {"type": "integer"}} if name == "account" else
502
+ {"account": {"type": "string"}, "since": {"type": "string"}, "event": {"type": "string"}} if name == "log" else
503
+ {"since_last_read": {"type": "boolean"}} if name == "diff" else {}),
504
+ "required": ["account"] if name == "account" else [], "additionalProperties": False}}
505
+ for name in ("inventory", "account", "log", "tokens", "release", "diff")]
506
+
507
+
508
+ def admin_context():
509
+ """Authenticate the startup environment, rechecked on list and every call."""
510
+ import hashlib
511
+ import hmac
512
+ from datetime import datetime, timezone
513
+ from pathlib import Path
514
+ path = os.environ.get("THTH_REPORT_CREDENTIALS")
515
+ token = os.environ.get("THTH_REPORT_TOKEN")
516
+ if not path or not token:
517
+ return None
518
+ if APP_DIR not in sys.path:
519
+ sys.path.insert(0, APP_DIR)
520
+ from thth.report_http import load_credentials
521
+ from thth.report_isolation import validate_environment
522
+ try:
523
+ root, credentials = load_credentials(Path(path))
524
+ digest = hashlib.sha256(token.encode()).hexdigest()
525
+ for expected, expiry, revoked, context in credentials:
526
+ if hmac.compare_digest(digest, expected) and not revoked and datetime.now(timezone.utc) < expiry and context.scope == "admin":
527
+ validate_environment(root, context.allowed_accounts, allow_unreadable=True)
528
+ return context
529
+ except (OSError, ValueError, TypeError):
530
+ pass
531
+ return None
532
+
533
+
498
534
  def call_tool(name: str, arguments: dict | None) -> dict:
499
535
  """CLI を呼んで結果を返すだけ。判断(条件分岐・整形)をここに書かない。"""
500
536
  arguments = arguments or {}
537
+ if isinstance(name, str) and name.startswith("thth_admin_"):
538
+ context = admin_context()
539
+ if context is None:
540
+ return {"content": [{"type": "text", "text": "unauthorized"}], "isError": True}
541
+ from thth.report_service import execute_report, ReportServiceError
542
+ try:
543
+ request = {"operation": name[len("thth_"):], **arguments}
544
+ result = execute_report(context, request)
545
+ return {"content": [{"type": "text", "text": json.dumps(result, ensure_ascii=False)}]}
546
+ except (ValueError, TypeError, ReportServiceError) as error:
547
+ reason = "invalid_options" if str(error) == "invalid_options" else "invalid_request"
548
+ return {"content": [{"type": "text", "text": reason}], "isError": True}
501
549
  if name == "thth_lint":
502
550
  proc = run_cli(["lint", arguments["file"], "--json"])
503
551
  text = proc.stdout
@@ -709,7 +757,7 @@ def _handle_request(req: dict):
709
757
  if method == "notifications/initialized":
710
758
  return None
711
759
  if method == "tools/list":
712
- return {"jsonrpc": "2.0", "id": req_id, "result": {"tools": TOOLS}}
760
+ return {"jsonrpc": "2.0", "id": req_id, "result": {"tools": TOOLS + (ADMIN_TOOLS if admin_context() is not None else [])}}
713
761
  if method == "tools/call":
714
762
  params = req.get("params") or {}
715
763
  if not isinstance(params, dict):
@@ -0,0 +1,14 @@
1
+ ---
2
+ name: thth-admin
3
+ description: Read authenticated THTH administrator inventory, changes and token status; hand findings to a person.
4
+ ---
5
+
6
+ 開始時は `thth admin diff --since-last-read --json` を読み、変化があった account を `thth admin account <name> --json` で確認する。週1回 `thth admin tokens --json` を確認する。
7
+
8
+ 読んだ結果から承認・投稿・設定変更をしない。必要な対処は根拠・欠測理由・観測時刻とともに人に渡す。記録が無い null は正常・停止・期限なしの証拠ではない。API probe は人が明示的に必要とした場合の CLI `--probe` のみ。
9
+
10
+ CLI は SSH のOSユーザー権限で実行する。読む位置の更新が明示的に依頼された場合だけ `thth admin diff --since-last-read --mark-read --by <名前>`。HTTP/MCPはcursorを書けない。
11
+
12
+ MCPの6道具は `thth_admin_inventory`, `thth_admin_account`, `thth_admin_log`, `thth_admin_tokens`, `thth_admin_release`, `thth_admin_diff`。MCPプロセスへ `THTH_REPORT_CREDENTIALS`(owner-private service credential JSONのパス)と `THTH_REPORT_TOKEN`(対応するbearer credential)を秘密として渡す。scope adminの有効期限・取消・SHA256照合と全accountのroot分離検査が通るときだけ一覧に表示される。値を会話・ログ・引数に出さない。
13
+
14
+ 資格情報例の構造: schema_version=1, root=絶対パス, credentials=[{sha256, expires_at, revoked:false, accounts:{}, scope:"admin"}]。既存のscope未指定はuserのまま。管理者HTTPのtimerは保存済み観測だけでありlive稼働保証ではない。
@@ -298,7 +298,7 @@ def 媒体ごとの必須(media: str) -> list:
298
298
  @pytest.mark.parametrize("media", ["threads", "bluesky", "mastodon"])
299
299
  def test_addは雛形から外へ1本書く(置き場, media):
300
300
  r = run_thth(["account", "add", f"demo-{media}", "--media", media,
301
- "--project", "demo", *媒体ごとの必須(media)])
301
+ "--project", "demo", *媒体ごとの必須(media), "--by", "test-operator"])
302
302
  assert r.returncode == 0, r.stdout + r.stderr
303
303
 
304
304
  path = os.path.join(置き場["外"], f"demo-{media}.json")
@@ -318,7 +318,7 @@ def test_addが作るものは必ずproduction_false(置き場, media):
318
318
  """**道具が作ったものが、いきなり本物を投げる形で生まれてはいけない**
319
319
  (設計 §4.2「`production: true` を自分で書かない限り dry-run」)。"""
320
320
  r = run_thth(["account", "add", f"demo-{media}", "--media", media,
321
- "--project", "demo", *媒体ごとの必須(media)])
321
+ "--project", "demo", *媒体ごとの必須(media), "--by", "test-operator"])
322
322
  assert r.returncode == 0, r.stdout + r.stderr
323
323
  with open(os.path.join(置き場["外"], f"demo-{media}.json"), encoding="utf-8") as f:
324
324
  data = json.load(f)
@@ -336,7 +336,7 @@ def test_addが作った台帳は_productionを付けて打っても_rehearsal
336
336
  もう一方の関門は開けた状態で見る。
337
337
  """
338
338
  assert run_thth(["account", "add", "demo-threads", "--media", "threads",
339
- "--project", "demo"]).returncode == 0
339
+ "--project", "demo", "--by", "test-operator"]).returncode == 0
340
340
  r = run_thth(["throw", "demo-threads", "--now", "--production"])
341
341
  assert r.stdout.splitlines()[0] == "mode: rehearsal", r.stdout + r.stderr
342
342
 
@@ -344,7 +344,7 @@ def test_addが作った台帳は_productionを付けて打っても_rehearsal
344
344
  def test_addは既にあるものを上書きしない(置き場):
345
345
  台帳を置く(置き場["外"], "demo-threads", handle="先にあったほう")
346
346
  r = run_thth(["account", "add", "demo-threads", "--media", "threads",
347
- "--project", "demo"])
347
+ "--project", "demo", "--by", "test-operator"])
348
348
  assert r.returncode == 1, r.stdout + r.stderr
349
349
  assert "上書きしません" in r.stderr
350
350
  with open(os.path.join(置き場["外"], "demo-threads.json"), encoding="utf-8") as f:
@@ -364,7 +364,7 @@ def test_addは互換のときは書く前にloudに断る_順番を間違えさ
364
364
  台帳を置く(置き場["repo の中"], "repo2-threads")
365
365
 
366
366
  r = run_thth(["account", "add", "demo-threads", "--media", "threads",
367
- "--project", "demo"])
367
+ "--project", "demo", "--by", "test-operator"])
368
368
  assert r.returncode == 1, r.stdout + r.stderr
369
369
  assert "thth account migrate" in r.stderr
370
370
  assert "以後読まれません" in r.stderr
@@ -381,7 +381,7 @@ def test_addは互換のときでもrepoの中に書かない(置き場):
381
381
  """**読みが互換 (c) に落ちていても、書く先は外**(`--force` で進んだとき)。"""
382
382
  台帳を置く(置き場["repo の中"], "repo-threads")
383
383
  r = run_thth(["account", "add", "demo-threads", "--media", "threads",
384
- "--project", "demo", "--force"])
384
+ "--project", "demo", "--force", "--by", "test-operator"])
385
385
  assert r.returncode == 0, r.stdout + r.stderr
386
386
  assert os.path.exists(os.path.join(置き場["外"], "demo-threads.json"))
387
387
  assert not os.path.exists(os.path.join(置き場["repo の中"], "demo-threads.json"))
@@ -396,7 +396,7 @@ def test_addのhandleの既定はprojectであってアカウント名ではな
396
396
  **既定が残るのは Threads だけ**(監査 2・C10)——下の 2 本を見よ。
397
397
  """
398
398
  assert run_thth(["account", "add", "nigamilab-threads", "--media", "threads",
399
- "--project", "nigamilab"]).returncode == 0
399
+ "--project", "nigamilab", "--by", "test-operator"]).returncode == 0
400
400
  with open(os.path.join(置き場["外"], "nigamilab-threads.json"), encoding="utf-8") as f:
401
401
  assert json.load(f)["handle"] == "nigamilab"
402
402
 
@@ -408,7 +408,7 @@ def test_addのhandleの既定はprojectであってアカウント名ではな
408
408
  def test_addのredirect_uriが台帳に入る(置き場):
409
409
  """(a) `--redirect-uri` で渡した値がそのまま台帳に入り、**ダミーは残らない**。"""
410
410
  r = run_thth(["account", "add", "demo-threads", "--media", "threads",
411
- "--project", "demo", "--redirect-uri", "https://thth.me/callback/"])
411
+ "--project", "demo", "--redirect-uri", "https://thth.me/callback/", "--by", "test-operator"])
412
412
  assert r.returncode == 0, r.stdout + r.stderr
413
413
  with open(os.path.join(置き場["外"], "demo-threads.json"), encoding="utf-8") as f:
414
414
  data = json.load(f)
@@ -428,7 +428,7 @@ def test_addはredirect_uriを省いたら次の一手で1行言う(置き場):
428
428
  (**`add` と `auth` の間に、どこにも書かれていない手作業**)。
429
429
  """
430
430
  r = run_thth(["account", "add", "demo-threads", "--media", "threads",
431
- "--project", "demo"])
431
+ "--project", "demo", "--by", "test-operator"])
432
432
  assert r.returncode == 0, r.stdout + r.stderr
433
433
  with open(os.path.join(置き場["外"], "demo-threads.json"), encoding="utf-8") as f:
434
434
  assert json.load(f)["redirect_uri"] == "https://example.invalid/"
@@ -442,7 +442,7 @@ def test_addのredirect_uriはthreads以外では黙って捨てない(置き場
442
442
  """`redirect_uri` を読むのは Threads の `thth auth` だけ。**黙って捨てない**。"""
443
443
  r = run_thth(["account", "add", "demo-bluesky", "--media", "bluesky",
444
444
  "--project", "demo", "--handle", "demo2.bsky.social",
445
- "--redirect-uri", "https://thth.me/callback/"])
445
+ "--redirect-uri", "https://thth.me/callback/", "--by", "test-operator"])
446
446
  assert r.returncode == 2, r.stdout + r.stderr
447
447
  assert "--redirect-uri は threads" in r.stderr, r.stderr
448
448
  assert not os.path.exists(os.path.join(置き場["外"], "demo-bluesky.json"))
@@ -456,7 +456,7 @@ def test_addはblueskyのhandleを必須にする(置き場):
456
456
  **認可を保存しない**。黙って外れた値を書くより、ここで 1 回聞くほうが安い。
457
457
  """
458
458
  r = run_thth(["account", "add", "demo-bluesky", "--media", "bluesky",
459
- "--project", "demo"])
459
+ "--project", "demo", "--by", "test-operator"])
460
460
  assert r.returncode == 2, r.stdout + r.stderr
461
461
  assert "--handle が要ります" in r.stderr, r.stderr
462
462
  assert "name.bsky.social" in r.stderr, r.stderr # **例を添える**
@@ -467,13 +467,13 @@ def test_addはblueskyのhandleを必須にする(置き場):
467
467
  def test_addはmastodonのhandleとinstanceを必須にする(置き場):
468
468
  """(e) Mastodon は利用者名+インスタンス。**どちらも道具には推測できない。**"""
469
469
  handle無し = run_thth(["account", "add", "demo-mastodon", "--media", "mastodon",
470
- "--project", "demo", "--instance", "https://mastodon.social"])
470
+ "--project", "demo", "--instance", "https://mastodon.social", "--by", "test-operator"])
471
471
  assert handle無し.returncode == 2, handle無し.stdout + handle無し.stderr
472
472
  assert "--handle が要ります" in handle無し.stderr, handle無し.stderr
473
473
  assert "--instance https://mastodon.social" in handle無し.stderr, handle無し.stderr
474
474
 
475
475
  instance無し = run_thth(["account", "add", "demo-mastodon", "--media", "mastodon",
476
- "--project", "demo", "--handle", "user"])
476
+ "--project", "demo", "--handle", "user", "--by", "test-operator"])
477
477
  assert instance無し.returncode == 2, instance無し.stdout + instance無し.stderr
478
478
  assert "--instance が要ります" in instance無し.stderr, instance無し.stderr
479
479
  assert "--handle user" in instance無し.stderr, instance無し.stderr
@@ -486,7 +486,7 @@ def test_addはthreadsのhandleを今までどおり既定で埋める(置き場
486
486
  """(f) **Threads は従来どおり。** `--handle` を必須にしたのは他の 2 媒体だけ
487
487
  ——Threads の handle は利用者名そのもので、`--project` の値がだいたい当たる。"""
488
488
  r = run_thth(["account", "add", "nigamilab-threads", "--media", "threads",
489
- "--project", "nigamilab"])
489
+ "--project", "nigamilab", "--by", "test-operator"])
490
490
  assert r.returncode == 0, r.stdout + r.stderr
491
491
  with open(os.path.join(置き場["外"], "nigamilab-threads.json"), encoding="utf-8") as f:
492
492
  assert json.load(f)["handle"] == "nigamilab"
@@ -496,13 +496,13 @@ def test_addのinstanceは媒体で綴りが変わる(置き場):
496
496
  """Mastodon は `instance`、Bluesky は `service`(既存の台帳の綴り)。"""
497
497
  assert run_thth(["account", "add", "demo-mastodon", "--media", "mastodon",
498
498
  "--project", "demo", "--handle", "demo2", "--instance",
499
- "https://example.social"]).returncode == 0
499
+ "https://example.social", "--by", "test-operator"]).returncode == 0
500
500
  with open(os.path.join(置き場["外"], "demo-mastodon.json"), encoding="utf-8") as f:
501
501
  assert json.load(f)["instance"] == "https://example.social"
502
502
 
503
503
  assert run_thth(["account", "add", "demo-bluesky", "--media", "bluesky",
504
504
  "--project", "demo", "--handle", "demo2.bsky.social",
505
- "--instance", "https://pds.example"]).returncode == 0
505
+ "--instance", "https://pds.example", "--by", "test-operator"]).returncode == 0
506
506
  with open(os.path.join(置き場["外"], "demo-bluesky.json"), encoding="utf-8") as f:
507
507
  assert json.load(f)["service"] == "https://pds.example"
508
508
 
@@ -520,7 +520,7 @@ def test_addは置き場の外に書けない_名前を検査する(置き場, t
520
520
  気づけない。`thth posts` が post_id を検査するのと同じ守り方で断る。
521
521
  """
522
522
  外 = 置き場["外"]
523
- r = run_thth(["account", "add", name, "--media", "threads", "--project", "demo"])
523
+ r = run_thth(["account", "add", name, "--media", "threads", "--project", "demo", "--by", "test-operator"])
524
524
  assert r.returncode == 2, r.stdout + r.stderr
525
525
  assert "使えない字" in r.stderr, r.stderr
526
526
 
@@ -536,23 +536,23 @@ def test_addは普通の名前を断らない(置き場):
536
536
  """検査が厳しすぎて実在の綴りを弾いていないこと(`.`・`-`・`_` は通る)。"""
537
537
  for name in ("nigamilab-threads", "asmon_kanto-threads", "a.b-mastodon"):
538
538
  r = run_thth(["account", "add", name, "--media", "threads",
539
- "--project", "demo"])
539
+ "--project", "demo", "--by", "test-operator"])
540
540
  assert r.returncode == 0, f"{name}: {r.stdout}{r.stderr}"
541
541
  assert os.path.exists(os.path.join(置き場["外"], f"{name}.json"))
542
542
 
543
543
 
544
544
  def test_addは足りない指定を黙って埋めない(置き場):
545
545
  知らない媒体 = run_thth(["account", "add", "demo-x", "--media", "mixi",
546
- "--project", "demo"])
546
+ "--project", "demo", "--by", "test-operator"])
547
547
  assert 知らない媒体.returncode == 2
548
548
  assert "invalid choice" in 知らない媒体.stderr
549
549
 
550
- project無し = run_thth(["account", "add", "demo-threads", "--media", "threads"])
550
+ project無し = run_thth(["account", "add", "demo-threads", "--media", "threads", "--by", "test-operator"])
551
551
  assert project無し.returncode == 2, project無し.stdout + project無し.stderr
552
552
  assert "--project" in project無し.stderr
553
553
  assert not os.path.exists(os.path.join(置き場["外"], "demo-threads.json"))
554
554
 
555
- media無し = run_thth(["account", "add", "demo-threads", "--project", "demo"])
555
+ media無し = run_thth(["account", "add", "demo-threads", "--project", "demo", "--by", "test-operator"])
556
556
  assert media無し.returncode == 2, media無し.stdout + media無し.stderr
557
557
  assert "--media" in media無し.stderr
558
558
 
@@ -656,7 +656,7 @@ def test_置き場がファイルならaddは言葉で断る(置き場):
656
656
  f.write("これはディレクトリではない\n")
657
657
 
658
658
  r = run_thth(["account", "add", "demo-threads", "--media", "threads",
659
- "--project", "demo"])
659
+ "--project", "demo", "--by", "test-operator"])
660
660
  assert "Traceback" not in r.stderr, r.stderr
661
661
  assert r.returncode == 2, r.stdout + r.stderr
662
662
  assert "台帳を書けませんでした" in r.stderr, r.stderr
@@ -0,0 +1,60 @@
1
+ import hashlib
2
+ import json
3
+ from datetime import datetime,timedelta,timezone
4
+ import pytest
5
+ from thth import accounts, report_http, admin_report
6
+ from thth.report_service import ReportContext,ReportServiceError,execute_report
7
+ from tests.test_mcp import _load_server_module
8
+
9
+ @pytest.fixture
10
+ def credentials(tmp_path,monkeypatch):
11
+ root=tmp_path/'tenant';root.mkdir(mode=0o700);(root/'accounts').mkdir()
12
+ monkeypatch.setenv('THTH_ROOT',str(root));monkeypatch.setenv('THTH_ACCOUNTS_DIR',str(root/'accounts'))
13
+ for name in ('first','second'):
14
+ cfg={k:None for k in accounts.REQUIRED_FIELDS}
15
+ cfg.update(account=name,project=name,media='threads',handle=name,repo_dir=str(root/'repos/_none'),
16
+ token=str(root/'secret'/name),env=str(root/'secret'/('env-'+name)),queue_dir='docs/sns/queue',replies_dir='data/sns/replies')
17
+ (root/'accounts'/(name+'.json')).write_text(json.dumps(cfg))
18
+ token='a'*43
19
+ value=dict(schema_version=1,root=str(root),credentials=[dict(sha256=hashlib.sha256(token.encode()).hexdigest(),
20
+ expires_at=(datetime.now(timezone.utc)+timedelta(hours=1)).isoformat(),revoked=False,accounts={},scope='admin')])
21
+ path=tmp_path/'credential.json';path.write_text(json.dumps(value));path.chmod(0o600)
22
+ return path,token,value
23
+
24
+ def test_user_admin_negative_contract():
25
+ user=ReportContext({'first':'first'})
26
+ with pytest.raises(ReportServiceError,match='unsupported_operation'):execute_report(user,dict(operation='admin_inventory'))
27
+ admin=ReportContext({'first':'first'},scope='admin')
28
+ for operation in ('admin_inventory','admin_diff','admin_account'):
29
+ with pytest.raises(ReportServiceError,match='invalid_request'):execute_report(admin,dict(operation=operation,mark_read=True))
30
+
31
+ def test_admin_all_registry_and_mcp_auth_rechecked(credentials,monkeypatch):
32
+ path,token,value=credentials
33
+ root,creds=report_http.load_credentials(path)
34
+ assert set(creds[0][3].allowed_accounts)=={'first','second'}
35
+ server=_load_server_module()
36
+ assert server.admin_context() is None
37
+ assert server.call_tool('thth_admin_inventory',{})['content'][0]['text']=='unauthorized'
38
+ monkeypatch.setenv('THTH_REPORT_CREDENTIALS',str(path));monkeypatch.setenv('THTH_REPORT_TOKEN',token)
39
+ assert server.admin_context().scope=='admin'
40
+ assert len(server.ADMIN_TOOLS)==6
41
+ result=server.call_tool('thth_admin_inventory',{})
42
+ assert set(json.loads(result['content'][0]['text'])['by_account'])=={'first','second'}
43
+ assert server.call_tool('thth_admin_diff',{'mark_read':True})['isError']
44
+ value['credentials'][0]['revoked']=True;path.write_text(json.dumps(value))
45
+ assert server.admin_context() is None
46
+ assert server.call_tool('thth_admin_inventory',{})['content'][0]['text']=='unauthorized'
47
+
48
+ def test_admin_preflight_checks_unlisted_account(credentials):
49
+ path,token,value=credentials
50
+ root=path.parent/'tenant';cfgpath=root/'accounts/second.json';cfg=json.loads(cfgpath.read_text());cfg['token']='/outside/secret';cfgpath.write_text(json.dumps(cfg))
51
+ with pytest.raises(ValueError):report_http.PrivateReportServer(path,0)
52
+
53
+ def test_admin_shared_state_symlink_is_rejected(credentials):
54
+ path,token,value=credentials
55
+ root=path.parent/'tenant';outside=path.parent/'external';outside.mkdir()
56
+ (root/'state').mkdir();(root/'state/_admin').symlink_to(outside)
57
+ (outside/'timers.json').write_text(json.dumps(dict(by_account={'first':dict(observed_at=datetime.now(timezone.utc).isoformat(),units=[dict(unit='FAKE_EXTERNAL_SECRET')])})))
58
+ with pytest.raises(ValueError):report_http.PrivateReportServer(path,0)
59
+ report=admin_report.answer('timers',via='http')
60
+ assert 'FAKE_EXTERNAL_SECRET' not in json.dumps(report)
@@ -0,0 +1,121 @@
1
+ import json
2
+ import os
3
+ from pathlib import Path
4
+ from types import SimpleNamespace
5
+ import pytest
6
+ from thth import account_cli, accounts, admin_log, oauth
7
+
8
+ @pytest.fixture
9
+ def root(tmp_path, monkeypatch):
10
+ monkeypatch.setenv('THTH_ROOT', str(tmp_path))
11
+ monkeypatch.setenv('THTH_ACCOUNTS_DIR', str(tmp_path / 'accounts'))
12
+ return tmp_path
13
+
14
+ def args(**kw):
15
+ value = dict(name='test-threads', by='tester', media='threads', project='test', handle='tester',
16
+ instance=None, repo_dir=None, redirect_uri=None, force=False, json=True)
17
+ value.update(kw)
18
+ return SimpleNamespace(**value)
19
+
20
+ def test_actor_required_before_any_write(root):
21
+ assert account_cli.cmd_add(args(by=None)) == 2
22
+ assert not (root / 'accounts').exists()
23
+ for fun in (oauth.run_auth, oauth.run_auth_bluesky, oauth.run_token_set):
24
+ assert fun('missing', log=lambda _: None) == 2
25
+
26
+ def test_creation_and_force_preserve_provenance(root):
27
+ assert account_cli.cmd_add(args()) == 0
28
+ path = root / 'accounts/test-threads.json'
29
+ old = json.loads(path.read_text())
30
+ assert old['provenance']['created_by'] == 'tester'
31
+ assert old['provenance']['created_via'] == 'cli'
32
+ assert account_cli.cmd_add(args(force=True, by='second', handle='changed')) == 0
33
+ current = json.loads(path.read_text())
34
+ assert current['provenance'] == old['provenance']
35
+ assert current['handle'] == 'changed'
36
+ rows, broken = admin_log.read()
37
+ assert [r['event'] for r in rows] == ['account_added', 'account_updated']
38
+ assert rows[-1]['diff']['handle'] == ['tester', 'changed']
39
+ assert not broken
40
+ before = path.read_bytes()
41
+ assert account_cli.cmd_add(args(force=True, by=None)) == 2
42
+ assert path.read_bytes() == before
43
+
44
+ def test_log_append_mode_secret_and_broken(root):
45
+ cfg = {'media': 'threads'}
46
+ diff = admin_log.difference({'token':'FAKE_OLD_TOKEN','env':'FAKE_OLD_SECRET','notification_email':'a@example.test'},
47
+ {'token':'FAKE_NEW_TOKEN','env':'FAKE_NEW_SECRET','notification_email':'b@example.test'})
48
+ admin_log.append('token_set', 'demo', cfg, by='tester', diff=diff)
49
+ path = root / 'state/_admin/accounts.ndjson'
50
+ first = path.read_bytes()
51
+ admin_log.append('production_enabled', 'demo', cfg, by='tester', diff={'production':[False,True]})
52
+ assert path.read_bytes().startswith(first)
53
+ assert path.stat().st_mode & 0o777 == 0o600
54
+ assert 'FAKE_' not in path.read_text() and '@' not in path.read_text()
55
+ assert json.loads(first)['diff']['token'] == ['present','present']
56
+ with path.open('a') as f: f.write('broken\n[]\n')
57
+ rows, broken = admin_log.read(event='token_set')
58
+ assert len(rows) == 1 and broken == 2
59
+
60
+ def test_log_symlink_refused(root):
61
+ target = root / 'outside'; target.write_text('unchanged')
62
+ (root / 'state/_admin').mkdir(parents=True)
63
+ (root / 'state/_admin/accounts.ndjson').symlink_to(target)
64
+ with pytest.raises(OSError):
65
+ admin_log.append('token_set', 'demo', {'media':'threads'}, by='tester')
66
+ assert target.read_text() == 'unchanged'
67
+
68
+ def test_revoke_logs_no_secret(root):
69
+ assert account_cli.cmd_add(args()) == 0
70
+ path = root / 'accounts/test-threads.json'
71
+ cfg = json.loads(path.read_text()); token = root / 'fake.token'
72
+ cfg['token'] = str(token); path.write_text(json.dumps(cfg))
73
+ token.write_text('{"access_token":"FAKE_PRIVATE_TOKEN"}')
74
+ assert oauth.run_token_revoke('test-threads', by=None) == 2
75
+ assert token.exists()
76
+ assert oauth.run_token_revoke('test-threads', by='tester') == 0
77
+ assert not token.exists()
78
+ rows, _ = admin_log.read(event='token_revoked')
79
+ assert rows[0]['diff']['token'] == ['present','absent']
80
+
81
+ @pytest.mark.parametrize('bad', ['permissions','symlink','fifo'])
82
+ def test_invalid_log_blocks_mutation(root, bad):
83
+ assert account_cli.cmd_add(args()) == 0
84
+ path = root/'accounts/test-threads.json'; old = path.read_bytes()
85
+ log=root/'state/_admin/accounts.ndjson'
86
+ if bad=='permissions': log.chmod(0o644)
87
+ else:
88
+ log.unlink()
89
+ if bad=='symlink': log.symlink_to(root/'outside')
90
+ else: os.mkfifo(log)
91
+ assert account_cli.cmd_add(args(force=True,by='second',handle='changed'))==2
92
+ assert path.read_bytes()==old
93
+
94
+ def test_log_rejects_malformed_diff_and_scrubs_other_columns(root):
95
+ admin_log.append('token_set','demo',{'media':'threads'},by='tester')
96
+ path=root/'state/_admin/accounts.ndjson'; row=json.loads(path.read_text())
97
+ row['diff']={'token':'FAKE_RAW_TOKEN'}
98
+ with path.open('a') as f:f.write(json.dumps(row)+'\n')
99
+ rows,broken=admin_log.read()
100
+ assert broken==1 and len(rows)==1 and 'FAKE_RAW_TOKEN' not in json.dumps(rows)
101
+
102
+ def test_late_log_failure_restores_original(root, monkeypatch):
103
+ assert account_cli.cmd_add(args()) == 0
104
+ path=root/'accounts/test-threads.json'; before=path.read_bytes()
105
+ monkeypatch.setattr(admin_log.os,'write',lambda *a: (_ for _ in ()).throw(OSError('disk full')))
106
+ assert account_cli.cmd_add(args(force=True,by='second',handle='changed'))==2
107
+ assert path.read_bytes()==before
108
+
109
+ def test_fsync_failure_keeps_record_and_change_consistent(root, monkeypatch, capsys):
110
+ assert account_cli.cmd_add(args())==0
111
+ monkeypatch.setattr(admin_log.os,'fsync',lambda *a: (_ for _ in ()).throw(OSError('fsync failed')))
112
+ assert account_cli.cmd_add(args(force=True,by='second',handle='changed'))==2
113
+ path=root/'accounts/test-threads.json'
114
+ assert json.loads(path.read_text())['handle']=='changed'
115
+ rows,broken=admin_log.read()
116
+ assert rows[-1]['diff']['handle']==['tester','changed'] and broken==0
117
+ assert 'durability_unconfirmed' in capsys.readouterr().err
118
+
119
+ def test_bluesky_jwt_diff_is_presence_only():
120
+ diff=admin_log.difference({'accessJwt':'FAKE_BSKY_ACCESS_JWT','refreshJwt':'FAKE_BSKY_REFRESH_JWT'}, {})
121
+ assert diff=={'accessJwt':['present','absent'],'refreshJwt':['present','absent']}