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.
Files changed (253) hide show
  1. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/PKG-INFO +28 -11
  2. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/README.md +27 -10
  3. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/pyproject.toml +1 -1
  4. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_curator.py +100 -3
  5. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_evolve_applier.py +64 -0
  6. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_host_main.py +26 -0
  7. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_identity.py +11 -0
  8. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_lessons.py +91 -0
  9. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_notify_daemon.py +35 -0
  10. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_shadow_review.py +1 -0
  11. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_skills.py +27 -0
  12. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_spawn_watchdog.py +4 -0
  13. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_tool_annotations.py +1 -1
  14. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/adapters/codex.py +1 -0
  15. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/config.py +4 -0
  16. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/curator.py +183 -14
  17. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/evolve_applier.py +58 -2
  18. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/host.py +43 -2
  19. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/identity.py +9 -2
  20. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/lessons.py +57 -0
  21. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/notify.py +51 -9
  22. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/shadow_review.py +7 -3
  23. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/spawn_budget.py +8 -4
  24. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/tools/dashboard.py +1 -1
  25. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/tools/lessons.py +138 -19
  26. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/tools/skills.py +41 -5
  27. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/tools/threads.py +1 -0
  28. threadkeeper-0.17.2/threadkeeper/wikilinks.py +105 -0
  29. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper.egg-info/PKG-INFO +28 -11
  30. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper.egg-info/SOURCES.txt +1 -0
  31. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/LICENSE +0 -0
  32. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/setup.cfg +0 -0
  33. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_adapters.py +0 -0
  34. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_agent_status.py +0 -0
  35. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_auto_update.py +0 -0
  36. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_backup.py +0 -0
  37. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_brief_footprint.py +0 -0
  38. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_brief_sections.py +0 -0
  39. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_candidate_reviewer.py +0 -0
  40. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_concepts.py +0 -0
  41. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_config_settings.py +0 -0
  42. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_config_watcher.py +0 -0
  43. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_core_memory.py +0 -0
  44. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_daemon_host_config.py +0 -0
  45. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_daemon_host_integration.py +0 -0
  46. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_daemon_liveness.py +0 -0
  47. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_daemon_state.py +0 -0
  48. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_dashboard.py +0 -0
  49. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_db_compact.py +0 -0
  50. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_db_contention.py +0 -0
  51. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_db_migration_fts.py +0 -0
  52. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_db_runtime.py +0 -0
  53. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_db_schema.py +0 -0
  54. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_delegated_search.py +0 -0
  55. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_dialectic.py +0 -0
  56. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_dialectic_feed_tools.py +0 -0
  57. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_dialectic_miner.py +0 -0
  58. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_dialectic_observation_resolve.py +0 -0
  59. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_dialectic_recompute.py +0 -0
  60. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_dialectic_tier.py +0 -0
  61. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_dialectic_validator.py +0 -0
  62. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_dialog_fts_external.py +0 -0
  63. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_egress_policy.py +0 -0
  64. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_embed_routing.py +0 -0
  65. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_embedding_generation.py +0 -0
  66. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_embedding_model_revision.py +0 -0
  67. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_embedding_storage_dedup.py +0 -0
  68. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_error_paths.py +0 -0
  69. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_eval_harness.py +0 -0
  70. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_evolve_apply_2.py +0 -0
  71. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_evolve_apply_3.py +0 -0
  72. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_evolve_daemon.py +0 -0
  73. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_extract_daemon.py +0 -0
  74. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_extract_dedup.py +0 -0
  75. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_forget.py +0 -0
  76. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_github_budget.py +0 -0
  77. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_github_safety.py +0 -0
  78. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_helpers.py +0 -0
  79. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_host_embed.py +0 -0
  80. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_host_env.py +0 -0
  81. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_host_wedged.py +0 -0
  82. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_hybrid_retrieval.py +0 -0
  83. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_i18n_multilang.py +0 -0
  84. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_ingest_cursor.py +0 -0
  85. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_ingest_denylist.py +0 -0
  86. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_ingest_secret_redaction.py +0 -0
  87. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_ingest_status.py +0 -0
  88. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_ingest_transactions.py +0 -0
  89. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_mcp_resources_prompts.py +0 -0
  90. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_memory_eval.py +0 -0
  91. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_memory_guard.py +0 -0
  92. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_memory_guard_host.py +0 -0
  93. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_menubar_app.py +0 -0
  94. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_missed_spawns.py +0 -0
  95. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_model_catalog.py +0 -0
  96. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_nudges.py +0 -0
  97. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_onnx_embeddings.py +0 -0
  98. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_panel.py +0 -0
  99. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_permissions.py +0 -0
  100. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_pickup.py +0 -0
  101. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_probe_daemon.py +0 -0
  102. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_process_health.py +0 -0
  103. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_release_workflows.py +0 -0
  104. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_retention.py +0 -0
  105. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_review_prompts.py +0 -0
  106. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_search_fts_punctuation.py +0 -0
  107. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_session_read_purity.py +0 -0
  108. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_single_flight_prompt_prefixes.py +0 -0
  109. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_skill_hint.py +0 -0
  110. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_skill_passive_tier.py +0 -0
  111. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_skill_tier.py +0 -0
  112. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_skill_updater.py +0 -0
  113. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_skill_use_parser.py +0 -0
  114. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_skill_watcher.py +0 -0
  115. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_spawn_budget.py +0 -0
  116. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_spawn_codex_stdin.py +0 -0
  117. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_spawn_config.py +0 -0
  118. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_spawn_hint.py +0 -0
  119. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_spawn_permission_gate.py +0 -0
  120. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_spawn_reap.py +0 -0
  121. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_spawn_slim.py +0 -0
  122. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_spawn_wrap.py +0 -0
  123. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_sync_capture.py +0 -0
  124. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_sync_identity.py +0 -0
  125. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_sync_migrate.py +0 -0
  126. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_sync_protocol.py +0 -0
  127. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_sync_reset_node.py +0 -0
  128. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_sync_transport.py +0 -0
  129. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_task_retention.py +0 -0
  130. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_task_spool.py +0 -0
  131. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_thin_session.py +0 -0
  132. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_thread_janitor.py +0 -0
  133. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_threads.py +0 -0
  134. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_tools_smoke.py +0 -0
  135. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_validate_threads.py +0 -0
  136. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_vec_search.py +0 -0
  137. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/tests/test_verify_ingest.py +0 -0
  138. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/__init__.py +0 -0
  139. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/_mcp.py +0 -0
  140. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/_setup.py +0 -0
  141. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/_spawn_wrap.py +0 -0
  142. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/adapters/__init__.py +0 -0
  143. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/adapters/_hook_helpers.py +0 -0
  144. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/adapters/antigravity.py +0 -0
  145. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/adapters/base.py +0 -0
  146. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/adapters/claude_code.py +0 -0
  147. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/adapters/claude_desktop.py +0 -0
  148. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/adapters/copilot.py +0 -0
  149. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/adapters/vscode.py +0 -0
  150. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/agent_metadata.py +0 -0
  151. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/agent_status.py +0 -0
  152. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/assets/macos-agent-status/Info.plist +0 -0
  153. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/assets/macos-agent-status/README.md +0 -0
  154. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/assets/macos-agent-status/ThreadKeeperAgentStatus.swift +0 -0
  155. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/assets/macos-agent-status/build.sh +0 -0
  156. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/assets/macos-agent-status/install.sh +0 -0
  157. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/auto_update.py +0 -0
  158. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/backup.py +0 -0
  159. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/brief.py +0 -0
  160. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/candidate_reviewer.py +0 -0
  161. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/config_io.py +0 -0
  162. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/config_watcher.py +0 -0
  163. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/curator_snapshots.py +0 -0
  164. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/daemon_liveness.py +0 -0
  165. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/daemon_state.py +0 -0
  166. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/daemon_supervisor.py +0 -0
  167. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/db.py +0 -0
  168. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/dialectic_miner.py +0 -0
  169. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/dialectic_validator.py +0 -0
  170. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/egress.py +0 -0
  171. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/elicitation.py +0 -0
  172. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/embeddings.py +0 -0
  173. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/eval/__init__.py +0 -0
  174. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/eval/__main__.py +0 -0
  175. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/eval/harness.py +0 -0
  176. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/evolve_daemon.py +0 -0
  177. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/extract_daemon.py +0 -0
  178. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/forget.py +0 -0
  179. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/github_budget.py +0 -0
  180. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/github_safety.py +0 -0
  181. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/harvest.py +0 -0
  182. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/helpers.py +0 -0
  183. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/host_embed.py +0 -0
  184. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/i18n.py +0 -0
  185. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/ingest.py +0 -0
  186. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/memory_guard.py +0 -0
  187. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/menubar_app.py +0 -0
  188. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/migrate_embeddings.py +0 -0
  189. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/model_catalog.py +0 -0
  190. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/nudges.py +0 -0
  191. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/permissions.py +0 -0
  192. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/probe_daemon.py +0 -0
  193. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/process_health.py +0 -0
  194. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/retention.py +0 -0
  195. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/retrieval.py +0 -0
  196. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/review_prompts.py +0 -0
  197. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/search_proxy.py +0 -0
  198. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/server.py +0 -0
  199. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/skill_audit.py +0 -0
  200. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/skill_updater.py +0 -0
  201. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/skill_watcher.py +0 -0
  202. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/spawn_config.py +0 -0
  203. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/sync/__init__.py +0 -0
  204. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/sync/capture.py +0 -0
  205. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/sync/daemon.py +0 -0
  206. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/sync/identity.py +0 -0
  207. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/sync/migrate.py +0 -0
  208. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/sync/protocol.py +0 -0
  209. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/sync/reset_node.py +0 -0
  210. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/sync/server.py +0 -0
  211. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/task_spool.py +0 -0
  212. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/thread_janitor.py +0 -0
  213. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/tool_schemas.py +0 -0
  214. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/tools/__init__.py +0 -0
  215. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/tools/agent_status.py +0 -0
  216. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/tools/candidate_reviewer.py +0 -0
  217. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/tools/concepts.py +0 -0
  218. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/tools/config_watch.py +0 -0
  219. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/tools/consolidate.py +0 -0
  220. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/tools/core_memory.py +0 -0
  221. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/tools/correlation.py +0 -0
  222. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/tools/curator.py +0 -0
  223. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/tools/db_maintenance.py +0 -0
  224. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/tools/dialectic.py +0 -0
  225. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/tools/dialectic_feed.py +0 -0
  226. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/tools/dialog.py +0 -0
  227. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/tools/distill.py +0 -0
  228. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/tools/evolve_applier.py +0 -0
  229. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/tools/extract.py +0 -0
  230. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/tools/forget.py +0 -0
  231. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/tools/graph.py +0 -0
  232. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/tools/invariants.py +0 -0
  233. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/tools/memory_guard.py +0 -0
  234. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/tools/missed_spawns.py +0 -0
  235. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/tools/panel.py +0 -0
  236. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/tools/peers.py +0 -0
  237. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/tools/pickup.py +0 -0
  238. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/tools/probes.py +0 -0
  239. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/tools/process_health.py +0 -0
  240. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/tools/prompts.py +0 -0
  241. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/tools/resources.py +0 -0
  242. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/tools/session.py +0 -0
  243. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/tools/shadow_review.py +0 -0
  244. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/tools/spawn.py +0 -0
  245. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/tools/style.py +0 -0
  246. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/tools/sync.py +0 -0
  247. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/tools/validate.py +0 -0
  248. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/trash.py +0 -0
  249. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper/verify_ingest.py +0 -0
  250. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper.egg-info/dependency_links.txt +0 -0
  251. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper.egg-info/entry_points.txt +0 -0
  252. {threadkeeper-0.17.0 → threadkeeper-0.17.2}/threadkeeper.egg-info/requires.txt +0 -0
  253. {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.0
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. Shadow-origin `lesson_append` is a compact
603
- fallback only: oversized bodies are rejected, near-duplicate slugs are blocked,
604
- and semantic body matches are routed to the incumbent lesson or surfaced for
605
- curation instead of minting a sibling lesson.
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 child reads every
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 writes a recovery artifact under `<db dir>/curator/trash/`: lessons store
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
- untracked scratch files do not block. Each managed-checkout child fetches the
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. Shadow-origin `lesson_append` is a compact
559
- fallback only: oversized bodies are rejected, near-duplicate slugs are blocked,
560
- and semantic body matches are routed to the incumbent lesson or surfaced for
561
- curation instead of minting a sibling lesson.
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 child reads every
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 writes a recovery artifact under `<db dir>/curator/trash/`: lessons store
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
- untracked scratch files do not block. Each managed-checkout child fetches the
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.0"
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 shell or spawn.
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 they aren't in
1106
- allowed_tools. (Destructive is the default, so advisory is now opt-in.)"""
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.