threadkeeper 0.17.3__tar.gz → 0.17.4__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 (254) hide show
  1. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/PKG-INFO +16 -3
  2. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/README.md +15 -2
  3. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/pyproject.toml +1 -1
  4. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_curator.py +42 -0
  5. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_spawn_slim.py +7 -2
  6. threadkeeper-0.17.4/tests/test_spawn_worktree_isolation.py +112 -0
  7. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/curator.py +5 -4
  8. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/skill_audit.py +15 -2
  9. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/tools/spawn.py +133 -0
  10. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper.egg-info/PKG-INFO +16 -3
  11. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper.egg-info/SOURCES.txt +1 -0
  12. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/LICENSE +0 -0
  13. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/setup.cfg +0 -0
  14. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_adapters.py +0 -0
  15. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_agent_status.py +0 -0
  16. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_auto_update.py +0 -0
  17. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_backup.py +0 -0
  18. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_brief_footprint.py +0 -0
  19. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_brief_sections.py +0 -0
  20. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_candidate_reviewer.py +0 -0
  21. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_concepts.py +0 -0
  22. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_config_settings.py +0 -0
  23. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_config_watcher.py +0 -0
  24. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_core_memory.py +0 -0
  25. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_daemon_host_config.py +0 -0
  26. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_daemon_host_integration.py +0 -0
  27. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_daemon_liveness.py +0 -0
  28. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_daemon_state.py +0 -0
  29. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_dashboard.py +0 -0
  30. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_db_compact.py +0 -0
  31. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_db_contention.py +0 -0
  32. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_db_migration_fts.py +0 -0
  33. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_db_runtime.py +0 -0
  34. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_db_schema.py +0 -0
  35. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_delegated_search.py +0 -0
  36. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_dialectic.py +0 -0
  37. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_dialectic_feed_tools.py +0 -0
  38. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_dialectic_miner.py +0 -0
  39. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_dialectic_observation_resolve.py +0 -0
  40. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_dialectic_recompute.py +0 -0
  41. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_dialectic_tier.py +0 -0
  42. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_dialectic_validator.py +0 -0
  43. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_dialog_fts_external.py +0 -0
  44. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_egress_policy.py +0 -0
  45. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_embed_routing.py +0 -0
  46. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_embedding_generation.py +0 -0
  47. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_embedding_model_revision.py +0 -0
  48. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_embedding_storage_dedup.py +0 -0
  49. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_error_paths.py +0 -0
  50. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_eval_harness.py +0 -0
  51. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_evolve_applier.py +0 -0
  52. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_evolve_apply_2.py +0 -0
  53. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_evolve_apply_3.py +0 -0
  54. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_evolve_daemon.py +0 -0
  55. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_extract_daemon.py +0 -0
  56. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_extract_dedup.py +0 -0
  57. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_forget.py +0 -0
  58. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_github_budget.py +0 -0
  59. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_github_safety.py +0 -0
  60. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_helpers.py +0 -0
  61. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_host_embed.py +0 -0
  62. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_host_env.py +0 -0
  63. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_host_main.py +0 -0
  64. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_host_wedged.py +0 -0
  65. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_hybrid_retrieval.py +0 -0
  66. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_i18n_multilang.py +0 -0
  67. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_identity.py +0 -0
  68. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_ingest_cursor.py +0 -0
  69. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_ingest_denylist.py +0 -0
  70. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_ingest_secret_redaction.py +0 -0
  71. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_ingest_status.py +0 -0
  72. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_ingest_transactions.py +0 -0
  73. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_lessons.py +0 -0
  74. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_mcp_resources_prompts.py +0 -0
  75. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_memory_eval.py +0 -0
  76. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_memory_guard.py +0 -0
  77. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_memory_guard_host.py +0 -0
  78. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_menubar_app.py +0 -0
  79. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_missed_spawns.py +0 -0
  80. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_model_catalog.py +0 -0
  81. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_notify_daemon.py +0 -0
  82. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_nudges.py +0 -0
  83. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_onnx_embeddings.py +0 -0
  84. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_panel.py +0 -0
  85. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_permissions.py +0 -0
  86. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_pickup.py +0 -0
  87. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_probe_daemon.py +0 -0
  88. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_process_health.py +0 -0
  89. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_release_workflows.py +0 -0
  90. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_retention.py +0 -0
  91. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_review_prompts.py +0 -0
  92. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_search_fts_punctuation.py +0 -0
  93. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_session_read_purity.py +0 -0
  94. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_shadow_review.py +0 -0
  95. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_single_flight_prompt_prefixes.py +0 -0
  96. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_skill_hint.py +0 -0
  97. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_skill_passive_tier.py +0 -0
  98. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_skill_tier.py +0 -0
  99. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_skill_updater.py +0 -0
  100. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_skill_use_parser.py +0 -0
  101. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_skill_watcher.py +0 -0
  102. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_skills.py +0 -0
  103. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_spawn_budget.py +0 -0
  104. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_spawn_codex_stdin.py +0 -0
  105. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_spawn_config.py +0 -0
  106. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_spawn_hint.py +0 -0
  107. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_spawn_permission_gate.py +0 -0
  108. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_spawn_reap.py +0 -0
  109. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_spawn_watchdog.py +0 -0
  110. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_spawn_wrap.py +0 -0
  111. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_sync_capture.py +0 -0
  112. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_sync_identity.py +0 -0
  113. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_sync_migrate.py +0 -0
  114. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_sync_protocol.py +0 -0
  115. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_sync_reset_node.py +0 -0
  116. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_sync_transport.py +0 -0
  117. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_task_retention.py +0 -0
  118. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_task_spool.py +0 -0
  119. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_thin_session.py +0 -0
  120. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_thread_janitor.py +0 -0
  121. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_threads.py +0 -0
  122. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_tool_annotations.py +0 -0
  123. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_tools_smoke.py +0 -0
  124. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_validate_threads.py +0 -0
  125. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_vec_search.py +0 -0
  126. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/tests/test_verify_ingest.py +0 -0
  127. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/__init__.py +0 -0
  128. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/_mcp.py +0 -0
  129. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/_setup.py +0 -0
  130. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/_spawn_wrap.py +0 -0
  131. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/adapters/__init__.py +0 -0
  132. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/adapters/_hook_helpers.py +0 -0
  133. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/adapters/antigravity.py +0 -0
  134. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/adapters/base.py +0 -0
  135. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/adapters/claude_code.py +0 -0
  136. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/adapters/claude_desktop.py +0 -0
  137. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/adapters/codex.py +0 -0
  138. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/adapters/copilot.py +0 -0
  139. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/adapters/vscode.py +0 -0
  140. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/agent_metadata.py +0 -0
  141. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/agent_status.py +0 -0
  142. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/assets/macos-agent-status/Info.plist +0 -0
  143. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/assets/macos-agent-status/README.md +0 -0
  144. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/assets/macos-agent-status/ThreadKeeperAgentStatus.swift +0 -0
  145. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/assets/macos-agent-status/build.sh +0 -0
  146. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/assets/macos-agent-status/install.sh +0 -0
  147. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/auto_update.py +0 -0
  148. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/backup.py +0 -0
  149. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/brief.py +0 -0
  150. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/candidate_reviewer.py +0 -0
  151. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/config.py +0 -0
  152. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/config_io.py +0 -0
  153. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/config_watcher.py +0 -0
  154. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/curator_snapshots.py +0 -0
  155. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/daemon_liveness.py +0 -0
  156. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/daemon_state.py +0 -0
  157. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/daemon_supervisor.py +0 -0
  158. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/db.py +0 -0
  159. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/dialectic_miner.py +0 -0
  160. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/dialectic_validator.py +0 -0
  161. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/egress.py +0 -0
  162. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/elicitation.py +0 -0
  163. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/embeddings.py +0 -0
  164. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/eval/__init__.py +0 -0
  165. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/eval/__main__.py +0 -0
  166. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/eval/harness.py +0 -0
  167. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/evolve_applier.py +0 -0
  168. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/evolve_daemon.py +0 -0
  169. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/extract_daemon.py +0 -0
  170. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/forget.py +0 -0
  171. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/github_budget.py +0 -0
  172. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/github_safety.py +0 -0
  173. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/harvest.py +0 -0
  174. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/helpers.py +0 -0
  175. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/host.py +0 -0
  176. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/host_embed.py +0 -0
  177. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/i18n.py +0 -0
  178. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/identity.py +0 -0
  179. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/ingest.py +0 -0
  180. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/lessons.py +0 -0
  181. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/memory_guard.py +0 -0
  182. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/menubar_app.py +0 -0
  183. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/migrate_embeddings.py +0 -0
  184. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/model_catalog.py +0 -0
  185. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/notify.py +0 -0
  186. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/nudges.py +0 -0
  187. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/permissions.py +0 -0
  188. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/probe_daemon.py +0 -0
  189. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/process_health.py +0 -0
  190. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/retention.py +0 -0
  191. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/retrieval.py +0 -0
  192. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/review_prompts.py +0 -0
  193. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/search_proxy.py +0 -0
  194. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/server.py +0 -0
  195. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/shadow_review.py +0 -0
  196. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/skill_updater.py +0 -0
  197. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/skill_watcher.py +0 -0
  198. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/spawn_budget.py +0 -0
  199. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/spawn_config.py +0 -0
  200. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/sync/__init__.py +0 -0
  201. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/sync/capture.py +0 -0
  202. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/sync/daemon.py +0 -0
  203. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/sync/identity.py +0 -0
  204. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/sync/migrate.py +0 -0
  205. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/sync/protocol.py +0 -0
  206. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/sync/reset_node.py +0 -0
  207. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/sync/server.py +0 -0
  208. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/task_spool.py +0 -0
  209. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/thread_janitor.py +0 -0
  210. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/tool_schemas.py +0 -0
  211. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/tools/__init__.py +0 -0
  212. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/tools/agent_status.py +0 -0
  213. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/tools/candidate_reviewer.py +0 -0
  214. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/tools/concepts.py +0 -0
  215. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/tools/config_watch.py +0 -0
  216. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/tools/consolidate.py +0 -0
  217. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/tools/core_memory.py +0 -0
  218. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/tools/correlation.py +0 -0
  219. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/tools/curator.py +0 -0
  220. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/tools/dashboard.py +0 -0
  221. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/tools/db_maintenance.py +0 -0
  222. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/tools/dialectic.py +0 -0
  223. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/tools/dialectic_feed.py +0 -0
  224. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/tools/dialog.py +0 -0
  225. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/tools/distill.py +0 -0
  226. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/tools/evolve_applier.py +0 -0
  227. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/tools/extract.py +0 -0
  228. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/tools/forget.py +0 -0
  229. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/tools/graph.py +0 -0
  230. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/tools/invariants.py +0 -0
  231. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/tools/lessons.py +0 -0
  232. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/tools/memory_guard.py +0 -0
  233. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/tools/missed_spawns.py +0 -0
  234. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/tools/panel.py +0 -0
  235. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/tools/peers.py +0 -0
  236. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/tools/pickup.py +0 -0
  237. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/tools/probes.py +0 -0
  238. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/tools/process_health.py +0 -0
  239. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/tools/prompts.py +0 -0
  240. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/tools/resources.py +0 -0
  241. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/tools/session.py +0 -0
  242. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/tools/shadow_review.py +0 -0
  243. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/tools/skills.py +0 -0
  244. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/tools/style.py +0 -0
  245. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/tools/sync.py +0 -0
  246. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/tools/threads.py +0 -0
  247. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/tools/validate.py +0 -0
  248. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/trash.py +0 -0
  249. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/verify_ingest.py +0 -0
  250. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper/wikilinks.py +0 -0
  251. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper.egg-info/dependency_links.txt +0 -0
  252. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper.egg-info/entry_points.txt +0 -0
  253. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper.egg-info/requires.txt +0 -0
  254. {threadkeeper-0.17.3 → threadkeeper-0.17.4}/threadkeeper.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: threadkeeper
3
- Version: 0.17.3
3
+ Version: 0.17.4
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
@@ -310,6 +310,12 @@ uses a SQLite `BEGIN IMMEDIATE` reservation: `spawn()` re-checks the budget and
310
310
  inserts the child task row with its RSS estimate before `Popen`, so two
311
311
  concurrent spawns cannot both squeeze through the cap.
312
312
 
313
+ When `cwd` is inside a Git checkout, `spawn()` also requires the source
314
+ checkout's tracked files to be clean, then starts the child from a unique
315
+ branch/worktree under `THREADKEEPER_TASK_LOG_DIR/worktrees/`. Parallel children
316
+ therefore never share a mutable checkout or Git index. Non-Git directories keep
317
+ their existing behavior; a dirty Git checkout is refused before a child starts.
318
+
313
319
  The spawn wrapper also records each completed child's `duration_s`,
314
320
  `tokens_in`, `tokens_out`, `tokens_total`, and `cost_usd` when the underlying
315
321
  CLI emits a recognizable usage trailer. Optional daily ceilings
@@ -710,6 +716,12 @@ non-forced direct `curator_review()` calls return `not_due` inside the
710
716
  configured interval and record that status without spawning. A manual
711
717
  `curator_review(force=True)` bypasses the interval but still respects the lock.
712
718
 
719
+ For automation-created skills, the audit keeps foreground consultation separate
720
+ from maintenance: a background-review skill with `fg_uses=0` after 14 days is
721
+ still a false-positive prune candidate even if automatic review or sync loops
722
+ have increased its patch counter. Patches are maintenance activity, not proof
723
+ that a foreground user or agent consulted the skill.
724
+
713
725
  Before spawning, the scheduler hashes lessons, concepts, skill bodies, support
714
726
  trees, validators, and mirror state. Repeated manual calls over identical bytes
715
727
  return `unchanged_inventory`; the scheduled three-day pass still runs because
@@ -1549,8 +1561,9 @@ Spawn task spool files live in `THREADKEEPER_TASK_LOG_DIR` (default
1549
1561
  the hardened `~/.threadkeeper` perimeter by default; explicit overrides are
1550
1562
  refused when the configured directory is a symlink or is not owned by the
1551
1563
  current user. `spawn()` creates captured headless `.log`, stdin prompt spool,
1552
- and visible `.command` files with no-follow owner-only opens. `consolidate()`
1553
- garbage-collects task spool files once their task row is no longer retained.
1564
+ and visible `.command` files with no-follow owner-only opens. Git-backed spawns
1565
+ also create their isolated worktrees there. `consolidate()` garbage-collects
1566
+ task spool files once their task row is no longer retained.
1554
1567
 
1555
1568
  ---
1556
1569
 
@@ -266,6 +266,12 @@ uses a SQLite `BEGIN IMMEDIATE` reservation: `spawn()` re-checks the budget and
266
266
  inserts the child task row with its RSS estimate before `Popen`, so two
267
267
  concurrent spawns cannot both squeeze through the cap.
268
268
 
269
+ When `cwd` is inside a Git checkout, `spawn()` also requires the source
270
+ checkout's tracked files to be clean, then starts the child from a unique
271
+ branch/worktree under `THREADKEEPER_TASK_LOG_DIR/worktrees/`. Parallel children
272
+ therefore never share a mutable checkout or Git index. Non-Git directories keep
273
+ their existing behavior; a dirty Git checkout is refused before a child starts.
274
+
269
275
  The spawn wrapper also records each completed child's `duration_s`,
270
276
  `tokens_in`, `tokens_out`, `tokens_total`, and `cost_usd` when the underlying
271
277
  CLI emits a recognizable usage trailer. Optional daily ceilings
@@ -666,6 +672,12 @@ non-forced direct `curator_review()` calls return `not_due` inside the
666
672
  configured interval and record that status without spawning. A manual
667
673
  `curator_review(force=True)` bypasses the interval but still respects the lock.
668
674
 
675
+ For automation-created skills, the audit keeps foreground consultation separate
676
+ from maintenance: a background-review skill with `fg_uses=0` after 14 days is
677
+ still a false-positive prune candidate even if automatic review or sync loops
678
+ have increased its patch counter. Patches are maintenance activity, not proof
679
+ that a foreground user or agent consulted the skill.
680
+
669
681
  Before spawning, the scheduler hashes lessons, concepts, skill bodies, support
670
682
  trees, validators, and mirror state. Repeated manual calls over identical bytes
671
683
  return `unchanged_inventory`; the scheduled three-day pass still runs because
@@ -1505,8 +1517,9 @@ Spawn task spool files live in `THREADKEEPER_TASK_LOG_DIR` (default
1505
1517
  the hardened `~/.threadkeeper` perimeter by default; explicit overrides are
1506
1518
  refused when the configured directory is a symlink or is not owned by the
1507
1519
  current user. `spawn()` creates captured headless `.log`, stdin prompt spool,
1508
- and visible `.command` files with no-follow owner-only opens. `consolidate()`
1509
- garbage-collects task spool files once their task row is no longer retained.
1520
+ and visible `.command` files with no-follow owner-only opens. Git-backed spawns
1521
+ also create their isolated worktrees there. `consolidate()` garbage-collects
1522
+ task spool files once their task row is no longer retained.
1510
1523
 
1511
1524
  ---
1512
1525
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "threadkeeper"
7
- version = "0.17.3"
7
+ version = "0.17.4"
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" }]
@@ -843,6 +843,48 @@ def test_skill_validator_is_exhaustive_and_semantic(tmp_path, monkeypatch):
843
843
  assert "3. SKILL second-skill" in checklist
844
844
 
845
845
 
846
+ def test_curator_prune_rubric_keeps_patched_unconsulted_skill_eligible(
847
+ tmp_path, monkeypatch,
848
+ ):
849
+ pkg = _bootstrap(tmp_path, monkeypatch, min_lessons="2")
850
+ pkg["lessons"].append_lesson(title="one", body="b1", source="shadow")
851
+ pkg["lessons"].append_lesson(title="two", body="b2", source="shadow")
852
+ _write_audit_skill(
853
+ pkg["skills_dir"], "patched-background-skill", "# Rule\nDo the test.",
854
+ )
855
+ now = int(time.time())
856
+ conn = pkg["db"].get_db()
857
+ conn.execute(
858
+ "INSERT INTO skill_usage "
859
+ "(name, created_at, created_by_origin, last_patched_at, patch_count, "
860
+ "foreground_use_count, state) VALUES (?, ?, ?, ?, ?, ?, ?)",
861
+ (
862
+ "patched-background-skill", now - 30 * 86400,
863
+ "background_review", now, 4, 0, "active",
864
+ ),
865
+ )
866
+ conn.commit()
867
+
868
+ import threadkeeper.tools.spawn as spawn_mod
869
+ captured: list[dict] = []
870
+
871
+ def fake_spawn(**kwargs):
872
+ captured.append(kwargs)
873
+ return "spawn task_id=patched-background-skill pid=0"
874
+
875
+ monkeypatch.setattr(spawn_mod, "spawn", fake_spawn)
876
+ pkg["curator"].run_curator_pass(force=True)
877
+
878
+ prompt = captured[0]["prompt"]
879
+ assert "origin=background_review AND fg_uses=0" in prompt
880
+ assert "they are not foreground consultation" in prompt
881
+ assert "SKILL patched-background-skill" in prompt
882
+ assert "fg_uses=0" in prompt
883
+ assert "maintenance_patches=4" in prompt
884
+ assert "created=30d_ago" in prompt
885
+ assert "patches=0" not in prompt
886
+
887
+
846
888
  def test_skill_validate_tool_returns_post_change_contract(tmp_path, monkeypatch):
847
889
  pkg = _bootstrap(tmp_path, monkeypatch)
848
890
  primary = pkg["skills_dir"]
@@ -171,7 +171,7 @@ def test_visible_command_script_is_owner_only(mp_with_cid, monkeypatch):
171
171
  monkeypatch.setattr(spawn_mod.subprocess, "Popen", _FakePopen)
172
172
 
173
173
  spawn_fn = pkg["mcp"]._tool_manager._tools["spawn"].fn
174
- out = spawn_fn(prompt="do a thing", visible=True)
174
+ out = spawn_fn(prompt="do a thing", cwd=str(pkg["tmp"]), visible=True)
175
175
  assert out.startswith("ok task="), out
176
176
 
177
177
  cmd_files = list(spawn_mod.TASK_LOG_DIR.glob("*.command"))
@@ -197,7 +197,12 @@ def test_headless_log_file_is_owner_only(mp_with_cid, monkeypatch):
197
197
  monkeypatch.setattr(spawn_mod.subprocess, "Popen", _FakePopen)
198
198
 
199
199
  spawn_fn = pkg["mcp"]._tool_manager._tools["spawn"].fn
200
- out = spawn_fn(prompt="do a thing", visible=False, capture_output=True)
200
+ out = spawn_fn(
201
+ prompt="do a thing",
202
+ cwd=str(pkg["tmp"]),
203
+ visible=False,
204
+ capture_output=True,
205
+ )
201
206
  assert out.startswith("ok task="), out
202
207
 
203
208
  log_files = list(spawn_mod.TASK_LOG_DIR.glob("*.log"))
@@ -0,0 +1,112 @@
1
+ """Git worktree isolation on the shared spawn path."""
2
+ from __future__ import annotations
3
+
4
+ import subprocess
5
+ from pathlib import Path
6
+
7
+
8
+ _FAKE_CID = "aaaa0000-bbbb-1111-cccc-222233334444"
9
+
10
+
11
+ def _git(repo: Path, *args: str) -> str:
12
+ proc = subprocess.run(
13
+ ["git", "-C", str(repo), *args],
14
+ text=True,
15
+ capture_output=True,
16
+ check=True,
17
+ )
18
+ return proc.stdout.strip()
19
+
20
+
21
+ def _init_repo(tmp_path: Path) -> Path:
22
+ repo = tmp_path / "repo"
23
+ repo.mkdir()
24
+ _git(repo, "init", "-b", "main")
25
+ _git(repo, "config", "user.email", "test@example.com")
26
+ _git(repo, "config", "user.name", "Test User")
27
+ (repo / "tracked.txt").write_text("base\n", encoding="utf-8")
28
+ _git(repo, "add", "tracked.txt")
29
+ _git(repo, "commit", "-m", "base")
30
+ return repo
31
+
32
+
33
+ def _configure_spawn(monkeypatch, pkg):
34
+ import threadkeeper.identity as identity
35
+ import threadkeeper.spawn_config as spawn_config
36
+ import threadkeeper.tools.spawn as spawn_mod
37
+
38
+ monkeypatch.setattr(spawn_mod, "_claude_bin", lambda: "/bin/true")
39
+ monkeypatch.setattr(identity, "_active_cli", "claude")
40
+ monkeypatch.setattr(
41
+ spawn_config, "resolve_agent", lambda role, active_cli=None: "claude"
42
+ )
43
+ monkeypatch.setattr(spawn_config, "resolve_model", lambda cli, role="": "")
44
+ return spawn_mod
45
+
46
+
47
+ def test_git_spawns_get_distinct_branch_worktrees(mp_with_cid, monkeypatch):
48
+ pkg = mp_with_cid(_FAKE_CID)
49
+ repo = _init_repo(pkg["tmp"])
50
+ spawn_mod = _configure_spawn(monkeypatch, pkg)
51
+ child_cwds: list[Path] = []
52
+
53
+ class _FakePopen:
54
+ def __init__(self, _args, **kwargs):
55
+ child_cwds.append(Path(kwargs["cwd"]))
56
+ self.pid = 4200 + len(child_cwds)
57
+
58
+ monkeypatch.setattr(spawn_mod.subprocess, "Popen", _FakePopen)
59
+
60
+ first = spawn_mod.spawn(
61
+ prompt="first child", cwd=str(repo), visible=False, capture_output=False
62
+ )
63
+ second = spawn_mod.spawn(
64
+ prompt="second child", cwd=str(repo), visible=False, capture_output=False
65
+ )
66
+
67
+ assert first.startswith("ok task="), first
68
+ assert second.startswith("ok task="), second
69
+ assert len(child_cwds) == 2
70
+ assert child_cwds[0] != child_cwds[1]
71
+ assert all(path != repo and path.is_dir() for path in child_cwds)
72
+ roots = [
73
+ spawn_mod._git_result(["rev-parse", "--show-toplevel"], path)[0]
74
+ for path in child_cwds
75
+ ]
76
+ assert roots == [str(path) for path in child_cwds]
77
+ branches = [
78
+ spawn_mod._git_result(["branch", "--show-current"], path)[0]
79
+ for path in child_cwds
80
+ ]
81
+ assert len(set(branches)) == 2
82
+ assert all(branch.startswith("threadkeeper/spawn-tk_") for branch in branches)
83
+ assert spawn_mod._git_result(
84
+ ["status", "--porcelain", "--untracked-files=no"], repo
85
+ )[0] == ""
86
+
87
+
88
+ def test_dirty_git_checkout_is_refused_before_child_launch(
89
+ mp_with_cid, monkeypatch,
90
+ ):
91
+ pkg = mp_with_cid(_FAKE_CID)
92
+ repo = _init_repo(pkg["tmp"])
93
+ spawn_mod = _configure_spawn(monkeypatch, pkg)
94
+ (repo / "tracked.txt").write_text("uncommitted\n", encoding="utf-8")
95
+
96
+ def _unexpected_launch(*_args, **_kwargs):
97
+ raise AssertionError("dirty checkout must be rejected before Popen")
98
+
99
+ monkeypatch.setattr(spawn_mod.subprocess, "Popen", _unexpected_launch)
100
+
101
+ out = spawn_mod.spawn(
102
+ prompt="must not launch", cwd=str(repo), visible=False, capture_output=False
103
+ )
104
+
105
+ assert out == "ERR spawn_dirty_worktree mode=git"
106
+ worktrees, err = spawn_mod._git_result(
107
+ ["worktree", "list", "--porcelain"], repo
108
+ )
109
+ assert not err
110
+ assert worktrees.count("worktree ") == 1
111
+ conn = pkg["db"].get_db()
112
+ assert conn.execute("SELECT COUNT(*) FROM tasks").fetchone()[0] == 0
@@ -248,10 +248,11 @@ promotion and linking plan in REPORT.md for a foreground human instead.
248
248
  superseded by a newer entry, or a **FALSE POSITIVE** (auto-created
249
249
  by the background-review loop but never validated by actual use).
250
250
  Specifically flag as PRUNE:
251
- • origin=background_review AND use_count=0 AND patches=0 AND
252
- created >14 days ago → strong false-positive signal: nobody ever
253
- consulted it, and the agent that created it never came back to
254
- refine it.
251
+ • origin=background_review AND fg_uses=0 AND created >14 days ago
252
+ → strong false-positive signal: no foreground user or agent ever
253
+ consulted it. `maintenance_patches` count automated or other maintenance
254
+ writes; they are not foreground consultation and never reset this
255
+ eligibility signal.
255
256
  • SKILL_OUTCOME signals (in the events table) marking the skill
256
257
  as 'wrong' more often than 'helped' → user-judgment override.
257
258
  Format:
@@ -18,6 +18,7 @@ import os
18
18
  from pathlib import Path
19
19
  import re
20
20
  import sqlite3
21
+ import time
21
22
  from typing import Iterable
22
23
 
23
24
  import yaml
@@ -245,7 +246,8 @@ def _telemetry_rows(conn: sqlite3.Connection, include_archived: bool) -> dict[st
245
246
  rows = conn.execute(
246
247
  "SELECT name, created_at, created_by_origin, last_used_at, "
247
248
  "last_viewed_at, last_patched_at, use_count, view_count, "
248
- "patch_count, pinned, state, tier FROM skill_usage" + where
249
+ "patch_count, foreground_use_count, pinned, state, tier "
250
+ "FROM skill_usage" + where
249
251
  + " ORDER BY name"
250
252
  ).fetchall()
251
253
  except sqlite3.OperationalError:
@@ -327,7 +329,7 @@ def _record_for(
327
329
  for key in (
328
330
  "created_at", "last_used_at", "last_viewed_at",
329
331
  "last_patched_at", "use_count", "view_count", "patch_count",
330
- "pinned",
332
+ "foreground_use_count", "pinned",
331
333
  )
332
334
  },
333
335
  "source_path": str(source) if source else None,
@@ -533,9 +535,20 @@ def format_skill_checklist(manifest: dict) -> str:
533
535
  for index, record in enumerate(manifest["skills"], start=1):
534
536
  flags = ",".join(record["findings"]) or "clean"
535
537
  protected = " [PROTECTED]" if record["protected"] else ""
538
+ telemetry = record["telemetry"]
539
+ created_at = int(telemetry.get("created_at") or 0)
540
+ created_age = (
541
+ max(0, int(time.time()) - created_at) // 86400
542
+ if created_at else "?"
543
+ )
536
544
  lines.append(
537
545
  f"{index}. SKILL {record['name']}{protected} "
538
546
  f"state={record['state']} origin={record['origin']} "
547
+ f"fg_uses={telemetry.get('foreground_use_count') or 0} "
548
+ f"uses={telemetry.get('use_count') or 0} "
549
+ f"views={telemetry.get('view_count') or 0} "
550
+ f"maintenance_patches={telemetry.get('patch_count') or 0} "
551
+ f"created={created_age}d_ago "
539
552
  f"source_kind={record['source_kind']} "
540
553
  f"source={record['source_path'] or '-'} findings={flags}"
541
554
  )
@@ -16,6 +16,7 @@ import sys
16
16
  import secrets
17
17
  import time
18
18
  import json as _json
19
+ from dataclasses import dataclass
19
20
  from pathlib import Path
20
21
  from typing import Optional
21
22
 
@@ -57,6 +58,30 @@ _BYPASS_ENV_OVERRIDE = "THREADKEEPER_ALLOW_BYPASS_PERMISSIONS_SPAWN"
57
58
  # that and feed larger prompts through the existing owner-only stdin spool.
58
59
  CLAUDE_PROMPT_ARGV_MAX_BYTES = 96 * 1024
59
60
 
61
+ # A child that starts from a repository must never share that repository's
62
+ # mutable checkout with another child. Worktrees live in the protected task
63
+ # spool rather than beside the caller's checkout, where they cannot surprise a
64
+ # developer scanning their project tree.
65
+ _SPAWN_WORKTREE_DIRNAME = "worktrees"
66
+ _SPAWN_WORKTREE_BRANCH_PREFIX = "threadkeeper/spawn-"
67
+ # Keep git preflight independent from tests (and callers) that replace the
68
+ # child-launcher `subprocess.Popen` below.
69
+ _GIT_POPEN = subprocess.Popen
70
+
71
+
72
+ @dataclass(frozen=True)
73
+ class _SpawnWorktree:
74
+ """A per-task checkout derived from a clean caller worktree."""
75
+
76
+ repo_root: Path
77
+ relative_cwd: Path
78
+ path: Path
79
+ branch: str
80
+
81
+ @property
82
+ def child_cwd(self) -> Path:
83
+ return self.path / self.relative_cwd
84
+
60
85
 
61
86
  def _utf8_len(text: str) -> int:
62
87
  return len(text.encode("utf-8"))
@@ -402,6 +427,102 @@ def _build_slim_mcp_config(
402
427
  return slim_path
403
428
 
404
429
 
430
+ def _git_result(args: list[str], cwd: Path) -> tuple[str, str]:
431
+ """Run a short git query and return stdout plus a compact error string."""
432
+ try:
433
+ proc = _GIT_POPEN(
434
+ ["git", *args],
435
+ cwd=str(cwd),
436
+ text=True,
437
+ stdout=subprocess.PIPE,
438
+ stderr=subprocess.PIPE,
439
+ )
440
+ stdout, stderr = proc.communicate(timeout=15)
441
+ except subprocess.TimeoutExpired:
442
+ proc.kill()
443
+ proc.communicate()
444
+ return "", "git command timed out"
445
+ except (FileNotFoundError, OSError) as e:
446
+ return "", str(e)
447
+ if proc.returncode == 0:
448
+ return stdout.strip(), ""
449
+ detail = (stderr or stdout or f"exit={proc.returncode}").strip()
450
+ return "", detail.replace("\n", " ")[:240]
451
+
452
+
453
+ def _spawn_worktree_plan(
454
+ cwd: str, task_id: str
455
+ ) -> tuple[Optional[_SpawnWorktree], str]:
456
+ """Return an isolated worktree plan or safely decline a git checkout.
457
+
458
+ Non-git directories keep the historic behavior. Git directories must be
459
+ clean before a child can run: copying uncommitted tracked changes into a
460
+ second checkout would either lose them or reintroduce the shared-WIP race.
461
+ """
462
+ source_cwd = Path(cwd).resolve()
463
+ root_text, root_err = _git_result(
464
+ ["rev-parse", "--show-toplevel"], source_cwd
465
+ )
466
+ if root_err:
467
+ if "not a git repository" in root_err.lower():
468
+ return None, ""
469
+ return None, f"ERR spawn_worktree_check_failed={root_err}"
470
+ repo_root = Path(root_text).resolve()
471
+ try:
472
+ relative_cwd = source_cwd.relative_to(repo_root)
473
+ except ValueError:
474
+ return None, "ERR spawn_worktree_check_failed=cwd_outside_repo"
475
+
476
+ dirty, status_err = _git_result(
477
+ ["status", "--porcelain", "--untracked-files=no"], repo_root
478
+ )
479
+ if status_err:
480
+ return None, f"ERR spawn_worktree_check_failed={status_err}"
481
+ if dirty:
482
+ return None, "ERR spawn_dirty_worktree mode=git"
483
+
484
+ try:
485
+ parent = ensure_task_spool_dir(TASK_LOG_DIR / _SPAWN_WORKTREE_DIRNAME)
486
+ except OSError as e:
487
+ return None, f"ERR spawn_worktree_spool_unavailable={e}"
488
+ path = parent / task_id
489
+ if path.exists() or path.is_symlink():
490
+ return None, "ERR spawn_worktree_path_in_use"
491
+ return _SpawnWorktree(
492
+ repo_root=repo_root,
493
+ relative_cwd=relative_cwd,
494
+ path=path,
495
+ branch=_SPAWN_WORKTREE_BRANCH_PREFIX + task_id,
496
+ ), ""
497
+
498
+
499
+ def _remove_spawn_worktree(worktree: _SpawnWorktree) -> None:
500
+ """Best-effort cleanup for a worktree whose child never launched."""
501
+ _git_result(
502
+ ["worktree", "remove", "--force", str(worktree.path)],
503
+ worktree.repo_root,
504
+ )
505
+ _git_result(["branch", "-D", worktree.branch], worktree.repo_root)
506
+
507
+
508
+ def _create_spawn_worktree(worktree: _SpawnWorktree) -> str:
509
+ """Create the task's branch/worktree after the spawn budget is reserved."""
510
+ _, err = _git_result(
511
+ [
512
+ "worktree", "add", "--quiet", "-b", worktree.branch,
513
+ str(worktree.path), "HEAD",
514
+ ],
515
+ worktree.repo_root,
516
+ )
517
+ if err:
518
+ _remove_spawn_worktree(worktree)
519
+ return f"ERR spawn_worktree_create_failed={err}"
520
+ if not worktree.child_cwd.is_dir():
521
+ _remove_spawn_worktree(worktree)
522
+ return "ERR spawn_worktree_create_failed=cwd_not_in_clean_checkout"
523
+ return ""
524
+
525
+
405
526
  def _spawn_impl(prompt: str, cwd: str = "", append_system: str = "",
406
527
  model: str = "", effort: str = "",
407
528
  permission_mode: str = "auto",
@@ -519,6 +640,9 @@ def _spawn_impl(prompt: str, cwd: str = "", append_system: str = "",
519
640
  for c in task_id
520
641
  ):
521
642
  return "ERR invalid_task_id"
643
+ worktree, worktree_err = _spawn_worktree_plan(cwd, task_id)
644
+ if worktree_err:
645
+ return worktree_err
522
646
  sys_extra = sys_extra_template.format(
523
647
  parent=parent_cid or "(unknown)",
524
648
  child=child_cid,
@@ -778,6 +902,13 @@ def _spawn_impl(prompt: str, cwd: str = "", append_system: str = "",
778
902
  retry_root or None, int(retry_attempt or 0),
779
903
  ),
780
904
  )
905
+ if worktree is not None:
906
+ worktree_create_err = _create_spawn_worktree(worktree)
907
+ if worktree_create_err:
908
+ conn.rollback()
909
+ return worktree_create_err
910
+ cwd = str(worktree.child_cwd)
911
+ conn.execute("UPDATE tasks SET cwd=? WHERE id=?", (cwd, task_id))
781
912
  except sqlite3.Error as e:
782
913
  conn.rollback()
783
914
  return f"ERR spawn_reservation_failed={e}"
@@ -943,6 +1074,8 @@ exit $rc
943
1074
  stdin_f.close()
944
1075
  proc_pid = proc.pid
945
1076
  except (FileNotFoundError, OSError) as e:
1077
+ if worktree is not None:
1078
+ _remove_spawn_worktree(worktree)
946
1079
  conn.rollback()
947
1080
  return f"ERR spawn_failed={e}"
948
1081
  try:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: threadkeeper
3
- Version: 0.17.3
3
+ Version: 0.17.4
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
@@ -310,6 +310,12 @@ uses a SQLite `BEGIN IMMEDIATE` reservation: `spawn()` re-checks the budget and
310
310
  inserts the child task row with its RSS estimate before `Popen`, so two
311
311
  concurrent spawns cannot both squeeze through the cap.
312
312
 
313
+ When `cwd` is inside a Git checkout, `spawn()` also requires the source
314
+ checkout's tracked files to be clean, then starts the child from a unique
315
+ branch/worktree under `THREADKEEPER_TASK_LOG_DIR/worktrees/`. Parallel children
316
+ therefore never share a mutable checkout or Git index. Non-Git directories keep
317
+ their existing behavior; a dirty Git checkout is refused before a child starts.
318
+
313
319
  The spawn wrapper also records each completed child's `duration_s`,
314
320
  `tokens_in`, `tokens_out`, `tokens_total`, and `cost_usd` when the underlying
315
321
  CLI emits a recognizable usage trailer. Optional daily ceilings
@@ -710,6 +716,12 @@ non-forced direct `curator_review()` calls return `not_due` inside the
710
716
  configured interval and record that status without spawning. A manual
711
717
  `curator_review(force=True)` bypasses the interval but still respects the lock.
712
718
 
719
+ For automation-created skills, the audit keeps foreground consultation separate
720
+ from maintenance: a background-review skill with `fg_uses=0` after 14 days is
721
+ still a false-positive prune candidate even if automatic review or sync loops
722
+ have increased its patch counter. Patches are maintenance activity, not proof
723
+ that a foreground user or agent consulted the skill.
724
+
713
725
  Before spawning, the scheduler hashes lessons, concepts, skill bodies, support
714
726
  trees, validators, and mirror state. Repeated manual calls over identical bytes
715
727
  return `unchanged_inventory`; the scheduled three-day pass still runs because
@@ -1549,8 +1561,9 @@ Spawn task spool files live in `THREADKEEPER_TASK_LOG_DIR` (default
1549
1561
  the hardened `~/.threadkeeper` perimeter by default; explicit overrides are
1550
1562
  refused when the configured directory is a symlink or is not owned by the
1551
1563
  current user. `spawn()` creates captured headless `.log`, stdin prompt spool,
1552
- and visible `.command` files with no-follow owner-only opens. `consolidate()`
1553
- garbage-collects task spool files once their task row is no longer retained.
1564
+ and visible `.command` files with no-follow owner-only opens. Git-backed spawns
1565
+ also create their isolated worktrees there. `consolidate()` garbage-collects
1566
+ task spool files once their task row is no longer retained.
1554
1567
 
1555
1568
  ---
1556
1569
 
@@ -99,6 +99,7 @@ tests/test_spawn_permission_gate.py
99
99
  tests/test_spawn_reap.py
100
100
  tests/test_spawn_slim.py
101
101
  tests/test_spawn_watchdog.py
102
+ tests/test_spawn_worktree_isolation.py
102
103
  tests/test_spawn_wrap.py
103
104
  tests/test_sync_capture.py
104
105
  tests/test_sync_identity.py
File without changes
File without changes