thth 3.3.0__tar.gz → 3.3.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {thth-3.3.0 → thth-3.3.1}/PKG-INFO +2 -2
- {thth-3.3.0 → thth-3.3.1}/README.md +1 -1
- {thth-3.3.0 → thth-3.3.1}/skills/thth/SKILL.md +1 -1
- {thth-3.3.0 → thth-3.3.1}/tests/conftest.py +10 -0
- thth-3.3.1/tests/helpers/fake_threads_status.py +123 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_incident_notifications.py +4 -2
- {thth-3.3.0 → thth-3.3.1}/tests/test_v210_json_contract.py +3 -1
- thth-3.3.1/tests/test_v331_inflight_cli.py +156 -0
- thth-3.3.1/tests/test_v331_listing_resolve.py +136 -0
- thth-3.3.1/tests/test_v331_outbox_retention.py +135 -0
- thth-3.3.1/tests/test_v331_publish_status.py +213 -0
- thth-3.3.1/tests/test_v331_self_resolve.py +222 -0
- thth-3.3.1/thth/VERSION +1 -0
- {thth-3.3.0 → thth-3.3.1}/thth/adapters/base.py +6 -0
- {thth-3.3.0 → thth-3.3.1}/thth/adapters/threads.py +177 -7
- {thth-3.3.0 → thth-3.3.1}/thth/admin_log.py +3 -1
- {thth-3.3.0 → thth-3.3.1}/thth/cli.py +9 -0
- {thth-3.3.0 → thth-3.3.1}/thth/core.py +92 -13
- {thth-3.3.0 → thth-3.3.1}/thth/healthcheck.py +11 -1
- {thth-3.3.0 → thth-3.3.1}/thth/incident.py +77 -3
- {thth-3.3.0 → thth-3.3.1}/thth/inflight.py +61 -8
- thth-3.3.1/thth/inflight_cli.py +301 -0
- thth-3.3.1/thth/inflight_resolve.py +435 -0
- {thth-3.3.0 → thth-3.3.1}/thth/leave_gate.py +1 -1
- {thth-3.3.0 → thth-3.3.1}/thth/morning.py +18 -0
- {thth-3.3.0 → thth-3.3.1}/thth/runs.py +8 -0
- thth-3.3.0/thth/VERSION +0 -1
- {thth-3.3.0 → thth-3.3.1}/.gitignore +0 -0
- {thth-3.3.0 → thth-3.3.1}/LICENSE +0 -0
- {thth-3.3.0 → thth-3.3.1}/accounts.example/bluesky.json +0 -0
- {thth-3.3.0 → thth-3.3.1}/accounts.example/mastodon.json +0 -0
- {thth-3.3.0 → thth-3.3.1}/accounts.example/threads.json +0 -0
- {thth-3.3.0 → thth-3.3.1}/accounts.example/x.json +0 -0
- {thth-3.3.0 → thth-3.3.1}/bin/thth +0 -0
- {thth-3.3.0 → thth-3.3.1}/bin/thth-remote +0 -0
- {thth-3.3.0 → thth-3.3.1}/bin/thth-run +0 -0
- {thth-3.3.0 → thth-3.3.1}/mcp/server.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/pyproject.toml +0 -0
- {thth-3.3.0 → thth-3.3.1}/skills/thth-admin/SKILL.md +0 -0
- {thth-3.3.0 → thth-3.3.1}/systemd/thth@.service +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/__init__.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/coordination_snapshot.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/fixtures/GraphemeBreakTest-17.0.0.txt +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/fixtures/build_injection_eval.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/fixtures/callback/worker_responses.json +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/fixtures/injection_eval.json +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/fixtures/umami-bile.md +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/helpers/__init__.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/helpers/fake_oauth_server.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/helpers/fake_push_adapter.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/helpers/fake_redirect_server.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/helpers/fake_search_adapter.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/helpers/fake_threads_reader.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/helpers/fake_threads_writes.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/helpers/fake_x.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/helpers/hold_lock.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/helpers/hold_repo_lock.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/helpers/worker_probe.mjs +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_27_acceptance_fixes.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_account_name_check.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_account_report.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_accounts_dir.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_adapter_boundary.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_admin_http_mcp.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_admin_log.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_admin_notifications.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_admin_repairs.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_admin_report.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_after_cli.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_analytics_comparison.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_analytics_report.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_api_diagnostic.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_api_error_body.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_app_env_cli.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_approval.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_approve_cmd.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_ask.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_atlas_fifth_review.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_atlas_fourth_review.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_atlas_review.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_atlas_second_review.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_atlas_third_review.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_audit1_fixes.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_audit2_fixes.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_audit_b_counterexamples.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_audit_b_m1_semantics.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_audit_b_n1_depth.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_audit_b_r1_closure.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_audit_v201.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_audit_v23_details.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_auth.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_auth_identity_required.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_behind_remote.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_bluesky_adapter.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_bluesky_secret_scrub.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_board_needs_review.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_board_running.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_bundle.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_charcount.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_cli_entrypoint.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_cli_help.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_cli_vm_path.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_collect.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_collect_engagements.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_collect_runs.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_collect_sent.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_comparability.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_control_chars_refused.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_conversation_paging.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_count_for.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_docs_links.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_doctor.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_doctor_all_permissions.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_doctor_next_step.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_duplicate_front_matter_keys.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_editorial_audit.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_editorial_n3_n4.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_engagements.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_external_acceptance.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_fake_api.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_form_spec.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_form_spec_cli.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_forms.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_fresh_install.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_front_matter_injection.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_healthcheck_notifications.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_hypotheses.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_hypothesis_boundaries.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_hypothesis_units.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_impact_cli.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_inbox_granted_scopes.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_independent_thread_review.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_independent_topic_review.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_independent_topic_review_r2.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_inflight.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_inflight_tag_body_hash.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_jst.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_lint.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_list_projections.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_llm_trial.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_llm_trial_engage.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_lock.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_m2_independent.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_maintain.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_mastodon_adapter.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_mastodon_visibility.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_max_per_run.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_mcp.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_mcp_robustness.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_measured.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_media_accounts.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_media_next_steps.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_media_roundtrip.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_mismatch_diagnostics.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_n5_reply_null_element.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_n6_replies_incomplete_ledger.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_n7_doctor_error_body.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_no_limits.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_observation_provenance.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_operations_handoff.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_packaging.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_preview.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_production_mode.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_pull_cli.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_queue.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_quiet_hours.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_r2_r4_r5.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_read_cli_bare_runtimeerror.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_reason_adoption.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_redact.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_redirect_token_leak.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_refresh.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_refresh_destination.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_rejudge_new.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_rejudge_r2_r3.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_replies.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_replies_refresh.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_repo_lock.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_repo_sync.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_report_checkout_import.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_report_http.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_report_isolation.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_report_service.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_reverse_audit.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_review_case_id_cluster_m2.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_review_case_id_cluster_r2.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_review_cli.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_review_evidence_account.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_review_form_spec_and_case_id.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_review_provenance.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_review_records.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_revoke.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_roundtrip.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_run_missing_env.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_run_record_failclosed.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_runs_collected.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_runs_read_rows.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_schedule_multiple.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_schedule_view.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_scopes_source_requested.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_search_engage_targets.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_search_media_routes.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_secret_value_redact.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_secrets_grep.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_security_small_fixes.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_select.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_selfupdate.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_send.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_send_cli.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_send_control_chars.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_send_media.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_send_visibility.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_sent_integrity.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_server_json.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_site.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_skill_doc.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_study_report.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_systemd.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_thread_publish.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_thread_read.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_threadrun.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_threads_read_permissions.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_threadshape.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_thth_root.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_token_exists.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_token_scopes_record.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_token_set.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_topic.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_topic_advice.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_topic_cli.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_topic_models.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_topic_review_fixes.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_topic_shelf_observers.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_topic_store.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_topics_record_concurrent.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_topics_tool.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_trial_frictions.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v210_admin_transaction.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v210_approve_account.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v210_length.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v210_lock_wait.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v210_mastodon_scopes.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v210_mentions.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v210_post_urls.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v210_repo_paths.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v210_reposts.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v210_study_add.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v210_tool_version.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v210_unanswered.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v210_unanswered_scope.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v210_where_arguments.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v210_where_filters.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v210_who_arguments.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v211_auth_integration.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v211_authflow.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v211_bluesky_stdin.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v211_doctor_binding.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v211_mastodon_auth.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v211_x_auth.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v212_approval_relay.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v212_ask_coordination.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v212_budget.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v212_budget_auth.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v212_budget_mcp.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v212_deletion.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v212_job_completion_fault.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v212_leave.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v212_leave_gate.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v212_leave_providers.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v212_leave_token_safety.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v212_mastodon_guide.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v212_mcp_compat.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v212_missing_token.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v212_read_coordination.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v212_server_writes.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v212_token_parent.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v212_write_storage.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v2131_send_dry_run.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v2131_threads_publish_wait.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v2132_send_inflight_hint.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v2132_threads_create_timeout.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v2132_threads_create_unknown.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v213_acceptance_fixes.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v213_adts_asf.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v213_approval_attachments.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v213_attachment_kinds.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v213_bluesky_external.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v213_bluesky_media.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v213_bluesky_metadata.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v213_bluesky_metadata_uri.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v213_bluesky_metadata_video.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v213_bluesky_record.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v213_bluesky_record_video.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v213_bluesky_video.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v213_bluesky_webm_named.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v213_bmff_typed_metadata.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v213_budget_lock.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v213_capabilities.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v213_carryover_timeout.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v213_graphemes.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v213_mastodon_auth_media.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v213_mastodon_bmff_audio.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v213_mastodon_flac.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v213_mastodon_gif.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v213_mastodon_link.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v213_mastodon_media.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v213_mastodon_mp3.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v213_mastodon_ogg_opus.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v213_mastodon_ogg_vorbis.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v213_mastodon_poll.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v213_mastodon_primary_size_boundary.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v213_mastodon_quote.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v213_mastodon_thumbnail.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v213_mastodon_wav.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v213_mastodon_webm_audio.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v213_mastodon_webm_video.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v213_media_cleanup.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v213_media_formats.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v213_media_foundation.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v213_media_prepared.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v213_media_relay.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v213_media_relay_refusals.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v213_media_upload.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v213_receipt_deadline.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v213_review_followups.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v213_stage810_audit.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v213_static_diagnostics.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v213_stop_observation.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v213_systemd_worker.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v213_threads_ghost.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v213_threads_gif.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v213_threads_link.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v213_threads_media.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v213_threads_media_spoiler.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v213_threads_poll.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v213_threads_quote.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v213_threads_quote_media.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v213_threads_replies.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v213_threads_text.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v213_threads_text_spoiler.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v213_threads_video.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v213_transport_review.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v213_video_typed_metadata.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v213_vorbis_comment_boundary.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v213_webm_schema_boundary.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v2141_draft_lint_reason.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v2141_managed_repo_umask.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v214_release_signing.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v214_x_adapter.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v214_x_auth.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v214_x_media.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v214_x_posts_budget.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v21b_approved_writes.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v28_tags.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v310_morning.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v310_search_timeout.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v310_watch_words.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v311_leave_same_user.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v311_mastodon_retract.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v311_morning_overdue.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v311_morning_own_exclusion.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v311_morning_unanswered_window.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v311_notifications_guidance.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v311_replies_owned.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v312_leave_shared_token.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v312_own_handles_stopped.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v312_report_admin.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v312_report_channel.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v312_report_inbox.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v312_report_mcp_user.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v31_analysis.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v31_collection.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v320_reply_to_file_display.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v320_reply_to_file_lint.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v320_reply_to_file_resolve.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v320_report_fixes.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v330_fingerprint_version.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v330_held.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v330_held_names.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v330_last_refusals.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v330_notification_route.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v330_observe.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v330_report_friction.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v330_report_words.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_v330_reports_effect.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_value_domains_shown.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_version.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_where_cli.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_who_cli.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_writeback_rebase.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_writeback_validate.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/tests/test_x_metrics.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/__init__.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/__main__.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/_grapheme_data.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/account_cli.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/account_report.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/accounts.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/adapters/__init__.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/adapters/auth_mastodon.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/adapters/auth_threads.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/adapters/auth_x.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/adapters/bluesky.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/adapters/bluesky_media.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/adapters/bluesky_video.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/adapters/mastodon.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/adapters/mastodon_media.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/adapters/threads_media.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/adapters/x.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/adapters/x_media.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/admin_notifications.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/admin_report.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/after_cli.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/analytics_comparison.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/analytics_report.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/analytics_shapes.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/analytics_threads.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/api_diagnostic.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/appconfig.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/appenv.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/approval.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/approval_jobs.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/approval_relay.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/ask.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/ask_cli.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/audioformats.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/authclients.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/authflow.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/authpassword.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/bluesky_metadata.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/budget_x.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/budget_x_posts.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/bundle.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/collect.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/collection_context.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/collection_status.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/deletion.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/doctor.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/ebmlaudio.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/ebmlvideo.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/engagements.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/fingerprint_golden.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/flacformats.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/forms.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/graphemes.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/handoff_cursor.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/httpsafe.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/jst.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/leave.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/lint.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/lock.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/maintain.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/managed_repo.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/measured.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/media.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/media_capabilities.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/media_cleanup.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/media_delivery.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/media_relay.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/media_uploads.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/mediaformats.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/mpeg_audio.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/notification_route.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/oauth.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/oggformats.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/operations_handoff.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/postid.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/queuefile.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/read_coordination.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/read_window.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/redact.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/refusals.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/replies.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/report.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/report_details.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/report_http.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/report_inbox.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/report_isolation.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/report_service.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/retract_cli.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/scopes.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/secrets_fs.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/select.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/selfupdate.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/sent.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/server_files.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/server_writes.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/stop_observation.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/study_cli.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/study_report.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/systemd_gen.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/tags.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/thread_read.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/threadrun.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/threads_read_cli.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/threadshape.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/threadthrow.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/tool_version.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/topic_advice.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/topic_cli.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/topic_models.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/topic_store.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/topics.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/unanswered.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/vorbisformats.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/watch_cli.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/where_cli.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/who_cli.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/writeback.py +0 -0
- {thth-3.3.0 → thth-3.3.1}/thth/x_metrics.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: thth
|
|
3
|
-
Version: 3.3.
|
|
3
|
+
Version: 3.3.1
|
|
4
4
|
Summary: 承認済みの投稿だけを投げる門(Threads・Bluesky・Mastodon)。人が承認し、記録は利用者の git に残る。
|
|
5
5
|
Project-URL: Homepage, https://thth.me
|
|
6
6
|
Project-URL: Source, https://github.com/aokings/thth
|
|
@@ -125,7 +125,7 @@ timer(systemd・`thth systemd` で生成)は10分ごとに実行し、投稿
|
|
|
125
125
|
- **英語の文書**: [README.en.md](README.en.md)・[docs/usage.en.md](docs/usage.en.md)・[llms.txt](llms.txt)。
|
|
126
126
|
- **skill**: `skills/thth/SKILL.md`(wheel にも入る)。
|
|
127
127
|
|
|
128
|
-
- **動くもの**(`thth --help` の全サブコマンド): `lint`・`preview`・`approve`・`account`・`revoke`・`posts`・`replies`・`measured`・`threads`・`after`・`analytics-report`・`study-report`・`study`・`unanswered`・`handoff-report`・`observe`・`morning`・`serve-reports`・`approval-worker`・`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`・`report`・`admin`。
|
|
128
|
+
- **動くもの**(`thth --help` の全サブコマンド): `lint`・`preview`・`approve`・`account`・`revoke`・`posts`・`replies`・`measured`・`threads`・`after`・`analytics-report`・`study-report`・`study`・`unanswered`・`handoff-report`・`observe`・`morning`・`serve-reports`・`approval-worker`・`topics`・`forms`・`queue`・`schedule`・`throw`・`run`・`systemd`・`board`・`collect`・`pull`・`auth`・`refresh`・`maintain`・`send`・`doctor`・`app`・`token`・`ask`・`mentions`・`profile`・`thread`・`where`・`who`・`retract`・`inflight`・`location`・`notifications`・`report`・`admin`。
|
|
129
129
|
- **最初の本番投稿の記録**: 2026-09-09、@aoking に疎通確認を 1 本(`17916074118445631`)。
|
|
130
130
|
- **未着手**: X・Facebook ページ・Instagram の各アダプタ。トピック検索の権限(tester には降りない)。泉のサーバ(v2-5)。
|
|
131
131
|
- **権限の制約**: tester に降りる scope は 5 つ。削除はできない。
|
|
@@ -101,7 +101,7 @@ timer(systemd・`thth systemd` で生成)は10分ごとに実行し、投稿
|
|
|
101
101
|
- **英語の文書**: [README.en.md](README.en.md)・[docs/usage.en.md](docs/usage.en.md)・[llms.txt](llms.txt)。
|
|
102
102
|
- **skill**: `skills/thth/SKILL.md`(wheel にも入る)。
|
|
103
103
|
|
|
104
|
-
- **動くもの**(`thth --help` の全サブコマンド): `lint`・`preview`・`approve`・`account`・`revoke`・`posts`・`replies`・`measured`・`threads`・`after`・`analytics-report`・`study-report`・`study`・`unanswered`・`handoff-report`・`observe`・`morning`・`serve-reports`・`approval-worker`・`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`・`report`・`admin`。
|
|
104
|
+
- **動くもの**(`thth --help` の全サブコマンド): `lint`・`preview`・`approve`・`account`・`revoke`・`posts`・`replies`・`measured`・`threads`・`after`・`analytics-report`・`study-report`・`study`・`unanswered`・`handoff-report`・`observe`・`morning`・`serve-reports`・`approval-worker`・`topics`・`forms`・`queue`・`schedule`・`throw`・`run`・`systemd`・`board`・`collect`・`pull`・`auth`・`refresh`・`maintain`・`send`・`doctor`・`app`・`token`・`ask`・`mentions`・`profile`・`thread`・`where`・`who`・`retract`・`inflight`・`location`・`notifications`・`report`・`admin`。
|
|
105
105
|
- **最初の本番投稿の記録**: 2026-09-09、@aoking に疎通確認を 1 本(`17916074118445631`)。
|
|
106
106
|
- **未着手**: X・Facebook ページ・Instagram の各アダプタ。トピック検索の権限(tester には降りない)。泉のサーバ(v2-5)。
|
|
107
107
|
- **権限の制約**: tester に降りる scope は 5 つ。削除はできない。
|
|
@@ -168,7 +168,7 @@ MCP `after_you_posted`。**24h の刻みが無ければ `null`**(0 と混ぜ
|
|
|
168
168
|
|
|
169
169
|
新規 account は利用者メール・管理者メール・TLS SMTP を `thth notifications config` で設定し、`status` → `test` を実行する。両宛先の受信箱で到達、account 専用 HEALTHCHECK_URL の missed-ping 通知を確認してから production/scheduled を有効にする。既存 account にも同じ設定を追加する。詳細は `docs/停止通知と運用記録.md`。
|
|
170
170
|
|
|
171
|
-
プロジェクトの作業開始・再開時は `git fetch` 後にリモート `docs/sns/queue/` の `thth_run_state`・`thth_run_detail`・`thth_run_next` を確認する。dirty worktree へ無条件に pull しない。リモートの確認は `git show origin/main:docs/sns/queue/FILE.md` 等で行える。停止・成否不明なら inflight
|
|
171
|
+
プロジェクトの作業開始・再開時は `git fetch` 後にリモート `docs/sns/queue/` の `thth_run_state`・`thth_run_detail`・`thth_run_next` を確認する。dirty worktree へ無条件に pull しない。リモートの確認は `git show origin/main:docs/sns/queue/FILE.md` 等で行える。停止・成否不明なら inflight を手で消して再投稿しない(3.3.1 から道具が毎 run の最初に媒体へ 1 回だけ問い合わせ、決まれば自分で解く)。止まり続けるなら `thth inflight <account> show` で中身を見て、媒体で確かめた人が `thth inflight <account> resolve --not-published|--published <post_id> --by <名前>` で解く(二段確認・MCP には無い)。復旧状態は投稿成功の保証ではなく、公開結果は status/post_id/posted_at を見る。
|
|
172
172
|
|
|
173
173
|
`mentions <account> --json` は 3 媒体の言及を同じ項目で読む。返信前に `replied` の object/false/null を区別する。`unanswered <account> --since 7d --json` は自分の根投稿の未回答候補を台帳で読む。`cannot_say` と採集の古さを確認し、`--refresh` は明示されたときだけ使う。件数は account ごとに扱い、送信は既存の承認・digest・production の門を通す。
|
|
174
174
|
|
|
@@ -67,6 +67,16 @@ def local_http_test_transport(monkeypatch):
|
|
|
67
67
|
opener.open('http://'+host,timeout=0.01)
|
|
68
68
|
|
|
69
69
|
|
|
70
|
+
@pytest.fixture(autouse=True)
|
|
71
|
+
def no_threads_status_poll_wait(monkeypatch):
|
|
72
|
+
"""公開の結果が分からないときの container の問い合わせ(設計 3.3.1 §2)を
|
|
73
|
+
試験では待たない。本番の既定は 20 秒おき・最大 3 回。偽サーバは status の
|
|
74
|
+
GET に答えない(`query_failed`)ので、従前の試験の結果(inflight を残す)は
|
|
75
|
+
変わらない——変わるのは待つ時間だけ。subprocess の `bin/thth` にも env で届く。
|
|
76
|
+
"""
|
|
77
|
+
monkeypatch.setenv('THTH_THREADS_STATUS_POLL_SECONDS', '0')
|
|
78
|
+
|
|
79
|
+
|
|
70
80
|
@pytest.fixture(autouse=True)
|
|
71
81
|
def frozen_now_jst(monkeypatch):
|
|
72
82
|
"""`thth.jst.now_jst()` を既定で静かな時間帯の外(2026-09-09 10:00 JST)に固定する。
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""container の status を答える偽 Threads(設計 3.3.1 §6・**tests の中だけ**)。
|
|
2
|
+
|
|
3
|
+
本物の Threads API には触らない。`http.server` で次の 4 つの口だけを真似る:
|
|
4
|
+
|
|
5
|
+
- `POST /<user>/threads` → container を作る(id は `CONTAINER_ID`)
|
|
6
|
+
- `POST /<user>/threads_publish` → `script.publish` の先頭から 1 つずつ答える
|
|
7
|
+
- `GET /<container>` → `script.status` の先頭から 1 つずつ(最後の 1 つは繰り返す)
|
|
8
|
+
- `GET /v1.0/<user>/threads` → `script.posts`(自分の最近の投稿)
|
|
9
|
+
|
|
10
|
+
何回呼ばれたかを数える(`publish_calls`・`status_calls`・`list_calls`)——
|
|
11
|
+
「2 度出していない」を回数で確かめるため。
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import contextlib
|
|
16
|
+
import dataclasses
|
|
17
|
+
import http.server
|
|
18
|
+
import json
|
|
19
|
+
import threading
|
|
20
|
+
import urllib.parse
|
|
21
|
+
|
|
22
|
+
from thth.adapters import threads as threads_mod
|
|
23
|
+
|
|
24
|
+
USER_ID = "12345"
|
|
25
|
+
CONTAINER_ID = "900"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclasses.dataclass
|
|
29
|
+
class Script:
|
|
30
|
+
# 公開の答え: ("ok", id) / (500, None) / (400, None) / ("drop", None)(接続断)
|
|
31
|
+
publish: list = dataclasses.field(default_factory=lambda: [("ok", "777")])
|
|
32
|
+
# container の status の答え: "FINISHED" などの語 / 数字(HTTP の失敗)
|
|
33
|
+
status: list = dataclasses.field(default_factory=lambda: ["FINISHED"])
|
|
34
|
+
# 自分の最近の投稿(`GET /v1.0/<user>/threads` の data)
|
|
35
|
+
posts: list = dataclasses.field(default_factory=list)
|
|
36
|
+
list_status: int = 200
|
|
37
|
+
publish_calls: int = 0
|
|
38
|
+
status_calls: int = 0
|
|
39
|
+
list_calls: int = 0
|
|
40
|
+
create_calls: int = 0
|
|
41
|
+
# 公開の要求に載っていた creation_id(どの container を公開したか)。
|
|
42
|
+
publish_ids: list = dataclasses.field(default_factory=list)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _handler(script: Script):
|
|
46
|
+
class Handler(http.server.BaseHTTPRequestHandler):
|
|
47
|
+
def _json(self, code, payload):
|
|
48
|
+
body = json.dumps(payload).encode("utf-8")
|
|
49
|
+
self.send_response(code)
|
|
50
|
+
self.send_header("Content-Type", "application/json")
|
|
51
|
+
self.send_header("Content-Length", str(len(body)))
|
|
52
|
+
self.end_headers()
|
|
53
|
+
self.wfile.write(body)
|
|
54
|
+
|
|
55
|
+
def do_POST(self): # noqa: N802
|
|
56
|
+
length = int(self.headers.get("Content-Length") or 0)
|
|
57
|
+
raw = self.rfile.read(length) if length else b""
|
|
58
|
+
if self.path.endswith("/threads_publish"):
|
|
59
|
+
script.publish_calls += 1
|
|
60
|
+
params = urllib.parse.parse_qs(raw.decode("utf-8"))
|
|
61
|
+
script.publish_ids.append((params.get("creation_id") or [None])[0])
|
|
62
|
+
kind, value = (script.publish.pop(0) if script.publish else (500, None))
|
|
63
|
+
if kind == "ok":
|
|
64
|
+
self._json(200, {"id": value})
|
|
65
|
+
elif kind == "drop":
|
|
66
|
+
# 応答を返さずに切る(接続断)。
|
|
67
|
+
self.close_connection = True
|
|
68
|
+
self.connection.close()
|
|
69
|
+
else:
|
|
70
|
+
self._json(kind, {"error": {"message": "fake", "code": 1,
|
|
71
|
+
"is_transient": True}})
|
|
72
|
+
return
|
|
73
|
+
script.create_calls += 1
|
|
74
|
+
self._json(200, {"id": CONTAINER_ID})
|
|
75
|
+
|
|
76
|
+
def do_GET(self): # noqa: N802
|
|
77
|
+
path = urllib.parse.urlsplit(self.path).path
|
|
78
|
+
if path == f"/v1.0/{USER_ID}/threads":
|
|
79
|
+
script.list_calls += 1
|
|
80
|
+
if script.list_status != 200:
|
|
81
|
+
self._json(script.list_status, {"error": {"message": "fake"}})
|
|
82
|
+
return
|
|
83
|
+
self._json(200, {"data": list(script.posts)})
|
|
84
|
+
return
|
|
85
|
+
if path == f"/{CONTAINER_ID}":
|
|
86
|
+
script.status_calls += 1
|
|
87
|
+
answer = script.status.pop(0) if len(script.status) > 1 else script.status[0]
|
|
88
|
+
if isinstance(answer, int):
|
|
89
|
+
self._json(answer, {"error": {"message": "fake"}})
|
|
90
|
+
else:
|
|
91
|
+
self._json(200, {"status": answer, "id": CONTAINER_ID})
|
|
92
|
+
return
|
|
93
|
+
self._json(404, {"error": {"message": "not found"}})
|
|
94
|
+
|
|
95
|
+
def log_message(self, format, *args): # noqa: A002
|
|
96
|
+
pass
|
|
97
|
+
|
|
98
|
+
return Handler
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@contextlib.contextmanager
|
|
102
|
+
def serve(script: Script):
|
|
103
|
+
server = http.server.HTTPServer(("127.0.0.1", 0), _handler(script))
|
|
104
|
+
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
105
|
+
thread.start()
|
|
106
|
+
try:
|
|
107
|
+
yield f"http://127.0.0.1:{server.server_port}"
|
|
108
|
+
finally:
|
|
109
|
+
server.shutdown()
|
|
110
|
+
thread.join(timeout=5)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def adapter(base_url: str, **kwargs) -> threads_mod.ThreadsAdapter:
|
|
114
|
+
kwargs.setdefault("wait_seconds", 0)
|
|
115
|
+
kwargs.setdefault("timeout", 2.0)
|
|
116
|
+
kwargs.setdefault("status_poll_seconds", 0)
|
|
117
|
+
return threads_mod.ThreadsAdapter(base_url=base_url, access_token="fake-token",
|
|
118
|
+
user_id=USER_ID, **kwargs)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def post_row(post_id: str, text: str, timestamp: str) -> dict:
|
|
122
|
+
return {"id": post_id, "text": text, "timestamp": timestamp,
|
|
123
|
+
"permalink": f"https://www.threads.net/@x/post/{post_id}"}
|
|
@@ -76,7 +76,8 @@ def test_git_roundtrip_preserves_original_and_deduplicates(setup):
|
|
|
76
76
|
assert "thth_run_state: recovered" in s.path.read_text()
|
|
77
77
|
assert len(s.captured) == 4
|
|
78
78
|
assert incident._fingerprint(s.path) == before
|
|
79
|
-
|
|
79
|
+
# 3.3.1 §5: 送り終えた事象も 30 日は残す(以前は 1 件に畳んでいた)。
|
|
80
|
+
assert len(row(s)["events"]) == 2
|
|
80
81
|
|
|
81
82
|
|
|
82
83
|
def test_initial_success_is_quiet(setup):
|
|
@@ -99,7 +100,8 @@ def test_role_failure_retries_only_unfinished_and_other_role_gets_recovery(setup
|
|
|
99
100
|
monkeypatch.setattr(incident, "_send", lambda c, r, e, a: calls.append((r, e["state"])) or True)
|
|
100
101
|
notify(s, "success", result=False)
|
|
101
102
|
assert calls[-2:] == [("user@example.org", "blocked"), ("user@example.org", "recovered")]
|
|
102
|
-
|
|
103
|
+
# 3.3.1 §5: 送り終えた事象も 30 日は残す(以前は 1 件に畳んでいた)。
|
|
104
|
+
assert len(row(s)["events"]) == 2
|
|
103
105
|
|
|
104
106
|
|
|
105
107
|
def test_same_address_single_message(setup):
|
|
@@ -30,7 +30,9 @@ COMMANDS = '''lint|preview|approve|account|revoke|posts|replies|measured|threads
|
|
|
30
30
|
'observe',
|
|
31
31
|
# 3.1.2: 報告の口(設計 3.1.2 §1)。
|
|
32
32
|
'report file', 'report list', 'report show', 'report add', 'admin reports list', 'admin reports show',
|
|
33
|
-
'admin reports reply', 'admin reports close', 'admin reports export'
|
|
33
|
+
'admin reports reply', 'admin reports close', 'admin reports export',
|
|
34
|
+
# 3.3.1 §4: 人の口 thth inflight(show|resolve・--json)。
|
|
35
|
+
'inflight']
|
|
34
36
|
|
|
35
37
|
|
|
36
38
|
def test_json_table_covers_every_parser():
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""3.3.1 §4: `thth inflight <account> [show|resolve] --by <名前>`(人の口)。
|
|
2
|
+
|
|
3
|
+
09-23 に masaru が `inflight.json` を手で消した操作の代わり。二段確認・控え
|
|
4
|
+
(`inflight.resolved-<日時>.json`)・変更ログ `inflight_resolved`。MCP には出さない。
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import datetime
|
|
9
|
+
import importlib.util
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import pathlib
|
|
13
|
+
|
|
14
|
+
from tests.conftest import init_git_pair, make_queue_text, write_queue_file
|
|
15
|
+
from tests.helpers import fake_threads_status as fake
|
|
16
|
+
from thth import accounts as accounts_mod
|
|
17
|
+
from thth import admin_log
|
|
18
|
+
from thth import cli
|
|
19
|
+
from thth import core
|
|
20
|
+
from thth import inflight as inflight_mod
|
|
21
|
+
from thth import inflight_cli
|
|
22
|
+
from thth import queuefile
|
|
23
|
+
from thth import runs as runs_mod
|
|
24
|
+
|
|
25
|
+
NOW = datetime.datetime.fromisoformat("2026-09-09T10:00:00+09:00")
|
|
26
|
+
REPO = pathlib.Path(__file__).resolve().parent.parent
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _stuck(isolated_account_factory, tmp_path, *, git=True):
|
|
30
|
+
if git:
|
|
31
|
+
pair = init_git_pair(tmp_path, seed_content=make_queue_text(), seed_name="a.md")
|
|
32
|
+
account = isolated_account_factory(repo_dir=pair["work"], production=True)
|
|
33
|
+
else:
|
|
34
|
+
account = isolated_account_factory(production=True)
|
|
35
|
+
write_queue_file(account["queue_dir"], "a.md")
|
|
36
|
+
script = fake.Script(publish=[(500, None)], status=[503])
|
|
37
|
+
with fake.serve(script) as base_url:
|
|
38
|
+
result = core.throw_once(account["name"], production_flag=True,
|
|
39
|
+
adapter_factory=lambda c, t: fake.adapter(base_url), now=NOW)
|
|
40
|
+
assert result.action == "inflight"
|
|
41
|
+
return account, accounts_mod.state_dir_for(account["name"])
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _cli(capsys, *argv):
|
|
45
|
+
rc = cli.main(["inflight", *argv])
|
|
46
|
+
out = capsys.readouterr()
|
|
47
|
+
return rc, out.out, out.err
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _digest_from(out):
|
|
51
|
+
return next(line.split(": ", 1)[1] for line in out.splitlines() if line.startswith("digest: "))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_showは中身を出すが本文とtokenとcontainer_idは出さない(isolated_account_factory, tmp_path, capsys):
|
|
55
|
+
account, state_dir = _stuck(isolated_account_factory, tmp_path)
|
|
56
|
+
rc, out, _ = _cli(capsys, account["name"], "show")
|
|
57
|
+
assert rc == 0
|
|
58
|
+
assert "a.md" in out and "container_id: あり" in out and "query_failed" in out
|
|
59
|
+
assert "本文です" not in out and "fake-token" not in out and fake.CONTAINER_ID not in out
|
|
60
|
+
rc, out, _ = _cli(capsys, account["name"], "show", "--json")
|
|
61
|
+
info = json.loads(out)["inflight"]
|
|
62
|
+
assert info["container_id_present"] is True and info["remote_state"] == "query_failed"
|
|
63
|
+
assert info["post_id_present"] is False and info["self_resolvable"] is True
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_showはinflightが無ければそう言う(isolated_account_factory, capsys):
|
|
67
|
+
account = isolated_account_factory()
|
|
68
|
+
rc, out, _ = _cli(capsys, account["name"])
|
|
69
|
+
assert rc == 0 and "inflight はありません" in out
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_resolve_not_publishedの往復_二段確認と控えと変更ログ(isolated_account_factory, tmp_path, capsys):
|
|
73
|
+
account, state_dir = _stuck(isolated_account_factory, tmp_path, git=False)
|
|
74
|
+
before = inflight_mod.read(state_dir)
|
|
75
|
+
# 一段目: 何もしない。
|
|
76
|
+
rc, out, _ = _cli(capsys, account["name"], "resolve", "--not-published", "--by", "masaru")
|
|
77
|
+
assert rc == 1 and "解きません" in out
|
|
78
|
+
assert inflight_mod.read(state_dir) == before
|
|
79
|
+
code = _digest_from(out)
|
|
80
|
+
# 二段目。
|
|
81
|
+
rc, out, _ = _cli(capsys, account["name"], "resolve", "--not-published", "--by", "masaru",
|
|
82
|
+
"--confirm", code)
|
|
83
|
+
assert rc == 0, out
|
|
84
|
+
assert inflight_mod.read(state_dir) is None
|
|
85
|
+
archives = inflight_mod.archives(state_dir)
|
|
86
|
+
assert len(archives) == 1
|
|
87
|
+
saved = json.loads(open(archives[0], encoding="utf-8").read())
|
|
88
|
+
assert saved["resolved_by"] == "masaru" and saved["resolution"] == "human_not_published"
|
|
89
|
+
assert saved["inflight"]["started"] == before["started"]
|
|
90
|
+
assert os.stat(archives[0]).st_mode & 0o777 == 0o600
|
|
91
|
+
rows, broken = admin_log.read(account=account["name"], event="inflight_resolved")
|
|
92
|
+
assert len(rows) == 1 and rows[0]["by"] == "masaru" and not broken
|
|
93
|
+
runs = [r for r in runs_mod.read_runs(state_dir) if r.get("inflight_resolution")]
|
|
94
|
+
assert runs[-1]["inflight_resolution"] == "human_not_published"
|
|
95
|
+
# 原稿は approved のまま(次の run が出し直す)。
|
|
96
|
+
qf = queuefile.parse(os.path.join(account["queue_dir"], "a.md"))
|
|
97
|
+
assert qf.get("status") == "approved"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_resolve_publishedは書き戻して解く(isolated_account_factory, tmp_path, capsys):
|
|
101
|
+
account, state_dir = _stuck(isolated_account_factory, tmp_path)
|
|
102
|
+
rc, out, _ = _cli(capsys, account["name"], "resolve", "--published", "8001", "--by", "masaru")
|
|
103
|
+
assert rc == 1
|
|
104
|
+
rc, out, err = _cli(capsys, account["name"], "resolve", "--published", "8001", "--by", "masaru",
|
|
105
|
+
"--confirm", _digest_from(out))
|
|
106
|
+
assert rc == 0, err
|
|
107
|
+
assert inflight_mod.read(state_dir) is None
|
|
108
|
+
qf = queuefile.parse(os.path.join(account["queue_dir"], "a.md"))
|
|
109
|
+
assert qf.get("status") == "posted" and str(qf.get("post_id")) == "8001"
|
|
110
|
+
assert len(inflight_mod.archives(state_dir)) == 1
|
|
111
|
+
rows, _ = admin_log.read(account=account["name"], event="inflight_resolved")
|
|
112
|
+
assert rows[-1]["diff"]["resolution"] == [None, "human_published"]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def test_digestが違えば解かない_別のinflightには流用できない(isolated_account_factory, tmp_path, capsys):
|
|
116
|
+
account, state_dir = _stuck(isolated_account_factory, tmp_path, git=False)
|
|
117
|
+
rc, out, _ = _cli(capsys, account["name"], "resolve", "--not-published", "--by", "masaru")
|
|
118
|
+
code = _digest_from(out)
|
|
119
|
+
inflight_mod.update(state_dir, started="2026-09-09T11:00:00+09:00")
|
|
120
|
+
rc, _, err = _cli(capsys, account["name"], "resolve", "--not-published", "--by", "masaru",
|
|
121
|
+
"--confirm", code)
|
|
122
|
+
assert rc == 1 and "confirm_mismatch" in err
|
|
123
|
+
assert inflight_mod.read(state_dir) is not None and inflight_mod.archives(state_dir) == []
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_byと決めることが無ければ断る(isolated_account_factory, tmp_path, capsys, monkeypatch):
|
|
127
|
+
monkeypatch.delenv("THTH_ACTOR", raising=False)
|
|
128
|
+
account, state_dir = _stuck(isolated_account_factory, tmp_path, git=False)
|
|
129
|
+
rc, _, err = _cli(capsys, account["name"], "resolve", "--not-published")
|
|
130
|
+
assert rc == 1 and err.startswith("by_required")
|
|
131
|
+
rc, _, err = _cli(capsys, account["name"], "resolve", "--by", "masaru")
|
|
132
|
+
assert rc == 1 and err.startswith("decision_required")
|
|
133
|
+
assert inflight_mod.read(state_dir) is not None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def test_出たと分かっているinflightはnot_publishedで解かない(isolated_account_factory, tmp_path, capsys):
|
|
137
|
+
account, state_dir = _stuck(isolated_account_factory, tmp_path, git=False)
|
|
138
|
+
inflight_mod.update(state_dir, post_id="8002")
|
|
139
|
+
rc, _, err = _cli(capsys, account["name"], "resolve", "--not-published", "--by", "masaru")
|
|
140
|
+
assert rc == 1 and err.startswith("published_known")
|
|
141
|
+
rc, _, err = _cli(capsys, account["name"], "resolve", "--published", "9999", "--by", "masaru")
|
|
142
|
+
assert rc == 1 and err.startswith("post_id_differs")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def test_添付のinflightはこの口では解かない(isolated_account_factory, tmp_path, capsys):
|
|
146
|
+
account, state_dir = _stuck(isolated_account_factory, tmp_path, git=False)
|
|
147
|
+
record = inflight_mod.read(state_dir)
|
|
148
|
+
info = inflight_cli.summary({**record, "media": {"account": account["name"]}})
|
|
149
|
+
assert info["media"] is True and info["self_resolvable"] is False
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def test_mcpにはinflightの口が無い():
|
|
153
|
+
spec = importlib.util.spec_from_file_location("thth_mcp_server", REPO / "mcp" / "server.py")
|
|
154
|
+
server = importlib.util.module_from_spec(spec)
|
|
155
|
+
spec.loader.exec_module(server)
|
|
156
|
+
assert not any("inflight" in tool["name"] for tool in server.TOOLS)
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""3.3.1 §3 の後段: container の無い媒体(Mastodon・Bluesky・X)の自己解決。
|
|
2
|
+
|
|
3
|
+
自分の最近の投稿を公開時刻の前後 10 分で引き、本文の指紋が一致する投稿が
|
|
4
|
+
**ちょうど 1 件**なら書き戻して解く。**0 件なら解かない**——出ていないと
|
|
5
|
+
言い切れない媒体がある(本文を変えて返す・索引が遅れる)。2 件でも決めない。
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import datetime
|
|
10
|
+
import os
|
|
11
|
+
|
|
12
|
+
from tests.conftest import init_git_pair, make_queue_text
|
|
13
|
+
from thth import accounts as accounts_mod
|
|
14
|
+
from thth import core
|
|
15
|
+
from thth import inflight as inflight_mod
|
|
16
|
+
from thth import queuefile
|
|
17
|
+
from thth import runs as runs_mod
|
|
18
|
+
from thth.adapters import base as adapter_base
|
|
19
|
+
|
|
20
|
+
NOW = datetime.datetime.fromisoformat("2026-09-09T10:00:00+09:00")
|
|
21
|
+
BODY = "## mastodon\n\n本文です。\n"
|
|
22
|
+
NEAR = "2026-09-09T01:02:00.000Z"
|
|
23
|
+
FAR = "2026-09-09T00:40:00.000Z"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ListOnlyAdapter(adapter_base.Adapter):
|
|
27
|
+
"""container を持たない媒体の偽物。公開は「分からない」で終わり、一覧だけ返す。"""
|
|
28
|
+
|
|
29
|
+
CAPABILITIES = frozenset({"recent_posts"})
|
|
30
|
+
|
|
31
|
+
def __init__(self, *, rows=None, fail=False):
|
|
32
|
+
self.rows = rows or []
|
|
33
|
+
self.fail = fail
|
|
34
|
+
self.publish_calls = 0
|
|
35
|
+
self.list_calls = 0
|
|
36
|
+
|
|
37
|
+
def publish(self, post, *, dry_run, on_container_created=None, before_publish=None):
|
|
38
|
+
self.publish_calls += 1
|
|
39
|
+
return adapter_base.PublishResult(None, None, "2026-09-09T10:00:00+09:00",
|
|
40
|
+
error="公開失敗: timed out",
|
|
41
|
+
failure="publish_ambiguous")
|
|
42
|
+
|
|
43
|
+
def recent_posts(self, *, limit=25):
|
|
44
|
+
self.list_calls += 1
|
|
45
|
+
if self.fail:
|
|
46
|
+
raise adapter_base.AdapterError("fake_list_failed")
|
|
47
|
+
return list(self.rows)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _row(post_id, text, ts):
|
|
51
|
+
return {"post_id": post_id, "timestamp": ts, "url": None, "text": text, "topic": None}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _stuck(isolated_account_factory, tmp_path):
|
|
55
|
+
seed = make_queue_text(body=BODY, media="mastodon")
|
|
56
|
+
pair = init_git_pair(tmp_path, seed_content=seed, seed_name="a.md")
|
|
57
|
+
account = isolated_account_factory(repo_dir=pair["work"], production=True,
|
|
58
|
+
media="mastodon", instance="https://mastodon.example")
|
|
59
|
+
state_dir = accounts_mod.state_dir_for(account["name"])
|
|
60
|
+
first = ListOnlyAdapter()
|
|
61
|
+
result = core.throw_once(account["name"], production_flag=True,
|
|
62
|
+
adapter_factory=lambda c, t: first, now=NOW)
|
|
63
|
+
assert result.action == "inflight"
|
|
64
|
+
left = inflight_mod.read(state_dir)
|
|
65
|
+
assert left["container_id"] is None and left["text_fingerprint"]
|
|
66
|
+
return account, state_dir
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _again(account, adapter):
|
|
70
|
+
return core.throw_once(account["name"], production_flag=True,
|
|
71
|
+
adapter_factory=lambda c, t: adapter, now=NOW)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_一覧に1件あれば書き戻して解き_出し直さない(isolated_account_factory, tmp_path):
|
|
75
|
+
account, state_dir = _stuck(isolated_account_factory, tmp_path)
|
|
76
|
+
adapter = ListOnlyAdapter(rows=[_row("m-1", "本文です。", NEAR),
|
|
77
|
+
_row("m-2", "本文です。", FAR),
|
|
78
|
+
_row("m-3", "別の本文", NEAR)])
|
|
79
|
+
result = _again(account, adapter)
|
|
80
|
+
assert adapter.publish_calls == 0 # 2 度出していない
|
|
81
|
+
assert inflight_mod.read(state_dir) is None
|
|
82
|
+
qf = queuefile.parse(os.path.join(account["queue_dir"], "a.md"))
|
|
83
|
+
assert qf.get("status") == "posted" and qf.get("post_id") == "m-1"
|
|
84
|
+
rows = runs_mod.read_runs(state_dir)
|
|
85
|
+
assert any(r.get("inflight_resolution") == "listing_located" and r["post_id"] == "m-1"
|
|
86
|
+
for r in rows)
|
|
87
|
+
assert len(inflight_mod.archives(state_dir)) == 1
|
|
88
|
+
assert result.action in ("none", "post")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_空白や改行を詰め直された本文も同じと見る(isolated_account_factory, tmp_path):
|
|
92
|
+
account, state_dir = _stuck(isolated_account_factory, tmp_path)
|
|
93
|
+
adapter = ListOnlyAdapter(rows=[_row("m-4", "\n本文です。 \n", NEAR)])
|
|
94
|
+
_again(account, adapter)
|
|
95
|
+
assert inflight_mod.read(state_dir) is None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_0件なら解かない(isolated_account_factory, tmp_path):
|
|
99
|
+
account, state_dir = _stuck(isolated_account_factory, tmp_path)
|
|
100
|
+
adapter = ListOnlyAdapter(rows=[_row("m-2", "本文です。", FAR)])
|
|
101
|
+
result = _again(account, adapter)
|
|
102
|
+
assert result.action == "inflight" and result.error == "inflight_unresolved"
|
|
103
|
+
assert adapter.publish_calls == 0
|
|
104
|
+
left = inflight_mod.read(state_dir)
|
|
105
|
+
assert left["remote_state"] == "listing_none"
|
|
106
|
+
assert inflight_mod.archives(state_dir) == []
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_2件なら決めない(isolated_account_factory, tmp_path):
|
|
110
|
+
account, state_dir = _stuck(isolated_account_factory, tmp_path)
|
|
111
|
+
adapter = ListOnlyAdapter(rows=[_row("m-5", "本文です。", NEAR), _row("m-6", "本文です。", NEAR)])
|
|
112
|
+
result = _again(account, adapter)
|
|
113
|
+
assert result.action == "inflight" and adapter.publish_calls == 0
|
|
114
|
+
assert inflight_mod.read(state_dir)["remote_state"] == "listing_many"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_一覧が引けなければ解かない(isolated_account_factory, tmp_path):
|
|
118
|
+
account, state_dir = _stuck(isolated_account_factory, tmp_path)
|
|
119
|
+
adapter = ListOnlyAdapter(fail=True)
|
|
120
|
+
result = _again(account, adapter)
|
|
121
|
+
assert result.action == "inflight" and adapter.publish_calls == 0
|
|
122
|
+
assert inflight_mod.read(state_dir)["remote_state"] == "listing_failed"
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def test_指紋の無い古いinflightは訊かずに従前どおり止まる(isolated_account_factory, tmp_path):
|
|
126
|
+
account, state_dir = _stuck(isolated_account_factory, tmp_path)
|
|
127
|
+
record = inflight_mod.read(state_dir)
|
|
128
|
+
record.pop("text_fingerprint")
|
|
129
|
+
inflight_mod.clear(state_dir)
|
|
130
|
+
inflight_mod.write(state_dir, **{k: record[k] for k in (
|
|
131
|
+
"file", "started", "container_id", "post_id", "body_hash", "approved_fingerprint")})
|
|
132
|
+
adapter = ListOnlyAdapter(rows=[_row("m-1", "本文です。", NEAR)])
|
|
133
|
+
result = _again(account, adapter)
|
|
134
|
+
assert result.action == "inflight" and result.error is None
|
|
135
|
+
assert adapter.list_calls == 0
|
|
136
|
+
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""3.3.1 §5: 運用通知の事象を 30 日残し、「最後に送った運用通知」を board と observe に出す。
|
|
2
|
+
|
|
3
|
+
09-22 の停止の通知は outbox から消えていて、送ったかどうかを翌日に言えなかった。
|
|
4
|
+
届いたかどうかは道具には分からない(SMTP の受領は到達ではない)——人が受信箱と
|
|
5
|
+
照合できるよう、時刻・状態・受領した役割を残す。宛先は出さない。
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
|
|
12
|
+
import pytest
|
|
13
|
+
|
|
14
|
+
from tests.test_incident_notifications import notify, row, setup # noqa: F401 — fixture
|
|
15
|
+
from thth import accounts, cli, incident, morning
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _save_row(s, value):
|
|
19
|
+
incident._save(os.path.join(s.state, incident.STATE_FILE), value)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_送り終えた事象を残し最後に送った通知を控える(setup):
|
|
23
|
+
s = setup
|
|
24
|
+
notify(s)
|
|
25
|
+
notify(s, "success", result=False)
|
|
26
|
+
value = row(s)
|
|
27
|
+
assert [e["state"] for e in value["events"]] == ["blocked", "recovered"]
|
|
28
|
+
sent = value["last_sent"]
|
|
29
|
+
assert sent["state"] == "recovered" and sent["roles"] == ["admin", "user"]
|
|
30
|
+
assert sent["reason"] == "healthy" and sent["at"]
|
|
31
|
+
# 宛先は控えない。
|
|
32
|
+
assert "example.org" not in json.dumps(sent)
|
|
33
|
+
summary = incident.summary(s.cfg, s.state)["last_sent"]
|
|
34
|
+
assert summary["basis"] == "recorded" and summary["state"] == "recovered"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_30日を過ぎた送り終えた事象は畳む_最後の1件は残す(setup):
|
|
38
|
+
s = setup
|
|
39
|
+
notify(s)
|
|
40
|
+
notify(s, "success", result=False)
|
|
41
|
+
value = row(s)
|
|
42
|
+
for event in value["events"]:
|
|
43
|
+
event["at"] = "2026-07-01T10:00:00+09:00"
|
|
44
|
+
_save_row(s, value)
|
|
45
|
+
notify(s, "success", result=False) # 遷移なし・畳むだけ
|
|
46
|
+
assert len(row(s)["events"]) == 1
|
|
47
|
+
assert row(s)["last_sent"]["state"] == "recovered" # 控えは畳まれても残る
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_送り終えていない事象は古くても畳まない(setup, monkeypatch):
|
|
51
|
+
s = setup
|
|
52
|
+
notify(s)
|
|
53
|
+
value = row(s)
|
|
54
|
+
value["events"][0]["accepted"] = {}
|
|
55
|
+
value["events"][0]["at"] = "2026-07-01T10:00:00+09:00"
|
|
56
|
+
value["events"].append(dict(value["events"][0], id="f" * 32, accepted={}))
|
|
57
|
+
_save_row(s, value)
|
|
58
|
+
# SMTP が受け付けない(両方失敗)まま流す。
|
|
59
|
+
monkeypatch.setattr(incident, "_send", lambda *a, **k: False)
|
|
60
|
+
notify(s)
|
|
61
|
+
assert len(row(s)["events"]) == 2
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_30日以内でも数が溢れたら古い順に畳む(setup):
|
|
65
|
+
s = setup
|
|
66
|
+
notify(s)
|
|
67
|
+
value = row(s)
|
|
68
|
+
template = value["events"][0]
|
|
69
|
+
value["events"] = [dict(template, id=f"{i:032x}") for i in range(60)]
|
|
70
|
+
_save_row(s, value)
|
|
71
|
+
notify(s) # 同じ停止(遷移なし)
|
|
72
|
+
assert len(row(s)["events"]) == incident.RETENTION_CAP
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_控えの無い古いoutboxは残っている事象から言う(setup):
|
|
76
|
+
s = setup
|
|
77
|
+
notify(s)
|
|
78
|
+
value = row(s)
|
|
79
|
+
value.pop("last_sent")
|
|
80
|
+
_save_row(s, value)
|
|
81
|
+
summary = incident.last_sent(row(s))
|
|
82
|
+
assert summary["basis"] == "event_at" and summary["state"] == "blocked"
|
|
83
|
+
assert "事象の時刻" in incident.last_sent_line(summary)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_送った記録が無ければ記録なしと言う():
|
|
87
|
+
assert incident.last_sent({"events": []}) is None
|
|
88
|
+
assert "記録なし" in incident.last_sent_line(None)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_壊れた控えはoutboxを壊れたものとして扱う(setup):
|
|
92
|
+
s = setup
|
|
93
|
+
notify(s)
|
|
94
|
+
value = row(s)
|
|
95
|
+
value["last_sent"] = {"at": "x", "state": "blocked", "reason": "healthy", "roles": ["user"]}
|
|
96
|
+
with pytest.raises(ValueError):
|
|
97
|
+
incident._validate(value)
|
|
98
|
+
value["last_sent"] = {"at": "2026-09-09T10:00:00+09:00", "state": "blocked",
|
|
99
|
+
"reason": "healthy", "roles": ["someone@example.org"]}
|
|
100
|
+
with pytest.raises(ValueError):
|
|
101
|
+
incident._validate(value)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _outbox_for(account_name, sent):
|
|
105
|
+
state = accounts.state_dir_for(account_name)
|
|
106
|
+
incident._save(os.path.join(state, incident.STATE_FILE), {
|
|
107
|
+
"version": 1, "last": "success", "events": [], "last_sent": sent})
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
SENT = {"at": "2026-09-22T20:05:00+09:00", "state": "blocked", "reason": "publish_http_500",
|
|
111
|
+
"event_at": "2026-09-22T20:04:00+09:00", "roles": ["admin", "user"]}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_boardに最後に送った運用通知が出る(isolated_account, capsys):
|
|
115
|
+
_outbox_for(isolated_account["name"], SENT)
|
|
116
|
+
assert cli.main(["board"]) == 0
|
|
117
|
+
out = capsys.readouterr().out
|
|
118
|
+
line = next(l for l in out.splitlines() if l.strip().startswith("最後に送った運用通知:"))
|
|
119
|
+
assert "2026-09-22T20:05:00+09:00" in line and "blocked" in line and "admin・user" in line
|
|
120
|
+
assert cli.main(["board", "--json"]) == 0
|
|
121
|
+
payload = json.loads(capsys.readouterr().out)
|
|
122
|
+
acct = next(r for r in payload["accounts"] if r["account"] == isolated_account["name"])
|
|
123
|
+
assert acct["incident_notifications"]["last_sent"]["roles"] == ["admin", "user"]
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_observeに最後に送った運用通知が出る(isolated_account):
|
|
127
|
+
_outbox_for(isolated_account["name"], SENT)
|
|
128
|
+
payload = morning.build(isolated_account["name"], mark=False)
|
|
129
|
+
today = next(sec for sec in payload["sections"] if sec["section"] == "today")
|
|
130
|
+
node = today["value"]["by_account"][isolated_account["name"]]["value"]
|
|
131
|
+
cell = node["last_notification"]
|
|
132
|
+
assert cell["cannot_say"] is None and cell["value"]["state"] == "blocked"
|
|
133
|
+
lines = []
|
|
134
|
+
morning.render(payload, out=lines.append)
|
|
135
|
+
assert any("最後に送った運用通知" in l and "publish_http_500" in l for l in lines)
|