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.
Files changed (257) hide show
  1. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/PKG-INFO +32 -7
  2. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/README.md +31 -6
  3. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/pyproject.toml +1 -1
  4. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_adapters.py +1 -0
  5. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_agent_status.py +39 -0
  6. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_candidate_reviewer.py +2 -0
  7. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_curator.py +121 -0
  8. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_daemon_liveness.py +4 -2
  9. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_evolve_daemon.py +162 -17
  10. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_lessons.py +67 -0
  11. threadkeeper-0.17.7/tests/test_setup.py +31 -0
  12. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_shadow_review.py +2 -0
  13. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_spawn_slim.py +3 -0
  14. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_spawn_watchdog.py +36 -0
  15. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_tool_annotations.py +9 -4
  16. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/_setup.py +8 -1
  17. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/adapters/codex.py +8 -0
  18. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/agent_metadata.py +1 -1
  19. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/agent_status.py +4 -4
  20. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/candidate_reviewer.py +10 -1
  21. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/curator.py +177 -7
  22. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/db.py +51 -2
  23. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/evolve_daemon.py +348 -49
  24. threadkeeper-0.17.7/threadkeeper/link_health.py +81 -0
  25. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/server.py +1 -0
  26. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/shadow_review.py +8 -1
  27. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/spawn_budget.py +7 -5
  28. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/tools/curator.py +40 -0
  29. threadkeeper-0.17.7/threadkeeper/tools/evolve_research.py +22 -0
  30. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/tools/lessons.py +111 -1
  31. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/tools/spawn.py +23 -4
  32. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper.egg-info/PKG-INFO +32 -7
  33. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper.egg-info/SOURCES.txt +3 -0
  34. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/LICENSE +0 -0
  35. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/setup.cfg +0 -0
  36. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_auto_update.py +0 -0
  37. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_backup.py +0 -0
  38. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_brief_footprint.py +0 -0
  39. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_brief_sections.py +0 -0
  40. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_concepts.py +0 -0
  41. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_config_settings.py +0 -0
  42. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_config_watcher.py +0 -0
  43. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_core_memory.py +0 -0
  44. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_daemon_host_config.py +0 -0
  45. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_daemon_host_integration.py +0 -0
  46. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_daemon_state.py +0 -0
  47. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_dashboard.py +0 -0
  48. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_db_compact.py +0 -0
  49. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_db_contention.py +0 -0
  50. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_db_migration_fts.py +0 -0
  51. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_db_runtime.py +0 -0
  52. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_db_schema.py +0 -0
  53. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_delegated_search.py +0 -0
  54. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_dialectic.py +0 -0
  55. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_dialectic_feed_tools.py +0 -0
  56. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_dialectic_miner.py +0 -0
  57. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_dialectic_observation_resolve.py +0 -0
  58. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_dialectic_recompute.py +0 -0
  59. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_dialectic_tier.py +0 -0
  60. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_dialectic_validator.py +0 -0
  61. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_dialog_fts_external.py +0 -0
  62. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_egress_policy.py +0 -0
  63. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_embed_routing.py +0 -0
  64. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_embedding_generation.py +0 -0
  65. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_embedding_model_revision.py +0 -0
  66. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_embedding_storage_dedup.py +0 -0
  67. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_error_paths.py +0 -0
  68. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_eval_harness.py +0 -0
  69. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_evolve_applier.py +0 -0
  70. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_evolve_apply_2.py +0 -0
  71. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_evolve_apply_3.py +0 -0
  72. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_extract_daemon.py +0 -0
  73. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_extract_dedup.py +0 -0
  74. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_forget.py +0 -0
  75. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_github_budget.py +0 -0
  76. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_github_safety.py +0 -0
  77. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_helpers.py +0 -0
  78. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_host_embed.py +0 -0
  79. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_host_env.py +0 -0
  80. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_host_main.py +0 -0
  81. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_host_wedged.py +0 -0
  82. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_hybrid_retrieval.py +0 -0
  83. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_i18n_multilang.py +0 -0
  84. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_identity.py +0 -0
  85. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_ingest_cursor.py +0 -0
  86. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_ingest_denylist.py +0 -0
  87. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_ingest_secret_redaction.py +0 -0
  88. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_ingest_status.py +0 -0
  89. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_ingest_transactions.py +0 -0
  90. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_mcp_resources_prompts.py +0 -0
  91. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_memory_eval.py +0 -0
  92. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_memory_guard.py +0 -0
  93. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_memory_guard_host.py +0 -0
  94. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_menubar_app.py +0 -0
  95. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_missed_spawns.py +0 -0
  96. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_model_catalog.py +0 -0
  97. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_notify_daemon.py +0 -0
  98. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_nudges.py +0 -0
  99. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_onnx_embeddings.py +0 -0
  100. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_panel.py +0 -0
  101. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_permissions.py +0 -0
  102. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_pickup.py +0 -0
  103. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_probe_daemon.py +0 -0
  104. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_process_health.py +0 -0
  105. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_release_workflows.py +0 -0
  106. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_retention.py +0 -0
  107. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_review_prompts.py +0 -0
  108. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_search_fts_punctuation.py +0 -0
  109. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_session_read_purity.py +0 -0
  110. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_single_flight_prompt_prefixes.py +0 -0
  111. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_skill_hint.py +0 -0
  112. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_skill_passive_tier.py +0 -0
  113. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_skill_tier.py +0 -0
  114. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_skill_updater.py +0 -0
  115. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_skill_use_parser.py +0 -0
  116. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_skill_watcher.py +0 -0
  117. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_skills.py +0 -0
  118. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_spawn_budget.py +0 -0
  119. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_spawn_codex_stdin.py +0 -0
  120. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_spawn_config.py +0 -0
  121. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_spawn_hint.py +0 -0
  122. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_spawn_permission_gate.py +0 -0
  123. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_spawn_reap.py +0 -0
  124. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_spawn_worktree_isolation.py +0 -0
  125. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_spawn_wrap.py +0 -0
  126. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_sync_capture.py +0 -0
  127. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_sync_identity.py +0 -0
  128. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_sync_migrate.py +0 -0
  129. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_sync_protocol.py +0 -0
  130. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_sync_reset_node.py +0 -0
  131. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_sync_transport.py +0 -0
  132. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_task_retention.py +0 -0
  133. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_task_spool.py +0 -0
  134. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_thin_session.py +0 -0
  135. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_thread_janitor.py +0 -0
  136. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_threads.py +0 -0
  137. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_tools_smoke.py +0 -0
  138. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_validate_threads.py +0 -0
  139. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_vec_search.py +0 -0
  140. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/tests/test_verify_ingest.py +0 -0
  141. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/__init__.py +0 -0
  142. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/_mcp.py +0 -0
  143. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/_spawn_wrap.py +0 -0
  144. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/adapters/__init__.py +0 -0
  145. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/adapters/_hook_helpers.py +0 -0
  146. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/adapters/antigravity.py +0 -0
  147. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/adapters/base.py +0 -0
  148. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/adapters/claude_code.py +0 -0
  149. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/adapters/claude_desktop.py +0 -0
  150. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/adapters/copilot.py +0 -0
  151. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/adapters/vscode.py +0 -0
  152. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/assets/macos-agent-status/Info.plist +0 -0
  153. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/assets/macos-agent-status/README.md +0 -0
  154. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/assets/macos-agent-status/ThreadKeeperAgentStatus.swift +0 -0
  155. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/assets/macos-agent-status/build.sh +0 -0
  156. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/assets/macos-agent-status/install.sh +0 -0
  157. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/auto_update.py +0 -0
  158. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/backup.py +0 -0
  159. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/brief.py +0 -0
  160. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/config.py +0 -0
  161. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/config_io.py +0 -0
  162. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/config_watcher.py +0 -0
  163. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/curator_snapshots.py +0 -0
  164. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/daemon_liveness.py +0 -0
  165. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/daemon_state.py +0 -0
  166. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/daemon_supervisor.py +0 -0
  167. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/dialectic_miner.py +0 -0
  168. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/dialectic_validator.py +0 -0
  169. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/egress.py +0 -0
  170. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/elicitation.py +0 -0
  171. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/embeddings.py +0 -0
  172. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/eval/__init__.py +0 -0
  173. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/eval/__main__.py +0 -0
  174. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/eval/harness.py +0 -0
  175. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/evolve_applier.py +0 -0
  176. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/extract_daemon.py +0 -0
  177. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/forget.py +0 -0
  178. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/github_budget.py +0 -0
  179. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/github_safety.py +0 -0
  180. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/harvest.py +0 -0
  181. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/helpers.py +0 -0
  182. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/host.py +0 -0
  183. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/host_embed.py +0 -0
  184. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/i18n.py +0 -0
  185. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/identity.py +0 -0
  186. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/ingest.py +0 -0
  187. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/lessons.py +0 -0
  188. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/memory_guard.py +0 -0
  189. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/menubar_app.py +0 -0
  190. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/migrate_embeddings.py +0 -0
  191. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/model_catalog.py +0 -0
  192. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/notify.py +0 -0
  193. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/nudges.py +0 -0
  194. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/permissions.py +0 -0
  195. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/probe_daemon.py +0 -0
  196. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/process_health.py +0 -0
  197. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/retention.py +0 -0
  198. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/retrieval.py +0 -0
  199. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/review_prompts.py +0 -0
  200. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/search_proxy.py +0 -0
  201. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/skill_audit.py +0 -0
  202. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/skill_updater.py +0 -0
  203. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/skill_watcher.py +0 -0
  204. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/spawn_config.py +0 -0
  205. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/sync/__init__.py +0 -0
  206. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/sync/capture.py +0 -0
  207. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/sync/daemon.py +0 -0
  208. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/sync/identity.py +0 -0
  209. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/sync/migrate.py +0 -0
  210. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/sync/protocol.py +0 -0
  211. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/sync/reset_node.py +0 -0
  212. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/sync/server.py +0 -0
  213. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/task_spool.py +0 -0
  214. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/thread_janitor.py +0 -0
  215. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/tool_schemas.py +0 -0
  216. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/tools/__init__.py +0 -0
  217. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/tools/agent_status.py +0 -0
  218. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/tools/candidate_reviewer.py +0 -0
  219. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/tools/concepts.py +0 -0
  220. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/tools/config_watch.py +0 -0
  221. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/tools/consolidate.py +0 -0
  222. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/tools/core_memory.py +0 -0
  223. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/tools/correlation.py +0 -0
  224. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/tools/dashboard.py +0 -0
  225. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/tools/db_maintenance.py +0 -0
  226. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/tools/dialectic.py +0 -0
  227. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/tools/dialectic_feed.py +0 -0
  228. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/tools/dialog.py +0 -0
  229. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/tools/distill.py +0 -0
  230. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/tools/evolve_applier.py +0 -0
  231. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/tools/extract.py +0 -0
  232. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/tools/forget.py +0 -0
  233. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/tools/graph.py +0 -0
  234. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/tools/invariants.py +0 -0
  235. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/tools/memory_guard.py +0 -0
  236. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/tools/missed_spawns.py +0 -0
  237. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/tools/panel.py +0 -0
  238. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/tools/peers.py +0 -0
  239. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/tools/pickup.py +0 -0
  240. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/tools/probes.py +0 -0
  241. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/tools/process_health.py +0 -0
  242. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/tools/prompts.py +0 -0
  243. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/tools/resources.py +0 -0
  244. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/tools/session.py +0 -0
  245. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/tools/shadow_review.py +0 -0
  246. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/tools/skills.py +0 -0
  247. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/tools/style.py +0 -0
  248. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/tools/sync.py +0 -0
  249. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/tools/threads.py +0 -0
  250. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/tools/validate.py +0 -0
  251. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/trash.py +0 -0
  252. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/verify_ingest.py +0 -0
  253. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper/wikilinks.py +0 -0
  254. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper.egg-info/dependency_links.txt +0 -0
  255. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper.egg-info/entry_points.txt +0 -0
  256. {threadkeeper-0.17.5 → threadkeeper-0.17.7}/threadkeeper.egg-info/requires.txt +0 -0
  257. {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.5
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 `bypassPermissions`, and no GitHub
856
- access**. It distills external findings into a digest file under
857
- `~/.threadkeeper/evolve-research/`. With no `Bash`/`gh`/network-write tool it
858
- has no exfiltration channel, so the untrusted pages it reads cannot act.
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 `bypassPermissions`, and no GitHub
812
- access**. It distills external findings into a digest file under
813
- `~/.threadkeeper/evolve-research/`. With no `Bash`/`gh`/network-write tool it
814
- has no exfiltration channel, so the untrusted pages it reads cannot act.
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.5"
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 = 4")
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] == 4
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 write a fresh
107
- digest file the audit phase will fence into its prompt."""
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
- rdir = pkg["ed"]._research_dir()
110
- rdir.mkdir(parents=True, exist_ok=True)
111
- f = rdir / f"RESEARCH-{now}.md"
112
- f.write_text(text, encoding="utf-8")
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 file=RESEARCH.md ok task=tk pid=1",
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, "spawn",
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, "spawn",
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, "spawn",
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, "spawn",
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
- monkeypatch.setattr(
712
- spawn_mod, "spawn",
713
- lambda **kw: captured.append(dict(kw)) or "ok task=tk_ev pid=1",
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, "spawn",
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, "spawn", fake_spawn)
985
+ monkeypatch.setattr(spawn_mod, "_spawn_impl", fake_spawn)
841
986
 
842
987
  t = threading.Thread(target=run_pass)
843
988
  t.start()