threadkeeper 0.5.2__tar.gz → 0.5.3__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 (100) hide show
  1. {threadkeeper-0.5.2 → threadkeeper-0.5.3}/PKG-INFO +18 -15
  2. {threadkeeper-0.5.2 → threadkeeper-0.5.3}/README.md +17 -14
  3. {threadkeeper-0.5.2 → threadkeeper-0.5.3}/pyproject.toml +1 -1
  4. {threadkeeper-0.5.2 → threadkeeper-0.5.3}/tests/test_skill_hint.py +1 -1
  5. {threadkeeper-0.5.2 → threadkeeper-0.5.3}/tests/test_skills.py +66 -1
  6. {threadkeeper-0.5.2 → threadkeeper-0.5.3}/threadkeeper/brief.py +2 -2
  7. {threadkeeper-0.5.2 → threadkeeper-0.5.3}/threadkeeper/review_prompts.py +4 -4
  8. {threadkeeper-0.5.2 → threadkeeper-0.5.3}/threadkeeper/shadow_review.py +3 -3
  9. {threadkeeper-0.5.2 → threadkeeper-0.5.3}/threadkeeper/tools/skills.py +116 -41
  10. {threadkeeper-0.5.2 → threadkeeper-0.5.3}/threadkeeper/tools/threads.py +12 -3
  11. {threadkeeper-0.5.2 → threadkeeper-0.5.3}/threadkeeper.egg-info/PKG-INFO +18 -15
  12. {threadkeeper-0.5.2 → threadkeeper-0.5.3}/LICENSE +0 -0
  13. {threadkeeper-0.5.2 → threadkeeper-0.5.3}/setup.cfg +0 -0
  14. {threadkeeper-0.5.2 → threadkeeper-0.5.3}/tests/test_adapters.py +0 -0
  15. {threadkeeper-0.5.2 → threadkeeper-0.5.3}/tests/test_brief_sections.py +0 -0
  16. {threadkeeper-0.5.2 → threadkeeper-0.5.3}/tests/test_candidate_reviewer.py +0 -0
  17. {threadkeeper-0.5.2 → threadkeeper-0.5.3}/tests/test_core_memory.py +0 -0
  18. {threadkeeper-0.5.2 → threadkeeper-0.5.3}/tests/test_curator.py +0 -0
  19. {threadkeeper-0.5.2 → threadkeeper-0.5.3}/tests/test_delegated_search.py +0 -0
  20. {threadkeeper-0.5.2 → threadkeeper-0.5.3}/tests/test_dialectic.py +0 -0
  21. {threadkeeper-0.5.2 → threadkeeper-0.5.3}/tests/test_dialectic_tier.py +0 -0
  22. {threadkeeper-0.5.2 → threadkeeper-0.5.3}/tests/test_error_paths.py +0 -0
  23. {threadkeeper-0.5.2 → threadkeeper-0.5.3}/tests/test_extract_daemon.py +0 -0
  24. {threadkeeper-0.5.2 → threadkeeper-0.5.3}/tests/test_i18n_multilang.py +0 -0
  25. {threadkeeper-0.5.2 → threadkeeper-0.5.3}/tests/test_identity.py +0 -0
  26. {threadkeeper-0.5.2 → threadkeeper-0.5.3}/tests/test_lessons.py +0 -0
  27. {threadkeeper-0.5.2 → threadkeeper-0.5.3}/tests/test_missed_spawns.py +0 -0
  28. {threadkeeper-0.5.2 → threadkeeper-0.5.3}/tests/test_nudges.py +0 -0
  29. {threadkeeper-0.5.2 → threadkeeper-0.5.3}/tests/test_process_health.py +0 -0
  30. {threadkeeper-0.5.2 → threadkeeper-0.5.3}/tests/test_shadow_review.py +0 -0
  31. {threadkeeper-0.5.2 → threadkeeper-0.5.3}/tests/test_skill_tier.py +0 -0
  32. {threadkeeper-0.5.2 → threadkeeper-0.5.3}/tests/test_skill_use_parser.py +0 -0
  33. {threadkeeper-0.5.2 → threadkeeper-0.5.3}/tests/test_skill_watcher.py +0 -0
  34. {threadkeeper-0.5.2 → threadkeeper-0.5.3}/tests/test_spawn_budget.py +0 -0
  35. {threadkeeper-0.5.2 → threadkeeper-0.5.3}/tests/test_spawn_config.py +0 -0
  36. {threadkeeper-0.5.2 → threadkeeper-0.5.3}/tests/test_spawn_hint.py +0 -0
  37. {threadkeeper-0.5.2 → threadkeeper-0.5.3}/tests/test_spawn_slim.py +0 -0
  38. {threadkeeper-0.5.2 → threadkeeper-0.5.3}/tests/test_threads.py +0 -0
  39. {threadkeeper-0.5.2 → threadkeeper-0.5.3}/tests/test_tools_smoke.py +0 -0
  40. {threadkeeper-0.5.2 → threadkeeper-0.5.3}/tests/test_validate_threads.py +0 -0
  41. {threadkeeper-0.5.2 → threadkeeper-0.5.3}/tests/test_vec_search.py +0 -0
  42. {threadkeeper-0.5.2 → threadkeeper-0.5.3}/threadkeeper/__init__.py +0 -0
  43. {threadkeeper-0.5.2 → threadkeeper-0.5.3}/threadkeeper/_mcp.py +0 -0
  44. {threadkeeper-0.5.2 → threadkeeper-0.5.3}/threadkeeper/_setup.py +0 -0
  45. {threadkeeper-0.5.2 → threadkeeper-0.5.3}/threadkeeper/adapters/__init__.py +0 -0
  46. {threadkeeper-0.5.2 → threadkeeper-0.5.3}/threadkeeper/adapters/_hook_helpers.py +0 -0
  47. {threadkeeper-0.5.2 → threadkeeper-0.5.3}/threadkeeper/adapters/base.py +0 -0
  48. {threadkeeper-0.5.2 → threadkeeper-0.5.3}/threadkeeper/adapters/claude_code.py +0 -0
  49. {threadkeeper-0.5.2 → threadkeeper-0.5.3}/threadkeeper/adapters/claude_desktop.py +0 -0
  50. {threadkeeper-0.5.2 → threadkeeper-0.5.3}/threadkeeper/adapters/codex.py +0 -0
  51. {threadkeeper-0.5.2 → threadkeeper-0.5.3}/threadkeeper/adapters/copilot.py +0 -0
  52. {threadkeeper-0.5.2 → threadkeeper-0.5.3}/threadkeeper/adapters/gemini.py +0 -0
  53. {threadkeeper-0.5.2 → threadkeeper-0.5.3}/threadkeeper/adapters/vscode.py +0 -0
  54. {threadkeeper-0.5.2 → threadkeeper-0.5.3}/threadkeeper/candidate_reviewer.py +0 -0
  55. {threadkeeper-0.5.2 → threadkeeper-0.5.3}/threadkeeper/config.py +0 -0
  56. {threadkeeper-0.5.2 → threadkeeper-0.5.3}/threadkeeper/curator.py +0 -0
  57. {threadkeeper-0.5.2 → threadkeeper-0.5.3}/threadkeeper/db.py +0 -0
  58. {threadkeeper-0.5.2 → threadkeeper-0.5.3}/threadkeeper/embeddings.py +0 -0
  59. {threadkeeper-0.5.2 → threadkeeper-0.5.3}/threadkeeper/extract_daemon.py +0 -0
  60. {threadkeeper-0.5.2 → threadkeeper-0.5.3}/threadkeeper/helpers.py +0 -0
  61. {threadkeeper-0.5.2 → threadkeeper-0.5.3}/threadkeeper/i18n.py +0 -0
  62. {threadkeeper-0.5.2 → threadkeeper-0.5.3}/threadkeeper/identity.py +0 -0
  63. {threadkeeper-0.5.2 → threadkeeper-0.5.3}/threadkeeper/ingest.py +0 -0
  64. {threadkeeper-0.5.2 → threadkeeper-0.5.3}/threadkeeper/lessons.py +0 -0
  65. {threadkeeper-0.5.2 → threadkeeper-0.5.3}/threadkeeper/nudges.py +0 -0
  66. {threadkeeper-0.5.2 → threadkeeper-0.5.3}/threadkeeper/process_health.py +0 -0
  67. {threadkeeper-0.5.2 → threadkeeper-0.5.3}/threadkeeper/search_proxy.py +0 -0
  68. {threadkeeper-0.5.2 → threadkeeper-0.5.3}/threadkeeper/server.py +0 -0
  69. {threadkeeper-0.5.2 → threadkeeper-0.5.3}/threadkeeper/skill_watcher.py +0 -0
  70. {threadkeeper-0.5.2 → threadkeeper-0.5.3}/threadkeeper/spawn_budget.py +0 -0
  71. {threadkeeper-0.5.2 → threadkeeper-0.5.3}/threadkeeper/spawn_config.py +0 -0
  72. {threadkeeper-0.5.2 → threadkeeper-0.5.3}/threadkeeper/tools/__init__.py +0 -0
  73. {threadkeeper-0.5.2 → threadkeeper-0.5.3}/threadkeeper/tools/candidate_reviewer.py +0 -0
  74. {threadkeeper-0.5.2 → threadkeeper-0.5.3}/threadkeeper/tools/concepts.py +0 -0
  75. {threadkeeper-0.5.2 → threadkeeper-0.5.3}/threadkeeper/tools/consolidate.py +0 -0
  76. {threadkeeper-0.5.2 → threadkeeper-0.5.3}/threadkeeper/tools/core_memory.py +0 -0
  77. {threadkeeper-0.5.2 → threadkeeper-0.5.3}/threadkeeper/tools/correlation.py +0 -0
  78. {threadkeeper-0.5.2 → threadkeeper-0.5.3}/threadkeeper/tools/curator.py +0 -0
  79. {threadkeeper-0.5.2 → threadkeeper-0.5.3}/threadkeeper/tools/dialectic.py +0 -0
  80. {threadkeeper-0.5.2 → threadkeeper-0.5.3}/threadkeeper/tools/dialog.py +0 -0
  81. {threadkeeper-0.5.2 → threadkeeper-0.5.3}/threadkeeper/tools/distill.py +0 -0
  82. {threadkeeper-0.5.2 → threadkeeper-0.5.3}/threadkeeper/tools/extract.py +0 -0
  83. {threadkeeper-0.5.2 → threadkeeper-0.5.3}/threadkeeper/tools/graph.py +0 -0
  84. {threadkeeper-0.5.2 → threadkeeper-0.5.3}/threadkeeper/tools/invariants.py +0 -0
  85. {threadkeeper-0.5.2 → threadkeeper-0.5.3}/threadkeeper/tools/lessons.py +0 -0
  86. {threadkeeper-0.5.2 → threadkeeper-0.5.3}/threadkeeper/tools/missed_spawns.py +0 -0
  87. {threadkeeper-0.5.2 → threadkeeper-0.5.3}/threadkeeper/tools/peers.py +0 -0
  88. {threadkeeper-0.5.2 → threadkeeper-0.5.3}/threadkeeper/tools/pickup.py +0 -0
  89. {threadkeeper-0.5.2 → threadkeeper-0.5.3}/threadkeeper/tools/probes.py +0 -0
  90. {threadkeeper-0.5.2 → threadkeeper-0.5.3}/threadkeeper/tools/process_health.py +0 -0
  91. {threadkeeper-0.5.2 → threadkeeper-0.5.3}/threadkeeper/tools/session.py +0 -0
  92. {threadkeeper-0.5.2 → threadkeeper-0.5.3}/threadkeeper/tools/shadow_review.py +0 -0
  93. {threadkeeper-0.5.2 → threadkeeper-0.5.3}/threadkeeper/tools/spawn.py +0 -0
  94. {threadkeeper-0.5.2 → threadkeeper-0.5.3}/threadkeeper/tools/style.py +0 -0
  95. {threadkeeper-0.5.2 → threadkeeper-0.5.3}/threadkeeper/tools/validate.py +0 -0
  96. {threadkeeper-0.5.2 → threadkeeper-0.5.3}/threadkeeper.egg-info/SOURCES.txt +0 -0
  97. {threadkeeper-0.5.2 → threadkeeper-0.5.3}/threadkeeper.egg-info/dependency_links.txt +0 -0
  98. {threadkeeper-0.5.2 → threadkeeper-0.5.3}/threadkeeper.egg-info/entry_points.txt +0 -0
  99. {threadkeeper-0.5.2 → threadkeeper-0.5.3}/threadkeeper.egg-info/requires.txt +0 -0
  100. {threadkeeper-0.5.2 → threadkeeper-0.5.3}/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.5.2
3
+ Version: 0.5.3
4
4
  Summary: Multi-agent shared brain across Claude Code/Desktop, Codex, Gemini, Copilot, 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
@@ -82,10 +82,10 @@ make it more than a memory store:
82
82
  harvester, candidate-reviewer, weekly Curator) materialize
83
83
  class-level skills as the agents work. Adapted to multi-CLI:
84
84
  SKILL.md is the primary write target and gets mirrored to every
85
- detected CLI's skills directory simultaneously
86
- (`~/.claude/skills/`, `~/.codex/skills/`, `~/.threadkeeper/skills/`),
87
- with lessons.md as a fallback for CLIs without a native skills
88
- loader.
85
+ known/configured skills root simultaneously (`~/.claude/skills/`,
86
+ `~/.codex/skills/`, existing `~/.agents/skills/`, extra roots from
87
+ `THREADKEEPER_EXTRA_SKILLS_DIRS`, and `~/.threadkeeper/skills/`),
88
+ with lessons.md as a fallback for CLIs without a native skills loader.
89
89
 
90
90
  ---
91
91
 
@@ -223,8 +223,8 @@ shows agents focused on their primary task rarely do).
223
223
  brief() SKILL.md + lessons.md ─► skill_usage │
224
224
  │ │ │ │
225
225
  │ ▼ ▼ │
226
- │ (every detected CLI's │ │
227
- │ skills/ directory) │ │
226
+ │ (every configured │ │
227
+ │ skills/ root) │ │
228
228
  │ │ │ │
229
229
  │ └──────► [5] Curator daemon ───┘
230
230
  │ (cron, every 7d)
@@ -246,11 +246,11 @@ shows agents focused on their primary task rarely do).
246
246
  | 5 | Curator daemon | every 7 days (env knob) | every existing lesson + recently-touched skill | REPORT-`<date>`.md (advisory) or direct PATCH/PRUNE/CONSOLIDATE |
247
247
 
248
248
  All five write into the universal Skill format (`SKILL.md` under each
249
- detected CLI's skills directory — `~/.claude/skills/`,
250
- `~/.codex/skills/`, plus the canonical `~/.threadkeeper/skills/`
251
- mirror), with `~/.threadkeeper/lessons.md` as a CLI-agnostic fallback
252
- for clients without a native skills loader (Gemini, Copilot, bare
253
- MCP).
249
+ known/configured skills root — `~/.claude/skills/`, `~/.codex/skills/`,
250
+ existing `~/.agents/skills/`, optional `THREADKEEPER_EXTRA_SKILLS_DIRS`,
251
+ plus the canonical `~/.threadkeeper/skills/` mirror), with
252
+ `~/.threadkeeper/lessons.md` as a CLI-agnostic fallback for clients
253
+ without a native skills loader (Gemini, Copilot, bare MCP).
254
254
 
255
255
  #### 1. Auto-review on close_thread
256
256
 
@@ -260,9 +260,12 @@ thread's notes. The prompt is rubric-form (Q1–Q5 yes/no) with explicit
260
260
  positive examples for incident-vs-rule classification. The fork also
261
261
  receives a "recently active skills" block so it prefers PATCHing
262
262
  existing umbrellas over creating new ones (*active-update bias*).
263
- Child appends a lesson via `lesson_append`, optionally mirrors to
264
- `~/.claude/skills/<name>/SKILL.md`, then closes with
265
- `mark_skill_materialized`. Opt in with `THREADKEEPER_AUTO_REVIEW=1`.
263
+ Child appends a lesson via `lesson_append`, writes/patches a skill via
264
+ `skill_manage` or writes a skill file directly, then closes with
265
+ `mark_skill_materialized`. If `skill_path` points at a `SKILL.md` (or a
266
+ skill directory), thread-keeper immediately mirrors that whole skill
267
+ into every configured skills root. Opt in with
268
+ `THREADKEEPER_AUTO_REVIEW=1`.
266
269
 
267
270
  #### 2. Shadow-review daemon
268
271
 
@@ -48,10 +48,10 @@ make it more than a memory store:
48
48
  harvester, candidate-reviewer, weekly Curator) materialize
49
49
  class-level skills as the agents work. Adapted to multi-CLI:
50
50
  SKILL.md is the primary write target and gets mirrored to every
51
- detected CLI's skills directory simultaneously
52
- (`~/.claude/skills/`, `~/.codex/skills/`, `~/.threadkeeper/skills/`),
53
- with lessons.md as a fallback for CLIs without a native skills
54
- loader.
51
+ known/configured skills root simultaneously (`~/.claude/skills/`,
52
+ `~/.codex/skills/`, existing `~/.agents/skills/`, extra roots from
53
+ `THREADKEEPER_EXTRA_SKILLS_DIRS`, and `~/.threadkeeper/skills/`),
54
+ with lessons.md as a fallback for CLIs without a native skills loader.
55
55
 
56
56
  ---
57
57
 
@@ -189,8 +189,8 @@ shows agents focused on their primary task rarely do).
189
189
  brief() SKILL.md + lessons.md ─► skill_usage │
190
190
  │ │ │ │
191
191
  │ ▼ ▼ │
192
- │ (every detected CLI's │ │
193
- │ skills/ directory) │ │
192
+ │ (every configured │ │
193
+ │ skills/ root) │ │
194
194
  │ │ │ │
195
195
  │ └──────► [5] Curator daemon ───┘
196
196
  │ (cron, every 7d)
@@ -212,11 +212,11 @@ shows agents focused on their primary task rarely do).
212
212
  | 5 | Curator daemon | every 7 days (env knob) | every existing lesson + recently-touched skill | REPORT-`<date>`.md (advisory) or direct PATCH/PRUNE/CONSOLIDATE |
213
213
 
214
214
  All five write into the universal Skill format (`SKILL.md` under each
215
- detected CLI's skills directory — `~/.claude/skills/`,
216
- `~/.codex/skills/`, plus the canonical `~/.threadkeeper/skills/`
217
- mirror), with `~/.threadkeeper/lessons.md` as a CLI-agnostic fallback
218
- for clients without a native skills loader (Gemini, Copilot, bare
219
- MCP).
215
+ known/configured skills root — `~/.claude/skills/`, `~/.codex/skills/`,
216
+ existing `~/.agents/skills/`, optional `THREADKEEPER_EXTRA_SKILLS_DIRS`,
217
+ plus the canonical `~/.threadkeeper/skills/` mirror), with
218
+ `~/.threadkeeper/lessons.md` as a CLI-agnostic fallback for clients
219
+ without a native skills loader (Gemini, Copilot, bare MCP).
220
220
 
221
221
  #### 1. Auto-review on close_thread
222
222
 
@@ -226,9 +226,12 @@ thread's notes. The prompt is rubric-form (Q1–Q5 yes/no) with explicit
226
226
  positive examples for incident-vs-rule classification. The fork also
227
227
  receives a "recently active skills" block so it prefers PATCHing
228
228
  existing umbrellas over creating new ones (*active-update bias*).
229
- Child appends a lesson via `lesson_append`, optionally mirrors to
230
- `~/.claude/skills/<name>/SKILL.md`, then closes with
231
- `mark_skill_materialized`. Opt in with `THREADKEEPER_AUTO_REVIEW=1`.
229
+ Child appends a lesson via `lesson_append`, writes/patches a skill via
230
+ `skill_manage` or writes a skill file directly, then closes with
231
+ `mark_skill_materialized`. If `skill_path` points at a `SKILL.md` (or a
232
+ skill directory), thread-keeper immediately mirrors that whole skill
233
+ into every configured skills root. Opt in with
234
+ `THREADKEEPER_AUTO_REVIEW=1`.
232
235
 
233
236
  #### 2. Shadow-review daemon
234
237
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "threadkeeper"
7
- version = "0.5.2"
7
+ version = "0.5.3"
8
8
  description = "Multi-agent shared brain across Claude Code/Desktop, Codex, Gemini, Copilot, 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" }]
@@ -1,5 +1,5 @@
1
1
  """brief() skill_hint nudge — fires when a recently-closed thread is rich
2
- enough to be worth materializing as a Claude skill under ~/.claude/skills/.
2
+ enough to be worth materializing as a mirrored reusable skill.
3
3
 
4
4
  After complex tasks, the agent should turn distilled insights into
5
5
  reusable skills, not let them sit only in notes. The nudge surfaces
@@ -535,7 +535,7 @@ def test_skill_manage_mirrors_skill_across_detected_clis(
535
535
  skills_pkg, monkeypatch,
536
536
  ):
537
537
  """skill_manage(create) must write SKILL.md not only to the canonical
538
- ~/.claude/skills/ but also to every detected CLI's skills_dir() —
538
+ primary skills root but also to every configured skills_dir() —
539
539
  so one materialization reaches Claude AND Codex at once."""
540
540
  # Pin two mirror targets that we own (in tmp_path) so the test is
541
541
  # hermetic — don't touch the developer's ~/.codex/ or ~/.threadkeeper/.
@@ -568,6 +568,71 @@ def test_skill_manage_mirrors_skill_across_detected_clis(
568
568
  canonical.read_text()
569
569
 
570
570
 
571
+ def test_mirror_targets_include_known_skill_roots_without_install_detection(
572
+ skills_pkg, monkeypatch,
573
+ ):
574
+ """Mirror planning should include native skill roots even when an
575
+ adapter's executable/config detection would be false. Skill roots are
576
+ cheap paths; creation should not depend on whether the CLI has already
577
+ written a config file."""
578
+ from threadkeeper.tools import skills as st
579
+ import threadkeeper.adapters as adapters
580
+
581
+ class FakeAdapter:
582
+ def skills_dir(self):
583
+ return skills_pkg["tmp"] / "future_cli_skills"
584
+
585
+ monkeypatch.setattr(adapters, "ADAPTERS", [FakeAdapter()])
586
+
587
+ targets = st._mirror_targets("future-skill")
588
+ assert skills_pkg["tmp"] / "future_cli_skills" / "future-skill" in targets
589
+
590
+
591
+ def test_mark_skill_materialized_mirrors_external_skill_dir(
592
+ skills_pkg, monkeypatch,
593
+ ):
594
+ """If an agent created a skill directly in Codex/Claude and then calls
595
+ mark_skill_materialized(skill_path=...), thread-keeper should copy the
596
+ whole skill dir to canonical + mirrors."""
597
+ mirror_codex = skills_pkg["tmp"] / "fake_codex_skills"
598
+ mirror_agents = skills_pkg["tmp"] / "fake_agents_skills"
599
+
600
+ from threadkeeper.tools import skills as st
601
+
602
+ def fake_mirror_targets(name):
603
+ return [mirror_codex / name, mirror_agents / name]
604
+
605
+ monkeypatch.setattr(st, "_mirror_targets", fake_mirror_targets)
606
+
607
+ external = skills_pkg["tmp"] / "external_codex" / "external-sync"
608
+ (external / "agents").mkdir(parents=True)
609
+ (external / "SKILL.md").write_text(
610
+ "---\n"
611
+ "name: external-sync\n"
612
+ "description: Use when testing external skill sync.\n"
613
+ "---\n\n"
614
+ "# external-sync\n",
615
+ encoding="utf-8",
616
+ )
617
+ (external / "agents" / "openai.yaml").write_text(
618
+ "interface:\n"
619
+ " display_name: \"External Sync\"\n",
620
+ encoding="utf-8",
621
+ )
622
+
623
+ open_t = _tool(skills_pkg, "open_thread")
624
+ mark = _tool(skills_pkg, "mark_skill_materialized")
625
+ tid = open_t(question="external skill sync")
626
+
627
+ assert mark(thread_id=tid, skill_path=str(external / "SKILL.md")) == "ok"
628
+
629
+ canonical = skills_pkg["skills_root"] / "external-sync"
630
+ assert (canonical / "SKILL.md").exists()
631
+ assert (canonical / "agents" / "openai.yaml").exists()
632
+ assert (mirror_codex / "external-sync" / "SKILL.md").exists()
633
+ assert (mirror_agents / "external-sync" / "agents" / "openai.yaml").exists()
634
+
635
+
571
636
  def test_skill_manage_delete_removes_mirrors(skills_pkg, monkeypatch):
572
637
  """delete must propagate to every mirror target — otherwise stale
573
638
  SKILL.md files keep auto-triggering in Codex / canonical store."""
@@ -355,7 +355,7 @@ def render_brief(conn: sqlite3.Connection, query: str = "", k: int = 6) -> str:
355
355
  # ── skill_hint ────────────────────────────────────────────────────────
356
356
  # Behavioral nudge: after a rich thread closes, the lessons inside
357
357
  # it (insights + repeated moves) should
358
- # be materialized as a reusable Claude skill under ~/.claude/skills/,
358
+ # be materialized as a reusable skill mirrored across configured roots,
359
359
  # not just sit in notes. Trigger only on threads recently closed AND
360
360
  # rich enough to be worth a class-level skill — never on one-off chatter.
361
361
  #
@@ -413,7 +413,7 @@ def render_brief(conn: sqlite3.Connection, query: str = "", k: int = 6) -> str:
413
413
  warn = "⚠️ " if consecutive_ignored >= 3 else ""
414
414
  out.append(
415
415
  f" → {warn}closed thread is rich (≥5 notes, ≥2 insight/move). "
416
- "MATERIALIZE: invoke skill-creator to write ~/.claude/skills/"
416
+ "MATERIALIZE: invoke skill-creator or skill_manage to write "
417
417
  "<class-level-name>/SKILL.md from the insights — don't let "
418
418
  "learnings sit only in notes"
419
419
  )
@@ -139,7 +139,7 @@ MEMORY_REVIEW_PROMPT = (
139
139
  SKILL_REVIEW_PROMPT = (
140
140
  "Review the closed thread above and materialize any class-level "
141
141
  "lessons.\n\n"
142
- "PRIMARY output: a SKILL.md under ~/.claude/skills/<name>/ via "
142
+ "PRIMARY output: a SKILL.md via "
143
143
  "skill_manage(action='create'|'patch'|'write_file'|'delete'). The "
144
144
  "Skill format is the universal format — Claude Code, Claude "
145
145
  "Desktop, Codex, the Anthropic IDE plugins, and any MCP-aware tool "
@@ -149,8 +149,8 @@ SKILL_REVIEW_PROMPT = (
149
149
  "FALLBACK output (only when target CLI has no skills/ directory — "
150
150
  "Gemini / Copilot / generic MCP clients without a skill loader): "
151
151
  "lesson_append(title, body, summary, source=thread_id) writes into "
152
- "~/.threadkeeper/lessons.md. Use this only if the primary path "
153
- "isn't available; otherwise the SKILL.md is strictly better.\n\n"
152
+ "~/.threadkeeper/lessons.md. Use this only if a SKILL.md is not "
153
+ "appropriate; otherwise the mirrored SKILL.md is strictly better.\n\n"
154
154
  + RUBRIC_QUESTIONS + "\n\n"
155
155
  "PREFERENCE ORDER (pick the earliest action that fits):\n"
156
156
  " 1. PATCH an existing skill. If the conversation referenced (or "
@@ -192,7 +192,7 @@ COMBINED_REVIEW_PROMPT = (
192
192
  "/ note as appropriate.\n\n"
193
193
  " **Skills** — how to handle this class of task. PRIMARY: "
194
194
  "skill_manage(action='create'|'patch'|'write_file'|'delete') → "
195
- "~/.claude/skills/<name>/SKILL.md. The Skill format auto-triggers "
195
+ "a mirrored SKILL.md under every configured skills root. The Skill format auto-triggers "
196
196
  "via frontmatter description and is consumed by every modern "
197
197
  "agentic CLI (Claude Code/Desktop, Codex CLI/desktop, IDE plugins) "
198
198
  "— strictly better than an opt-in lessons.md scan. FALLBACK: "
@@ -51,9 +51,9 @@ from .review_prompts import POSITIVE_EXAMPLES
51
51
 
52
52
  SHADOW_REVIEW_PROMPT = f"""\
53
53
  You are a SHADOW LEARNING OBSERVER for thread-keeper. You read a slice
54
- of recent dialog from across ALL Claude sessions on this machine and
54
+ of recent dialog from across ALL agent sessions on this machine and
55
55
  decide whether any CLASS-LEVEL learning emerged that's worth a durable
56
- skill under ~/.claude/skills/.
56
+ skill.
57
57
 
58
58
  CLASS-LEVEL signals (materialize):
59
59
  {SHADOW_CLASS_SIGNAL_EXAMPLES}\
@@ -84,7 +84,7 @@ PROCEDURE
84
84
  - body: markdown rationale + procedure
85
85
  - summary: optional one-line TL;DR
86
86
  b. OPTIONAL: also call `mcp__thread-keeper__skill_manage(...)` to mirror
87
- under ~/.claude/skills/ when Claude-specific frontmatter
87
+ SKILL.md into every configured skills root when frontmatter
88
88
  auto-triggering adds value beyond the lesson alone.
89
89
  c. Output `MATERIALIZED: <slug>` on success.
90
90
 
@@ -1,8 +1,9 @@
1
- """Claude-skills lifecycle: create/edit/patch/delete + usage telemetry +
1
+ """Multi-CLI skills lifecycle: create/edit/patch/delete + usage telemetry +
2
2
  curator + background-review fork.
3
3
 
4
4
  Bridges thread-keeper (working memory across sessions) and Claude's
5
- ~/.claude/skills/ store (procedural memory). The Learning loop:
5
+ primary ~/.claude/skills/ store plus mirrored CLI skill roots
6
+ (procedural memory). The Learning loop:
6
7
 
7
8
  rich thread closes → brief() surfaces skill_hint nudge →
8
9
  agent calls review_thread(...) → spawned child reads notes,
@@ -24,6 +25,7 @@ Validator enforces the agentskills.io-compatible frontmatter shape:
24
25
  from __future__ import annotations
25
26
 
26
27
  import json
28
+ import os
27
29
  import re
28
30
  import shutil
29
31
  import sqlite3
@@ -143,55 +145,90 @@ def _archive_dir() -> Path:
143
145
 
144
146
 
145
147
  # ──────────────────────────────────────────────────────────────────────────
146
- # Multi-mirror — propagate SKILL.md across every detected CLI's skills/
147
- # directory so a single skill_manage call reaches Claude AND Codex AND
148
- # the canonical ~/.threadkeeper/skills/ fallback at once. Best-effort:
149
- # per-mirror failures are logged but don't fail the canonical write.
148
+ # Multi-mirror — propagate a whole skill directory across every known
149
+ # native skills/ root so a single materialization reaches Claude, Codex,
150
+ # shared ~/.agents skills, and the canonical ~/.threadkeeper/skills/
151
+ # fallback at once. Best-effort: per-mirror failures are logged but
152
+ # don't fail the canonical write.
150
153
  # ──────────────────────────────────────────────────────────────────────────
151
154
 
152
- def _mirror_targets(name: str) -> list[Path]:
153
- """Per-CLI skill directories that should hold a copy of <name>.
155
+ def _extra_skill_roots() -> list[Path]:
156
+ """Additional skills roots outside CLI adapters.
154
157
 
155
- Always includes the canonical ~/.threadkeeper/skills/<name>/ fallback
156
- (consumed by lessons.md-style scans from CLIs without a native
157
- skills/ loader). Plus every installed adapter whose skills_dir() is
158
- distinct from CLAUDE_SKILLS_DIR (the canonical write target)."""
159
- from ..adapters import installed_adapters
158
+ THREADKEEPER_EXTRA_SKILLS_DIRS is os.pathsep-separated. We also mirror
159
+ into ~/.agents/skills when that shared OpenAI-agent skill root already
160
+ exists on the machine.
161
+ """
162
+ roots: list[Path] = []
163
+ raw = os.environ.get("THREADKEEPER_EXTRA_SKILLS_DIRS", "").strip()
164
+ if raw:
165
+ for item in raw.split(os.pathsep):
166
+ item = item.strip()
167
+ if item:
168
+ roots.append(Path(item).expanduser())
169
+ agents_root = Path("~/.agents/skills").expanduser()
170
+ if agents_root.exists():
171
+ roots.append(agents_root)
172
+ return roots
173
+
174
+
175
+ def _skill_roots() -> list[Path]:
176
+ """Every root that should receive mirrored skill directories.
177
+
178
+ Use all adapters, not only installed adapters. Skill roots are cheap
179
+ filesystem paths, and relying on install-detection can miss a CLI that
180
+ is present but has not written its config yet.
181
+ """
182
+ from ..adapters import ADAPTERS
160
183
  from ..config import DB_PATH
161
184
 
162
- targets: list[Path] = []
163
- seen: set[Path] = {CLAUDE_SKILLS_DIR.resolve()}
164
- # CLI-native skills/ dirs from each installed adapter.
165
- for a in installed_adapters():
166
- sd = a.skills_dir()
167
- if sd is None:
168
- continue
169
- sd_r = sd.resolve() if sd.exists() else sd
170
- if sd_r in seen:
171
- continue
172
- seen.add(sd_r)
173
- targets.append(sd / name)
174
- # Canonical thread-keeper-side mirror (always written, used as the
175
- # source of truth for restore / curator audit / non-skills CLIs).
176
- tk_canonical = (DB_PATH.parent / "skills").resolve()
177
- if tk_canonical not in seen:
178
- targets.append((DB_PATH.parent / "skills") / name)
179
- return targets
185
+ roots: list[Path] = []
186
+ seen: set[Path] = set()
187
+
188
+ def add(root: Path | None) -> None:
189
+ if root is None:
190
+ return
191
+ root = root.expanduser()
192
+ key = root.resolve()
193
+ if key in seen:
194
+ return
195
+ seen.add(key)
196
+ roots.append(root)
197
+
198
+ add(CLAUDE_SKILLS_DIR)
199
+ for adapter in ADAPTERS:
200
+ add(adapter.skills_dir())
201
+ for root in _extra_skill_roots():
202
+ add(root)
203
+ add(DB_PATH.parent / "skills")
204
+ return roots
205
+
206
+
207
+ def _mirror_targets(name: str) -> list[Path]:
208
+ """All non-primary skill directories that should hold <name>/."""
209
+ primary = CLAUDE_SKILLS_DIR.resolve()
210
+ return [root / name for root in _skill_roots() if root.resolve() != primary]
211
+
212
+
213
+ def _copy_skill_tree(src: Path, dst: Path) -> None:
214
+ """Replace dst with a recursive copy of src unless they are identical."""
215
+ if src.resolve() == dst.resolve():
216
+ return
217
+ dst.parent.mkdir(parents=True, exist_ok=True)
218
+ if dst.exists():
219
+ shutil.rmtree(dst)
220
+ shutil.copytree(src, dst)
180
221
 
181
222
 
182
223
  def _mirror_skill_dir(name: str) -> None:
183
224
  """Copy CLAUDE_SKILLS_DIR/<name>/ → every mirror target (recursive).
184
225
  Pre-existing mirror is replaced atomically. Best-effort per target."""
185
- import shutil as _sh
186
226
  src = _skill_dir(name)
187
227
  if not src.exists():
188
228
  return
189
229
  for dst in _mirror_targets(name):
190
230
  try:
191
- dst.parent.mkdir(parents=True, exist_ok=True)
192
- if dst.exists():
193
- _sh.rmtree(dst)
194
- _sh.copytree(src, dst)
231
+ _copy_skill_tree(src, dst)
195
232
  except Exception:
196
233
  # Per-mirror failure (permission denied / disk full / etc.)
197
234
  # doesn't fail the canonical write. The user can re-sync
@@ -199,6 +236,43 @@ def _mirror_skill_dir(name: str) -> None:
199
236
  pass
200
237
 
201
238
 
239
+ def mirror_skill_from_path(skill_path: str) -> Optional[str]:
240
+ """Mirror an existing external skill dir into every configured root.
241
+
242
+ `skill_path` may point at SKILL.md or at the skill directory. The
243
+ frontmatter `name` is authoritative; the source directory does not
244
+ need to be under CLAUDE_SKILLS_DIR. Returns the mirrored skill name,
245
+ or None if the path is absent/invalid. Best-effort mirrors are still
246
+ handled by _mirror_skill_dir after the canonical copy lands.
247
+ """
248
+ raw = skill_path.strip()
249
+ if not raw:
250
+ return None
251
+ p = Path(raw).expanduser()
252
+ src = p.parent if p.name == "SKILL.md" else p
253
+ md = src / "SKILL.md"
254
+ if not md.is_file():
255
+ return None
256
+ try:
257
+ body = md.read_text(encoding="utf-8")
258
+ except OSError:
259
+ return None
260
+ fields, err = _parse_frontmatter(body)
261
+ if err or not fields:
262
+ return None
263
+ name = fields["name"]
264
+ if err := _validate_name(name):
265
+ return None
266
+ if err := _validate_skill_md(body, name):
267
+ return None
268
+ try:
269
+ _copy_skill_tree(src, _skill_dir(name))
270
+ except Exception:
271
+ return None
272
+ _mirror_skill_dir(name)
273
+ return name
274
+
275
+
202
276
  def _unmirror_skill(name: str) -> None:
203
277
  """Remove <name>/ from every mirror target. Best-effort per target."""
204
278
  import shutil as _sh
@@ -359,7 +433,7 @@ _VALID_OUTCOMES: set[str] = {"helped", "partial", "wrong"}
359
433
 
360
434
  @mcp.tool()
361
435
  def skill_record(name: str, kind: str = "use", outcome: str = "") -> str:
362
- """Record usage telemetry for a Claude skill.
436
+ """Record usage telemetry for a mirrored skill.
363
437
 
364
438
  `kind`: 'use' | 'view' | 'patch' | 'create'. Bumps the corresponding
365
439
  counter + timestamp in skill_usage. The curator reads these to decide
@@ -425,9 +499,10 @@ def skill_manage(action: str,
425
499
  new_string: str = "",
426
500
  sub_path: str = "",
427
501
  description: str = "") -> str:
428
- """Create, edit, patch, or delete Claude skills under ~/.claude/skills/.
502
+ """Create, edit, patch, or delete skills under the primary skills root.
429
503
 
430
- Atomic write with frontmatter validation before disk hits.
504
+ Atomic primary write with frontmatter validation before disk hits, then
505
+ best-effort mirror into every configured skill root.
431
506
 
432
507
  Actions:
433
508
  create — write a brand-new skill. Requires `name` + `description`
@@ -627,7 +702,7 @@ def _action_delete(name: str) -> str:
627
702
 
628
703
  @mcp.tool()
629
704
  def skill_list(include_archived: bool = False) -> str:
630
- """List Claude skills with telemetry. Format:
705
+ """List skills with telemetry. Format:
631
706
  <name> tier=<hypothesis|observed|validated> origin=<...>
632
707
  state=<active|stale|archived> uses=N fg_uses=N
633
708
  views=N patches=N wrong=N pinned=0/1 last_active=<age>
@@ -763,7 +838,7 @@ def curator_run(stale_after_days: int = 30,
763
838
  Lifecycle:
764
839
  active → stale when last activity > stale_after_days
765
840
  stale → archived when last activity > archive_after_days
766
- (also moves directory to ~/.claude/skills/.archive/)
841
+ (also moves primary directory to .archive/)
767
842
 
768
843
  Tier-aware adjustments (the discrete trust signal trumps raw activity):
769
844
  • tier='validated' skills are NEVER stale-aged or archived — proven
@@ -140,20 +140,29 @@ def close_thread(thread_id: str, outcome: str) -> str:
140
140
  @mcp.tool()
141
141
  def mark_skill_materialized(thread_id: str, skill_path: str = "") -> str:
142
142
  """Close the Learning loop: record that a closed thread's insights were
143
- written into a Claude skill under ~/.claude/skills/.
143
+ written into a skill.
144
144
 
145
145
  Stops the brief()'s `skill_hint` nudge from firing for this thread. Also
146
146
  appends a `move` note pointing at the skill path so future briefs surface
147
147
  the link.
148
148
 
149
149
  Pass the absolute path to the SKILL.md (or skill directory) when known;
150
- leave empty if you only want to silence the hint without recording a path."""
150
+ leave empty if you only want to silence the hint without recording a path.
151
+ When a path is provided, thread-keeper also mirrors that skill directory
152
+ into every configured native skills root (Claude, Codex, shared agents,
153
+ and ~/.threadkeeper/skills) on a best-effort basis."""
151
154
  conn = get_db()
152
155
  _ensure_session(conn)
153
156
  if not conn.execute("SELECT 1 FROM threads WHERE id=?", (thread_id,)).fetchone():
154
157
  return f"ERR thread_not_found={thread_id}"
155
158
  now = int(time.time())
156
159
  path = skill_path.strip()
160
+ if path:
161
+ try:
162
+ from .skills import mirror_skill_from_path
163
+ mirror_skill_from_path(path)
164
+ except Exception:
165
+ pass
157
166
  summary = path or "(no path recorded)"
158
167
  conn.execute(
159
168
  "INSERT INTO events (session_id, kind, target, summary, created_at) "
@@ -163,7 +172,7 @@ def mark_skill_materialized(thread_id: str, skill_path: str = "") -> str:
163
172
  )
164
173
  note_body = (
165
174
  f"materialized into {path}" if path
166
- else "materialized into a Claude skill (path not recorded)"
175
+ else "materialized into a skill (path not recorded)"
167
176
  )
168
177
  emb = _embed(note_body)
169
178
  cur = conn.execute(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: threadkeeper
3
- Version: 0.5.2
3
+ Version: 0.5.3
4
4
  Summary: Multi-agent shared brain across Claude Code/Desktop, Codex, Gemini, Copilot, 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
@@ -82,10 +82,10 @@ make it more than a memory store:
82
82
  harvester, candidate-reviewer, weekly Curator) materialize
83
83
  class-level skills as the agents work. Adapted to multi-CLI:
84
84
  SKILL.md is the primary write target and gets mirrored to every
85
- detected CLI's skills directory simultaneously
86
- (`~/.claude/skills/`, `~/.codex/skills/`, `~/.threadkeeper/skills/`),
87
- with lessons.md as a fallback for CLIs without a native skills
88
- loader.
85
+ known/configured skills root simultaneously (`~/.claude/skills/`,
86
+ `~/.codex/skills/`, existing `~/.agents/skills/`, extra roots from
87
+ `THREADKEEPER_EXTRA_SKILLS_DIRS`, and `~/.threadkeeper/skills/`),
88
+ with lessons.md as a fallback for CLIs without a native skills loader.
89
89
 
90
90
  ---
91
91
 
@@ -223,8 +223,8 @@ shows agents focused on their primary task rarely do).
223
223
  brief() SKILL.md + lessons.md ─► skill_usage │
224
224
  │ │ │ │
225
225
  │ ▼ ▼ │
226
- │ (every detected CLI's │ │
227
- │ skills/ directory) │ │
226
+ │ (every configured │ │
227
+ │ skills/ root) │ │
228
228
  │ │ │ │
229
229
  │ └──────► [5] Curator daemon ───┘
230
230
  │ (cron, every 7d)
@@ -246,11 +246,11 @@ shows agents focused on their primary task rarely do).
246
246
  | 5 | Curator daemon | every 7 days (env knob) | every existing lesson + recently-touched skill | REPORT-`<date>`.md (advisory) or direct PATCH/PRUNE/CONSOLIDATE |
247
247
 
248
248
  All five write into the universal Skill format (`SKILL.md` under each
249
- detected CLI's skills directory — `~/.claude/skills/`,
250
- `~/.codex/skills/`, plus the canonical `~/.threadkeeper/skills/`
251
- mirror), with `~/.threadkeeper/lessons.md` as a CLI-agnostic fallback
252
- for clients without a native skills loader (Gemini, Copilot, bare
253
- MCP).
249
+ known/configured skills root — `~/.claude/skills/`, `~/.codex/skills/`,
250
+ existing `~/.agents/skills/`, optional `THREADKEEPER_EXTRA_SKILLS_DIRS`,
251
+ plus the canonical `~/.threadkeeper/skills/` mirror), with
252
+ `~/.threadkeeper/lessons.md` as a CLI-agnostic fallback for clients
253
+ without a native skills loader (Gemini, Copilot, bare MCP).
254
254
 
255
255
  #### 1. Auto-review on close_thread
256
256
 
@@ -260,9 +260,12 @@ thread's notes. The prompt is rubric-form (Q1–Q5 yes/no) with explicit
260
260
  positive examples for incident-vs-rule classification. The fork also
261
261
  receives a "recently active skills" block so it prefers PATCHing
262
262
  existing umbrellas over creating new ones (*active-update bias*).
263
- Child appends a lesson via `lesson_append`, optionally mirrors to
264
- `~/.claude/skills/<name>/SKILL.md`, then closes with
265
- `mark_skill_materialized`. Opt in with `THREADKEEPER_AUTO_REVIEW=1`.
263
+ Child appends a lesson via `lesson_append`, writes/patches a skill via
264
+ `skill_manage` or writes a skill file directly, then closes with
265
+ `mark_skill_materialized`. If `skill_path` points at a `SKILL.md` (or a
266
+ skill directory), thread-keeper immediately mirrors that whole skill
267
+ into every configured skills root. Opt in with
268
+ `THREADKEEPER_AUTO_REVIEW=1`.
266
269
 
267
270
  #### 2. Shadow-review daemon
268
271
 
File without changes
File without changes