threadkeeper 0.17.0__tar.gz → 0.17.2__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.0 → threadkeeper-0.17.2}/PKG-INFO +28 -11
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/README.md +27 -10
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/pyproject.toml +1 -1
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_curator.py +100 -3
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_evolve_applier.py +64 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_host_main.py +26 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_identity.py +11 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_lessons.py +91 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_notify_daemon.py +35 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_shadow_review.py +1 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_skills.py +27 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_spawn_watchdog.py +4 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_tool_annotations.py +1 -1
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/adapters/codex.py +1 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/config.py +4 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/curator.py +183 -14
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/evolve_applier.py +58 -2
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/host.py +43 -2
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/identity.py +9 -2
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/lessons.py +57 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/notify.py +51 -9
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/shadow_review.py +7 -3
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/spawn_budget.py +8 -4
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/tools/dashboard.py +1 -1
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/tools/lessons.py +138 -19
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/tools/skills.py +41 -5
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/tools/threads.py +1 -0
- threadkeeper-0.17.2/threadkeeper/wikilinks.py +105 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper.egg-info/PKG-INFO +28 -11
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper.egg-info/SOURCES.txt +1 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/LICENSE +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/setup.cfg +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_adapters.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_agent_status.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_auto_update.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_backup.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_brief_footprint.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_brief_sections.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_candidate_reviewer.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_concepts.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_config_settings.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_config_watcher.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_core_memory.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_daemon_host_config.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_daemon_host_integration.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_daemon_liveness.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_daemon_state.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_dashboard.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_db_compact.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_db_contention.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_db_migration_fts.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_db_runtime.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_db_schema.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_delegated_search.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_dialectic.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_dialectic_feed_tools.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_dialectic_miner.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_dialectic_observation_resolve.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_dialectic_recompute.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_dialectic_tier.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_dialectic_validator.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_dialog_fts_external.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_egress_policy.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_embed_routing.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_embedding_generation.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_embedding_model_revision.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_embedding_storage_dedup.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_error_paths.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_eval_harness.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_evolve_apply_2.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_evolve_apply_3.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_evolve_daemon.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_extract_daemon.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_extract_dedup.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_forget.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_github_budget.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_github_safety.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_helpers.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_host_embed.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_host_env.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_host_wedged.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_hybrid_retrieval.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_i18n_multilang.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_ingest_cursor.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_ingest_denylist.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_ingest_secret_redaction.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_ingest_status.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_ingest_transactions.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_mcp_resources_prompts.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_memory_eval.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_memory_guard.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_memory_guard_host.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_menubar_app.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_missed_spawns.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_model_catalog.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_nudges.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_onnx_embeddings.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_panel.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_permissions.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_pickup.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_probe_daemon.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_process_health.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_release_workflows.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_retention.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_review_prompts.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_search_fts_punctuation.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_session_read_purity.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_single_flight_prompt_prefixes.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_skill_hint.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_skill_passive_tier.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_skill_tier.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_skill_updater.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_skill_use_parser.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_skill_watcher.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_spawn_budget.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_spawn_codex_stdin.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_spawn_config.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_spawn_hint.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_spawn_permission_gate.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_spawn_reap.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_spawn_slim.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_spawn_wrap.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_sync_capture.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_sync_identity.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_sync_migrate.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_sync_protocol.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_sync_reset_node.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_sync_transport.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_task_retention.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_task_spool.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_thin_session.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_thread_janitor.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_threads.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_tools_smoke.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_validate_threads.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_vec_search.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_verify_ingest.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/__init__.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/_mcp.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/_setup.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/_spawn_wrap.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/adapters/__init__.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/adapters/_hook_helpers.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/adapters/antigravity.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/adapters/base.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/adapters/claude_code.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/adapters/claude_desktop.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/adapters/copilot.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/adapters/vscode.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/agent_metadata.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/agent_status.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/assets/macos-agent-status/Info.plist +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/assets/macos-agent-status/README.md +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/assets/macos-agent-status/ThreadKeeperAgentStatus.swift +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/assets/macos-agent-status/build.sh +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/assets/macos-agent-status/install.sh +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/auto_update.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/backup.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/brief.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/candidate_reviewer.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/config_io.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/config_watcher.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/curator_snapshots.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/daemon_liveness.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/daemon_state.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/daemon_supervisor.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/db.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/dialectic_miner.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/dialectic_validator.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/egress.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/elicitation.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/embeddings.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/eval/__init__.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/eval/__main__.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/eval/harness.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/evolve_daemon.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/extract_daemon.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/forget.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/github_budget.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/github_safety.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/harvest.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/helpers.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/host_embed.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/i18n.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/ingest.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/memory_guard.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/menubar_app.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/migrate_embeddings.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/model_catalog.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/nudges.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/permissions.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/probe_daemon.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/process_health.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/retention.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/retrieval.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/review_prompts.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/search_proxy.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/server.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/skill_audit.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/skill_updater.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/skill_watcher.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/spawn_config.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/sync/__init__.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/sync/capture.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/sync/daemon.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/sync/identity.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/sync/migrate.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/sync/protocol.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/sync/reset_node.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/sync/server.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/task_spool.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/thread_janitor.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/tool_schemas.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/tools/__init__.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/tools/agent_status.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/tools/candidate_reviewer.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/tools/concepts.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/tools/config_watch.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/tools/consolidate.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/tools/core_memory.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/tools/correlation.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/tools/curator.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/tools/db_maintenance.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/tools/dialectic.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/tools/dialectic_feed.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/tools/dialog.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/tools/distill.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/tools/evolve_applier.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/tools/extract.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/tools/forget.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/tools/graph.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/tools/invariants.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/tools/memory_guard.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/tools/missed_spawns.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/tools/panel.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/tools/peers.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/tools/pickup.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/tools/probes.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/tools/process_health.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/tools/prompts.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/tools/resources.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/tools/session.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/tools/shadow_review.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/tools/spawn.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/tools/style.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/tools/sync.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/tools/validate.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/trash.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/verify_ingest.py +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper.egg-info/dependency_links.txt +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper.egg-info/entry_points.txt +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper.egg-info/requires.txt +0 -0
- {threadkeeper-0.17.0 → threadkeeper-0.17.2}/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.2
|
|
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
|
|
@@ -599,10 +599,13 @@ daemon even if a CLI drops the no-embeddings env. Idempotent through
|
|
|
599
599
|
`events.kind='shadow_review_pass'`.
|
|
600
600
|
|
|
601
601
|
Before writing memory, the observer now checks existing lessons/skills and
|
|
602
|
-
prefers patching broad skills.
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
602
|
+
prefers patching broad skills. `lesson_patch(slug, old_string, new_string)`
|
|
603
|
+
can correct one unique substring without reserializing a lesson. Shadow-origin
|
|
604
|
+
`lesson_append` is a compact fallback only: oversized new bodies are rejected,
|
|
605
|
+
though an existing same-slug long lesson may be corrected without increasing
|
|
606
|
+
its body size; near-duplicate slugs are blocked, and semantic body matches are
|
|
607
|
+
routed to the incumbent lesson or surfaced for curation instead of minting a
|
|
608
|
+
sibling lesson.
|
|
606
609
|
|
|
607
610
|
#### 3. Extract daemon
|
|
608
611
|
|
|
@@ -682,7 +685,12 @@ ThreadKeeper/Claude Code/Codex/Agent Skills compatibility, resource/link
|
|
|
682
685
|
findings, mirror hashes, exact-body duplicate groups, and lexical candidates
|
|
683
686
|
for semantic review. System and installed-plugin sources are resolved from
|
|
684
687
|
their read-only caches rather than misreported as missing mirrors; telemetry
|
|
685
|
-
rows with no real `SKILL.md` remain explicit orphans. The
|
|
688
|
+
rows with no real `SKILL.md` remain explicit orphans. The same inventory also
|
|
689
|
+
flags a dense lesson subtopic when at least
|
|
690
|
+
`THREADKEEPER_CURATOR_PROMOTION_MIN_LESSONS` lessons (default 3) share a pair
|
|
691
|
+
of meaningful title terms. A non-protected candidate must become one validated,
|
|
692
|
+
checklist-style canonical skill before its source lessons are retired; protected
|
|
693
|
+
clusters are left for human review. The child reads every
|
|
686
694
|
complete skill and relevant support file, performs current web research against
|
|
687
695
|
official docs and comparable
|
|
688
696
|
public skills, then writes numbered per-skill verdicts to
|
|
@@ -751,7 +759,11 @@ cross-process, while foreground/human deletes are unaffected. `mp_dashboard`
|
|
|
751
759
|
shows admitted and refused operations with `status=HIT` when the Curator reaches
|
|
752
760
|
the ceiling.
|
|
753
761
|
Before `lesson_remove` or `skill_manage(action='delete')` removes anything, it
|
|
754
|
-
also
|
|
762
|
+
also rewrites inbound `[[wikilinks]]` when a consolidation provides
|
|
763
|
+
`replacement_slug` / `replacement_name` for the surviving umbrella. A plain
|
|
764
|
+
removal returns its complete `dangling_wikilinks=` source list instead, so
|
|
765
|
+
those links can be repaired immediately. It writes a recovery artifact under
|
|
766
|
+
`<db dir>/curator/trash/`: lessons store
|
|
755
767
|
the exact sentinel section plus usage row, and skills store the full skill
|
|
756
768
|
directory plus usage row. Restore trash artifacts with `lesson_restore(slug=...)`
|
|
757
769
|
or `skill_manage(action='restore', name=...)`. Trash retention is bounded by
|
|
@@ -762,14 +774,14 @@ also the Curator apply worker: after the roadmap issue queue is empty, it looks
|
|
|
762
774
|
for the latest complete Curator report (`CURATOR_PASS_COMPLETE`) whose path and
|
|
763
775
|
current SHA-256 match an unapplied `curator_report_provenance` event, then
|
|
764
776
|
spawns an `evolve_applier` child to apply only safe, still-current memory
|
|
765
|
-
maintenance through `lesson_append` / `lesson_remove` / `skill_manage` /
|
|
777
|
+
maintenance through `lesson_append` / `lesson_patch` / `lesson_remove` / `skill_manage` /
|
|
766
778
|
`concept_manage`. It never touches `[PROTECTED]`,
|
|
767
779
|
foreground/user, pinned, or validated entries. Only after the child finishes
|
|
768
780
|
does it call `evolve_mark_curator_report_applied(...)` with the verified hash;
|
|
769
781
|
the mark rechecks that hash and prevents replaying the same report.
|
|
770
782
|
|
|
771
783
|
The shared lesson file has its own write serialization: `lesson_append`,
|
|
772
|
-
`lesson_remove`, and `lesson_restore` hold a blocking `fcntl.flock` on
|
|
784
|
+
`lesson_patch`, `lesson_remove`, and `lesson_restore` hold a blocking `fcntl.flock` on
|
|
773
785
|
`lessons.md.lock` around file creation/read/mutate/write, so foreground calls
|
|
774
786
|
and learning-loop children cannot last-writer-win over each other's sections.
|
|
775
787
|
|
|
@@ -891,7 +903,12 @@ throttling the roadmap loop.
|
|
|
891
903
|
Before any PR-producing reviewer/audit or applier child is spawned, the parent
|
|
892
904
|
checks the target checkout with `git status --porcelain --untracked-files=no`.
|
|
893
905
|
Tracked-file WIP records `skipped_dirty_worktree` and no child is dispatched;
|
|
894
|
-
|
|
906
|
+
the default managed checkout first preserves orphaned untracked files under
|
|
907
|
+
`~/.threadkeeper/evolve-recovery/untracked-*/` and removes them from the next
|
|
908
|
+
task’s tree. This prevents unrelated unfinished tests from blocking every PR
|
|
909
|
+
repair. Ignored files (including `.venv`) and explicit operator checkouts stay
|
|
910
|
+
in place; live writers prevent recovery, and backup failures block dispatch.
|
|
911
|
+
Each managed-checkout child fetches the
|
|
895
912
|
configured branch only to retrieve the configured immutable commit, then
|
|
896
913
|
prepares or resumes its deterministic local/remote feature branch from
|
|
897
914
|
`THREADKEEPER_EVOLVE_REPO_COMMIT`, never from the branch's moving tip. Retries
|
|
@@ -1374,7 +1391,7 @@ Three tools keep the memory tidy. `consolidate()` and `forget()` default to
|
|
|
1374
1391
|
`thread_janitor`), and
|
|
1375
1392
|
**outcomes** (what those loops actually produced — skills materialized,
|
|
1376
1393
|
tier promotions, candidate accept-vs-reject rate, plus knowledge-store
|
|
1377
|
-
mutation counts: `lesson_append` / `lesson_remove`,
|
|
1394
|
+
mutation counts: `lesson_append` (including patches) / `lesson_remove`,
|
|
1378
1395
|
`curator_report_applied`, `roadmap_issue_applied`,
|
|
1379
1396
|
`roadmap_issue_skipped`, `evolve_applied`, `dialectic_claim` /
|
|
1380
1397
|
`dialectic_supersede`). A `curator_net_change
|
|
@@ -555,10 +555,13 @@ daemon even if a CLI drops the no-embeddings env. Idempotent through
|
|
|
555
555
|
`events.kind='shadow_review_pass'`.
|
|
556
556
|
|
|
557
557
|
Before writing memory, the observer now checks existing lessons/skills and
|
|
558
|
-
prefers patching broad skills.
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
558
|
+
prefers patching broad skills. `lesson_patch(slug, old_string, new_string)`
|
|
559
|
+
can correct one unique substring without reserializing a lesson. Shadow-origin
|
|
560
|
+
`lesson_append` is a compact fallback only: oversized new bodies are rejected,
|
|
561
|
+
though an existing same-slug long lesson may be corrected without increasing
|
|
562
|
+
its body size; near-duplicate slugs are blocked, and semantic body matches are
|
|
563
|
+
routed to the incumbent lesson or surfaced for curation instead of minting a
|
|
564
|
+
sibling lesson.
|
|
562
565
|
|
|
563
566
|
#### 3. Extract daemon
|
|
564
567
|
|
|
@@ -638,7 +641,12 @@ ThreadKeeper/Claude Code/Codex/Agent Skills compatibility, resource/link
|
|
|
638
641
|
findings, mirror hashes, exact-body duplicate groups, and lexical candidates
|
|
639
642
|
for semantic review. System and installed-plugin sources are resolved from
|
|
640
643
|
their read-only caches rather than misreported as missing mirrors; telemetry
|
|
641
|
-
rows with no real `SKILL.md` remain explicit orphans. The
|
|
644
|
+
rows with no real `SKILL.md` remain explicit orphans. The same inventory also
|
|
645
|
+
flags a dense lesson subtopic when at least
|
|
646
|
+
`THREADKEEPER_CURATOR_PROMOTION_MIN_LESSONS` lessons (default 3) share a pair
|
|
647
|
+
of meaningful title terms. A non-protected candidate must become one validated,
|
|
648
|
+
checklist-style canonical skill before its source lessons are retired; protected
|
|
649
|
+
clusters are left for human review. The child reads every
|
|
642
650
|
complete skill and relevant support file, performs current web research against
|
|
643
651
|
official docs and comparable
|
|
644
652
|
public skills, then writes numbered per-skill verdicts to
|
|
@@ -707,7 +715,11 @@ cross-process, while foreground/human deletes are unaffected. `mp_dashboard`
|
|
|
707
715
|
shows admitted and refused operations with `status=HIT` when the Curator reaches
|
|
708
716
|
the ceiling.
|
|
709
717
|
Before `lesson_remove` or `skill_manage(action='delete')` removes anything, it
|
|
710
|
-
also
|
|
718
|
+
also rewrites inbound `[[wikilinks]]` when a consolidation provides
|
|
719
|
+
`replacement_slug` / `replacement_name` for the surviving umbrella. A plain
|
|
720
|
+
removal returns its complete `dangling_wikilinks=` source list instead, so
|
|
721
|
+
those links can be repaired immediately. It writes a recovery artifact under
|
|
722
|
+
`<db dir>/curator/trash/`: lessons store
|
|
711
723
|
the exact sentinel section plus usage row, and skills store the full skill
|
|
712
724
|
directory plus usage row. Restore trash artifacts with `lesson_restore(slug=...)`
|
|
713
725
|
or `skill_manage(action='restore', name=...)`. Trash retention is bounded by
|
|
@@ -718,14 +730,14 @@ also the Curator apply worker: after the roadmap issue queue is empty, it looks
|
|
|
718
730
|
for the latest complete Curator report (`CURATOR_PASS_COMPLETE`) whose path and
|
|
719
731
|
current SHA-256 match an unapplied `curator_report_provenance` event, then
|
|
720
732
|
spawns an `evolve_applier` child to apply only safe, still-current memory
|
|
721
|
-
maintenance through `lesson_append` / `lesson_remove` / `skill_manage` /
|
|
733
|
+
maintenance through `lesson_append` / `lesson_patch` / `lesson_remove` / `skill_manage` /
|
|
722
734
|
`concept_manage`. It never touches `[PROTECTED]`,
|
|
723
735
|
foreground/user, pinned, or validated entries. Only after the child finishes
|
|
724
736
|
does it call `evolve_mark_curator_report_applied(...)` with the verified hash;
|
|
725
737
|
the mark rechecks that hash and prevents replaying the same report.
|
|
726
738
|
|
|
727
739
|
The shared lesson file has its own write serialization: `lesson_append`,
|
|
728
|
-
`lesson_remove`, and `lesson_restore` hold a blocking `fcntl.flock` on
|
|
740
|
+
`lesson_patch`, `lesson_remove`, and `lesson_restore` hold a blocking `fcntl.flock` on
|
|
729
741
|
`lessons.md.lock` around file creation/read/mutate/write, so foreground calls
|
|
730
742
|
and learning-loop children cannot last-writer-win over each other's sections.
|
|
731
743
|
|
|
@@ -847,7 +859,12 @@ throttling the roadmap loop.
|
|
|
847
859
|
Before any PR-producing reviewer/audit or applier child is spawned, the parent
|
|
848
860
|
checks the target checkout with `git status --porcelain --untracked-files=no`.
|
|
849
861
|
Tracked-file WIP records `skipped_dirty_worktree` and no child is dispatched;
|
|
850
|
-
|
|
862
|
+
the default managed checkout first preserves orphaned untracked files under
|
|
863
|
+
`~/.threadkeeper/evolve-recovery/untracked-*/` and removes them from the next
|
|
864
|
+
task’s tree. This prevents unrelated unfinished tests from blocking every PR
|
|
865
|
+
repair. Ignored files (including `.venv`) and explicit operator checkouts stay
|
|
866
|
+
in place; live writers prevent recovery, and backup failures block dispatch.
|
|
867
|
+
Each managed-checkout child fetches the
|
|
851
868
|
configured branch only to retrieve the configured immutable commit, then
|
|
852
869
|
prepares or resumes its deterministic local/remote feature branch from
|
|
853
870
|
`THREADKEEPER_EVOLVE_REPO_COMMIT`, never from the branch's moving tip. Retries
|
|
@@ -1330,7 +1347,7 @@ Three tools keep the memory tidy. `consolidate()` and `forget()` default to
|
|
|
1330
1347
|
`thread_janitor`), and
|
|
1331
1348
|
**outcomes** (what those loops actually produced — skills materialized,
|
|
1332
1349
|
tier promotions, candidate accept-vs-reject rate, plus knowledge-store
|
|
1333
|
-
mutation counts: `lesson_append` / `lesson_remove`,
|
|
1350
|
+
mutation counts: `lesson_append` (including patches) / `lesson_remove`,
|
|
1334
1351
|
`curator_report_applied`, `roadmap_issue_applied`,
|
|
1335
1352
|
`roadmap_issue_skipped`, `evolve_applied`, `dialectic_claim` /
|
|
1336
1353
|
`dialectic_supersede`). A `curator_net_change
|
|
@@ -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.2"
|
|
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" }]
|
|
@@ -51,6 +51,7 @@ def _bootstrap(
|
|
|
51
51
|
monkeypatch,
|
|
52
52
|
interval="0",
|
|
53
53
|
min_lessons="3",
|
|
54
|
+
promotion_min_lessons="3",
|
|
54
55
|
destructive=None,
|
|
55
56
|
retention=None,
|
|
56
57
|
max_destructive=None,
|
|
@@ -70,6 +71,7 @@ def _bootstrap(
|
|
|
70
71
|
"THREADKEEPER_CURATOR_INTERVAL_S": interval,
|
|
71
72
|
"THREADKEEPER_CURATOR_MANAGE_FOREGROUND_SKILLS": "0",
|
|
72
73
|
"THREADKEEPER_CURATOR_MIN_LESSONS": min_lessons,
|
|
74
|
+
"THREADKEEPER_CURATOR_PROMOTION_MIN_LESSONS": promotion_min_lessons,
|
|
73
75
|
"THREADKEEPER_CURATOR_REPORTS_DIR": str(tmp_path / "curator"),
|
|
74
76
|
"THREADKEEPER_LESSONS": str(tmp_path / "lessons.md"),
|
|
75
77
|
"THREADKEEPER_TASK_LOG_DIR": str(tmp_path / "tasks"),
|
|
@@ -178,6 +180,61 @@ def test_collect_inventory_counts_lessons_and_skills(tmp_path, monkeypatch):
|
|
|
178
180
|
assert "STALE LESSONS (dry-run decay ranking)" in dump
|
|
179
181
|
|
|
180
182
|
|
|
183
|
+
def test_dense_lesson_cluster_crosses_promotion_threshold(tmp_path, monkeypatch):
|
|
184
|
+
pkg = _bootstrap(tmp_path, monkeypatch, write_origin="shadow_review")
|
|
185
|
+
for title in (
|
|
186
|
+
"curator snapshot protects lesson rollback",
|
|
187
|
+
"curator snapshot keeps lesson evidence",
|
|
188
|
+
):
|
|
189
|
+
pkg["lessons"].append_lesson(title=title, body="procedure", source="shadow")
|
|
190
|
+
|
|
191
|
+
conn = pkg["db"].get_db()
|
|
192
|
+
candidates = pkg["curator"]._detect_lesson_promotion_candidates(
|
|
193
|
+
list(pkg["lessons"].iter_lessons()),
|
|
194
|
+
pkg["lessons"].lesson_usage_map(conn),
|
|
195
|
+
)
|
|
196
|
+
assert candidates == []
|
|
197
|
+
|
|
198
|
+
pkg["lessons"].append_lesson(
|
|
199
|
+
title="curator snapshot restores lesson recovery",
|
|
200
|
+
body="procedure",
|
|
201
|
+
source="shadow",
|
|
202
|
+
)
|
|
203
|
+
candidates = pkg["curator"]._detect_lesson_promotion_candidates(
|
|
204
|
+
list(pkg["lessons"].iter_lessons()),
|
|
205
|
+
pkg["lessons"].lesson_usage_map(conn),
|
|
206
|
+
)
|
|
207
|
+
assert len(candidates) == 1
|
|
208
|
+
candidate = candidates[0]
|
|
209
|
+
assert candidate.topic_terms == ("curator", "snapshot")
|
|
210
|
+
assert candidate.decision == "PROMOTE_TO_SKILL"
|
|
211
|
+
assert candidate.protected_slugs == ()
|
|
212
|
+
assert candidate.lesson_slugs == (
|
|
213
|
+
"curator-snapshot-keeps-lesson-evidence",
|
|
214
|
+
"curator-snapshot-protects-lesson-rollback",
|
|
215
|
+
"curator-snapshot-restores-lesson-recovery",
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def test_protected_dense_cluster_requires_human_review(tmp_path, monkeypatch):
|
|
220
|
+
pkg = _bootstrap(tmp_path, monkeypatch)
|
|
221
|
+
for title in (
|
|
222
|
+
"curator snapshot protects lesson rollback",
|
|
223
|
+
"curator snapshot keeps lesson evidence",
|
|
224
|
+
"curator snapshot restores lesson recovery",
|
|
225
|
+
):
|
|
226
|
+
pkg["lessons"].append_lesson(title=title, body="procedure", source="T123")
|
|
227
|
+
|
|
228
|
+
conn = pkg["db"].get_db()
|
|
229
|
+
candidates = pkg["curator"]._detect_lesson_promotion_candidates(
|
|
230
|
+
list(pkg["lessons"].iter_lessons()),
|
|
231
|
+
pkg["lessons"].lesson_usage_map(conn),
|
|
232
|
+
)
|
|
233
|
+
assert len(candidates) == 1
|
|
234
|
+
assert candidates[0].decision == "HUMAN_REVIEW"
|
|
235
|
+
assert candidates[0].protected_slugs == candidates[0].lesson_slugs
|
|
236
|
+
|
|
237
|
+
|
|
181
238
|
def test_collect_inventory_marks_legacy_and_unknown_skills_protected(
|
|
182
239
|
tmp_path, monkeypatch,
|
|
183
240
|
):
|
|
@@ -293,11 +350,13 @@ def test_run_curator_pass_spawns_when_threshold_met(tmp_path, monkeypatch):
|
|
|
293
350
|
assert "lesson-one" in kw["prompt"]
|
|
294
351
|
assert "lesson-two" in kw["prompt"]
|
|
295
352
|
# Scoped toolset — destructive default (the new default) includes
|
|
296
|
-
# lesson_append / lesson_remove / skill_manage, but never
|
|
353
|
+
# lesson_append / lesson_patch / lesson_remove / skill_manage, but never
|
|
354
|
+
# shell or spawn.
|
|
297
355
|
allowed = kw["extra_allowed_tools"]
|
|
298
356
|
assert "lesson_list" in allowed
|
|
299
357
|
assert "lesson_get" in allowed
|
|
300
358
|
assert "lesson_append" in allowed
|
|
359
|
+
assert "lesson_patch" in allowed
|
|
301
360
|
assert "lesson_remove" in allowed
|
|
302
361
|
assert "skill_manage" in allowed
|
|
303
362
|
assert "evolve_format" in allowed
|
|
@@ -319,6 +378,42 @@ def test_run_curator_pass_spawns_when_threshold_met(tmp_path, monkeypatch):
|
|
|
319
378
|
assert "THREADKEEPER_CURATOR_SNAPSHOT_DIR" not in os.environ
|
|
320
379
|
|
|
321
380
|
|
|
381
|
+
def test_run_curator_pass_hands_dense_cluster_to_skill_promotion(
|
|
382
|
+
tmp_path, monkeypatch,
|
|
383
|
+
):
|
|
384
|
+
pkg = _bootstrap(
|
|
385
|
+
tmp_path,
|
|
386
|
+
monkeypatch,
|
|
387
|
+
min_lessons="3",
|
|
388
|
+
write_origin="shadow_review",
|
|
389
|
+
)
|
|
390
|
+
for title in (
|
|
391
|
+
"curator snapshot protects lesson rollback",
|
|
392
|
+
"curator snapshot keeps lesson evidence",
|
|
393
|
+
"curator snapshot restores lesson recovery",
|
|
394
|
+
):
|
|
395
|
+
pkg["lessons"].append_lesson(title=title, body="procedure", source="shadow")
|
|
396
|
+
|
|
397
|
+
import threadkeeper.tools.spawn as spawn_mod
|
|
398
|
+
captured: list[dict] = []
|
|
399
|
+
|
|
400
|
+
def fake_spawn(**kwargs):
|
|
401
|
+
captured.append(kwargs)
|
|
402
|
+
return "spawn task_id=fake-promotion-curator pid=0"
|
|
403
|
+
|
|
404
|
+
monkeypatch.setattr(spawn_mod, "spawn", fake_spawn)
|
|
405
|
+
|
|
406
|
+
out = pkg["curator"].run_curator_pass(force=True)
|
|
407
|
+
assert "fake-promotion-curator" in out
|
|
408
|
+
assert len(captured) == 1
|
|
409
|
+
prompt = captured[0]["prompt"]
|
|
410
|
+
assert "LESSON CLUSTER PROMOTION CANDIDATES (n=1)" in prompt
|
|
411
|
+
assert "PROMOTE_TO_SKILL: topic=curator snapshot lesson_count=3" in prompt
|
|
412
|
+
assert "Create one new, clearly named canonical skill" in prompt
|
|
413
|
+
assert "## Retired lessons" in prompt
|
|
414
|
+
assert "Only after it passes may you call" in prompt
|
|
415
|
+
|
|
416
|
+
|
|
322
417
|
def test_run_curator_pass_batches_large_inventory_without_oversize_prompt(
|
|
323
418
|
tmp_path, monkeypatch,
|
|
324
419
|
):
|
|
@@ -1089,6 +1184,7 @@ def test_destructive_mode_widens_allowed_tools(tmp_path, monkeypatch):
|
|
|
1089
1184
|
# Destructive mode → widened toolset (incl. lesson_remove for prune/consolidate)
|
|
1090
1185
|
assert "skill_manage" in allowed
|
|
1091
1186
|
assert "lesson_append" in allowed
|
|
1187
|
+
assert "lesson_patch" in allowed
|
|
1092
1188
|
assert "lesson_remove" in allowed
|
|
1093
1189
|
assert "evolve_format" in allowed
|
|
1094
1190
|
# Prompt explicitly flips into destructive mode
|
|
@@ -1102,8 +1198,9 @@ def test_advisory_mode_excludes_destructive_tools(
|
|
|
1102
1198
|
tmp_path, monkeypatch,
|
|
1103
1199
|
):
|
|
1104
1200
|
"""With THREADKEEPER_CURATOR_DESTRUCTIVE=0 the curator child is read-only:
|
|
1105
|
-
prompt forbids skill_manage/lesson_append/lesson_remove and
|
|
1106
|
-
allowed_tools. (Destructive is the default, so advisory is
|
|
1201
|
+
prompt forbids skill_manage/lesson_append/lesson_patch/lesson_remove and
|
|
1202
|
+
they aren't in allowed_tools. (Destructive is the default, so advisory is
|
|
1203
|
+
now opt-in.)"""
|
|
1107
1204
|
monkeypatch.setenv("THREADKEEPER_CURATOR_DESTRUCTIVE", "0")
|
|
1108
1205
|
pkg = _bootstrap(tmp_path, monkeypatch, min_lessons="2")
|
|
1109
1206
|
pkg["lessons"].append_lesson(
|
|
@@ -2540,6 +2540,9 @@ def test_ensure_repo_ready_recovers_dirty_managed_base_before_refresh(
|
|
|
2540
2540
|
(repo / "shared.txt").write_text(
|
|
2541
2541
|
"issue implementation left on main\n", encoding="utf-8"
|
|
2542
2542
|
)
|
|
2543
|
+
(repo / "tests").mkdir()
|
|
2544
|
+
orphan = repo / "tests" / "test_other_issue.py"
|
|
2545
|
+
orphan.write_text("raise AssertionError('unrelated unfinished feature')\n")
|
|
2543
2546
|
monkeypatch.setattr(
|
|
2544
2547
|
pkg["ea"], "_prs_for_head_branch",
|
|
2545
2548
|
lambda *args, **kwargs: (_ for _ in ()).throw(
|
|
@@ -2568,6 +2571,67 @@ def test_ensure_repo_ready_recovers_dirty_managed_base_before_refresh(
|
|
|
2568
2571
|
).fetchone()
|
|
2569
2572
|
assert row["target"] == "managed_repo_refresh"
|
|
2570
2573
|
assert "recovered_abandoned_wip branch=main" in row["summary"]
|
|
2574
|
+
assert not orphan.exists()
|
|
2575
|
+
saved = list((tmp_path / "evolve-recovery").glob("untracked-*/tests/*.py"))
|
|
2576
|
+
assert len(saved) == 1
|
|
2577
|
+
assert "unrelated unfinished feature" in saved[0].read_text()
|
|
2578
|
+
|
|
2579
|
+
|
|
2580
|
+
@pytest.mark.parametrize("mode", ["recover", "explicit", "live", "copy_error"])
|
|
2581
|
+
def test_untracked_recovery_preserves_work_and_isolates_next_validation(
|
|
2582
|
+
tmp_path, monkeypatch, mode,
|
|
2583
|
+
):
|
|
2584
|
+
pkg = _bootstrap(tmp_path, monkeypatch, pin_repo=False)
|
|
2585
|
+
ea = pkg["ea"]
|
|
2586
|
+
repo = ea._managed_repo_dir()
|
|
2587
|
+
subprocess.run(["git", "init", str(repo)], check=True, capture_output=True)
|
|
2588
|
+
# Use Git's actual ignore rules, including filenames with spaces/newlines.
|
|
2589
|
+
(repo / ".git" / "info" / "exclude").write_text(".venv/\n")
|
|
2590
|
+
(repo / ".venv").mkdir()
|
|
2591
|
+
runtime = repo / ".venv" / "runtime"
|
|
2592
|
+
runtime.write_text("keep runtime")
|
|
2593
|
+
(repo / "tests").mkdir()
|
|
2594
|
+
source = repo / "tests" / "test_orphan with\nnewline.py"
|
|
2595
|
+
source.write_text("unfinished work\n")
|
|
2596
|
+
outside = tmp_path / "outside.txt"
|
|
2597
|
+
outside.write_text("external target")
|
|
2598
|
+
link = repo / "orphan-link"
|
|
2599
|
+
link.symlink_to(outside)
|
|
2600
|
+
if mode == "explicit":
|
|
2601
|
+
monkeypatch.setattr(ea, "EVOLVE_REPO_ROOT", str(repo))
|
|
2602
|
+
if mode == "live":
|
|
2603
|
+
monkeypatch.setattr(ea, "_running_git_writer_children", lambda conn: ["live"])
|
|
2604
|
+
if mode == "copy_error":
|
|
2605
|
+
def fail_copy(*args, **kwargs):
|
|
2606
|
+
raise OSError("backup unavailable")
|
|
2607
|
+
monkeypatch.setattr(ea.shutil, "copy2", fail_copy)
|
|
2608
|
+
|
|
2609
|
+
result = pkg["orig"]["_git_worktree_precondition"](
|
|
2610
|
+
pkg["db"].get_db(), repo, "conflict_repair"
|
|
2611
|
+
)
|
|
2612
|
+
|
|
2613
|
+
assert runtime.read_text() == "keep runtime"
|
|
2614
|
+
assert outside.read_text() == "external target"
|
|
2615
|
+
if mode == "recover":
|
|
2616
|
+
assert result == ""
|
|
2617
|
+
assert not source.exists() and not link.is_symlink()
|
|
2618
|
+
backups = list((tmp_path / "evolve-recovery").glob("untracked-*"))
|
|
2619
|
+
assert len(backups) == 1
|
|
2620
|
+
assert backups[0].stat().st_mode & 0o777 == 0o700
|
|
2621
|
+
assert (backups[0] / source.relative_to(repo)).read_text() == "unfinished work\n"
|
|
2622
|
+
assert (backups[0] / "orphan-link").is_symlink()
|
|
2623
|
+
assert pkg["orig"]["_git_worktree_precondition"](
|
|
2624
|
+
pkg["db"].get_db(), repo, "conflict_repair"
|
|
2625
|
+
) == ""
|
|
2626
|
+
assert len(list((tmp_path / "evolve-recovery").glob("untracked-*"))) == 1
|
|
2627
|
+
else:
|
|
2628
|
+
assert source.read_text() == "unfinished work\n" and link.is_symlink()
|
|
2629
|
+
if mode == "live":
|
|
2630
|
+
assert result.startswith("evolve_git_writer_running")
|
|
2631
|
+
elif mode == "copy_error":
|
|
2632
|
+
assert "untracked_quarantine_failed" in result
|
|
2633
|
+
else:
|
|
2634
|
+
assert result == ""
|
|
2571
2635
|
|
|
2572
2636
|
|
|
2573
2637
|
def test_managed_refresh_checks_live_writer_before_dirty_recovery(
|
|
@@ -36,3 +36,29 @@ def test_serve_wires_local_encoder(monkeypatch, tmp_path):
|
|
|
36
36
|
assert seen["sock"] == config.HOST_SOCK_PATH
|
|
37
37
|
# the wired encoder delegates to embeddings._encode
|
|
38
38
|
assert callable(seen["enc"])
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_wedge_deadline_self_heal(monkeypatch, tmp_path):
|
|
42
|
+
host = _reimport(monkeypatch, tmp_path)
|
|
43
|
+
f = host._wedge_deadline_passed
|
|
44
|
+
# no starvation streak → never heals
|
|
45
|
+
assert f(None, 1000.0, 600.0) is False
|
|
46
|
+
# streak shorter than the window → hold on
|
|
47
|
+
assert f(1000.0, 1000.0 + 599.0, 600.0) is False
|
|
48
|
+
# streak reached the window → self-heal
|
|
49
|
+
assert f(1000.0, 1000.0 + 600.0, 600.0) is True
|
|
50
|
+
assert f(1000.0, 1000.0 + 900.0, 600.0) is True
|
|
51
|
+
# feature disabled (wedge_s <= 0) → never heals even when long-starved
|
|
52
|
+
assert f(1000.0, 1000.0 + 100000.0, 0.0) is False
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_heartbeat_returns_bool(monkeypatch, tmp_path):
|
|
56
|
+
host = _reimport(monkeypatch, tmp_path)
|
|
57
|
+
import threadkeeper.db as db
|
|
58
|
+
monkeypatch.setattr(db, "run_write", lambda *a, **k: None)
|
|
59
|
+
monkeypatch.setattr(host, "_heartbeat", host._heartbeat) # ensure real fn
|
|
60
|
+
assert host._heartbeat() is True
|
|
61
|
+
def _boom(*a, **k):
|
|
62
|
+
raise RuntimeError("wedged")
|
|
63
|
+
monkeypatch.setattr(db, "run_write", _boom)
|
|
64
|
+
assert host._heartbeat() is False
|
|
@@ -13,6 +13,17 @@ from __future__ import annotations
|
|
|
13
13
|
|
|
14
14
|
import re
|
|
15
15
|
|
|
16
|
+
import pytest
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_emit_requires_session_setup(fresh_mp):
|
|
20
|
+
"""A forgotten session setup must fail instead of dropping telemetry."""
|
|
21
|
+
identity = fresh_mp["identity"]
|
|
22
|
+
conn = fresh_mp["db"].get_db()
|
|
23
|
+
|
|
24
|
+
with pytest.raises(RuntimeError, match="requires session setup"):
|
|
25
|
+
identity._emit(conn, "test_missing_session")
|
|
26
|
+
|
|
16
27
|
|
|
17
28
|
def test_session_id_set_after_ensure_session(fresh_mp):
|
|
18
29
|
identity = fresh_mp["identity"]
|
|
@@ -205,6 +205,40 @@ def test_mcp_lesson_append_validates_inputs(tmp_path, monkeypatch):
|
|
|
205
205
|
assert out.startswith("ok slug=ok-one")
|
|
206
206
|
|
|
207
207
|
|
|
208
|
+
def test_mcp_lesson_patch_replaces_one_unique_substring(tmp_path, monkeypatch):
|
|
209
|
+
pkg = _bootstrap(tmp_path, monkeypatch)
|
|
210
|
+
la = _tool(pkg, "lesson_append")
|
|
211
|
+
lp = _tool(pkg, "lesson_patch")
|
|
212
|
+
la(
|
|
213
|
+
title="retry strategy",
|
|
214
|
+
summary="Retry only transient failures.",
|
|
215
|
+
body=(
|
|
216
|
+
"Use equal jitter for transient errors; do not retry errors "
|
|
217
|
+
"indefinitely."
|
|
218
|
+
),
|
|
219
|
+
source="shadow",
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
out = lp(
|
|
223
|
+
slug="retry-strategy",
|
|
224
|
+
old_string="equal jitter",
|
|
225
|
+
new_string="decorrelated jitter",
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
assert out.startswith("ok slug=retry-strategy")
|
|
229
|
+
section = pkg["path"].read_text()
|
|
230
|
+
assert "decorrelated jitter" in section
|
|
231
|
+
assert "equal jitter" not in section
|
|
232
|
+
assert "source=shadow" in section
|
|
233
|
+
assert "Retry only transient failures." in section
|
|
234
|
+
assert lp(
|
|
235
|
+
slug="retry-strategy", old_string="missing", new_string="x",
|
|
236
|
+
) == "ERR old_string_not_found"
|
|
237
|
+
assert lp(
|
|
238
|
+
slug="retry-strategy", old_string="errors", new_string="x",
|
|
239
|
+
).startswith("ERR old_string_ambiguous")
|
|
240
|
+
|
|
241
|
+
|
|
208
242
|
def test_shadow_lesson_append_rejects_overlong_body(tmp_path, monkeypatch):
|
|
209
243
|
pkg = _bootstrap(tmp_path, monkeypatch)
|
|
210
244
|
la = _tool(pkg, "lesson_append")
|
|
@@ -214,6 +248,28 @@ def test_shadow_lesson_append_rejects_overlong_body(tmp_path, monkeypatch):
|
|
|
214
248
|
assert "max=450" in out
|
|
215
249
|
|
|
216
250
|
|
|
251
|
+
def test_shadow_lesson_append_allows_non_growing_same_slug_repair(
|
|
252
|
+
tmp_path, monkeypatch,
|
|
253
|
+
):
|
|
254
|
+
pkg = _bootstrap(tmp_path, monkeypatch, write_origin="shadow_review")
|
|
255
|
+
la = _tool(pkg, "lesson_append")
|
|
256
|
+
original = "word " * 451
|
|
257
|
+
repaired = "rule " + "word " * 450
|
|
258
|
+
pkg["lessons"].append_lesson(
|
|
259
|
+
title="long shadow lesson", body=original, source="shadow",
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
out = la(title="long shadow lesson", body=repaired, source="shadow")
|
|
263
|
+
|
|
264
|
+
assert out.startswith("ok slug=long-shadow-lesson")
|
|
265
|
+
assert "rule" in pkg["path"].read_text()
|
|
266
|
+
assert la(
|
|
267
|
+
title="long shadow lesson",
|
|
268
|
+
body="longer " + "word " * 450,
|
|
269
|
+
source="shadow",
|
|
270
|
+
).startswith("ERR shadow_lesson_too_long")
|
|
271
|
+
|
|
272
|
+
|
|
217
273
|
def test_shadow_lesson_append_rejects_near_duplicate_slug(
|
|
218
274
|
tmp_path, monkeypatch,
|
|
219
275
|
):
|
|
@@ -477,6 +533,41 @@ def test_mcp_lesson_remove_deletes_nonprotected_section(tmp_path, monkeypatch):
|
|
|
477
533
|
)
|
|
478
534
|
|
|
479
535
|
|
|
536
|
+
def test_lesson_consolidation_rewrites_inbound_wikilinks(tmp_path, monkeypatch):
|
|
537
|
+
pkg = _bootstrap(tmp_path, monkeypatch, write_origin="shadow_review")
|
|
538
|
+
la = _tool(pkg, "lesson_append")
|
|
539
|
+
lr = _tool(pkg, "lesson_remove")
|
|
540
|
+
la(title="umbrella rule", body="new canonical rule", source="shadow")
|
|
541
|
+
la(title="retired rule", body="old duplicate rule", source="shadow")
|
|
542
|
+
la(
|
|
543
|
+
title="consumer rule",
|
|
544
|
+
body="See [[retired-rule]], [[retired-rule|this rule]], and "
|
|
545
|
+
"[[retired-rule#details]].",
|
|
546
|
+
source="shadow",
|
|
547
|
+
)
|
|
548
|
+
|
|
549
|
+
out = lr(slug="retired-rule", replacement_slug="umbrella rule")
|
|
550
|
+
|
|
551
|
+
assert out == "ok removed=retired-rule inbound_rewritten=lesson:consumer-rule"
|
|
552
|
+
consumer = _tool(pkg, "lesson_get")(slug="consumer-rule")
|
|
553
|
+
assert "[[umbrella-rule]]" in consumer
|
|
554
|
+
assert "[[umbrella-rule|this rule]]" in consumer
|
|
555
|
+
assert "[[umbrella-rule#details]]" in consumer
|
|
556
|
+
assert "[[retired-rule" not in consumer
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def test_lesson_remove_reports_dangling_wikilink_sources(tmp_path, monkeypatch):
|
|
560
|
+
pkg = _bootstrap(tmp_path, monkeypatch, write_origin="shadow_review")
|
|
561
|
+
la = _tool(pkg, "lesson_append")
|
|
562
|
+
lr = _tool(pkg, "lesson_remove")
|
|
563
|
+
la(title="retired rule", body="old duplicate rule", source="shadow")
|
|
564
|
+
la(title="consumer rule", body="See [[retired-rule]].", source="shadow")
|
|
565
|
+
|
|
566
|
+
out = lr(slug="retired-rule")
|
|
567
|
+
|
|
568
|
+
assert out == "ok removed=retired-rule dangling_wikilinks=lesson:consumer-rule"
|
|
569
|
+
|
|
570
|
+
|
|
480
571
|
def test_mcp_lesson_restore_recreates_original_bytes(tmp_path, monkeypatch):
|
|
481
572
|
pkg = _bootstrap(tmp_path, monkeypatch, write_origin="shadow_review")
|
|
482
573
|
la = _tool(pkg, "lesson_append")
|
|
@@ -131,6 +131,41 @@ def test_timeout_and_zero_exit_children_ignored(tmp_path, monkeypatch):
|
|
|
131
131
|
assert out == "ok fired=0", out
|
|
132
132
|
|
|
133
133
|
|
|
134
|
+
def test_dead_child_returncode_null_with_fatal_log_fires(tmp_path, monkeypatch, caplog):
|
|
135
|
+
# A child reaped after the DB writer wedged closes its row with the exit
|
|
136
|
+
# code lost (return_code NULL). It must still alert when the captured log
|
|
137
|
+
# shows a fatal degradation signature — the quota-exhaustion case that
|
|
138
|
+
# first exposed the gap.
|
|
139
|
+
m = _bootstrap(tmp_path, monkeypatch)
|
|
140
|
+
notify, db = m["notify"], m["db"]
|
|
141
|
+
conn = db.get_db()
|
|
142
|
+
assert notify.run_notify_pass(force=True) == "seed"
|
|
143
|
+
(tmp_path / "tasks" / "child-q.log").write_text(
|
|
144
|
+
"booting\nYou have exceeded your monthly quota\n")
|
|
145
|
+
_task(conn, "child-q", rc=None, role="candidate_reviewer",
|
|
146
|
+
ended_at=int(time.time()))
|
|
147
|
+
with caplog.at_level(logging.WARNING, logger="threadkeeper.notify"):
|
|
148
|
+
out = notify.run_notify_pass(force=True)
|
|
149
|
+
assert out == "ok fired=1", out
|
|
150
|
+
assert "candidate_reviewer child died (rc=unknown)" in caplog.text
|
|
151
|
+
assert "exceeded your monthly quota" in caplog.text
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def test_dead_child_returncode_null_clean_log_ignored(tmp_path, monkeypatch):
|
|
155
|
+
# return_code NULL with no failure evidence in the log is indistinguishable
|
|
156
|
+
# from a clean completion — must NOT fire (no false positives).
|
|
157
|
+
m = _bootstrap(tmp_path, monkeypatch)
|
|
158
|
+
notify, db = m["notify"], m["db"]
|
|
159
|
+
conn = db.get_db()
|
|
160
|
+
assert notify.run_notify_pass(force=True) == "seed"
|
|
161
|
+
(tmp_path / "tasks" / "child-clean.log").write_text(
|
|
162
|
+
"starting\nall done, wrote 2 notes\n")
|
|
163
|
+
_task(conn, "child-clean", rc=None, role="curator",
|
|
164
|
+
ended_at=int(time.time()))
|
|
165
|
+
out = notify.run_notify_pass(force=True)
|
|
166
|
+
assert out == "ok fired=0", out
|
|
167
|
+
|
|
168
|
+
|
|
134
169
|
# ── cooldown / de-dup ───────────────────────────────────────────────────────
|
|
135
170
|
|
|
136
171
|
def test_cooldown_dedups_repeat_failures(tmp_path, monkeypatch):
|
|
@@ -375,6 +375,7 @@ def test_run_shadow_pass_spawns_when_threshold_met(tmp_path, monkeypatch):
|
|
|
375
375
|
assert "lesson_list(k=80)" in kw["prompt"]
|
|
376
376
|
assert "hard cap 450" in kw["prompt"]
|
|
377
377
|
assert "mcp__thread-keeper__lesson_get" in kw["extra_allowed_tools"]
|
|
378
|
+
assert "mcp__thread-keeper__lesson_patch" in kw["extra_allowed_tools"]
|
|
378
379
|
assert long_msg.strip()[:40] in kw["prompt"]
|
|
379
380
|
# De-privileged (issue #76): no bare Read/Write — path-scoped skill/
|
|
380
381
|
# lesson MCP tools only.
|