thth 2.5.0__tar.gz → 2.8.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.
- {thth-2.5.0 → thth-2.8.0}/PKG-INFO +17 -3
- {thth-2.5.0 → thth-2.8.0}/README.md +16 -2
- {thth-2.5.0 → thth-2.8.0}/accounts.example/bluesky.json +1 -0
- {thth-2.5.0 → thth-2.8.0}/accounts.example/mastodon.json +1 -0
- {thth-2.5.0 → thth-2.8.0}/mcp/server.py +14 -1
- thth-2.8.0/tests/test_27_acceptance_fixes.py +45 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_analytics_comparison.py +22 -1
- {thth-2.5.0 → thth-2.8.0}/tests/test_analytics_report.py +22 -3
- {thth-2.5.0 → thth-2.8.0}/tests/test_bundle.py +18 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_collect.py +33 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_collect_sent.py +2 -1
- {thth-2.5.0 → thth-2.8.0}/tests/test_mastodon_adapter.py +1 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_select.py +14 -1
- {thth-2.5.0 → thth-2.8.0}/tests/test_study_report.py +2 -1
- {thth-2.5.0 → thth-2.8.0}/tests/test_thread_publish.py +67 -1
- thth-2.8.0/tests/test_v28_tags.py +247 -0
- thth-2.8.0/tests/test_v31_analysis.py +219 -0
- thth-2.8.0/tests/test_v31_collection.py +181 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_where_cli.py +7 -1
- thth-2.8.0/thth/VERSION +1 -0
- {thth-2.5.0 → thth-2.8.0}/thth/adapters/base.py +1 -0
- {thth-2.5.0 → thth-2.8.0}/thth/adapters/bluesky.py +133 -4
- {thth-2.5.0 → thth-2.8.0}/thth/adapters/mastodon.py +96 -2
- {thth-2.5.0 → thth-2.8.0}/thth/analytics_comparison.py +138 -13
- {thth-2.5.0 → thth-2.8.0}/thth/analytics_report.py +41 -4
- thth-2.8.0/thth/analytics_shapes.py +51 -0
- thth-2.8.0/thth/analytics_threads.py +95 -0
- {thth-2.5.0 → thth-2.8.0}/thth/approval.py +9 -0
- {thth-2.5.0 → thth-2.8.0}/thth/bundle.py +22 -3
- {thth-2.5.0 → thth-2.8.0}/thth/cli.py +14 -6
- {thth-2.5.0 → thth-2.8.0}/thth/collect.py +33 -6
- thth-2.8.0/thth/collection_context.py +58 -0
- thth-2.8.0/thth/collection_status.py +59 -0
- {thth-2.5.0 → thth-2.8.0}/thth/core.py +62 -25
- thth-2.8.0/thth/handoff_cursor.py +168 -0
- {thth-2.5.0 → thth-2.8.0}/thth/incident.py +3 -3
- {thth-2.5.0 → thth-2.8.0}/thth/lint.py +8 -3
- {thth-2.5.0 → thth-2.8.0}/thth/measured.py +2 -0
- {thth-2.5.0 → thth-2.8.0}/thth/operations_handoff.py +39 -5
- thth-2.8.0/thth/report_details.py +51 -0
- {thth-2.5.0 → thth-2.8.0}/thth/report_service.py +7 -3
- {thth-2.5.0 → thth-2.8.0}/thth/select.py +12 -2
- {thth-2.5.0 → thth-2.8.0}/thth/study_report.py +18 -1
- thth-2.8.0/thth/tags.py +124 -0
- {thth-2.5.0 → thth-2.8.0}/thth/threads_read_cli.py +23 -2
- {thth-2.5.0 → thth-2.8.0}/thth/threadthrow.py +6 -3
- {thth-2.5.0 → thth-2.8.0}/thth/where_cli.py +37 -3
- thth-2.5.0/thth/VERSION +0 -1
- {thth-2.5.0 → thth-2.8.0}/.gitignore +0 -0
- {thth-2.5.0 → thth-2.8.0}/LICENSE +0 -0
- {thth-2.5.0 → thth-2.8.0}/accounts.example/threads.json +0 -0
- {thth-2.5.0 → thth-2.8.0}/bin/thth +0 -0
- {thth-2.5.0 → thth-2.8.0}/bin/thth-remote +0 -0
- {thth-2.5.0 → thth-2.8.0}/bin/thth-run +0 -0
- {thth-2.5.0 → thth-2.8.0}/pyproject.toml +0 -0
- {thth-2.5.0 → thth-2.8.0}/skills/thth/SKILL.md +0 -0
- {thth-2.5.0 → thth-2.8.0}/systemd/thth@.service +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/__init__.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/conftest.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/fixtures/build_injection_eval.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/fixtures/callback/worker_responses.json +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/fixtures/injection_eval.json +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/fixtures/umami-bile.md +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/helpers/__init__.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/helpers/fake_oauth_server.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/helpers/fake_push_adapter.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/helpers/fake_redirect_server.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/helpers/fake_search_adapter.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/helpers/fake_threads_reader.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/helpers/fake_threads_writes.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/helpers/hold_lock.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/helpers/hold_repo_lock.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/helpers/worker_probe.mjs +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_account_name_check.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_account_report.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_accounts_dir.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_adapter_boundary.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_after_cli.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_api_diagnostic.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_api_error_body.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_app_env_cli.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_approval.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_approve_cmd.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_ask.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_atlas_fifth_review.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_atlas_fourth_review.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_atlas_review.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_atlas_second_review.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_atlas_third_review.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_audit1_fixes.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_audit2_fixes.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_audit_b_counterexamples.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_audit_b_m1_semantics.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_audit_b_n1_depth.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_audit_b_r1_closure.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_audit_v201.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_audit_v23_details.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_auth.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_auth_identity_required.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_behind_remote.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_bluesky_adapter.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_bluesky_secret_scrub.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_board_needs_review.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_board_running.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_charcount.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_cli_entrypoint.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_cli_help.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_cli_vm_path.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_collect_engagements.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_collect_runs.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_comparability.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_control_chars_refused.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_conversation_paging.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_count_for.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_docs_links.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_doctor.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_doctor_all_permissions.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_doctor_next_step.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_duplicate_front_matter_keys.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_editorial_audit.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_editorial_n3_n4.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_engagements.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_external_acceptance.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_fake_api.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_form_spec.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_form_spec_cli.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_forms.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_fresh_install.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_front_matter_injection.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_healthcheck_notifications.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_hypotheses.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_hypothesis_boundaries.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_hypothesis_units.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_impact_cli.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_inbox_granted_scopes.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_incident_notifications.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_independent_thread_review.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_independent_topic_review.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_independent_topic_review_r2.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_inflight.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_jst.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_lint.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_list_projections.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_llm_trial.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_llm_trial_engage.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_lock.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_m2_independent.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_maintain.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_mastodon_visibility.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_max_per_run.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_mcp.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_mcp_robustness.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_measured.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_media_accounts.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_media_next_steps.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_media_roundtrip.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_mismatch_diagnostics.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_n5_reply_null_element.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_n6_replies_incomplete_ledger.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_n7_doctor_error_body.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_no_limits.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_observation_provenance.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_operations_handoff.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_packaging.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_preview.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_production_mode.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_pull_cli.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_queue.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_quiet_hours.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_r2_r4_r5.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_read_cli_bare_runtimeerror.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_reason_adoption.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_redact.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_redirect_token_leak.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_refresh.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_refresh_destination.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_rejudge_new.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_rejudge_r2_r3.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_replies.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_replies_refresh.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_repo_lock.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_repo_sync.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_report_checkout_import.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_report_http.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_report_isolation.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_report_service.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_reverse_audit.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_review_case_id_cluster_m2.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_review_case_id_cluster_r2.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_review_cli.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_review_evidence_account.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_review_form_spec_and_case_id.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_review_provenance.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_review_records.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_revoke.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_roundtrip.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_run_missing_env.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_run_record_failclosed.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_runs_collected.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_runs_read_rows.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_schedule_multiple.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_schedule_view.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_scopes_source_requested.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_search_engage_targets.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_search_media_routes.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_secret_value_redact.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_secrets_grep.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_security_small_fixes.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_selfupdate.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_send.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_send_cli.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_send_control_chars.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_send_media.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_send_visibility.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_sent_integrity.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_server_json.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_site.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_skill_doc.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_systemd.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_thread_read.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_threadrun.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_threads_read_permissions.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_threadshape.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_thth_root.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_token_exists.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_token_scopes_record.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_token_set.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_topic.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_topic_advice.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_topic_cli.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_topic_models.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_topic_review_fixes.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_topic_shelf_observers.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_topic_store.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_topics_record_concurrent.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_topics_tool.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_trial_frictions.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_v21b_approved_writes.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_value_domains_shown.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_version.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_who_cli.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_writeback_rebase.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_writeback_validate.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/tests/test_x_metrics.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/thth/__init__.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/thth/__main__.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/thth/account_cli.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/thth/account_report.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/thth/accounts.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/thth/adapters/__init__.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/thth/adapters/threads.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/thth/after_cli.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/thth/api_diagnostic.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/thth/appenv.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/thth/ask.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/thth/ask_cli.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/thth/doctor.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/thth/engagements.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/thth/forms.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/thth/healthcheck.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/thth/httpsafe.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/thth/inflight.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/thth/jst.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/thth/lock.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/thth/maintain.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/thth/oauth.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/thth/postid.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/thth/queuefile.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/thth/redact.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/thth/replies.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/thth/report.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/thth/report_http.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/thth/report_isolation.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/thth/retract_cli.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/thth/runs.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/thth/scopes.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/thth/secrets_fs.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/thth/selfupdate.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/thth/sent.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/thth/systemd_gen.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/thth/thread_read.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/thth/threadrun.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/thth/threadshape.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/thth/topic_advice.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/thth/topic_cli.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/thth/topic_models.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/thth/topic_store.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/thth/topics.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/thth/who_cli.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/thth/writeback.py +0 -0
- {thth-2.5.0 → thth-2.8.0}/thth/x_metrics.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: thth
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.8.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.
|
|
36
|
+
このソースの版は **2.7.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
|
|
|
@@ -45,7 +45,7 @@ watchtower(`~/Developer/watchtower`)の隣に同じ流儀で並べる。watc
|
|
|
45
45
|
|
|
46
46
|
## 実装と運用の状態(2026-09-18)
|
|
47
47
|
|
|
48
|
-
2.
|
|
48
|
+
2.7.0 の実装 revision `f331e8c` の全件テストは **2743 件**(`python -m pytest tests/ -q -n auto -p no:cacheprovider`・1 skip・rc=0)。開発中の変更の検証は対象 revision の CI を参照してください。
|
|
49
49
|
|
|
50
50
|
**媒体**: Threads(稼働)・Bluesky・Mastodon(同席用の台帳あり・未稼働)。
|
|
51
51
|
|
|
@@ -59,6 +59,20 @@ timer(systemd・`thth systemd` で生成)は10分ごとに実行し、投稿
|
|
|
59
59
|
|
|
60
60
|
**トピックの棚**(`thth topics`): 観測者ごとに並ぶ・打ち消し `retract-note`・`history`。
|
|
61
61
|
|
|
62
|
+
### v2.7.0 の変更
|
|
63
|
+
|
|
64
|
+
- 指標採集に720時間(30日)を追加し、対象窓を刻みから38日と計算。正常時は指標6回、返信は従来の5回です。返信の採集窓は独立して維持します。
|
|
65
|
+
- 新しい観測に、採取時刻以前48時間以内で最も新しい日次フォロワー数を文脈として添えます。`followers_count_at` と `staleness_hours` を含み、追加APIや過去行の後補完はありません。該当しない場合は `context_reason` に理由を残します。
|
|
66
|
+
- Threads・Bluesky・Mastodonの刻みを揃え、媒体にないviewsはnullのまま維持。[合成検証と限界](docs/検証_v31_C実装_2026-09-18.md)。実SNS APIでの30日後採集は未確認です。
|
|
67
|
+
- C3・C4は実装保留。[独立規約照合報告](docs/照合_MetaPlatformTerms_枝と語の観測_2026-09-18_Codex.md)の未確認事項とApp Reviewの結果を踏まえて別途判断します。
|
|
68
|
+
|
|
69
|
+
### v2.6.0 の変更
|
|
70
|
+
|
|
71
|
+
- 既存台帳から刻み別の値、中央値の IQR・min・max、枝別の帰結、採集成否と鮮度、層別比較、採集時刻を上限とした枝の形を追加。既存キーは維持し `schema_version=1` のままです。
|
|
72
|
+
- `cannot_say_details` にコードと既存の説明文を併記。施策レポートには未成熟投稿が24時間に達する予定を添え、採集保証とは分けます。
|
|
73
|
+
- `handoff-report --since-last-read` で保存済みsnapshotとの差分。cursor保存は CLI の `--mark-read --by 名前` を明示したときだけ。MCP・HTTPは読むだけです。
|
|
74
|
+
- API呼出を増やさない分析の改良です。VM5accountの読取検証と限界は [検証記録](docs/検証_v31_A実装_2026-09-18.md) に記載。Bluesky/Mastodon の非空実台帳と実採用施策の検証は未確認です。
|
|
75
|
+
|
|
62
76
|
### v2.5.0 の変更
|
|
63
77
|
|
|
64
78
|
- 読み取り専用のレポート 3 本: `analytics-report`(活動のスナップショット・`--compare-previous` で隣接期間の比較)・`handoff-report`(ローカル運用記録の引継ぎ)・`study-report`(施策の宣言と本人の観測の結合)。MCP に `analytics_report`・`operations_handoff`・`study_report`。数値は期間・母数・欠測・根拠を連れて歩き、因果や推奨は出しません。
|
|
@@ -9,7 +9,7 @@ watchtower(`~/Developer/watchtower`)の隣に同じ流儀で並べる。watc
|
|
|
9
9
|
|
|
10
10
|
## 版
|
|
11
11
|
|
|
12
|
-
このソースの版は **2.
|
|
12
|
+
このソースの版は **2.7.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
|
|
|
@@ -21,7 +21,7 @@ watchtower(`~/Developer/watchtower`)の隣に同じ流儀で並べる。watc
|
|
|
21
21
|
|
|
22
22
|
## 実装と運用の状態(2026-09-18)
|
|
23
23
|
|
|
24
|
-
2.
|
|
24
|
+
2.7.0 の実装 revision `f331e8c` の全件テストは **2743 件**(`python -m pytest tests/ -q -n auto -p no:cacheprovider`・1 skip・rc=0)。開発中の変更の検証は対象 revision の CI を参照してください。
|
|
25
25
|
|
|
26
26
|
**媒体**: Threads(稼働)・Bluesky・Mastodon(同席用の台帳あり・未稼働)。
|
|
27
27
|
|
|
@@ -35,6 +35,20 @@ timer(systemd・`thth systemd` で生成)は10分ごとに実行し、投稿
|
|
|
35
35
|
|
|
36
36
|
**トピックの棚**(`thth topics`): 観測者ごとに並ぶ・打ち消し `retract-note`・`history`。
|
|
37
37
|
|
|
38
|
+
### v2.7.0 の変更
|
|
39
|
+
|
|
40
|
+
- 指標採集に720時間(30日)を追加し、対象窓を刻みから38日と計算。正常時は指標6回、返信は従来の5回です。返信の採集窓は独立して維持します。
|
|
41
|
+
- 新しい観測に、採取時刻以前48時間以内で最も新しい日次フォロワー数を文脈として添えます。`followers_count_at` と `staleness_hours` を含み、追加APIや過去行の後補完はありません。該当しない場合は `context_reason` に理由を残します。
|
|
42
|
+
- Threads・Bluesky・Mastodonの刻みを揃え、媒体にないviewsはnullのまま維持。[合成検証と限界](docs/検証_v31_C実装_2026-09-18.md)。実SNS APIでの30日後採集は未確認です。
|
|
43
|
+
- C3・C4は実装保留。[独立規約照合報告](docs/照合_MetaPlatformTerms_枝と語の観測_2026-09-18_Codex.md)の未確認事項とApp Reviewの結果を踏まえて別途判断します。
|
|
44
|
+
|
|
45
|
+
### v2.6.0 の変更
|
|
46
|
+
|
|
47
|
+
- 既存台帳から刻み別の値、中央値の IQR・min・max、枝別の帰結、採集成否と鮮度、層別比較、採集時刻を上限とした枝の形を追加。既存キーは維持し `schema_version=1` のままです。
|
|
48
|
+
- `cannot_say_details` にコードと既存の説明文を併記。施策レポートには未成熟投稿が24時間に達する予定を添え、採集保証とは分けます。
|
|
49
|
+
- `handoff-report --since-last-read` で保存済みsnapshotとの差分。cursor保存は CLI の `--mark-read --by 名前` を明示したときだけ。MCP・HTTPは読むだけです。
|
|
50
|
+
- API呼出を増やさない分析の改良です。VM5accountの読取検証と限界は [検証記録](docs/検証_v31_A実装_2026-09-18.md) に記載。Bluesky/Mastodon の非空実台帳と実採用施策の検証は未確認です。
|
|
51
|
+
|
|
38
52
|
### v2.5.0 の変更
|
|
39
53
|
|
|
40
54
|
- 読み取り専用のレポート 3 本: `analytics-report`(活動のスナップショット・`--compare-previous` で隣接期間の比較)・`handoff-report`(ローカル運用記録の引継ぎ)・`study-report`(施策の宣言と本人の観測の結合)。MCP に `analytics_report`・`operations_handoff`・`study_report`。数値は期間・母数・欠測・根拠を連れて歩き、因果や推奨は出しません。
|
|
@@ -143,7 +143,8 @@ TOOLS = [
|
|
|
143
143
|
"name": "operations_handoff",
|
|
144
144
|
"description": "ローカル運用記録の状態・承認待ち・inflight・通知未処理を根拠と鮮度の制約付きで返す。同期・承認・再送はしない",
|
|
145
145
|
"inputSchema": {"type": "object", "properties": {
|
|
146
|
-
"account": {"type": "string"}, "project": {"type": "string"}
|
|
146
|
+
"account": {"type": "string"}, "project": {"type": "string"},
|
|
147
|
+
"since_last_read": {"type": "boolean"}}},
|
|
147
148
|
},
|
|
148
149
|
{
|
|
149
150
|
"name": "study_report",
|
|
@@ -159,6 +160,7 @@ TOOLS = [
|
|
|
159
160
|
"inputSchema": {
|
|
160
161
|
"type": "object",
|
|
161
162
|
"properties": {
|
|
163
|
+
"by": {"type": "string", "enum": ["kind", "hour_band", "topic", "tag"]},
|
|
162
164
|
"compare_previous": {"type": "boolean", "description": "直前の同じ日数との比較(既定false)"},
|
|
163
165
|
"account": {"type": "string"},
|
|
164
166
|
"project": {"type": "string"},
|
|
@@ -397,6 +399,11 @@ def validate_arguments(name: str, arguments) -> dict:
|
|
|
397
399
|
raise ToolInputError(
|
|
398
400
|
f"{name}: arguments は object で渡してください"
|
|
399
401
|
f"(受け取った: {type(arguments).__name__})")
|
|
402
|
+
if name == "analytics_report" and "by" in arguments:
|
|
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
|
+
if name == "operations_handoff" and "since_last_read" in arguments and type(arguments["since_last_read"]) is not bool:
|
|
406
|
+
raise ToolInputError("operations_handoff: since_last_read は boolean です")
|
|
400
407
|
props = schema.get("properties") or {}
|
|
401
408
|
for key in schema.get("required") or []:
|
|
402
409
|
if arguments.get(key) is None:
|
|
@@ -533,6 +540,10 @@ def call_tool(name: str, arguments: dict | None) -> dict:
|
|
|
533
540
|
text = proc.stdout
|
|
534
541
|
elif name == "operations_handoff":
|
|
535
542
|
args = ["handoff-report"]
|
|
543
|
+
if "mark_read" in arguments or "by" in arguments:
|
|
544
|
+
raise ValueError("operations_handoff is read-only")
|
|
545
|
+
if arguments.get("since_last_read"):
|
|
546
|
+
args.append("--since-last-read")
|
|
536
547
|
if arguments.get("project"):
|
|
537
548
|
args += ["--project", arguments["project"]]
|
|
538
549
|
else:
|
|
@@ -558,6 +569,8 @@ def call_tool(name: str, arguments: dict | None) -> dict:
|
|
|
558
569
|
args += ["--" + key.replace("_", "-"), str(arguments[key])]
|
|
559
570
|
if arguments.get("compare_previous"):
|
|
560
571
|
args.append("--compare-previous")
|
|
572
|
+
if arguments.get("by"):
|
|
573
|
+
args.extend(["--by", arguments["by"]])
|
|
561
574
|
args.append("--json")
|
|
562
575
|
proc = run_cli(args)
|
|
563
576
|
text = proc.stdout
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import datetime as dt
|
|
2
|
+
import json
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from thth import collection_context, jst
|
|
5
|
+
|
|
6
|
+
NOW=jst.parse('2026-10-01T01:00:00+09:00')
|
|
7
|
+
|
|
8
|
+
def daily(value,age,**extra):
|
|
9
|
+
return {'account':'a','date':'ignored','collected_at':jst.iso(NOW-dt.timedelta(hours=age)),
|
|
10
|
+
'metrics':{'followers_count':value},**extra}
|
|
11
|
+
|
|
12
|
+
def test_recent_daily_across_month_and_date_ignored(tmp_path):
|
|
13
|
+
(tmp_path/'a-2026-09.ndjson').write_text(json.dumps(daily(120,2,date='2026-09-29')))
|
|
14
|
+
value=collection_context.followers('a',tmp_path,NOW)
|
|
15
|
+
assert value=={'followers_count':120,'followers_count_at':'2026-09-30T23:00:00+09:00',
|
|
16
|
+
'staleness_hours':2.0,'source':'insights_account'}
|
|
17
|
+
(tmp_path/'a-2026-10.ndjson').write_text(json.dumps(daily(121,1.234)))
|
|
18
|
+
value=collection_context.followers('a',tmp_path,NOW)
|
|
19
|
+
assert value['followers_count']==121 and value['staleness_hours']==1.23
|
|
20
|
+
|
|
21
|
+
def test_recent_daily_inclusive_48h_and_no_old_fallback(tmp_path):
|
|
22
|
+
p=tmp_path/'a-2026-09.ndjson'
|
|
23
|
+
p.write_text(json.dumps(daily(0,48)))
|
|
24
|
+
assert collection_context.followers('a',tmp_path,NOW)['followers_count']==0
|
|
25
|
+
p.write_text(json.dumps(daily(2,49)))
|
|
26
|
+
assert collection_context.followers('a',tmp_path,NOW) is None
|
|
27
|
+
p.write_text('\n'.join(map(json.dumps,[daily(2,3),daily(True,1)])))
|
|
28
|
+
assert collection_context.followers('a',tmp_path,NOW) is None
|
|
29
|
+
|
|
30
|
+
def test_recent_daily_collect_reason_and_no_retrofit(tmp_path,isolated_account_factory,monkeypatch):
|
|
31
|
+
from tests.test_v31_collection import setup_route,Adapter,POSTED,daily_path,daily_row
|
|
32
|
+
from thth import collect,accounts
|
|
33
|
+
a=setup_route(tmp_path,isolated_account_factory,monkeypatch,'sent')
|
|
34
|
+
now=POSTED+dt.timedelta(hours=1)
|
|
35
|
+
p=daily_path(a)
|
|
36
|
+
p.write_text(json.dumps(daily_row(a,9,collected_at=jst.iso(now-dt.timedelta(hours=49)))))
|
|
37
|
+
collect.collect_once(a['name'],adapter=Adapter(),now=now,log=lambda _:None)
|
|
38
|
+
out=Path(accounts.data_dirs(a,a['name'])['insights_posts'])/'P.ndjson'
|
|
39
|
+
before=out.read_bytes()
|
|
40
|
+
assert json.loads(before)['context'] is None
|
|
41
|
+
assert json.loads(before)['context_reason']=='no_recent_daily_record'
|
|
42
|
+
p.write_text(json.dumps(daily_row(a,0,collected_at=jst.iso(now))))
|
|
43
|
+
collect.collect_once(a['name'],adapter=Adapter(),now=POSTED+dt.timedelta(hours=6),log=lambda _:None)
|
|
44
|
+
assert out.read_bytes().startswith(before)
|
|
45
|
+
assert json.loads(out.read_text().splitlines()[-1])['context']['followers_count']==0
|
|
@@ -48,6 +48,27 @@ def test_half_open_zero_delta_and_read_only(isolated_account_factory):
|
|
|
48
48
|
assert json.loads("\n".join(s[4:] for s in markdown.splitlines() if s.startswith(" "))) == r
|
|
49
49
|
|
|
50
50
|
|
|
51
|
+
def test_tag_strata_use_observed_tags_and_suppress_small_samples(isolated_account_factory):
|
|
52
|
+
a = isolated_account_factory(media="bluesky")
|
|
53
|
+
seed(a, "old", NOW - datetime.timedelta(days=10), value=5,
|
|
54
|
+
extra={"tags": ["茶"], "topic": "予定だけの語"})
|
|
55
|
+
seed(a, "new1", NOW - datetime.timedelta(days=3), value=10,
|
|
56
|
+
extra={"tags": ["茶", "苦味"], "topic": "予定だけの語"})
|
|
57
|
+
seed(a, "new2", NOW - datetime.timedelta(days=2), value=20,
|
|
58
|
+
extra={"tags": ["茶"], "topic": "予定だけの語"})
|
|
59
|
+
seed(a, "untagged", NOW - datetime.timedelta(days=1, hours=3), value=30,
|
|
60
|
+
extra={"tags": [], "topic": "予定だけの語"})
|
|
61
|
+
result = report(a, min_n=2, by="tag")
|
|
62
|
+
group = result["by_account"][a["name"]]["posts"]["stratified"]
|
|
63
|
+
assert group["strata"]["tagged"]["current"]["n_total"] == 2
|
|
64
|
+
assert group["strata"]["untagged"]["current"]["n_total"] == 1
|
|
65
|
+
assert group["strata"]["#茶"]["current"]["metrics"]["views"]["median"] == 15
|
|
66
|
+
assert group["strata"]["#苦味"]["current"]["metrics"]["views"]["median"] is None
|
|
67
|
+
assert group["strata"]["#茶"]["comparison"]["views"]["absolute_median_change"] is None
|
|
68
|
+
assert "#予定だけの語" not in group["strata"]
|
|
69
|
+
assert group["reconciliation"]["current"] == {"sum_n_total": 3, "n_total": 3}
|
|
70
|
+
|
|
71
|
+
|
|
51
72
|
@pytest.mark.parametrize("age,marks,extra,reason", [
|
|
52
73
|
(23.99, [24], {}, "premature_observation"),
|
|
53
74
|
(30, [24], {}, "late_observation"),
|
|
@@ -85,7 +106,7 @@ def test_invalid_metric_does_not_become_zero(isolated_account_factory, value):
|
|
|
85
106
|
seed(a, "p", NOW - datetime.timedelta(days=3), value=value)
|
|
86
107
|
p = report(a, min_n=1)["by_account"][a["name"]]["posts"]["current"]
|
|
87
108
|
assert p["n_eligible"] == 1 # time-eligible does not imply every metric exists
|
|
88
|
-
assert p["metrics"]["views"] == {"n_total": 1, "n_eligible": 0, "n_missing": 1, "median": None}
|
|
109
|
+
assert p["metrics"]["views"] == {"n_total": 1, "n_eligible": 0, "n_missing": 1, "median": None, "iqr": None, "min": None, "max": None, "spread_reason": "below_min_n"}
|
|
89
110
|
json.dumps(p, allow_nan=False)
|
|
90
111
|
|
|
91
112
|
|
|
@@ -32,9 +32,19 @@ def test_boundaries_evidence_missing_and_no_writes(isolated_account_factory):
|
|
|
32
32
|
node = result["by_account"][account["name"]]
|
|
33
33
|
original = after_cli.answer(account["name"], now=NOW, window_days=7)
|
|
34
34
|
for key in ("posts", "engagements", "comparable", "cannot_say"):
|
|
35
|
-
|
|
35
|
+
def legacy_projection(actual, original):
|
|
36
|
+
if isinstance(original, dict):
|
|
37
|
+
return {k: legacy_projection(actual[k], v) for k, v in original.items()}
|
|
38
|
+
if isinstance(original, list):
|
|
39
|
+
assert len(actual) == len(original)
|
|
40
|
+
return [legacy_projection(a, b) for a, b in zip(actual, original)]
|
|
41
|
+
return actual
|
|
42
|
+
actual = node[key]
|
|
43
|
+
if key == "cannot_say":
|
|
44
|
+
actual = [x for x in actual if not x.startswith("collection_records_")]
|
|
45
|
+
assert legacy_projection(actual, original[key]) == original[key]
|
|
36
46
|
assert {p["post_id"] for p in node["posts"]["by_post"]} == {"start", "end"}
|
|
37
|
-
assert node["posts"]["views_24h"] == {"median": None, "n": 1}
|
|
47
|
+
assert node["posts"]["views_24h"] == {"median": None, "n": 1, "iqr": None, "min": None, "max": None, "spread_reason": "below_min_n"}
|
|
38
48
|
end = next(p for p in node["posts"]["by_post"] if p["post_id"] == "end")
|
|
39
49
|
assert end["views_24h"] is None and end["covered"] is False
|
|
40
50
|
assert node["engagements"]["by_branch"][0]["post_id"] == "reply"
|
|
@@ -46,7 +56,16 @@ def test_boundaries_evidence_missing_and_no_writes(isolated_account_factory):
|
|
|
46
56
|
assert result["generated_at"] == "2026-09-17T08:00:00+09:00"
|
|
47
57
|
text = json.dumps(result)
|
|
48
58
|
assert "PRIVATE BODY" not in text
|
|
49
|
-
assert '"username"' not in text
|
|
59
|
+
assert '"username"' not in text
|
|
60
|
+
def assert_text_only_in_details(value, details=False):
|
|
61
|
+
if isinstance(value, dict):
|
|
62
|
+
assert "text" not in value or details
|
|
63
|
+
for key, child in value.items():
|
|
64
|
+
assert_text_only_in_details(child, key == "cannot_say_details")
|
|
65
|
+
elif isinstance(value, list):
|
|
66
|
+
for child in value:
|
|
67
|
+
assert_text_only_in_details(child, details)
|
|
68
|
+
assert_text_only_in_details(result)
|
|
50
69
|
markdown = analytics_report.render_markdown(result)
|
|
51
70
|
recovered = "\n".join(line[4:] for line in markdown.splitlines() if line.startswith(" "))
|
|
52
71
|
assert json.loads(recovered) == result
|
|
@@ -51,6 +51,24 @@ def account_cfg(**over):
|
|
|
51
51
|
return cfg
|
|
52
52
|
|
|
53
53
|
|
|
54
|
+
def test_bluesky_bundle_uses_effective_first_segment_and_preserves_body_tags():
|
|
55
|
+
fm = FM.replace("nigamilab-threads", "sample-bluesky")
|
|
56
|
+
body = BODY.replace("## threads", "## bluesky")
|
|
57
|
+
b = bundle.parse_text(make(fm, body), "b.md")
|
|
58
|
+
cfg = account_cfg(media="bluesky", hashtags=True)
|
|
59
|
+
assert not [issue for issue in bundle.check(b, account_cfg=cfg)
|
|
60
|
+
if not issue.startswith("warning:")]
|
|
61
|
+
assert bundle.effective_segments(b.segments, cfg, b.get("topic"))[0].endswith(
|
|
62
|
+
"\n#コーヒー")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_mastodon_bundle_disabled_topic_has_one_warning():
|
|
66
|
+
b = bundle.parse_text(make(FM, BODY.replace("## threads", "## mastodon")), "b.md")
|
|
67
|
+
issues = bundle.check(b, account_cfg=account_cfg(media="mastodon", hashtags=False))
|
|
68
|
+
assert [issue for issue in issues if "topic が効きません" in issue] == [
|
|
69
|
+
"warning: 1 段目: この媒体では topic が効きません(hashtags: false)"]
|
|
70
|
+
|
|
71
|
+
|
|
54
72
|
# --- v1 を壊さない ---------------------------------------------------------
|
|
55
73
|
|
|
56
74
|
def test_旧版の解析はthth2を型外にする():
|
|
@@ -89,6 +89,39 @@ def test_刻みを跨いだら1行だけ足す(tmp_path, isolated_account_factor
|
|
|
89
89
|
assert rows[0]["age_hours"] == 2.0
|
|
90
90
|
|
|
91
91
|
|
|
92
|
+
def test_bluesky_observed_tags_are_saved_without_body_or_handle(tmp_path, isolated_account_factory):
|
|
93
|
+
pair = init_git_pair(tmp_path, seed_content=make_queue_text({
|
|
94
|
+
"status": "posted", "post_id": "POST1",
|
|
95
|
+
"posted_at": "2026-09-10T10:00:00+09:00", "topic": "予定だけの語"}))
|
|
96
|
+
account = isolated_account_factory(repo_dir=pair["work"], production=True,
|
|
97
|
+
media="bluesky")
|
|
98
|
+
class TaggedAdapter(FakeAdapter):
|
|
99
|
+
def observed_tags(self, post_id):
|
|
100
|
+
assert post_id == "POST1"
|
|
101
|
+
return ["実測の茶"]
|
|
102
|
+
collect_mod.run_collect(account["name"], adapter=TaggedAdapter(), now=NOW,
|
|
103
|
+
log=lambda _line: None)
|
|
104
|
+
row = _rows(pair["work"], "data/sns/insights/posts/POST1.ndjson")[0]
|
|
105
|
+
assert row["tags"] == ["実測の茶"]
|
|
106
|
+
assert row["topic"] == "予定だけの語" # planned metadata stays separate
|
|
107
|
+
assert not {"text", "body", "username", "handle"} & row.keys()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_bluesky_tag_read_failure_is_unknown_not_untagged(tmp_path, isolated_account_factory):
|
|
111
|
+
pair = init_git_pair(tmp_path, seed_content=make_queue_text({
|
|
112
|
+
"status": "posted", "post_id": "POST1",
|
|
113
|
+
"posted_at": "2026-09-10T10:00:00+09:00"}))
|
|
114
|
+
account = isolated_account_factory(repo_dir=pair["work"], production=True,
|
|
115
|
+
media="bluesky")
|
|
116
|
+
class BadTaggedAdapter(FakeAdapter):
|
|
117
|
+
def observed_tags(self, post_id):
|
|
118
|
+
raise ValueError("malformed record")
|
|
119
|
+
collect_mod.run_collect(account["name"], adapter=BadTaggedAdapter(), now=NOW,
|
|
120
|
+
log=lambda _line: None)
|
|
121
|
+
row = _rows(pair["work"], "data/sns/insights/posts/POST1.ndjson")[0]
|
|
122
|
+
assert row["tags"] is None
|
|
123
|
+
|
|
124
|
+
|
|
92
125
|
def test_採取時点の所有accountが行に書かれる(tmp_path, isolated_account_factory):
|
|
93
126
|
"""外部レビュー再判定 R3・2026-09-12: 台帳の行そのものに、採取時点の所有
|
|
94
127
|
`account` を残す。これが無いと、`thth/measured.py` は「いまの原稿の
|
|
@@ -220,7 +220,8 @@ def test_d_従来の台帳は置き場も行の中身も変わらない(tmp_path
|
|
|
220
220
|
assert set(rows[0]) == {
|
|
221
221
|
"post_id", "file", "source", "account", "medium", "topic", "reply_to",
|
|
222
222
|
"text_length", "has_link", "collected_at", "posted_at", "age_hours",
|
|
223
|
-
"marks", "metrics"}, sorted(rows[0])
|
|
223
|
+
"marks", "metrics", "context", "context_reason"}, sorted(rows[0])
|
|
224
|
+
assert rows[0]["context"] is None
|
|
224
225
|
assert rows[0]["source"] == "queue"
|
|
225
226
|
assert rows[0]["file"] == "a.md"
|
|
226
227
|
assert rows[0]["age_hours"] == 2.0
|
|
@@ -74,6 +74,7 @@ STATUS_FIXTURE = {
|
|
|
74
74
|
"favourites_count": 7,
|
|
75
75
|
"replies_count": 3,
|
|
76
76
|
"reblogs_count": 2,
|
|
77
|
+
"tags": [], # real Status shape: observed_tags can distinguish empty from missing
|
|
77
78
|
"account": {"id": "9000", "acct": "nigamilab"},
|
|
78
79
|
# 本物の応答は必ず持つ(監査 P2-2)。`fetch_post()`(T1-1)の C-1 検査対象。
|
|
79
80
|
"visibility": "public",
|
|
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
import datetime
|
|
5
5
|
|
|
6
6
|
from tests.conftest import make_queue_text, parse_verified
|
|
7
|
-
from thth import queuefile, select as select_mod
|
|
7
|
+
from thth import approval, queuefile, select as select_mod
|
|
8
8
|
|
|
9
9
|
ACCOUNT = "nigamilab-threads"
|
|
10
10
|
NOW = datetime.datetime.fromisoformat("2026-09-09T10:00:00+09:00")
|
|
@@ -44,6 +44,19 @@ def select(files, **kwargs):
|
|
|
44
44
|
return select_mod.select_one(files, **kwargs)
|
|
45
45
|
|
|
46
46
|
|
|
47
|
+
def test_bluesky_hashtags_policy_change_stales_old_approval(tmp_path):
|
|
48
|
+
body = "## bluesky\n本文です。\n"
|
|
49
|
+
old_sha = approval.compute_approved_sha(
|
|
50
|
+
section="本文です。", account=ACCOUNT, reply_to=None, topic="茶",
|
|
51
|
+
publish_at="2026-09-09T08:00:00+09:00")
|
|
52
|
+
p = write(tmp_path, "bluesky.md", body=body, media="bluesky",
|
|
53
|
+
fm_overrides={"topic": "茶", "approved_sha": old_sha,
|
|
54
|
+
"publish_at": "2026-09-09T08:00:00+09:00"})
|
|
55
|
+
result = select(parse_all([p]), account_cfg=account_cfg(media="bluesky", hashtags=True))
|
|
56
|
+
assert result.chosen is None
|
|
57
|
+
assert any(r.reason == "approval_stale" for r in result.rejections)
|
|
58
|
+
|
|
59
|
+
|
|
47
60
|
# 1. post_id が入っている → status より先に落とす(approved に戻されても出ない・§3.5)
|
|
48
61
|
def test_1_post_idがあれば承認済みでも出ない(tmp_path):
|
|
49
62
|
p = write(tmp_path, "a.md", fm_overrides={
|
|
@@ -149,7 +149,8 @@ def test_broken_source_immature_and_sparse_remain_unknown(isolated_account_facto
|
|
|
149
149
|
assert r["observations"]["changed"]["n_immature"] == 1
|
|
150
150
|
assert r["observations"]["baseline"]["metrics"]["views"]["n_eligible"] == 1
|
|
151
151
|
assert all(v["absolute_median_change"] is None for v in r["comparison"].values())
|
|
152
|
-
assert len(r["cannot_say"]) ==
|
|
152
|
+
assert len(r["cannot_say"]) == 3
|
|
153
|
+
assert r["cannot_say"][-1] == "forecast_assumes_collection_runs"
|
|
153
154
|
|
|
154
155
|
|
|
155
156
|
def test_invalid_cli_is_error_without_input_disclosure(isolated_account_factory, capsys):
|
|
@@ -21,7 +21,7 @@ from pathlib import Path
|
|
|
21
21
|
import pytest
|
|
22
22
|
|
|
23
23
|
from tests.conftest import init_git_pair, run_git
|
|
24
|
-
from thth import approval, bundle, inflight, threadrun, threadthrow
|
|
24
|
+
from thth import approval, bundle, cli, inflight, threadrun, threadthrow
|
|
25
25
|
from thth.adapters.base import PublishResult
|
|
26
26
|
|
|
27
27
|
NOW = datetime.datetime.fromisoformat("2026-09-15T19:00:00+09:00")
|
|
@@ -117,6 +117,72 @@ def write_and_push(pair, text):
|
|
|
117
117
|
run_git(pair["work"], ["push"])
|
|
118
118
|
|
|
119
119
|
|
|
120
|
+
def _bluesky_bundle_text(account: str, *, transformed: bool = True,
|
|
121
|
+
include_body_tag: bool = True) -> str:
|
|
122
|
+
segments = ["本文 #苦味" if include_body_tag else "本文", "続きです"]
|
|
123
|
+
text = bundle_text(account=account, topic="茶", segments=segments).replace(
|
|
124
|
+
"## threads", "## bluesky")
|
|
125
|
+
old = approval.compute_bundle_sha(
|
|
126
|
+
segments=segments, account=account, topic="茶",
|
|
127
|
+
publish_at="2026-09-15T19:00:00+09:00",
|
|
128
|
+
continue_until="2026-09-15T20:00:00+09:00")
|
|
129
|
+
effective = [segments[0] + "\n#茶", "続きです"]
|
|
130
|
+
new = approval.compute_bundle_sha(
|
|
131
|
+
segments=effective, account=account, topic="茶",
|
|
132
|
+
publish_at="2026-09-15T19:00:00+09:00",
|
|
133
|
+
continue_until="2026-09-15T20:00:00+09:00")
|
|
134
|
+
assert text.count("approved_sha: " + old) == 1
|
|
135
|
+
return text.replace("approved_sha: " + old, "approved_sha: " + new) if transformed else text
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def test_bluesky_bundle_displays_and_publishes_effective_segments(
|
|
139
|
+
tmp_path, isolated_account_factory):
|
|
140
|
+
name = "bluesky-bundle"
|
|
141
|
+
source = _bluesky_bundle_text(name)
|
|
142
|
+
pair = init_git_pair(tmp_path, seed_content=source, seed_name="thread.md")
|
|
143
|
+
account = isolated_account_factory(name=name, repo_dir=pair["work"],
|
|
144
|
+
media="bluesky", hashtags=True,
|
|
145
|
+
production=True, quiet_hours=None,
|
|
146
|
+
min_interval_hours=0)
|
|
147
|
+
path = os.path.join(pair["work"], REL)
|
|
148
|
+
prepared, error = cli._prepare_bundle(path, source)
|
|
149
|
+
assert error is None
|
|
150
|
+
assert prepared["segments"] == ["本文 #苦味\n#茶", "続きです"]
|
|
151
|
+
adapter = FakeAdapter()
|
|
152
|
+
results = threadthrow.publish_bundle(name, REL, adapter_factory=lambda *_: adapter,
|
|
153
|
+
now=NOW)
|
|
154
|
+
assert [r.action for r in results] == ["published", "published"]
|
|
155
|
+
assert [call["text"] for call in adapter.calls] == prepared["segments"]
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def test_bluesky_bundle_old_published_segment_stays_frozen_after_policy_change(
|
|
159
|
+
tmp_path, isolated_account_factory):
|
|
160
|
+
name = "bluesky-frozen"
|
|
161
|
+
source = _bluesky_bundle_text(name, transformed=False, include_body_tag=False)
|
|
162
|
+
pair = init_git_pair(tmp_path, seed_content=source, seed_name="thread.md")
|
|
163
|
+
account = isolated_account_factory(name=name, repo_dir=pair["work"],
|
|
164
|
+
media="bluesky", hashtags=False,
|
|
165
|
+
production=True, quiet_hours=None,
|
|
166
|
+
min_interval_hours=0)
|
|
167
|
+
adapter = FakeAdapter()
|
|
168
|
+
first = threadthrow.publish_bundle(name, REL, adapter_factory=lambda *_: adapter,
|
|
169
|
+
now=NOW, max_posts=1)
|
|
170
|
+
assert first[0].action == "published"
|
|
171
|
+
assert adapter.calls[0]["text"] == "本文"
|
|
172
|
+
ledger = Path(account["accounts_dir"]) / f"{name}.json"
|
|
173
|
+
cfg = json.loads(ledger.read_text())
|
|
174
|
+
cfg["hashtags"] = True
|
|
175
|
+
ledger.write_text(json.dumps(cfg))
|
|
176
|
+
path = os.path.join(pair["work"], REL)
|
|
177
|
+
prepared, error = cli._prepare_bundle(path, Path(path).read_text())
|
|
178
|
+
assert prepared is None
|
|
179
|
+
assert "公開済みの段の本文は変えられません" in error
|
|
180
|
+
later = threadthrow.publish_bundle(name, REL, adapter_factory=lambda *_: adapter,
|
|
181
|
+
now=NOW)
|
|
182
|
+
assert all(r.action != "published" for r in later)
|
|
183
|
+
assert len(adapter.calls) == 1
|
|
184
|
+
|
|
185
|
+
|
|
120
186
|
# --- 正常系 -----------------------------------------------------------------
|
|
121
187
|
|
|
122
188
|
def test_束を順に出して親子がつながる(thread_account):
|