threadkeeper 0.17.3__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.3 → threadkeeper-0.17.4}/PKG-INFO +16 -3
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/README.md +15 -2
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/pyproject.toml +1 -1
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_curator.py +42 -0
- {threadkeeper-0.17.3 → 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.3 → threadkeeper-0.17.4}/threadkeeper/curator.py +5 -4
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/skill_audit.py +15 -2
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/tools/spawn.py +133 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper.egg-info/PKG-INFO +16 -3
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper.egg-info/SOURCES.txt +1 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/LICENSE +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/setup.cfg +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_adapters.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_agent_status.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_auto_update.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_backup.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_brief_footprint.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_brief_sections.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_candidate_reviewer.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_concepts.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_config_settings.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_config_watcher.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_core_memory.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_daemon_host_config.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_daemon_host_integration.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_daemon_liveness.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_daemon_state.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_dashboard.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_db_compact.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_db_contention.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_db_migration_fts.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_db_runtime.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_db_schema.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_delegated_search.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_dialectic.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_dialectic_feed_tools.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_dialectic_miner.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_dialectic_observation_resolve.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_dialectic_recompute.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_dialectic_tier.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_dialectic_validator.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_dialog_fts_external.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_egress_policy.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_embed_routing.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_embedding_generation.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_embedding_model_revision.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_embedding_storage_dedup.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_error_paths.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_eval_harness.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_evolve_applier.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_evolve_apply_2.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_evolve_apply_3.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_evolve_daemon.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_extract_daemon.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_extract_dedup.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_forget.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_github_budget.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_github_safety.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_helpers.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_host_embed.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_host_env.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_host_main.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_host_wedged.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_hybrid_retrieval.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_i18n_multilang.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_identity.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_ingest_cursor.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_ingest_denylist.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_ingest_secret_redaction.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_ingest_status.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_ingest_transactions.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_lessons.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_mcp_resources_prompts.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_memory_eval.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_memory_guard.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_memory_guard_host.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_menubar_app.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_missed_spawns.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_model_catalog.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_notify_daemon.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_nudges.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_onnx_embeddings.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_panel.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_permissions.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_pickup.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_probe_daemon.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_process_health.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_release_workflows.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_retention.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_review_prompts.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_search_fts_punctuation.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_session_read_purity.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_shadow_review.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_single_flight_prompt_prefixes.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_skill_hint.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_skill_passive_tier.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_skill_tier.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_skill_updater.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_skill_use_parser.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_skill_watcher.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_skills.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_spawn_budget.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_spawn_codex_stdin.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_spawn_config.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_spawn_hint.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_spawn_permission_gate.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_spawn_reap.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_spawn_watchdog.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_spawn_wrap.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_sync_capture.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_sync_identity.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_sync_migrate.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_sync_protocol.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_sync_reset_node.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_sync_transport.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_task_retention.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_task_spool.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_thin_session.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_thread_janitor.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_threads.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_tool_annotations.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_tools_smoke.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_validate_threads.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_vec_search.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_verify_ingest.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/__init__.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/_mcp.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/_setup.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/_spawn_wrap.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/adapters/__init__.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/adapters/_hook_helpers.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/adapters/antigravity.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/adapters/base.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/adapters/claude_code.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/adapters/claude_desktop.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/adapters/codex.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/adapters/copilot.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/adapters/vscode.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/agent_metadata.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/agent_status.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/assets/macos-agent-status/Info.plist +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/assets/macos-agent-status/README.md +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/assets/macos-agent-status/ThreadKeeperAgentStatus.swift +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/assets/macos-agent-status/build.sh +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/assets/macos-agent-status/install.sh +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/auto_update.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/backup.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/brief.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/candidate_reviewer.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/config.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/config_io.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/config_watcher.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/curator_snapshots.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/daemon_liveness.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/daemon_state.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/daemon_supervisor.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/db.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/dialectic_miner.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/dialectic_validator.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/egress.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/elicitation.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/embeddings.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/eval/__init__.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/eval/__main__.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/eval/harness.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/evolve_applier.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/evolve_daemon.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/extract_daemon.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/forget.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/github_budget.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/github_safety.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/harvest.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/helpers.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/host.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/host_embed.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/i18n.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/identity.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/ingest.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/lessons.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/memory_guard.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/menubar_app.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/migrate_embeddings.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/model_catalog.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/notify.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/nudges.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/permissions.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/probe_daemon.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/process_health.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/retention.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/retrieval.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/review_prompts.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/search_proxy.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/server.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/shadow_review.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/skill_updater.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/skill_watcher.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/spawn_budget.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/spawn_config.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/sync/__init__.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/sync/capture.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/sync/daemon.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/sync/identity.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/sync/migrate.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/sync/protocol.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/sync/reset_node.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/sync/server.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/task_spool.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/thread_janitor.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/tool_schemas.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/tools/__init__.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/tools/agent_status.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/tools/candidate_reviewer.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/tools/concepts.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/tools/config_watch.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/tools/consolidate.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/tools/core_memory.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/tools/correlation.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/tools/curator.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/tools/dashboard.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/tools/db_maintenance.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/tools/dialectic.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/tools/dialectic_feed.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/tools/dialog.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/tools/distill.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/tools/evolve_applier.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/tools/extract.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/tools/forget.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/tools/graph.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/tools/invariants.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/tools/lessons.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/tools/memory_guard.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/tools/missed_spawns.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/tools/panel.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/tools/peers.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/tools/pickup.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/tools/probes.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/tools/process_health.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/tools/prompts.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/tools/resources.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/tools/session.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/tools/shadow_review.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/tools/skills.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/tools/style.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/tools/sync.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/tools/threads.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/tools/validate.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/trash.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/verify_ingest.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/wikilinks.py +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper.egg-info/dependency_links.txt +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper.egg-info/entry_points.txt +0 -0
- {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper.egg-info/requires.txt +0 -0
- {threadkeeper-0.17.3 → 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
|
|
@@ -1549,8 +1561,9 @@ Spawn task spool files live in `THREADKEEPER_TASK_LOG_DIR` (default
|
|
|
1549
1561
|
the hardened `~/.threadkeeper` perimeter by default; explicit overrides are
|
|
1550
1562
|
refused when the configured directory is a symlink or is not owned by the
|
|
1551
1563
|
current user. `spawn()` creates captured headless `.log`, stdin prompt spool,
|
|
1552
|
-
and visible `.command` files with no-follow owner-only opens.
|
|
1553
|
-
|
|
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.
|
|
1554
1567
|
|
|
1555
1568
|
---
|
|
1556
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
|
|
@@ -1505,8 +1517,9 @@ Spawn task spool files live in `THREADKEEPER_TASK_LOG_DIR` (default
|
|
|
1505
1517
|
the hardened `~/.threadkeeper` perimeter by default; explicit overrides are
|
|
1506
1518
|
refused when the configured directory is a symlink or is not owned by the
|
|
1507
1519
|
current user. `spawn()` creates captured headless `.log`, stdin prompt spool,
|
|
1508
|
-
and visible `.command` files with no-follow owner-only opens.
|
|
1509
|
-
|
|
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.
|
|
1510
1523
|
|
|
1511
1524
|
---
|
|
1512
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" }]
|
|
@@ -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"]
|
|
@@ -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
|
|
@@ -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:
|
|
@@ -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
|
)
|
|
@@ -16,6 +16,7 @@ import sys
|
|
|
16
16
|
import secrets
|
|
17
17
|
import time
|
|
18
18
|
import json as _json
|
|
19
|
+
from dataclasses import dataclass
|
|
19
20
|
from pathlib import Path
|
|
20
21
|
from typing import Optional
|
|
21
22
|
|
|
@@ -57,6 +58,30 @@ _BYPASS_ENV_OVERRIDE = "THREADKEEPER_ALLOW_BYPASS_PERMISSIONS_SPAWN"
|
|
|
57
58
|
# that and feed larger prompts through the existing owner-only stdin spool.
|
|
58
59
|
CLAUDE_PROMPT_ARGV_MAX_BYTES = 96 * 1024
|
|
59
60
|
|
|
61
|
+
# A child that starts from a repository must never share that repository's
|
|
62
|
+
# mutable checkout with another child. Worktrees live in the protected task
|
|
63
|
+
# spool rather than beside the caller's checkout, where they cannot surprise a
|
|
64
|
+
# developer scanning their project tree.
|
|
65
|
+
_SPAWN_WORKTREE_DIRNAME = "worktrees"
|
|
66
|
+
_SPAWN_WORKTREE_BRANCH_PREFIX = "threadkeeper/spawn-"
|
|
67
|
+
# Keep git preflight independent from tests (and callers) that replace the
|
|
68
|
+
# child-launcher `subprocess.Popen` below.
|
|
69
|
+
_GIT_POPEN = subprocess.Popen
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True)
|
|
73
|
+
class _SpawnWorktree:
|
|
74
|
+
"""A per-task checkout derived from a clean caller worktree."""
|
|
75
|
+
|
|
76
|
+
repo_root: Path
|
|
77
|
+
relative_cwd: Path
|
|
78
|
+
path: Path
|
|
79
|
+
branch: str
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def child_cwd(self) -> Path:
|
|
83
|
+
return self.path / self.relative_cwd
|
|
84
|
+
|
|
60
85
|
|
|
61
86
|
def _utf8_len(text: str) -> int:
|
|
62
87
|
return len(text.encode("utf-8"))
|
|
@@ -402,6 +427,102 @@ def _build_slim_mcp_config(
|
|
|
402
427
|
return slim_path
|
|
403
428
|
|
|
404
429
|
|
|
430
|
+
def _git_result(args: list[str], cwd: Path) -> tuple[str, str]:
|
|
431
|
+
"""Run a short git query and return stdout plus a compact error string."""
|
|
432
|
+
try:
|
|
433
|
+
proc = _GIT_POPEN(
|
|
434
|
+
["git", *args],
|
|
435
|
+
cwd=str(cwd),
|
|
436
|
+
text=True,
|
|
437
|
+
stdout=subprocess.PIPE,
|
|
438
|
+
stderr=subprocess.PIPE,
|
|
439
|
+
)
|
|
440
|
+
stdout, stderr = proc.communicate(timeout=15)
|
|
441
|
+
except subprocess.TimeoutExpired:
|
|
442
|
+
proc.kill()
|
|
443
|
+
proc.communicate()
|
|
444
|
+
return "", "git command timed out"
|
|
445
|
+
except (FileNotFoundError, OSError) as e:
|
|
446
|
+
return "", str(e)
|
|
447
|
+
if proc.returncode == 0:
|
|
448
|
+
return stdout.strip(), ""
|
|
449
|
+
detail = (stderr or stdout or f"exit={proc.returncode}").strip()
|
|
450
|
+
return "", detail.replace("\n", " ")[:240]
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def _spawn_worktree_plan(
|
|
454
|
+
cwd: str, task_id: str
|
|
455
|
+
) -> tuple[Optional[_SpawnWorktree], str]:
|
|
456
|
+
"""Return an isolated worktree plan or safely decline a git checkout.
|
|
457
|
+
|
|
458
|
+
Non-git directories keep the historic behavior. Git directories must be
|
|
459
|
+
clean before a child can run: copying uncommitted tracked changes into a
|
|
460
|
+
second checkout would either lose them or reintroduce the shared-WIP race.
|
|
461
|
+
"""
|
|
462
|
+
source_cwd = Path(cwd).resolve()
|
|
463
|
+
root_text, root_err = _git_result(
|
|
464
|
+
["rev-parse", "--show-toplevel"], source_cwd
|
|
465
|
+
)
|
|
466
|
+
if root_err:
|
|
467
|
+
if "not a git repository" in root_err.lower():
|
|
468
|
+
return None, ""
|
|
469
|
+
return None, f"ERR spawn_worktree_check_failed={root_err}"
|
|
470
|
+
repo_root = Path(root_text).resolve()
|
|
471
|
+
try:
|
|
472
|
+
relative_cwd = source_cwd.relative_to(repo_root)
|
|
473
|
+
except ValueError:
|
|
474
|
+
return None, "ERR spawn_worktree_check_failed=cwd_outside_repo"
|
|
475
|
+
|
|
476
|
+
dirty, status_err = _git_result(
|
|
477
|
+
["status", "--porcelain", "--untracked-files=no"], repo_root
|
|
478
|
+
)
|
|
479
|
+
if status_err:
|
|
480
|
+
return None, f"ERR spawn_worktree_check_failed={status_err}"
|
|
481
|
+
if dirty:
|
|
482
|
+
return None, "ERR spawn_dirty_worktree mode=git"
|
|
483
|
+
|
|
484
|
+
try:
|
|
485
|
+
parent = ensure_task_spool_dir(TASK_LOG_DIR / _SPAWN_WORKTREE_DIRNAME)
|
|
486
|
+
except OSError as e:
|
|
487
|
+
return None, f"ERR spawn_worktree_spool_unavailable={e}"
|
|
488
|
+
path = parent / task_id
|
|
489
|
+
if path.exists() or path.is_symlink():
|
|
490
|
+
return None, "ERR spawn_worktree_path_in_use"
|
|
491
|
+
return _SpawnWorktree(
|
|
492
|
+
repo_root=repo_root,
|
|
493
|
+
relative_cwd=relative_cwd,
|
|
494
|
+
path=path,
|
|
495
|
+
branch=_SPAWN_WORKTREE_BRANCH_PREFIX + task_id,
|
|
496
|
+
), ""
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def _remove_spawn_worktree(worktree: _SpawnWorktree) -> None:
|
|
500
|
+
"""Best-effort cleanup for a worktree whose child never launched."""
|
|
501
|
+
_git_result(
|
|
502
|
+
["worktree", "remove", "--force", str(worktree.path)],
|
|
503
|
+
worktree.repo_root,
|
|
504
|
+
)
|
|
505
|
+
_git_result(["branch", "-D", worktree.branch], worktree.repo_root)
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def _create_spawn_worktree(worktree: _SpawnWorktree) -> str:
|
|
509
|
+
"""Create the task's branch/worktree after the spawn budget is reserved."""
|
|
510
|
+
_, err = _git_result(
|
|
511
|
+
[
|
|
512
|
+
"worktree", "add", "--quiet", "-b", worktree.branch,
|
|
513
|
+
str(worktree.path), "HEAD",
|
|
514
|
+
],
|
|
515
|
+
worktree.repo_root,
|
|
516
|
+
)
|
|
517
|
+
if err:
|
|
518
|
+
_remove_spawn_worktree(worktree)
|
|
519
|
+
return f"ERR spawn_worktree_create_failed={err}"
|
|
520
|
+
if not worktree.child_cwd.is_dir():
|
|
521
|
+
_remove_spawn_worktree(worktree)
|
|
522
|
+
return "ERR spawn_worktree_create_failed=cwd_not_in_clean_checkout"
|
|
523
|
+
return ""
|
|
524
|
+
|
|
525
|
+
|
|
405
526
|
def _spawn_impl(prompt: str, cwd: str = "", append_system: str = "",
|
|
406
527
|
model: str = "", effort: str = "",
|
|
407
528
|
permission_mode: str = "auto",
|
|
@@ -519,6 +640,9 @@ def _spawn_impl(prompt: str, cwd: str = "", append_system: str = "",
|
|
|
519
640
|
for c in task_id
|
|
520
641
|
):
|
|
521
642
|
return "ERR invalid_task_id"
|
|
643
|
+
worktree, worktree_err = _spawn_worktree_plan(cwd, task_id)
|
|
644
|
+
if worktree_err:
|
|
645
|
+
return worktree_err
|
|
522
646
|
sys_extra = sys_extra_template.format(
|
|
523
647
|
parent=parent_cid or "(unknown)",
|
|
524
648
|
child=child_cid,
|
|
@@ -778,6 +902,13 @@ def _spawn_impl(prompt: str, cwd: str = "", append_system: str = "",
|
|
|
778
902
|
retry_root or None, int(retry_attempt or 0),
|
|
779
903
|
),
|
|
780
904
|
)
|
|
905
|
+
if worktree is not None:
|
|
906
|
+
worktree_create_err = _create_spawn_worktree(worktree)
|
|
907
|
+
if worktree_create_err:
|
|
908
|
+
conn.rollback()
|
|
909
|
+
return worktree_create_err
|
|
910
|
+
cwd = str(worktree.child_cwd)
|
|
911
|
+
conn.execute("UPDATE tasks SET cwd=? WHERE id=?", (cwd, task_id))
|
|
781
912
|
except sqlite3.Error as e:
|
|
782
913
|
conn.rollback()
|
|
783
914
|
return f"ERR spawn_reservation_failed={e}"
|
|
@@ -943,6 +1074,8 @@ exit $rc
|
|
|
943
1074
|
stdin_f.close()
|
|
944
1075
|
proc_pid = proc.pid
|
|
945
1076
|
except (FileNotFoundError, OSError) as e:
|
|
1077
|
+
if worktree is not None:
|
|
1078
|
+
_remove_spawn_worktree(worktree)
|
|
946
1079
|
conn.rollback()
|
|
947
1080
|
return f"ERR spawn_failed={e}"
|
|
948
1081
|
try:
|
|
@@ -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
|
|
@@ -1549,8 +1561,9 @@ Spawn task spool files live in `THREADKEEPER_TASK_LOG_DIR` (default
|
|
|
1549
1561
|
the hardened `~/.threadkeeper` perimeter by default; explicit overrides are
|
|
1550
1562
|
refused when the configured directory is a symlink or is not owned by the
|
|
1551
1563
|
current user. `spawn()` creates captured headless `.log`, stdin prompt spool,
|
|
1552
|
-
and visible `.command` files with no-follow owner-only opens.
|
|
1553
|
-
|
|
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.
|
|
1554
1567
|
|
|
1555
1568
|
---
|
|
1556
1569
|
|
|
@@ -99,6 +99,7 @@ tests/test_spawn_permission_gate.py
|
|
|
99
99
|
tests/test_spawn_reap.py
|
|
100
100
|
tests/test_spawn_slim.py
|
|
101
101
|
tests/test_spawn_watchdog.py
|
|
102
|
+
tests/test_spawn_worktree_isolation.py
|
|
102
103
|
tests/test_spawn_wrap.py
|
|
103
104
|
tests/test_sync_capture.py
|
|
104
105
|
tests/test_sync_identity.py
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|