threadkeeper 0.17.2__tar.gz → 0.17.4__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.
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/PKG-INFO +29 -9
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/README.md +28 -8
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/pyproject.toml +1 -1
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_agent_status.py +38 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_curator.py +42 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_notify_daemon.py +42 -2
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_spawn_slim.py +7 -2
- threadkeeper-0.17.4/tests/test_spawn_worktree_isolation.py +112 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/agent_status.py +42 -11
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/curator.py +5 -4
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/notify.py +40 -4
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/skill_audit.py +15 -2
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/tools/spawn.py +133 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper.egg-info/PKG-INFO +29 -9
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper.egg-info/SOURCES.txt +1 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/LICENSE +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/setup.cfg +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_adapters.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_auto_update.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_backup.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_brief_footprint.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_brief_sections.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_candidate_reviewer.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_concepts.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_config_settings.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_config_watcher.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_core_memory.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_daemon_host_config.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_daemon_host_integration.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_daemon_liveness.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_daemon_state.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_dashboard.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_db_compact.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_db_contention.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_db_migration_fts.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_db_runtime.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_db_schema.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_delegated_search.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_dialectic.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_dialectic_feed_tools.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_dialectic_miner.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_dialectic_observation_resolve.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_dialectic_recompute.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_dialectic_tier.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_dialectic_validator.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_dialog_fts_external.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_egress_policy.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_embed_routing.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_embedding_generation.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_embedding_model_revision.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_embedding_storage_dedup.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_error_paths.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_eval_harness.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_evolve_applier.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_evolve_apply_2.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_evolve_apply_3.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_evolve_daemon.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_extract_daemon.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_extract_dedup.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_forget.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_github_budget.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_github_safety.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_helpers.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_host_embed.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_host_env.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_host_main.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_host_wedged.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_hybrid_retrieval.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_i18n_multilang.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_identity.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_ingest_cursor.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_ingest_denylist.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_ingest_secret_redaction.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_ingest_status.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_ingest_transactions.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_lessons.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_mcp_resources_prompts.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_memory_eval.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_memory_guard.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_memory_guard_host.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_menubar_app.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_missed_spawns.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_model_catalog.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_nudges.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_onnx_embeddings.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_panel.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_permissions.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_pickup.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_probe_daemon.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_process_health.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_release_workflows.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_retention.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_review_prompts.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_search_fts_punctuation.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_session_read_purity.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_shadow_review.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_single_flight_prompt_prefixes.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_skill_hint.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_skill_passive_tier.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_skill_tier.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_skill_updater.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_skill_use_parser.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_skill_watcher.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_skills.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_spawn_budget.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_spawn_codex_stdin.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_spawn_config.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_spawn_hint.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_spawn_permission_gate.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_spawn_reap.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_spawn_watchdog.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_spawn_wrap.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_sync_capture.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_sync_identity.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_sync_migrate.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_sync_protocol.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_sync_reset_node.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_sync_transport.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_task_retention.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_task_spool.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_thin_session.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_thread_janitor.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_threads.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_tool_annotations.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_tools_smoke.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_validate_threads.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_vec_search.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_verify_ingest.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/__init__.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/_mcp.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/_setup.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/_spawn_wrap.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/adapters/__init__.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/adapters/_hook_helpers.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/adapters/antigravity.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/adapters/base.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/adapters/claude_code.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/adapters/claude_desktop.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/adapters/codex.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/adapters/copilot.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/adapters/vscode.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/agent_metadata.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/assets/macos-agent-status/Info.plist +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/assets/macos-agent-status/README.md +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/assets/macos-agent-status/ThreadKeeperAgentStatus.swift +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/assets/macos-agent-status/build.sh +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/assets/macos-agent-status/install.sh +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/auto_update.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/backup.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/brief.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/candidate_reviewer.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/config.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/config_io.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/config_watcher.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/curator_snapshots.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/daemon_liveness.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/daemon_state.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/daemon_supervisor.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/db.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/dialectic_miner.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/dialectic_validator.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/egress.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/elicitation.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/embeddings.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/eval/__init__.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/eval/__main__.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/eval/harness.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/evolve_applier.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/evolve_daemon.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/extract_daemon.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/forget.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/github_budget.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/github_safety.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/harvest.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/helpers.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/host.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/host_embed.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/i18n.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/identity.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/ingest.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/lessons.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/memory_guard.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/menubar_app.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/migrate_embeddings.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/model_catalog.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/nudges.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/permissions.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/probe_daemon.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/process_health.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/retention.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/retrieval.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/review_prompts.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/search_proxy.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/server.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/shadow_review.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/skill_updater.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/skill_watcher.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/spawn_budget.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/spawn_config.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/sync/__init__.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/sync/capture.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/sync/daemon.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/sync/identity.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/sync/migrate.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/sync/protocol.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/sync/reset_node.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/sync/server.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/task_spool.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/thread_janitor.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/tool_schemas.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/tools/__init__.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/tools/agent_status.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/tools/candidate_reviewer.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/tools/concepts.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/tools/config_watch.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/tools/consolidate.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/tools/core_memory.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/tools/correlation.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/tools/curator.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/tools/dashboard.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/tools/db_maintenance.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/tools/dialectic.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/tools/dialectic_feed.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/tools/dialog.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/tools/distill.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/tools/evolve_applier.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/tools/extract.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/tools/forget.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/tools/graph.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/tools/invariants.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/tools/lessons.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/tools/memory_guard.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/tools/missed_spawns.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/tools/panel.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/tools/peers.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/tools/pickup.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/tools/probes.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/tools/process_health.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/tools/prompts.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/tools/resources.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/tools/session.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/tools/shadow_review.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/tools/skills.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/tools/style.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/tools/sync.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/tools/threads.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/tools/validate.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/trash.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/verify_ingest.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/wikilinks.py +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper.egg-info/dependency_links.txt +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper.egg-info/entry_points.txt +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper.egg-info/requires.txt +0 -0
- {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: threadkeeper
|
|
3
|
-
Version: 0.17.
|
|
3
|
+
Version: 0.17.4
|
|
4
4
|
Summary: Multi-agent shared brain across Claude Code/Desktop, Codex, Antigravity CLI, Copilot, and VS Code. Cross-session memory, self-improving skill loops, inter-agent signaling — one local MCP server.
|
|
5
5
|
Author: thread-keeper contributors
|
|
6
6
|
License: MIT
|
|
@@ -310,6 +310,12 @@ uses a SQLite `BEGIN IMMEDIATE` reservation: `spawn()` re-checks the budget and
|
|
|
310
310
|
inserts the child task row with its RSS estimate before `Popen`, so two
|
|
311
311
|
concurrent spawns cannot both squeeze through the cap.
|
|
312
312
|
|
|
313
|
+
When `cwd` is inside a Git checkout, `spawn()` also requires the source
|
|
314
|
+
checkout's tracked files to be clean, then starts the child from a unique
|
|
315
|
+
branch/worktree under `THREADKEEPER_TASK_LOG_DIR/worktrees/`. Parallel children
|
|
316
|
+
therefore never share a mutable checkout or Git index. Non-Git directories keep
|
|
317
|
+
their existing behavior; a dirty Git checkout is refused before a child starts.
|
|
318
|
+
|
|
313
319
|
The spawn wrapper also records each completed child's `duration_s`,
|
|
314
320
|
`tokens_in`, `tokens_out`, `tokens_total`, and `cost_usd` when the underlying
|
|
315
321
|
CLI emits a recognizable usage trailer. Optional daily ceilings
|
|
@@ -710,6 +716,12 @@ non-forced direct `curator_review()` calls return `not_due` inside the
|
|
|
710
716
|
configured interval and record that status without spawning. A manual
|
|
711
717
|
`curator_review(force=True)` bypasses the interval but still respects the lock.
|
|
712
718
|
|
|
719
|
+
For automation-created skills, the audit keeps foreground consultation separate
|
|
720
|
+
from maintenance: a background-review skill with `fg_uses=0` after 14 days is
|
|
721
|
+
still a false-positive prune candidate even if automatic review or sync loops
|
|
722
|
+
have increased its patch counter. Patches are maintenance activity, not proof
|
|
723
|
+
that a foreground user or agent consulted the skill.
|
|
724
|
+
|
|
713
725
|
Before spawning, the scheduler hashes lessons, concepts, skill bodies, support
|
|
714
726
|
trees, validators, and mirror state. Repeated manual calls over identical bytes
|
|
715
727
|
return `unchanged_inventory`; the scheduled three-day pass still runs because
|
|
@@ -1058,7 +1070,10 @@ Three detection sources per tick:
|
|
|
1058
1070
|
from an exhausted subscription; the real outcome only lands in
|
|
1059
1071
|
`tasks.return_code`. The reason is read from the child's log tail.
|
|
1060
1072
|
3. **Materialization** — `skill_materialized` / `skill_create` (skill) and
|
|
1061
|
-
`lesson_append` (lesson).
|
|
1073
|
+
`lesson_append` (lesson). Each notification contains one readable artifact
|
|
1074
|
+
name: the skill's first heading (falling back to its directory name), or
|
|
1075
|
+
the lesson name with slug separators replaced by spaces. Paths, provenance,
|
|
1076
|
+
and operation metadata stay out of the notification text.
|
|
1062
1077
|
|
|
1063
1078
|
A per-loop cooldown collapses a lapsed-subscription storm into one actionable
|
|
1064
1079
|
alert; the first run seeds its cursor to the current position, so historical
|
|
@@ -1089,12 +1104,16 @@ will register the app in **System Settings ▸ Notifications** and show banners;
|
|
|
1089
1104
|
banners are titled **Thread-Keeper** (`CFBundleDisplayName`). The first launch
|
|
1090
1105
|
after an update shows a one-time permission prompt.
|
|
1091
1106
|
|
|
1092
|
-
`agent_status` feeds the app two lists — `recent_results` (
|
|
1093
|
-
|
|
1094
|
-
tagged with a `notify` flag computed from the toggles
|
|
1107
|
+
`agent_status` feeds the app two lists — `recent_results` (named skill/lesson
|
|
1108
|
+
write events plus completed-task history) and `recent_failures` (the two failure
|
|
1109
|
+
sources above) — each item tagged with a `notify` flag computed from the toggles
|
|
1110
|
+
below. The app **lists**
|
|
1095
1111
|
every item in its menu but only **posts a banner** for flagged ones, so turning a
|
|
1096
|
-
category off silences the banner without hiding the history.
|
|
1097
|
-
|
|
1112
|
+
category off silences the banner without hiding the history. Each materialization
|
|
1113
|
+
uses its own skill or lesson toggle; generic task reports are history-only.
|
|
1114
|
+
Foreground writes appear too, without waiting for a background child to finish.
|
|
1115
|
+
A pathless skill mark only silences its reminder and has no materialization
|
|
1116
|
+
banner. Enabling a toggle never replays backlog.
|
|
1098
1117
|
|
|
1099
1118
|
**Settings in the app.** The menu-bar app's **Settings ▸ Notifications** tab
|
|
1100
1119
|
edits these same `THREADKEEPER_NOTIFY_*` keys visually — switches for the
|
|
@@ -1542,8 +1561,9 @@ Spawn task spool files live in `THREADKEEPER_TASK_LOG_DIR` (default
|
|
|
1542
1561
|
the hardened `~/.threadkeeper` perimeter by default; explicit overrides are
|
|
1543
1562
|
refused when the configured directory is a symlink or is not owned by the
|
|
1544
1563
|
current user. `spawn()` creates captured headless `.log`, stdin prompt spool,
|
|
1545
|
-
and visible `.command` files with no-follow owner-only opens.
|
|
1546
|
-
|
|
1564
|
+
and visible `.command` files with no-follow owner-only opens. Git-backed spawns
|
|
1565
|
+
also create their isolated worktrees there. `consolidate()` garbage-collects
|
|
1566
|
+
task spool files once their task row is no longer retained.
|
|
1547
1567
|
|
|
1548
1568
|
---
|
|
1549
1569
|
|
|
@@ -266,6 +266,12 @@ uses a SQLite `BEGIN IMMEDIATE` reservation: `spawn()` re-checks the budget and
|
|
|
266
266
|
inserts the child task row with its RSS estimate before `Popen`, so two
|
|
267
267
|
concurrent spawns cannot both squeeze through the cap.
|
|
268
268
|
|
|
269
|
+
When `cwd` is inside a Git checkout, `spawn()` also requires the source
|
|
270
|
+
checkout's tracked files to be clean, then starts the child from a unique
|
|
271
|
+
branch/worktree under `THREADKEEPER_TASK_LOG_DIR/worktrees/`. Parallel children
|
|
272
|
+
therefore never share a mutable checkout or Git index. Non-Git directories keep
|
|
273
|
+
their existing behavior; a dirty Git checkout is refused before a child starts.
|
|
274
|
+
|
|
269
275
|
The spawn wrapper also records each completed child's `duration_s`,
|
|
270
276
|
`tokens_in`, `tokens_out`, `tokens_total`, and `cost_usd` when the underlying
|
|
271
277
|
CLI emits a recognizable usage trailer. Optional daily ceilings
|
|
@@ -666,6 +672,12 @@ non-forced direct `curator_review()` calls return `not_due` inside the
|
|
|
666
672
|
configured interval and record that status without spawning. A manual
|
|
667
673
|
`curator_review(force=True)` bypasses the interval but still respects the lock.
|
|
668
674
|
|
|
675
|
+
For automation-created skills, the audit keeps foreground consultation separate
|
|
676
|
+
from maintenance: a background-review skill with `fg_uses=0` after 14 days is
|
|
677
|
+
still a false-positive prune candidate even if automatic review or sync loops
|
|
678
|
+
have increased its patch counter. Patches are maintenance activity, not proof
|
|
679
|
+
that a foreground user or agent consulted the skill.
|
|
680
|
+
|
|
669
681
|
Before spawning, the scheduler hashes lessons, concepts, skill bodies, support
|
|
670
682
|
trees, validators, and mirror state. Repeated manual calls over identical bytes
|
|
671
683
|
return `unchanged_inventory`; the scheduled three-day pass still runs because
|
|
@@ -1014,7 +1026,10 @@ Three detection sources per tick:
|
|
|
1014
1026
|
from an exhausted subscription; the real outcome only lands in
|
|
1015
1027
|
`tasks.return_code`. The reason is read from the child's log tail.
|
|
1016
1028
|
3. **Materialization** — `skill_materialized` / `skill_create` (skill) and
|
|
1017
|
-
`lesson_append` (lesson).
|
|
1029
|
+
`lesson_append` (lesson). Each notification contains one readable artifact
|
|
1030
|
+
name: the skill's first heading (falling back to its directory name), or
|
|
1031
|
+
the lesson name with slug separators replaced by spaces. Paths, provenance,
|
|
1032
|
+
and operation metadata stay out of the notification text.
|
|
1018
1033
|
|
|
1019
1034
|
A per-loop cooldown collapses a lapsed-subscription storm into one actionable
|
|
1020
1035
|
alert; the first run seeds its cursor to the current position, so historical
|
|
@@ -1045,12 +1060,16 @@ will register the app in **System Settings ▸ Notifications** and show banners;
|
|
|
1045
1060
|
banners are titled **Thread-Keeper** (`CFBundleDisplayName`). The first launch
|
|
1046
1061
|
after an update shows a one-time permission prompt.
|
|
1047
1062
|
|
|
1048
|
-
`agent_status` feeds the app two lists — `recent_results` (
|
|
1049
|
-
|
|
1050
|
-
tagged with a `notify` flag computed from the toggles
|
|
1063
|
+
`agent_status` feeds the app two lists — `recent_results` (named skill/lesson
|
|
1064
|
+
write events plus completed-task history) and `recent_failures` (the two failure
|
|
1065
|
+
sources above) — each item tagged with a `notify` flag computed from the toggles
|
|
1066
|
+
below. The app **lists**
|
|
1051
1067
|
every item in its menu but only **posts a banner** for flagged ones, so turning a
|
|
1052
|
-
category off silences the banner without hiding the history.
|
|
1053
|
-
|
|
1068
|
+
category off silences the banner without hiding the history. Each materialization
|
|
1069
|
+
uses its own skill or lesson toggle; generic task reports are history-only.
|
|
1070
|
+
Foreground writes appear too, without waiting for a background child to finish.
|
|
1071
|
+
A pathless skill mark only silences its reminder and has no materialization
|
|
1072
|
+
banner. Enabling a toggle never replays backlog.
|
|
1054
1073
|
|
|
1055
1074
|
**Settings in the app.** The menu-bar app's **Settings ▸ Notifications** tab
|
|
1056
1075
|
edits these same `THREADKEEPER_NOTIFY_*` keys visually — switches for the
|
|
@@ -1498,8 +1517,9 @@ Spawn task spool files live in `THREADKEEPER_TASK_LOG_DIR` (default
|
|
|
1498
1517
|
the hardened `~/.threadkeeper` perimeter by default; explicit overrides are
|
|
1499
1518
|
refused when the configured directory is a symlink or is not owned by the
|
|
1500
1519
|
current user. `spawn()` creates captured headless `.log`, stdin prompt spool,
|
|
1501
|
-
and visible `.command` files with no-follow owner-only opens.
|
|
1502
|
-
|
|
1520
|
+
and visible `.command` files with no-follow owner-only opens. Git-backed spawns
|
|
1521
|
+
also create their isolated worktrees there. `consolidate()` garbage-collects
|
|
1522
|
+
task spool files once their task row is no longer retained.
|
|
1503
1523
|
|
|
1504
1524
|
---
|
|
1505
1525
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "threadkeeper"
|
|
7
|
-
version = "0.17.
|
|
7
|
+
version = "0.17.4"
|
|
8
8
|
description = "Multi-agent shared brain across Claude Code/Desktop, Codex, Antigravity CLI, Copilot, and VS Code. Cross-session memory, self-improving skill loops, inter-agent signaling — one local MCP server."
|
|
9
9
|
requires-python = ">=3.11"
|
|
10
10
|
authors = [{ name = "thread-keeper contributors" }]
|
|
@@ -4,6 +4,8 @@ import json
|
|
|
4
4
|
import os
|
|
5
5
|
import time
|
|
6
6
|
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
7
9
|
|
|
8
10
|
_FAKE_CID = "33334444-5555-6666-7777-888899990000"
|
|
9
11
|
|
|
@@ -468,6 +470,42 @@ def test_agent_status_recent_results_for_useful_completed_tasks(mp_with_cid):
|
|
|
468
470
|
assert snap["recent_results"][0]["summary"] == (
|
|
469
471
|
"Processed 2 candidates into durable notes."
|
|
470
472
|
)
|
|
473
|
+
assert snap["recent_results"][0]["notify"] is False
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
@pytest.mark.parametrize("poll,skill,lesson", [
|
|
477
|
+
(30, True, True), (30, True, False), (30, False, True), (0, True, True),
|
|
478
|
+
])
|
|
479
|
+
def test_materialization_results_name_actual_writes(mp_with_cid, poll, skill, lesson):
|
|
480
|
+
pkg = mp_with_cid(_FAKE_CID)
|
|
481
|
+
pkg["config"].NOTIFY_POLL_S = poll
|
|
482
|
+
pkg["config"].NOTIFY_SKILL_MATERIALIZED = skill
|
|
483
|
+
pkg["config"].NOTIFY_LESSON = lesson
|
|
484
|
+
result = _txt(_tool(pkg, "skill_manage")(
|
|
485
|
+
action="create", name="api-contract-testing",
|
|
486
|
+
description="Test API contracts.", content="# API contract testing\n\nUse schemas.",
|
|
487
|
+
))
|
|
488
|
+
assert result.startswith("ok"), result
|
|
489
|
+
result = _txt(_tool(pkg, "lesson_append")(
|
|
490
|
+
title="Verify state transitions", body="Assert the state after the action.",
|
|
491
|
+
source="T1",
|
|
492
|
+
))
|
|
493
|
+
assert result.startswith("ok"), result
|
|
494
|
+
_insert_completed_task(pkg, "tk_generic_done",
|
|
495
|
+
"You are a CANDIDATE REVIEWER for thread-keeper's extract queue.",
|
|
496
|
+
"Processed 2 candidates into durable notes.\n")
|
|
497
|
+
|
|
498
|
+
from threadkeeper.agent_status import agent_status_snapshot
|
|
499
|
+
|
|
500
|
+
items = agent_status_snapshot(refresh=False)["recent_results"]
|
|
501
|
+
by_role = {item["role"]: item for item in items}
|
|
502
|
+
assert by_role["skill"]["summary"] == "API contract testing"
|
|
503
|
+
assert by_role["lesson"]["summary"] == "Verify state transitions"
|
|
504
|
+
assert by_role["skill"]["notify"] is bool(poll and skill)
|
|
505
|
+
assert by_role["lesson"]["notify"] is bool(poll and lesson)
|
|
506
|
+
assert by_role["candidate_reviewer"]["notify"] is False
|
|
507
|
+
assert all(item["id"].startswith("materialization:")
|
|
508
|
+
for item in items if item["role"] in ("skill", "lesson"))
|
|
471
509
|
|
|
472
510
|
|
|
473
511
|
def test_agent_status_mcp_json_output(mp_with_cid):
|
|
@@ -843,6 +843,48 @@ def test_skill_validator_is_exhaustive_and_semantic(tmp_path, monkeypatch):
|
|
|
843
843
|
assert "3. SKILL second-skill" in checklist
|
|
844
844
|
|
|
845
845
|
|
|
846
|
+
def test_curator_prune_rubric_keeps_patched_unconsulted_skill_eligible(
|
|
847
|
+
tmp_path, monkeypatch,
|
|
848
|
+
):
|
|
849
|
+
pkg = _bootstrap(tmp_path, monkeypatch, min_lessons="2")
|
|
850
|
+
pkg["lessons"].append_lesson(title="one", body="b1", source="shadow")
|
|
851
|
+
pkg["lessons"].append_lesson(title="two", body="b2", source="shadow")
|
|
852
|
+
_write_audit_skill(
|
|
853
|
+
pkg["skills_dir"], "patched-background-skill", "# Rule\nDo the test.",
|
|
854
|
+
)
|
|
855
|
+
now = int(time.time())
|
|
856
|
+
conn = pkg["db"].get_db()
|
|
857
|
+
conn.execute(
|
|
858
|
+
"INSERT INTO skill_usage "
|
|
859
|
+
"(name, created_at, created_by_origin, last_patched_at, patch_count, "
|
|
860
|
+
"foreground_use_count, state) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
861
|
+
(
|
|
862
|
+
"patched-background-skill", now - 30 * 86400,
|
|
863
|
+
"background_review", now, 4, 0, "active",
|
|
864
|
+
),
|
|
865
|
+
)
|
|
866
|
+
conn.commit()
|
|
867
|
+
|
|
868
|
+
import threadkeeper.tools.spawn as spawn_mod
|
|
869
|
+
captured: list[dict] = []
|
|
870
|
+
|
|
871
|
+
def fake_spawn(**kwargs):
|
|
872
|
+
captured.append(kwargs)
|
|
873
|
+
return "spawn task_id=patched-background-skill pid=0"
|
|
874
|
+
|
|
875
|
+
monkeypatch.setattr(spawn_mod, "spawn", fake_spawn)
|
|
876
|
+
pkg["curator"].run_curator_pass(force=True)
|
|
877
|
+
|
|
878
|
+
prompt = captured[0]["prompt"]
|
|
879
|
+
assert "origin=background_review AND fg_uses=0" in prompt
|
|
880
|
+
assert "they are not foreground consultation" in prompt
|
|
881
|
+
assert "SKILL patched-background-skill" in prompt
|
|
882
|
+
assert "fg_uses=0" in prompt
|
|
883
|
+
assert "maintenance_patches=4" in prompt
|
|
884
|
+
assert "created=30d_ago" in prompt
|
|
885
|
+
assert "patches=0" not in prompt
|
|
886
|
+
|
|
887
|
+
|
|
846
888
|
def test_skill_validate_tool_returns_post_change_contract(tmp_path, monkeypatch):
|
|
847
889
|
pkg = _bootstrap(tmp_path, monkeypatch)
|
|
848
890
|
primary = pkg["skills_dir"]
|
|
@@ -228,8 +228,48 @@ def test_positives_on_fire(tmp_path, monkeypatch, caplog):
|
|
|
228
228
|
with caplog.at_level(logging.WARNING, logger="threadkeeper.notify"):
|
|
229
229
|
out = notify.run_notify_pass(force=True)
|
|
230
230
|
assert out == "ok fired=2", out
|
|
231
|
-
assert "skill materialized" in caplog.text
|
|
232
|
-
assert "lesson added
|
|
231
|
+
assert "skill materialized | Foo" in caplog.text
|
|
232
|
+
assert "lesson added | My lesson" in caplog.text
|
|
233
|
+
assert "/skills/" not in caplog.text
|
|
234
|
+
assert "op=create" not in caplog.text and "source=shadow" not in caplog.text
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
@pytest.mark.parametrize("directory", [False, True])
|
|
238
|
+
def test_skill_notification_uses_heading(tmp_path, monkeypatch, directory):
|
|
239
|
+
m = _bootstrap(tmp_path, monkeypatch, skill="true")
|
|
240
|
+
md = tmp_path / "api-contract-testing" / "SKILL.md"
|
|
241
|
+
md.parent.mkdir()
|
|
242
|
+
md.write_text("---\nname: api-contract-testing\ndescription: |\n"
|
|
243
|
+
" Internal description\n---\n\n# API contract testing\n"
|
|
244
|
+
"\nLong implementation details that do not belong in a banner.\n")
|
|
245
|
+
notify = m["notify"]
|
|
246
|
+
conn = m["db"].get_db()
|
|
247
|
+
sent = []
|
|
248
|
+
monkeypatch.setattr(notify, "_dispatch", lambda *args: sent.append(args))
|
|
249
|
+
assert notify.run_notify_pass(force=True) == "seed"
|
|
250
|
+
_ev(conn, "skill_materialized", target="T1",
|
|
251
|
+
summary=str(md.parent if directory else md))
|
|
252
|
+
assert notify.run_notify_pass(force=True) == "ok fired=1"
|
|
253
|
+
assert sent == [("Thread-keeper: skill materialized", "API contract testing")]
|
|
254
|
+
assert notify.run_notify_pass(force=True) == "ok fired=0"
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
@pytest.mark.parametrize("body", [b"---\nname: foo\n---\nNo heading.", b"\xff"])
|
|
258
|
+
def test_skill_notification_falls_back_to_name(tmp_path, monkeypatch, body):
|
|
259
|
+
notify = _bootstrap(tmp_path, monkeypatch)["notify"]
|
|
260
|
+
md = tmp_path / "api-contract-testing" / "SKILL.md"
|
|
261
|
+
md.parent.mkdir()
|
|
262
|
+
md.write_bytes(body)
|
|
263
|
+
assert notify._fmt_skill({"kind": "skill_create", "target": "api-contract-testing",
|
|
264
|
+
"summary": str(md)})[1] == "Api contract testing"
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def test_pathless_skill_mark_does_not_claim_a_materialization(tmp_path, monkeypatch):
|
|
268
|
+
m = _bootstrap(tmp_path, monkeypatch, skill="true")
|
|
269
|
+
notify, conn = m["notify"], m["db"].get_db()
|
|
270
|
+
assert notify.run_notify_pass(force=True) == "seed"
|
|
271
|
+
_ev(conn, "skill_materialized", target="T1", summary="(no path recorded)")
|
|
272
|
+
assert notify.run_notify_pass(force=True) == "ok fired=0"
|
|
233
273
|
|
|
234
274
|
|
|
235
275
|
# ── disabled ────────────────────────────────────────────────────────────────
|
|
@@ -171,7 +171,7 @@ def test_visible_command_script_is_owner_only(mp_with_cid, monkeypatch):
|
|
|
171
171
|
monkeypatch.setattr(spawn_mod.subprocess, "Popen", _FakePopen)
|
|
172
172
|
|
|
173
173
|
spawn_fn = pkg["mcp"]._tool_manager._tools["spawn"].fn
|
|
174
|
-
out = spawn_fn(prompt="do a thing", visible=True)
|
|
174
|
+
out = spawn_fn(prompt="do a thing", cwd=str(pkg["tmp"]), visible=True)
|
|
175
175
|
assert out.startswith("ok task="), out
|
|
176
176
|
|
|
177
177
|
cmd_files = list(spawn_mod.TASK_LOG_DIR.glob("*.command"))
|
|
@@ -197,7 +197,12 @@ def test_headless_log_file_is_owner_only(mp_with_cid, monkeypatch):
|
|
|
197
197
|
monkeypatch.setattr(spawn_mod.subprocess, "Popen", _FakePopen)
|
|
198
198
|
|
|
199
199
|
spawn_fn = pkg["mcp"]._tool_manager._tools["spawn"].fn
|
|
200
|
-
out = spawn_fn(
|
|
200
|
+
out = spawn_fn(
|
|
201
|
+
prompt="do a thing",
|
|
202
|
+
cwd=str(pkg["tmp"]),
|
|
203
|
+
visible=False,
|
|
204
|
+
capture_output=True,
|
|
205
|
+
)
|
|
201
206
|
assert out.startswith("ok task="), out
|
|
202
207
|
|
|
203
208
|
log_files = list(spawn_mod.TASK_LOG_DIR.glob("*.log"))
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Git worktree isolation on the shared spawn path."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import subprocess
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
_FAKE_CID = "aaaa0000-bbbb-1111-cccc-222233334444"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _git(repo: Path, *args: str) -> str:
|
|
12
|
+
proc = subprocess.run(
|
|
13
|
+
["git", "-C", str(repo), *args],
|
|
14
|
+
text=True,
|
|
15
|
+
capture_output=True,
|
|
16
|
+
check=True,
|
|
17
|
+
)
|
|
18
|
+
return proc.stdout.strip()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _init_repo(tmp_path: Path) -> Path:
|
|
22
|
+
repo = tmp_path / "repo"
|
|
23
|
+
repo.mkdir()
|
|
24
|
+
_git(repo, "init", "-b", "main")
|
|
25
|
+
_git(repo, "config", "user.email", "test@example.com")
|
|
26
|
+
_git(repo, "config", "user.name", "Test User")
|
|
27
|
+
(repo / "tracked.txt").write_text("base\n", encoding="utf-8")
|
|
28
|
+
_git(repo, "add", "tracked.txt")
|
|
29
|
+
_git(repo, "commit", "-m", "base")
|
|
30
|
+
return repo
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _configure_spawn(monkeypatch, pkg):
|
|
34
|
+
import threadkeeper.identity as identity
|
|
35
|
+
import threadkeeper.spawn_config as spawn_config
|
|
36
|
+
import threadkeeper.tools.spawn as spawn_mod
|
|
37
|
+
|
|
38
|
+
monkeypatch.setattr(spawn_mod, "_claude_bin", lambda: "/bin/true")
|
|
39
|
+
monkeypatch.setattr(identity, "_active_cli", "claude")
|
|
40
|
+
monkeypatch.setattr(
|
|
41
|
+
spawn_config, "resolve_agent", lambda role, active_cli=None: "claude"
|
|
42
|
+
)
|
|
43
|
+
monkeypatch.setattr(spawn_config, "resolve_model", lambda cli, role="": "")
|
|
44
|
+
return spawn_mod
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_git_spawns_get_distinct_branch_worktrees(mp_with_cid, monkeypatch):
|
|
48
|
+
pkg = mp_with_cid(_FAKE_CID)
|
|
49
|
+
repo = _init_repo(pkg["tmp"])
|
|
50
|
+
spawn_mod = _configure_spawn(monkeypatch, pkg)
|
|
51
|
+
child_cwds: list[Path] = []
|
|
52
|
+
|
|
53
|
+
class _FakePopen:
|
|
54
|
+
def __init__(self, _args, **kwargs):
|
|
55
|
+
child_cwds.append(Path(kwargs["cwd"]))
|
|
56
|
+
self.pid = 4200 + len(child_cwds)
|
|
57
|
+
|
|
58
|
+
monkeypatch.setattr(spawn_mod.subprocess, "Popen", _FakePopen)
|
|
59
|
+
|
|
60
|
+
first = spawn_mod.spawn(
|
|
61
|
+
prompt="first child", cwd=str(repo), visible=False, capture_output=False
|
|
62
|
+
)
|
|
63
|
+
second = spawn_mod.spawn(
|
|
64
|
+
prompt="second child", cwd=str(repo), visible=False, capture_output=False
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
assert first.startswith("ok task="), first
|
|
68
|
+
assert second.startswith("ok task="), second
|
|
69
|
+
assert len(child_cwds) == 2
|
|
70
|
+
assert child_cwds[0] != child_cwds[1]
|
|
71
|
+
assert all(path != repo and path.is_dir() for path in child_cwds)
|
|
72
|
+
roots = [
|
|
73
|
+
spawn_mod._git_result(["rev-parse", "--show-toplevel"], path)[0]
|
|
74
|
+
for path in child_cwds
|
|
75
|
+
]
|
|
76
|
+
assert roots == [str(path) for path in child_cwds]
|
|
77
|
+
branches = [
|
|
78
|
+
spawn_mod._git_result(["branch", "--show-current"], path)[0]
|
|
79
|
+
for path in child_cwds
|
|
80
|
+
]
|
|
81
|
+
assert len(set(branches)) == 2
|
|
82
|
+
assert all(branch.startswith("threadkeeper/spawn-tk_") for branch in branches)
|
|
83
|
+
assert spawn_mod._git_result(
|
|
84
|
+
["status", "--porcelain", "--untracked-files=no"], repo
|
|
85
|
+
)[0] == ""
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_dirty_git_checkout_is_refused_before_child_launch(
|
|
89
|
+
mp_with_cid, monkeypatch,
|
|
90
|
+
):
|
|
91
|
+
pkg = mp_with_cid(_FAKE_CID)
|
|
92
|
+
repo = _init_repo(pkg["tmp"])
|
|
93
|
+
spawn_mod = _configure_spawn(monkeypatch, pkg)
|
|
94
|
+
(repo / "tracked.txt").write_text("uncommitted\n", encoding="utf-8")
|
|
95
|
+
|
|
96
|
+
def _unexpected_launch(*_args, **_kwargs):
|
|
97
|
+
raise AssertionError("dirty checkout must be rejected before Popen")
|
|
98
|
+
|
|
99
|
+
monkeypatch.setattr(spawn_mod.subprocess, "Popen", _unexpected_launch)
|
|
100
|
+
|
|
101
|
+
out = spawn_mod.spawn(
|
|
102
|
+
prompt="must not launch", cwd=str(repo), visible=False, capture_output=False
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
assert out == "ERR spawn_dirty_worktree mode=git"
|
|
106
|
+
worktrees, err = spawn_mod._git_result(
|
|
107
|
+
["worktree", "list", "--porcelain"], repo
|
|
108
|
+
)
|
|
109
|
+
assert not err
|
|
110
|
+
assert worktrees.count("worktree ") == 1
|
|
111
|
+
conn = pkg["db"].get_db()
|
|
112
|
+
assert conn.execute("SELECT COUNT(*) FROM tasks").fetchone()[0] == 0
|
|
@@ -878,17 +878,47 @@ def _extract_useful_result(task_id: str) -> str:
|
|
|
878
878
|
return ""
|
|
879
879
|
|
|
880
880
|
|
|
881
|
-
def
|
|
881
|
+
def _recent_materializations(conn, now: int, limit: int) -> list[dict[str, Any]]:
|
|
882
|
+
"""Name actual writes, including foreground writes without a child task."""
|
|
882
883
|
from . import config
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
# notifications are enabled (NOTIFY_POLL_S master gate) and a positive toggle
|
|
886
|
-
# is on, but the menu always lists them (`notify` flag).
|
|
884
|
+
from .notify import _fmt_skill, _fmt_lesson
|
|
885
|
+
|
|
887
886
|
enabled = float(getattr(config, "NOTIFY_POLL_S", 0) or 0) > 0
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
887
|
+
rows = conn.execute(
|
|
888
|
+
"SELECT id, kind, target, summary, created_at FROM events "
|
|
889
|
+
"WHERE kind IN ('skill_create', 'skill_materialized', 'lesson_append') "
|
|
890
|
+
"AND created_at>=? ORDER BY created_at DESC, id DESC LIMIT ?",
|
|
891
|
+
(now - _RESULT_WINDOW_S, int(limit)),
|
|
892
|
+
).fetchall()
|
|
893
|
+
results = []
|
|
894
|
+
for row in rows:
|
|
895
|
+
skill = row["kind"] != "lesson_append"
|
|
896
|
+
title, summary = (_fmt_skill if skill else _fmt_lesson)(dict(row))
|
|
897
|
+
if not summary:
|
|
898
|
+
continue
|
|
899
|
+
age_s = max(0, now - row["created_at"])
|
|
900
|
+
results.append({
|
|
901
|
+
"id": f"materialization:{row['id']}",
|
|
902
|
+
"task_id": "",
|
|
903
|
+
"role": "skill" if skill else "lesson",
|
|
904
|
+
"loop_id": "",
|
|
905
|
+
"loop_name": "Skill" if skill else "Lesson",
|
|
906
|
+
"title": title,
|
|
907
|
+
"summary": summary,
|
|
908
|
+
"ended_at": row["created_at"],
|
|
909
|
+
"age_s": age_s,
|
|
910
|
+
"age": fmt_age(age_s),
|
|
911
|
+
"notify": enabled and bool(
|
|
912
|
+
config.NOTIFY_SKILL_MATERIALIZED if skill else config.NOTIFY_LESSON
|
|
913
|
+
),
|
|
914
|
+
})
|
|
915
|
+
return results
|
|
916
|
+
|
|
917
|
+
|
|
918
|
+
def _recent_results(conn, now: int, limit: int = 10) -> list[dict[str, Any]]:
|
|
919
|
+
# Event-backed materializations carry names and per-category toggles.
|
|
920
|
+
# Generic completion reports remain in history without duplicate banners.
|
|
921
|
+
materializations = _recent_materializations(conn, now, limit)
|
|
892
922
|
role_loop = _role_to_loop()
|
|
893
923
|
rows = conn.execute(
|
|
894
924
|
"SELECT id, prompt, ended_at, return_code FROM tasks "
|
|
@@ -919,11 +949,12 @@ def _recent_results(conn, now: int, limit: int = 10) -> list[dict[str, Any]]:
|
|
|
919
949
|
"ended_at": ended_at,
|
|
920
950
|
"age_s": age_s,
|
|
921
951
|
"age": fmt_age(age_s),
|
|
922
|
-
"notify":
|
|
952
|
+
"notify": False,
|
|
923
953
|
})
|
|
924
954
|
if len(results) >= limit:
|
|
925
955
|
break
|
|
926
|
-
return results
|
|
956
|
+
return sorted(materializations + results, key=lambda r: r["ended_at"],
|
|
957
|
+
reverse=True)[:limit]
|
|
927
958
|
|
|
928
959
|
|
|
929
960
|
def _recent_failures(conn, now: int, limit: int = 10) -> list[dict[str, Any]]:
|
|
@@ -248,10 +248,11 @@ promotion and linking plan in REPORT.md for a foreground human instead.
|
|
|
248
248
|
superseded by a newer entry, or a **FALSE POSITIVE** (auto-created
|
|
249
249
|
by the background-review loop but never validated by actual use).
|
|
250
250
|
Specifically flag as PRUNE:
|
|
251
|
-
• origin=background_review AND
|
|
252
|
-
|
|
253
|
-
consulted it
|
|
254
|
-
|
|
251
|
+
• origin=background_review AND fg_uses=0 AND created >14 days ago
|
|
252
|
+
→ strong false-positive signal: no foreground user or agent ever
|
|
253
|
+
consulted it. `maintenance_patches` count automated or other maintenance
|
|
254
|
+
writes; they are not foreground consultation and never reset this
|
|
255
|
+
eligibility signal.
|
|
255
256
|
• SKILL_OUTCOME signals (in the events table) marking the skill
|
|
256
257
|
as 'wrong' more often than 'helped' → user-judgment override.
|
|
257
258
|
Format:
|
|
@@ -27,6 +27,8 @@ from __future__ import annotations
|
|
|
27
27
|
|
|
28
28
|
import logging
|
|
29
29
|
import os
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
import re
|
|
30
32
|
import sqlite3
|
|
31
33
|
import subprocess
|
|
32
34
|
import threading
|
|
@@ -349,11 +351,43 @@ def _fmt_child(c: dict) -> tuple[str, str]:
|
|
|
349
351
|
|
|
350
352
|
|
|
351
353
|
def _fmt_skill(s: dict) -> tuple[str, str]:
|
|
352
|
-
|
|
354
|
+
path = (s.get("summary") or "").strip()
|
|
355
|
+
name = s.get("target", "") if s.get("kind") == "skill_create" else ""
|
|
356
|
+
if path and path != "(no path recorded)":
|
|
357
|
+
md = Path(path).expanduser()
|
|
358
|
+
if md.name == "SKILL.md":
|
|
359
|
+
name = md.parent.name
|
|
360
|
+
else:
|
|
361
|
+
name = md.name
|
|
362
|
+
md = md / "SKILL.md"
|
|
363
|
+
try:
|
|
364
|
+
# A title is near the top. Never load an entire skill (or follow
|
|
365
|
+
# paths mentioned in its body) just to render a notification.
|
|
366
|
+
body = ""
|
|
367
|
+
if md.is_file():
|
|
368
|
+
with md.open(encoding="utf-8") as stream:
|
|
369
|
+
body = stream.read(8192)
|
|
370
|
+
if body.startswith("---\n"):
|
|
371
|
+
body = body.split("\n---", 1)[-1]
|
|
372
|
+
heading = re.search(r"^#\s+(.+?)(?:\s+#+)?\s*$", body, re.MULTILINE)
|
|
373
|
+
if heading:
|
|
374
|
+
name = heading.group(1)
|
|
375
|
+
except (OSError, ValueError):
|
|
376
|
+
pass
|
|
377
|
+
return ("Thread-keeper: skill materialized", _artifact_name(name))
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _artifact_name(name: str) -> str:
|
|
381
|
+
"""One readable name, without slug separators or Markdown decoration."""
|
|
382
|
+
name = " ".join((name or "").replace("`", "").split())
|
|
383
|
+
if " " not in name:
|
|
384
|
+
name = re.sub(r"[-_]+", " ", name)
|
|
385
|
+
name = name[:1].upper() + name[1:]
|
|
386
|
+
return name[:200]
|
|
353
387
|
|
|
354
388
|
|
|
355
389
|
def _fmt_lesson(l: dict) -> tuple[str, str]:
|
|
356
|
-
return ("Thread-keeper: lesson added",
|
|
390
|
+
return ("Thread-keeper: lesson added", _artifact_name(l["target"]))
|
|
357
391
|
|
|
358
392
|
|
|
359
393
|
# ── pass + daemon lifecycle ─────────────────────────────────────────────────
|
|
@@ -401,8 +435,10 @@ def run_notify_pass(force: bool = False, *, scheduled: bool = False) -> str:
|
|
|
401
435
|
_dispatch(*_fmt_child(c))
|
|
402
436
|
fired += 1
|
|
403
437
|
for s in skills:
|
|
404
|
-
|
|
405
|
-
|
|
438
|
+
title, body = _fmt_skill(s)
|
|
439
|
+
if body: # A pathless mark only silences a nudge; no named artifact.
|
|
440
|
+
_dispatch(title, body)
|
|
441
|
+
fired += 1
|
|
406
442
|
for l in lessons:
|
|
407
443
|
_dispatch(*_fmt_lesson(l))
|
|
408
444
|
fired += 1
|
|
@@ -18,6 +18,7 @@ import os
|
|
|
18
18
|
from pathlib import Path
|
|
19
19
|
import re
|
|
20
20
|
import sqlite3
|
|
21
|
+
import time
|
|
21
22
|
from typing import Iterable
|
|
22
23
|
|
|
23
24
|
import yaml
|
|
@@ -245,7 +246,8 @@ def _telemetry_rows(conn: sqlite3.Connection, include_archived: bool) -> dict[st
|
|
|
245
246
|
rows = conn.execute(
|
|
246
247
|
"SELECT name, created_at, created_by_origin, last_used_at, "
|
|
247
248
|
"last_viewed_at, last_patched_at, use_count, view_count, "
|
|
248
|
-
"patch_count, pinned, state, tier
|
|
249
|
+
"patch_count, foreground_use_count, pinned, state, tier "
|
|
250
|
+
"FROM skill_usage" + where
|
|
249
251
|
+ " ORDER BY name"
|
|
250
252
|
).fetchall()
|
|
251
253
|
except sqlite3.OperationalError:
|
|
@@ -327,7 +329,7 @@ def _record_for(
|
|
|
327
329
|
for key in (
|
|
328
330
|
"created_at", "last_used_at", "last_viewed_at",
|
|
329
331
|
"last_patched_at", "use_count", "view_count", "patch_count",
|
|
330
|
-
"pinned",
|
|
332
|
+
"foreground_use_count", "pinned",
|
|
331
333
|
)
|
|
332
334
|
},
|
|
333
335
|
"source_path": str(source) if source else None,
|
|
@@ -533,9 +535,20 @@ def format_skill_checklist(manifest: dict) -> str:
|
|
|
533
535
|
for index, record in enumerate(manifest["skills"], start=1):
|
|
534
536
|
flags = ",".join(record["findings"]) or "clean"
|
|
535
537
|
protected = " [PROTECTED]" if record["protected"] else ""
|
|
538
|
+
telemetry = record["telemetry"]
|
|
539
|
+
created_at = int(telemetry.get("created_at") or 0)
|
|
540
|
+
created_age = (
|
|
541
|
+
max(0, int(time.time()) - created_at) // 86400
|
|
542
|
+
if created_at else "?"
|
|
543
|
+
)
|
|
536
544
|
lines.append(
|
|
537
545
|
f"{index}. SKILL {record['name']}{protected} "
|
|
538
546
|
f"state={record['state']} origin={record['origin']} "
|
|
547
|
+
f"fg_uses={telemetry.get('foreground_use_count') or 0} "
|
|
548
|
+
f"uses={telemetry.get('use_count') or 0} "
|
|
549
|
+
f"views={telemetry.get('view_count') or 0} "
|
|
550
|
+
f"maintenance_patches={telemetry.get('patch_count') or 0} "
|
|
551
|
+
f"created={created_age}d_ago "
|
|
539
552
|
f"source_kind={record['source_kind']} "
|
|
540
553
|
f"source={record['source_path'] or '-'} findings={flags}"
|
|
541
554
|
)
|