threadkeeper 0.17.5__tar.gz → 0.17.7__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.5 → threadkeeper-0.17.7}/PKG-INFO +32 -7
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/README.md +31 -6
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/pyproject.toml +1 -1
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_adapters.py +1 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_agent_status.py +39 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_candidate_reviewer.py +2 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_curator.py +121 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_daemon_liveness.py +4 -2
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_evolve_daemon.py +162 -17
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_lessons.py +67 -0
- threadkeeper-0.17.7/tests/test_setup.py +31 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_shadow_review.py +2 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_spawn_slim.py +3 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_spawn_watchdog.py +36 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_tool_annotations.py +9 -4
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/_setup.py +8 -1
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/adapters/codex.py +8 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/agent_metadata.py +1 -1
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/agent_status.py +4 -4
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/candidate_reviewer.py +10 -1
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/curator.py +177 -7
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/db.py +51 -2
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/evolve_daemon.py +348 -49
- threadkeeper-0.17.7/threadkeeper/link_health.py +81 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/server.py +1 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/shadow_review.py +8 -1
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/spawn_budget.py +7 -5
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/tools/curator.py +40 -0
- threadkeeper-0.17.7/threadkeeper/tools/evolve_research.py +22 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/tools/lessons.py +111 -1
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/tools/spawn.py +23 -4
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper.egg-info/PKG-INFO +32 -7
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper.egg-info/SOURCES.txt +3 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/LICENSE +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/setup.cfg +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_auto_update.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_backup.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_brief_footprint.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_brief_sections.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_concepts.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_config_settings.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_config_watcher.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_core_memory.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_daemon_host_config.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_daemon_host_integration.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_daemon_state.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_dashboard.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_db_compact.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_db_contention.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_db_migration_fts.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_db_runtime.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_db_schema.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_delegated_search.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_dialectic.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_dialectic_feed_tools.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_dialectic_miner.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_dialectic_observation_resolve.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_dialectic_recompute.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_dialectic_tier.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_dialectic_validator.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_dialog_fts_external.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_egress_policy.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_embed_routing.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_embedding_generation.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_embedding_model_revision.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_embedding_storage_dedup.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_error_paths.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_eval_harness.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_evolve_applier.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_evolve_apply_2.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_evolve_apply_3.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_extract_daemon.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_extract_dedup.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_forget.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_github_budget.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_github_safety.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_helpers.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_host_embed.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_host_env.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_host_main.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_host_wedged.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_hybrid_retrieval.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_i18n_multilang.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_identity.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_ingest_cursor.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_ingest_denylist.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_ingest_secret_redaction.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_ingest_status.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_ingest_transactions.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_mcp_resources_prompts.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_memory_eval.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_memory_guard.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_memory_guard_host.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_menubar_app.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_missed_spawns.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_model_catalog.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_notify_daemon.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_nudges.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_onnx_embeddings.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_panel.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_permissions.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_pickup.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_probe_daemon.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_process_health.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_release_workflows.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_retention.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_review_prompts.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_search_fts_punctuation.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_session_read_purity.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_single_flight_prompt_prefixes.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_skill_hint.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_skill_passive_tier.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_skill_tier.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_skill_updater.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_skill_use_parser.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_skill_watcher.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_skills.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_spawn_budget.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_spawn_codex_stdin.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_spawn_config.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_spawn_hint.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_spawn_permission_gate.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_spawn_reap.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_spawn_worktree_isolation.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_spawn_wrap.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_sync_capture.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_sync_identity.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_sync_migrate.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_sync_protocol.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_sync_reset_node.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_sync_transport.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_task_retention.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_task_spool.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_thin_session.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_thread_janitor.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_threads.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_tools_smoke.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_validate_threads.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_vec_search.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_verify_ingest.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/__init__.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/_mcp.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/_spawn_wrap.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/adapters/__init__.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/adapters/_hook_helpers.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/adapters/antigravity.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/adapters/base.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/adapters/claude_code.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/adapters/claude_desktop.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/adapters/copilot.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/adapters/vscode.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/assets/macos-agent-status/Info.plist +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/assets/macos-agent-status/README.md +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/assets/macos-agent-status/ThreadKeeperAgentStatus.swift +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/assets/macos-agent-status/build.sh +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/assets/macos-agent-status/install.sh +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/auto_update.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/backup.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/brief.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/config.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/config_io.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/config_watcher.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/curator_snapshots.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/daemon_liveness.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/daemon_state.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/daemon_supervisor.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/dialectic_miner.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/dialectic_validator.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/egress.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/elicitation.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/embeddings.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/eval/__init__.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/eval/__main__.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/eval/harness.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/evolve_applier.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/extract_daemon.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/forget.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/github_budget.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/github_safety.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/harvest.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/helpers.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/host.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/host_embed.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/i18n.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/identity.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/ingest.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/lessons.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/memory_guard.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/menubar_app.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/migrate_embeddings.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/model_catalog.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/notify.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/nudges.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/permissions.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/probe_daemon.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/process_health.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/retention.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/retrieval.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/review_prompts.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/search_proxy.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/skill_audit.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/skill_updater.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/skill_watcher.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/spawn_config.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/sync/__init__.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/sync/capture.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/sync/daemon.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/sync/identity.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/sync/migrate.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/sync/protocol.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/sync/reset_node.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/sync/server.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/task_spool.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/thread_janitor.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/tool_schemas.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/tools/__init__.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/tools/agent_status.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/tools/candidate_reviewer.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/tools/concepts.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/tools/config_watch.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/tools/consolidate.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/tools/core_memory.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/tools/correlation.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/tools/dashboard.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/tools/db_maintenance.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/tools/dialectic.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/tools/dialectic_feed.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/tools/dialog.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/tools/distill.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/tools/evolve_applier.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/tools/extract.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/tools/forget.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/tools/graph.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/tools/invariants.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/tools/memory_guard.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/tools/missed_spawns.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/tools/panel.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/tools/peers.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/tools/pickup.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/tools/probes.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/tools/process_health.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/tools/prompts.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/tools/resources.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/tools/session.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/tools/shadow_review.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/tools/skills.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/tools/style.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/tools/sync.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/tools/threads.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/tools/validate.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/trash.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/verify_ingest.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/wikilinks.py +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper.egg-info/dependency_links.txt +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper.egg-info/entry_points.txt +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper.egg-info/requires.txt +0 -0
- {threadkeeper-0.17.5 → threadkeeper-0.17.7}/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.7
|
|
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
|
|
@@ -137,6 +137,9 @@ config, copies hooks to
|
|
|
137
137
|
each CLI's per-user instructions file (`CLAUDE.md` / `AGENTS.md` /
|
|
138
138
|
`copilot-instructions.md` — Claude Desktop and VS Code
|
|
139
139
|
have no global instructions file, so that step is skipped for them).
|
|
140
|
+
The generated MCP entry pins imports to this configured installation, so an
|
|
141
|
+
agent launched inside another thread-keeper checkout cannot load that checkout's
|
|
142
|
+
unmerged code against the live memory database.
|
|
140
143
|
|
|
141
144
|
Restart your CLI of choice. Hook-capable clients inject a brief on the first
|
|
142
145
|
message; hookless clients such as Codex and Antigravity CLI either follow the
|
|
@@ -217,7 +220,7 @@ read/act split, plus MCP elicitation for host-native confirmations:
|
|
|
217
220
|
|
|
218
221
|
| Primitive | Control | What thread-keeper exposes | When to use |
|
|
219
222
|
|---|---|---|---|
|
|
220
|
-
| **Tools** | model-controlled (may act) | the full surface — `brief`, `note`, `spawn`, `search`, `curator_review`, … | the agent decides to call them |
|
|
223
|
+
| **Tools** | model-controlled (may act) | the full surface — `brief`, `note`, `spawn`, `search`, `curator_review`, `wikilink_health`, … | the agent decides to call them |
|
|
221
224
|
| **Resources** | application-controlled, read-only | `memory://brief`, `memory://context`, `memory://dashboard`, `memory://agent-status` | the **host** attaches/pulls them automatically |
|
|
222
225
|
| **Prompts** | user-controlled templates | `review_recent_threads`, `run_library_curation`, `audit_threadkeeper` | the user runs them (Claude Code: `/mcp__thread-keeper__<name>`) |
|
|
223
226
|
|
|
@@ -351,7 +354,10 @@ repair partial work, and continue rather than restart blindly.
|
|
|
351
354
|
`THREADKEEPER_SPAWN_TIMEOUT_RETRY_LIMIT` (default 3; 0 disables) bounds the
|
|
352
355
|
retry chain, with `THREADKEEPER_SPAWN_TIMEOUT_RETRY_DELAY_S` available for a
|
|
353
356
|
non-zero delay. Timed-out children are surfaced as `tasks_timed_out` in
|
|
354
|
-
`mp_dashboard` and `timed_out` in `agent_status`.
|
|
357
|
+
`mp_dashboard` and `timed_out` in `agent_status`. `agent_status` and
|
|
358
|
+
`tk-agent-status` are observation-only and never terminate or respawn a
|
|
359
|
+
child; timeout enforcement and the continuation retry live solely in the
|
|
360
|
+
spawn-budget daemon.
|
|
355
361
|
|
|
356
362
|
`tk-agent-status` exposes autonomous learning loop status as structured JSON
|
|
357
363
|
or compact text for external monitors:
|
|
@@ -614,6 +620,11 @@ routed to the incumbent lesson or surfaced for curation instead of minting a
|
|
|
614
620
|
sibling lesson. A clear new directive or debunk also flags older permissive
|
|
615
621
|
lessons on the same concrete practice for patch, cross-link, or supersession
|
|
616
622
|
review.
|
|
623
|
+
Before a genuinely new fallback lesson is written,
|
|
624
|
+
`lesson_neighbors(title, body, summary, k=3)` shows the nearest existing lesson
|
|
625
|
+
slugs (semantic, with a lexical fallback). Shadow and candidate reviewers use
|
|
626
|
+
that preflight to patch/consolidate an incumbent or add a `[[slug]]` cross-link
|
|
627
|
+
to a related, distinct lesson while its body is still editable.
|
|
617
628
|
|
|
618
629
|
#### 3. Extract daemon
|
|
619
630
|
|
|
@@ -718,6 +729,14 @@ non-forced direct `curator_review()` calls return `not_due` inside the
|
|
|
718
729
|
configured interval and record that status without spawning. A manual
|
|
719
730
|
`curator_review(force=True)` bypasses the interval but still respects the lock.
|
|
720
731
|
|
|
732
|
+
When a Curator reviews a lesson pair and deliberately keeps both, it records a
|
|
733
|
+
structured `keep_both` merge verdict with the two slugs and a short reason.
|
|
734
|
+
Later inventories show those prior verdicts and each lesson's current
|
|
735
|
+
bidirectional `[[wikilink]]` adjacency (`links=[...]`), including for the
|
|
736
|
+
relevant side of a multi-batch review. This preserves intentional
|
|
737
|
+
general/specific and prevention/recovery layering without making a child
|
|
738
|
+
re-read both lesson bodies to rediscover it.
|
|
739
|
+
|
|
721
740
|
For automation-created skills, the audit keeps foreground consultation separate
|
|
722
741
|
from maintenance: a background-review skill with `fg_uses=0` after 14 days is
|
|
723
742
|
still a false-positive prune candidate even if automatic review or sync loops
|
|
@@ -852,10 +871,16 @@ runs as **two alternating phases**, never co-granting web research and
|
|
|
852
871
|
shell/`bypassPermissions` to the same child:
|
|
853
872
|
|
|
854
873
|
- **research phase** — a read-only child with `WebSearch`/`WebFetch` and
|
|
855
|
-
read-only repo reads but **no shell, no `
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
874
|
+
read-only repo reads but **no shell, no generic `Write`, no
|
|
875
|
+
`bypassPermissions`, and no GitHub access**. Before dispatch, the parent
|
|
876
|
+
registers one pass ID, owner child, and digest target. The child can submit
|
|
877
|
+
only that pass through `evolve_research_handoff(...)`; it cannot choose a
|
|
878
|
+
path. The handoff is one-shot, capped at 12,000 characters / 400 lines,
|
|
879
|
+
atomically persisted with a SHA-256, and records rejected, failed, expired,
|
|
880
|
+
and tampered outcomes in Evolve telemetry. The later audit reads only a
|
|
881
|
+
fresh accepted handoff whose final file still matches that hash. With no
|
|
882
|
+
`Bash`/`gh`/network-write tool it has no exfiltration channel, so the
|
|
883
|
+
untrusted pages it reads cannot act.
|
|
859
884
|
- **audit phase** — the privileged child (`bypassPermissions` + `Bash`/`Edit`/
|
|
860
885
|
`Write`) that audits the repo, opens the `docs/ROADMAP.md` PR, and creates or
|
|
861
886
|
updates GitHub issues. It holds **no web tools**; it consumes the research
|
|
@@ -93,6 +93,9 @@ config, copies hooks to
|
|
|
93
93
|
each CLI's per-user instructions file (`CLAUDE.md` / `AGENTS.md` /
|
|
94
94
|
`copilot-instructions.md` — Claude Desktop and VS Code
|
|
95
95
|
have no global instructions file, so that step is skipped for them).
|
|
96
|
+
The generated MCP entry pins imports to this configured installation, so an
|
|
97
|
+
agent launched inside another thread-keeper checkout cannot load that checkout's
|
|
98
|
+
unmerged code against the live memory database.
|
|
96
99
|
|
|
97
100
|
Restart your CLI of choice. Hook-capable clients inject a brief on the first
|
|
98
101
|
message; hookless clients such as Codex and Antigravity CLI either follow the
|
|
@@ -173,7 +176,7 @@ read/act split, plus MCP elicitation for host-native confirmations:
|
|
|
173
176
|
|
|
174
177
|
| Primitive | Control | What thread-keeper exposes | When to use |
|
|
175
178
|
|---|---|---|---|
|
|
176
|
-
| **Tools** | model-controlled (may act) | the full surface — `brief`, `note`, `spawn`, `search`, `curator_review`, … | the agent decides to call them |
|
|
179
|
+
| **Tools** | model-controlled (may act) | the full surface — `brief`, `note`, `spawn`, `search`, `curator_review`, `wikilink_health`, … | the agent decides to call them |
|
|
177
180
|
| **Resources** | application-controlled, read-only | `memory://brief`, `memory://context`, `memory://dashboard`, `memory://agent-status` | the **host** attaches/pulls them automatically |
|
|
178
181
|
| **Prompts** | user-controlled templates | `review_recent_threads`, `run_library_curation`, `audit_threadkeeper` | the user runs them (Claude Code: `/mcp__thread-keeper__<name>`) |
|
|
179
182
|
|
|
@@ -307,7 +310,10 @@ repair partial work, and continue rather than restart blindly.
|
|
|
307
310
|
`THREADKEEPER_SPAWN_TIMEOUT_RETRY_LIMIT` (default 3; 0 disables) bounds the
|
|
308
311
|
retry chain, with `THREADKEEPER_SPAWN_TIMEOUT_RETRY_DELAY_S` available for a
|
|
309
312
|
non-zero delay. Timed-out children are surfaced as `tasks_timed_out` in
|
|
310
|
-
`mp_dashboard` and `timed_out` in `agent_status`.
|
|
313
|
+
`mp_dashboard` and `timed_out` in `agent_status`. `agent_status` and
|
|
314
|
+
`tk-agent-status` are observation-only and never terminate or respawn a
|
|
315
|
+
child; timeout enforcement and the continuation retry live solely in the
|
|
316
|
+
spawn-budget daemon.
|
|
311
317
|
|
|
312
318
|
`tk-agent-status` exposes autonomous learning loop status as structured JSON
|
|
313
319
|
or compact text for external monitors:
|
|
@@ -570,6 +576,11 @@ routed to the incumbent lesson or surfaced for curation instead of minting a
|
|
|
570
576
|
sibling lesson. A clear new directive or debunk also flags older permissive
|
|
571
577
|
lessons on the same concrete practice for patch, cross-link, or supersession
|
|
572
578
|
review.
|
|
579
|
+
Before a genuinely new fallback lesson is written,
|
|
580
|
+
`lesson_neighbors(title, body, summary, k=3)` shows the nearest existing lesson
|
|
581
|
+
slugs (semantic, with a lexical fallback). Shadow and candidate reviewers use
|
|
582
|
+
that preflight to patch/consolidate an incumbent or add a `[[slug]]` cross-link
|
|
583
|
+
to a related, distinct lesson while its body is still editable.
|
|
573
584
|
|
|
574
585
|
#### 3. Extract daemon
|
|
575
586
|
|
|
@@ -674,6 +685,14 @@ non-forced direct `curator_review()` calls return `not_due` inside the
|
|
|
674
685
|
configured interval and record that status without spawning. A manual
|
|
675
686
|
`curator_review(force=True)` bypasses the interval but still respects the lock.
|
|
676
687
|
|
|
688
|
+
When a Curator reviews a lesson pair and deliberately keeps both, it records a
|
|
689
|
+
structured `keep_both` merge verdict with the two slugs and a short reason.
|
|
690
|
+
Later inventories show those prior verdicts and each lesson's current
|
|
691
|
+
bidirectional `[[wikilink]]` adjacency (`links=[...]`), including for the
|
|
692
|
+
relevant side of a multi-batch review. This preserves intentional
|
|
693
|
+
general/specific and prevention/recovery layering without making a child
|
|
694
|
+
re-read both lesson bodies to rediscover it.
|
|
695
|
+
|
|
677
696
|
For automation-created skills, the audit keeps foreground consultation separate
|
|
678
697
|
from maintenance: a background-review skill with `fg_uses=0` after 14 days is
|
|
679
698
|
still a false-positive prune candidate even if automatic review or sync loops
|
|
@@ -808,10 +827,16 @@ runs as **two alternating phases**, never co-granting web research and
|
|
|
808
827
|
shell/`bypassPermissions` to the same child:
|
|
809
828
|
|
|
810
829
|
- **research phase** — a read-only child with `WebSearch`/`WebFetch` and
|
|
811
|
-
read-only repo reads but **no shell, no `
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
830
|
+
read-only repo reads but **no shell, no generic `Write`, no
|
|
831
|
+
`bypassPermissions`, and no GitHub access**. Before dispatch, the parent
|
|
832
|
+
registers one pass ID, owner child, and digest target. The child can submit
|
|
833
|
+
only that pass through `evolve_research_handoff(...)`; it cannot choose a
|
|
834
|
+
path. The handoff is one-shot, capped at 12,000 characters / 400 lines,
|
|
835
|
+
atomically persisted with a SHA-256, and records rejected, failed, expired,
|
|
836
|
+
and tampered outcomes in Evolve telemetry. The later audit reads only a
|
|
837
|
+
fresh accepted handoff whose final file still matches that hash. With no
|
|
838
|
+
`Bash`/`gh`/network-write tool it has no exfiltration channel, so the
|
|
839
|
+
untrusted pages it reads cannot act.
|
|
815
840
|
- **audit phase** — the privileged child (`bypassPermissions` + `Bash`/`Edit`/
|
|
816
841
|
`Write`) that audits the repo, opens the `docs/ROADMAP.md` PR, and creates or
|
|
817
842
|
updates GitHub issues. It holds **no web tools**; it consumes the research
|
|
@@ -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.7"
|
|
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" }]
|
|
@@ -383,6 +383,7 @@ def test_codex_spawn_argv_skips_git_repo_check(tmp_path, monkeypatch):
|
|
|
383
383
|
assert argv[:3] == ["/usr/local/bin/codex", "exec", "--skip-git-repo-check"]
|
|
384
384
|
assert argv[-1] == "-"
|
|
385
385
|
assert "-m" in argv and "gpt-5.5" in argv
|
|
386
|
+
assert 'mcp_servers.thread-keeper.env.PYTHONSAFEPATH="1"' in argv
|
|
386
387
|
# Default (non-bypass) path still sandboxes.
|
|
387
388
|
assert "--sandbox" in argv and "workspace-write" in argv
|
|
388
389
|
|
|
@@ -784,3 +784,42 @@ def test_recent_failures_notify_flag_off_when_toggle_disabled(
|
|
|
784
784
|
fails = {f["task_id"]: f for f in snap["recent_failures"]}
|
|
785
785
|
assert "deadchild2" in fails # still listed in the menu
|
|
786
786
|
assert fails["deadchild2"]["notify"] is False # but no banner posted
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
def test_agent_status_tool_default_read_does_not_enforce_lifecycle(
|
|
790
|
+
fresh_mp, monkeypatch
|
|
791
|
+
):
|
|
792
|
+
pkg = fresh_mp
|
|
793
|
+
_insert_task(pkg, "tk_overdue", "Build a compact menu-bar status app.")
|
|
794
|
+
conn = pkg["db"].get_db()
|
|
795
|
+
conn.execute(
|
|
796
|
+
"UPDATE tasks SET started_at=? WHERE id=?",
|
|
797
|
+
(int(time.time()) - 10000, "tk_overdue"),
|
|
798
|
+
)
|
|
799
|
+
conn.commit()
|
|
800
|
+
|
|
801
|
+
import threadkeeper.spawn_budget as sb
|
|
802
|
+
|
|
803
|
+
reap_calls: list = []
|
|
804
|
+
respawn_calls: list = []
|
|
805
|
+
monkeypatch.setattr(
|
|
806
|
+
sb, "_reap_timed_out",
|
|
807
|
+
lambda conn, row, now: reap_calls.append(row["id"]) or True,
|
|
808
|
+
)
|
|
809
|
+
monkeypatch.setattr(
|
|
810
|
+
sb, "_respawn_timed_out",
|
|
811
|
+
lambda conn, row, age: respawn_calls.append(row["id"]),
|
|
812
|
+
)
|
|
813
|
+
|
|
814
|
+
result = _txt(_tool(pkg, "agent_status")())
|
|
815
|
+
|
|
816
|
+
assert reap_calls == []
|
|
817
|
+
assert respawn_calls == []
|
|
818
|
+
row = conn.execute(
|
|
819
|
+
"SELECT ended_at, return_code FROM tasks WHERE id='tk_overdue'"
|
|
820
|
+
).fetchone()
|
|
821
|
+
assert row["ended_at"] is None
|
|
822
|
+
assert row["return_code"] is None
|
|
823
|
+
count = conn.execute("SELECT COUNT(*) FROM tasks").fetchone()[0]
|
|
824
|
+
assert count == 1
|
|
825
|
+
assert "tk_overdue" in result or "agents=1" in result
|
|
@@ -203,6 +203,8 @@ def test_run_spawns_when_threshold_met(tmp_path, monkeypatch):
|
|
|
203
203
|
assert "skill_manage" in allowed
|
|
204
204
|
assert "accept_candidate" in allowed
|
|
205
205
|
assert "reject_candidate" in allowed
|
|
206
|
+
assert "lesson_neighbors(title=<prospective>" in kw["prompt"]
|
|
207
|
+
assert "lesson_neighbors" in allowed
|
|
206
208
|
assert "Bash" not in allowed
|
|
207
209
|
# De-privileged (issue #76): no bare Read/Write/Edit — only the
|
|
208
210
|
# path-scoped skill/lesson/candidate MCP tools.
|
|
@@ -281,6 +281,64 @@ def test_collect_inventory_preview_truncates_large_store(tmp_path, monkeypatch):
|
|
|
281
281
|
assert "live curator pass reviews complete bounded batches" in dump
|
|
282
282
|
|
|
283
283
|
|
|
284
|
+
def test_curator_reuses_merge_verdicts_and_wikilink_adjacency(
|
|
285
|
+
tmp_path, monkeypatch,
|
|
286
|
+
):
|
|
287
|
+
"""A later pass receives durable keep-both context without body reads."""
|
|
288
|
+
pkg = _bootstrap(tmp_path, monkeypatch, min_lessons="2")
|
|
289
|
+
pkg["lessons"].append_lesson(
|
|
290
|
+
title="general-prevention",
|
|
291
|
+
body="The broad guard points to [[specific-recovery]].",
|
|
292
|
+
source="shadow",
|
|
293
|
+
)
|
|
294
|
+
pkg["lessons"].append_lesson(
|
|
295
|
+
title="specific-recovery",
|
|
296
|
+
body="The repair path points to [[general-prevention]].",
|
|
297
|
+
source="shadow",
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
import threadkeeper.tools.spawn as spawn_mod
|
|
301
|
+
captured: list[dict] = []
|
|
302
|
+
|
|
303
|
+
def fake_spawn(**kwargs):
|
|
304
|
+
captured.append(kwargs)
|
|
305
|
+
return f"spawn task_id=curator-{len(captured)} pid=0"
|
|
306
|
+
|
|
307
|
+
monkeypatch.setattr(spawn_mod, "spawn", fake_spawn)
|
|
308
|
+
assert "curator-1" in pkg["curator"].run_curator_pass(force=True)
|
|
309
|
+
|
|
310
|
+
from threadkeeper._mcp import mcp
|
|
311
|
+
verdict = mcp._tool_manager._tools["curator_merge_verdict"].fn
|
|
312
|
+
out = verdict(
|
|
313
|
+
"specific-recovery",
|
|
314
|
+
"general-prevention",
|
|
315
|
+
"prevention/recovery",
|
|
316
|
+
)
|
|
317
|
+
assert out == (
|
|
318
|
+
"ok merge_verdict=general-prevention,specific-recovery "
|
|
319
|
+
"decision=keep_both"
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
assert "curator-2" in pkg["curator"].run_curator_pass(force=True)
|
|
323
|
+
prompt = captured[-1]["prompt"]
|
|
324
|
+
assert "general-prevention" in prompt
|
|
325
|
+
assert "specific-recovery" in prompt
|
|
326
|
+
assert "links=[specific-recovery]" in prompt
|
|
327
|
+
assert "links=[general-prevention]" in prompt
|
|
328
|
+
assert "## PRIOR MERGE VERDICTS (n=1)" in prompt
|
|
329
|
+
assert "decision=keep_both reason=prevention/recovery" in prompt
|
|
330
|
+
|
|
331
|
+
conn = pkg["db"].get_db()
|
|
332
|
+
row = conn.execute(
|
|
333
|
+
"SELECT left_slug, right_slug, decision, reason "
|
|
334
|
+
"FROM curator_merge_verdicts"
|
|
335
|
+
).fetchone()
|
|
336
|
+
assert tuple(row) == (
|
|
337
|
+
"general-prevention", "specific-recovery", "keep_both",
|
|
338
|
+
"prevention/recovery",
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
|
|
284
342
|
# ──────────────────────────────────────────────────────────────────────
|
|
285
343
|
# run_curator_pass — dispatch logic
|
|
286
344
|
# ──────────────────────────────────────────────────────────────────────
|
|
@@ -364,6 +422,7 @@ def test_run_curator_pass_spawns_when_threshold_met(tmp_path, monkeypatch):
|
|
|
364
422
|
assert "evolve_format" in allowed
|
|
365
423
|
assert "Read" in allowed
|
|
366
424
|
assert "curator_report_write" in allowed
|
|
425
|
+
assert "curator_merge_verdict" in allowed
|
|
367
426
|
assert "Write" not in allowed
|
|
368
427
|
assert "WebSearch" in allowed
|
|
369
428
|
assert "WebFetch" in allowed
|
|
@@ -916,6 +975,68 @@ def test_skill_validate_tool_returns_post_change_contract(tmp_path, monkeypatch)
|
|
|
916
975
|
}
|
|
917
976
|
|
|
918
977
|
|
|
978
|
+
def test_wikilink_health_reports_dangling_lesson_and_skill_links(
|
|
979
|
+
tmp_path, monkeypatch,
|
|
980
|
+
):
|
|
981
|
+
pkg = _bootstrap(tmp_path, monkeypatch)
|
|
982
|
+
pkg["lessons"].append_lesson(
|
|
983
|
+
title="valid lesson",
|
|
984
|
+
body="Available target.",
|
|
985
|
+
source="shadow",
|
|
986
|
+
)
|
|
987
|
+
pkg["lessons"].append_lesson(
|
|
988
|
+
title="lesson source",
|
|
989
|
+
body="See [[valid-lesson]] and [[missing-lesson]].",
|
|
990
|
+
source="shadow",
|
|
991
|
+
)
|
|
992
|
+
_write_audit_skill(
|
|
993
|
+
pkg["skills_dir"], "skill-source",
|
|
994
|
+
"See [[valid-lesson]] and [[missing-skill]].",
|
|
995
|
+
)
|
|
996
|
+
conn = pkg["db"].get_db()
|
|
997
|
+
|
|
998
|
+
from threadkeeper.link_health import scan_wikilink_health
|
|
999
|
+
|
|
1000
|
+
result = scan_wikilink_health(conn)
|
|
1001
|
+
|
|
1002
|
+
assert result["summary"] == {
|
|
1003
|
+
"lessons_scanned": 2,
|
|
1004
|
+
"skills_scanned": 1,
|
|
1005
|
+
"references_scanned": 4,
|
|
1006
|
+
"dangling_references": 2,
|
|
1007
|
+
}
|
|
1008
|
+
assert result["dangling_references"] == [
|
|
1009
|
+
{
|
|
1010
|
+
"source_kind": "lesson",
|
|
1011
|
+
"source": "lesson-source",
|
|
1012
|
+
"target": "missing-lesson",
|
|
1013
|
+
},
|
|
1014
|
+
{
|
|
1015
|
+
"source_kind": "skill",
|
|
1016
|
+
"source": "skill-source",
|
|
1017
|
+
"target": "missing-skill",
|
|
1018
|
+
},
|
|
1019
|
+
]
|
|
1020
|
+
|
|
1021
|
+
|
|
1022
|
+
def test_wikilink_health_tool_exposes_checker_result(tmp_path, monkeypatch):
|
|
1023
|
+
pkg = _bootstrap(tmp_path, monkeypatch)
|
|
1024
|
+
pkg["lessons"].append_lesson(
|
|
1025
|
+
title="lesson source", body="See [[missing-target]].", source="shadow",
|
|
1026
|
+
)
|
|
1027
|
+
from threadkeeper._mcp import mcp
|
|
1028
|
+
|
|
1029
|
+
result = mcp._tool_manager._tools["wikilink_health"].fn()
|
|
1030
|
+
payload = json.loads(result)
|
|
1031
|
+
|
|
1032
|
+
assert payload["summary"]["dangling_references"] == 1
|
|
1033
|
+
assert payload["dangling_references"] == [{
|
|
1034
|
+
"source_kind": "lesson",
|
|
1035
|
+
"source": "lesson-source",
|
|
1036
|
+
"target": "missing-target",
|
|
1037
|
+
}]
|
|
1038
|
+
|
|
1039
|
+
|
|
919
1040
|
def test_curator_report_write_is_path_scoped_and_replaceable(
|
|
920
1041
|
tmp_path, monkeypatch,
|
|
921
1042
|
):
|
|
@@ -14,7 +14,7 @@ def test_daemon_health_is_additive_without_schema_version_bump(mp_with_cid):
|
|
|
14
14
|
db = pkg["db"]
|
|
15
15
|
conn = db.get_db()
|
|
16
16
|
conn.execute("DROP TABLE daemon_health")
|
|
17
|
-
conn.execute("PRAGMA user_version =
|
|
17
|
+
conn.execute(f"PRAGMA user_version = {db.CURRENT_SCHEMA_VERSION}")
|
|
18
18
|
conn.commit()
|
|
19
19
|
conn.close()
|
|
20
20
|
|
|
@@ -23,7 +23,9 @@ def test_daemon_health_is_additive_without_schema_version_bump(mp_with_cid):
|
|
|
23
23
|
|
|
24
24
|
conn = db.get_db()
|
|
25
25
|
try:
|
|
26
|
-
assert conn.execute("PRAGMA user_version").fetchone()[0] ==
|
|
26
|
+
assert conn.execute("PRAGMA user_version").fetchone()[0] == (
|
|
27
|
+
db.CURRENT_SCHEMA_VERSION
|
|
28
|
+
)
|
|
27
29
|
assert conn.execute(
|
|
28
30
|
"SELECT 1 FROM sqlite_master "
|
|
29
31
|
"WHERE type='table' AND name='daemon_health'"
|
|
@@ -103,23 +103,49 @@ def _add_evolve(conn, suggestion, rationale=None, applied=0, status="pending"):
|
|
|
103
103
|
|
|
104
104
|
def _seed_research(pkg, conn, text="- idea: adopt Y\n sources: https://ex.com\n"):
|
|
105
105
|
"""Make run_evolve_pass take the AUDIT branch on its next call: record a
|
|
106
|
-
prior research spawn so _last_spawn_phase()=='research', and
|
|
107
|
-
|
|
106
|
+
prior research spawn so _last_spawn_phase()=='research', and create a
|
|
107
|
+
fresh, accepted handoff that the audit phase may fence into its prompt."""
|
|
108
108
|
now = int(time.time())
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
109
|
+
pass_id, f = pkg["ed"]._authorize_research_handoff(
|
|
110
|
+
conn, now, _FAKE_CID,
|
|
111
|
+
)
|
|
112
|
+
persisted = text.rstrip() + "\n"
|
|
113
|
+
f.write_text(persisted, encoding="utf-8")
|
|
114
|
+
digest = pkg["ed"].hashlib.sha256(persisted.encode("utf-8")).hexdigest()
|
|
115
|
+
conn.execute(
|
|
116
|
+
"UPDATE evolve_research_handoffs SET status='accepted', "
|
|
117
|
+
"content_sha256=?, content_chars=?, completed_at=? WHERE pass_id=?",
|
|
118
|
+
(digest, len(persisted), now, pass_id),
|
|
119
|
+
)
|
|
113
120
|
conn.execute(
|
|
114
121
|
"INSERT INTO events (session_id, kind, target, summary, created_at) "
|
|
115
122
|
"VALUES (?, 'evolve_review_pass', ?, ?, ?)",
|
|
116
|
-
("s_prev", str(now), "spawned research
|
|
123
|
+
("s_prev", str(now), "spawned research pass=seeded ok task=tk pid=1",
|
|
117
124
|
now),
|
|
118
125
|
)
|
|
119
126
|
conn.commit()
|
|
120
127
|
return f, text
|
|
121
128
|
|
|
122
129
|
|
|
130
|
+
def _authorize_researcher(pkg, conn, now=None):
|
|
131
|
+
"""Create the parent/task state a real researcher handoff requires."""
|
|
132
|
+
now = int(time.time()) if now is None else int(now)
|
|
133
|
+
pass_id, target = pkg["ed"]._authorize_research_handoff(
|
|
134
|
+
conn, now, _FAKE_CID,
|
|
135
|
+
)
|
|
136
|
+
conn.execute(
|
|
137
|
+
"INSERT INTO tasks "
|
|
138
|
+
"(id, pid, parent_cid, spawned_cid, cwd, prompt, started_at, role, write_origin) "
|
|
139
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
140
|
+
(
|
|
141
|
+
f"tk_research_{pass_id[-6:]}", 0, "parent", _FAKE_CID,
|
|
142
|
+
str(target.parent), "research", now, "evolve_researcher", "evolve",
|
|
143
|
+
),
|
|
144
|
+
)
|
|
145
|
+
conn.commit()
|
|
146
|
+
return pass_id, target
|
|
147
|
+
|
|
148
|
+
|
|
123
149
|
def test_audit_prompt_uses_paginated_issue_dedup(tmp_path, monkeypatch):
|
|
124
150
|
pkg = _bootstrap(tmp_path, monkeypatch)
|
|
125
151
|
prompt = pkg["ed"].EVOLVE_AUDIT_PROMPT
|
|
@@ -475,7 +501,7 @@ def test_run_evolve_pass_force_spawns_research_first(
|
|
|
475
501
|
pkg = _bootstrap(tmp_path, monkeypatch)
|
|
476
502
|
calls = {}
|
|
477
503
|
import threadkeeper.tools.spawn as spawn_mod
|
|
478
|
-
monkeypatch.setattr(spawn_mod, "
|
|
504
|
+
monkeypatch.setattr(spawn_mod, "_spawn_impl",
|
|
479
505
|
lambda **kw: calls.update(kw) or "ok task=tk_ev pid=1")
|
|
480
506
|
|
|
481
507
|
out = pkg["ed"].run_evolve_pass(force=True)
|
|
@@ -513,7 +539,7 @@ def test_run_evolve_pass_below_min(tmp_path, monkeypatch):
|
|
|
513
539
|
_add_evolve(conn, "only one")
|
|
514
540
|
calls = {}
|
|
515
541
|
import threadkeeper.tools.spawn as spawn_mod
|
|
516
|
-
monkeypatch.setattr(spawn_mod, "
|
|
542
|
+
monkeypatch.setattr(spawn_mod, "_spawn_impl",
|
|
517
543
|
lambda **kw: calls.update(kw) or "ok task=tk_ev pid=1")
|
|
518
544
|
|
|
519
545
|
out = pkg["ed"].run_evolve_pass(force=True)
|
|
@@ -539,7 +565,7 @@ def test_run_evolve_pass_skips_legacy_backlog_until_interval(
|
|
|
539
565
|
calls = {}
|
|
540
566
|
import threadkeeper.tools.spawn as spawn_mod
|
|
541
567
|
monkeypatch.setattr(
|
|
542
|
-
spawn_mod, "
|
|
568
|
+
spawn_mod, "_spawn_impl",
|
|
543
569
|
lambda **kw: calls.update(kw) or "ok task=tk_ev pid=1",
|
|
544
570
|
)
|
|
545
571
|
|
|
@@ -558,7 +584,7 @@ def test_run_evolve_pass_research_phase_is_read_only(tmp_path, monkeypatch):
|
|
|
558
584
|
_add_evolve(conn, "suggestion alpha")
|
|
559
585
|
calls = {}
|
|
560
586
|
import threadkeeper.tools.spawn as spawn_mod
|
|
561
|
-
monkeypatch.setattr(spawn_mod, "
|
|
587
|
+
monkeypatch.setattr(spawn_mod, "_spawn_impl",
|
|
562
588
|
lambda **kw: calls.update(kw) or "ok task=tk_ev pid=1")
|
|
563
589
|
out = pkg["ed"].run_evolve_pass(force=True)
|
|
564
590
|
assert out.startswith("spawned research")
|
|
@@ -567,11 +593,128 @@ def test_run_evolve_pass_research_phase_is_read_only(tmp_path, monkeypatch):
|
|
|
567
593
|
# web research yes; bypass/shell/GitHub-write no
|
|
568
594
|
assert "WebSearch" in calls["extra_allowed_tools"]
|
|
569
595
|
assert "WebFetch" in calls["extra_allowed_tools"]
|
|
596
|
+
assert "Write" not in calls["extra_allowed_tools"]
|
|
597
|
+
assert "evolve_research_handoff" in calls["extra_allowed_tools"]
|
|
598
|
+
assert calls["allowed_tools_override"] == (
|
|
599
|
+
"mcp__thread-keeper__broadcast",
|
|
600
|
+
)
|
|
570
601
|
assert calls["permission_mode"] != "bypassPermissions"
|
|
571
602
|
assert "Bash" not in calls["extra_allowed_tools"]
|
|
572
603
|
assert "Edit" not in calls["extra_allowed_tools"]
|
|
573
604
|
assert "evolve_decide" not in calls["extra_allowed_tools"]
|
|
574
605
|
assert pkg["ed"]._last_evolve_ts(conn) > 0
|
|
606
|
+
handoff = conn.execute(
|
|
607
|
+
"SELECT owner_cid, status, target_path FROM evolve_research_handoffs"
|
|
608
|
+
).fetchone()
|
|
609
|
+
assert handoff["owner_cid"] == calls["child_cid_override"]
|
|
610
|
+
assert handoff["status"] == "pending"
|
|
611
|
+
assert Path(handoff["target_path"]).name.startswith("RESEARCH-")
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def test_research_handoff_accepts_only_registered_owner_and_target(
|
|
615
|
+
tmp_path, monkeypatch,
|
|
616
|
+
):
|
|
617
|
+
pkg = _bootstrap(tmp_path, monkeypatch)
|
|
618
|
+
conn = pkg["db"].get_db()
|
|
619
|
+
pass_id, target = _authorize_researcher(pkg, conn)
|
|
620
|
+
sentinel = tmp_path / "source.py"
|
|
621
|
+
sentinel.write_text("unchanged\n", encoding="utf-8")
|
|
622
|
+
monkeypatch.setenv("THREADKEEPER_SPAWNED_CHILD", "1")
|
|
623
|
+
monkeypatch.setenv("THREADKEEPER_WRITE_ORIGIN", "evolve")
|
|
624
|
+
|
|
625
|
+
out = _tool(pkg, "evolve_research_handoff")(
|
|
626
|
+
pass_id=pass_id,
|
|
627
|
+
content="- idea: scoped handoff\n sources: https://example.test\n",
|
|
628
|
+
)
|
|
629
|
+
|
|
630
|
+
persisted = "- idea: scoped handoff\n sources: https://example.test\n"
|
|
631
|
+
assert out.startswith("ok pass_id=")
|
|
632
|
+
assert target.read_text(encoding="utf-8") == persisted
|
|
633
|
+
assert sentinel.read_text(encoding="utf-8") == "unchanged\n"
|
|
634
|
+
row = conn.execute(
|
|
635
|
+
"SELECT status, content_chars, content_sha256 FROM evolve_research_handoffs "
|
|
636
|
+
"WHERE pass_id=?", (pass_id,),
|
|
637
|
+
).fetchone()
|
|
638
|
+
assert row["status"] == "accepted"
|
|
639
|
+
assert row["content_chars"] == len(persisted)
|
|
640
|
+
assert row["content_sha256"] == pkg["ed"].hashlib.sha256(
|
|
641
|
+
persisted.encode("utf-8")
|
|
642
|
+
).hexdigest()
|
|
643
|
+
assert pkg["ed"]._latest_research(int(time.time())) == (target, persisted)
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
def test_research_handoff_rejects_wrong_owner_and_malformed_content(
|
|
647
|
+
tmp_path, monkeypatch,
|
|
648
|
+
):
|
|
649
|
+
pkg = _bootstrap(tmp_path, monkeypatch)
|
|
650
|
+
conn = pkg["db"].get_db()
|
|
651
|
+
monkeypatch.setenv("THREADKEEPER_SPAWNED_CHILD", "1")
|
|
652
|
+
monkeypatch.setenv("THREADKEEPER_WRITE_ORIGIN", "evolve")
|
|
653
|
+
|
|
654
|
+
pass_id, target = _authorize_researcher(pkg, conn)
|
|
655
|
+
monkeypatch.setattr(pkg["ed"].identity, "_detect_self_cid", lambda: "other")
|
|
656
|
+
assert _tool(pkg, "evolve_research_handoff")(
|
|
657
|
+
pass_id=pass_id, content="- harmless\n",
|
|
658
|
+
) == "ERR research_handoff_not_authorized"
|
|
659
|
+
assert not target.exists()
|
|
660
|
+
assert conn.execute(
|
|
661
|
+
"SELECT status FROM evolve_research_handoffs WHERE pass_id=?", (pass_id,)
|
|
662
|
+
).fetchone()["status"] == "pending"
|
|
663
|
+
|
|
664
|
+
monkeypatch.setattr(pkg["ed"].identity, "_detect_self_cid", lambda: _FAKE_CID)
|
|
665
|
+
malformed_id, malformed_target = _authorize_researcher(pkg, conn)
|
|
666
|
+
out = _tool(pkg, "evolve_research_handoff")(
|
|
667
|
+
pass_id=malformed_id, content="bad\x00digest",
|
|
668
|
+
)
|
|
669
|
+
assert out == "ERR digest_malformed reason=malformed_nul"
|
|
670
|
+
malformed = conn.execute(
|
|
671
|
+
"SELECT status, failure FROM evolve_research_handoffs WHERE pass_id=?",
|
|
672
|
+
(malformed_id,),
|
|
673
|
+
).fetchone()
|
|
674
|
+
assert malformed["status"] == "failed"
|
|
675
|
+
assert malformed["failure"] == "malformed_nul"
|
|
676
|
+
assert not malformed_target.exists()
|
|
677
|
+
telemetry = conn.execute(
|
|
678
|
+
"SELECT summary FROM events WHERE kind=? AND summary LIKE ? "
|
|
679
|
+
"ORDER BY id DESC LIMIT 1",
|
|
680
|
+
(pkg["ed"].EVOLVE_RESEARCH_HANDOFF_KIND, f"pass_id={malformed_id}%"),
|
|
681
|
+
).fetchone()["summary"]
|
|
682
|
+
assert "status=failed reason=malformed_nul" in telemetry
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
def test_research_handoff_rejects_oversize_and_audit_rejects_tampering(
|
|
686
|
+
tmp_path, monkeypatch,
|
|
687
|
+
):
|
|
688
|
+
pkg = _bootstrap(tmp_path, monkeypatch)
|
|
689
|
+
conn = pkg["db"].get_db()
|
|
690
|
+
monkeypatch.setenv("THREADKEEPER_SPAWNED_CHILD", "1")
|
|
691
|
+
monkeypatch.setenv("THREADKEEPER_WRITE_ORIGIN", "evolve")
|
|
692
|
+
|
|
693
|
+
oversize_id, _ = _authorize_researcher(pkg, conn)
|
|
694
|
+
out = _tool(pkg, "evolve_research_handoff")(
|
|
695
|
+
pass_id=oversize_id,
|
|
696
|
+
content="x" * (pkg["ed"].EVOLVE_RESEARCH_MAX_CHARS + 1),
|
|
697
|
+
)
|
|
698
|
+
assert out == (
|
|
699
|
+
f"ERR digest_too_large max_chars={pkg['ed'].EVOLVE_RESEARCH_MAX_CHARS}"
|
|
700
|
+
)
|
|
701
|
+
assert conn.execute(
|
|
702
|
+
"SELECT status, failure FROM evolve_research_handoffs WHERE pass_id=?",
|
|
703
|
+
(oversize_id,),
|
|
704
|
+
).fetchone()["failure"] == "too_large"
|
|
705
|
+
|
|
706
|
+
pass_id, target = _authorize_researcher(pkg, conn)
|
|
707
|
+
assert _tool(pkg, "evolve_research_handoff")(
|
|
708
|
+
pass_id=pass_id, content="- trusted before replacement\n",
|
|
709
|
+
).startswith("ok pass_id=")
|
|
710
|
+
target.write_text("- replacement after handoff\n", encoding="utf-8")
|
|
711
|
+
assert pkg["ed"]._latest_research(int(time.time())) == (None, "")
|
|
712
|
+
tampered = conn.execute(
|
|
713
|
+
"SELECT status, failure FROM evolve_research_handoffs WHERE pass_id=?",
|
|
714
|
+
(pass_id,),
|
|
715
|
+
).fetchone()
|
|
716
|
+
assert tampered["status"] == "tampered"
|
|
717
|
+
assert tampered["failure"] == "sha256_mismatch"
|
|
575
718
|
|
|
576
719
|
|
|
577
720
|
def test_run_evolve_pass_audit_phase_no_web_consumes_fenced_research(
|
|
@@ -708,10 +851,12 @@ def test_web_research_and_privileged_write_never_cogranted(tmp_path, monkeypatch
|
|
|
708
851
|
_add_evolve(conn, "suggestion alpha")
|
|
709
852
|
captured = []
|
|
710
853
|
import threadkeeper.tools.spawn as spawn_mod
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
854
|
+
def capture(**kw):
|
|
855
|
+
captured.append(dict(kw))
|
|
856
|
+
return "ok task=tk_ev pid=1"
|
|
857
|
+
|
|
858
|
+
monkeypatch.setattr(spawn_mod, "spawn", capture)
|
|
859
|
+
monkeypatch.setattr(spawn_mod, "_spawn_impl", capture)
|
|
715
860
|
|
|
716
861
|
# Phase 1 records a real "spawned research" pass, so phase 2 takes the audit
|
|
717
862
|
# branch on the next forced call.
|
|
@@ -748,7 +893,7 @@ def test_run_evolve_pass_runs_reviewer_in_repo_root(tmp_path, monkeypatch):
|
|
|
748
893
|
monkeypatch.setattr(pkg["ed"], "_ensure_repo_ready", lambda: (repo, ""))
|
|
749
894
|
calls = {}
|
|
750
895
|
import threadkeeper.tools.spawn as spawn_mod
|
|
751
|
-
monkeypatch.setattr(spawn_mod, "
|
|
896
|
+
monkeypatch.setattr(spawn_mod, "_spawn_impl",
|
|
752
897
|
lambda **kw: calls.update(kw) or "ok task=tk_ev pid=1")
|
|
753
898
|
|
|
754
899
|
out = pkg["ed"].run_evolve_pass(force=True)
|
|
@@ -837,7 +982,7 @@ def test_run_evolve_pass_single_flight_lock_race(tmp_path, monkeypatch):
|
|
|
837
982
|
errors.append(e)
|
|
838
983
|
release_spawn.set()
|
|
839
984
|
|
|
840
|
-
monkeypatch.setattr(spawn_mod, "
|
|
985
|
+
monkeypatch.setattr(spawn_mod, "_spawn_impl", fake_spawn)
|
|
841
986
|
|
|
842
987
|
t = threading.Thread(target=run_pass)
|
|
843
988
|
t.start()
|