threadkeeper 0.17.2__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.2 → threadkeeper-0.17.4}/PKG-INFO +29 -9
  2. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/README.md +28 -8
  3. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/pyproject.toml +1 -1
  4. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_agent_status.py +38 -0
  5. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_curator.py +42 -0
  6. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_notify_daemon.py +42 -2
  7. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_spawn_slim.py +7 -2
  8. threadkeeper-0.17.4/tests/test_spawn_worktree_isolation.py +112 -0
  9. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/agent_status.py +42 -11
  10. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/curator.py +5 -4
  11. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/notify.py +40 -4
  12. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/skill_audit.py +15 -2
  13. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/tools/spawn.py +133 -0
  14. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper.egg-info/PKG-INFO +29 -9
  15. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper.egg-info/SOURCES.txt +1 -0
  16. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/LICENSE +0 -0
  17. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/setup.cfg +0 -0
  18. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_adapters.py +0 -0
  19. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_auto_update.py +0 -0
  20. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_backup.py +0 -0
  21. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_brief_footprint.py +0 -0
  22. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_brief_sections.py +0 -0
  23. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_candidate_reviewer.py +0 -0
  24. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_concepts.py +0 -0
  25. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_config_settings.py +0 -0
  26. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_config_watcher.py +0 -0
  27. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_core_memory.py +0 -0
  28. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_daemon_host_config.py +0 -0
  29. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_daemon_host_integration.py +0 -0
  30. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_daemon_liveness.py +0 -0
  31. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_daemon_state.py +0 -0
  32. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_dashboard.py +0 -0
  33. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_db_compact.py +0 -0
  34. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_db_contention.py +0 -0
  35. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_db_migration_fts.py +0 -0
  36. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_db_runtime.py +0 -0
  37. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_db_schema.py +0 -0
  38. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_delegated_search.py +0 -0
  39. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_dialectic.py +0 -0
  40. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_dialectic_feed_tools.py +0 -0
  41. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_dialectic_miner.py +0 -0
  42. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_dialectic_observation_resolve.py +0 -0
  43. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_dialectic_recompute.py +0 -0
  44. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_dialectic_tier.py +0 -0
  45. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_dialectic_validator.py +0 -0
  46. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_dialog_fts_external.py +0 -0
  47. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_egress_policy.py +0 -0
  48. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_embed_routing.py +0 -0
  49. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_embedding_generation.py +0 -0
  50. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_embedding_model_revision.py +0 -0
  51. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_embedding_storage_dedup.py +0 -0
  52. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_error_paths.py +0 -0
  53. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_eval_harness.py +0 -0
  54. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_evolve_applier.py +0 -0
  55. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_evolve_apply_2.py +0 -0
  56. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_evolve_apply_3.py +0 -0
  57. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_evolve_daemon.py +0 -0
  58. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_extract_daemon.py +0 -0
  59. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_extract_dedup.py +0 -0
  60. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_forget.py +0 -0
  61. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_github_budget.py +0 -0
  62. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_github_safety.py +0 -0
  63. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_helpers.py +0 -0
  64. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_host_embed.py +0 -0
  65. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_host_env.py +0 -0
  66. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_host_main.py +0 -0
  67. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_host_wedged.py +0 -0
  68. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_hybrid_retrieval.py +0 -0
  69. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_i18n_multilang.py +0 -0
  70. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_identity.py +0 -0
  71. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_ingest_cursor.py +0 -0
  72. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_ingest_denylist.py +0 -0
  73. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_ingest_secret_redaction.py +0 -0
  74. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_ingest_status.py +0 -0
  75. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_ingest_transactions.py +0 -0
  76. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_lessons.py +0 -0
  77. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_mcp_resources_prompts.py +0 -0
  78. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_memory_eval.py +0 -0
  79. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_memory_guard.py +0 -0
  80. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_memory_guard_host.py +0 -0
  81. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_menubar_app.py +0 -0
  82. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_missed_spawns.py +0 -0
  83. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_model_catalog.py +0 -0
  84. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_nudges.py +0 -0
  85. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_onnx_embeddings.py +0 -0
  86. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_panel.py +0 -0
  87. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_permissions.py +0 -0
  88. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_pickup.py +0 -0
  89. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_probe_daemon.py +0 -0
  90. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_process_health.py +0 -0
  91. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_release_workflows.py +0 -0
  92. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_retention.py +0 -0
  93. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_review_prompts.py +0 -0
  94. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_search_fts_punctuation.py +0 -0
  95. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_session_read_purity.py +0 -0
  96. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_shadow_review.py +0 -0
  97. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_single_flight_prompt_prefixes.py +0 -0
  98. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_skill_hint.py +0 -0
  99. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_skill_passive_tier.py +0 -0
  100. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_skill_tier.py +0 -0
  101. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_skill_updater.py +0 -0
  102. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_skill_use_parser.py +0 -0
  103. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_skill_watcher.py +0 -0
  104. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_skills.py +0 -0
  105. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_spawn_budget.py +0 -0
  106. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_spawn_codex_stdin.py +0 -0
  107. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_spawn_config.py +0 -0
  108. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_spawn_hint.py +0 -0
  109. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_spawn_permission_gate.py +0 -0
  110. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_spawn_reap.py +0 -0
  111. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_spawn_watchdog.py +0 -0
  112. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_spawn_wrap.py +0 -0
  113. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_sync_capture.py +0 -0
  114. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_sync_identity.py +0 -0
  115. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_sync_migrate.py +0 -0
  116. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_sync_protocol.py +0 -0
  117. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_sync_reset_node.py +0 -0
  118. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_sync_transport.py +0 -0
  119. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_task_retention.py +0 -0
  120. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_task_spool.py +0 -0
  121. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_thin_session.py +0 -0
  122. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_thread_janitor.py +0 -0
  123. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_threads.py +0 -0
  124. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_tool_annotations.py +0 -0
  125. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_tools_smoke.py +0 -0
  126. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_validate_threads.py +0 -0
  127. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_vec_search.py +0 -0
  128. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/tests/test_verify_ingest.py +0 -0
  129. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/__init__.py +0 -0
  130. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/_mcp.py +0 -0
  131. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/_setup.py +0 -0
  132. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/_spawn_wrap.py +0 -0
  133. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/adapters/__init__.py +0 -0
  134. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/adapters/_hook_helpers.py +0 -0
  135. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/adapters/antigravity.py +0 -0
  136. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/adapters/base.py +0 -0
  137. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/adapters/claude_code.py +0 -0
  138. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/adapters/claude_desktop.py +0 -0
  139. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/adapters/codex.py +0 -0
  140. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/adapters/copilot.py +0 -0
  141. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/adapters/vscode.py +0 -0
  142. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/agent_metadata.py +0 -0
  143. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/assets/macos-agent-status/Info.plist +0 -0
  144. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/assets/macos-agent-status/README.md +0 -0
  145. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/assets/macos-agent-status/ThreadKeeperAgentStatus.swift +0 -0
  146. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/assets/macos-agent-status/build.sh +0 -0
  147. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/assets/macos-agent-status/install.sh +0 -0
  148. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/auto_update.py +0 -0
  149. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/backup.py +0 -0
  150. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/brief.py +0 -0
  151. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/candidate_reviewer.py +0 -0
  152. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/config.py +0 -0
  153. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/config_io.py +0 -0
  154. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/config_watcher.py +0 -0
  155. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/curator_snapshots.py +0 -0
  156. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/daemon_liveness.py +0 -0
  157. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/daemon_state.py +0 -0
  158. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/daemon_supervisor.py +0 -0
  159. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/db.py +0 -0
  160. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/dialectic_miner.py +0 -0
  161. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/dialectic_validator.py +0 -0
  162. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/egress.py +0 -0
  163. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/elicitation.py +0 -0
  164. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/embeddings.py +0 -0
  165. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/eval/__init__.py +0 -0
  166. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/eval/__main__.py +0 -0
  167. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/eval/harness.py +0 -0
  168. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/evolve_applier.py +0 -0
  169. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/evolve_daemon.py +0 -0
  170. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/extract_daemon.py +0 -0
  171. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/forget.py +0 -0
  172. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/github_budget.py +0 -0
  173. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/github_safety.py +0 -0
  174. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/harvest.py +0 -0
  175. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/helpers.py +0 -0
  176. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/host.py +0 -0
  177. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/host_embed.py +0 -0
  178. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/i18n.py +0 -0
  179. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/identity.py +0 -0
  180. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/ingest.py +0 -0
  181. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/lessons.py +0 -0
  182. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/memory_guard.py +0 -0
  183. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/menubar_app.py +0 -0
  184. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/migrate_embeddings.py +0 -0
  185. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/model_catalog.py +0 -0
  186. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/nudges.py +0 -0
  187. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/permissions.py +0 -0
  188. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/probe_daemon.py +0 -0
  189. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/process_health.py +0 -0
  190. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/retention.py +0 -0
  191. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/retrieval.py +0 -0
  192. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/review_prompts.py +0 -0
  193. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/search_proxy.py +0 -0
  194. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/server.py +0 -0
  195. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/shadow_review.py +0 -0
  196. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/skill_updater.py +0 -0
  197. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/skill_watcher.py +0 -0
  198. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/spawn_budget.py +0 -0
  199. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/spawn_config.py +0 -0
  200. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/sync/__init__.py +0 -0
  201. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/sync/capture.py +0 -0
  202. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/sync/daemon.py +0 -0
  203. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/sync/identity.py +0 -0
  204. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/sync/migrate.py +0 -0
  205. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/sync/protocol.py +0 -0
  206. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/sync/reset_node.py +0 -0
  207. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/sync/server.py +0 -0
  208. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/task_spool.py +0 -0
  209. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/thread_janitor.py +0 -0
  210. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/tool_schemas.py +0 -0
  211. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/tools/__init__.py +0 -0
  212. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/tools/agent_status.py +0 -0
  213. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/tools/candidate_reviewer.py +0 -0
  214. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/tools/concepts.py +0 -0
  215. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/tools/config_watch.py +0 -0
  216. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/tools/consolidate.py +0 -0
  217. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/tools/core_memory.py +0 -0
  218. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/tools/correlation.py +0 -0
  219. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/tools/curator.py +0 -0
  220. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/tools/dashboard.py +0 -0
  221. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/tools/db_maintenance.py +0 -0
  222. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/tools/dialectic.py +0 -0
  223. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/tools/dialectic_feed.py +0 -0
  224. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/tools/dialog.py +0 -0
  225. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/tools/distill.py +0 -0
  226. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/tools/evolve_applier.py +0 -0
  227. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/tools/extract.py +0 -0
  228. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/tools/forget.py +0 -0
  229. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/tools/graph.py +0 -0
  230. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/tools/invariants.py +0 -0
  231. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/tools/lessons.py +0 -0
  232. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/tools/memory_guard.py +0 -0
  233. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/tools/missed_spawns.py +0 -0
  234. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/tools/panel.py +0 -0
  235. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/tools/peers.py +0 -0
  236. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/tools/pickup.py +0 -0
  237. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/tools/probes.py +0 -0
  238. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/tools/process_health.py +0 -0
  239. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/tools/prompts.py +0 -0
  240. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/tools/resources.py +0 -0
  241. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/tools/session.py +0 -0
  242. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/tools/shadow_review.py +0 -0
  243. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/tools/skills.py +0 -0
  244. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/tools/style.py +0 -0
  245. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/tools/sync.py +0 -0
  246. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/tools/threads.py +0 -0
  247. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/tools/validate.py +0 -0
  248. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/trash.py +0 -0
  249. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/verify_ingest.py +0 -0
  250. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper/wikilinks.py +0 -0
  251. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper.egg-info/dependency_links.txt +0 -0
  252. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper.egg-info/entry_points.txt +0 -0
  253. {threadkeeper-0.17.2 → threadkeeper-0.17.4}/threadkeeper.egg-info/requires.txt +0 -0
  254. {threadkeeper-0.17.2 → 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.2
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
@@ -1058,7 +1070,10 @@ Three detection sources per tick:
1058
1070
  from an exhausted subscription; the real outcome only lands in
1059
1071
  `tasks.return_code`. The reason is read from the child's log tail.
1060
1072
  3. **Materialization** — `skill_materialized` / `skill_create` (skill) and
1061
- `lesson_append` (lesson).
1073
+ `lesson_append` (lesson). Each notification contains one readable artifact
1074
+ name: the skill's first heading (falling back to its directory name), or
1075
+ the lesson name with slug separators replaced by spaces. Paths, provenance,
1076
+ and operation metadata stay out of the notification text.
1062
1077
 
1063
1078
  A per-loop cooldown collapses a lapsed-subscription storm into one actionable
1064
1079
  alert; the first run seeds its cursor to the current position, so historical
@@ -1089,12 +1104,16 @@ will register the app in **System Settings ▸ Notifications** and show banners;
1089
1104
  banners are titled **Thread-Keeper** (`CFBundleDisplayName`). The first launch
1090
1105
  after an update shows a one-time permission prompt.
1091
1106
 
1092
- `agent_status` feeds the app two lists — `recent_results` (positive: captured
1093
- skills/lessons) and `recent_failures` (the two failure sources above) — each item
1094
- tagged with a `notify` flag computed from the toggles below. The app **lists**
1107
+ `agent_status` feeds the app two lists — `recent_results` (named skill/lesson
1108
+ write events plus completed-task history) and `recent_failures` (the two failure
1109
+ sources above) — each item tagged with a `notify` flag computed from the toggles
1110
+ below. The app **lists**
1095
1111
  every item in its menu but only **posts a banner** for flagged ones, so turning a
1096
- category off silences the banner without hiding the history. Enabling a toggle
1097
- never replays backlog.
1112
+ category off silences the banner without hiding the history. Each materialization
1113
+ uses its own skill or lesson toggle; generic task reports are history-only.
1114
+ Foreground writes appear too, without waiting for a background child to finish.
1115
+ A pathless skill mark only silences its reminder and has no materialization
1116
+ banner. Enabling a toggle never replays backlog.
1098
1117
 
1099
1118
  **Settings in the app.** The menu-bar app's **Settings ▸ Notifications** tab
1100
1119
  edits these same `THREADKEEPER_NOTIFY_*` keys visually — switches for the
@@ -1542,8 +1561,9 @@ Spawn task spool files live in `THREADKEEPER_TASK_LOG_DIR` (default
1542
1561
  the hardened `~/.threadkeeper` perimeter by default; explicit overrides are
1543
1562
  refused when the configured directory is a symlink or is not owned by the
1544
1563
  current user. `spawn()` creates captured headless `.log`, stdin prompt spool,
1545
- and visible `.command` files with no-follow owner-only opens. `consolidate()`
1546
- 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.
1547
1567
 
1548
1568
  ---
1549
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
@@ -1014,7 +1026,10 @@ Three detection sources per tick:
1014
1026
  from an exhausted subscription; the real outcome only lands in
1015
1027
  `tasks.return_code`. The reason is read from the child's log tail.
1016
1028
  3. **Materialization** — `skill_materialized` / `skill_create` (skill) and
1017
- `lesson_append` (lesson).
1029
+ `lesson_append` (lesson). Each notification contains one readable artifact
1030
+ name: the skill's first heading (falling back to its directory name), or
1031
+ the lesson name with slug separators replaced by spaces. Paths, provenance,
1032
+ and operation metadata stay out of the notification text.
1018
1033
 
1019
1034
  A per-loop cooldown collapses a lapsed-subscription storm into one actionable
1020
1035
  alert; the first run seeds its cursor to the current position, so historical
@@ -1045,12 +1060,16 @@ will register the app in **System Settings ▸ Notifications** and show banners;
1045
1060
  banners are titled **Thread-Keeper** (`CFBundleDisplayName`). The first launch
1046
1061
  after an update shows a one-time permission prompt.
1047
1062
 
1048
- `agent_status` feeds the app two lists — `recent_results` (positive: captured
1049
- skills/lessons) and `recent_failures` (the two failure sources above) — each item
1050
- tagged with a `notify` flag computed from the toggles below. The app **lists**
1063
+ `agent_status` feeds the app two lists — `recent_results` (named skill/lesson
1064
+ write events plus completed-task history) and `recent_failures` (the two failure
1065
+ sources above) — each item tagged with a `notify` flag computed from the toggles
1066
+ below. The app **lists**
1051
1067
  every item in its menu but only **posts a banner** for flagged ones, so turning a
1052
- category off silences the banner without hiding the history. Enabling a toggle
1053
- never replays backlog.
1068
+ category off silences the banner without hiding the history. Each materialization
1069
+ uses its own skill or lesson toggle; generic task reports are history-only.
1070
+ Foreground writes appear too, without waiting for a background child to finish.
1071
+ A pathless skill mark only silences its reminder and has no materialization
1072
+ banner. Enabling a toggle never replays backlog.
1054
1073
 
1055
1074
  **Settings in the app.** The menu-bar app's **Settings ▸ Notifications** tab
1056
1075
  edits these same `THREADKEEPER_NOTIFY_*` keys visually — switches for the
@@ -1498,8 +1517,9 @@ Spawn task spool files live in `THREADKEEPER_TASK_LOG_DIR` (default
1498
1517
  the hardened `~/.threadkeeper` perimeter by default; explicit overrides are
1499
1518
  refused when the configured directory is a symlink or is not owned by the
1500
1519
  current user. `spawn()` creates captured headless `.log`, stdin prompt spool,
1501
- and visible `.command` files with no-follow owner-only opens. `consolidate()`
1502
- 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.
1503
1523
 
1504
1524
  ---
1505
1525
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "threadkeeper"
7
- version = "0.17.2"
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" }]
@@ -4,6 +4,8 @@ import json
4
4
  import os
5
5
  import time
6
6
 
7
+ import pytest
8
+
7
9
 
8
10
  _FAKE_CID = "33334444-5555-6666-7777-888899990000"
9
11
 
@@ -468,6 +470,42 @@ def test_agent_status_recent_results_for_useful_completed_tasks(mp_with_cid):
468
470
  assert snap["recent_results"][0]["summary"] == (
469
471
  "Processed 2 candidates into durable notes."
470
472
  )
473
+ assert snap["recent_results"][0]["notify"] is False
474
+
475
+
476
+ @pytest.mark.parametrize("poll,skill,lesson", [
477
+ (30, True, True), (30, True, False), (30, False, True), (0, True, True),
478
+ ])
479
+ def test_materialization_results_name_actual_writes(mp_with_cid, poll, skill, lesson):
480
+ pkg = mp_with_cid(_FAKE_CID)
481
+ pkg["config"].NOTIFY_POLL_S = poll
482
+ pkg["config"].NOTIFY_SKILL_MATERIALIZED = skill
483
+ pkg["config"].NOTIFY_LESSON = lesson
484
+ result = _txt(_tool(pkg, "skill_manage")(
485
+ action="create", name="api-contract-testing",
486
+ description="Test API contracts.", content="# API contract testing\n\nUse schemas.",
487
+ ))
488
+ assert result.startswith("ok"), result
489
+ result = _txt(_tool(pkg, "lesson_append")(
490
+ title="Verify state transitions", body="Assert the state after the action.",
491
+ source="T1",
492
+ ))
493
+ assert result.startswith("ok"), result
494
+ _insert_completed_task(pkg, "tk_generic_done",
495
+ "You are a CANDIDATE REVIEWER for thread-keeper's extract queue.",
496
+ "Processed 2 candidates into durable notes.\n")
497
+
498
+ from threadkeeper.agent_status import agent_status_snapshot
499
+
500
+ items = agent_status_snapshot(refresh=False)["recent_results"]
501
+ by_role = {item["role"]: item for item in items}
502
+ assert by_role["skill"]["summary"] == "API contract testing"
503
+ assert by_role["lesson"]["summary"] == "Verify state transitions"
504
+ assert by_role["skill"]["notify"] is bool(poll and skill)
505
+ assert by_role["lesson"]["notify"] is bool(poll and lesson)
506
+ assert by_role["candidate_reviewer"]["notify"] is False
507
+ assert all(item["id"].startswith("materialization:")
508
+ for item in items if item["role"] in ("skill", "lesson"))
471
509
 
472
510
 
473
511
  def test_agent_status_mcp_json_output(mp_with_cid):
@@ -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"]
@@ -228,8 +228,48 @@ def test_positives_on_fire(tmp_path, monkeypatch, caplog):
228
228
  with caplog.at_level(logging.WARNING, logger="threadkeeper.notify"):
229
229
  out = notify.run_notify_pass(force=True)
230
230
  assert out == "ok fired=2", out
231
- assert "skill materialized" in caplog.text
232
- assert "lesson added" in caplog.text and "my-lesson" in caplog.text
231
+ assert "skill materialized | Foo" in caplog.text
232
+ assert "lesson added | My lesson" in caplog.text
233
+ assert "/skills/" not in caplog.text
234
+ assert "op=create" not in caplog.text and "source=shadow" not in caplog.text
235
+
236
+
237
+ @pytest.mark.parametrize("directory", [False, True])
238
+ def test_skill_notification_uses_heading(tmp_path, monkeypatch, directory):
239
+ m = _bootstrap(tmp_path, monkeypatch, skill="true")
240
+ md = tmp_path / "api-contract-testing" / "SKILL.md"
241
+ md.parent.mkdir()
242
+ md.write_text("---\nname: api-contract-testing\ndescription: |\n"
243
+ " Internal description\n---\n\n# API contract testing\n"
244
+ "\nLong implementation details that do not belong in a banner.\n")
245
+ notify = m["notify"]
246
+ conn = m["db"].get_db()
247
+ sent = []
248
+ monkeypatch.setattr(notify, "_dispatch", lambda *args: sent.append(args))
249
+ assert notify.run_notify_pass(force=True) == "seed"
250
+ _ev(conn, "skill_materialized", target="T1",
251
+ summary=str(md.parent if directory else md))
252
+ assert notify.run_notify_pass(force=True) == "ok fired=1"
253
+ assert sent == [("Thread-keeper: skill materialized", "API contract testing")]
254
+ assert notify.run_notify_pass(force=True) == "ok fired=0"
255
+
256
+
257
+ @pytest.mark.parametrize("body", [b"---\nname: foo\n---\nNo heading.", b"\xff"])
258
+ def test_skill_notification_falls_back_to_name(tmp_path, monkeypatch, body):
259
+ notify = _bootstrap(tmp_path, monkeypatch)["notify"]
260
+ md = tmp_path / "api-contract-testing" / "SKILL.md"
261
+ md.parent.mkdir()
262
+ md.write_bytes(body)
263
+ assert notify._fmt_skill({"kind": "skill_create", "target": "api-contract-testing",
264
+ "summary": str(md)})[1] == "Api contract testing"
265
+
266
+
267
+ def test_pathless_skill_mark_does_not_claim_a_materialization(tmp_path, monkeypatch):
268
+ m = _bootstrap(tmp_path, monkeypatch, skill="true")
269
+ notify, conn = m["notify"], m["db"].get_db()
270
+ assert notify.run_notify_pass(force=True) == "seed"
271
+ _ev(conn, "skill_materialized", target="T1", summary="(no path recorded)")
272
+ assert notify.run_notify_pass(force=True) == "ok fired=0"
233
273
 
234
274
 
235
275
  # ── disabled ────────────────────────────────────────────────────────────────
@@ -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
@@ -878,17 +878,47 @@ def _extract_useful_result(task_id: str) -> str:
878
878
  return ""
879
879
 
880
880
 
881
- def _recent_results(conn, now: int, limit: int = 10) -> list[dict[str, Any]]:
881
+ def _recent_materializations(conn, now: int, limit: int) -> list[dict[str, Any]]:
882
+ """Name actual writes, including foreground writes without a child task."""
882
883
  from . import config
883
- # Positive materialization notifications (#257). These are captured skills /
884
- # lessons surfaced as useful loop output; the app posts a banner only when
885
- # notifications are enabled (NOTIFY_POLL_S master gate) and a positive toggle
886
- # is on, but the menu always lists them (`notify` flag).
884
+ from .notify import _fmt_skill, _fmt_lesson
885
+
887
886
  enabled = float(getattr(config, "NOTIFY_POLL_S", 0) or 0) > 0
888
- positive_notify = enabled and bool(
889
- getattr(config, "NOTIFY_SKILL_MATERIALIZED", False)
890
- or getattr(config, "NOTIFY_LESSON", False)
891
- )
887
+ rows = conn.execute(
888
+ "SELECT id, kind, target, summary, created_at FROM events "
889
+ "WHERE kind IN ('skill_create', 'skill_materialized', 'lesson_append') "
890
+ "AND created_at>=? ORDER BY created_at DESC, id DESC LIMIT ?",
891
+ (now - _RESULT_WINDOW_S, int(limit)),
892
+ ).fetchall()
893
+ results = []
894
+ for row in rows:
895
+ skill = row["kind"] != "lesson_append"
896
+ title, summary = (_fmt_skill if skill else _fmt_lesson)(dict(row))
897
+ if not summary:
898
+ continue
899
+ age_s = max(0, now - row["created_at"])
900
+ results.append({
901
+ "id": f"materialization:{row['id']}",
902
+ "task_id": "",
903
+ "role": "skill" if skill else "lesson",
904
+ "loop_id": "",
905
+ "loop_name": "Skill" if skill else "Lesson",
906
+ "title": title,
907
+ "summary": summary,
908
+ "ended_at": row["created_at"],
909
+ "age_s": age_s,
910
+ "age": fmt_age(age_s),
911
+ "notify": enabled and bool(
912
+ config.NOTIFY_SKILL_MATERIALIZED if skill else config.NOTIFY_LESSON
913
+ ),
914
+ })
915
+ return results
916
+
917
+
918
+ def _recent_results(conn, now: int, limit: int = 10) -> list[dict[str, Any]]:
919
+ # Event-backed materializations carry names and per-category toggles.
920
+ # Generic completion reports remain in history without duplicate banners.
921
+ materializations = _recent_materializations(conn, now, limit)
892
922
  role_loop = _role_to_loop()
893
923
  rows = conn.execute(
894
924
  "SELECT id, prompt, ended_at, return_code FROM tasks "
@@ -919,11 +949,12 @@ def _recent_results(conn, now: int, limit: int = 10) -> list[dict[str, Any]]:
919
949
  "ended_at": ended_at,
920
950
  "age_s": age_s,
921
951
  "age": fmt_age(age_s),
922
- "notify": positive_notify,
952
+ "notify": False,
923
953
  })
924
954
  if len(results) >= limit:
925
955
  break
926
- return results
956
+ return sorted(materializations + results, key=lambda r: r["ended_at"],
957
+ reverse=True)[:limit]
927
958
 
928
959
 
929
960
  def _recent_failures(conn, now: int, limit: int = 10) -> list[dict[str, Any]]:
@@ -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:
@@ -27,6 +27,8 @@ from __future__ import annotations
27
27
 
28
28
  import logging
29
29
  import os
30
+ from pathlib import Path
31
+ import re
30
32
  import sqlite3
31
33
  import subprocess
32
34
  import threading
@@ -349,11 +351,43 @@ def _fmt_child(c: dict) -> tuple[str, str]:
349
351
 
350
352
 
351
353
  def _fmt_skill(s: dict) -> tuple[str, str]:
352
- return ("Thread-keeper: skill materialized", (s["summary"] or s["target"])[:200])
354
+ path = (s.get("summary") or "").strip()
355
+ name = s.get("target", "") if s.get("kind") == "skill_create" else ""
356
+ if path and path != "(no path recorded)":
357
+ md = Path(path).expanduser()
358
+ if md.name == "SKILL.md":
359
+ name = md.parent.name
360
+ else:
361
+ name = md.name
362
+ md = md / "SKILL.md"
363
+ try:
364
+ # A title is near the top. Never load an entire skill (or follow
365
+ # paths mentioned in its body) just to render a notification.
366
+ body = ""
367
+ if md.is_file():
368
+ with md.open(encoding="utf-8") as stream:
369
+ body = stream.read(8192)
370
+ if body.startswith("---\n"):
371
+ body = body.split("\n---", 1)[-1]
372
+ heading = re.search(r"^#\s+(.+?)(?:\s+#+)?\s*$", body, re.MULTILINE)
373
+ if heading:
374
+ name = heading.group(1)
375
+ except (OSError, ValueError):
376
+ pass
377
+ return ("Thread-keeper: skill materialized", _artifact_name(name))
378
+
379
+
380
+ def _artifact_name(name: str) -> str:
381
+ """One readable name, without slug separators or Markdown decoration."""
382
+ name = " ".join((name or "").replace("`", "").split())
383
+ if " " not in name:
384
+ name = re.sub(r"[-_]+", " ", name)
385
+ name = name[:1].upper() + name[1:]
386
+ return name[:200]
353
387
 
354
388
 
355
389
  def _fmt_lesson(l: dict) -> tuple[str, str]:
356
- return ("Thread-keeper: lesson added", f"{l['target']} ({l['summary']})"[:200])
390
+ return ("Thread-keeper: lesson added", _artifact_name(l["target"]))
357
391
 
358
392
 
359
393
  # ── pass + daemon lifecycle ─────────────────────────────────────────────────
@@ -401,8 +435,10 @@ def run_notify_pass(force: bool = False, *, scheduled: bool = False) -> str:
401
435
  _dispatch(*_fmt_child(c))
402
436
  fired += 1
403
437
  for s in skills:
404
- _dispatch(*_fmt_skill(s))
405
- fired += 1
438
+ title, body = _fmt_skill(s)
439
+ if body: # A pathless mark only silences a nudge; no named artifact.
440
+ _dispatch(title, body)
441
+ fired += 1
406
442
  for l in lessons:
407
443
  _dispatch(*_fmt_lesson(l))
408
444
  fired += 1
@@ -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
  )