threadkeeper 0.17.6__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.6 → threadkeeper-0.17.7}/PKG-INFO +15 -6
  2. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/README.md +14 -5
  3. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/pyproject.toml +1 -1
  4. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_agent_status.py +39 -0
  5. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_daemon_liveness.py +4 -2
  6. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_evolve_daemon.py +162 -17
  7. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_spawn_watchdog.py +36 -0
  8. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_tool_annotations.py +5 -2
  9. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/agent_metadata.py +1 -1
  10. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/agent_status.py +4 -4
  11. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/db.py +23 -2
  12. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/evolve_daemon.py +348 -49
  13. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/server.py +1 -0
  14. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/spawn_budget.py +7 -5
  15. threadkeeper-0.17.7/threadkeeper/tools/evolve_research.py +22 -0
  16. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/tools/spawn.py +16 -3
  17. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper.egg-info/PKG-INFO +15 -6
  18. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper.egg-info/SOURCES.txt +1 -0
  19. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/LICENSE +0 -0
  20. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/setup.cfg +0 -0
  21. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_adapters.py +0 -0
  22. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_auto_update.py +0 -0
  23. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_backup.py +0 -0
  24. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_brief_footprint.py +0 -0
  25. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_brief_sections.py +0 -0
  26. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_candidate_reviewer.py +0 -0
  27. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_concepts.py +0 -0
  28. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_config_settings.py +0 -0
  29. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_config_watcher.py +0 -0
  30. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_core_memory.py +0 -0
  31. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_curator.py +0 -0
  32. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_daemon_host_config.py +0 -0
  33. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_daemon_host_integration.py +0 -0
  34. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_daemon_state.py +0 -0
  35. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_dashboard.py +0 -0
  36. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_db_compact.py +0 -0
  37. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_db_contention.py +0 -0
  38. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_db_migration_fts.py +0 -0
  39. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_db_runtime.py +0 -0
  40. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_db_schema.py +0 -0
  41. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_delegated_search.py +0 -0
  42. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_dialectic.py +0 -0
  43. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_dialectic_feed_tools.py +0 -0
  44. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_dialectic_miner.py +0 -0
  45. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_dialectic_observation_resolve.py +0 -0
  46. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_dialectic_recompute.py +0 -0
  47. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_dialectic_tier.py +0 -0
  48. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_dialectic_validator.py +0 -0
  49. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_dialog_fts_external.py +0 -0
  50. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_egress_policy.py +0 -0
  51. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_embed_routing.py +0 -0
  52. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_embedding_generation.py +0 -0
  53. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_embedding_model_revision.py +0 -0
  54. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_embedding_storage_dedup.py +0 -0
  55. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_error_paths.py +0 -0
  56. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_eval_harness.py +0 -0
  57. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_evolve_applier.py +0 -0
  58. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_evolve_apply_2.py +0 -0
  59. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_evolve_apply_3.py +0 -0
  60. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_extract_daemon.py +0 -0
  61. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_extract_dedup.py +0 -0
  62. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_forget.py +0 -0
  63. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_github_budget.py +0 -0
  64. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_github_safety.py +0 -0
  65. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_helpers.py +0 -0
  66. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_host_embed.py +0 -0
  67. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_host_env.py +0 -0
  68. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_host_main.py +0 -0
  69. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_host_wedged.py +0 -0
  70. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_hybrid_retrieval.py +0 -0
  71. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_i18n_multilang.py +0 -0
  72. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_identity.py +0 -0
  73. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_ingest_cursor.py +0 -0
  74. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_ingest_denylist.py +0 -0
  75. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_ingest_secret_redaction.py +0 -0
  76. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_ingest_status.py +0 -0
  77. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_ingest_transactions.py +0 -0
  78. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_lessons.py +0 -0
  79. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_mcp_resources_prompts.py +0 -0
  80. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_memory_eval.py +0 -0
  81. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_memory_guard.py +0 -0
  82. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_memory_guard_host.py +0 -0
  83. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_menubar_app.py +0 -0
  84. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_missed_spawns.py +0 -0
  85. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_model_catalog.py +0 -0
  86. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_notify_daemon.py +0 -0
  87. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_nudges.py +0 -0
  88. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_onnx_embeddings.py +0 -0
  89. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_panel.py +0 -0
  90. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_permissions.py +0 -0
  91. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_pickup.py +0 -0
  92. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_probe_daemon.py +0 -0
  93. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_process_health.py +0 -0
  94. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_release_workflows.py +0 -0
  95. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_retention.py +0 -0
  96. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_review_prompts.py +0 -0
  97. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_search_fts_punctuation.py +0 -0
  98. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_session_read_purity.py +0 -0
  99. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_setup.py +0 -0
  100. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_shadow_review.py +0 -0
  101. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_single_flight_prompt_prefixes.py +0 -0
  102. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_skill_hint.py +0 -0
  103. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_skill_passive_tier.py +0 -0
  104. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_skill_tier.py +0 -0
  105. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_skill_updater.py +0 -0
  106. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_skill_use_parser.py +0 -0
  107. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_skill_watcher.py +0 -0
  108. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_skills.py +0 -0
  109. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_spawn_budget.py +0 -0
  110. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_spawn_codex_stdin.py +0 -0
  111. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_spawn_config.py +0 -0
  112. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_spawn_hint.py +0 -0
  113. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_spawn_permission_gate.py +0 -0
  114. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_spawn_reap.py +0 -0
  115. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_spawn_slim.py +0 -0
  116. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_spawn_worktree_isolation.py +0 -0
  117. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_spawn_wrap.py +0 -0
  118. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_sync_capture.py +0 -0
  119. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_sync_identity.py +0 -0
  120. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_sync_migrate.py +0 -0
  121. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_sync_protocol.py +0 -0
  122. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_sync_reset_node.py +0 -0
  123. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_sync_transport.py +0 -0
  124. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_task_retention.py +0 -0
  125. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_task_spool.py +0 -0
  126. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_thin_session.py +0 -0
  127. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_thread_janitor.py +0 -0
  128. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_threads.py +0 -0
  129. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_tools_smoke.py +0 -0
  130. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_validate_threads.py +0 -0
  131. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_vec_search.py +0 -0
  132. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/tests/test_verify_ingest.py +0 -0
  133. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/__init__.py +0 -0
  134. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/_mcp.py +0 -0
  135. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/_setup.py +0 -0
  136. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/_spawn_wrap.py +0 -0
  137. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/adapters/__init__.py +0 -0
  138. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/adapters/_hook_helpers.py +0 -0
  139. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/adapters/antigravity.py +0 -0
  140. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/adapters/base.py +0 -0
  141. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/adapters/claude_code.py +0 -0
  142. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/adapters/claude_desktop.py +0 -0
  143. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/adapters/codex.py +0 -0
  144. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/adapters/copilot.py +0 -0
  145. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/adapters/vscode.py +0 -0
  146. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/assets/macos-agent-status/Info.plist +0 -0
  147. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/assets/macos-agent-status/README.md +0 -0
  148. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/assets/macos-agent-status/ThreadKeeperAgentStatus.swift +0 -0
  149. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/assets/macos-agent-status/build.sh +0 -0
  150. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/assets/macos-agent-status/install.sh +0 -0
  151. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/auto_update.py +0 -0
  152. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/backup.py +0 -0
  153. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/brief.py +0 -0
  154. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/candidate_reviewer.py +0 -0
  155. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/config.py +0 -0
  156. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/config_io.py +0 -0
  157. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/config_watcher.py +0 -0
  158. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/curator.py +0 -0
  159. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/curator_snapshots.py +0 -0
  160. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/daemon_liveness.py +0 -0
  161. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/daemon_state.py +0 -0
  162. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/daemon_supervisor.py +0 -0
  163. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/dialectic_miner.py +0 -0
  164. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/dialectic_validator.py +0 -0
  165. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/egress.py +0 -0
  166. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/elicitation.py +0 -0
  167. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/embeddings.py +0 -0
  168. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/eval/__init__.py +0 -0
  169. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/eval/__main__.py +0 -0
  170. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/eval/harness.py +0 -0
  171. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/evolve_applier.py +0 -0
  172. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/extract_daemon.py +0 -0
  173. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/forget.py +0 -0
  174. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/github_budget.py +0 -0
  175. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/github_safety.py +0 -0
  176. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/harvest.py +0 -0
  177. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/helpers.py +0 -0
  178. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/host.py +0 -0
  179. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/host_embed.py +0 -0
  180. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/i18n.py +0 -0
  181. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/identity.py +0 -0
  182. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/ingest.py +0 -0
  183. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/lessons.py +0 -0
  184. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/link_health.py +0 -0
  185. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/memory_guard.py +0 -0
  186. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/menubar_app.py +0 -0
  187. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/migrate_embeddings.py +0 -0
  188. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/model_catalog.py +0 -0
  189. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/notify.py +0 -0
  190. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/nudges.py +0 -0
  191. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/permissions.py +0 -0
  192. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/probe_daemon.py +0 -0
  193. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/process_health.py +0 -0
  194. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/retention.py +0 -0
  195. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/retrieval.py +0 -0
  196. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/review_prompts.py +0 -0
  197. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/search_proxy.py +0 -0
  198. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/shadow_review.py +0 -0
  199. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/skill_audit.py +0 -0
  200. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/skill_updater.py +0 -0
  201. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/skill_watcher.py +0 -0
  202. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/spawn_config.py +0 -0
  203. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/sync/__init__.py +0 -0
  204. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/sync/capture.py +0 -0
  205. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/sync/daemon.py +0 -0
  206. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/sync/identity.py +0 -0
  207. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/sync/migrate.py +0 -0
  208. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/sync/protocol.py +0 -0
  209. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/sync/reset_node.py +0 -0
  210. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/sync/server.py +0 -0
  211. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/task_spool.py +0 -0
  212. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/thread_janitor.py +0 -0
  213. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/tool_schemas.py +0 -0
  214. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/tools/__init__.py +0 -0
  215. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/tools/agent_status.py +0 -0
  216. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/tools/candidate_reviewer.py +0 -0
  217. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/tools/concepts.py +0 -0
  218. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/tools/config_watch.py +0 -0
  219. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/tools/consolidate.py +0 -0
  220. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/tools/core_memory.py +0 -0
  221. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/tools/correlation.py +0 -0
  222. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/tools/curator.py +0 -0
  223. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/tools/dashboard.py +0 -0
  224. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/tools/db_maintenance.py +0 -0
  225. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/tools/dialectic.py +0 -0
  226. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/tools/dialectic_feed.py +0 -0
  227. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/tools/dialog.py +0 -0
  228. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/tools/distill.py +0 -0
  229. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/tools/evolve_applier.py +0 -0
  230. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/tools/extract.py +0 -0
  231. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/tools/forget.py +0 -0
  232. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/tools/graph.py +0 -0
  233. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/tools/invariants.py +0 -0
  234. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/tools/lessons.py +0 -0
  235. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/tools/memory_guard.py +0 -0
  236. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/tools/missed_spawns.py +0 -0
  237. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/tools/panel.py +0 -0
  238. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/tools/peers.py +0 -0
  239. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/tools/pickup.py +0 -0
  240. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/tools/probes.py +0 -0
  241. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/tools/process_health.py +0 -0
  242. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/tools/prompts.py +0 -0
  243. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/tools/resources.py +0 -0
  244. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/tools/session.py +0 -0
  245. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/tools/shadow_review.py +0 -0
  246. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/tools/skills.py +0 -0
  247. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/tools/style.py +0 -0
  248. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/tools/sync.py +0 -0
  249. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/tools/threads.py +0 -0
  250. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/tools/validate.py +0 -0
  251. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/trash.py +0 -0
  252. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/verify_ingest.py +0 -0
  253. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper/wikilinks.py +0 -0
  254. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper.egg-info/dependency_links.txt +0 -0
  255. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper.egg-info/entry_points.txt +0 -0
  256. {threadkeeper-0.17.6 → threadkeeper-0.17.7}/threadkeeper.egg-info/requires.txt +0 -0
  257. {threadkeeper-0.17.6 → 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.6
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
@@ -354,7 +354,10 @@ repair partial work, and continue rather than restart blindly.
354
354
  `THREADKEEPER_SPAWN_TIMEOUT_RETRY_LIMIT` (default 3; 0 disables) bounds the
355
355
  retry chain, with `THREADKEEPER_SPAWN_TIMEOUT_RETRY_DELAY_S` available for a
356
356
  non-zero delay. Timed-out children are surfaced as `tasks_timed_out` in
357
- `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.
358
361
 
359
362
  `tk-agent-status` exposes autonomous learning loop status as structured JSON
360
363
  or compact text for external monitors:
@@ -868,10 +871,16 @@ runs as **two alternating phases**, never co-granting web research and
868
871
  shell/`bypassPermissions` to the same child:
869
872
 
870
873
  - **research phase** — a read-only child with `WebSearch`/`WebFetch` and
871
- read-only repo reads but **no shell, no `bypassPermissions`, and no GitHub
872
- access**. It distills external findings into a digest file under
873
- `~/.threadkeeper/evolve-research/`. With no `Bash`/`gh`/network-write tool it
874
- 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.
875
884
  - **audit phase** — the privileged child (`bypassPermissions` + `Bash`/`Edit`/
876
885
  `Write`) that audits the repo, opens the `docs/ROADMAP.md` PR, and creates or
877
886
  updates GitHub issues. It holds **no web tools**; it consumes the research
@@ -310,7 +310,10 @@ repair partial work, and continue rather than restart blindly.
310
310
  `THREADKEEPER_SPAWN_TIMEOUT_RETRY_LIMIT` (default 3; 0 disables) bounds the
311
311
  retry chain, with `THREADKEEPER_SPAWN_TIMEOUT_RETRY_DELAY_S` available for a
312
312
  non-zero delay. Timed-out children are surfaced as `tasks_timed_out` in
313
- `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.
314
317
 
315
318
  `tk-agent-status` exposes autonomous learning loop status as structured JSON
316
319
  or compact text for external monitors:
@@ -824,10 +827,16 @@ runs as **two alternating phases**, never co-granting web research and
824
827
  shell/`bypassPermissions` to the same child:
825
828
 
826
829
  - **research phase** — a read-only child with `WebSearch`/`WebFetch` and
827
- read-only repo reads but **no shell, no `bypassPermissions`, and no GitHub
828
- access**. It distills external findings into a digest file under
829
- `~/.threadkeeper/evolve-research/`. With no `Bash`/`gh`/network-write tool it
830
- 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.
831
840
  - **audit phase** — the privileged child (`bypassPermissions` + `Bash`/`Edit`/
832
841
  `Write`) that audits the repo, opens the `docs/ROADMAP.md` PR, and creates or
833
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.6"
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" }]
@@ -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
@@ -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()
@@ -402,3 +402,39 @@ def test_agent_status_reports_timed_out(mp_with_cid, monkeypatch):
402
402
  snap = agent_status_snapshot(refresh=False)
403
403
  assert snap["timed_out_count"] == 1
404
404
  assert "timed_out=1" in format_agent_status(snap)
405
+
406
+
407
+ def test_refresh_all_running_enforce_false_skips_kill_and_respawn(
408
+ mp_with_cid, monkeypatch
409
+ ):
410
+ monkeypatch.setenv("THREADKEEPER_SPAWN_MAX_RUNTIME_S", "60")
411
+ pkg = mp_with_cid(_FAKE_CID)
412
+
413
+ import threadkeeper.spawn_budget as sb
414
+
415
+ reap_calls: list = []
416
+ respawn_calls: list = []
417
+ monkeypatch.setattr(
418
+ sb, "_reap_timed_out",
419
+ lambda conn, row, now: reap_calls.append(row["id"]) or True,
420
+ )
421
+ monkeypatch.setattr(
422
+ sb, "_respawn_timed_out",
423
+ lambda conn, row, age: respawn_calls.append(row["id"]),
424
+ )
425
+
426
+ old = int(time.time()) - 200
427
+ conn = _insert_running(pkg, "tk_enforce", os.getpid(), old)
428
+
429
+ sb._refresh_all_running(conn, enforce=False)
430
+
431
+ assert reap_calls == []
432
+ assert respawn_calls == []
433
+ row = conn.execute(
434
+ "SELECT ended_at FROM tasks WHERE id='tk_enforce'"
435
+ ).fetchone()
436
+ assert row["ended_at"] is None
437
+
438
+ sb._refresh_all_running(conn)
439
+
440
+ assert len(reap_calls) == 1
@@ -60,7 +60,8 @@ WRITE_TOOLS = {
60
60
  "evolve_apply_roadmap_issue", "evolve_decide",
61
61
  "evolve_format", "evolve_issue_create", "evolve_mark_applied",
62
62
  "evolve_mark_curator_report_applied",
63
- "evolve_mark_roadmap_issue_applied", "export_distillates", "extract_recent",
63
+ "evolve_mark_roadmap_issue_applied", "evolve_research_handoff",
64
+ "export_distillates", "extract_recent",
64
65
  "idle_thread", "inbox", "ingest", "lesson_append", "lesson_restore",
65
66
  "link", "live_status", "lesson_get", "lesson_list", "mark_skill_materialized",
66
67
  "memory_guard_reclaim", "note",
@@ -155,7 +156,9 @@ def test_status_tools_emit_validating_structured_content(fresh_mp, name):
155
156
  assert tool.output_schema is not None, f"{name} must advertise outputSchema"
156
157
 
157
158
  fn = pkg["mcp"]._tool_manager._tools[name].fn
158
- kwargs = {"refresh": False} if name == "agent_status" else {}
159
+ # agent_status is now side-effect-free under its default, so the
160
+ # annotation test exercises the default path.
161
+ kwargs: dict = {}
159
162
  result = fn(**kwargs)
160
163
 
161
164
  # legacy human-readable text block preserved
@@ -90,7 +90,7 @@ AGENT_ROLES: Final[tuple[dict[str, str], ...]] = (
90
90
  "evolve reviewer before a roadmap decision."
91
91
  ),
92
92
  "reads": "Repository code, documentation, and research sources",
93
- "writes": "Research findings for the evolve review",
93
+ "writes": "One parent-authorized, pass-scoped research digest",
94
94
  "impact": "read_only",
95
95
  "interval_key": "THREADKEEPER_EVOLVE_REVIEW_INTERVAL_S",
96
96
  },
@@ -330,15 +330,15 @@ def _status_for(pid: int | None, ended_at: int | None) -> str:
330
330
 
331
331
 
332
332
  def _refresh_rss(conn) -> None:
333
- """Refresh task liveness/RSS using the existing spawn-budget sweeper.
333
+ """Refresh task liveness/RSS in observation-only mode.
334
334
 
335
- This intentionally reuses the production measurement path, so the widget
336
- and spawn-budget tool agree on memory numbers.
335
+ Status reads measure only and never kill or respawn; lifecycle
336
+ enforcement stays daemon-owned.
337
337
  """
338
338
  try:
339
339
  from .spawn_budget import _refresh_all_running
340
340
 
341
- _refresh_all_running(conn)
341
+ _refresh_all_running(conn, enforce=False)
342
342
  except Exception:
343
343
  # A status widget should degrade to the last cached RSS instead of
344
344
  # failing when ps is briefly unavailable or the DB is locked.
@@ -35,7 +35,7 @@ __all__ = [
35
35
  "SCHEMA",
36
36
  ]
37
37
 
38
- CURRENT_SCHEMA_VERSION = 4
38
+ CURRENT_SCHEMA_VERSION = 5
39
39
 
40
40
  # sqlite-vec extension state. We probe once at first get_db() call and
41
41
  # cache the verdict. _VEC_AVAILABLE = True means vec0 virtual tables work
@@ -556,6 +556,25 @@ CREATE TABLE IF NOT EXISTS evolve_issues (
556
556
  created_at INTEGER NOT NULL
557
557
  );
558
558
 
559
+ -- Parent-authorized handoffs from an unprivileged Evolve web-research child.
560
+ -- The child never chooses a path: it can only submit content for the exact
561
+ -- pass row created before it was spawned. The audit phase accepts only a
562
+ -- completed row whose final on-disk SHA-256 still matches this record.
563
+ CREATE TABLE IF NOT EXISTS evolve_research_handoffs (
564
+ pass_id TEXT PRIMARY KEY,
565
+ target_path TEXT NOT NULL UNIQUE,
566
+ owner_cid TEXT NOT NULL,
567
+ authorized_at INTEGER NOT NULL,
568
+ expires_at INTEGER NOT NULL,
569
+ status TEXT NOT NULL CHECK(status IN (
570
+ 'pending', 'writing', 'accepted', 'failed', 'tampered', 'expired'
571
+ )),
572
+ content_sha256 TEXT,
573
+ content_chars INTEGER,
574
+ completed_at INTEGER,
575
+ failure TEXT
576
+ );
577
+
559
578
  CREATE INDEX IF NOT EXISTS idx_notes_thread ON notes(thread_id);
560
579
  CREATE INDEX IF NOT EXISTS idx_notes_created ON notes(created_at DESC);
561
580
  CREATE INDEX IF NOT EXISTS idx_threads_state ON threads(state);
@@ -582,6 +601,8 @@ CREATE UNIQUE INDEX IF NOT EXISTS idx_evolve_issues_fingerprint
582
601
  ON evolve_issues(fingerprint);
583
602
  CREATE INDEX IF NOT EXISTS idx_evolve_issues_hash
584
603
  ON evolve_issues(content_hash);
604
+ CREATE INDEX IF NOT EXISTS idx_evolve_research_handoffs_ready
605
+ ON evolve_research_handoffs(status, completed_at DESC);
585
606
  CREATE INDEX IF NOT EXISTS idx_evolve_issues_number
586
607
  ON evolve_issues(issue_number);
587
608
  CREATE INDEX IF NOT EXISTS idx_probes_category ON probes(category);
@@ -925,7 +946,7 @@ def _rebuild_dialog_fts_if_needed(conn: sqlite3.Connection) -> None:
925
946
 
926
947
 
927
948
  def _run_schema_migrations(conn: sqlite3.Connection, from_version: int) -> None:
928
- if from_version not in (0, 1, 2, 3):
949
+ if from_version not in (0, 1, 2, 3, 4):
929
950
  raise RuntimeError(
930
951
  f"unsupported SQLite schema version {from_version}; "
931
952
  f"expected 0..{CURRENT_SCHEMA_VERSION}"